Expand notification settings API with marketing consent

- 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에 새 쓰기 필드·규칙 반영
This commit is contained in:
윤정민 2026-07-23 17:19:00 +09:00
parent ad38a47a64
commit a9634b7229
6 changed files with 148 additions and 5 deletions

View File

@ -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로 합칠 여지. |

View File

@ -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,
},
};
}

View File

@ -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 };
}
/**

View File

@ -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` 공통 응답. */

View File

@ -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<Record<NotificationKey, boolean>>;
@ -103,6 +112,15 @@ export interface User {
knowledgeLevel: KnowledgeLevel;
createdAt: Timestamp;
notifications?: NotificationsMap;
/**
* . `notifications`
* (marketingOptInAt) .
* marketingOptIn만 false로 .
*/
marketingOptIn?: boolean;
marketingOptInAt?: Timestamp;
/** 마지막 로그인 기기의 FCM 토큰. 클라가 직접 덮어쓰기. */
fcmToken?: string;

View File

@ -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();