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 { 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 { 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; 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 { 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; 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 { 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 { 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 { 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 { 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 { 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 { const doc: Record = { 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 ): Promise { await firestore.collection(COLLECTION).doc(uid).set(patch, { merge: true }); } /** * 유저 문서 및 하위 컬렉션(voteHistory 등)을 모두 삭제한다. */ export async function deleteUser(uid: string): Promise { 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 { const ref = firestore.collection(COLLECTION).doc(uid); return firestore.runTransaction(async (tx) => { const snap = await tx.get(ref); const user = (snap.data() ?? {}) as Partial; 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 = { 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, }; }); }