Add season records endpoint
- GET /stats/seasons 추가 — 유저의 시즌별 최종 성적(seasonHistory)을 최근 시즌부터 반환 - SeasonRecordDto로 응답 형태 고정, settledAt(Timestamp)은 UTC ISO 문자열로 변환해 유출 방지 - 정산 전 빈 목록·정렬·DTO 변환 테스트 추가
This commit is contained in:
parent
5a02b074bf
commit
477fcabc78
@ -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);
|
||||
|
||||
@ -56,6 +56,19 @@ export async function listSeasonHistoryEntries(
|
||||
.filter((e) => e.uid.length > 0);
|
||||
}
|
||||
|
||||
/** 유저의 시즌별 최종 성적 목록 — 최근 시즌부터(seasonId 내림차순). */
|
||||
export async function listUserSeasonHistory(
|
||||
uid: string
|
||||
): Promise<SeasonHistoryDoc[]> {
|
||||
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;
|
||||
|
||||
|
||||
@ -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<SeasonRecordDto[]> {
|
||||
const docs = await listUserSeasonHistory(uid);
|
||||
return docs.map(toSeasonRecordDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 시즌 종료 정산. `dailyArchive`가 매일 호출하지만 실제 실행 조건은
|
||||
* "`config/season`이 존재 + `settledAt` 없음 + today > endDate"의 단 한 번이다.
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user