From dfd21ba4c25bfa45bc211a0fa4e05a0cfad5a59b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Tue, 14 Apr 2026 13:16:50 +0900 Subject: [PATCH] Implement nickname reservation system and add user delete functionality. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 닉네임 선점 및 검증을 위한 예약 시스템을 구현하고 관련 로직을 추가했습니다. - 유저 데이터 및 하위 컬렉션을 완전히 삭제하는 회원 탈퇴 기능을 구현했습니다. - 토큰의 프로필 사진 변경 시 유저 정보를 자동으로 동기화하도록 개선했습니다. - RTDB를 활용하여 닉네임 예약 상태를 관리하고, 회원 가입 시 이를 검증하도록 변경했습니다. --- database.rules.json | 8 ++ src/handlers/userHandlers.ts | 33 +++-- src/repositories/nicknameRepository.ts | 100 ++++++++++++++ src/repositories/userRepository.ts | 26 +++- src/services/userService.ts | 70 +++++++++- tests/services/userService.test.ts | 173 +++++++++++++++++++++++-- 6 files changed, 388 insertions(+), 22 deletions(-) create mode 100644 src/repositories/nicknameRepository.ts diff --git a/database.rules.json b/database.rules.json index 03e649a..17f4583 100644 --- a/database.rules.json +++ b/database.rules.json @@ -18,6 +18,14 @@ "cache": { ".read": false, ".write": false + }, + "nicknames": { + ".read": false, + ".write": false + }, + "userNicknames": { + ".read": false, + ".write": false } } } diff --git a/src/handlers/userHandlers.ts b/src/handlers/userHandlers.ts index 26d1d0d..ae03775 100644 --- a/src/handlers/userHandlers.ts +++ b/src/handlers/userHandlers.ts @@ -1,23 +1,40 @@ import { onRequest } from "firebase-functions/https"; -import { requireAuth, requireAuthToken } from "../middleware/auth.js"; +import { requireAuthToken } from "../middleware/auth.js"; import { sendError } from "../middleware/errors.js"; -import { createMe, getMe } from "../services/userService.js"; +import { + checkNickname, + createMe, + deleteMe, + getMe, +} from "../services/userService.js"; export const user = onRequest(async (req, res) => { try { - if (req.method === "GET") { - const uid = await requireAuth(req); - const u = await getMe(uid); - res.status(200).json({ user: { uid, ...u } }); + 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 === "POST") { + if (req.method === "GET" && req.path === "/") { + const token = await requireAuthToken(req); + const u = await getMe(token); + res.status(200).json({ user: { uid: 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({ user: { uid: token.uid, ...u } }); return; } - res.status(405).json({ error: `method ${req.method} not allowed` }); + 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); } diff --git a/src/repositories/nicknameRepository.ts b/src/repositories/nicknameRepository.ts new file mode 100644 index 0000000..14e35e3 --- /dev/null +++ b/src/repositories/nicknameRepository.ts @@ -0,0 +1,100 @@ +import { rtdb } from "../firebase.js"; +import { HttpError } from "../middleware/errors.js"; + +export const RESERVATION_TTL_MS = 10 * 60 * 1000; + +interface ReservationNode { + uid: string; + reservedAt: number; +} + +function nicknamePath(name: string): string { + return `/nicknames/${name}`; +} + +function userNicknamePath(uid: string): string { + return `/userNicknames/${uid}`; +} + +export async function reserveNickname( + uid: string, + name: string +): Promise<{ previousReservation: string | null }> { + const prevSnap = await rtdb.ref(userNicknamePath(uid)).get(); + const previous = prevSnap.exists() ? (prevSnap.val() as string) : null; + + const now = Date.now(); + const txResult = await rtdb + .ref(nicknamePath(name)) + .transaction((current: ReservationNode | null) => { + if (current === null) { + return { uid, reservedAt: now }; + } + if (current.uid === uid) { + return { uid, reservedAt: now }; + } + if (now - current.reservedAt > RESERVATION_TTL_MS) { + return { uid, reservedAt: now }; + } + return undefined; + }); + + if (!txResult.committed) { + throw new HttpError(409, "nickname already taken", "NICKNAME_TAKEN"); + } + + const updates: Record = { + [userNicknamePath(uid)]: name, + }; + if (previous && previous !== name) { + updates[nicknamePath(previous)] = null; + } + await rtdb.ref().update(updates); + + const previousReservation = + previous && previous !== name ? previous : null; + return { previousReservation }; +} + +export async function consumeReservation( + uid: string, + name: string +): Promise { + const [userSnap, nameSnap] = await Promise.all([ + rtdb.ref(userNicknamePath(uid)).get(), + rtdb.ref(nicknamePath(name)).get(), + ]); + + const userName = userSnap.exists() ? (userSnap.val() as string) : null; + const node = nameSnap.exists() + ? (nameSnap.val() as ReservationNode) + : null; + + if (userName !== name || !node || node.uid !== uid) { + throw new HttpError( + 409, + "nickname reservation missing or mismatched", + "RESERVATION_MISSING" + ); + } + + await rtdb.ref().update({ + [userNicknamePath(uid)]: null, + [nicknamePath(name)]: null, + }); +} + +export async function releaseReservation(uid: string): Promise { + const snap = await rtdb.ref(userNicknamePath(uid)).get(); + if (!snap.exists()) return; + const name = snap.val() as string; + + const nameSnap = await rtdb.ref(nicknamePath(name)).get(); + const updates: Record = { + [userNicknamePath(uid)]: null, + }; + if (nameSnap.exists() && (nameSnap.val() as ReservationNode).uid === uid) { + updates[nicknamePath(name)] = null; + } + await rtdb.ref().update(updates); +} diff --git a/src/repositories/userRepository.ts b/src/repositories/userRepository.ts index 1551ed2..d24382c 100644 --- a/src/repositories/userRepository.ts +++ b/src/repositories/userRepository.ts @@ -1,6 +1,11 @@ import { FieldValue } from "firebase-admin/firestore"; -import { firestore } from "../firebase.js"; -import type { KnowledgeLevel, Provider, TeamCode, User } from "../types/panit.js"; +import { firestore } from "../firebase"; +import type { + KnowledgeLevel, + Provider, + TeamCode, + User, +} from "../types/panit"; const COLLECTION = "users"; @@ -40,3 +45,20 @@ export async function createUser(uid: string, input: RegisterInput): Promise> +): Promise { + await firestore.collection(COLLECTION).doc(uid).set(patch, { merge: true }); +} + +/** + * 유저 문서 및 하위 컬렉션(voteHistory 등)을 모두 삭제한다. + */ +export async function deleteUser(uid: string): Promise { + await firestore.recursiveDelete(firestore.collection(COLLECTION).doc(uid)); +} diff --git a/src/services/userService.ts b/src/services/userService.ts index 9aa9e9a..b8c8e3a 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -1,9 +1,17 @@ import type { DecodedIdToken } from "firebase-admin/auth"; import { HttpError } from "../middleware/errors.js"; +import { auth } from "../firebase.js"; import { createUser, + deleteUser, getUser, + updateUser, } from "../repositories/userRepository.js"; +import { + consumeReservation, + releaseReservation, + reserveNickname, +} from "../repositories/nicknameRepository.js"; import { KnowledgeLevel, TeamCode, @@ -33,7 +41,7 @@ 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 > 10) { + if (value.length < 1 || value.length > 9) { throw new HttpError( 400, "displayName length must be 1~10", @@ -56,12 +64,22 @@ function providerFromToken(token: DecodedIdToken): Provider { /** * 현재 로그인한 유저 문서를 조회한다. 없으면 404 + `USER_NOT_FOUND`. + * + * 토큰의 `picture`가 저장된 `photoUrl`과 다르면 자동 동기화한다 + * (프로필 사진 변경 반영). 응답은 동기화된 값으로 반환한다. */ -export async function getMe(uid: string): Promise { - const user = await getUser(uid); +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 user; } @@ -99,12 +117,15 @@ export async function createMe( if (!token.email) { throw new HttpError(400, "token has no email", "INVALID_INPUT"); } + const provider = providerFromToken(token); + + await consumeReservation(token.uid, displayName); await createUser(token.uid, { displayName, email: token.email, photoUrl: token.picture, - provider: providerFromToken(token), + provider, favoriteTeamCode, knowledgeLevel, }); @@ -115,3 +136,44 @@ export async function createMe( } return 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; + } +} + +/** + * 닉네임 중복 체크 & 예약. 성공 시 요청 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 { previousReservation } = await reserveNickname( + token.uid, + displayName + ); + return { available: true, previousReservation }; +} diff --git a/tests/services/userService.test.ts b/tests/services/userService.test.ts index cf648a7..7be5616 100644 --- a/tests/services/userService.test.ts +++ b/tests/services/userService.test.ts @@ -1,8 +1,17 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { DecodedIdToken } from "firebase-admin/auth"; -import { firestore } from "../../src/firebase.js"; -import { createMe, getMe } from "../../src/services/userService.js"; +import { firestore, rtdb } from "../../src/firebase.js"; +import { + checkNickname, + createMe, + deleteMe, + getMe, +} from "../../src/services/userService.js"; import { HttpError } from "../../src/middleware/errors.js"; +import { + RESERVATION_TTL_MS, + reserveNickname, +} from "../../src/repositories/nicknameRepository.js"; const uid = "user-1"; @@ -34,28 +43,50 @@ const validBody = { describe("userService", () => { beforeEach(async () => { await firestore.recursiveDelete(firestore.collection("users")); + await rtdb.ref("/nicknames").remove(); + await rtdb.ref("/userNicknames").remove(); }); + async function createMeWithReservation( + token = fakeToken(), + body: Record = validBody + ) { + await reserveNickname(token.uid, body.displayName as string); + return createMe(token, body); + } + describe("getMe", () => { it("미존재 유저는 404 + USER_NOT_FOUND", async () => { - await expect(getMe(uid)).rejects.toMatchObject({ + await expect(getMe(fakeToken())).rejects.toMatchObject({ status: 404, code: "USER_NOT_FOUND", }); }); it("존재하는 유저를 반환한다", async () => { - await createMe(fakeToken(), validBody); - const u = await getMe(uid); + await createMeWithReservation(); + const u = await getMe(fakeToken()); expect(u.displayName).toBe("유저1"); expect(u.favoriteTeamCode).toBe("LG"); expect(u.knowledgeLevel).toBe("casual"); }); + + it("토큰의 photoUrl이 바뀌면 자동 동기화한다", async () => { + await createMeWithReservation(); + const newToken = fakeToken({ picture: "https://cdn.example.com/new.png" }); + + const u = await getMe(newToken); + expect(u.photoUrl).toBe("https://cdn.example.com/new.png"); + + // Firestore 저장본도 갱신되었는지 재조회로 확인 + const u2 = await getMe(fakeToken({ picture: "https://cdn.example.com/new.png" })); + expect(u2.photoUrl).toBe("https://cdn.example.com/new.png"); + }); }); describe("createMe", () => { it("정상 생성 시 토큰의 email/photo/provider를 사용한다", async () => { - const u = await createMe(fakeToken(), validBody); + const u = await createMeWithReservation(); expect(u.email).toBe("tester@example.com"); expect(u.photoUrl).toBe("https://cdn.example.com/p.png"); expect(u.provider).toBe("google"); @@ -67,12 +98,12 @@ describe("userService", () => { const t = fakeToken({ firebase: { identities: {}, sign_in_provider: "apple.com" }, }); - const u = await createMe(t, validBody); + const u = await createMeWithReservation(t); expect(u.provider).toBe("apple"); }); it("중복 생성 시 409 + USER_ALREADY_EXISTS", async () => { - await createMe(fakeToken(), validBody); + await createMeWithReservation(); await expect(createMe(fakeToken(), validBody)).rejects.toMatchObject({ status: 409, code: "USER_ALREADY_EXISTS", @@ -99,4 +130,130 @@ describe("userService", () => { await expect(createMe(t, validBody)).rejects.toBeInstanceOf(HttpError); }); }); + + describe("deleteMe", () => { + it("유저 문서와 하위 컬렉션을 삭제한다", async () => { + await createMeWithReservation(); + await firestore + .collection("users").doc(uid) + .collection("voteHistory").doc("2026-04-12") + .set({ data: [] }); + + await deleteMe(fakeToken()); + + await expect(getMe(fakeToken())).rejects.toMatchObject({ code: "USER_NOT_FOUND" }); + const sub = await firestore + .collection("users").doc(uid) + .collection("voteHistory").get(); + expect(sub.empty).toBe(true); + }); + + it("미존재 유저는 404 + USER_NOT_FOUND", async () => { + await expect( + deleteMe(fakeToken({ uid: "nonexistent", sub: "nonexistent" })) + ).rejects.toMatchObject({ + status: 404, + code: "USER_NOT_FOUND", + }); + }); + }); + + describe("checkNickname", () => { + it("새 예약 성공 → previousReservation: null", async () => { + const result = await checkNickname(fakeToken(), "닉네임A"); + expect(result).toEqual({ available: true, previousReservation: null }); + const snap = await rtdb.ref("/nicknames/닉네임A").get(); + expect(snap.val()).toMatchObject({ uid }); + const userSnap = await rtdb.ref(`/userNicknames/${uid}`).get(); + expect(userSnap.val()).toBe("닉네임A"); + }); + + it("같은 uid 재요청(다른 이름) → 이전 이름 반환 & 이전 경로 제거", async () => { + await checkNickname(fakeToken(), "닉네임A"); + const result = await checkNickname(fakeToken(), "닉네임B"); + expect(result).toEqual({ + available: true, + previousReservation: "닉네임A", + }); + const oldSnap = await rtdb.ref("/nicknames/닉네임A").get(); + expect(oldSnap.exists()).toBe(false); + const newSnap = await rtdb.ref("/nicknames/닉네임B").get(); + expect(newSnap.val()).toMatchObject({ uid }); + const userSnap = await rtdb.ref(`/userNicknames/${uid}`).get(); + expect(userSnap.val()).toBe("닉네임B"); + }); + + it("같은 uid 동일 이름 재요청 → 멱등, previousReservation: null", async () => { + await checkNickname(fakeToken(), "닉네임A"); + const result = await checkNickname(fakeToken(), "닉네임A"); + expect(result).toEqual({ available: true, previousReservation: null }); + }); + + it("타 uid 점유(TTL 이내) → 409 NICKNAME_TAKEN", async () => { + await checkNickname( + fakeToken({ uid: "other", sub: "other" }), + "닉네임A" + ); + await expect( + checkNickname(fakeToken(), "닉네임A") + ).rejects.toMatchObject({ + status: 409, + code: "NICKNAME_TAKEN", + }); + }); + + it("타 uid 점유(TTL 초과) → 탈취 성공", async () => { + await rtdb.ref("/nicknames/닉네임A").set({ + uid: "other", + reservedAt: Date.now() - RESERVATION_TTL_MS - 1000, + }); + const result = await checkNickname(fakeToken(), "닉네임A"); + expect(result.available).toBe(true); + const snap = await rtdb.ref("/nicknames/닉네임A").get(); + expect(snap.val()).toMatchObject({ uid }); + }); + + it("이미 가입된 유저 → 409 USER_ALREADY_EXISTS", async () => { + await createMeWithReservation(); + await expect( + checkNickname(fakeToken(), "닉네임X") + ).rejects.toMatchObject({ + status: 409, + code: "USER_ALREADY_EXISTS", + }); + }); + + it("displayName 형식 오류 → 400", async () => { + await expect(checkNickname(fakeToken(), "")).rejects.toMatchObject({ + status: 400, + code: "INVALID_INPUT", + }); + }); + }); + + describe("createMe 예약 연동", () => { + it("예약 없이 createMe → 409 RESERVATION_MISSING", async () => { + await expect(createMe(fakeToken(), validBody)).rejects.toMatchObject({ + status: 409, + code: "RESERVATION_MISSING", + }); + }); + + it("createMe 성공 후 예약 경로가 소비된다", async () => { + await createMeWithReservation(); + const nameSnap = await rtdb + .ref(`/nicknames/${validBody.displayName}`) + .get(); + expect(nameSnap.exists()).toBe(false); + const userSnap = await rtdb.ref(`/userNicknames/${uid}`).get(); + expect(userSnap.exists()).toBe(false); + }); + + it("deleteMe 후 예약 잔재 없음", async () => { + await createMeWithReservation(); + await deleteMe(fakeToken()); + const userSnap = await rtdb.ref(`/userNicknames/${uid}`).get(); + expect(userSnap.exists()).toBe(false); + }); + }); });