mmday-firebase/src/services/judgmentService.ts
윤정민 c94ce69e4f Remove redundant .js extensions from import paths.
- TypeScript 소스 파일 내 모든 import 구문에서 불필요한 `.js` 확장자를 제거하여 모듈 참조 방식을 표준화했습니다.
- 핸들러, 서비스, 리포지토리 및 테스트 코드를 포함한 프로젝트 전반의 import 경로를 일관성 있게 정리했습니다.
2026-05-06 16:17:51 +09:00

131 lines
4.5 KiB
TypeScript

import { logger } from "firebase-functions";
import { listByDate } from "../repositories/gameRepository";
import { getRange, setDay } from "../repositories/voteHistoryRepository";
import {
applyDailyJudgmentTx,
applyWeeklyMasterTx,
getUser,
} from "../repositories/userRepository";
import {
judgeByCounts,
streakBonus,
thresholdsFor,
} from "../constants/judgment";
import type { DailyJudgment, VoteHistoryDoc } from "../types/panit";
import { addDaysKst, tuesdayOf, type DateString } from "../types/dateString";
/**
* `(lastJudged, upTo)` 구간(양 끝 제외)에 **실제 판정 가능한 경기일**(=`thresholdsFor`가
* `"skip"`이 아닌 날)이 한 번이라도 있었는지 검사한다. 있으면 그 날을 결석한 것이므로
* streak이 끊겼다고 본다. 모두 skip이거나 해당 구간이 비어있으면 끊기지 않음.
*
* KBO 휴장일(월요일, 우천 전체 취소, 올스타브레이크 등)을 자연스럽게 무시한다.
*
* 비용: 구간 일수만큼 `games` 컬렉션 read. 14일까지만 거슬러 올라가고 그 이상이면
* 결석으로 간주한다(현실적으로 14일 연속 휴장은 없음).
*/
export async function hasMissedGameDayBetween(
lastJudged: DateString,
upTo: DateString
): Promise<boolean> {
const MAX_LOOKBACK = 14;
let cursor = addDaysKst(upTo, -1);
for (let i = 0; i < MAX_LOOKBACK && cursor > lastJudged; i++) {
const games = await listByDate(cursor);
const completed = games.filter((g) => g.status === "completed").length;
if (thresholdsFor(completed) !== "skip") return true;
cursor = addDaysKst(cursor, -1);
}
// MAX_LOOKBACK 초과로 종료된 경우엔 안전을 위해 결석으로 본다.
return cursor > lastJudged;
}
/**
* 지정 날짜의 투표 이력을 판정하고 결과를 voteHistory와 user doc에 반영한다.
*
* - `dailyArchive`가 해당 날짜의 voteHistory 도큐먼트를 기록한 이후에 호출한다.
* - 전부 취소되어 투표 데이터가 비어있는 날에도 skip 판정을 남기기 위해 호출 가능.
*
* @param uid - 유저 ID
* @param date - 판정 대상 날짜 (KST)
* @param voteDoc - 해당 날짜의 voteHistory 도큐먼트 내용(판정 필드 미포함)
*/
export async function judgeDay(
uid: string,
date: DateString,
voteDoc: VoteHistoryDoc
): Promise<void> {
const games = await listByDate(date);
const completedCount = games.filter((g) => g.status === "completed").length;
const threshold = thresholdsFor(completedCount);
let judgment: DailyJudgment;
let correctCount: number;
if (threshold === "skip") {
judgment = "skip";
correctCount = 0;
} else {
correctCount = voteDoc.data.filter((v) => v.result).length;
judgment = judgeByCounts(correctCount, threshold);
}
// 결석 여부는 휴장일을 제외하고 판단한다. (lastJudged, date) 구간에 실제
// 경기일이 하나라도 있었는데 user가 그날 안 했으면 streak 끊김.
const userPre = await getUser(uid);
const lastJudgedPre = userPre?.lastJudgedDate;
const streakBrokenIn =
lastJudgedPre != null &&
lastJudgedPre < addDaysKst(date, -1) &&
(await hasMissedGameDayBetween(lastJudgedPre, date));
const tx = await applyDailyJudgmentTx(uid, date, {
judgment,
correctCount,
completedCount,
streakBrokenIn,
computePoints: (streakAfter) => correctCount * 10 + streakBonus(streakAfter),
});
if (tx.skippedByGuard) {
logger.info(`judgeDay: ${uid} ${date} already judged, skipping`);
return;
}
await setDay(uid, date, {
...voteDoc,
judgment,
correctCount,
completedCount,
streakAfter: tx.streakAfter,
});
}
/**
* 일요일 아카이브 직후 호출되어, 해당 주(화~일) 6일의 판정이 모두
* `success|perfect|skip` 이면 주간 마스터 티켓을 지급한다.
*
* @param uid - 유저 ID
* @param sundayDate - 방금 아카이브/판정이 끝난 일요일 날짜
*/
export async function judgeWeekIfNeeded(
uid: string,
sundayDate: DateString
): Promise<void> {
const tuesday = tuesdayOf(sundayDate);
const entries = await getRange(uid, tuesday, sundayDate);
if (entries.length === 0) return;
const byDate = new Map(entries.map((e) => [e.date, e.doc] as const));
for (let i = 0; i < 6; i++) {
const d = addDaysKst(tuesday, i);
const doc = byDate.get(d);
if (!doc || !doc.judgment) return; // 판정 누락
if (doc.judgment === "fail") return;
}
const granted = await applyWeeklyMasterTx(uid, tuesday);
if (granted) {
logger.info(`weekly-master granted: uid=${uid} tuesday=${tuesday}`);
}
}