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:
parent
ad38a47a64
commit
a9634b7229
@ -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로 합칠 여지. |
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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` 공통 응답. */
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user