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

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
@@ -48,8 +48,16 @@ export interface BriefingCandidate {
articleUrl: string;
/** AI 요약 본문. */
aiSummary: string;
/**
* 출처 구분 — PRD-06 「공식 출처와 팬덤 커뮤니티 출처를 구분 표시」 · 9/18 v1.5.
* 선택 필드다(수신 규격 확정 전 · 해석 확인 요청). 받으면 카드에 표시한다.
*/
sourceKind?: SourceKind;
}
export const SOURCE_KINDS = ['official', 'fandom'] as const;
export type SourceKind = typeof SOURCE_KINDS[number];
export interface IngestResult {
accepted: number;
rejected: Array<{ index: number; reason: string }>;
@@ -116,9 +124,15 @@ export function validateCandidate(
}
}
const kind = text('sourceKind');
if (kind !== null && !(SOURCE_KINDS as readonly string[]).includes(kind)) {
return { ok: false, reason: `sourceKind 는 ${SOURCE_KINDS.join('|')} 중 하나여야 합니다.` };
}
return {
ok: true,
value: {
...(kind ? { sourceKind: kind as SourceKind } : {}),
fandomId,
section: section as Section,
title: required.title!,
@@ -189,6 +203,7 @@ export const fnBriefingIngest = onCall(async (req) => {
sourceUrl: candidate.sourceUrl,
articleUrl: candidate.articleUrl,
aiSummary: candidate.aiSummary,
...(candidate.sourceKind ? { sourceKind: candidate.sourceKind } : {}),
// B-75 「AI 요약 표시」 — 사람이 쓴 글이 아님을 화면이 밝힐 근거다.
aiGenerated: true,
// B-47 「AI Draft 자동게시 금지」 — 수신은 언제나 draft 로 끝난다.
@@ -226,14 +241,20 @@ export const fnBriefingPublish = onCall(async (req) => {
const role = requireRole(req, ['master', 'ops']);
const fandomId = str(req.data, 'fandomId');
const day = str(req.data, 'dayKey');
// 항목별 승인(상세정의 §12 「운영 검수 → 승인」) — `itemIds` 를 주면 그 초안만 게시한다.
const itemIds = itemIdsOf(req.data);
const db = getFirestore();
const drafts = await db.collection('briefings')
const all = await db.collection('briefings')
.where('fandomId', '==', fandomId)
.where('dayKey', '==', day)
.where('status', '==', 'draft')
.get();
if (drafts.empty) {
const drafts = { docs: itemIds ? all.docs.filter((d) => itemIds.includes(d.id)) : all.docs };
if (itemIds && drafts.docs.length !== itemIds.length) {
fail(ERR.permissionDenied, '이 팬덤 · 날짜의 초안이 아닌 항목이 있습니다.');
}
if (drafts.docs.length === 0) {
fail(ERR.permissionDenied, '게시할 브리핑 초안이 없습니다.');
}
@@ -261,7 +282,7 @@ export const fnBriefingPublish = onCall(async (req) => {
targetPath: `briefings/${fandomId}_${day}`,
targetType: 'briefing',
reason: 'BRIEFING_PUBLISH',
patch: { status: 'published', count: drafts.size, bySection },
patch: { status: 'published', count: drafts.docs.length, bySection, itemIds: drafts.docs.map((d) => d.id) },
createdAt: FieldValue.serverTimestamp(),
});
@@ -278,7 +299,48 @@ export const fnBriefingPublish = onCall(async (req) => {
});
return {
fandomId, dayKey: day, published: drafts.size, bySection,
fandomId, dayKey: day, published: drafts.docs.length, bySection,
pushed: pushed === true,
};
});
function itemIdsOf(data: unknown): string[] | undefined {
const raw = (data as Record<string, unknown> | null)?.itemIds;
if (raw === undefined) return undefined;
if (!Array.isArray(raw) || raw.length === 0 || raw.some((v) => typeof v !== 'string' || !v)) {
fail(ERR.permissionDenied, 'itemIds 는 비어 있지 않은 문자열 배열이어야 합니다.');
}
return [...new Set(raw as string[])];
}
/**
* `fn-briefing-reject` — 항목별 반려(상세정의 §12 「운영 검수」 · 별첨 A §3 「Draft Review」).
* 반려 사유를 남기고 `rejected` 로 닫는다 — 게시 대상에서 빠지고 기록은 보존한다.
*/
export const fnBriefingReject = onCall(async (req) => {
const actorUid = callerUid(req);
const role = requireRole(req, ['master', 'ops']);
const itemIds = itemIdsOf(req.data);
if (!itemIds) fail(ERR.permissionDenied, '반려할 항목이 없습니다.');
const reason = str(req.data, 'reason');
const db = getFirestore();
const refs = itemIds.map((id) => db.doc(`briefings/${id}`));
await db.runTransaction(async (tx) => {
const snaps = await tx.getAll(...refs);
for (const snap of snaps) {
if (snap.get('status') !== 'draft') fail(ERR.permissionDenied, '초안만 반려할 수 있습니다.');
}
for (const snap of snaps) {
tx.update(snap.ref, {
status: 'rejected', rejectReason: reason,
rejectedBy: actorUid, rejectedAt: FieldValue.serverTimestamp(),
});
tx.create(db.collection('adminActionLogs').doc(), {
actorUid, role, targetPath: snap.ref.path, targetType: 'briefing',
reason: 'BRIEFING_REJECT', patch: { status: 'rejected', rejectReason: reason },
createdAt: FieldValue.serverTimestamp(),
});
}
});
return { rejected: itemIds.length };
});
+2 -1
View File
@@ -51,7 +51,8 @@ export {
export {
fnTranslationSubmit, fnTranslationMark, fnTranslationSettle,
} from './account/translation.js';
export { fnBriefingIngest, fnBriefingPublish } from './briefing/briefing.js';
export { fnBriefingIngest, fnBriefingPublish, fnBriefingReject } from './briefing/briefing.js';
export { fnInboxEclipse, fnInboxHeroDrop, fnInboxQuestDone } from './notify/inbox.js';
export { fnPlanetEvolutionRecord, fnAdminPlanetEvolutionRun } from './fandom/evolution.js';
export { fnPlanetHistory } from './fandom/history.js';
+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() ?? {},
);
},
);
+46 -1
View File
@@ -4,7 +4,7 @@ import type { CallableRequest } from 'firebase-functions/v2/https';
import { bootAdmin, shutdownAdmin } from './support.js';
import {
candidateDocId, fnBriefingIngest, fnBriefingPublish, validateCandidate,
candidateDocId, fnBriefingIngest, fnBriefingPublish, fnBriefingReject, validateCandidate,
} from '../src/briefing/briefing.js';
import { dayKey } from '../src/core/time.js';
import { fandomTopic, notifyEclipseWarning } from '../src/notify/push.js';
@@ -276,6 +276,51 @@ describe('승인 — fn-briefing-publish (export 직접 호출)', () => {
});
});
describe('항목별 검수 — 승인 · 반려(상세정의 §12)', () => {
const day = () => dayKey(new Date());
async function ids(): Promise<string[]> {
await fnBriefingIngest.run(req('ops', {
items: [
candidate({ sourceUrl: 'https://example.com/i/1' }),
candidate({ sourceUrl: 'https://example.com/i/2' }),
candidate({ section: 'SNS', sourceUrl: 'https://example.com/i/3', sourceKind: 'fandom' }),
],
}));
const docs = await getFirestore().collection('briefings').where('fandomId', '==', FANDOM).get();
return docs.docs.map((d) => d.id).sort();
}
it('출처 구분은 선택 필드 — 받으면 저장하고, 정해진 값이 아니면 거부한다', async () => {
expect(validateCandidate(candidate({ sourceKind: 'blog' })).ok).toBe(false);
const [, , third] = await ids();
const kinds = (await getFirestore().collection('briefings').where('fandomId', '==', FANDOM).get())
.docs.map((d) => d.get('sourceKind'));
expect(kinds.filter((k) => k === 'fandom')).toHaveLength(1);
expect(third).toBeTruthy();
});
it('고른 항목만 게시하고, 반려는 사유와 함께 닫고 다시 게시되지 않는다', async () => {
const [a, b, c] = await ids();
await fnBriefingReject.run(req('ops', { itemIds: [b], reason: '출처 불명확' }));
const result = await fnBriefingPublish.run(req('ops', { fandomId: FANDOM, dayKey: day(), itemIds: [a] }));
expect(result.published).toBe(1);
const db = getFirestore();
expect((await db.doc(`briefings/${a}`).get()).get('status')).toBe('published');
const rejected = await db.doc(`briefings/${b}`).get();
expect(rejected.get('status')).toBe('rejected');
expect(rejected.get('rejectReason')).toBe('출처 불명확');
expect((await db.doc(`briefings/${c}`).get()).get('status')).toBe('draft');
// 반려 · 게시된 항목은 다시 고를 수 없다.
await expect(fnBriefingPublish.run(req('ops', { fandomId: FANDOM, dayKey: day(), itemIds: [b] })))
.rejects.toThrowError('초안이 아닌 항목');
await expect(fnBriefingReject.run(req('ops', { itemIds: [a], reason: 'x' })))
.rejects.toThrowError('초안만 반려');
await expect(fnBriefingReject.run(req('viewer', { itemIds: [c], reason: 'x' })))
.rejects.toThrowError('권한이 없습니다.');
});
});
describe('당일 필터 — 앱이 읽는 질의', () => {
it('게시된 당일분만 고르고 초안과 전일분은 제외한다', async () => {
const db = getFirestore();
+69
View File
@@ -0,0 +1,69 @@
import { afterAll, beforeAll, expect, it } from 'vitest';
import { getFirestore } from 'firebase-admin/firestore';
import { notifyEclipseTransition, notifyHeroDrop, notifyQuestDone } from '../src/notify/inbox.js';
import { bootAdmin, shutdownAdmin } from './support.js';
/** 알림함 항목 — Figma `229:11265` 표본 문구 · 명세 `518:88353` 이동 규칙의 서버 절반. */
const FANDOM = 'test-inbox-fandom';
const A = 'test-inbox-a';
const B = 'test-inbox-b';
async function inbox(uid: string) {
const snap = await getFirestore().collection(`users/${uid}/notifications`).get();
return snap.docs.map((d) => d.data());
}
async function wipe() {
const db = getFirestore();
for (const uid of [A, B]) await db.recursiveDelete(db.doc(`users/${uid}`));
await db.recursiveDelete(db.doc(`fandoms/${FANDOM}`));
await db.doc('quests/test-inbox-quest').delete();
}
beforeAll(async () => {
bootAdmin();
await wipe();
const db = getFirestore();
await db.doc(`fandoms/${FANDOM}`).set({ name: 'BTS' });
for (const uid of [A, B]) await db.doc(`fandoms/${FANDOM}/members/${uid}`).set({ joinedAt: new Date() });
await db.doc('quests/test-inbox-quest').set({ title: '만보기' });
});
afterAll(async () => { await wipe(); await shutdownAdmin(); });
it('이클립스 발생 · 방어는 회원 전원에게 한 건씩, 재전달해도 늘지 않는다', async () => {
expect(await notifyEclipseTransition(FANDOM, 'r1', { status: 'scheduled' }, { status: 'active' })).toBe('started');
expect(await notifyEclipseTransition(FANDOM, 'r1', { status: 'scheduled' }, { status: 'active' })).toBe('started');
expect(await notifyEclipseTransition(FANDOM, 'r1', { status: 'active' }, { status: 'ended', result: 'success' })).toBe('defended');
// 실패 종료 · 상태 변화 없음은 알리지 않는다.
expect(await notifyEclipseTransition(FANDOM, 'r2', { status: 'active' }, { status: 'ended', result: 'failed' })).toBeNull();
expect(await notifyEclipseTransition(FANDOM, 'r1', { status: 'active' }, { status: 'active' })).toBeNull();
for (const uid of [A, B]) {
const items = await inbox(uid);
const titles = items.map((n) => n.title).sort();
expect(titles).toEqual(['내 행성의 이클립스가 발생했어요.', '내 행성의 이클립스를 방어했어요.']);
expect(items.every((n) => n.eventType === 'eclipse' && n.source === 'BTS' && !n.targetPath)).toBe(true);
}
});
it('영웅의 선물 발동은 회원 전원에게 발동자 닉네임으로', async () => {
await notifyHeroDrop(FANDOM, 'd1', '팜하니짱');
const hero = (await inbox(B)).filter((n) => n.eventType === 'item_used');
expect(hero).toHaveLength(1);
expect(hero[0]!.title).toBe('팜하니짱님의 고귀한 기여로 영웅의 선물이 발동되었습니다!');
expect(hero[0]!.source).toBe('아이템');
});
it('퀘스트 완료는 횟수가 오른 때만 본인에게, 상세 경로를 싣는다', async () => {
const day = '2026-10-05';
expect(await notifyQuestDone(A, 'test-inbox-quest', undefined, { dayKey: day, count: 1 })).toBe(true);
// 같은 횟수 재기록(updatedAt 만 바뀜)은 알리지 않는다.
expect(await notifyQuestDone(A, 'test-inbox-quest', { dayKey: day, count: 1 }, { dayKey: day, count: 1 })).toBe(false);
expect(await notifyQuestDone(A, 'test-inbox-quest', { dayKey: day, count: 1 }, { dayKey: day, count: 2 })).toBe(true);
const quest = (await inbox(A)).filter((n) => n.eventType === 'quest');
expect(quest).toHaveLength(2);
expect(quest[0]!.title).toBe('[만보기] 퀘스트를 완료했어요!');
expect(quest[0]!.targetPath).toBe('quests/test-inbox-quest');
});