mmday-firebase/src/handlers/userHandlers.ts
윤정민 107f75c23a 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 키 생략, 미지 필드 차단)
2026-07-20 16:28:45 +09:00

79 lines
2.5 KiB
TypeScript

import { onRequest } from "firebase-functions/https";
import { requireAuthToken } from "../middleware/auth";
import { sendError } from "../middleware/errors";
import {
checkNickname,
createMe,
deleteMe,
getMe,
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 {
if (req.method === "GET" && req.path === "/check-nickname") {
const token = await requireAuthToken(req);
const result = await checkNickname(token, req.query.displayName);
res.status(200).json(result);
return;
}
if (req.method === "GET" && req.path === "/") {
const token = await requireAuthToken(req);
const u = await getMe(token);
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(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(toMeResponse(token.uid, u));
return;
}
if (req.method === "PATCH" && req.path === "/notifications") {
const token = await requireAuthToken(req);
const notifications = await updateNotifications(token, req.body ?? {});
const body: NotificationsResponseDto = { notifications };
res.status(200).json(body);
return;
}
if (req.method === "DELETE" && req.path === "/") {
const token = await requireAuthToken(req);
await deleteMe(token);
res.status(204).send();
return;
}
res.status(404).json({ error: "not found" });
} catch (err) {
sendError(res, err);
}
});