mmday-firebase/src/services/userService.ts
윤정민 c94ce69e4f Remove redundant .js extensions from import paths.
- TypeScript 소스 파일 내 모든 import 구문에서 불필요한 `.js` 확장자를 제거하여 모듈 참조 방식을 표준화했습니다.
- 핸들러, 서비스, 리포지토리 및 테스트 코드를 포함한 프로젝트 전반의 import 경로를 일관성 있게 정리했습니다.
2026-05-06 16:17:51 +09:00

297 lines
8.5 KiB
TypeScript

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<T extends string>(
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<UserProfile> {
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<UserProfile> {
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<TeamCode>(
"favoriteTeamCode",
body.favoriteTeamCode,
TEAM_CODE_VALUES
);
const knowledgeLevel = parseEnum<KnowledgeLevel>(
"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<void> {
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<UserProfile> {
const user = await getUser(token.uid);
if (!user) {
throw new HttpError(404, "user not found", "USER_NOT_FOUND");
}
const patch: Record<string, unknown> = {};
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<TeamCode>(
"favoriteTeamCode",
body.favoriteTeamCode,
TEAM_CODE_VALUES
);
}
if (body.knowledgeLevel !== undefined) {
patch.knowledgeLevel = parseEnum<KnowledgeLevel>(
"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 };
}