- TypeScript 소스 파일 내 모든 import 구문에서 불필요한 `.js` 확장자를 제거하여 모듈 참조 방식을 표준화했습니다. - 핸들러, 서비스, 리포지토리 및 테스트 코드를 포함한 프로젝트 전반의 import 경로를 일관성 있게 정리했습니다.
297 lines
8.5 KiB
TypeScript
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 };
|
|
}
|