알림함 항목 생성 · 알림 이동 · 브리핑 항목 승인·상세 · 푸시 토픽을 가입 팬덤 전체로

This commit is contained in:
Sn-Kinos
2026-10-05 22:48:10 +09:00
parent fbf9a2c3d7
commit cf608a1d99
27 changed files with 714 additions and 69 deletions
+136
View File
@@ -0,0 +1,136 @@
import { FieldValue, getFirestore } from 'firebase-admin/firestore';
import { onDocumentCreated, onDocumentUpdated, onDocumentWritten } from 'firebase-functions/v2/firestore';
import { logger } from 'firebase-functions';
/**
* 알림함(`users/{uid}/notifications`) 항목 생성 — Figma `229:11265` 홈/알림 · 명세 `518:88353`.
*
* Figma 표본의 문구 · 출처 표기를 그대로 쓴다. 2차 범위(이클립스 · 퀘스트 · 영웅의 선물)만
* 여기서 만든다. INSIDE · 캠페인 댓글 · 좋아요 알림은 3차(10/16) 범위다.
* - 「내 행성의 이클립스를 방어했어요.」 · 「내 행성의 이클립스가 발생했어요.」 행성명 → 이동 X
* - 「○○님의 고귀한 기여로 영웅의 선물이 발동되었습니다!」 아이템 → 이동 X
* - 「[퀘스트명] 퀘스트를 완료했어요!」 퀘스트 → 해당 퀘스트 상세 이동
*
* 문서 필드: `eventType`(앱 이동 축) · `type`(B-77 구분) · `title` · `source` · `targetPath`.
* 문서 ID 를 사건마다 고정해 트리거 재전달이 같은 알림을 두 번 쌓지 않는다.
*/
export type InboxEvent = 'comment' | 'like' | 'campaign' | 'quest' | 'eclipse' | 'item_used';
export interface InboxItem {
eventType: InboxEvent;
type: 'essential' | 'fandomEvent' | 'community';
title: string;
source: string;
targetPath?: string;
}
export async function notifyOnce(uid: string, id: string, item: InboxItem): Promise<boolean> {
try {
await getFirestore().doc(`users/${uid}/notifications/${id}`).create({
...item,
read: false,
createdAt: FieldValue.serverTimestamp(),
});
return true;
} catch (error) {
// 이미 쌓은 알림(ALREADY_EXISTS = 6)은 정상이다.
if ((error as { code?: number }).code !== 6) {
logger.error('[inbox] 알림 기록 실패', { uid, id, message: (error as Error).message });
}
return false;
}
}
/**
* 팬덤 회원 전원에게 한 건씩. 이클립스 · 영웅의 선물처럼 「내 행성」 사건이다.
* ponytail: 회원을 한 번에 읽는다. 회원 수가 수십만이 되면 페이지 단위 + 작업 큐로.
*/
export async function notifyMembers(fandomId: string, id: string, item: InboxItem): Promise<number> {
const db = getFirestore();
const members = await db.collection(`fandoms/${fandomId}/members`).select().get();
const writer = db.bulkWriter();
writer.onWriteError((e) => e.code !== 6 && e.failedAttempts < 3);
for (const m of members.docs) {
writer.create(db.doc(`users/${m.id}/notifications/${id}`), {
...item, read: false, createdAt: FieldValue.serverTimestamp(),
}).catch(() => undefined);
}
await writer.close();
return members.size;
}
/** 이클립스 회차 상태 전이 → 회원 전원. 발생(active 진입) · 방어(ended + success). */
export async function notifyEclipseTransition(
fandomId: string, roundId: string,
before: { status?: unknown }, after: { status?: unknown; result?: unknown },
): Promise<'started' | 'defended' | null> {
const started = before.status !== 'active' && after.status === 'active';
const defended = before.status !== 'ended' && after.status === 'ended' && after.result === 'success';
if (!started && !defended) return null;
const name = ((await getFirestore().doc(`fandoms/${fandomId}`).get()).get('name') as string | undefined) ?? '';
await notifyMembers(fandomId, `eclipse_${fandomId}_${roundId}_${started ? 'start' : 'defended'}`, {
eventType: 'eclipse', type: 'fandomEvent',
title: started ? '내 행성의 이클립스가 발생했어요.' : '내 행성의 이클립스를 방어했어요.',
source: name,
});
return started ? 'started' : 'defended';
}
/** 영웅의 선물 발동 → 회원 전원. */
export async function notifyHeroDrop(fandomId: string, dropId: string, heroNickname: unknown): Promise<number> {
const hero = typeof heroNickname === 'string' && heroNickname ? heroNickname : '팬';
return notifyMembers(fandomId, `herodrop_${fandomId}_${dropId}`, {
eventType: 'item_used', type: 'fandomEvent',
title: `${hero}님의 고귀한 기여로 영웅의 선물이 발동되었습니다!`, source: '아이템',
});
}
/**
* 퀘스트 완료 → 본인. 진행 문서(`questProgress`)는 완료 처리 트랜잭션에서만 횟수가 오른다
* (`progressWriter`). 같은 날 횟수가 늘었거나 새 날의 첫 완료일 때 한 건.
*/
export async function notifyQuestDone(
uid: string, questId: string,
before: { dayKey?: unknown; count?: unknown } | undefined,
after: { dayKey?: unknown; count?: unknown },
): Promise<boolean> {
const count = typeof after.count === 'number' ? after.count : 0;
if (count < 1) return false;
if (before && before.dayKey === after.dayKey && (before.count as number | undefined ?? 0) >= count) return false;
const quest = await getFirestore().doc(`quests/${questId}`).get();
const title = (quest.get('title') as string | undefined) ?? '';
return notifyOnce(uid, `quest_${questId}_${String(after.dayKey)}_${count}`, {
eventType: 'quest', type: 'essential',
title: `[${title}] 퀘스트를 완료했어요!`, source: '퀘스트',
targetPath: `quests/${questId}`,
});
}
export const fnInboxEclipse = onDocumentUpdated(
{ document: 'fandoms/{fandomId}/eclipseRounds/{roundId}', retry: true },
async (event) => {
const before = event.data?.before.data() ?? {};
const after = event.data?.after.data() ?? {};
await notifyEclipseTransition(event.params.fandomId, event.params.roundId, before, after);
},
);
export const fnInboxHeroDrop = onDocumentCreated(
{ document: 'fandoms/{fandomId}/heroDrops/{dropId}', retry: true },
async (event) => {
await notifyHeroDrop(event.params.fandomId, event.params.dropId, event.data?.get('heroNickname'));
},
);
export const fnInboxQuestDone = onDocumentWritten(
{ document: 'users/{uid}/questProgress/{questId}', retry: true },
async (event) => {
const after = event.data?.after;
if (!after?.exists) return;
const before = event.data?.before;
await notifyQuestDone(
event.params.uid, event.params.questId,
before?.exists ? before.data() : undefined, after.data() ?? {},
);
},
);