import { Timestamp } from "firebase-admin/firestore"; import { firestore } from "../firebase"; import { fetchScheduleFromKbo, statusFromRecord, } from "../repositories/kboRepository"; import { getGameList } from "./gameListService"; import type { GameListRecord } from "../kbo/game-list"; import type { ScheduleGame, GameStatus } from "../kbo/schedule"; import type { Game } from "../types/panit"; const COLLECTION = "games"; /** * KBO schedule의 `date`("MM.DD")와 `time`("HH:mm")을 KST 기준 Firestore Timestamp로 변환한다. */ export function toTime(year: number, date: string, time: string): Timestamp { const [mm, dd] = date.split(".").map((v) => parseInt(v, 10)); const [hhRaw, miRaw] = (time || "00:00").split(":"); const hh = parseInt(hhRaw, 10); const mi = parseInt(miRaw, 10); // KST = UTC+9 const utcMs = Date.UTC(year, mm - 1, dd, hh - 9, mi); return Timestamp.fromMillis(utcMs); } /** * ScheduleGame → Firestore Game doc 변환. gameId가 없으면 `null`. * 완료된 경기의 경우 점수로 승리팀 코드를 계산한다. */ export function toGameDoc(year: number, g: ScheduleGame): Game | null { if (!g.gameId) return null; const doc: Game = { time: toTime(year, g.date, g.time), stadium: g.stadium, status: g.status, homeTeamCode: g.homeTeamCode, awayTeamCode: g.awayTeamCode, }; if ( g.status === "completed" && g.homeScore !== null && g.awayScore !== null && g.homeScore !== g.awayScore ) { doc.winningTeamCode = g.homeScore > g.awayScore ? g.homeTeamCode : g.awayTeamCode; } if (g.status === "cancelled" && g.note && g.note !== "-") { doc.cancelReason = g.note; } return doc; } /** * 특정 연도·월의 KBO 경기 일정을 fetch하여 Firestore `games` 컬렉션에 upsert한다. * * - `merge: true`로 기존 문서의 수동 필드(예: 외부에서 채운 `winningTeamCode`)를 보존한다. * - gameId가 없는 일정 엔트리는 스킵. * * @returns upsert한 문서 개수 */ export async function syncGamesForMonth(year: number, month: number): Promise { const result = await fetchScheduleFromKbo({ year, month }); const batch = firestore.batch(); let count = 0; for (const g of result.games) { const doc = toGameDoc(year, g); if (!doc) continue; batch.set(firestore.collection(COLLECTION).doc(g.gameId as string), doc, { merge: true, }); count++; } if (count > 0) await batch.commit(); return count; } /** * 라이브 게임 레코드(`getGameList` 응답)로부터 `games` 문서에 적용할 부분 업데이트를 도출한다. * status가 결정 불가능한 경우(`null`) 호출자가 무시할 수 있도록 `null`을 반환. * * `completed`이고 무승부가 아니면 점수 비교로 `winningTeamCode`를 채운다. * 무승부거나 status가 `completed`가 아니면 `winningTeamCode`는 포함하지 않는다(기존 값 유지를 위함). */ export function gameUpdateFromRecord( rec: GameListRecord ): { status: GameStatus; winningTeamCode?: string; cancelReason?: string } | null { const status = statusFromRecord(rec); if (!status) return null; const update: { status: GameStatus; winningTeamCode?: string; cancelReason?: string; } = { status }; if ( status === "completed" && rec.score.home != null && rec.score.away != null && rec.score.home !== rec.score.away ) { update.winningTeamCode = rec.score.home > rec.score.away ? rec.homeTeamCode : rec.awayTeamCode; } if (status === "cancelled" && rec.status.cancelName) { update.cancelReason = rec.status.cancelName; } return update; } /** * 지정 날짜(YYYYMMDD)의 라이브 게임 데이터를 가져와 `games` 컬렉션의 status·winningTeamCode· * cancelReason을 갱신한다. 사전 read로 기존 값과 비교하여 **변경이 있는 문서만** write한다 — * 매일 같은 값으로 덮어쓰는 무의미한 write를 줄이기 위함. status 변경이 발생하면 * `onGameCompleted` 트리거가 깨어나 후속 처리(userVotes에 result 기록 등)를 이어간다. * * 호출 경로: * - cron(`kboDailyRefresh`): 어제 날짜로 호출. 월간 schedule refresh가 놓치는 월말 갱신 보완. * - debug 엔드포인트(`/debug/forceSync`): 임의 날짜를 강제 sync. stuck 데이터 즉시 복구용. * * 휴장일이면 라이브 API가 0건 반환 → no-op. * * @param yyyymmdd KST 기준 YYYYMMDD 문자열 * @returns `updated`: 실제로 write가 일어난 문서 수 */ export async function forceSyncDay( yyyymmdd: string ): Promise<{ updated: number }> { const result = await getGameList(yyyymmdd); const targets: Array<{ gameId: string; update: { status: GameStatus; winningTeamCode?: string; cancelReason?: string }; }> = []; for (const rec of result.games) { if (!rec.gameId) continue; const update = gameUpdateFromRecord(rec); if (!update) continue; targets.push({ gameId: rec.gameId, update }); } if (targets.length === 0) return { updated: 0 }; const refs = targets.map((t) => firestore.collection(COLLECTION).doc(t.gameId)); const snaps = await firestore.getAll(...refs); const batch = firestore.batch(); let updated = 0; snaps.forEach((snap, i) => { const { gameId, update } = targets[i]; const existing = snap.exists ? (snap.data() as Partial) : null; const sameStatus = existing?.status === update.status; const sameWinner = existing?.winningTeamCode === update.winningTeamCode; const sameReason = existing?.cancelReason === update.cancelReason; if (existing && sameStatus && sameWinner && sameReason) return; batch.set(firestore.collection(COLLECTION).doc(gameId), update, { merge: true, }); updated++; }); if (updated > 0) await batch.commit(); return { updated }; }