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 { DRAW_TEAM_CODE, 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` 없음): 무승부('DRAW') 투표만 적중, 팀 투표는 오답. // 승부가 난 경기: 승리 팀 투표만 적중 — 무승부 투표는 자연히 오답이 된다. const isDraw = !game.winningTeamCode; const rtdbUpdates: Record = {}; for (const uid of uids) { const voted = votes[uid].team; const result = isDraw ? voted === DRAW_TEAM_CODE : 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}`); // 'DRAW'는 무승부 수동 처리 — winningTeamCode 없이 completed로 기록한다 // (판정 규칙상 "winningTeamCode 없음 = 무승부"이므로 남아있는 값도 지운다). const isDraw = winningTeamCode === DRAW_TEAM_CODE; await ref.update({ status: "completed", winningTeamCode: isDraw ? FieldValue.delete() : winningTeamCode, endedAt: FieldValue.serverTimestamp(), }); return { ok: true, gameId }; }