diff --git a/src/handlers/statsHandlers.ts b/src/handlers/statsHandlers.ts index 8d1059a..76d0417 100644 --- a/src/handlers/statsHandlers.ts +++ b/src/handlers/statsHandlers.ts @@ -2,6 +2,7 @@ import { onRequest } from "firebase-functions/https"; import { requireAuth } from "../middleware/auth"; import { sendError } from "../middleware/errors"; import { getStats, getHistory } from "../services/statsService"; +import { getSeasonRecords } from "../services/seasonService"; import type { UserStatsDto, VoteHistoryDto } from "../types/dto/statsDto"; export const stats = onRequest(async (req, res) => { @@ -18,6 +19,11 @@ export const stats = onRequest(async (req, res) => { return; } + if (tail === "seasons" && req.method === "GET") { + res.status(200).json({ seasons: await getSeasonRecords(uid) }); + return; + } + if ((tail === "stats" || segs.length === 1) && req.method === "GET") { const period = req.query.period ? String(req.query.period) : undefined; const result: UserStatsDto = await getStats(uid, period); diff --git a/src/repositories/seasonRepository.ts b/src/repositories/seasonRepository.ts index e05ebfe..9548cc2 100644 --- a/src/repositories/seasonRepository.ts +++ b/src/repositories/seasonRepository.ts @@ -56,6 +56,19 @@ export async function listSeasonHistoryEntries( .filter((e) => e.uid.length > 0); } +/** 유저의 시즌별 최종 성적 목록 — 최근 시즌부터(seasonId 내림차순). */ +export async function listUserSeasonHistory( + uid: string +): Promise { + const snap = await firestore + .collection("users") + .doc(uid) + .collection("seasonHistory") + .orderBy("seasonId", "desc") + .get(); + return snap.docs.map((d) => d.data() as SeasonHistoryDoc); +} + /** 배치 한도(500 write)의 여유분 — 유저당 2 write(아카이브 + 리셋). */ const SETTLE_CHUNK = 200; diff --git a/src/services/seasonService.ts b/src/services/seasonService.ts index 36d8949..a48423a 100644 --- a/src/services/seasonService.ts +++ b/src/services/seasonService.ts @@ -3,14 +3,24 @@ import { tierOf } from "../constants/tiers"; import { getSeasonConfig, listSeasonHistoryEntries, + listUserSeasonHistory, markSeasonSettled, settleUsers, } from "../repositories/seasonRepository"; import { listAllRankedUsers } from "../repositories/userRepository"; import { invalidateStats } from "./statsService"; +import { toSeasonRecordDto, type SeasonRecordDto } from "../types/dto/statsDto"; import type { DateString } from "../types/dateString"; import type { SeasonHistoryDoc } from "../types/season"; +/** GET /stats/seasons — 유저의 시즌별 최종 성적(최근 시즌부터). 정산 전에는 빈 목록. */ +export async function getSeasonRecords( + uid: string +): Promise { + const docs = await listUserSeasonHistory(uid); + return docs.map(toSeasonRecordDto); +} + /** * 시즌 종료 정산. `dailyArchive`가 매일 호출하지만 실제 실행 조건은 * "`config/season`이 존재 + `settledAt` 없음 + today > endDate"의 단 한 번이다. diff --git a/src/types/dto/statsDto.ts b/src/types/dto/statsDto.ts index 460a7c9..f60c748 100644 --- a/src/types/dto/statsDto.ts +++ b/src/types/dto/statsDto.ts @@ -1,5 +1,6 @@ import { toIsoOrUndefined } from "./iso"; -import type { DailyJudgment, StatsResponse, VoteHistoryDoc } from "../panit"; +import type { DailyJudgment, StatsResponse, TierName, VoteHistoryDoc } from "../panit"; +import type { SeasonHistoryDoc } from "../season"; /** GET /stats 응답. Timestamp 유출이 없어 기존 타입을 그대로 별칭한다. */ export type UserStatsDto = StatsResponse; @@ -30,6 +31,31 @@ export interface VoteHistoryDto { /** 문서가 없는 날은 빈 목록으로 응답한다 (기존 동작 유지). */ export const EMPTY_VOTE_HISTORY_DTO: VoteHistoryDto = { data: [] }; +/** + * GET /stats/seasons 응답 원소 — 시즌 종료 시 정산된 최종 성적. + * `settledAt`(Timestamp)만 UTC ISO 8601 문자열로 바꾼 응답 전용 형태. + */ +export interface SeasonRecordDto { + seasonId: string; + tierPoints: number; + tier: TierName; + rank: number; + totalRanked: number; + settledAt?: string; +} + +/** 필드를 명시적으로 나열한다 — 스프레드의 Timestamp 유출 방지(toVoteHistoryDto와 동일 이유). */ +export function toSeasonRecordDto(doc: SeasonHistoryDoc): SeasonRecordDto { + return { + seasonId: doc.seasonId, + tierPoints: doc.tierPoints, + tier: doc.tier, + rank: doc.rank, + totalRanked: doc.totalRanked, + settledAt: toIsoOrUndefined(doc.settledAt), + }; +} + /** 필드를 명시적으로 나열한다 — 스프레드를 쓰면 문서에 새로 생긴 Timestamp 가 그대로 유출된다. */ export function toVoteHistoryDto(doc: VoteHistoryDoc): VoteHistoryDto { return { diff --git a/tests/services/seasonService.test.ts b/tests/services/seasonService.test.ts index 07bf1b5..f348562 100644 --- a/tests/services/seasonService.test.ts +++ b/tests/services/seasonService.test.ts @@ -1,7 +1,10 @@ import { beforeEach, describe, expect, it } from "vitest"; import { FieldValue, Timestamp } from "firebase-admin/firestore"; import { firestore } from "../../src/firebase"; -import { maybeSettleSeason } from "../../src/services/seasonService"; +import { + getSeasonRecords, + maybeSettleSeason, +} from "../../src/services/seasonService"; import type { DateString } from "../../src/types/dateString"; import type { SeasonHistoryDoc } from "../../src/types/season"; import type { User } from "../../src/types/panit"; @@ -164,4 +167,41 @@ describe("seasonService.maybeSettleSeason", () => { expect((await readHistory("done"))?.rank).toBe(1); expect((await readUser("b")).tierPoints).toBe(0); }); + + it("getSeasonRecords: 정산 전 빈 목록, 정산 후 최근 시즌부터 DTO 반환", async () => { + await seedUser("a"); + expect(await getSeasonRecords("a")).toEqual([]); + + const history = firestore + .collection("users") + .doc("a") + .collection("seasonHistory"); + await history.doc("2025").set({ + seasonId: "2025", + tierPoints: 300, + tier: "PR", + rank: 5, + totalRanked: 10, + settledAt: Timestamp.now(), + }); + await history.doc("2026").set({ + seasonId: "2026", + tierPoints: 900, + tier: "ST", + rank: 2, + totalRanked: 12, + settledAt: Timestamp.now(), + }); + + const records = await getSeasonRecords("a"); + expect(records.map((r) => r.seasonId)).toEqual(["2026", "2025"]); + expect(records[0]).toMatchObject({ + tierPoints: 900, + tier: "ST", + rank: 2, + totalRanked: 12, + }); + // Timestamp가 그대로 유출되지 않고 ISO 문자열로 변환된다. + expect(typeof records[0].settledAt).toBe("string"); + }); });