import type { DecodedIdToken } from "firebase-admin/auth"; import { FieldValue } from "firebase-admin/firestore"; import { HttpError } from "../middleware/errors"; import { auth } from "../firebase"; import { createUser, deleteUser, findUidByDisplayName, getUser, updateUser, } from "../repositories/userRepository"; import { deleteReservation, releaseReservation, reserveNickname, verifyReservation, } from "../repositories/nicknameRepository"; import { KnowledgeLevel, TeamCode, type Provider, type User, type UserProfile, } from "../types/panit"; /** * 내부 User 문서를 클라이언트 응답용 프로필로 변환한다. * streak/티어/티켓/판정 회계 필드는 의도적으로 제외 — 해당 데이터는 `/stats`로 서빙된다. * (user doc의 streak은 lazy 보정 전 값이라 그대로 노출 금지.) */ function toUserProfile(user: User): UserProfile { return { displayName: user.displayName, email: user.email, photoUrl: user.photoUrl, provider: user.provider, favoriteTeamCode: user.favoriteTeamCode, knowledgeLevel: user.knowledgeLevel, createdAt: user.createdAt, lastJudgedDate: user.lastJudgedDate, }; } const TEAM_CODE_VALUES = Object.values(TeamCode); const KNOWLEDGE_LEVEL_VALUES = Object.values(KnowledgeLevel); function parseEnum( field: string, value: unknown, allowed: readonly T[] ): T { if (typeof value !== "string" || !allowed.includes(value as T)) { throw new HttpError( 400, `${field} must be one of: ${allowed.join(", ")}`, "INVALID_INPUT" ); } return value as T; } function parseDisplayName(value: unknown): string { if (typeof value !== "string") { throw new HttpError(400, "displayName must be a string", "INVALID_INPUT"); } if (value.length < 1 || value.length > 9) { throw new HttpError( 400, "displayName length must be 1~10", "INVALID_INPUT" ); } return value; } function providerFromToken(token: DecodedIdToken): Provider { const raw = token.firebase?.sign_in_provider; if (raw === "google.com") return "google"; if (raw === "apple.com") return "apple"; throw new HttpError( 400, `unsupported sign-in provider: ${raw}`, "INVALID_INPUT" ); } /** * 현재 로그인한 유저 문서를 조회한다. 없으면 404 + `USER_NOT_FOUND`. * * 토큰의 `picture`가 저장된 `photoUrl`과 다르면 자동 동기화한다 * (프로필 사진 변경 반영). 응답은 동기화된 값으로 반환한다. */ export async function getMe(token: DecodedIdToken): Promise { const user = await getUser(token.uid); if (!user) { throw new HttpError(404, "user not found", "USER_NOT_FOUND"); } const tokenPhoto = token.picture; if (tokenPhoto && tokenPhoto !== user.photoUrl) { await updateUser(token.uid, { photoUrl: tokenPhoto }); user.photoUrl = tokenPhoto; } return toUserProfile(user); } export interface CreateMeBody { displayName?: unknown; favoriteTeamCode?: unknown; knowledgeLevel?: unknown; } /** * 온보딩 완료 시 유저 문서를 생성한다. 이미 존재하면 409 + `USER_ALREADY_EXISTS`. * email/photoUrl/provider는 Firebase ID Token에서 추출한다. */ export async function createMe( token: DecodedIdToken, body: CreateMeBody ): Promise { const existing = await getUser(token.uid); if (existing) { throw new HttpError(409, "user already exists", "USER_ALREADY_EXISTS"); } const displayName = parseDisplayName(body.displayName); const favoriteTeamCode = body.favoriteTeamCode === undefined || body.favoriteTeamCode === null ? undefined : parseEnum( "favoriteTeamCode", body.favoriteTeamCode, TEAM_CODE_VALUES ); const knowledgeLevel = parseEnum( "knowledgeLevel", body.knowledgeLevel, KNOWLEDGE_LEVEL_VALUES ); if (!token.email) { throw new HttpError(400, "token has no email", "INVALID_INPUT"); } const provider = providerFromToken(token); await verifyReservation(token.uid, displayName); await createUser(token.uid, { displayName, email: token.email, photoUrl: token.picture, provider, favoriteTeamCode, knowledgeLevel, }); await deleteReservation(token.uid, displayName); const created = await getUser(token.uid); if (!created) { throw new HttpError(500, "failed to read created user"); } return toUserProfile(created); } /** * 현재 로그인한 유저의 Firestore 문서(+ 하위 컬렉션)와 Firebase Auth 계정을 삭제한다. * 존재하지 않으면 404 + `USER_NOT_FOUND`. */ export async function deleteMe(token: DecodedIdToken): Promise { const existing = await getUser(token.uid); if (!existing) { throw new HttpError(404, "user not found", "USER_NOT_FOUND"); } await deleteUser(token.uid); await releaseReservation(token.uid); try { await auth.deleteUser(token.uid); } catch (err) { const code = (err as { code?: string }).code; if (code !== "auth/user-not-found") throw err; } } // ── 이름 변경 조건 ────────────────────────────────────────────── /** 이름 변경 불가 사유. null이면 통과. */ type NameChangeRule = (user: User, newName: string) => string | null; /** * 이름 변경 조건 배열. 조건 추가/삭제는 이 배열만 수정하면 된다. * 각 규칙은 불가 사유 문자열을 반환하거나, 통과 시 null을 반환한다. */ export const nameChangeRules: NameChangeRule[] = [ (user, newName) => user.displayName === newName ? "same as current name" : null, ]; function validateNameChange(user: User, newName: string): void { for (const rule of nameChangeRules) { const reason = rule(user, newName); if (reason) { throw new HttpError(400, reason, "NAME_CHANGE_DENIED"); } } } // ── updateMe ──────────────────────────────────────────────────── export interface UpdateMeBody { displayName?: unknown; favoriteTeamCode?: unknown; knowledgeLevel?: unknown; } /** * 현재 로그인한 유저의 프로필을 부분 수정한다. * body에 포함된 필드만 변경하며, favoriteTeamCode에 null을 보내면 응원팀을 해제한다. */ export async function updateMe( token: DecodedIdToken, body: UpdateMeBody ): Promise { const user = await getUser(token.uid); if (!user) { throw new HttpError(404, "user not found", "USER_NOT_FOUND"); } const patch: Record = {}; if (body.displayName !== undefined) { const newName = parseDisplayName(body.displayName); validateNameChange(user, newName); const owner = await findUidByDisplayName(newName); if (owner && owner !== token.uid) { throw new HttpError(409, "nickname already taken", "NICKNAME_TAKEN"); } patch.displayName = newName; } if (body.favoriteTeamCode !== undefined) { patch.favoriteTeamCode = body.favoriteTeamCode === null ? FieldValue.delete() : parseEnum( "favoriteTeamCode", body.favoriteTeamCode, TEAM_CODE_VALUES ); } if (body.knowledgeLevel !== undefined) { patch.knowledgeLevel = parseEnum( "knowledgeLevel", body.knowledgeLevel, KNOWLEDGE_LEVEL_VALUES ); } if (Object.keys(patch).length === 0) { throw new HttpError(400, "no fields to update", "INVALID_INPUT"); } await updateUser(token.uid, patch); const updated = await getUser(token.uid); return toUserProfile(updated!); } /** * 닉네임 중복 체크 & 예약. 성공 시 요청 uid로 해당 닉네임을 선점한다. * 같은 uid가 이전에 다른 닉네임을 예약했다면 해제 후 이전 이름을 반환한다. */ export async function checkNickname( token: DecodedIdToken, displayNameRaw: unknown ): Promise<{ available: true; previousReservation: string | null }> { const displayName = parseDisplayName(displayNameRaw); const existing = await getUser(token.uid); if (existing) { throw new HttpError(409, "user already exists", "USER_ALREADY_EXISTS"); } const confirmedOwner = await findUidByDisplayName(displayName); if (confirmedOwner && confirmedOwner !== token.uid) { throw new HttpError(409, "nickname already taken", "NICKNAME_TAKEN"); } const { previousReservation } = await reserveNickname( token.uid, displayName ); return { available: true, previousReservation }; }