- 티어 코드를 bronze~diamond에서 야구 테마 BW/PR/ST/AS/MVP로 교체 (임계값 0/100/300/700/1500 유지), 클라이언트 동기화 주석 추가 - 누적 예측 수 기반 레벨 시스템 제거 — levels.ts 삭제, /stats 응답의 currentLevel·progress 필드 제거 - 스코어보드 top/me 엔트리에 tierPoints 파생 tier 코드 포함, 배포 이전 생성 캐시는 응답 시점에 tier 보강 - 주간 마스터 티켓 잔재 주석과 문서 표의 tickets 항목 정리, 폐기된 레벨·티켓 언급 주석 정돈 - statsService 테스트를 새 티어 코드·필드 구성으로 갱신
365 lines
12 KiB
TypeScript
365 lines
12 KiB
TypeScript
import { FieldValue, Timestamp } from "firebase-admin/firestore";
|
|
import { firestore } from "../firebase";
|
|
import type {
|
|
DailyJudgment,
|
|
KnowledgeLevel,
|
|
Provider,
|
|
RankSnapshot,
|
|
TeamCode,
|
|
User,
|
|
} from "../types/panit";
|
|
import { type DateString } from "../types/dateString";
|
|
|
|
const COLLECTION = "users";
|
|
|
|
export interface RegisterInput {
|
|
displayName: string;
|
|
email: string;
|
|
photoUrl?: string;
|
|
provider: Provider;
|
|
favoriteTeamCode?: TeamCode;
|
|
knowledgeLevel: KnowledgeLevel;
|
|
}
|
|
/**
|
|
* 특정 유저 문서를 조회한다.
|
|
*
|
|
* 주의: 반환된 `currentStreak`은 lazy 보정 전 값이다. 클라이언트에 노출하거나
|
|
* streak 기반 로직에 쓸 때는 `statsService.getStats`를 거쳐 보정된 값을 사용한다.
|
|
* 쓰기는 `applyDailyJudgmentTx`만 수행한다.
|
|
*
|
|
* @param uid - Firebase Auth UID
|
|
* @returns 유저 문서. 존재하지 않으면 `null`.
|
|
*/
|
|
export async function getUser(uid: string): Promise<User | null> {
|
|
const snap = await firestore.collection(COLLECTION).doc(uid).get();
|
|
return snap.exists ? (snap.data() as User) : null;
|
|
}
|
|
|
|
/** 스코어보드 "me" 계산에 필요한 최소 필드만 담는 형태. */
|
|
export interface ScoreboardSelfUser {
|
|
displayName: string;
|
|
photoUrl?: string;
|
|
tierPoints?: number;
|
|
favoriteTeamCode?: TeamCode;
|
|
rankSnapshot?: RankSnapshot;
|
|
}
|
|
|
|
/**
|
|
* 스코어보드 응답의 "me" 계산에 필요한 필드만 fieldMask로 읽어온다.
|
|
* 전체 user doc(streak/notifications 등) 대신 5개 필드만 전송받아 bandwidth를 줄인다.
|
|
*
|
|
* @returns 필요한 필드 부분집합. 문서가 없으면 `null`.
|
|
*/
|
|
export async function getUserForScoreboard(
|
|
uid: string
|
|
): Promise<ScoreboardSelfUser | null> {
|
|
const ref = firestore.collection(COLLECTION).doc(uid);
|
|
const [snap] = await firestore.getAll(ref, {
|
|
fieldMask: [
|
|
"displayName",
|
|
"photoUrl",
|
|
"tierPoints",
|
|
"favoriteTeamCode",
|
|
"rankSnapshot",
|
|
],
|
|
});
|
|
if (!snap.exists) return null;
|
|
const data = snap.data() as Partial<User>;
|
|
const self: ScoreboardSelfUser = { displayName: data.displayName ?? "" };
|
|
if (data.photoUrl) self.photoUrl = data.photoUrl;
|
|
if (data.tierPoints !== undefined) self.tierPoints = data.tierPoints;
|
|
if (data.favoriteTeamCode) self.favoriteTeamCode = data.favoriteTeamCode;
|
|
if (data.rankSnapshot) self.rankSnapshot = data.rankSnapshot;
|
|
return self;
|
|
}
|
|
|
|
export interface ScoreboardUserEntry {
|
|
uid: string;
|
|
displayName: string;
|
|
photoUrl?: string;
|
|
tierPoints: number;
|
|
favoriteTeamCode?: TeamCode;
|
|
rankSnapshot?: RankSnapshot;
|
|
}
|
|
|
|
/**
|
|
* `tierPoints` 상위 N명을 조회한다. `teamCode` 지정 시 해당 팀을 응원하는
|
|
* 유저로 한정한다. `tierPoints`가 없거나 0 이하인 유저(벤치워머)는 결과에
|
|
* 포함되지 않는다 — `countRankedUsers`(분모)와 "랭킹 대상" 정의를 공유한다.
|
|
* `rankSnapshot`도 함께 가져와 delta 계산에 사용 가능.
|
|
*/
|
|
export async function listTopByTierPoints(
|
|
limit: number,
|
|
teamCode?: TeamCode
|
|
): Promise<ScoreboardUserEntry[]> {
|
|
let query: FirebaseFirestore.Query = firestore.collection(COLLECTION);
|
|
if (teamCode) query = query.where("favoriteTeamCode", "==", teamCode);
|
|
// 비활성화(탈퇴) 계정은 랭킹에서 제외.
|
|
query = query.where("active", "==", true);
|
|
const snap = await query
|
|
// 0pt는 랭킹 미노출 — 리스트 인원과 totalCount 분모가 정의상 일치한다.
|
|
.where("tierPoints", ">", 0)
|
|
.orderBy("tierPoints", "desc")
|
|
.limit(limit)
|
|
.select(
|
|
"displayName",
|
|
"photoUrl",
|
|
"tierPoints",
|
|
"favoriteTeamCode",
|
|
"rankSnapshot"
|
|
)
|
|
.get();
|
|
return snap.docs.map((d) => {
|
|
const data = d.data() as Partial<User>;
|
|
const entry: ScoreboardUserEntry = {
|
|
uid: d.id,
|
|
displayName: data.displayName ?? "",
|
|
tierPoints: data.tierPoints ?? 0,
|
|
};
|
|
if (data.photoUrl) entry.photoUrl = data.photoUrl;
|
|
if (data.favoriteTeamCode) entry.favoriteTeamCode = data.favoriteTeamCode;
|
|
if (data.rankSnapshot) entry.rankSnapshot = data.rankSnapshot;
|
|
return entry;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* `tierPoints > threshold`인 유저 수를 반환한다. `teamCode` 지정 시 해당
|
|
* 팀을 응원하는 유저로 한정한다. Firestore count aggregation 사용.
|
|
*/
|
|
export async function countUsersAboveTierPoints(
|
|
threshold: number,
|
|
teamCode?: TeamCode
|
|
): Promise<number> {
|
|
let query: FirebaseFirestore.Query = firestore.collection(COLLECTION);
|
|
if (teamCode) query = query.where("favoriteTeamCode", "==", teamCode);
|
|
const agg = await query
|
|
.where("active", "==", true)
|
|
.where("tierPoints", ">", threshold)
|
|
.count()
|
|
.get();
|
|
return agg.data().count;
|
|
}
|
|
|
|
/**
|
|
* `tierPoints > 0`인 유저(= 스코어보드 대상)의 총 수. `teamCode` 지정 시
|
|
* 해당 팀 내 총 수를 반환한다. percentile 계산용.
|
|
*/
|
|
export async function countRankedUsers(teamCode?: TeamCode): Promise<number> {
|
|
let query: FirebaseFirestore.Query = firestore.collection(COLLECTION);
|
|
if (teamCode) query = query.where("favoriteTeamCode", "==", teamCode);
|
|
const agg = await query
|
|
.where("active", "==", true)
|
|
.where("tierPoints", ">", 0)
|
|
.count()
|
|
.get();
|
|
return agg.data().count;
|
|
}
|
|
|
|
/**
|
|
* `cutoff` 이전에 비활성화된 계정 uid 목록. `accountPurge`의 파기 대상 조회용.
|
|
*/
|
|
export async function listDeactivatedBefore(
|
|
cutoff: Date,
|
|
limit = 100
|
|
): Promise<string[]> {
|
|
const snap = await firestore
|
|
.collection(COLLECTION)
|
|
.where("active", "==", false)
|
|
.where("deactivatedAt", "<=", Timestamp.fromDate(cutoff))
|
|
.limit(limit)
|
|
.select()
|
|
.get();
|
|
return snap.docs.map((d) => d.id);
|
|
}
|
|
|
|
/**
|
|
* displayName으로 유저의 uid를 조회한다. 없으면 `null`.
|
|
* 닉네임 유니크성 검증용 fallback.
|
|
*/
|
|
export async function findUidByDisplayName(
|
|
displayName: string
|
|
): Promise<string | null> {
|
|
const snap = await firestore
|
|
.collection(COLLECTION)
|
|
.where("displayName", "==", displayName)
|
|
.limit(1)
|
|
.get();
|
|
return snap.empty ? null : snap.docs[0].id;
|
|
}
|
|
|
|
/** 어드민 검색/목록용 — 문서 id(uid)를 함께 얹은 형태. */
|
|
export interface UserWithUid {
|
|
uid: string;
|
|
user: User;
|
|
}
|
|
|
|
/**
|
|
* displayName 접두어로 유저를 검색한다 (어드민 콘솔용).
|
|
* Firestore 접두어 쿼리(orderBy + startAt/endAt )라 중간 문자열 매치는 안 된다.
|
|
*/
|
|
export async function searchUsersByDisplayNamePrefix(
|
|
prefix: string,
|
|
limit = 10
|
|
): Promise<UserWithUid[]> {
|
|
const snap = await firestore
|
|
.collection(COLLECTION)
|
|
.orderBy("displayName")
|
|
.startAt(prefix)
|
|
.endAt(prefix + "")
|
|
.limit(Math.min(Math.max(limit, 1), 30))
|
|
.get();
|
|
return snap.docs.map((d) => ({ uid: d.id, user: d.data() as User }));
|
|
}
|
|
|
|
/** 가입일 내림차순 유저 목록 (어드민 콘솔용). cursor 는 직전 페이지 마지막 문서의 uid. */
|
|
export async function listUsersByCreatedAt(
|
|
limit = 20,
|
|
cursor?: string
|
|
): Promise<{ items: UserWithUid[]; cursor: string | null }> {
|
|
let q = firestore
|
|
.collection(COLLECTION)
|
|
.orderBy("createdAt", "desc")
|
|
.limit(Math.min(Math.max(limit, 1), 100));
|
|
if (cursor) {
|
|
const c = await firestore.collection(COLLECTION).doc(cursor).get();
|
|
if (c.exists) q = q.startAfter(c) as typeof q;
|
|
}
|
|
const snap = await q.get();
|
|
return {
|
|
items: snap.docs.map((d) => ({ uid: d.id, user: d.data() as User })),
|
|
cursor: snap.docs.length ? snap.docs[snap.docs.length - 1].id : null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 신규 유저 문서를 생성한다. `createdAt`은 서버 타임스탬프로 기록되며,
|
|
* 같은 UID가 있으면 전체 덮어쓴다(`merge: false`).
|
|
*/
|
|
export async function createUser(uid: string, input: RegisterInput): Promise<void> {
|
|
const doc: Record<string, unknown> = {
|
|
displayName: input.displayName,
|
|
email: input.email,
|
|
provider: input.provider,
|
|
knowledgeLevel: input.knowledgeLevel,
|
|
active: true,
|
|
createdAt: FieldValue.serverTimestamp(),
|
|
};
|
|
if (input.photoUrl) doc.photoUrl = input.photoUrl;
|
|
if (input.favoriteTeamCode) doc.favoriteTeamCode = input.favoriteTeamCode;
|
|
await firestore.collection(COLLECTION).doc(uid).set(doc, { merge: false });
|
|
}
|
|
|
|
/**
|
|
* 유저 문서의 일부 필드를 병합 업데이트한다.
|
|
* FieldValue.delete() 등을 전달할 수 있도록 값 타입에 FieldValue를 허용한다.
|
|
*/
|
|
export async function updateUser(
|
|
uid: string,
|
|
patch: Record<string, unknown>
|
|
): Promise<void> {
|
|
await firestore.collection(COLLECTION).doc(uid).set(patch, { merge: true });
|
|
}
|
|
|
|
/**
|
|
* 유저 문서 및 하위 컬렉션(voteHistory 등)을 모두 삭제한다.
|
|
*/
|
|
export async function deleteUser(uid: string): Promise<void> {
|
|
await firestore.recursiveDelete(firestore.collection(COLLECTION).doc(uid));
|
|
}
|
|
|
|
export interface DailyJudgmentResult {
|
|
judgment: DailyJudgment;
|
|
correctCount: number;
|
|
completedCount: number;
|
|
/** 판정 후 스트릭 값. `skip`이면 기존 값과 동일. */
|
|
streakAfter: number;
|
|
/** 해당 판정이 멱등성 가드로 인해 생략됐는지 여부. */
|
|
skippedByGuard: boolean;
|
|
}
|
|
|
|
/**
|
|
* 일간 판정을 user doc에 원자적으로 반영한다. 동일 날짜가 이미 판정된 경우
|
|
* 현재 저장된 상태를 반환하여 호출자가 멱등 경로에서 voteHistory 쓰기를
|
|
* 건너뛸 수 있도록 한다.
|
|
*
|
|
* - perfect/success → 스트릭 +1, 하이스트릭 갱신, 티어 포인트 가산
|
|
* - fail → 스트릭 0 (포인트 감점 없음)
|
|
* - skip → 스트릭 불변, 포인트 불변
|
|
*
|
|
* `computePoints`는 판정 후 스트릭(streakAfter)을 인자로 받아 해당 판정으로
|
|
* 획득할 포인트를 반환한다. `fail|skip`일 땐 호출되지 않는다.
|
|
*/
|
|
export async function applyDailyJudgmentTx(
|
|
uid: string,
|
|
date: DateString,
|
|
input: {
|
|
judgment: DailyJudgment;
|
|
correctCount: number;
|
|
completedCount: number;
|
|
/**
|
|
* 호출자(judgeDay)가 사전에 판정한 결석 여부. true면 `currentStreak`을 0으로 본 뒤
|
|
* 판정 분기를 적용한다. 휴장일은 제외하고 판단되어 들어온다.
|
|
*/
|
|
streakBrokenIn: boolean;
|
|
/**
|
|
* 판정 직전(=현재 tierPoints 기준) rank 스냅샷. 제공되면 동일 트랜잭션 patch에
|
|
* `rankSnapshot`으로 함께 기록해 별도 write를 절약한다. "판정 전 rank" 의미를
|
|
* 보존하려면 호출자가 트랜잭션 호출 전에 계산해 넘겨야 한다.
|
|
*/
|
|
rankSnapshot?: RankSnapshot;
|
|
computePoints: (streakAfter: number) => number;
|
|
}
|
|
): Promise<DailyJudgmentResult> {
|
|
const ref = firestore.collection(COLLECTION).doc(uid);
|
|
return firestore.runTransaction(async (tx) => {
|
|
const snap = await tx.get(ref);
|
|
const user = (snap.data() ?? {}) as Partial<User>;
|
|
|
|
const lastJudged = user.lastJudgedDate;
|
|
if (lastJudged && lastJudged >= date) {
|
|
const streakAfter = user.currentStreak ?? 0;
|
|
return {
|
|
judgment: input.judgment,
|
|
correctCount: input.correctCount,
|
|
completedCount: input.completedCount,
|
|
streakAfter,
|
|
skippedByGuard: true,
|
|
};
|
|
}
|
|
|
|
// 결석 여부는 호출자(judgeDay)가 휴장일을 제외하고 사전에 판정해서 넘겨준다.
|
|
const currentStreak = input.streakBrokenIn ? 0 : user.currentStreak ?? 0;
|
|
const highestStreak = user.highestStreak ?? 0;
|
|
const tierPoints = user.tierPoints ?? 0;
|
|
let nextStreak = currentStreak;
|
|
let nextPoints = tierPoints;
|
|
|
|
if (input.judgment === "perfect" || input.judgment === "success") {
|
|
nextStreak = currentStreak + 1;
|
|
nextPoints = tierPoints + input.computePoints(nextStreak);
|
|
} else if (input.judgment === "fail") {
|
|
nextStreak = 0;
|
|
}
|
|
// skip: 변화 없음.
|
|
|
|
const patch: Record<string, unknown> = {
|
|
currentStreak: nextStreak,
|
|
highestStreak: Math.max(highestStreak, nextStreak),
|
|
tierPoints: nextPoints,
|
|
lastJudgedDate: date,
|
|
};
|
|
// 판정 전 rank 스냅샷을 같은 patch에 합쳐 user doc write를 1회로 줄인다.
|
|
if (input.rankSnapshot) patch.rankSnapshot = input.rankSnapshot;
|
|
tx.set(ref, patch, { merge: true });
|
|
|
|
return {
|
|
judgment: input.judgment,
|
|
correctCount: input.correctCount,
|
|
completedCount: input.completedCount,
|
|
streakAfter: nextStreak,
|
|
skippedByGuard: false,
|
|
};
|
|
});
|
|
}
|