From a9634b722911f170f680c01cd457efb463396a61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Thu, 23 Jul 2026 17:19:00 +0900 Subject: [PATCH] Expand notification settings API with marketing consent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NotificationKey에 predictionRemind·jjaekTalk 추가(마케팅은 opt-in 맵에서 제외) - GET/POST/PATCH /user 응답에 notifications 맵과 marketingOptIn 포함 — 클라가 서버 설정을 읽을 수 있게 함 - updateMe에 marketingOptIn 처리 추가: 동의 시각은 서버 serverTimestamp로 기록, 철회 시 동의 이력 보존 (기존에는 미파싱으로 단독 PATCH가 400) - updateNotifications가 전체 맵 재기록 대신 patch 키만 merge 기록하도록 변경(동시 토글 유실 방지) - userService 테스트 8건 추가(허용/차단 키, boolean 검증, 동의 시각 기록·보존, 기본값) - backend-writes.md §1.1에 새 쓰기 필드·규칙 반영 --- docs/data-patterns/backend-writes.md | 4 +- src/handlers/userHandlers.ts | 2 + src/services/userService.ts | 31 ++++++++- src/types/dto/userDto.ts | 4 ++ src/types/panit.ts | 18 ++++++ tests/services/userService.test.ts | 94 ++++++++++++++++++++++++++++ 6 files changed, 148 insertions(+), 5 deletions(-) diff --git a/docs/data-patterns/backend-writes.md b/docs/data-patterns/backend-writes.md index 0b9b90a..470821f 100644 --- a/docs/data-patterns/backend-writes.md +++ b/docs/data-patterns/backend-writes.md @@ -17,9 +17,9 @@ | 항목 | 내용 | |---|---| -| 주요 필드 | `displayName, email, provider, knowledgeLevel, photoUrl, favoriteTeamCode, createdAt` (생성), `currentStreak, highestStreak, tierPoints, lastJudgedDate, rankSnapshot, notifications` (운영 중 갱신) | +| 주요 필드 | `displayName, email, provider, knowledgeLevel, photoUrl, favoriteTeamCode, createdAt` (생성), `currentStreak, highestStreak, tierPoints, lastJudgedDate, rankSnapshot, notifications, marketingOptIn, marketingOptInAt` (운영 중 갱신) | | Writer / 위치 | `createUser` `userRepository.ts:128-139` · `updateUser` `:145-150` · `applyDailyJudgmentTx` `:182-254` · `deleteUser` `:155-157` · `snapshotRankForUser` `rankSnapshotService.ts:26-54` · `settleUsers` `seasonRepository.ts`(tierPoints=0 리셋 + rankSnapshot 삭제) | -| Trigger | 생성=POST `/user`(`createMe`, userService.ts:120) · 갱신=PATCH `/user`(`updateMe`), PATCH `/user/notifications`, **GET `/user`(`getMe`) 의 photoUrl 자동 동기화**(userService.ts:101-105) · 판정=`dailyArchive` cron→`judgeDay`→`applyDailyJudgmentTx` · rankSnapshot=`dailyArchive` cron(judge **이전**) · 시즌 리셋=`dailyArchive` cron→`maybeSettleSeason`(시즌 endDate 다음 날 1회, §1.8) · 삭제=DELETE `/user` | +| Trigger | 생성=POST `/user`(`createMe`, userService.ts:120) · 갱신=PATCH `/user`(`updateMe` — `marketingOptIn` 포함, 동의 시각은 서버 serverTimestamp 로만 기록·철회 시 보존), PATCH `/user/notifications`(키 whitelist=`NotificationKey`: attendance/predictionRemind/jjaekTalk, **patch 키만 merge 기록**), **GET `/user`(`getMe`) 의 photoUrl 자동 동기화**(userService.ts:101-105) · 판정=`dailyArchive` cron→`judgeDay`→`applyDailyJudgmentTx` · rankSnapshot=`dailyArchive` cron(judge **이전**) · 시즌 리셋=`dailyArchive` cron→`maybeSettleSeason`(시즌 endDate 다음 날 1회, §1.8) · 삭제=DELETE `/user` | | Mechanism | 생성 `set({merge:false})`; 일반 갱신 `set({merge:true})`; 판정 `runTransaction`+`set(merge:true)`+`FieldValue.serverTimestamp()`(createdAt); 삭제 `recursiveDelete`(서브컬렉션 voteHistory/attendance/pointLedger 포함) | | 빈도/볼륨 | 가입 1회/유저 · 프로필 수정 드묾 · **rankSnapshot + 판정 = 유저당 매일 2회 write** (전체 활성 유저 N명 × 매일) | | 비용 관찰 | ⚠️ **`getMe`(읽기 경로)에서 토큰 사진이 다르면 매 요청 write 발생** — 사진 변경이 잦은 토큰이면 read마다 hot write. ⚠️ `rankSnapshot` write(archive 중)와 `applyDailyJudgmentTx` write가 **같은 doc을 같은 cron run에서 2번** 건드림 → 1 write로 합칠 여지. | diff --git a/src/handlers/userHandlers.ts b/src/handlers/userHandlers.ts index 826873a..8d389ad 100644 --- a/src/handlers/userHandlers.ts +++ b/src/handlers/userHandlers.ts @@ -28,6 +28,8 @@ function toMeResponse(uid: string, profile: UserProfileDto): MeResponseDto { knowledgeLevel: profile.knowledgeLevel, createdAt: profile.createdAt, lastJudgedDate: profile.lastJudgedDate, + notifications: profile.notifications, + marketingOptIn: profile.marketingOptIn, }, }; } diff --git a/src/services/userService.ts b/src/services/userService.ts index bd818ed..c8c063d 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -48,6 +48,8 @@ function toUserProfile(user: User): UserProfileDto { knowledgeLevel: user.knowledgeLevel, createdAt: toIsoOrUndefined(user.createdAt), lastJudgedDate: user.lastJudgedDate, + notifications: user.notifications ?? {}, + marketingOptIn: user.marketingOptIn ?? false, }; } @@ -191,6 +193,8 @@ export async function createMe( provider, knowledgeLevel, createdAt: toIso(Timestamp.now()), + notifications: {}, + marketingOptIn: false, }; if (token.picture) profile.photoUrl = token.picture; if (favoriteTeamCode) profile.favoriteTeamCode = favoriteTeamCode; @@ -290,6 +294,7 @@ export interface UpdateMeBody { displayName?: unknown; favoriteTeamCode?: unknown; knowledgeLevel?: unknown; + marketingOptIn?: unknown; } /** @@ -336,6 +341,22 @@ export async function updateMe( ); } + if (body.marketingOptIn !== undefined) { + if (typeof body.marketingOptIn !== "boolean") { + throw new HttpError( + 400, + "marketingOptIn must be boolean", + "INVALID_INPUT" + ); + } + patch.marketingOptIn = body.marketingOptIn; + // 동의 시각은 서버 시각이 법적 근거 — 클라가 보낸 값은 쓰지 않는다. + // 철회(false) 시에는 동의 이력(marketingOptInAt)을 보존한다. + if (body.marketingOptIn) { + patch.marketingOptInAt = FieldValue.serverTimestamp(); + } + } + if (Object.keys(patch).length === 0) { throw new HttpError(400, "no fields to update", "INVALID_INPUT"); } @@ -357,6 +378,9 @@ export async function updateMe( if (typeof patch.knowledgeLevel === "string") { merged.knowledgeLevel = patch.knowledgeLevel as KnowledgeLevel; } + if (typeof patch.marketingOptIn === "boolean") { + merged.marketingOptIn = patch.marketingOptIn; + } return toUserProfile(merged); } @@ -415,9 +439,10 @@ export async function updateNotifications( throw new HttpError(400, "no notification keys to update", "INVALID_INPUT"); } - const merged: NotificationsMap = { ...(user.notifications ?? {}), ...patch }; - await updateUser(token.uid, { notifications: merged }); - return merged; + // set(merge:true)가 맵을 재귀 병합하므로 patch 키만 쓴다 — 전체 맵을 다시 쓰면 + // 동시 토글이 서로의 변경을 last-write-wins로 지울 수 있다. + await updateUser(token.uid, { notifications: patch }); + return { ...(user.notifications ?? {}), ...patch }; } /** diff --git a/src/types/dto/userDto.ts b/src/types/dto/userDto.ts index c62f3f8..32a6a9d 100644 --- a/src/types/dto/userDto.ts +++ b/src/types/dto/userDto.ts @@ -17,6 +17,10 @@ export interface UserProfileDto { knowledgeLevel: KnowledgeLevel; createdAt?: string; lastJudgedDate?: DateString; + /** 알림 opt-in 맵. 키는 `NotificationKey`, 미설정 키는 발송 안 함과 동일. */ + notifications: NotificationsMap; + /** 마케팅 수신 동의 여부. 동의 시각은 서버 내부 기록(marketingOptInAt)으로만 보존. */ + marketingOptIn: boolean; } /** `GET/POST/PATCH /user` 공통 응답. */ diff --git a/src/types/panit.ts b/src/types/panit.ts index 8bf75d3..2ab8759 100644 --- a/src/types/panit.ts +++ b/src/types/panit.ts @@ -45,8 +45,17 @@ export enum AttendanceResult { AlreadyCheckedIn = "alreadyCheckedIn", } +/** + * `users/{uid}.notifications` 맵의 허용 키. 미설정/false 모두 "발송 안 함"인 opt-in 모델. + * 마케팅 동의는 여기 넣지 않는다 — 동의 시각 기록이 필요해 `marketingOptIn`/`marketingOptInAt` + * 필드가 단일 소스다. + */ export enum NotificationKey { Attendance = "attendance", + /** 경기 시작 전 예측 마감 임박 리마인드. */ + PredictionRemind = "predictionRemind", + /** 짹의 톡톡(선톡·경기 결과·응원 메시지). */ + JjaekTalk = "jjaekTalk", } export type NotificationsMap = Partial>; @@ -103,6 +112,15 @@ export interface User { knowledgeLevel: KnowledgeLevel; createdAt: Timestamp; notifications?: NotificationsMap; + + /** + * 마케팅 수신 동의. `notifications` 맵과 분리된 단일 소스 — + * 동의 시각(marketingOptInAt) 기록이 법적 요건이라 별도 필드로 관리한다. + * 철회 시 marketingOptIn만 false로 바꾸고 동의 이력은 보존한다. + */ + marketingOptIn?: boolean; + marketingOptInAt?: Timestamp; + /** 마지막 로그인 기기의 FCM 토큰. 클라가 직접 덮어쓰기. */ fcmToken?: string; diff --git a/tests/services/userService.test.ts b/tests/services/userService.test.ts index 346341a..4429202 100644 --- a/tests/services/userService.test.ts +++ b/tests/services/userService.test.ts @@ -9,6 +9,7 @@ import { purgeExpiredAccounts, PURGE_GRACE_DAYS, updateMe, + updateNotifications, } from "../../src/services/userService"; import { HttpError } from "../../src/middleware/errors"; import { @@ -109,6 +110,99 @@ describe("userService", () => { }); }); + describe("updateNotifications", () => { + it("허용 키(attendance/predictionRemind/jjaekTalk)를 저장하고 병합해 반환한다", async () => { + await createMeWithReservation(); + const first = await updateNotifications(fakeToken(), { + notifications: { attendance: true, predictionRemind: true }, + }); + expect(first).toEqual({ attendance: true, predictionRemind: true }); + + // 부분 업데이트 — 미포함 키는 유지된다. + const second = await updateNotifications(fakeToken(), { + notifications: { jjaekTalk: true, predictionRemind: false }, + }); + expect(second).toEqual({ + attendance: true, + predictionRemind: false, + jjaekTalk: true, + }); + }); + + it("whitelist 밖 키는 400 + INVALID_INPUT (marketing 포함)", async () => { + await createMeWithReservation(); + // marketing은 marketingOptIn 필드가 단일 소스라 notifications 맵에서 제외. + for (const key of ["marketing", "unknown"]) { + await expect( + updateNotifications(fakeToken(), { notifications: { [key]: true } }) + ).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" }); + } + }); + + it("boolean이 아닌 값은 400", async () => { + await createMeWithReservation(); + await expect( + updateNotifications(fakeToken(), { + notifications: { attendance: "yes" }, + }) + ).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" }); + }); + + it("getMe 응답에 notifications 맵이 포함된다 (미설정이면 빈 맵)", async () => { + await createMeWithReservation(); + expect((await getMe(fakeToken())).notifications).toEqual({}); + + await updateNotifications(fakeToken(), { + notifications: { predictionRemind: true }, + }); + expect((await getMe(fakeToken())).notifications).toEqual({ + predictionRemind: true, + }); + }); + }); + + describe("마케팅 동의 (updateMe.marketingOptIn)", () => { + async function readUserDoc() { + const snap = await firestore.collection("users").doc(uid).get(); + return snap.data() ?? {}; + } + + it("marketingOptIn 단독 PATCH가 성공하고 동의 시각을 서버가 기록한다", async () => { + await createMeWithReservation(); + const u = await updateMe(fakeToken(), { marketingOptIn: true }); + expect(u.marketingOptIn).toBe(true); + + const doc = await readUserDoc(); + expect(doc.marketingOptIn).toBe(true); + expect(doc.marketingOptInAt).toBeTruthy(); + }); + + it("철회(false) 시 동의 이력(marketingOptInAt)은 보존된다", async () => { + await createMeWithReservation(); + await updateMe(fakeToken(), { marketingOptIn: true }); + const optInAt = (await readUserDoc()).marketingOptInAt; + + const u = await updateMe(fakeToken(), { marketingOptIn: false }); + expect(u.marketingOptIn).toBe(false); + + const doc = await readUserDoc(); + expect(doc.marketingOptIn).toBe(false); + expect(doc.marketingOptInAt).toEqual(optInAt); + }); + + it("boolean이 아니면 400", async () => { + await createMeWithReservation(); + await expect( + updateMe(fakeToken(), { marketingOptIn: "yes" }) + ).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" }); + }); + + it("미설정 유저의 getMe는 marketingOptIn=false", async () => { + await createMeWithReservation(); + expect((await getMe(fakeToken())).marketingOptIn).toBe(false); + }); + }); + describe("createMe", () => { it("정상 생성 시 토큰의 email/photo/provider를 사용한다", async () => { const u = await createMeWithReservation();