Serialize API dates as UTC ISO strings via response DTOs
- Firestore Timestamp가 res.json으로 그대로 나가 {_seconds,_nanoseconds}로 직렬화되던 문제 수정
- 앱의 포인트 내역(/reward/ledger)과 예측 기록(/stats/history) 크래시 원인 제거
- 클라 소비 7개 핸들러(reward/attendance/prediction/user/stats/kbo/chat)의 모든 응답에 명시적 DTO 타입 도입
- 날짜 와이어 형식을 순수 UTC ISO 8601(...Z)로 통일 — 채팅의 기존 KST(+09:00) 출력도 UTC로 전환
- DTO는 필드를 명시적으로 나열해 조립 (스프레드 덤프 제거) — 문서에 새 Timestamp 필드가 생겨도 다시 새지 않는다
- 재사용되는 주문 형태만 toOrderDto로 분리, 나머지는 응답 경계에서 직접 조립
- Firestore 저장 형식은 변경하지 않음. 출석 idempotent 재요청 경로는 저장된 Timestamp/문자열을 모두 처리
- DTO 직렬화 회귀 테스트 추가 (_seconds 부재, UTC ISO 형식, optional 키 생략, 미지 필드 차단)
This commit is contained in:
parent
6d7635ff4a
commit
107f75c23a
@ -9,6 +9,13 @@ import {
|
|||||||
reportMessage,
|
reportMessage,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
} from "../services/chatService";
|
} from "../services/chatService";
|
||||||
|
import type {
|
||||||
|
ChatHistoryPageDto,
|
||||||
|
ChatQuotaDto,
|
||||||
|
ChatReportDto,
|
||||||
|
ChatSendResultDto,
|
||||||
|
ChatSuggestionsDto,
|
||||||
|
} from "../types/dto/chatDto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 채팅(짹) 엔드포인트(§3). `/chat/*` 전체가 인증 필요 API다.
|
* AI 채팅(짹) 엔드포인트(§3). `/chat/*` 전체가 인증 필요 API다.
|
||||||
@ -43,14 +50,14 @@ export const chat = onRequest({ timeoutSeconds: 60 }, async (req, res) => {
|
|||||||
if (segs[0] === "messages" && segs.length === 1) {
|
if (segs[0] === "messages" && segs.length === 1) {
|
||||||
const uid = await requireChatAuth(req);
|
const uid = await requireChatAuth(req);
|
||||||
if (req.method === "POST") {
|
if (req.method === "POST") {
|
||||||
const result = await sendMessage(uid, req.body ?? {});
|
const result: ChatSendResultDto = await sendMessage(uid, req.body ?? {});
|
||||||
res.status(200).json(result);
|
res.status(200).json(result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (req.method === "GET") {
|
if (req.method === "GET") {
|
||||||
const cursor = req.query.cursor != null ? String(req.query.cursor) : undefined;
|
const cursor = req.query.cursor != null ? String(req.query.cursor) : undefined;
|
||||||
const limit = req.query.limit != null ? String(req.query.limit) : 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);
|
res.status(200).json(result);
|
||||||
return;
|
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") {
|
if (segs[0] === "messages" && segs.length === 3 && segs[2] === "report" && req.method === "POST") {
|
||||||
const uid = await requireChatAuth(req);
|
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);
|
res.status(200).json(result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (segs[0] === "quota" && req.method === "GET") {
|
if (segs[0] === "quota" && req.method === "GET") {
|
||||||
const uid = await requireChatAuth(req);
|
const uid = await requireChatAuth(req);
|
||||||
const result = await getQuota(uid);
|
const result: ChatQuotaDto = await getQuota(uid);
|
||||||
res.status(200).json(result);
|
res.status(200).json(result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (segs[0] === "suggestions" && req.method === "GET") {
|
if (segs[0] === "suggestions" && req.method === "GET") {
|
||||||
const uid = await requireChatAuth(req);
|
const uid = await requireChatAuth(req);
|
||||||
const result = await getSuggestions(uid);
|
const result: ChatSuggestionsDto = await getSuggestions(uid);
|
||||||
res.status(200).json(result);
|
res.status(200).json(result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,13 @@ import { getGameList } from "../services/gameListService";
|
|||||||
import { getGameDetail } from "../services/gameDetailService";
|
import { getGameDetail } from "../services/gameDetailService";
|
||||||
import { TeamCode } from "../types/panit";
|
import { TeamCode } from "../types/panit";
|
||||||
import { kboTodayKst } from "../types/dateString";
|
import { kboTodayKst } from "../types/dateString";
|
||||||
|
import type {
|
||||||
|
KboGameDetailDto,
|
||||||
|
KboGamesDto,
|
||||||
|
KboPlayerDto,
|
||||||
|
KboRankDto,
|
||||||
|
KboScheduleDto,
|
||||||
|
} from "../types/dto/kboDto";
|
||||||
|
|
||||||
enum KboPath {
|
enum KboPath {
|
||||||
Rank = "rank",
|
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);
|
res.status(200).json(result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -94,7 +101,7 @@ export const kbo = onRequest(async (req, res) => {
|
|||||||
team = raw as TeamCode;
|
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);
|
res.status(200).json(result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -109,7 +116,7 @@ export const kbo = onRequest(async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await getGameList(date, series, league);
|
const result: KboGamesDto = await getGameList(date, series, league);
|
||||||
res.status(200).json(result);
|
res.status(200).json(result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -133,7 +140,7 @@ export const kbo = onRequest(async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await getGameDetail({ gameId, series, league, season });
|
const result: KboGameDetailDto = await getGameDetail({ gameId, series, league, season });
|
||||||
res.status(200).json(result);
|
res.status(200).json(result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -170,7 +177,7 @@ export const kbo = onRequest(async (req, res) => {
|
|||||||
team = raw as TeamCode;
|
team = raw as TeamCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await getPlayerStats({
|
const result: KboPlayerDto = await getPlayerStats({
|
||||||
type,
|
type,
|
||||||
year,
|
year,
|
||||||
team,
|
team,
|
||||||
|
|||||||
@ -10,6 +10,13 @@ import {
|
|||||||
} from "../services/predictionService";
|
} from "../services/predictionService";
|
||||||
import { getScoreboard } from "../services/scoreboardService";
|
import { getScoreboard } from "../services/scoreboardService";
|
||||||
import { kboTodayKst } from "../types/dateString";
|
import { kboTodayKst } from "../types/dateString";
|
||||||
|
import type {
|
||||||
|
GamesResponseDto,
|
||||||
|
MyVotesDto,
|
||||||
|
PredictionMutationDto,
|
||||||
|
ScoreboardDto,
|
||||||
|
VoteSummaryDto,
|
||||||
|
} from "../types/dto/predictionDto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `date` 쿼리 파라미터 해석.
|
* `date` 쿼리 파라미터 해석.
|
||||||
@ -28,7 +35,8 @@ export const prediction = onRequest(async (req, res) => {
|
|||||||
if (tail === "games" && req.method === "GET") {
|
if (tail === "games" && req.method === "GET") {
|
||||||
const date = resolveDateParam(req.query.date);
|
const date = resolveDateParam(req.query.date);
|
||||||
const games = await listGamesByDate(date);
|
const games = await listGamesByDate(date);
|
||||||
res.status(200).json({ date, games });
|
const dto: GamesResponseDto = { date, games };
|
||||||
|
res.status(200).json(dto);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,7 +46,7 @@ export const prediction = onRequest(async (req, res) => {
|
|||||||
if (type !== "team" && type !== "overall") {
|
if (type !== "team" && type !== "overall") {
|
||||||
throw new HttpError(400, `invalid type: ${type}`);
|
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.set("Cache-Control", "private, max-age=60");
|
||||||
res.status(200).json(result);
|
res.status(200).json(result);
|
||||||
return;
|
return;
|
||||||
@ -46,7 +54,7 @@ export const prediction = onRequest(async (req, res) => {
|
|||||||
|
|
||||||
if (tail === "summary" && req.method === "GET") {
|
if (tail === "summary" && req.method === "GET") {
|
||||||
const gameId = String(req.query.gameId ?? "");
|
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.set("Cache-Control", "public, max-age=5");
|
||||||
res.status(200).json(result);
|
res.status(200).json(result);
|
||||||
return;
|
return;
|
||||||
@ -55,20 +63,20 @@ export const prediction = onRequest(async (req, res) => {
|
|||||||
if (tail === "prediction" || segs.length === 1) {
|
if (tail === "prediction" || segs.length === 1) {
|
||||||
if (req.method === "POST") {
|
if (req.method === "POST") {
|
||||||
const uid = await requireAuth(req);
|
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);
|
res.status(201).json(result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (req.method === "PUT") {
|
if (req.method === "PUT") {
|
||||||
const uid = await requireAuth(req);
|
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);
|
res.status(200).json(result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (req.method === "GET") {
|
if (req.method === "GET") {
|
||||||
const uid = await requireAuth(req);
|
const uid = await requireAuth(req);
|
||||||
const date = resolveDateParam(req.query.date);
|
const date = resolveDateParam(req.query.date);
|
||||||
const result = await getMyVotes(uid, date);
|
const result: MyVotesDto = await getMyVotes(uid, date);
|
||||||
res.status(200).json(result);
|
res.status(200).json(result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,16 +6,111 @@ import { listLedger } from "../repositories/pointLedgerRepository";
|
|||||||
import { getCatalog, getCatalogProduct } from "../services/rewardCatalogService";
|
import { getCatalog, getCatalogProduct } from "../services/rewardCatalogService";
|
||||||
import { computeEligibility } from "../services/eligibilityService";
|
import { computeEligibility } from "../services/eligibilityService";
|
||||||
import { cancelOrder, createOrder, getOrder, listOrders } from "../services/orderService";
|
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, "");
|
function pageArgs(req: { query: Record<string, unknown> }): [number, string | undefined] {
|
||||||
if (req.method === "GET" && path === "wallet") { res.json((await getWallet(uid)) ?? { availableBalance: 0, reservedBalance: 0, totalEarned: 0, totalSpent: 0, version: 0 }); return; }
|
return [Number(req.query.limit) || 20, typeof req.query.cursor === "string" ? req.query.cursor : undefined];
|
||||||
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; }
|
export const reward = onRequest(async (req, res) => {
|
||||||
if (req.method === "GET" && path === "eligibility") { res.json(await computeEligibility(uid)); return; }
|
try {
|
||||||
if (req.method === "POST" && path === "orders") { res.status(201).json(await createOrder(uid, req.body)); return; }
|
const uid = await requireAuth(req);
|
||||||
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 path = req.path.replace(/^\/+|\/+$/g, "");
|
||||||
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; }
|
if (req.method === "GET" && path === "wallet") {
|
||||||
res.status(404).json({ error: "not found" });
|
const wallet = await getWallet(uid);
|
||||||
} catch (err) { sendError(res, err); } });
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { onRequest } from "firebase-functions/https";
|
|||||||
import { requireAuth } from "../middleware/auth";
|
import { requireAuth } from "../middleware/auth";
|
||||||
import { sendError } from "../middleware/errors";
|
import { sendError } from "../middleware/errors";
|
||||||
import { getStats, getHistory } from "../services/statsService";
|
import { getStats, getHistory } from "../services/statsService";
|
||||||
|
import type { UserStatsDto, VoteHistoryDto } from "../types/dto/statsDto";
|
||||||
|
|
||||||
export const stats = onRequest(async (req, res) => {
|
export const stats = onRequest(async (req, res) => {
|
||||||
const segs = req.path.replace(/^\/+|\/+$/g, "").split("/");
|
const segs = req.path.replace(/^\/+|\/+$/g, "").split("/");
|
||||||
@ -12,14 +13,14 @@ export const stats = onRequest(async (req, res) => {
|
|||||||
|
|
||||||
if (tail === "history" && req.method === "GET") {
|
if (tail === "history" && req.method === "GET") {
|
||||||
const date = String(req.query.date ?? "");
|
const date = String(req.query.date ?? "");
|
||||||
const result = await getHistory(uid, date);
|
const result: VoteHistoryDto = await getHistory(uid, date);
|
||||||
res.status(200).json(result);
|
res.status(200).json(result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((tail === "stats" || segs.length === 1) && req.method === "GET") {
|
if ((tail === "stats" || segs.length === 1) && req.method === "GET") {
|
||||||
const period = req.query.period ? String(req.query.period) : undefined;
|
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);
|
res.status(200).json(result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,28 @@ import {
|
|||||||
updateMe,
|
updateMe,
|
||||||
updateNotifications,
|
updateNotifications,
|
||||||
} from "../services/userService";
|
} 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) => {
|
export const user = onRequest(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@ -21,25 +43,26 @@ export const user = onRequest(async (req, res) => {
|
|||||||
if (req.method === "GET" && req.path === "/") {
|
if (req.method === "GET" && req.path === "/") {
|
||||||
const token = await requireAuthToken(req);
|
const token = await requireAuthToken(req);
|
||||||
const u = await getMe(token);
|
const u = await getMe(token);
|
||||||
res.status(200).json({ user: { uid: token.uid, ...u } });
|
res.status(200).json(toMeResponse(token.uid, u));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (req.method === "POST" && req.path === "/") {
|
if (req.method === "POST" && req.path === "/") {
|
||||||
const token = await requireAuthToken(req);
|
const token = await requireAuthToken(req);
|
||||||
const u = await createMe(token, req.body ?? {});
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (req.method === "PATCH" && req.path === "/") {
|
if (req.method === "PATCH" && req.path === "/") {
|
||||||
const token = await requireAuthToken(req);
|
const token = await requireAuthToken(req);
|
||||||
const u = await updateMe(token, req.body ?? {});
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (req.method === "PATCH" && req.path === "/notifications") {
|
if (req.method === "PATCH" && req.path === "/notifications") {
|
||||||
const token = await requireAuthToken(req);
|
const token = await requireAuthToken(req);
|
||||||
const notifications = await updateNotifications(token, req.body ?? {});
|
const notifications = await updateNotifications(token, req.body ?? {});
|
||||||
res.status(200).json({ notifications });
|
const body: NotificationsResponseDto = { notifications };
|
||||||
|
res.status(200).json(body);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (req.method === "DELETE" && req.path === "/") {
|
if (req.method === "DELETE" && req.path === "/") {
|
||||||
|
|||||||
@ -212,9 +212,13 @@ export async function reserveRequestTx(params: ReserveParams): Promise<ReserveOu
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** KST 자정 리셋 시각(다음 날 00:00 KST, ISO 8601). */
|
/**
|
||||||
|
* 쿼터 리셋 시각 — 다음 날 00:00 KST 를 가리키는 순간을 UTC ISO 8601 로 낸다.
|
||||||
|
*
|
||||||
|
* 기준은 여전히 KST 자정이지만, 와이어 형식은 다른 응답과 같은 `...Z` 로 통일한다.
|
||||||
|
*/
|
||||||
export function kstResetAt(date: DateString): string {
|
export function kstResetAt(date: DateString): string {
|
||||||
return `${addDays(date, 1)}T00:00:00+09:00`;
|
return new Date(`${addDays(date, 1)}T00:00:00+09:00`).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 차감 복원(§3.1 복원 규칙) ──
|
// ── 차감 복원(§3.1 복원 규칙) ──
|
||||||
|
|||||||
@ -6,8 +6,10 @@ import { HttpError } from "../middleware/errors";
|
|||||||
import { getMonthDoc, getMonthDocTx, getStateDocTx, monthDocRef, stateDocRef } from "../repositories/attendanceRepository";
|
import { getMonthDoc, getMonthDocTx, getStateDocTx, monthDocRef, stateDocRef } from "../repositories/attendanceRepository";
|
||||||
import { getAvailableBalance, getWalletTx } from "../repositories/walletRepository";
|
import { getAvailableBalance, getWalletTx } from "../repositories/walletRepository";
|
||||||
import { applyPointChangesTx } from "./pointService";
|
import { applyPointChangesTx } from "./pointService";
|
||||||
import { AttendanceResult, PointLedgerType, type AttendanceCheckInResult, type AttendanceMonth, type AttendanceStateDoc, type PointAward } from "../types/panit";
|
import { AttendanceResult, PointLedgerType, type AttendanceCheckInResult, type AttendanceStateDoc, type PointAward } from "../types/panit";
|
||||||
import { addDays, isMonthString, monthOf, toDateString } from "../types/dateString";
|
import { addDays, isMonthString, monthOf, toDateString } from "../types/dateString";
|
||||||
|
import { toIso } from "../types/dto/iso";
|
||||||
|
import type { AttendanceMonthDto, CheckInDto } from "../types/dto/attendanceDto";
|
||||||
|
|
||||||
const SKEW = 5 * 60 * 1000; const TZ = /(Z|[+-]\d{2}:?\d{2})$/;
|
const SKEW = 5 * 60 * 1000; const TZ = /(Z|[+-]\d{2}:?\d{2})$/;
|
||||||
export interface CheckInBody { clientAttemptedAt?: unknown; clientIdempotencyKey?: unknown }
|
export interface CheckInBody { clientAttemptedAt?: unknown; clientIdempotencyKey?: unknown }
|
||||||
@ -15,15 +17,34 @@ function attempted(v: unknown) { if (typeof v !== "string" || !TZ.test(v) || Num
|
|||||||
function key(v: unknown) { if (typeof v !== "string" || v.length < 1 || v.length > 128) throw new HttpError(400, "invalid clientIdempotencyKey", "INVALID_INPUT"); return v; }
|
function key(v: unknown) { if (typeof v !== "string" || v.length < 1 || v.length > 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); }
|
function insert(days: number[], day: number) { return [...days, day].sort((a, b) => a - b); }
|
||||||
|
|
||||||
export async function checkIn(token: DecodedIdToken, body: CheckInBody): Promise<AttendanceCheckInResult> {
|
/**
|
||||||
|
* `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<CheckInDto> {
|
||||||
const clientAt = attempted(body.clientAttemptedAt); const idem = key(body.clientIdempotencyKey); const nowDate = new Date();
|
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));
|
const today = toDateString(nowDate); const month = monthOf(today); const day = Number(today.slice(8));
|
||||||
return firestore.runTransaction(async (tx) => {
|
return firestore.runTransaction(async (tx) => {
|
||||||
const monthDoc = await getMonthDocTx(tx, token.uid, month); const state = await getStateDocTx(tx, token.uid);
|
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 ?? [];
|
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 consecutive = state && addDays(state.lastAttendanceDate, 1) === today;
|
||||||
const streak = consecutive ? state.currentAttendanceStreak + 1 : 1; const cycle = consecutive ? state.streakCycleStart : today;
|
const streak = consecutive ? state.currentAttendanceStreak + 1 : 1; const cycle = consecutive ? state.streakCycleStart : today;
|
||||||
const awards: PointAward[] = [{ type: PointLedgerType.AttendanceDaily, amount: ATTENDANCE_DAILY_POINTS }];
|
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 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 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) };
|
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<AttendanceMonth> { 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<AttendanceMonthDto> { 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 }; }
|
||||||
|
|||||||
@ -40,6 +40,7 @@ import { CHAT_REPORT_REASONS, type ChatConfig, type ChatMessagesPage, type ChatM
|
|||||||
type ChatSuggestionsView, type ChatToolCallInfo, type NavAction } from "../types/chat";
|
type ChatSuggestionsView, type ChatToolCallInfo, type NavAction } from "../types/chat";
|
||||||
import type { User } from "../types/panit";
|
import type { User } from "../types/panit";
|
||||||
import { todayKst, type DateString } from "../types/dateString";
|
import { todayKst, type DateString } from "../types/dateString";
|
||||||
|
import { toIso } from "../types/dto/iso";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 채팅(짹) 서비스 — `POST /chat/messages` 11단계 처리(§3.1)와 부속 조회.
|
* 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;
|
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 스레드 결정 규칙) — 클라이언트는 스레드를 지정하지 않는다. */
|
/** 활성 threadId 결정(§3 스레드 결정 규칙) — 클라이언트는 스레드를 지정하지 않는다. */
|
||||||
function resolveThreadId(user: User | null): string {
|
function resolveThreadId(user: User | null): string {
|
||||||
return resolveTeamCode(user?.favoriteTeamCode) ?? "default";
|
return resolveTeamCode(user?.favoriteTeamCode) ?? "default";
|
||||||
@ -101,7 +96,7 @@ async function buildSendResult(
|
|||||||
crisis,
|
crisis,
|
||||||
remainingCount: Math.max(0, config.dailyLimit - used),
|
remainingCount: Math.max(0, config.dailyLimit - used),
|
||||||
limit: config.dailyLimit,
|
limit: config.dailyLimit,
|
||||||
createdAt: toKstIso(createdAt),
|
createdAt: toIso(createdAt),
|
||||||
...(toolCalls && toolCalls.length > 0 ? { toolCalls: withToolLabels(toolCalls) } : {}),
|
...(toolCalls && toolCalls.length > 0 ? { toolCalls: withToolLabels(toolCalls) } : {}),
|
||||||
...(actions && actions.length > 0 ? { actions } : {}),
|
...(actions && actions.length > 0 ? { actions } : {}),
|
||||||
};
|
};
|
||||||
@ -472,7 +467,7 @@ export async function getMessages(
|
|||||||
role: m.role,
|
role: m.role,
|
||||||
content: m.content,
|
content: m.content,
|
||||||
crisis: m.crisis,
|
crisis: m.crisis,
|
||||||
createdAt: toKstIso(m.createdAt),
|
createdAt: toIso(m.createdAt),
|
||||||
...(m.role === "user" && m.clientMessageId ? { clientMessageId: m.clientMessageId } : {}),
|
...(m.role === "user" && m.clientMessageId ? { clientMessageId: m.clientMessageId } : {}),
|
||||||
...(m.role === "assistant" && m.toolCalls?.length ? { toolCalls: withToolLabels(m.toolCalls) } : {}),
|
...(m.role === "assistant" && m.toolCalls?.length ? { toolCalls: withToolLabels(m.toolCalls) } : {}),
|
||||||
...(m.role === "assistant" && m.actions?.length ? { actions: m.actions } : {}),
|
...(m.role === "assistant" && m.actions?.length ? { actions: m.actions } : {}),
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { HttpError } from "../middleware/errors";
|
import { HttpError } from "../middleware/errors";
|
||||||
import { getGame, listByDate, type GameWithId } from "../repositories/gameRepository";
|
import { getGame, listByDate } from "../repositories/gameRepository";
|
||||||
import {
|
import {
|
||||||
getUserVote,
|
getUserVote,
|
||||||
submitVote,
|
submitVote,
|
||||||
@ -7,9 +7,16 @@ import {
|
|||||||
getCounts,
|
getCounts,
|
||||||
getUserDateVotes,
|
getUserDateVotes,
|
||||||
} from "../repositories/voteRepository";
|
} 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 { fromTimestamp, parseDateString, type DateString } from "../types/dateString";
|
||||||
import { MemCache } from "../lib/memCache";
|
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 };
|
type SummaryCounts = { homeCount: number; awayCount: number; drawCount: number };
|
||||||
|
|
||||||
@ -38,7 +45,7 @@ async function loadWaitingGame(gameId: string): Promise<Game> {
|
|||||||
export async function createPrediction(
|
export async function createPrediction(
|
||||||
uid: string,
|
uid: string,
|
||||||
body: { gameId?: string; selectedTeamCode?: string }
|
body: { gameId?: string; selectedTeamCode?: string }
|
||||||
): Promise<{ ok: true }> {
|
): Promise<PredictionMutationDto> {
|
||||||
if (!body.gameId || !body.selectedTeamCode) {
|
if (!body.gameId || !body.selectedTeamCode) {
|
||||||
throw new HttpError(400, "gameId and selectedTeamCode required");
|
throw new HttpError(400, "gameId and selectedTeamCode required");
|
||||||
}
|
}
|
||||||
@ -62,7 +69,7 @@ export async function createPrediction(
|
|||||||
export async function updatePrediction(
|
export async function updatePrediction(
|
||||||
uid: string,
|
uid: string,
|
||||||
body: { gameId?: string; selectedTeamCode?: string }
|
body: { gameId?: string; selectedTeamCode?: string }
|
||||||
): Promise<{ ok: true; changed: boolean }> {
|
): Promise<PredictionMutationDto> {
|
||||||
if (!body.gameId || !body.selectedTeamCode) {
|
if (!body.gameId || !body.selectedTeamCode) {
|
||||||
throw new HttpError(400, "gameId and selectedTeamCode required");
|
throw new HttpError(400, "gameId and selectedTeamCode required");
|
||||||
}
|
}
|
||||||
@ -90,7 +97,7 @@ export async function updatePrediction(
|
|||||||
export async function getMyVotes(
|
export async function getMyVotes(
|
||||||
uid: string,
|
uid: string,
|
||||||
date: string
|
date: string
|
||||||
): Promise<Record<string, VoteEntry>> {
|
): Promise<MyVotesDto> {
|
||||||
try {
|
try {
|
||||||
return getUserDateVotes(uid, parseDateString(date));
|
return getUserDateVotes(uid, parseDateString(date));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -98,17 +105,16 @@ export async function getMyVotes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listGamesByDate(date: string): Promise<GameWithId[]> {
|
export async function listGamesByDate(date: string): Promise<GameDto[]> {
|
||||||
try {
|
try {
|
||||||
return await listByDate(parseDateString(date));
|
const games = await listByDate(parseDateString(date));
|
||||||
|
return games.map(toGameDto);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new HttpError(400, (err as Error).message);
|
throw new HttpError(400, (err as Error).message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSummary(
|
export async function getSummary(gameId: string): Promise<VoteSummaryDto> {
|
||||||
gameId: string
|
|
||||||
): Promise<{ homeCount: number; awayCount: number; drawCount: number }> {
|
|
||||||
if (!gameId) throw new HttpError(400, "gameId required");
|
if (!gameId) throw new HttpError(400, "gameId required");
|
||||||
return summaryCache.getOrFetch(gameId, () => getCounts(gameId));
|
return summaryCache.getOrFetch(gameId, () => getCounts(gameId));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
import { MemCache } from "../lib/memCache";
|
import { MemCache } from "../lib/memCache";
|
||||||
import { getProduct, listProducts } from "../repositories/productRepository";
|
import { getProduct, listProducts } from "../repositories/productRepository";
|
||||||
|
import type { ProductDetailDto, ProductSummaryDto } from "../types/dto/rewardDto";
|
||||||
|
|
||||||
const cache = new MemCache<unknown>(60_000, 100);
|
// 목록과 상세는 노출 필드가 달라 캐시를 분리한다 (하나의 캐시에 담으면 값 타입이 unknown 으로 뭉개진다).
|
||||||
|
const listCache = new MemCache<ProductSummaryDto[]>(60_000, 1);
|
||||||
|
const detailCache = new MemCache<ProductDetailDto | null>(60_000, 100);
|
||||||
|
|
||||||
function catalogSummary<T extends {
|
function catalogSummary<T extends {
|
||||||
id: string;
|
id: string;
|
||||||
@ -23,16 +26,16 @@ function catalogSummary<T extends {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCatalog() {
|
export async function getCatalog(): Promise<ProductSummaryDto[]> {
|
||||||
return cache.getOrFetch("list", async () =>
|
return listCache.getOrFetch("list", async () =>
|
||||||
(await listProducts())
|
(await listProducts())
|
||||||
.sort((a, b) => a.displayOrder - b.displayOrder)
|
.sort((a, b) => a.displayOrder - b.displayOrder)
|
||||||
.map(catalogSummary)
|
.map(catalogSummary)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCatalogProduct(id: string) {
|
export async function getCatalogProduct(id: string): Promise<ProductDetailDto | null> {
|
||||||
return cache.getOrFetch(`product:${id}`, async () => {
|
return detailCache.getOrFetch(`product:${id}`, async () => {
|
||||||
const product = await getProduct(id);
|
const product = await getProduct(id);
|
||||||
if (!product || !product.active || !product.redeemable) return null;
|
if (!product || !product.active || !product.redeemable) return null;
|
||||||
return {
|
return {
|
||||||
@ -43,6 +46,6 @@ export async function getCatalogProduct(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function invalidateRewardCatalog(productId?: string) {
|
export function invalidateRewardCatalog(productId?: string) {
|
||||||
cache.delete("list");
|
listCache.delete("list");
|
||||||
if (productId) cache.delete(`product:${productId}`);
|
if (productId) detailCache.delete(`product:${productId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import {getAll, getDay} from "../repositories/voteHistoryRepository";
|
|||||||
import {getUser} from "../repositories/userRepository";
|
import {getUser} from "../repositories/userRepository";
|
||||||
import {hasMissedGameDayBetween} from "./judgmentService";
|
import {hasMissedGameDayBetween} from "./judgmentService";
|
||||||
import type {DailyJudgment, StatsResponse, VoteHistoryDoc} from "../types/panit";
|
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 {
|
import {
|
||||||
addDays,
|
addDays,
|
||||||
parseDateString,
|
parseDateString,
|
||||||
@ -245,7 +247,7 @@ function cachePath(uid: string, key: string): string {
|
|||||||
* @param uid - 유저 ID
|
* @param uid - 유저 ID
|
||||||
* @param periodParam - 기간 문자열 (`"2026"`, `"2026-04"`, `"2026-04-23"` 등). 생략 시 전체.
|
* @param periodParam - 기간 문자열 (`"2026"`, `"2026-04"`, `"2026-04-23"` 등). 생략 시 전체.
|
||||||
*/
|
*/
|
||||||
export async function getStats(uid: string, periodParam?: string): Promise<StatsResponse> {
|
export async function getStats(uid: string, periodParam?: string): Promise<UserStatsDto> {
|
||||||
const period = parsePeriod(periodParam);
|
const period = parsePeriod(periodParam);
|
||||||
const key = periodParam && periodParam !== "current" ? periodParam : "current";
|
const key = periodParam && periodParam !== "current" ? periodParam : "current";
|
||||||
|
|
||||||
@ -288,7 +290,7 @@ export async function invalidateStats(uid: string): Promise<void> {
|
|||||||
* @param date - 조회할 날짜 (`YYYY-MM-DD`)
|
* @param date - 조회할 날짜 (`YYYY-MM-DD`)
|
||||||
* @throws {HttpError} 400 — 날짜 형식이 올바르지 않을 때
|
* @throws {HttpError} 400 — 날짜 형식이 올바르지 않을 때
|
||||||
*/
|
*/
|
||||||
export async function getHistory(uid: string, date: string): Promise<VoteHistoryDoc> {
|
export async function getHistory(uid: string, date: string): Promise<VoteHistoryDto> {
|
||||||
let parsed;
|
let parsed;
|
||||||
try {
|
try {
|
||||||
parsed = parseDateString(date);
|
parsed = parseDateString(date);
|
||||||
@ -296,5 +298,6 @@ export async function getHistory(uid: string, date: string): Promise<VoteHistory
|
|||||||
throw new HttpError(400, (err as Error).message);
|
throw new HttpError(400, (err as Error).message);
|
||||||
}
|
}
|
||||||
const doc = await getDay(uid, parsed);
|
const doc = await getDay(uid, parsed);
|
||||||
return doc ?? {data: []};
|
if (!doc) return EMPTY_VOTE_HISTORY_DTO;
|
||||||
|
return toVoteHistoryDto(doc);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,15 +28,17 @@ import {
|
|||||||
type NotificationsMap,
|
type NotificationsMap,
|
||||||
type Provider,
|
type Provider,
|
||||||
type User,
|
type User,
|
||||||
type UserProfile,
|
|
||||||
} from "../types/panit";
|
} from "../types/panit";
|
||||||
|
import { toIso } from "../types/dto/iso";
|
||||||
|
import type { CheckNicknameDto, UserProfileDto } from "../types/dto/userDto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 내부 User 문서를 클라이언트 응답용 프로필로 변환한다.
|
* 내부 User 문서를 클라이언트 응답용 프로필로 변환한다.
|
||||||
* streak/티어/티켓/판정 회계 필드는 의도적으로 제외 — 해당 데이터는 `/stats`로 서빙된다.
|
* streak/티어/티켓/판정 회계 필드는 의도적으로 제외 — 해당 데이터는 `/stats`로 서빙된다.
|
||||||
* (user doc의 streak은 lazy 보정 전 값이라 그대로 노출 금지.)
|
* (user doc의 streak은 lazy 보정 전 값이라 그대로 노출 금지.)
|
||||||
|
* `createdAt`은 응답 경계에서 UTC ISO 문자열로 변환한다.
|
||||||
*/
|
*/
|
||||||
function toUserProfile(user: User): UserProfile {
|
function toUserProfile(user: User): UserProfileDto {
|
||||||
return {
|
return {
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@ -44,7 +46,7 @@ function toUserProfile(user: User): UserProfile {
|
|||||||
provider: user.provider,
|
provider: user.provider,
|
||||||
favoriteTeamCode: user.favoriteTeamCode,
|
favoriteTeamCode: user.favoriteTeamCode,
|
||||||
knowledgeLevel: user.knowledgeLevel,
|
knowledgeLevel: user.knowledgeLevel,
|
||||||
createdAt: user.createdAt,
|
createdAt: toIso(user.createdAt),
|
||||||
lastJudgedDate: user.lastJudgedDate,
|
lastJudgedDate: user.lastJudgedDate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -113,7 +115,7 @@ function samePhotoUrl(a: string | undefined, b: string | undefined): boolean {
|
|||||||
* (쿼리스트링만 다른 경우는 write하지 않음 — 읽기 경로의 불필요한 write 방지).
|
* (쿼리스트링만 다른 경우는 write하지 않음 — 읽기 경로의 불필요한 write 방지).
|
||||||
* 응답은 동기화된 값으로 반환한다.
|
* 응답은 동기화된 값으로 반환한다.
|
||||||
*/
|
*/
|
||||||
export async function getMe(token: DecodedIdToken): Promise<UserProfile> {
|
export async function getMe(token: DecodedIdToken): Promise<UserProfileDto> {
|
||||||
const user = await getUser(token.uid);
|
const user = await getUser(token.uid);
|
||||||
// 비활성화(탈퇴) 계정은 미존재로 취급 — 잔여 토큰으로 접근해도 온보딩으로 유도.
|
// 비활성화(탈퇴) 계정은 미존재로 취급 — 잔여 토큰으로 접근해도 온보딩으로 유도.
|
||||||
if (!user || user.active === false) {
|
if (!user || user.active === false) {
|
||||||
@ -142,7 +144,7 @@ export interface CreateMeBody {
|
|||||||
export async function createMe(
|
export async function createMe(
|
||||||
token: DecodedIdToken,
|
token: DecodedIdToken,
|
||||||
body: CreateMeBody
|
body: CreateMeBody
|
||||||
): Promise<UserProfile> {
|
): Promise<UserProfileDto> {
|
||||||
const existing = await getUser(token.uid);
|
const existing = await getUser(token.uid);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new HttpError(409, "user already exists", "USER_ALREADY_EXISTS");
|
throw new HttpError(409, "user already exists", "USER_ALREADY_EXISTS");
|
||||||
@ -183,12 +185,12 @@ export async function createMe(
|
|||||||
|
|
||||||
// 방금 쓴 값으로 응답을 합성한다(쓰기 후 2차 getUser 제거). `createdAt`은 저장본이
|
// 방금 쓴 값으로 응답을 합성한다(쓰기 후 2차 getUser 제거). `createdAt`은 저장본이
|
||||||
// serverTimestamp로 기록되므로 응답에는 근사치(now)를 싣는다 — 이후 getMe가 저장본 반영.
|
// serverTimestamp로 기록되므로 응답에는 근사치(now)를 싣는다 — 이후 getMe가 저장본 반영.
|
||||||
const profile: UserProfile = {
|
const profile: UserProfileDto = {
|
||||||
displayName,
|
displayName,
|
||||||
email: token.email,
|
email: token.email,
|
||||||
provider,
|
provider,
|
||||||
knowledgeLevel,
|
knowledgeLevel,
|
||||||
createdAt: Timestamp.now(),
|
createdAt: toIso(Timestamp.now()),
|
||||||
};
|
};
|
||||||
if (token.picture) profile.photoUrl = token.picture;
|
if (token.picture) profile.photoUrl = token.picture;
|
||||||
if (favoriteTeamCode) profile.favoriteTeamCode = favoriteTeamCode;
|
if (favoriteTeamCode) profile.favoriteTeamCode = favoriteTeamCode;
|
||||||
@ -297,7 +299,7 @@ export interface UpdateMeBody {
|
|||||||
export async function updateMe(
|
export async function updateMe(
|
||||||
token: DecodedIdToken,
|
token: DecodedIdToken,
|
||||||
body: UpdateMeBody
|
body: UpdateMeBody
|
||||||
): Promise<UserProfile> {
|
): Promise<UserProfileDto> {
|
||||||
const user = await getUser(token.uid);
|
const user = await getUser(token.uid);
|
||||||
if (!user || user.active === false) {
|
if (!user || user.active === false) {
|
||||||
throw new HttpError(404, "user not found", "USER_NOT_FOUND");
|
throw new HttpError(404, "user not found", "USER_NOT_FOUND");
|
||||||
@ -425,7 +427,7 @@ export async function updateNotifications(
|
|||||||
export async function checkNickname(
|
export async function checkNickname(
|
||||||
token: DecodedIdToken,
|
token: DecodedIdToken,
|
||||||
displayNameRaw: unknown
|
displayNameRaw: unknown
|
||||||
): Promise<{ available: true; previousReservation: string | null }> {
|
): Promise<CheckNicknameDto> {
|
||||||
const displayName = parseDisplayName(displayNameRaw);
|
const displayName = parseDisplayName(displayNameRaw);
|
||||||
|
|
||||||
const existing = await getUser(token.uid);
|
const existing = await getUser(token.uid);
|
||||||
|
|||||||
26
src/types/dto/attendanceDto.ts
Normal file
26
src/types/dto/attendanceDto.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
35
src/types/dto/chatDto.ts
Normal file
35
src/types/dto/chatDto.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
19
src/types/dto/iso.ts
Normal file
19
src/types/dto/iso.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
31
src/types/dto/kboDto.ts
Normal file
31
src/types/dto/kboDto.ts
Normal file
@ -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;
|
||||||
57
src/types/dto/predictionDto.ts
Normal file
57
src/types/dto/predictionDto.ts
Normal file
@ -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<string, VoteEntry>;
|
||||||
|
|
||||||
|
/** POST/PUT /prediction 응답. POST는 `changed`가 없고, PUT은 항상 포함한다. */
|
||||||
|
export interface PredictionMutationDto {
|
||||||
|
ok: true;
|
||||||
|
changed?: boolean;
|
||||||
|
}
|
||||||
206
src/types/dto/rewardDto.ts
Normal file
206
src/types/dto/rewardDto.ts
Normal file
@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
49
src/types/dto/statsDto.ts
Normal file
49
src/types/dto/statsDto.ts
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
30
src/types/dto/userDto.ts
Normal file
30
src/types/dto/userDto.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -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 });
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -167,7 +167,7 @@ describe("chatService", () => {
|
|||||||
expect(result.crisis).toBe(false);
|
expect(result.crisis).toBe(false);
|
||||||
expect(result.limit).toBe(10);
|
expect(result.limit).toBe(10);
|
||||||
expect(result.remainingCount).toBe(9);
|
expect(result.remainingCount).toBe(9);
|
||||||
expect(result.createdAt).toMatch(/\+09:00$/);
|
expect(result.createdAt).toMatch(/Z$/);
|
||||||
|
|
||||||
expect(await countMessages("HH")).toBe(2);
|
expect(await countMessages("HH")).toBe(2);
|
||||||
const req = await readRequest(key);
|
const req = await readRequest(key);
|
||||||
@ -360,7 +360,8 @@ describe("chatService", () => {
|
|||||||
.then(() => null, (e) => e);
|
.then(() => null, (e) => e);
|
||||||
expect(err).toMatchObject({ status: 403, code: "LIMIT_EXCEEDED" });
|
expect(err).toMatchObject({ status: 403, code: "LIMIT_EXCEEDED" });
|
||||||
expect(err.details.limit).toBe(1);
|
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);
|
expect((await readQuota()).used).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -682,7 +683,7 @@ describe("chatService", () => {
|
|||||||
expect(quota.used).toBe(0);
|
expect(quota.used).toBe(0);
|
||||||
expect(quota.limit).toBe(10);
|
expect(quota.limit).toBe(10);
|
||||||
expect(quota.remaining).toBe(10);
|
expect(quota.remaining).toBe(10);
|
||||||
expect(quota.resetAt).toMatch(/T00:00:00\+09:00$/);
|
expect(quota.resetAt).toMatch(/T15:00:00\.000Z$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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", () => {
|
describe("createMe", () => {
|
||||||
it("정상 생성 시 토큰의 email/photo/provider를 사용한다", async () => {
|
it("정상 생성 시 토큰의 email/photo/provider를 사용한다", async () => {
|
||||||
const u = await createMeWithReservation();
|
const u = await createMeWithReservation();
|
||||||
|
|||||||
37
tests/types/iso.test.ts
Normal file
37
tests/types/iso.test.ts
Normal file
@ -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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
90
tests/types/predictionStatsDto.test.ts
Normal file
90
tests/types/predictionStatsDto.test.ts
Normal file
@ -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<string, unknown> {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
152
tests/types/rewardDto.test.ts
Normal file
152
tests/types/rewardDto.test.ts
Normal file
@ -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<string, unknown> {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user