During dailyArchive, reconcileDayVotes may call processGameEndWithGame per unjudged game, each invalidating every voter's stats cache. Since dailyArchive already invalidates each archived user once at the end of its per-user loop, the per-game fan-out is redundant. Add an opt-in skipInvalidate flag and use it from reconcile. The live onGameCompleted trigger path keeps invalidating as before. Stale data is impossible: getStats also self-invalidates on the daily forDate rollover.
70 lines
2.6 KiB
TypeScript
70 lines
2.6 KiB
TypeScript
import { FieldValue } from "firebase-admin/firestore";
|
|
import { logger } from "firebase-functions";
|
|
import { firestore, rtdb } from "../firebase";
|
|
import { HttpError } from "../middleware/errors";
|
|
import { getGame } from "../repositories/gameRepository";
|
|
import { getAllUserVotes, deleteGameVotes } from "../repositories/voteRepository";
|
|
import { invalidateStats } from "./statsService";
|
|
import { fromTimestamp } from "../types/dateString";
|
|
import type { Game } from "../types/panit";
|
|
|
|
export async function processGameEnd(gameId: string): Promise<{ processed: number }> {
|
|
const game = await getGame(gameId);
|
|
if (!game) throw new HttpError(404, `game not found: ${gameId}`);
|
|
return processGameEndWithGame(gameId, game);
|
|
}
|
|
|
|
export async function processGameEndWithGame(
|
|
gameId: string,
|
|
game: Game,
|
|
opts?: { skipInvalidate?: boolean }
|
|
): Promise<{ processed: number }> {
|
|
if (game.status !== "completed") throw new HttpError(409, `game status must be completed, got ${game.status}`);
|
|
|
|
const date = fromTimestamp(game.time);
|
|
const votes = await getAllUserVotes(gameId);
|
|
const uids = Object.keys(votes);
|
|
|
|
// 무승부(`winningTeamCode` 없음): 양 팀 투표 모두 적중(`result: true`)으로 처리.
|
|
const isDraw = !game.winningTeamCode;
|
|
const rtdbUpdates: Record<string, unknown> = {};
|
|
for (const uid of uids) {
|
|
const voted = votes[uid].team;
|
|
const result = isDraw ? true : voted === game.winningTeamCode;
|
|
rtdbUpdates[`/userVotes/${uid}/${date}/${gameId}/team`] = voted;
|
|
rtdbUpdates[`/userVotes/${uid}/${date}/${gameId}/result`] = result;
|
|
}
|
|
if (Object.keys(rtdbUpdates).length > 0) {
|
|
await rtdb.ref().update(rtdbUpdates);
|
|
}
|
|
|
|
await deleteGameVotes(gameId);
|
|
|
|
// 아카이브 리컨실 경로에선 호출자(dailyArchive)가 유저 단위로 1회 무효화하므로
|
|
// 경기마다 투표자 전원을 다시 무효화하는 fan-out을 건너뛴다(중복 제거).
|
|
if (!opts?.skipInvalidate) {
|
|
await Promise.all(uids.map((uid) => invalidateStats(uid).catch(() => undefined)));
|
|
}
|
|
|
|
logger.info(`processGameEnd: ${gameId} processed ${uids.length} votes`);
|
|
return { processed: uids.length };
|
|
}
|
|
|
|
export async function markGameEnded(
|
|
gameId: string,
|
|
winningTeamCode: string
|
|
): Promise<{ ok: true; gameId: string }> {
|
|
if (!gameId || !winningTeamCode) {
|
|
throw new HttpError(400, "gameId and winningTeamCode required");
|
|
}
|
|
const ref = firestore.collection("games").doc(gameId);
|
|
const snap = await ref.get();
|
|
if (!snap.exists) throw new HttpError(404, `game not found: ${gameId}`);
|
|
await ref.update({
|
|
status: "completed",
|
|
winningTeamCode,
|
|
endedAt: FieldValue.serverTimestamp(),
|
|
});
|
|
return { ok: true, gameId };
|
|
}
|