import type { Timestamp } from "firebase-admin/firestore"; import type { DateString } from "./dateString"; export type Provider = "google" | "apple" | "anonymous"; export type DailyJudgment = "perfect" | "success" | "fail" | "skip"; /** 야구 테마 티어 코드 — 벤치워머(BW) → 유망주(PR) → 주전(ST) → 올스타(AS) → MVP. */ export type TierName = "BW" | "PR" | "ST" | "AS" | "MVP"; export type GameStatus = "scheduled" | "live" | "completed" | "cancelled"; /** * 무승부 예측의 예약 코드. 실제 팀 코드(TeamCode)와 겹치지 않는 센티넬로, * `selectedTeamCode`/`VoteEntry.team`에 그대로 저장·반환된다. */ export const DRAW_TEAM_CODE = "DRAW"; /** 투표 진영 — 홈 승리 / 원정 승리 / 무승부. counts 키(`{side}Count`)와 1:1. */ export type VoteSide = "home" | "away" | "draw"; export enum TeamCode { KT = "KT", NC = "NC", SK = "SK", LG = "LG", HT = "HT", LT = "LT", HH = "HH", OB = "OB", SS = "SS", WO = "WO", } export enum KnowledgeLevel { Beginner = "beginner", Casual = "casual", Expert = "expert", } export { PointLedgerType } from "./points"; import type { PointLedgerType } from "./points"; export enum AttendanceResult { CheckedIn = "checkedIn", AlreadyCheckedIn = "alreadyCheckedIn", } export enum NotificationKey { Attendance = "attendance", } export type NotificationsMap = Partial>; /** * 단일 재화(`balance`) 변동을 기록하는 ledger row. * `balanceAfter`가 단일 진실 원천 — 현재 잔액은 가장 최신 row의 `balanceAfter`다. * 같은 트랜잭션 내 여러 row가 동시 작성될 때는 `seq`로 안정 정렬한다. */ export interface AttendanceMonthDoc { /** 정렬된 1~말일 배열. */ days: number[]; lastCheckedInAt: Timestamp; lastIdempotencyKey?: string; lastResult?: AttendanceCheckInResult; } export interface AttendanceStateDoc { lastAttendanceDate: DateString; currentAttendanceStreak: number; streakCycleStart: DateString; highestAttendanceStreak: number; updatedAt: Timestamp; } export interface PointAward { type: PointLedgerType; amount: number; } export interface AttendanceCheckInResult { result: AttendanceResult; serverNow: Timestamp; attendedDays: number[]; totalCount: number; pointsAwarded: PointAward[]; balanceAfter: number; attendanceStreak: number; } export interface AttendanceMonth { month: string; attendedDays: number[]; totalCount: number; balance: number; } export interface User { displayName: string; email: string; photoUrl?: string; provider: Provider; favoriteTeamCode?: TeamCode; knowledgeLevel: KnowledgeLevel; createdAt: Timestamp; notifications?: NotificationsMap; /** 마지막 로그인 기기의 FCM 토큰. 클라가 직접 덮어쓰기. */ fcmToken?: string; /** * 계정 활성 여부. 탈퇴 시 삭제 대신 false(비활성화)로 전환된다 — 문서·기록은 * 유예 기간 동안 보존되고, 랭킹 쿼리(`active == true` 필터)에서만 제외된다. * 신규 가입 시 true로 생성되며, 필드가 없는 문서는 랭킹 쿼리에 잡히지 않으므로 * 기존 유저는 백필이 필요하다. */ active?: boolean; /** 비활성화 시각. 유예 기간(30일) 경과 시 `accountPurge` 배치가 영구 파기한다. */ deactivatedAt?: Timestamp; /** * 마지막 판정(`applyDailyJudgmentTx`) 시점 기준의 연속 참여일. * * 주의: 이 필드는 lazy 보정된다. 결석으로 streak이 끊겼더라도 * 다음 판정이 실행되기 전까지는 옛날 값이 그대로 남아 있을 수 있다. * * 따라서 외부에서 이 값을 직접 읽어 노출하면 안 되며, 반드시 * `getStats`(statsService)를 통해 보정된 `streakDays`를 사용한다. * * 쓰기 권한은 `applyDailyJudgmentTx`만 갖는다. */ currentStreak?: number; highestStreak?: number; /** * 적중 누적 포인트(티어·스코어보드 축). 쓰기는 `applyDailyJudgmentTx`가 원칙. * * ⚠️ 불변식: 이 값을 변경하는 **모든 경로**(백필·어드민 보정 포함)는 마지막에 * `precomputeScoreboardCache`를 호출해야 한다. 스코어보드 리스트는 사전계산 * 스냅샷, 내 순위는 라이브 계산이라 재계산 없이 값만 바꾸면 둘이 어긋난다. */ tierPoints?: number; /** * 마지막으로 일일 판정이 적용된 KST 날짜. * `currentStreak`의 신선도를 가늠하는 기준이며, * 결석 감지(`hasMissedGameDayBetween`)의 시작점으로도 쓰인다. */ lastJudgedDate?: DateString; rankSnapshot?: RankSnapshot; } /** * 클라이언트에 노출되는 유저 프로필. * * `User`에서 streak/티어/랭킹 스냅샷 등 보정이 필요하거나 내부 전용인 * 필드를 제외한 형태. 해당 데이터는 `StatsResponse`(GET /stats)로 서빙된다. * * `lastJudgedDate`는 stale 위험이 없는(판정 트랜잭션과 함께 갱신되는) 사실값이라 * 노출한다. 클라의 "어제 예측 리캡" 등에서 마지막으로 판정된 날짜를 필요로 한다. */ export interface UserProfile { displayName: string; email: string; photoUrl?: string; provider: Provider; favoriteTeamCode?: TeamCode; knowledgeLevel: KnowledgeLevel; createdAt: Timestamp; lastJudgedDate?: DateString; } export interface RankSnapshot { /** 스냅샷 생성 날짜 (KST). */ date: DateString; /** 전체 스코어보드에서의 rank (동점자 동일 rank). */ overall: number; /** 해당 팀 내 rank. 스냅샷 당시 `teamCode`와 함께만 유효. */ team?: number; /** 스냅샷 생성 시점의 `favoriteTeamCode`. 이후 팀 변경 시 team delta 무효화용. */ teamCode?: TeamCode; } export interface Game { time: Timestamp; stadium: string; status: GameStatus; homeTeamCode: string; awayTeamCode: string; winningTeamCode?: string; /** 경기가 cancelled일 때의 사유 텍스트 (예: "우천취소", "그라운드 사정"). */ cancelReason?: string; } export interface VoteEntry { team: string; result?: boolean; /** 경기 취소로 무효 처리된 투표 — 참여 흔적만 남고 판정·집계에서 제외된다. */ cancelled?: boolean; } export interface VoteHistoryDoc { /** `result` 없이 `cancelled: true`인 항목은 취소 경기 무효표 — 판정·집계 제외. */ data: Array<{ gameId: string; team: string; result?: boolean; cancelled?: boolean }>; judgment?: DailyJudgment; correctCount?: number; completedCount?: number; streakAfter?: number; rewardSettledAt?: Timestamp; rewardTotal?: number; } export interface StatsResponse { streakDays: number; highestStreak: number; weeklyResults: Array; winRates: { overall: number; weekly: number; monthly: number; season: number; }; totalPredictions: number; totalCorrect: number; weeklyPredictions: number; tier: TierName; tierPoints: number; updatedAt: number; forDate: DateString; }