이벤트별 개인 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
@@ -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분」.
*