Automate game result judging with triggers and self-healing archive.
- Firestore 트리거(`onGameCompleted`)를 도입하여 경기 종료(`completed`) 및 취소(`cancelled`) 시 투표 결과 판정과 데이터 정리를 자동화했습니다. - `dailyArchive` 과정에 자가치유(self-healing) 로직인 `reconcileDayVotes`를 추가하여, 트리거 누락이나 지연된 상태 변경 건을 아카이빙 시점에 보정합니다. - `markGameEnded`가 직접 로직을 수행하는 대신 Firestore 문서만 업데이트하도록 변경하여, 결과 처리 흐름을 트리거로 일원화했습니다. - 경기 일정 수집부터 통계 산출까지의 전체 투표 파이프라인과 운영 방법을 상세히 기술한 `VOTE_FLOW.md` 문서를 작성했습니다.
This commit is contained in:
parent
f1cf9610f4
commit
c3b62c5527
124
src/VOTE_FLOW.md
Normal file
124
src/VOTE_FLOW.md
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
# 투표 파이프라인 (Vote Flow)
|
||||||
|
|
||||||
|
경기 일정 수집 → 유저 투표 → 경기 종료 감지 → 결과 자동 판정 → 하루치 아카이빙 → 통계 응답의 전 과정.
|
||||||
|
|
||||||
|
## 개관
|
||||||
|
|
||||||
|
```
|
||||||
|
[KBO API]
|
||||||
|
│ 02:00 KST kboDailyRefresh (scheduled)
|
||||||
|
▼
|
||||||
|
Firestore: games/{gameId} ← status, winningTeamCode upsert
|
||||||
|
│ update
|
||||||
|
▼
|
||||||
|
onGameCompleted (Firestore trigger)
|
||||||
|
│ ├─ status → "completed" + winningTeamCode: processGameEndWithGame
|
||||||
|
│ └─ status → "cancelled": 투표/인덱스 정리
|
||||||
|
▼
|
||||||
|
RTDB: /userVotes/{uid}/{date}/{gameId}.result 기록 + /votes/{gameId} 삭제
|
||||||
|
│
|
||||||
|
│ 03:00 KST dailyArchive (scheduled)
|
||||||
|
▼
|
||||||
|
Firestore: voteHistory/{uid}/days/{date} ← 어제치 이전
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
stats 응답: voteHistory + 오늘 /userVotes 합산
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1. 경기 일정 수집 — 매일 02:00 KST
|
||||||
|
|
||||||
|
**`kboDailyRefresh`** · `src/scheduled/kboRefresh.ts:23`
|
||||||
|
|
||||||
|
- `fetchScheduleFromKbo`로 KBO 공식 일정 조회
|
||||||
|
- `syncGamesForMonth`(`src/services/gameSyncService.ts:55`)가 Firestore `games/{gameId}`에 `merge: true`로 upsert
|
||||||
|
- 완료 경기는 스코어로 `winningTeamCode` 계산해 동시 기록
|
||||||
|
- 월말 3일 이내면 다음 달도 함께 sync
|
||||||
|
- rank/schedule 캐시(`kboCache`) invalidate
|
||||||
|
|
||||||
|
## 2. 유저 투표 — 실시간
|
||||||
|
|
||||||
|
클라이언트 → `POST /prediction` → `createPrediction` · `src/handlers/predictionHandlers.ts:32`
|
||||||
|
|
||||||
|
`submitVote` (`src/repositories/voteRepository.ts:56`)가 RTDB 원자 업데이트:
|
||||||
|
|
||||||
|
| Path | Value |
|
||||||
|
|---|---|
|
||||||
|
| `/votes/{gameId}/counts/{homeCount\|awayCount}` | `increment(1)` |
|
||||||
|
| `/votes/{gameId}/users/{uid}` | `{ team }` |
|
||||||
|
| `/userVotes/{uid}/{date}/{gameId}` | `{ team }` |
|
||||||
|
|
||||||
|
변경(`PUT /prediction`)은 `changeVote`가 양쪽 카운트 조정.
|
||||||
|
|
||||||
|
## 3. 경기 종료 감지
|
||||||
|
|
||||||
|
`games/{gameId}` 문서 `status` 필드가 업데이트되는 것으로 통일. 경로는 두 가지:
|
||||||
|
|
||||||
|
- **자동**: `kboDailyRefresh`가 KBO 스코어 기반으로 `status: "completed"` + `winningTeamCode` 기록
|
||||||
|
- **수동**: `POST /admin/game/end` · `markGameEnded` (`src/services/gameResultService.ts`) — `endedAt` 포함 동일 필드 업데이트
|
||||||
|
|
||||||
|
취소 경기는 `status: "cancelled"`로 기록됨.
|
||||||
|
|
||||||
|
## 4. 자동 판정 (Firestore 트리거)
|
||||||
|
|
||||||
|
**`onGameCompleted`** · `src/triggers/onGameCompleted.ts`
|
||||||
|
|
||||||
|
`onDocumentUpdated("games/{gameId}")`에서 `before`/`after` 비교로 분기:
|
||||||
|
|
||||||
|
### 4-a. completed 전이 → 결과 반영
|
||||||
|
가드: `before.status !== "completed" && after.status === "completed" && winningTeamCode` (멱등성).
|
||||||
|
|
||||||
|
`processGameEndWithGame(gameId, after)` (`src/services/gameResultService.ts`):
|
||||||
|
1. `getAllUserVotes(gameId)` — RTDB `/votes/{gameId}/users` 조회
|
||||||
|
2. 각 유저별 `/userVotes/{uid}/{date}/{gameId}`에 `team` + `result: team === winningTeamCode` 기록
|
||||||
|
3. `deleteGameVotes(gameId)` — `/votes/{gameId}` 제거 (집계 데이터는 이후 불필요)
|
||||||
|
4. 전 유저 `invalidateStats`
|
||||||
|
|
||||||
|
### 4-b. cancelled 전이 → 정리
|
||||||
|
`before.status !== "cancelled" && after.status === "cancelled"`일 때:
|
||||||
|
- 전 유저 `/userVotes/{uid}/{date}/{gameId}` 제거
|
||||||
|
- `/votes/{gameId}` 제거
|
||||||
|
- 투표했던 유저들 `invalidateStats`
|
||||||
|
|
||||||
|
## 5. 하루치 아카이빙 — 매일 03:00 KST
|
||||||
|
|
||||||
|
**`dailyArchive`** · `src/scheduled/dailyArchive.ts:14`
|
||||||
|
|
||||||
|
`kboDailyRefresh`(02:00) + 트리거 처리 마진 후 실행.
|
||||||
|
|
||||||
|
어제 날짜(`daysAgoKst(1)`)의 `/userVotes` 스캔:
|
||||||
|
|
||||||
|
1. 각 유저의 모든 경기 투표가 `result` 보유인지 확인 (`allJudged`)
|
||||||
|
2. **리컨실리에이션**: 미판정 경기가 있으면 `getGame`으로 Firestore 조회 후 분기
|
||||||
|
- `completed` + `winningTeamCode` → `processGameEndWithGame` 즉석 호출(트리거 누락 자가치유)
|
||||||
|
- `cancelled` → 해당 vote 항목 삭제
|
||||||
|
- `scheduled`/`live` → warn 로그 + 유저 스킵 (실데이터 이슈)
|
||||||
|
3. 모두 정리된 유저만 `voteHistory` 에 `setDay(uid, date, { data })` 저장
|
||||||
|
4. RTDB `/userVotes/{uid}/{date}` 제거 + `invalidateStats`
|
||||||
|
|
||||||
|
## 6. 통계 응답
|
||||||
|
|
||||||
|
`stats` 핸들러 → `statsService`가 캐시 미스면 **`voteHistory` + 오늘 `/userVotes`**를 합산해 계산, 캐시 저장.
|
||||||
|
|
||||||
|
## 타이밍 요약
|
||||||
|
|
||||||
|
| 시각(KST) | 작업 |
|
||||||
|
|---|---|
|
||||||
|
| 실시간 | 유저 투표 `POST /prediction` |
|
||||||
|
| 경기 종료 직후~다음날 | KBO 스코어 확정 시점에 따라 변동 |
|
||||||
|
| 02:00 | `kboDailyRefresh` → games 문서 업데이트 → `onGameCompleted` 트리거 연쇄 |
|
||||||
|
| 03:00 | `dailyArchive` (리컨실리 + voteHistory 이전) |
|
||||||
|
|
||||||
|
## 수동 운영 경로
|
||||||
|
|
||||||
|
- **특정 경기 즉시 종료 처리**: `POST /admin/game/end { gameId, winningTeamCode }` (`adminHandlers.ts`) — 관리자 인증 필요. Firestore update를 통해 자동 트리거가 돈다.
|
||||||
|
- **스케줄 강제 실행**:
|
||||||
|
- 로컬: `firebase functions:shell` → `dailyArchive()` 또는 `kboDailyRefresh()`
|
||||||
|
- 배포: `gcloud scheduler jobs run firebase-schedule-<name>-asia-northeast3 --location=asia-northeast3` 또는 GCP 콘솔
|
||||||
|
- **재처리**: 누락된 게임은 games 문서를 터치(예: status 재기록)하여 트리거 재구동 가능.
|
||||||
|
|
||||||
|
## 엣지케이스 / 주의사항
|
||||||
|
|
||||||
|
- **트리거 실패**: Cloud Functions 자체 재시도 없이 `dailyArchive`의 리컨실리 경로로 자가치유한다 (최대 24h 지연). 심각한 장애 시 수동 터치로 복구.
|
||||||
|
- **취소 경기**: 5단계에서도 cancelled로 남아있으면 archive가 알아서 제거.
|
||||||
|
- **진짜 미판정(상태 전이 미발생)**: KBO 공식 일정이 늦게 업데이트되는 경우 — `dailyArchive`가 스킵하고 warn 로그를 남김. 다음 refresh 사이클 이후 재시도 흐름은 없고(다음 날 archive는 더 과거 날짜를 본다) 수동 개입 필요.
|
||||||
|
- **한 유저가 여러 경기에 투표**: 전원 판정 완료돼야 아카이브. 1경기라도 `scheduled`/`live`면 유저 전체 스킵.
|
||||||
@ -10,3 +10,4 @@ export { stats } from "./handlers/statsHandlers";
|
|||||||
export { admin } from "./handlers/adminHandlers";
|
export { admin } from "./handlers/adminHandlers";
|
||||||
export { kboDailyRefresh } from "./scheduled/kboRefresh";
|
export { kboDailyRefresh } from "./scheduled/kboRefresh";
|
||||||
export { dailyArchive } from "./scheduled/dailyArchive";
|
export { dailyArchive } from "./scheduled/dailyArchive";
|
||||||
|
export { onGameCompleted } from "./triggers/onGameCompleted";
|
||||||
|
|||||||
@ -121,3 +121,15 @@ export async function getUserDateVotes(
|
|||||||
export async function deleteGameVotes(gameId: string): Promise<void> {
|
export async function deleteGameVotes(gameId: string): Promise<void> {
|
||||||
await rtdb.ref(`/votes/${gameId}`).remove();
|
await rtdb.ref(`/votes/${gameId}`).remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유저별 일자 인덱스에서 특정 경기 항목을 제거한다.
|
||||||
|
* 취소/리컨실리 경로에서 아카이브 대상에서 제외하기 위해 사용.
|
||||||
|
*/
|
||||||
|
export async function deleteUserVoteGame(
|
||||||
|
uid: string,
|
||||||
|
date: DateString,
|
||||||
|
gameId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await rtdb.ref(`/userVotes/${uid}/${date}/${gameId}`).remove();
|
||||||
|
}
|
||||||
|
|||||||
@ -3,16 +3,70 @@ import { logger } from "firebase-functions";
|
|||||||
import { rtdb } from "../firebase.js";
|
import { rtdb } from "../firebase.js";
|
||||||
import { setDay } from "../repositories/voteHistoryRepository.js";
|
import { setDay } from "../repositories/voteHistoryRepository.js";
|
||||||
import { invalidateStats } from "../services/statsService.js";
|
import { invalidateStats } from "../services/statsService.js";
|
||||||
|
import { getGame } from "../repositories/gameRepository.js";
|
||||||
|
import { deleteUserVoteGame } from "../repositories/voteRepository.js";
|
||||||
|
import { processGameEndWithGame } from "../services/gameResultService.js";
|
||||||
import type { VoteHistoryDoc } from "../types/panit.js";
|
import type { VoteHistoryDoc } from "../types/panit.js";
|
||||||
import { daysAgoKst } from "../types/dateString.js";
|
import { daysAgoKst, type DateString } from "../types/dateString.js";
|
||||||
|
|
||||||
interface RawVote {
|
interface RawVote {
|
||||||
team: string;
|
team: string;
|
||||||
result?: boolean;
|
result?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DayVotes = Record<string, RawVote>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미판정 경기들을 games 문서로 조회해 자가치유한다.
|
||||||
|
* - completed + winningTeamCode: 즉석 processGameEndWithGame 호출 후 result 재주입.
|
||||||
|
* - cancelled: 유저 인덱스에서 제거.
|
||||||
|
* - 그 외: warn 로그 후 그대로 둔다(진짜 미판정).
|
||||||
|
*
|
||||||
|
* 반환: 갱신된 dayVotes (result 채워졌거나 cancelled 제거된 상태).
|
||||||
|
*/
|
||||||
|
async function reconcileDayVotes(
|
||||||
|
uid: string,
|
||||||
|
date: DateString,
|
||||||
|
dayVotes: DayVotes
|
||||||
|
): Promise<DayVotes> {
|
||||||
|
const result: DayVotes = { ...dayVotes };
|
||||||
|
for (const [gameId, vote] of Object.entries(dayVotes)) {
|
||||||
|
if (vote.result !== undefined) continue;
|
||||||
|
|
||||||
|
const game = await getGame(gameId);
|
||||||
|
if (!game) {
|
||||||
|
logger.warn(`reconcile: game not found ${gameId}, uid=${uid}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 트리거가 실패/유실되었거나 배포 전 상태 전이가 일어난 경우의 자가치유 경로.
|
||||||
|
if (game.status === "completed" && game.winningTeamCode) {
|
||||||
|
try {
|
||||||
|
await processGameEndWithGame(gameId, game);
|
||||||
|
result[gameId] = {
|
||||||
|
team: vote.team,
|
||||||
|
result: vote.team === game.winningTeamCode,
|
||||||
|
};
|
||||||
|
logger.info(`reconcile: judged ${gameId} on the fly (uid=${uid})`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`reconcile: processGameEnd failed ${gameId}`, err);
|
||||||
|
}
|
||||||
|
// 취소 경기 뒤늦게 감지: 아카이브 대상에서 제외해 allJudged 통과를 허용.
|
||||||
|
} else if (game.status === "cancelled") {
|
||||||
|
await deleteUserVoteGame(uid, date, gameId);
|
||||||
|
delete result[gameId];
|
||||||
|
logger.info(`reconcile: dropped cancelled ${gameId} (uid=${uid})`);
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`reconcile: ${gameId} still ${game.status} (uid=${uid}, date=${date})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export const dailyArchive = onSchedule(
|
export const dailyArchive = onSchedule(
|
||||||
{ schedule: "0 2 * * *", timeZone: "Asia/Seoul", region: "asia-northeast3" },
|
{ schedule: "0 3 * * *", timeZone: "Asia/Seoul", region: "asia-northeast3" },
|
||||||
async () => {
|
async () => {
|
||||||
const date = daysAgoKst(1);
|
const date = daysAgoKst(1);
|
||||||
logger.info(`dailyArchive start: ${date}`);
|
logger.info(`dailyArchive start: ${date}`);
|
||||||
@ -22,13 +76,20 @@ export const dailyArchive = onSchedule(
|
|||||||
logger.info("no userVotes to archive");
|
logger.info("no userVotes to archive");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const byUid = snap.val() as Record<string, Record<string, Record<string, RawVote>>>;
|
const byUid = snap.val() as Record<string, Record<string, DayVotes>>;
|
||||||
|
|
||||||
let archived = 0;
|
let archived = 0;
|
||||||
for (const uid of Object.keys(byUid)) {
|
for (const uid of Object.keys(byUid)) {
|
||||||
const dayVotes = byUid[uid]?.[date];
|
let dayVotes = byUid[uid]?.[date];
|
||||||
if (!dayVotes) continue;
|
if (!dayVotes) continue;
|
||||||
|
|
||||||
|
const hasUnjudged = Object.values(dayVotes).some(
|
||||||
|
(v) => v.result === undefined
|
||||||
|
);
|
||||||
|
if (hasUnjudged) {
|
||||||
|
dayVotes = await reconcileDayVotes(uid, date, dayVotes);
|
||||||
|
}
|
||||||
|
|
||||||
const data: VoteHistoryDoc["data"] = [];
|
const data: VoteHistoryDoc["data"] = [];
|
||||||
let allJudged = true;
|
let allJudged = true;
|
||||||
for (const [gameId, vote] of Object.entries(dayVotes)) {
|
for (const [gameId, vote] of Object.entries(dayVotes)) {
|
||||||
@ -43,6 +104,12 @@ export const dailyArchive = onSchedule(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 리컨실 결과 모든 경기가 cancelled로 제거된 경우: voteHistory에 빈 문서를 남기지 않고 인덱스만 정리.
|
||||||
|
if (data.length === 0) {
|
||||||
|
await rtdb.ref(`/userVotes/${uid}/${date}`).remove();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
await setDay(uid, date, { data });
|
await setDay(uid, date, { data });
|
||||||
await rtdb.ref(`/userVotes/${uid}/${date}`).remove();
|
await rtdb.ref(`/userVotes/${uid}/${date}`).remove();
|
||||||
await invalidateStats(uid).catch(() => undefined);
|
await invalidateStats(uid).catch(() => undefined);
|
||||||
@ -51,4 +118,4 @@ export const dailyArchive = onSchedule(
|
|||||||
|
|
||||||
logger.info(`dailyArchive done: ${archived} users archived for ${date}`);
|
logger.info(`dailyArchive done: ${archived} users archived for ${date}`);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -6,10 +6,18 @@ import { getGame } from "../repositories/gameRepository.js";
|
|||||||
import { getAllUserVotes, deleteGameVotes } from "../repositories/voteRepository.js";
|
import { getAllUserVotes, deleteGameVotes } from "../repositories/voteRepository.js";
|
||||||
import { invalidateStats } from "./statsService.js";
|
import { invalidateStats } from "./statsService.js";
|
||||||
import { fromTimestamp } from "../types/dateString.js";
|
import { fromTimestamp } from "../types/dateString.js";
|
||||||
|
import type { Game } from "../types/panit.js";
|
||||||
|
|
||||||
export async function processGameEnd(gameId: string): Promise<{ processed: number }> {
|
export async function processGameEnd(gameId: string): Promise<{ processed: number }> {
|
||||||
const game = await getGame(gameId);
|
const game = await getGame(gameId);
|
||||||
if (!game) throw new HttpError(404, `game not found: ${gameId}`);
|
if (!game) throw new HttpError(404, `game not found: ${gameId}`);
|
||||||
|
return processGameEndWithGame(gameId, game);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processGameEndWithGame(
|
||||||
|
gameId: string,
|
||||||
|
game: Game
|
||||||
|
): Promise<{ processed: number }> {
|
||||||
if (!game.winningTeamCode) throw new HttpError(400, "winningTeamCode not set");
|
if (!game.winningTeamCode) throw new HttpError(400, "winningTeamCode not set");
|
||||||
if (game.status !== "completed") throw new HttpError(409, `game status must be completed, got ${game.status}`);
|
if (game.status !== "completed") throw new HttpError(409, `game status must be completed, got ${game.status}`);
|
||||||
|
|
||||||
@ -39,7 +47,7 @@ export async function processGameEnd(gameId: string): Promise<{ processed: numbe
|
|||||||
export async function markGameEnded(
|
export async function markGameEnded(
|
||||||
gameId: string,
|
gameId: string,
|
||||||
winningTeamCode: string
|
winningTeamCode: string
|
||||||
): Promise<{ processed: number }> {
|
): Promise<{ ok: true; gameId: string }> {
|
||||||
if (!gameId || !winningTeamCode) {
|
if (!gameId || !winningTeamCode) {
|
||||||
throw new HttpError(400, "gameId and winningTeamCode required");
|
throw new HttpError(400, "gameId and winningTeamCode required");
|
||||||
}
|
}
|
||||||
@ -51,5 +59,5 @@ export async function markGameEnded(
|
|||||||
winningTeamCode,
|
winningTeamCode,
|
||||||
endedAt: FieldValue.serverTimestamp(),
|
endedAt: FieldValue.serverTimestamp(),
|
||||||
});
|
});
|
||||||
return processGameEnd(gameId);
|
return { ok: true, gameId };
|
||||||
}
|
}
|
||||||
|
|||||||
68
src/triggers/onGameCompleted.ts
Normal file
68
src/triggers/onGameCompleted.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import {onDocumentUpdated} from "firebase-functions/firestore";
|
||||||
|
import {logger} from "firebase-functions";
|
||||||
|
import {rtdb} from "../firebase.js";
|
||||||
|
import {processGameEndWithGame} from "../services/gameResultService.js";
|
||||||
|
import {
|
||||||
|
getAllUserVotes,
|
||||||
|
deleteGameVotes,
|
||||||
|
} from "../repositories/voteRepository.js";
|
||||||
|
import {invalidateStats} from "../services/statsService.js";
|
||||||
|
import {fromTimestamp} from "../types/dateString.js";
|
||||||
|
import type {Game} from "../types/panit.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `games/{gameId}` 문서 업데이트를 받아 유저 투표를 정리하는 Firestore 트리거.
|
||||||
|
*
|
||||||
|
* 두 전이 경로를 커버한다:
|
||||||
|
* - status → `completed` (+ winningTeamCode): `processGameEndWithGame`로 result 기록.
|
||||||
|
* - status → `cancelled`: 해당 경기의 유저 인덱스/카운트를 제거하여 아카이브 대상에서 제외.
|
||||||
|
*
|
||||||
|
* 전이 가드(이전 상태와 비교)로 동일 문서 재업데이트 시 중복 실행을 방지한다.
|
||||||
|
*/
|
||||||
|
export const onGameCompleted = onDocumentUpdated(
|
||||||
|
{document: "games/{gameId}", region: "asia-northeast3"},
|
||||||
|
async (event) => {
|
||||||
|
const before = event.data?.before.data() as Game | undefined;
|
||||||
|
const after = event.data?.after.data() as Game | undefined;
|
||||||
|
if (!after) return;
|
||||||
|
|
||||||
|
const gameId = event.params.gameId;
|
||||||
|
|
||||||
|
// 게임이 completed로 전이되었을때 (어떤 게임이 끝났음으로 처리되었을 때)
|
||||||
|
const becameCompleted = before?.status !== "completed" && after.status === "completed";
|
||||||
|
if (becameCompleted && after.winningTeamCode) {
|
||||||
|
try {
|
||||||
|
const {processed} = await processGameEndWithGame(gameId, after);
|
||||||
|
logger.info(`onGameCompleted: ${gameId} processed ${processed} votes`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`onGameCompleted failed for ${gameId}`, err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 게임이 cancelled로 전이되었을 때 (어떤 게임이 취소되었음으로 처리되었을 때)
|
||||||
|
const becameCancelled = before?.status !== "cancelled" && after.status === "cancelled";
|
||||||
|
if (becameCancelled) {
|
||||||
|
try {
|
||||||
|
const date = fromTimestamp(after.time);
|
||||||
|
const votes = await getAllUserVotes(gameId);
|
||||||
|
const uids = Object.keys(votes);
|
||||||
|
|
||||||
|
if (uids.length > 0) {
|
||||||
|
const updates: Record<string, null> = {};
|
||||||
|
for (const uid of uids) {
|
||||||
|
updates[`/userVotes/${uid}/${date}/${gameId}`] = null;
|
||||||
|
}
|
||||||
|
await rtdb.ref().update(updates);
|
||||||
|
}
|
||||||
|
await deleteGameVotes(gameId);
|
||||||
|
await Promise.all(
|
||||||
|
uids.map((uid) => invalidateStats(uid).catch(() => undefined))
|
||||||
|
);
|
||||||
|
logger.info(`onGameCancelled: ${gameId} cleared ${uids.length} votes`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`onGameCancelled failed for ${gameId}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
Loading…
x
Reference in New Issue
Block a user