mmday-firebase/src/repositories/userRepository.ts
윤정민 cf5f10af64 Add season-end settlement and tierPoints reset
- config/season 문서(id/startDate/endDate)로 시즌 경계 정의 — 문서가 없으면 시즌제 비활성(기존 동작 유지)
- endDate 다음 날 dailyArchive에서 maybeSettleSeason 1회 실행: 랭킹 대상 전원의 최종 성적(tierPoints·티어·동점 동일 rank)을 users/{uid}/seasonHistory/{seasonId}에 아카이브 후 tierPoints 0 리셋·rankSnapshot 제거
- 유저별 아카이브+리셋을 같은 배치로 묶고 settledAt 마커는 전원 완료 후 기록 — 부분 실패 재실행 시 기정산 유저 점수를 collectionGroup으로 되읽어 순위 보존(멱등)
- 스트릭은 시즌과 무관하게 유지(이월 이득은 streakBonus 상한이 제한)
- seasonHistory 본인 읽기 전용 rules와 seasonId collectionGroup 인덱스 추가, 정산 시나리오 테스트 신규 작성
2026-07-23 14:46:22 +09:00

398 lines
13 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;
});
}
/**
* 랭킹 대상(active && tierPoints > 0) 전원을 tierPoints 내림차순으로 반환한다.
* "랭킹 대상" 정의를 스코어보드(`listTopByTierPoints`/`countRankedUsers`)와
* 공유한다 — 시즌 정산도 같은 모집단을 리셋해야 순위와 리셋 범위가 일치한다.
* 시즌 정산 전용: 페이지 단위로 나눠 읽되 결과는 전량 메모리에 올린다.
*/
export async function listAllRankedUsers(): Promise<
Array<{ uid: string; tierPoints: number }>
> {
const PAGE = 500;
const results: Array<{ uid: string; tierPoints: number }> = [];
let last: FirebaseFirestore.QueryDocumentSnapshot | undefined;
for (;;) {
let query = firestore
.collection(COLLECTION)
.where("active", "==", true)
.where("tierPoints", ">", 0)
.orderBy("tierPoints", "desc")
.select("tierPoints")
.limit(PAGE);
if (last) query = query.startAfter(last);
const snap = await query.get();
for (const d of snap.docs) {
results.push({
uid: d.id,
tierPoints: (d.data() as Partial<User>).tierPoints ?? 0,
});
}
if (snap.docs.length < PAGE) return results;
last = snap.docs[snap.docs.length - 1];
}
}
/**
* `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,
};
});
}