- 티어 코드를 bronze~diamond에서 야구 테마 BW/PR/ST/AS/MVP로 교체 (임계값 0/100/300/700/1500 유지), 클라이언트 동기화 주석 추가 - 누적 예측 수 기반 레벨 시스템 제거 — levels.ts 삭제, /stats 응답의 currentLevel·progress 필드 제거 - 스코어보드 top/me 엔트리에 tierPoints 파생 tier 코드 포함, 배포 이전 생성 캐시는 응답 시점에 tier 보강 - 주간 마스터 티켓 잔재 주석과 문서 표의 tickets 항목 정리, 폐기된 레벨·티켓 언급 주석 정돈 - statsService 테스트를 새 티어 코드·필드 구성으로 갱신
231 lines
7.0 KiB
TypeScript
231 lines
7.0 KiB
TypeScript
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<Record<NotificationKey, boolean>>;
|
|
|
|
/**
|
|
* 단일 재화(`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<DailyJudgment | null>;
|
|
winRates: {
|
|
overall: number;
|
|
weekly: number;
|
|
monthly: number;
|
|
season: number;
|
|
};
|
|
totalPredictions: number;
|
|
totalCorrect: number;
|
|
weeklyPredictions: number;
|
|
tier: TierName;
|
|
tierPoints: number;
|
|
updatedAt: number;
|
|
forDate: DateString;
|
|
}
|