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

101 lines
2.9 KiB
TypeScript

import { MemCache } from "../lib/memCache";
import {
countUsersAboveTierPoints,
getUser,
} from "../repositories/userRepository";
import {
readScope,
type Scope,
} from "../repositories/scoreboardCacheRepository";
import { HttpError } from "../middleware/errors";
import { computePercentile, deltaFor } from "./scoreboardHelpers";
import { todayKst } from "../types/dateString";
import { type TeamCode } from "../types/panit";
import type {
ScoreboardEntry,
ScoreboardResponse,
ScoreboardScopeCache,
ScoreboardType,
} from "../types/scoreboard";
const BURST_TTL_MS = 30_000;
const scopeCache = new MemCache<ScoreboardScopeCache>(BURST_TTL_MS);
const meRankCache = new MemCache<{ rank: number; tierPoints: number }>(
BURST_TTL_MS
);
/** 테스트 전용: 캐시를 전부 비운다. */
export function __resetScoreboardCaches(): void {
// @ts-expect-error -- 내부 Map 접근.
scopeCache.cache.clear();
// @ts-expect-error
scopeCache.inflight.clear();
// @ts-expect-error
meRankCache.cache.clear();
// @ts-expect-error
meRankCache.inflight.clear();
}
function scopeOf(type: ScoreboardType, teamCode: TeamCode | undefined): Scope {
if (type === "overall") return { kind: "overall" };
if (!teamCode) throw new HttpError(400, "no favorite team");
return { kind: "team", teamCode };
}
function scopeKey(scope: Scope): string {
return scope.kind === "overall" ? "overall" : `team:${scope.teamCode}`;
}
export async function getScoreboard(
uid: string,
type: ScoreboardType
): Promise<ScoreboardResponse> {
const user = await getUser(uid);
if (!user) throw new HttpError(404, `user not found: ${uid}`);
const teamCode = type === "team" ? user.favoriteTeamCode : undefined;
const scope = scopeOf(type, teamCode);
const date = todayKst();
const cacheKey = `${date}:${scopeKey(scope)}`;
const cached = await scopeCache.getOrFetch(cacheKey, async () => {
const doc = await readScope(date, scope);
if (!doc) throw new HttpError(503, "scoreboard not ready");
return doc;
});
const myPoints = user.tierPoints ?? 0;
let me: ScoreboardEntry | null = null;
if (myPoints > 0) {
const { rank } = await meRankCache.getOrFetch(
`${cacheKey}:${uid}`,
async () => {
const above = await countUsersAboveTierPoints(myPoints, teamCode);
return { rank: above + 1, tierPoints: myPoints };
}
);
const entry: ScoreboardEntry = {
uid,
displayName: user.displayName,
tierPoints: myPoints,
rank,
rankDelta: deltaFor(user.rankSnapshot, rank, scope),
percentile: computePercentile(rank, cached.totalCount),
};
if (user.photoUrl) entry.photoUrl = user.photoUrl;
if (user.favoriteTeamCode) entry.favoriteTeamCode = user.favoriteTeamCode;
me = entry;
}
const response: ScoreboardResponse = {
type,
totalCount: cached.totalCount,
top: cached.top,
me,
};
if (teamCode) response.teamCode = teamCode;
return response;
}