mmday-firebase/src/handlers/chatHandlers.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

92 lines
3.3 KiB
TypeScript

import { onRequest } from "firebase-functions/https";
import type { Request } from "firebase-functions/https";
import { requireAuth } from "../middleware/auth";
import { HttpError, sendError } from "../middleware/errors";
import {
getMessages,
getQuota,
getSuggestions,
reportMessage,
sendMessage,
} from "../services/chatService";
import type {
ChatHistoryPageDto,
ChatQuotaDto,
ChatReportDto,
ChatSendResultDto,
ChatSuggestionsDto,
} from "../types/dto/chatDto";
/**
* AI 채팅(짹) 엔드포인트(§3). `/chat/*` 전체가 인증 필요 API다.
*
* - POST /chat/messages 메시지 전송(비스트리밍, §3.1)
* - GET /chat/messages 대화 이력 조회(§3.2)
* - GET /chat/quota 오늘 잔여 횟수(§3.3)
* - POST /chat/messages/{messageId}/report 메시지 신고(§3.4)
* - GET /chat/suggestions 추천 질문(§3.5)
*
* 기본 AI 벤더는 Vertex AI(Gemini, ADC 인증 — 별도 키 불필요)다(§8).
* 벤더 전환은 config/chat.provider.name으로: "mock" | "vertex" | "anthropic".
* anthropic 전환 시 `firebase functions:secrets:set ANTHROPIC_API_KEY` 등록과 함께
* 아래 onRequest 옵션에 `secrets: ["ANTHROPIC_API_KEY"]`를 추가한다(§8.2).
*/
/** 401 응답에 에러 코드 UNAUTHENTICATED를 포함한다(§3.1 에러 표 계약). */
async function requireChatAuth(req: Request): Promise<string> {
try {
return await requireAuth(req);
} catch (err) {
if (err instanceof HttpError && err.status === 401 && !err.code) {
throw new HttpError(401, err.message, "UNAUTHENTICATED");
}
throw err;
}
}
export const chat = onRequest({ timeoutSeconds: 60 }, async (req, res) => {
const segs = req.path.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
try {
if (segs[0] === "messages" && segs.length === 1) {
const uid = await requireChatAuth(req);
if (req.method === "POST") {
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: ChatHistoryPageDto = await getMessages(uid, cursor, limit);
res.status(200).json(result);
return;
}
}
if (segs[0] === "messages" && segs.length === 3 && segs[2] === "report" && req.method === "POST") {
const uid = await requireChatAuth(req);
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: ChatQuotaDto = await getQuota(uid);
res.status(200).json(result);
return;
}
if (segs[0] === "suggestions" && req.method === "GET") {
const uid = await requireChatAuth(req);
const result: ChatSuggestionsDto = await getSuggestions(uid);
res.status(200).json(result);
return;
}
res.status(404).json({ error: `Unknown: ${req.method} ${req.path}` });
} catch (err) {
sendError(res, err);
}
});