diff --git a/src/handlers/chatHandlers.ts b/src/handlers/chatHandlers.ts index 0cdcf7d..ee50498 100644 --- a/src/handlers/chatHandlers.ts +++ b/src/handlers/chatHandlers.ts @@ -9,6 +9,13 @@ import { reportMessage, sendMessage, } from "../services/chatService"; +import type { + ChatHistoryPageDto, + ChatQuotaDto, + ChatReportDto, + ChatSendResultDto, + ChatSuggestionsDto, +} from "../types/dto/chatDto"; /** * AI 채팅(짹) 엔드포인트(§3). `/chat/*` 전체가 인증 필요 API다. @@ -43,14 +50,14 @@ export const chat = onRequest({ timeoutSeconds: 60 }, async (req, res) => { if (segs[0] === "messages" && segs.length === 1) { const uid = await requireChatAuth(req); if (req.method === "POST") { - const result = await sendMessage(uid, req.body ?? {}); + const result: ChatSendResultDto = await sendMessage(uid, req.body ?? {}); res.status(200).json(result); return; } if (req.method === "GET") { const cursor = req.query.cursor != null ? String(req.query.cursor) : undefined; const limit = req.query.limit != null ? String(req.query.limit) : undefined; - const result = await getMessages(uid, cursor, limit); + const result: ChatHistoryPageDto = await getMessages(uid, cursor, limit); res.status(200).json(result); return; } @@ -58,21 +65,21 @@ export const chat = onRequest({ timeoutSeconds: 60 }, async (req, res) => { if (segs[0] === "messages" && segs.length === 3 && segs[2] === "report" && req.method === "POST") { const uid = await requireChatAuth(req); - const result = await reportMessage(uid, segs[1], req.body ?? {}); + const result: ChatReportDto = await reportMessage(uid, segs[1], req.body ?? {}); res.status(200).json(result); return; } if (segs[0] === "quota" && req.method === "GET") { const uid = await requireChatAuth(req); - const result = await getQuota(uid); + const result: ChatQuotaDto = await getQuota(uid); res.status(200).json(result); return; } if (segs[0] === "suggestions" && req.method === "GET") { const uid = await requireChatAuth(req); - const result = await getSuggestions(uid); + const result: ChatSuggestionsDto = await getSuggestions(uid); res.status(200).json(result); return; } diff --git a/src/handlers/kboHandlers.ts b/src/handlers/kboHandlers.ts index d787c24..431b0e4 100644 --- a/src/handlers/kboHandlers.ts +++ b/src/handlers/kboHandlers.ts @@ -6,6 +6,13 @@ import { getGameList } from "../services/gameListService"; import { getGameDetail } from "../services/gameDetailService"; import { TeamCode } from "../types/panit"; import { kboTodayKst } from "../types/dateString"; +import type { + KboGameDetailDto, + KboGamesDto, + KboPlayerDto, + KboRankDto, + KboScheduleDto, +} from "../types/dto/kboDto"; enum KboPath { Rank = "rank", @@ -51,7 +58,7 @@ export const kbo = onRequest(async (req, res) => { } } - const result = await getRank(years); + const result: KboRankDto = await getRank(years); res.status(200).json(result); return; } @@ -94,7 +101,7 @@ export const kbo = onRequest(async (req, res) => { team = raw as TeamCode; } - const result = await getSchedule(year, month, team, series, day); + const result: KboScheduleDto = await getSchedule(year, month, team, series, day); res.status(200).json(result); return; } @@ -109,7 +116,7 @@ export const kbo = onRequest(async (req, res) => { return; } - const result = await getGameList(date, series, league); + const result: KboGamesDto = await getGameList(date, series, league); res.status(200).json(result); return; } @@ -133,7 +140,7 @@ export const kbo = onRequest(async (req, res) => { return; } - const result = await getGameDetail({ gameId, series, league, season }); + const result: KboGameDetailDto = await getGameDetail({ gameId, series, league, season }); res.status(200).json(result); return; } @@ -170,7 +177,7 @@ export const kbo = onRequest(async (req, res) => { team = raw as TeamCode; } - const result = await getPlayerStats({ + const result: KboPlayerDto = await getPlayerStats({ type, year, team, diff --git a/src/handlers/predictionHandlers.ts b/src/handlers/predictionHandlers.ts index 0c5274b..42fce01 100644 --- a/src/handlers/predictionHandlers.ts +++ b/src/handlers/predictionHandlers.ts @@ -10,6 +10,13 @@ import { } from "../services/predictionService"; import { getScoreboard } from "../services/scoreboardService"; import { kboTodayKst } from "../types/dateString"; +import type { + GamesResponseDto, + MyVotesDto, + PredictionMutationDto, + ScoreboardDto, + VoteSummaryDto, +} from "../types/dto/predictionDto"; /** * `date` 쿼리 파라미터 해석. @@ -28,7 +35,8 @@ export const prediction = onRequest(async (req, res) => { if (tail === "games" && req.method === "GET") { const date = resolveDateParam(req.query.date); const games = await listGamesByDate(date); - res.status(200).json({ date, games }); + const dto: GamesResponseDto = { date, games }; + res.status(200).json(dto); return; } @@ -38,7 +46,7 @@ export const prediction = onRequest(async (req, res) => { if (type !== "team" && type !== "overall") { throw new HttpError(400, `invalid type: ${type}`); } - const result = await getScoreboard(uid, type); + const result: ScoreboardDto = await getScoreboard(uid, type); res.set("Cache-Control", "private, max-age=60"); res.status(200).json(result); return; @@ -46,7 +54,7 @@ export const prediction = onRequest(async (req, res) => { if (tail === "summary" && req.method === "GET") { const gameId = String(req.query.gameId ?? ""); - const result = await getSummary(gameId); + const result: VoteSummaryDto = await getSummary(gameId); res.set("Cache-Control", "public, max-age=5"); res.status(200).json(result); return; @@ -55,20 +63,20 @@ export const prediction = onRequest(async (req, res) => { if (tail === "prediction" || segs.length === 1) { if (req.method === "POST") { const uid = await requireAuth(req); - const result = await createPrediction(uid, req.body ?? {}); + const result: PredictionMutationDto = await createPrediction(uid, req.body ?? {}); res.status(201).json(result); return; } if (req.method === "PUT") { const uid = await requireAuth(req); - const result = await updatePrediction(uid, req.body ?? {}); + const result: PredictionMutationDto = await updatePrediction(uid, req.body ?? {}); res.status(200).json(result); return; } if (req.method === "GET") { const uid = await requireAuth(req); const date = resolveDateParam(req.query.date); - const result = await getMyVotes(uid, date); + const result: MyVotesDto = await getMyVotes(uid, date); res.status(200).json(result); return; } diff --git a/src/handlers/rewardHandlers.ts b/src/handlers/rewardHandlers.ts index fea08a0..14dd072 100644 --- a/src/handlers/rewardHandlers.ts +++ b/src/handlers/rewardHandlers.ts @@ -6,16 +6,111 @@ import { listLedger } from "../repositories/pointLedgerRepository"; import { getCatalog, getCatalogProduct } from "../services/rewardCatalogService"; import { computeEligibility } from "../services/eligibilityService"; import { cancelOrder, createOrder, getOrder, listOrders } from "../services/orderService"; +import { + EMPTY_WALLET_DTO, + toLedgerEntryDto, + toOrderDto, + toWalletDto, + type CancelOrderDto, + type CreateOrderResponseDto, + type EligibilityDto, + type LedgerPageDto, + type OrderPageDto, + type ProductDetailDto, + type ProductSummaryDto, + type WalletDto, +} from "../types/dto/rewardDto"; -export const reward = onRequest(async (req, res) => { try { const uid = await requireAuth(req); const path = req.path.replace(/^\/+|\/+$/g, ""); - if (req.method === "GET" && path === "wallet") { res.json((await getWallet(uid)) ?? { availableBalance: 0, reservedBalance: 0, totalEarned: 0, totalSpent: 0, version: 0 }); return; } - if (req.method === "GET" && path === "ledger") { res.json(await listLedger(uid, Number(req.query.limit) || 20, typeof req.query.cursor === "string" ? req.query.cursor : undefined)); return; } - if (req.method === "GET" && path === "products") { res.json(await getCatalog()); return; } - const product = path.match(/^products\/([^/]+)$/); if (req.method === "GET" && product) { const p = await getCatalogProduct(product[1]); if (!p) { res.status(404).json({ error: "PRODUCT_NOT_FOUND" }); return; } res.json(p); return; } - if (req.method === "GET" && path === "eligibility") { res.json(await computeEligibility(uid)); return; } - if (req.method === "POST" && path === "orders") { res.status(201).json(await createOrder(uid, req.body)); return; } - if (req.method === "GET" && path === "orders") { res.json(await listOrders(uid, Number(req.query.limit) || 20, typeof req.query.cursor === "string" ? req.query.cursor : undefined)); return; } - const order = path.match(/^orders\/([^/]+)$/); if (req.method === "GET" && order) { const o = await getOrder(order[1]); if (!o || o.uid !== uid) { res.status(404).json({ error: "ORDER_NOT_FOUND" }); return; } res.json(o); return; } - const cancel = path.match(/^orders\/([^/]+)\/cancel$/); if (req.method === "POST" && cancel) { res.json(await cancelOrder(uid, cancel[1])); return; } - res.status(404).json({ error: "not found" }); -} catch (err) { sendError(res, err); } }); +function pageArgs(req: { query: Record }): [number, string | undefined] { + return [Number(req.query.limit) || 20, typeof req.query.cursor === "string" ? req.query.cursor : undefined]; +} + +export const reward = onRequest(async (req, res) => { + try { + const uid = await requireAuth(req); + const path = req.path.replace(/^\/+|\/+$/g, ""); + + if (req.method === "GET" && path === "wallet") { + const wallet = await getWallet(uid); + const dto: WalletDto = wallet ? toWalletDto(wallet) : EMPTY_WALLET_DTO; + res.json(dto); + return; + } + + if (req.method === "GET" && path === "ledger") { + const page = await listLedger(uid, ...pageArgs(req)); + const dto: LedgerPageDto = { + items: page.items.map((entry) => toLedgerEntryDto(entry.id, entry)), + cursor: page.cursor, + }; + res.json(dto); + return; + } + + if (req.method === "GET" && path === "products") { + const dto: ProductSummaryDto[] = await getCatalog(); + res.json(dto); + return; + } + + const product = path.match(/^products\/([^/]+)$/); + if (req.method === "GET" && product) { + const dto: ProductDetailDto | null = await getCatalogProduct(product[1]); + if (!dto) { + res.status(404).json({ error: "PRODUCT_NOT_FOUND" }); + return; + } + res.json(dto); + return; + } + + if (req.method === "GET" && path === "eligibility") { + const dto: EligibilityDto = await computeEligibility(uid); + res.json(dto); + return; + } + + if (req.method === "POST" && path === "orders") { + const created = await createOrder(uid, req.body); + const dto: CreateOrderResponseDto = { + order: toOrderDto(created.order.id, created.order), + deduplicated: created.deduplicated, + availableBalance: created.availableBalance, + }; + res.status(201).json(dto); + return; + } + + if (req.method === "GET" && path === "orders") { + const page = await listOrders(uid, ...pageArgs(req)); + const dto: OrderPageDto = { + items: page.items.map((order) => toOrderDto(order.id, order)), + cursor: page.cursor, + }; + res.json(dto); + return; + } + + const order = path.match(/^orders\/([^/]+)$/); + if (req.method === "GET" && order) { + const found = await getOrder(order[1]); + if (!found || found.uid !== uid) { + res.status(404).json({ error: "ORDER_NOT_FOUND" }); + return; + } + res.json(toOrderDto(found.id, found)); + return; + } + + const cancel = path.match(/^orders\/([^/]+)\/cancel$/); + if (req.method === "POST" && cancel) { + const dto: CancelOrderDto = await cancelOrder(uid, cancel[1]); + res.json(dto); + return; + } + + res.status(404).json({ error: "not found" }); + } catch (err) { + sendError(res, err); + } +}); diff --git a/src/handlers/statsHandlers.ts b/src/handlers/statsHandlers.ts index 2b2af0e..8d1059a 100644 --- a/src/handlers/statsHandlers.ts +++ b/src/handlers/statsHandlers.ts @@ -2,6 +2,7 @@ import { onRequest } from "firebase-functions/https"; import { requireAuth } from "../middleware/auth"; import { sendError } from "../middleware/errors"; import { getStats, getHistory } from "../services/statsService"; +import type { UserStatsDto, VoteHistoryDto } from "../types/dto/statsDto"; export const stats = onRequest(async (req, res) => { const segs = req.path.replace(/^\/+|\/+$/g, "").split("/"); @@ -12,14 +13,14 @@ export const stats = onRequest(async (req, res) => { if (tail === "history" && req.method === "GET") { const date = String(req.query.date ?? ""); - const result = await getHistory(uid, date); + const result: VoteHistoryDto = await getHistory(uid, date); res.status(200).json(result); return; } if ((tail === "stats" || segs.length === 1) && req.method === "GET") { const period = req.query.period ? String(req.query.period) : undefined; - const result = await getStats(uid, period); + const result: UserStatsDto = await getStats(uid, period); res.status(200).json(result); return; } diff --git a/src/handlers/userHandlers.ts b/src/handlers/userHandlers.ts index 4c4b25c..826873a 100644 --- a/src/handlers/userHandlers.ts +++ b/src/handlers/userHandlers.ts @@ -9,6 +9,28 @@ import { updateMe, updateNotifications, } from "../services/userService"; +import type { + MeResponseDto, + NotificationsResponseDto, + UserProfileDto, +} from "../types/dto/userDto"; + +/** `uid` + 프로필 DTO를 `MeResponseDto`로 조립한다. 필드를 하나하나 명시해 새 필드 누락을 방지한다. */ +function toMeResponse(uid: string, profile: UserProfileDto): MeResponseDto { + return { + user: { + uid, + displayName: profile.displayName, + email: profile.email, + photoUrl: profile.photoUrl, + provider: profile.provider, + favoriteTeamCode: profile.favoriteTeamCode, + knowledgeLevel: profile.knowledgeLevel, + createdAt: profile.createdAt, + lastJudgedDate: profile.lastJudgedDate, + }, + }; +} export const user = onRequest(async (req, res) => { try { @@ -21,25 +43,26 @@ export const user = onRequest(async (req, res) => { if (req.method === "GET" && req.path === "/") { const token = await requireAuthToken(req); const u = await getMe(token); - res.status(200).json({ user: { uid: token.uid, ...u } }); + res.status(200).json(toMeResponse(token.uid, u)); return; } if (req.method === "POST" && req.path === "/") { const token = await requireAuthToken(req); const u = await createMe(token, req.body ?? {}); - res.status(201).json({ user: { uid: token.uid, ...u } }); + res.status(201).json(toMeResponse(token.uid, u)); return; } if (req.method === "PATCH" && req.path === "/") { const token = await requireAuthToken(req); const u = await updateMe(token, req.body ?? {}); - res.status(200).json({ user: { uid: token.uid, ...u } }); + res.status(200).json(toMeResponse(token.uid, u)); return; } if (req.method === "PATCH" && req.path === "/notifications") { const token = await requireAuthToken(req); const notifications = await updateNotifications(token, req.body ?? {}); - res.status(200).json({ notifications }); + const body: NotificationsResponseDto = { notifications }; + res.status(200).json(body); return; } if (req.method === "DELETE" && req.path === "/") { diff --git a/src/repositories/chatRepository.ts b/src/repositories/chatRepository.ts index 09d09c0..12e5ae9 100644 --- a/src/repositories/chatRepository.ts +++ b/src/repositories/chatRepository.ts @@ -212,9 +212,13 @@ export async function reserveRequestTx(params: ReserveParams): Promise 128) throw new HttpError(400, "invalid clientIdempotencyKey", "INVALID_INPUT"); return v; } function insert(days: number[], day: number) { return [...days, day].sort((a, b) => a - b); } -export async function checkIn(token: DecodedIdToken, body: CheckInBody): Promise { +/** + * `serverNow`를 UTC ISO 문자열로 정규화한다. Firestore에 저장된 `lastResult.serverNow`는 + * admin Timestamp 인스턴스이지만, 향후 저장 포맷 변경 등에 방어적으로 문자열도 그대로 통과시킨다. + */ +function serverNowIso(v: Timestamp | string): string { return typeof v === "string" ? v : toIso(v); } + +/** 내부 `AttendanceCheckInResult`(Timestamp 보유)를 응답용 `CheckInDto`(ISO 문자열)로 변환한다. */ +function toCheckInDto(r: AttendanceCheckInResult): CheckInDto { + return { + result: r.result, + serverNow: serverNowIso(r.serverNow), + attendedDays: r.attendedDays, + totalCount: r.totalCount, + pointsAwarded: r.pointsAwarded, + balanceAfter: r.balanceAfter, + attendanceStreak: r.attendanceStreak, + }; +} + +export async function checkIn(token: DecodedIdToken, body: CheckInBody): Promise { const clientAt = attempted(body.clientAttemptedAt); const idem = key(body.clientIdempotencyKey); const nowDate = new Date(); - if (Math.abs(clientAt.getTime() - nowDate.getTime()) > SKEW) throw new HttpError(409, "client clock differs from server by more than 5 minutes", "CLOCK_SKEW", { serverNow: Timestamp.fromDate(nowDate) }); + if (Math.abs(clientAt.getTime() - nowDate.getTime()) > SKEW) throw new HttpError(409, "client clock differs from server by more than 5 minutes", "CLOCK_SKEW", { serverNow: nowDate.toISOString() }); const today = toDateString(nowDate); const month = monthOf(today); const day = Number(today.slice(8)); return firestore.runTransaction(async (tx) => { const monthDoc = await getMonthDocTx(tx, token.uid, month); const state = await getStateDocTx(tx, token.uid); - if (monthDoc?.lastIdempotencyKey === idem && monthDoc.lastResult) return monthDoc.lastResult; + if (monthDoc?.lastIdempotencyKey === idem && monthDoc.lastResult) return toCheckInDto(monthDoc.lastResult); const days = monthDoc?.days ?? []; - if (days.includes(day)) { const wallet = await getWalletTx(tx, token.uid); return { result: AttendanceResult.AlreadyCheckedIn, serverNow: Timestamp.fromDate(nowDate), attendedDays: days, totalCount: days.length, pointsAwarded: [], balanceAfter: wallet?.availableBalance ?? 0, attendanceStreak: state?.currentAttendanceStreak ?? 0 }; } + if (days.includes(day)) { const wallet = await getWalletTx(tx, token.uid); return { result: AttendanceResult.AlreadyCheckedIn, serverNow: nowDate.toISOString(), attendedDays: days, totalCount: days.length, pointsAwarded: [], balanceAfter: wallet?.availableBalance ?? 0, attendanceStreak: state?.currentAttendanceStreak ?? 0 }; } const consecutive = state && addDays(state.lastAttendanceDate, 1) === today; const streak = consecutive ? state.currentAttendanceStreak + 1 : 1; const cycle = consecutive ? state.streakCycleStart : today; const awards: PointAward[] = [{ type: PointLedgerType.AttendanceDaily, amount: ATTENDANCE_DAILY_POINTS }]; @@ -33,7 +54,7 @@ export async function checkIn(token: DecodedIdToken, body: CheckInBody): Promise const wallet = await applyPointChangesTx(tx, token.uid, changes); const newDays = insert(days, day); const result: AttendanceCheckInResult = { result: AttendanceResult.CheckedIn, serverNow: Timestamp.fromDate(nowDate), attendedDays: newDays, totalCount: newDays.length, pointsAwarded: awards, balanceAfter: wallet!.availableBalance, attendanceStreak: streak }; const next: AttendanceStateDoc = { lastAttendanceDate: today, currentAttendanceStreak: streak, streakCycleStart: cycle, highestAttendanceStreak: Math.max(state?.highestAttendanceStreak ?? 0, streak), updatedAt: Timestamp.fromDate(nowDate) }; - tx.set(stateDocRef(token.uid), next); tx.set(monthDocRef(token.uid, month), { days: newDays, lastCheckedInAt: Timestamp.fromDate(nowDate), lastIdempotencyKey: idem, lastResult: result }, { merge: true }); return result; + tx.set(stateDocRef(token.uid), next); tx.set(monthDocRef(token.uid, month), { days: newDays, lastCheckedInAt: Timestamp.fromDate(nowDate), lastIdempotencyKey: idem, lastResult: result }, { merge: true }); return toCheckInDto(result); }); } -export async function getMonth(token: DecodedIdToken, raw: unknown): Promise { if (!isMonthString(raw)) throw new HttpError(400, "month must be YYYY-MM", "INVALID_INPUT"); const [doc, balance] = await Promise.all([getMonthDoc(token.uid, raw), getAvailableBalance(token.uid)]); if (!doc) throw new HttpError(404, "month not found", "MONTH_NOT_FOUND"); return { month: raw, attendedDays: doc.days, totalCount: doc.days.length, balance }; } +export async function getMonth(token: DecodedIdToken, raw: unknown): Promise { if (!isMonthString(raw)) throw new HttpError(400, "month must be YYYY-MM", "INVALID_INPUT"); const [doc, balance] = await Promise.all([getMonthDoc(token.uid, raw), getAvailableBalance(token.uid)]); if (!doc) throw new HttpError(404, "month not found", "MONTH_NOT_FOUND"); return { month: raw, attendedDays: doc.days, totalCount: doc.days.length, balance }; } diff --git a/src/services/chatService.ts b/src/services/chatService.ts index 37227c9..209b0cf 100644 --- a/src/services/chatService.ts +++ b/src/services/chatService.ts @@ -40,6 +40,7 @@ import { CHAT_REPORT_REASONS, type ChatConfig, type ChatMessagesPage, type ChatM type ChatSuggestionsView, type ChatToolCallInfo, type NavAction } from "../types/chat"; import type { User } from "../types/panit"; import { todayKst, type DateString } from "../types/dateString"; +import { toIso } from "../types/dto/iso"; /** * AI 채팅(짹) 서비스 — `POST /chat/messages` 11단계 처리(§3.1)와 부속 조회. @@ -47,12 +48,6 @@ import { todayKst, type DateString } from "../types/dateString"; const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -/** KST ISO 8601(+09:00) 포맷. */ -export function toKstIso(ts: Timestamp): string { - const kst = new Date(ts.toMillis() + 9 * 3600 * 1000); - return kst.toISOString().replace(/\.\d{3}Z$/, "+09:00"); -} - /** 활성 threadId 결정(§3 스레드 결정 규칙) — 클라이언트는 스레드를 지정하지 않는다. */ function resolveThreadId(user: User | null): string { return resolveTeamCode(user?.favoriteTeamCode) ?? "default"; @@ -101,7 +96,7 @@ async function buildSendResult( crisis, remainingCount: Math.max(0, config.dailyLimit - used), limit: config.dailyLimit, - createdAt: toKstIso(createdAt), + createdAt: toIso(createdAt), ...(toolCalls && toolCalls.length > 0 ? { toolCalls: withToolLabels(toolCalls) } : {}), ...(actions && actions.length > 0 ? { actions } : {}), }; @@ -472,7 +467,7 @@ export async function getMessages( role: m.role, content: m.content, crisis: m.crisis, - createdAt: toKstIso(m.createdAt), + createdAt: toIso(m.createdAt), ...(m.role === "user" && m.clientMessageId ? { clientMessageId: m.clientMessageId } : {}), ...(m.role === "assistant" && m.toolCalls?.length ? { toolCalls: withToolLabels(m.toolCalls) } : {}), ...(m.role === "assistant" && m.actions?.length ? { actions: m.actions } : {}), diff --git a/src/services/predictionService.ts b/src/services/predictionService.ts index 4a55e76..d992580 100644 --- a/src/services/predictionService.ts +++ b/src/services/predictionService.ts @@ -1,5 +1,5 @@ import { HttpError } from "../middleware/errors"; -import { getGame, listByDate, type GameWithId } from "../repositories/gameRepository"; +import { getGame, listByDate } from "../repositories/gameRepository"; import { getUserVote, submitVote, @@ -7,9 +7,16 @@ import { getCounts, getUserDateVotes, } from "../repositories/voteRepository"; -import { DRAW_TEAM_CODE, type Game, type VoteEntry, type VoteSide } from "../types/panit"; +import { DRAW_TEAM_CODE, type Game, type VoteSide } from "../types/panit"; import { fromTimestamp, parseDateString, type DateString } from "../types/dateString"; import { MemCache } from "../lib/memCache"; +import { + toGameDto, + type GameDto, + type MyVotesDto, + type PredictionMutationDto, + type VoteSummaryDto, +} from "../types/dto/predictionDto"; type SummaryCounts = { homeCount: number; awayCount: number; drawCount: number }; @@ -38,7 +45,7 @@ async function loadWaitingGame(gameId: string): Promise { export async function createPrediction( uid: string, body: { gameId?: string; selectedTeamCode?: string } -): Promise<{ ok: true }> { +): Promise { if (!body.gameId || !body.selectedTeamCode) { throw new HttpError(400, "gameId and selectedTeamCode required"); } @@ -62,7 +69,7 @@ export async function createPrediction( export async function updatePrediction( uid: string, body: { gameId?: string; selectedTeamCode?: string } -): Promise<{ ok: true; changed: boolean }> { +): Promise { if (!body.gameId || !body.selectedTeamCode) { throw new HttpError(400, "gameId and selectedTeamCode required"); } @@ -90,7 +97,7 @@ export async function updatePrediction( export async function getMyVotes( uid: string, date: string -): Promise> { +): Promise { try { return getUserDateVotes(uid, parseDateString(date)); } catch (err) { @@ -98,17 +105,16 @@ export async function getMyVotes( } } -export async function listGamesByDate(date: string): Promise { +export async function listGamesByDate(date: string): Promise { try { - return await listByDate(parseDateString(date)); + const games = await listByDate(parseDateString(date)); + return games.map(toGameDto); } catch (err) { throw new HttpError(400, (err as Error).message); } } -export async function getSummary( - gameId: string -): Promise<{ homeCount: number; awayCount: number; drawCount: number }> { +export async function getSummary(gameId: string): Promise { if (!gameId) throw new HttpError(400, "gameId required"); return summaryCache.getOrFetch(gameId, () => getCounts(gameId)); } diff --git a/src/services/rewardCatalogService.ts b/src/services/rewardCatalogService.ts index aa15ded..743e529 100644 --- a/src/services/rewardCatalogService.ts +++ b/src/services/rewardCatalogService.ts @@ -1,7 +1,10 @@ import { MemCache } from "../lib/memCache"; import { getProduct, listProducts } from "../repositories/productRepository"; +import type { ProductDetailDto, ProductSummaryDto } from "../types/dto/rewardDto"; -const cache = new MemCache(60_000, 100); +// 목록과 상세는 노출 필드가 달라 캐시를 분리한다 (하나의 캐시에 담으면 값 타입이 unknown 으로 뭉개진다). +const listCache = new MemCache(60_000, 1); +const detailCache = new MemCache(60_000, 100); function catalogSummary +export async function getCatalog(): Promise { + return listCache.getOrFetch("list", async () => (await listProducts()) .sort((a, b) => a.displayOrder - b.displayOrder) .map(catalogSummary) ); } -export async function getCatalogProduct(id: string) { - return cache.getOrFetch(`product:${id}`, async () => { +export async function getCatalogProduct(id: string): Promise { + return detailCache.getOrFetch(`product:${id}`, async () => { const product = await getProduct(id); if (!product || !product.active || !product.redeemable) return null; return { @@ -43,6 +46,6 @@ export async function getCatalogProduct(id: string) { } export function invalidateRewardCatalog(productId?: string) { - cache.delete("list"); - if (productId) cache.delete(`product:${productId}`); + listCache.delete("list"); + if (productId) detailCache.delete(`product:${productId}`); } diff --git a/src/services/statsService.ts b/src/services/statsService.ts index a807b46..1158d73 100644 --- a/src/services/statsService.ts +++ b/src/services/statsService.ts @@ -6,6 +6,8 @@ import {getAll, getDay} from "../repositories/voteHistoryRepository"; import {getUser} from "../repositories/userRepository"; import {hasMissedGameDayBetween} from "./judgmentService"; import type {DailyJudgment, StatsResponse, VoteHistoryDoc} from "../types/panit"; +import {EMPTY_VOTE_HISTORY_DTO, toVoteHistoryDto} from "../types/dto/statsDto"; +import type {UserStatsDto, VoteHistoryDto} from "../types/dto/statsDto"; import { addDays, parseDateString, @@ -245,7 +247,7 @@ function cachePath(uid: string, key: string): string { * @param uid - 유저 ID * @param periodParam - 기간 문자열 (`"2026"`, `"2026-04"`, `"2026-04-23"` 등). 생략 시 전체. */ -export async function getStats(uid: string, periodParam?: string): Promise { +export async function getStats(uid: string, periodParam?: string): Promise { const period = parsePeriod(periodParam); const key = periodParam && periodParam !== "current" ? periodParam : "current"; @@ -288,7 +290,7 @@ export async function invalidateStats(uid: string): Promise { * @param date - 조회할 날짜 (`YYYY-MM-DD`) * @throws {HttpError} 400 — 날짜 형식이 올바르지 않을 때 */ -export async function getHistory(uid: string, date: string): Promise { +export async function getHistory(uid: string, date: string): Promise { let parsed; try { parsed = parseDateString(date); @@ -296,5 +298,6 @@ export async function getHistory(uid: string, date: string): Promise { +export async function getMe(token: DecodedIdToken): Promise { const user = await getUser(token.uid); // 비활성화(탈퇴) 계정은 미존재로 취급 — 잔여 토큰으로 접근해도 온보딩으로 유도. if (!user || user.active === false) { @@ -142,7 +144,7 @@ export interface CreateMeBody { export async function createMe( token: DecodedIdToken, body: CreateMeBody -): Promise { +): Promise { const existing = await getUser(token.uid); if (existing) { throw new HttpError(409, "user already exists", "USER_ALREADY_EXISTS"); @@ -183,12 +185,12 @@ export async function createMe( // 방금 쓴 값으로 응답을 합성한다(쓰기 후 2차 getUser 제거). `createdAt`은 저장본이 // serverTimestamp로 기록되므로 응답에는 근사치(now)를 싣는다 — 이후 getMe가 저장본 반영. - const profile: UserProfile = { + const profile: UserProfileDto = { displayName, email: token.email, provider, knowledgeLevel, - createdAt: Timestamp.now(), + createdAt: toIso(Timestamp.now()), }; if (token.picture) profile.photoUrl = token.picture; if (favoriteTeamCode) profile.favoriteTeamCode = favoriteTeamCode; @@ -297,7 +299,7 @@ export interface UpdateMeBody { export async function updateMe( token: DecodedIdToken, body: UpdateMeBody -): Promise { +): Promise { const user = await getUser(token.uid); if (!user || user.active === false) { throw new HttpError(404, "user not found", "USER_NOT_FOUND"); @@ -425,7 +427,7 @@ export async function updateNotifications( export async function checkNickname( token: DecodedIdToken, displayNameRaw: unknown -): Promise<{ available: true; previousReservation: string | null }> { +): Promise { const displayName = parseDisplayName(displayNameRaw); const existing = await getUser(token.uid); diff --git a/src/types/dto/attendanceDto.ts b/src/types/dto/attendanceDto.ts new file mode 100644 index 0000000..0685614 --- /dev/null +++ b/src/types/dto/attendanceDto.ts @@ -0,0 +1,26 @@ +import type { AttendanceResult, PointLedgerType } from "../panit"; + +/** 포인트 지급 항목 (응답용). */ +export interface PointAwardDto { + type: PointLedgerType; + amount: number; +} + +/** `POST /attendance/check-in` 응답. `serverNow`는 UTC ISO 8601 문자열. */ +export interface CheckInDto { + result: AttendanceResult; + serverNow: string; + attendedDays: number[]; + totalCount: number; + pointsAwarded: PointAwardDto[]; + balanceAfter: number; + attendanceStreak: number; +} + +/** `GET /attendance/month` 응답. */ +export interface AttendanceMonthDto { + month: string; + attendedDays: number[]; + totalCount: number; + balance: number; +} diff --git a/src/types/dto/chatDto.ts b/src/types/dto/chatDto.ts new file mode 100644 index 0000000..e951366 --- /dev/null +++ b/src/types/dto/chatDto.ts @@ -0,0 +1,35 @@ +import type { + ChatMessageView, + ChatMessagesPage, + ChatQuotaView, + ChatSendResult, + ChatSuggestionsView, +} from "../chat"; + +/** + * `chat` 엔드포인트(§3) 응답 DTO — 모든 날짜 필드는 UTC ISO 8601(`...Z`) 문자열이다. + * + * 실제 형태는 `types/chat.ts`의 뷰 타입과 동일하다(그쪽이 이미 응답 전용으로 + * 문자열 날짜를 쓰고 있음). 여기서는 핸들러가 라우트별로 명시적 응답 타입을 + * 참조할 수 있도록 이름을 붙여 재노출한다. + */ + +/** `GET /chat/messages`(§3.2) 메시지 항목. */ +export type ChatMessageDto = ChatMessageView; + +/** `GET /chat/messages`(§3.2) 응답. */ +export type ChatHistoryPageDto = ChatMessagesPage; + +/** `POST /chat/messages`(§3.1) 응답. */ +export type ChatSendResultDto = ChatSendResult; + +/** `GET /chat/quota`(§3.3) 응답. */ +export type ChatQuotaDto = ChatQuotaView; + +/** `GET /chat/suggestions`(§3.5) 응답. */ +export type ChatSuggestionsDto = ChatSuggestionsView; + +/** `POST /chat/messages/{messageId}/report`(§3.4) 응답. */ +export interface ChatReportDto { + reported: boolean; +} diff --git a/src/types/dto/iso.ts b/src/types/dto/iso.ts new file mode 100644 index 0000000..1a5a9c4 --- /dev/null +++ b/src/types/dto/iso.ts @@ -0,0 +1,19 @@ +import type { Timestamp } from "firebase-admin/firestore"; + +/** + * Firestore Timestamp -> UTC ISO 8601 문자열. + * + * 응답 DTO 를 조립하는 경계에서만 사용한다. Firestore 에 저장하는 값은 계속 + * Timestamp 를 쓰고, 와이어로 나가는 값만 문자열로 바꾼다. + * + * admin SDK 의 Timestamp 를 그대로 res.json 에 넘기면 `{_seconds,_nanoseconds}` + * 로 직렬화되어 클라이언트 파서가 깨진다. 오프셋 없는 순수 UTC(`...Z`)로 통일한다. + */ +export function toIso(ts: Timestamp): string { + return ts.toDate().toISOString(); +} + +/** 값이 없을 수 있는 Timestamp 필드용. 없으면 키 자체를 생략할 수 있도록 undefined 를 돌려준다. */ +export function toIsoOrUndefined(ts: Timestamp | undefined | null): string | undefined { + return ts ? toIso(ts) : undefined; +} diff --git a/src/types/dto/kboDto.ts b/src/types/dto/kboDto.ts new file mode 100644 index 0000000..c735b72 --- /dev/null +++ b/src/types/dto/kboDto.ts @@ -0,0 +1,31 @@ +import type { + GameDetail, + GameListResult, + PlayerStatsResult, + ScheduleResult, + TeamRankResult, +} from "../kbo"; + +/** + * `kbo` 엔드포인트 응답 DTO. + * + * kbo 데이터는 Firestore Timestamp가 아닌 크롤 캐시 기반 평문 문자열 + * (`YYYY-MM-DD`, `"18:30"` 등)이라 값 변환은 하지 않는다 — 여기서는 + * 기존 서비스 반환 타입을 라우트별 이름으로 재노출해 핸들러가 응답 타입을 + * 명시적으로 참조하게 할 뿐이다. + */ + +/** `GET /kbo/rank` 응답. */ +export type KboRankDto = TeamRankResult[]; + +/** `GET /kbo/schedule` 응답. */ +export type KboScheduleDto = ScheduleResult; + +/** `GET /kbo/games` 응답. */ +export type KboGamesDto = GameListResult; + +/** `GET /kbo/gameDetail` 응답. */ +export type KboGameDetailDto = GameDetail; + +/** `GET /kbo/player` 응답. */ +export type KboPlayerDto = PlayerStatsResult; diff --git a/src/types/dto/predictionDto.ts b/src/types/dto/predictionDto.ts new file mode 100644 index 0000000..ad4e3fb --- /dev/null +++ b/src/types/dto/predictionDto.ts @@ -0,0 +1,57 @@ +import { toIso } from "./iso"; +import type { Game, GameStatus, VoteEntry } from "../panit"; +import type { ScoreboardResponse } from "../scoreboard"; + +/** + * GET /prediction/games 의 개별 경기. + * `Game`(src/types/panit.ts)에서 `time`(Timestamp)만 UTC ISO 8601 문자열로 바꾼 응답 전용 형태. + */ +export interface GameDto { + gameId: string; + time: string; + stadium: string; + status: GameStatus; + homeTeamCode: string; + awayTeamCode: string; + winningTeamCode?: string; + cancelReason?: string; +} + +/** GET /prediction/games 응답. */ +export interface GamesResponseDto { + date: string; + games: GameDto[]; +} + +/** 필드를 명시적으로 나열한다 — 스프레드를 쓰면 문서에 새로 생긴 Timestamp 가 그대로 유출된다. */ +export function toGameDto(game: Game & { gameId: string }): GameDto { + return { + gameId: game.gameId, + time: toIso(game.time), + stadium: game.stadium, + status: game.status, + homeTeamCode: game.homeTeamCode, + awayTeamCode: game.awayTeamCode, + winningTeamCode: game.winningTeamCode, + cancelReason: game.cancelReason, + }; +} + +/** GET /prediction/scoreboard 응답. Timestamp 유출이 없어 기존 타입을 그대로 별칭한다. */ +export type ScoreboardDto = ScoreboardResponse; + +/** GET /prediction/summary 응답. */ +export interface VoteSummaryDto { + homeCount: number; + awayCount: number; + drawCount: number; +} + +/** GET /prediction 응답 — 특정 날짜의 내 투표 목록(`gameId` -> 투표 내용). */ +export type MyVotesDto = Record; + +/** POST/PUT /prediction 응답. POST는 `changed`가 없고, PUT은 항상 포함한다. */ +export interface PredictionMutationDto { + ok: true; + changed?: boolean; +} diff --git a/src/types/dto/rewardDto.ts b/src/types/dto/rewardDto.ts new file mode 100644 index 0000000..ec017a0 --- /dev/null +++ b/src/types/dto/rewardDto.ts @@ -0,0 +1,206 @@ +import { toIso, toIsoOrUndefined } from "./iso"; +import type { PointLedgerEntry, PointLedgerType, PointOperation, WalletDoc } from "../points"; +import type { OrderDoc, OrderStatus } from "../reward"; + +/** + * reward 응답 DTO. + * + * Firestore 문서를 그대로 res.json 에 넘기면 Timestamp 가 `{_seconds,_nanoseconds}` + * 로 나가 클라이언트 파서가 깨진다. 여기의 빌더는 필드를 하나씩 나열해 조립한다 — + * 스프레드를 쓰면 문서에 새 Timestamp 필드가 생겼을 때 타입 검사를 통과하면서 + * 그대로 유출되기 때문이다. + */ + +export interface WalletDto { + availableBalance: number; + reservedBalance: number; + totalEarned: number; + totalSpent: number; + version: number; + createdAt?: string; + updatedAt?: string; +} + +export interface LedgerEntryDto { + id: string; + txId: string; + uid: string; + type: PointLedgerType; + op: PointOperation; + amount: number; + availableBefore: number; + availableAfter: number; + reservedBefore: number; + reservedAfter: number; + relatedDate?: string; + orderId?: string; + reversalOf?: string; + adminReason?: string; + adminActor?: string; + createdAt: string; +} + +export interface LedgerPageDto { + items: LedgerEntryDto[]; + cursor: string | null; +} + +export interface OrderItemDto { + productId: string; + qty: number; + pointPrice: number; + name: string; +} + +export interface RecipientDto { + name: string; + phone: string; + address1: string; + address2?: string; + postalCode: string; + deliveryMemo?: string; +} + +export interface OrderStatusHistoryEntryDto { + status: OrderStatus; + at: string; + actor: string; +} + +export interface OrderDto { + id: string; + uid: string; + items: OrderItemDto[]; + totalPoints: number; + recipient: RecipientDto; + status: OrderStatus; + clientIdempotencyKey: string; + reserveLedgerTxIds: string[]; + statusHistory: OrderStatusHistoryEntryDto[]; + orderedAt: string; + createdAt: string; + updatedAt: string; + cancelledBy?: string; + cancelledAt?: string; + confirmedAt?: string; + refundedAt?: string; +} + +export interface OrderPageDto { + items: OrderDto[]; + cursor: string | null; +} + +export interface CreateOrderResponseDto { + order: OrderDto; + deduplicated: boolean; + availableBalance?: number; +} + +export interface CancelOrderDto { + id: string; + status: OrderStatus; +} + +export interface ProductSummaryDto { + id: string; + name: string; + pointPrice: number; + active: boolean; + redeemable: boolean; + displayOrder: number; + mainImages: string[]; +} + +export interface ProductDetailDto extends ProductSummaryDto { + detailImages: string[]; +} + +export interface EligibilityDto { + eligible: boolean; + reasons: string[]; + availableBalance: number; +} + +/** 지갑 문서가 없는 신규 유저는 잔액 0 으로 응답한다 (기존 동작 유지). */ +export const EMPTY_WALLET_DTO: WalletDto = { + availableBalance: 0, + reservedBalance: 0, + totalEarned: 0, + totalSpent: 0, + version: 0, +}; + +export function toWalletDto(doc: WalletDoc): WalletDto { + return { + availableBalance: doc.availableBalance, + reservedBalance: doc.reservedBalance, + totalEarned: doc.totalEarned, + totalSpent: doc.totalSpent, + version: doc.version, + createdAt: toIsoOrUndefined(doc.createdAt), + updatedAt: toIsoOrUndefined(doc.updatedAt), + }; +} + +export function toLedgerEntryDto(id: string, entry: PointLedgerEntry): LedgerEntryDto { + return { + id, + txId: entry.txId, + uid: entry.uid, + type: entry.type, + op: entry.op, + amount: entry.amount, + availableBefore: entry.availableBefore, + availableAfter: entry.availableAfter, + reservedBefore: entry.reservedBefore, + reservedAfter: entry.reservedAfter, + relatedDate: entry.relatedDate, + orderId: entry.orderId, + reversalOf: entry.reversalOf, + adminReason: entry.adminReason, + adminActor: entry.adminActor, + createdAt: toIso(entry.createdAt), + }; +} + +/** + * 주문 DTO. POST /orders, GET /orders, GET /orders/:id 세 경로가 공유하고 + * statusHistory 중첩까지 같은 규칙을 타야 해서 이것만 함수로 뺐다. + */ +export function toOrderDto(id: string, doc: OrderDoc): OrderDto { + return { + id, + uid: doc.uid, + items: doc.items.map((item) => ({ + productId: item.productId, + qty: item.qty, + pointPrice: item.pointPrice, + name: item.name, + })), + totalPoints: doc.totalPoints, + recipient: { + name: doc.recipient.name, + phone: doc.recipient.phone, + address1: doc.recipient.address1, + address2: doc.recipient.address2, + postalCode: doc.recipient.postalCode, + deliveryMemo: doc.recipient.deliveryMemo, + }, + status: doc.status, + clientIdempotencyKey: doc.clientIdempotencyKey, + reserveLedgerTxIds: doc.reserveLedgerTxIds, + statusHistory: doc.statusHistory.map((h) => ({ + status: h.status, + at: toIso(h.at), + actor: h.actor, + })), + orderedAt: toIso(doc.orderedAt), + createdAt: toIso(doc.createdAt), + updatedAt: toIso(doc.updatedAt), + cancelledBy: doc.cancelledBy, + cancelledAt: toIsoOrUndefined(doc.cancelledAt), + confirmedAt: toIsoOrUndefined(doc.confirmedAt), + refundedAt: toIsoOrUndefined(doc.refundedAt), + }; +} diff --git a/src/types/dto/statsDto.ts b/src/types/dto/statsDto.ts new file mode 100644 index 0000000..460a7c9 --- /dev/null +++ b/src/types/dto/statsDto.ts @@ -0,0 +1,49 @@ +import { toIsoOrUndefined } from "./iso"; +import type { DailyJudgment, StatsResponse, VoteHistoryDoc } from "../panit"; + +/** GET /stats 응답. Timestamp 유출이 없어 기존 타입을 그대로 별칭한다. */ +export type UserStatsDto = StatsResponse; + +/** `VoteHistoryDto.data` 원소. */ +export interface VoteHistoryItemDto { + gameId: string; + team: string; + result?: boolean; + cancelled?: boolean; +} + +/** + * GET /stats/history 응답. + * `VoteHistoryDoc`(src/types/panit.ts)에서 `rewardSettledAt`(Timestamp)만 UTC ISO 8601 + * 문자열로 바꾼 응답 전용 형태. + */ +export interface VoteHistoryDto { + data: VoteHistoryItemDto[]; + judgment?: DailyJudgment; + correctCount?: number; + completedCount?: number; + streakAfter?: number; + rewardSettledAt?: string; + rewardTotal?: number; +} + +/** 문서가 없는 날은 빈 목록으로 응답한다 (기존 동작 유지). */ +export const EMPTY_VOTE_HISTORY_DTO: VoteHistoryDto = { data: [] }; + +/** 필드를 명시적으로 나열한다 — 스프레드를 쓰면 문서에 새로 생긴 Timestamp 가 그대로 유출된다. */ +export function toVoteHistoryDto(doc: VoteHistoryDoc): VoteHistoryDto { + return { + data: doc.data.map((v) => ({ + gameId: v.gameId, + team: v.team, + result: v.result, + cancelled: v.cancelled, + })), + judgment: doc.judgment, + correctCount: doc.correctCount, + completedCount: doc.completedCount, + streakAfter: doc.streakAfter, + rewardSettledAt: toIsoOrUndefined(doc.rewardSettledAt), + rewardTotal: doc.rewardTotal, + }; +} diff --git a/src/types/dto/userDto.ts b/src/types/dto/userDto.ts new file mode 100644 index 0000000..a2fb602 --- /dev/null +++ b/src/types/dto/userDto.ts @@ -0,0 +1,30 @@ +import type { KnowledgeLevel, NotificationsMap, Provider, TeamCode } from "../panit"; +import type { DateString } from "../dateString"; + +/** 클라이언트에 노출되는 유저 프로필 응답. `createdAt`은 UTC ISO 8601 문자열. */ +export interface UserProfileDto { + displayName: string; + email: string; + photoUrl?: string; + provider: Provider; + favoriteTeamCode?: TeamCode; + knowledgeLevel: KnowledgeLevel; + createdAt: string; + lastJudgedDate?: DateString; +} + +/** `GET/POST/PATCH /user` 공통 응답. */ +export interface MeResponseDto { + user: UserProfileDto & { uid: string }; +} + +/** `GET /user/check-nickname` 응답. */ +export interface CheckNicknameDto { + available: true; + previousReservation: string | null; +} + +/** `PATCH /user/notifications` 응답. */ +export interface NotificationsResponseDto { + notifications: NotificationsMap; +} diff --git a/tests/services/attendanceService.test.ts b/tests/services/attendanceService.test.ts index 9f787a9..e71a7b8 100644 --- a/tests/services/attendanceService.test.ts +++ b/tests/services/attendanceService.test.ts @@ -39,3 +39,38 @@ describe("attendanceService reward wallet", () => { await checkIn(token, at("2026-05-12T12:00:00+09:00", "m")); expect(await getMonth(token, "2026-05")).toMatchObject({ attendedDays: [12], totalCount: 1, balance: 20 }); }); }); + +describe("attendanceService 응답 직렬화", () => { + const UTC_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + + beforeEach(async () => { await firestore.recursiveDelete(firestore.doc(`users/${uid}`)); vi.useFakeTimers({ toFake: ["Date"] }); }); + afterEach(() => vi.useRealTimers()); + + it("신규 출석·중복 출석·멱등 replay 세 경로 모두 serverNow가 UTC ISO 문자열이다", async () => { + const fresh = await checkIn(token, at("2026-05-12T14:23:11+09:00", "iso-1")); + const replay = await checkIn(token, at("2026-05-12T14:23:11+09:00", "iso-1")); + const already = await checkIn(token, at("2026-05-12T15:00:00+09:00", "iso-2")); + + expect(fresh.result).toBe(AttendanceResult.CheckedIn); + expect(already.result).toBe(AttendanceResult.AlreadyCheckedIn); + for (const res of [fresh, replay, already]) { + expect(typeof res.serverNow).toBe("string"); + expect(res.serverNow).toMatch(UTC_ISO); + } + }); + + it("응답 JSON에 Firestore Timestamp가 남지 않는다", async () => { + const res = await checkIn(token, at("2026-05-12T14:23:11+09:00", "iso-3")); + const json = JSON.stringify(res); + expect(json).not.toContain("_seconds"); + expect(json).not.toContain("_nanoseconds"); + }); + + it("서버와 시계가 어긋나면 409 CLOCK_SKEW 바디의 serverNow도 UTC ISO 문자열이다", async () => { + vi.setSystemTime(new Date("2026-05-12T14:23:11+09:00")); + const skewed = { clientAttemptedAt: new Date("2026-05-12T15:23:11+09:00").toISOString(), clientIdempotencyKey: "skew" }; + const err = await checkIn(token, skewed).then(() => null, (e) => e); + expect(err).toMatchObject({ status: 409, code: "CLOCK_SKEW" }); + expect(err.details.serverNow).toMatch(UTC_ISO); + }); +}); diff --git a/tests/services/chatService.test.ts b/tests/services/chatService.test.ts index bd01a98..079f579 100644 --- a/tests/services/chatService.test.ts +++ b/tests/services/chatService.test.ts @@ -167,7 +167,7 @@ describe("chatService", () => { expect(result.crisis).toBe(false); expect(result.limit).toBe(10); expect(result.remainingCount).toBe(9); - expect(result.createdAt).toMatch(/\+09:00$/); + expect(result.createdAt).toMatch(/Z$/); expect(await countMessages("HH")).toBe(2); const req = await readRequest(key); @@ -360,7 +360,8 @@ describe("chatService", () => { .then(() => null, (e) => e); expect(err).toMatchObject({ status: 403, code: "LIMIT_EXCEEDED" }); expect(err.details.limit).toBe(1); - expect(err.details.resetAt).toMatch(/T00:00:00\+09:00$/); + // KST 자정 기준은 그대로, 와이어 형식만 UTC(= 전날 15:00Z) + expect(err.details.resetAt).toMatch(/T15:00:00\.000Z$/); expect((await readQuota()).used).toBe(1); }); @@ -682,7 +683,7 @@ describe("chatService", () => { expect(quota.used).toBe(0); expect(quota.limit).toBe(10); expect(quota.remaining).toBe(10); - expect(quota.resetAt).toMatch(/T00:00:00\+09:00$/); + expect(quota.resetAt).toMatch(/T15:00:00\.000Z$/); }); }); diff --git a/tests/services/userService.test.ts b/tests/services/userService.test.ts index 1675fd4..346341a 100644 --- a/tests/services/userService.test.ts +++ b/tests/services/userService.test.ts @@ -87,6 +87,28 @@ describe("userService", () => { }); }); + describe("응답 직렬화", () => { + const UTC_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + + it("createMe / getMe / updateMe 모두 createdAt이 UTC ISO 문자열이다", async () => { + const created = await createMeWithReservation(); + const fetched = await getMe(fakeToken()); + const updated = await updateMe(fakeToken(), { knowledgeLevel: "expert" }); + + for (const profile of [created, fetched, updated]) { + expect(typeof profile.createdAt).toBe("string"); + expect(profile.createdAt).toMatch(UTC_ISO); + } + }); + + it("응답 JSON에 Firestore Timestamp가 남지 않는다", async () => { + await createMeWithReservation(); + const json = JSON.stringify(await getMe(fakeToken())); + expect(json).not.toContain("_seconds"); + expect(json).not.toContain("_nanoseconds"); + }); + }); + describe("createMe", () => { it("정상 생성 시 토큰의 email/photo/provider를 사용한다", async () => { const u = await createMeWithReservation(); diff --git a/tests/types/iso.test.ts b/tests/types/iso.test.ts new file mode 100644 index 0000000..0c9089e --- /dev/null +++ b/tests/types/iso.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { Timestamp } from "firebase-admin/firestore"; +import { toIso, toIsoOrUndefined } from "../../src/types/dto/iso"; + +const UTC_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +describe("toIso", () => { + it("Timestamp 를 UTC ISO 문자열로 바꾼다", () => { + const ts = Timestamp.fromDate(new Date("2026-07-20T05:30:00.000Z")); + expect(toIso(ts)).toBe("2026-07-20T05:30:00.000Z"); + }); + + it("항상 Z 로 끝나는 UTC 형식을 낸다 (KST 오프셋 없음)", () => { + const ts = Timestamp.fromDate(new Date("2026-07-20T14:30:00+09:00")); + const iso = toIso(ts); + expect(iso).toMatch(UTC_ISO); + expect(iso).not.toContain("+09:00"); + expect(iso).toBe("2026-07-20T05:30:00.000Z"); + }); +}); + +describe("toIsoOrUndefined", () => { + it("값이 있으면 toIso 와 같은 결과를 낸다", () => { + const ts = Timestamp.fromDate(new Date("2026-01-02T03:04:05.678Z")); + expect(toIsoOrUndefined(ts)).toBe("2026-01-02T03:04:05.678Z"); + }); + + it("undefined / null 이면 undefined 를 낸다", () => { + expect(toIsoOrUndefined(undefined)).toBeUndefined(); + expect(toIsoOrUndefined(null)).toBeUndefined(); + }); + + it("undefined 필드는 JSON 직렬화에서 키가 사라진다", () => { + const dto = { at: toIsoOrUndefined(undefined) }; + expect(JSON.parse(JSON.stringify(dto))).toEqual({}); + }); +}); diff --git a/tests/types/predictionStatsDto.test.ts b/tests/types/predictionStatsDto.test.ts new file mode 100644 index 0000000..6051a6d --- /dev/null +++ b/tests/types/predictionStatsDto.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { Timestamp } from "firebase-admin/firestore"; +import { toGameDto } from "../../src/types/dto/predictionDto"; +import { EMPTY_VOTE_HISTORY_DTO, toVoteHistoryDto } from "../../src/types/dto/statsDto"; +import type { Game, VoteHistoryDoc } from "../../src/types/panit"; + +const UTC_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +const ts = (iso: string) => Timestamp.fromDate(new Date(iso)); + +function wire(dto: unknown): Record { + return JSON.parse(JSON.stringify(dto)); +} + +function expectNoTimestampLeak(dto: unknown): void { + const json = JSON.stringify(dto); + expect(json).not.toContain("_seconds"); + expect(json).not.toContain("_nanoseconds"); +} + +const game = { + gameId: "20260720LGHT0", + time: ts("2026-07-20T09:30:00.000Z"), + stadium: "잠실", + status: "scheduled", + homeTeamCode: "LG", + awayTeamCode: "HT", +} as Game & { gameId: string }; + +describe("toGameDto", () => { + it("time 을 UTC ISO 문자열로 바꾼다", () => { + const dto = toGameDto(game); + expectNoTimestampLeak(dto); + expect(dto.time).toMatch(UTC_ISO); + expect(dto.time).toBe("2026-07-20T09:30:00.000Z"); + }); + + it("경기가 끝나지 않았으면 결과 관련 키가 응답에 없다", () => { + const json = wire(toGameDto(game)); + expect(json).not.toHaveProperty("winningTeamCode"); + expect(json).not.toHaveProperty("cancelReason"); + }); + + it("문서에 없는 필드를 임의로 흘려보내지 않는다", () => { + const polluted = { ...game, internalOnly: "secret" } as Game & { gameId: string }; + expect(wire(toGameDto(polluted))).not.toHaveProperty("internalOnly"); + }); +}); + +describe("toVoteHistoryDto", () => { + const settled = { + data: [ + { gameId: "20260720LGHT0", team: "LG", result: true }, + { gameId: "20260720SSKT0", team: "SS", cancelled: true }, + ], + judgment: "success", + correctCount: 1, + completedCount: 2, + streakAfter: 3, + rewardSettledAt: ts("2026-07-21T00:15:00.000Z"), + rewardTotal: 70, + } as VoteHistoryDoc; + + it("rewardSettledAt 을 UTC ISO 문자열로 바꾼다", () => { + const dto = toVoteHistoryDto(settled); + expectNoTimestampLeak(dto); + expect(dto.rewardSettledAt).toMatch(UTC_ISO); + expect(dto.rewardSettledAt).toBe("2026-07-21T00:15:00.000Z"); + }); + + it("정산 전이면 rewardSettledAt 키가 응답에 없다", () => { + const pending = { ...settled, rewardSettledAt: undefined, rewardTotal: undefined } as VoteHistoryDoc; + const json = wire(toVoteHistoryDto(pending)); + expect(json).not.toHaveProperty("rewardSettledAt"); + expect(json).not.toHaveProperty("rewardTotal"); + expect(json.data).toHaveLength(2); + }); + + it("문서가 없는 날은 빈 목록으로 응답한다", () => { + expect(wire(EMPTY_VOTE_HISTORY_DTO)).toEqual({ data: [] }); + }); + + it("투표 항목의 미지 필드를 흘려보내지 않는다", () => { + const polluted = { + ...settled, + data: [{ gameId: "g1", team: "LG", result: true, internalOnly: "secret" }], + } as VoteHistoryDoc; + expect(JSON.stringify(toVoteHistoryDto(polluted))).not.toContain("internalOnly"); + }); +}); diff --git a/tests/types/rewardDto.test.ts b/tests/types/rewardDto.test.ts new file mode 100644 index 0000000..af03fa5 --- /dev/null +++ b/tests/types/rewardDto.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import { Timestamp } from "firebase-admin/firestore"; +import { + EMPTY_WALLET_DTO, + toLedgerEntryDto, + toOrderDto, + toWalletDto, +} from "../../src/types/dto/rewardDto"; +import { PointLedgerType, type PointLedgerEntry, type WalletDoc } from "../../src/types/points"; +import type { OrderDoc } from "../../src/types/reward"; + +const UTC_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +const ts = (iso: string) => Timestamp.fromDate(new Date(iso)); + +/** 와이어에 실제로 나가는 모양. Timestamp 유출은 여기서만 드러난다. */ +function wire(dto: unknown): Record { + return JSON.parse(JSON.stringify(dto)); +} + +/** 중첩 구조 어디에도 admin SDK Timestamp 의 내부 필드가 남아 있지 않은지 확인. */ +function expectNoTimestampLeak(dto: unknown): void { + const json = JSON.stringify(dto); + expect(json).not.toContain("_seconds"); + expect(json).not.toContain("_nanoseconds"); +} + +const wallet: WalletDoc = { + availableBalance: 1200, + reservedBalance: 300, + totalEarned: 5000, + totalSpent: 3500, + version: 7, + createdAt: ts("2026-01-02T03:04:05.678Z"), + updatedAt: ts("2026-07-20T05:30:00.000Z"), +}; + +const ledgerEntry: PointLedgerEntry = { + txId: "uid:2026-07-20:attendance_daily", + uid: "uid", + type: PointLedgerType.AttendanceDaily, + op: "credit", + amount: 20, + availableBefore: 1180, + availableAfter: 1200, + reservedBefore: 300, + reservedAfter: 300, + relatedDate: "2026-07-20", + createdAt: ts("2026-07-20T05:30:00.000Z"), +}; + +const baseOrder: OrderDoc = { + uid: "uid", + items: [{ productId: "p1", qty: 2, pointPrice: 500, name: "굿즈" }], + totalPoints: 1000, + recipient: { name: "홍길동", phone: "01000000000", address1: "서울", postalCode: "00000" }, + status: "reserved", + clientIdempotencyKey: "key-1", + reserveLedgerTxIds: ["uid:order:o1:reserve"], + orderedAt: ts("2026-07-20T05:30:00.000Z"), + statusHistory: [{ status: "reserved", at: ts("2026-07-20T05:30:00.000Z"), actor: "uid" }], + createdAt: ts("2026-07-20T05:30:00.000Z"), + updatedAt: ts("2026-07-20T05:30:00.000Z"), +}; + +describe("toWalletDto", () => { + it("날짜를 UTC ISO 문자열로 내보내고 Timestamp 를 유출하지 않는다", () => { + const dto = toWalletDto(wallet); + expectNoTimestampLeak(dto); + expect(wire(dto)).toEqual({ + availableBalance: 1200, + reservedBalance: 300, + totalEarned: 5000, + totalSpent: 3500, + version: 7, + createdAt: "2026-01-02T03:04:05.678Z", + updatedAt: "2026-07-20T05:30:00.000Z", + }); + }); + + it("지갑 문서가 없는 유저는 잔액 0 응답을 쓴다", () => { + expect(wire(EMPTY_WALLET_DTO)).toEqual({ + availableBalance: 0, + reservedBalance: 0, + totalEarned: 0, + totalSpent: 0, + version: 0, + }); + }); +}); + +describe("toLedgerEntryDto", () => { + it("createdAt 이 UTC ISO 문자열이고 문서 id 가 붙는다", () => { + const dto = toLedgerEntryDto("entry-1", ledgerEntry); + expectNoTimestampLeak(dto); + expect(dto.id).toBe("entry-1"); + expect(dto.createdAt).toMatch(UTC_ISO); + expect(dto.createdAt).toBe("2026-07-20T05:30:00.000Z"); + }); + + it("relatedDate 의 YYYY-MM-DD 형식은 그대로 둔다", () => { + expect(toLedgerEntryDto("entry-1", ledgerEntry).relatedDate).toBe("2026-07-20"); + }); + + it("선택 필드가 없으면 응답에서 키가 사라진다", () => { + const minimal: PointLedgerEntry = { ...ledgerEntry, relatedDate: undefined }; + const json = wire(toLedgerEntryDto("entry-1", minimal)); + expect(json).not.toHaveProperty("relatedDate"); + expect(json).not.toHaveProperty("orderId"); + }); +}); + +describe("toOrderDto", () => { + it("중첩된 statusHistory 까지 UTC ISO 문자열로 바꾼다", () => { + const dto = toOrderDto("o1", baseOrder); + expectNoTimestampLeak(dto); + expect(dto.orderedAt).toMatch(UTC_ISO); + expect(dto.createdAt).toMatch(UTC_ISO); + expect(dto.updatedAt).toMatch(UTC_ISO); + expect(dto.statusHistory[0].at).toMatch(UTC_ISO); + expect(dto.statusHistory[0].at).toBe("2026-07-20T05:30:00.000Z"); + }); + + it("선택 날짜 필드가 채워지면 모두 문자열로 나간다", () => { + const settled: OrderDoc = { + ...baseOrder, + status: "refunded", + cancelledBy: "admin", + cancelledAt: ts("2026-07-21T00:00:00.000Z"), + confirmedAt: ts("2026-07-22T00:00:00.000Z"), + refundedAt: ts("2026-07-23T00:00:00.000Z"), + }; + const dto = toOrderDto("o1", settled); + expectNoTimestampLeak(dto); + for (const value of [dto.cancelledAt, dto.confirmedAt, dto.refundedAt]) { + expect(value).toMatch(UTC_ISO); + } + }); + + it("선택 날짜 필드가 비면 응답에서 키가 사라진다", () => { + const json = wire(toOrderDto("o1", baseOrder)); + expect(json).not.toHaveProperty("cancelledAt"); + expect(json).not.toHaveProperty("confirmedAt"); + expect(json).not.toHaveProperty("refundedAt"); + expect(json).not.toHaveProperty("cancelledBy"); + }); + + it("문서에 없는 필드를 임의로 흘려보내지 않는다", () => { + const polluted = { ...baseOrder, internalOnly: "secret" } as OrderDoc; + expect(wire(toOrderDto("o1", polluted))).not.toHaveProperty("internalOnly"); + }); +});