이벤트별 개인 Push 발송

This commit is contained in:
Sn-Kinos
2026-10-05 23:24:02 +09:00
parent c8c1a1ae67
commit dc9861df38
4 changed files with 172 additions and 2 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ export {
fnTranslationSubmit, fnTranslationMark, fnTranslationSettle,
} from './account/translation.js';
export { fnBriefingIngest, fnBriefingPublish, fnBriefingReject } from './briefing/briefing.js';
export { fnInboxEclipse, fnInboxHeroDrop, fnInboxQuestDone } from './notify/inbox.js';
export { fnInboxEclipse, fnInboxHeroDrop, fnInboxQuestDone, fnPushInbox } from './notify/inbox.js';
export { fnPlanetEvolutionRecord, fnAdminPlanetEvolutionRun } from './fandom/evolution.js';
export { fnPlanetHistory } from './fandom/history.js';
@@ -2,6 +2,8 @@ import { FieldValue, getFirestore } from 'firebase-admin/firestore';
import { onDocumentCreated, onDocumentUpdated, onDocumentWritten } from 'firebase-functions/v2/firestore';
import { logger } from 'firebase-functions';
import { PUSH_CATEGORIES, sendUserPush, type UserPushOutcome } from './push.js';
/**
* 알림함(`users/{uid}/notifications`) 항목 생성 — Figma `229:11265` 홈/알림 · 명세 `518:88353`.
*
@@ -106,6 +108,37 @@ export async function notifyQuestDone(
});
}
/**
* 알림함 항목 1건 → 그 사용자 단말로 Push 1건 — C-45 이벤트별 개인 Push(이클립스 발생 · 방어 ·
* 영웅의 선물 · 퀘스트 완료). 알림함에 쌓이는 사건이 곧 개인 Push 대상이라 경로를 하나로 둔다 —
* 3차에 INSIDE · 캠페인 댓글 알림이 알림함에 생기면 Push 도 같이 나간다.
*
* 문구는 알림함 항목과 같다(Figma `229:11265`). 제목 자리는 사용자 노출 브랜드(FANUZU · 공통 기준 4항).
* 구분(`type`)이 꺼져 있으면 보내지 않는다([sendUserPush]). 문서 ID 가 사건마다 고정이라 생성
* 트리거가 한 번만 돈다. 재시도는 두지 않는다 — 재전달 시 같은 Push 가 두 번 갈 수 있다.
* ponytail: 회원 전원 알림(이클립스 · 영웅의 선물)은 회원 수만큼 함수가 돈다. 수십만이 되면 토큰 묶음 발송으로.
*/
export async function pushInboxItem(uid: string, item: Partial<InboxItem>): Promise<UserPushOutcome | null> {
const category = item.type;
if (!category || !PUSH_CATEGORIES.includes(category) || !item.title) return null;
return sendUserPush(uid, {
category,
title: 'FANUZU',
body: item.title,
data: {
kind: item.eventType ?? '',
...(item.targetPath ? { targetPath: item.targetPath } : {}),
},
});
}
export const fnPushInbox = onDocumentCreated(
{ document: 'users/{uid}/notifications/{id}' },
async (event) => {
await pushInboxItem(event.params.uid, (event.data?.data() ?? {}) as Partial<InboxItem>);
},
);
export const fnInboxEclipse = onDocumentUpdated(
{ document: 'fandoms/{fandomId}/eclipseRounds/{roundId}', retry: true },
async (event) => {
+62 -1
View File
@@ -1,5 +1,5 @@
import { getMessaging } from 'firebase-admin/messaging';
import { Timestamp, type DocumentSnapshot } from 'firebase-admin/firestore';
import { FieldValue, Timestamp, getFirestore, type DocumentSnapshot } from 'firebase-admin/firestore';
import { logger } from 'firebase-functions';
/**
@@ -64,6 +64,67 @@ export async function sendFandomPush(message: PushMessage): Promise<boolean> {
}
}
/** 사용자 단위 발송 결과. */
export type UserPushOutcome = 'sent' | 'off' | 'no-token' | 'failed';
/** 단말에서 지워진 토큰 — 다시 보내도 영영 실패하므로 사용자 문서에서 걷어 낸다. */
const DEAD_TOKEN_CODES = new Set([
'messaging/registration-token-not-registered',
'messaging/invalid-registration-token',
]);
/** 이 구분을 받는가 — 설정 화면(Figma `554:24062`)과 같은 기본값(Marketing 만 OFF). 필수는 끌 수 없다. */
export function pushAllowed(prefs: unknown, category: PushCategory): boolean {
if (category === 'essential') return true;
const v = (prefs && typeof prefs === 'object' ? prefs as Record<string, unknown> : {})[category];
return typeof v === 'boolean' ? v : category !== 'marketing';
}
/**
* 사용자 한 명의 단말 전부에 한 건 — C-45 「사용자 세그먼트 타게팅」 · 이벤트별 개인 Push.
*
* 토픽 발송과 달리 서버가 수신자를 고르므로 **알림 설정을 서버가 지킨다.** 설정 화면이 쓰는
* `users/{uid}.notificationPrefs` 를 읽어 꺼진 구분은 보내지 않는다 — B-77 「OFF 카테고리
* 발송 0건」. 토큰은 앱이 쌓는 `users/{uid}.fcmTokens`(단말마다 하나).
*
* 발송 실패는 호출자를 깨뜨리지 않는다([sendFandomPush] 와 같은 이유).
*/
export async function sendUserPush(
uid: string,
message: Omit<PushMessage, 'fandomId'> & { fandomId?: string },
): Promise<UserPushOutcome> {
const userRef = getFirestore().doc(`users/${uid}`);
const user = (await userRef.get()).data() ?? {};
if (!pushAllowed(user.notificationPrefs, message.category)) return 'off';
const tokens = Array.isArray(user.fcmTokens)
? [...new Set((user.fcmTokens as unknown[]).filter((t): t is string => typeof t === 'string' && t.length > 0))]
: [];
if (tokens.length === 0) return 'no-token';
try {
const result = await getMessaging().sendEachForMulticast({
tokens,
notification: { title: message.title, body: message.body },
data: {
category: message.category,
...(message.fandomId ? { fandomId: message.fandomId } : {}),
...message.data,
},
android: { priority: 'high' },
apns: { payload: { aps: { sound: 'default' } } },
});
const dead = tokens.filter((_, i) => {
const code = result.responses[i]?.error?.code;
return code !== undefined && DEAD_TOKEN_CODES.has(code);
});
if (dead.length > 0) await userRef.update({ fcmTokens: FieldValue.arrayRemove(...dead) });
logger.info('[push] 사용자 발송', { uid, success: result.successCount, failure: result.failureCount });
return result.successCount > 0 ? 'sent' : 'failed';
} catch (error) {
logger.error('[push] 사용자 발송 실패', { uid, message: (error as Error).message });
return 'failed';
}
}
/**
* 이클립스 사전 알림 — 원장 **C-22** 「발생 3분 전 푸시」 · C-24 「푸시 = 시작 − 3분」.
*
@@ -0,0 +1,76 @@
import { afterAll, beforeAll, beforeEach, expect, it, vi } from 'vitest';
import { getFirestore } from 'firebase-admin/firestore';
/** C-45 이벤트별 개인 Push — 알림 설정 존중 · 단말 토큰 · 죽은 토큰 정리. FCM 은 가짜로 바꾼다. */
const sent: Array<{ tokens: string[]; notification: unknown; data: Record<string, string> }> = [];
vi.mock('firebase-admin/messaging', () => ({
getMessaging: () => ({
sendEachForMulticast: async (m: { tokens: string[]; notification: unknown; data: Record<string, string> }) => {
sent.push(m);
const responses = m.tokens.map((t) => t === 'dead'
? { success: false, error: { code: 'messaging/registration-token-not-registered' } }
: { success: true });
return {
responses,
successCount: responses.filter((r) => r.success).length,
failureCount: responses.filter((r) => !r.success).length,
};
},
}),
}));
const { pushAllowed } = await import('../src/notify/push.js');
const { pushInboxItem } = await import('../src/notify/inbox.js');
const { bootAdmin, shutdownAdmin } = await import('./support.js');
const U = 'test-push-user';
const quest = {
eventType: 'quest' as const, type: 'essential' as const,
title: '[만보기] 퀘스트를 완료했어요!', source: '퀘스트', targetPath: 'quests/q1',
};
const eclipse = {
eventType: 'eclipse' as const, type: 'fandomEvent' as const,
title: '내 행성의 이클립스가 발생했어요.', source: 'BTS',
};
beforeAll(() => { bootAdmin(); });
beforeEach(async () => {
sent.length = 0;
await getFirestore().recursiveDelete(getFirestore().doc(`users/${U}`));
});
afterAll(async () => {
await getFirestore().recursiveDelete(getFirestore().doc(`users/${U}`));
await shutdownAdmin();
});
it('기본값은 Marketing 만 OFF · 서비스 필수는 끌 수 없다 (B-77)', () => {
expect(pushAllowed(undefined, 'fandomEvent')).toBe(true);
expect(pushAllowed(undefined, 'marketing')).toBe(false);
expect(pushAllowed({ fandomEvent: false }, 'fandomEvent')).toBe(false);
expect(pushAllowed({ essential: false }, 'essential')).toBe(true);
expect(pushAllowed({ marketing: true }, 'marketing')).toBe(true);
});
it('알림함 항목 문구 그대로 그 사용자 단말 전부에 보내고 죽은 토큰은 걷어 낸다', async () => {
await getFirestore().doc(`users/${U}`).set({ fcmTokens: ['t1', 'dead', 't1'] });
expect(await pushInboxItem(U, quest)).toBe('sent');
expect(sent).toHaveLength(1);
expect(sent[0]!.tokens).toEqual(['t1', 'dead']);
expect(sent[0]!.notification).toEqual({ title: 'FANUZU', body: '[만보기] 퀘스트를 완료했어요!' });
expect(sent[0]!.data).toMatchObject({ category: 'essential', kind: 'quest', targetPath: 'quests/q1' });
expect((await getFirestore().doc(`users/${U}`).get()).get('fcmTokens')).toEqual(['t1', 't1']);
});
it('설정에서 끈 구분은 보내지 않는다 — OFF 발송 0건', async () => {
await getFirestore().doc(`users/${U}`).set({ fcmTokens: ['t1'], notificationPrefs: { fandomEvent: false } });
expect(await pushInboxItem(U, eclipse)).toBe('off');
expect(await pushInboxItem(U, quest)).toBe('sent');
expect(sent).toHaveLength(1);
});
it('토큰이 없으면 보내지 않는다 · 구분을 모르는 항목은 건너뛴다', async () => {
await getFirestore().doc(`users/${U}`).set({});
expect(await pushInboxItem(U, eclipse)).toBe('no-token');
expect(await pushInboxItem(U, { title: 'x' })).toBeNull();
expect(sent).toHaveLength(0);
});