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 { requireAuth } from "../middleware/auth";
|
||||||
import { sendError } from "../middleware/errors";
|
import { sendError } from "../middleware/errors";
|
||||||
import { getStats, getHistory } from "../services/statsService";
|
import { getStats, getHistory } from "../services/statsService";
|
||||||
|
import { getSeasonRecords } from "../services/seasonService";
|
||||||
import type { UserStatsDto, VoteHistoryDto } from "../types/dto/statsDto";
|
import type { UserStatsDto, VoteHistoryDto } from "../types/dto/statsDto";
|
||||||
|
|
||||||
export const stats = onRequest(async (req, res) => {
|
export const stats = onRequest(async (req, res) => {
|
||||||
@ -18,6 +19,11 @@ export const stats = onRequest(async (req, res) => {
|
|||||||
return;
|
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") {
|
if ((tail === "stats" || segs.length === 1) && req.method === "GET") {
|
||||||
const period = req.query.period ? String(req.query.period) : undefined;
|
const period = req.query.period ? String(req.query.period) : undefined;
|
||||||
const result: UserStatsDto = await getStats(uid, period);
|
const result: UserStatsDto = await getStats(uid, period);
|
||||||
|
|||||||
@ -56,6 +56,19 @@ export async function listSeasonHistoryEntries(
|
|||||||
.filter((e) => e.uid.length > 0);
|
.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(아카이브 + 리셋). */
|
/** 배치 한도(500 write)의 여유분 — 유저당 2 write(아카이브 + 리셋). */
|
||||||
const SETTLE_CHUNK = 200;
|
const SETTLE_CHUNK = 200;
|
||||||
|
|
||||||
|
|||||||
@ -3,14 +3,24 @@ import { tierOf } from "../constants/tiers";
|
|||||||
import {
|
import {
|
||||||
getSeasonConfig,
|
getSeasonConfig,
|
||||||
listSeasonHistoryEntries,
|
listSeasonHistoryEntries,
|
||||||
|
listUserSeasonHistory,
|
||||||
markSeasonSettled,
|
markSeasonSettled,
|
||||||
settleUsers,
|
settleUsers,
|
||||||
} from "../repositories/seasonRepository";
|
} from "../repositories/seasonRepository";
|
||||||
import { listAllRankedUsers } from "../repositories/userRepository";
|
import { listAllRankedUsers } from "../repositories/userRepository";
|
||||||
import { invalidateStats } from "./statsService";
|
import { invalidateStats } from "./statsService";
|
||||||
|
import { toSeasonRecordDto, type SeasonRecordDto } from "../types/dto/statsDto";
|
||||||
import type { DateString } from "../types/dateString";
|
import type { DateString } from "../types/dateString";
|
||||||
import type { SeasonHistoryDoc } from "../types/season";
|
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`가 매일 호출하지만 실제 실행 조건은
|
* 시즌 종료 정산. `dailyArchive`가 매일 호출하지만 실제 실행 조건은
|
||||||
* "`config/season`이 존재 + `settledAt` 없음 + today > endDate"의 단 한 번이다.
|
* "`config/season`이 존재 + `settledAt` 없음 + today > endDate"의 단 한 번이다.
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { toIsoOrUndefined } from "./iso";
|
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 유출이 없어 기존 타입을 그대로 별칭한다. */
|
/** GET /stats 응답. Timestamp 유출이 없어 기존 타입을 그대로 별칭한다. */
|
||||||
export type UserStatsDto = StatsResponse;
|
export type UserStatsDto = StatsResponse;
|
||||||
@ -30,6 +31,31 @@ export interface VoteHistoryDto {
|
|||||||
/** 문서가 없는 날은 빈 목록으로 응답한다 (기존 동작 유지). */
|
/** 문서가 없는 날은 빈 목록으로 응답한다 (기존 동작 유지). */
|
||||||
export const EMPTY_VOTE_HISTORY_DTO: VoteHistoryDto = { data: [] };
|
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 가 그대로 유출된다. */
|
/** 필드를 명시적으로 나열한다 — 스프레드를 쓰면 문서에 새로 생긴 Timestamp 가 그대로 유출된다. */
|
||||||
export function toVoteHistoryDto(doc: VoteHistoryDoc): VoteHistoryDto {
|
export function toVoteHistoryDto(doc: VoteHistoryDoc): VoteHistoryDto {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import { FieldValue, Timestamp } from "firebase-admin/firestore";
|
import { FieldValue, Timestamp } from "firebase-admin/firestore";
|
||||||
import { firestore } from "../../src/firebase";
|
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 { DateString } from "../../src/types/dateString";
|
||||||
import type { SeasonHistoryDoc } from "../../src/types/season";
|
import type { SeasonHistoryDoc } from "../../src/types/season";
|
||||||
import type { User } from "../../src/types/panit";
|
import type { User } from "../../src/types/panit";
|
||||||
@ -164,4 +167,41 @@ describe("seasonService.maybeSettleSeason", () => {
|
|||||||
expect((await readHistory("done"))?.rank).toBe(1);
|
expect((await readHistory("done"))?.rank).toBe(1);
|
||||||
expect((await readUser("b")).tierPoints).toBe(0);
|
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