이벤트별 개인 Push 발송
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user