포그라운드 Push 표시 · 퀘스트 알림 다국어 제목 · 약관 미동의 계정의 행성 가입·성운 생성 차단
This commit is contained in:
@@ -147,6 +147,14 @@ function primaryPatch(user: Data, fandomId: string, cooldownDays: number, now: n
|
|||||||
* - `users/{uid}/memberships/{id}` — 「가입 또는 생성한 행성/성운」 목록(명세 §2.58)의 원천.
|
* - `users/{uid}/memberships/{id}` — 「가입 또는 생성한 행성/성운」 목록(명세 §2.58)의 원천.
|
||||||
* - `memberCount` 는 같은 트랜잭션에서 올린다 — 전환 조건(B-10)이 이 값을 읽는다.
|
* - `memberCount` 는 같은 트랜잭션에서 올린다 — 전환 조건(B-10)이 이 값을 읽는다.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 가입 약관(`termsAgreedAt`, [fnSignupConsent]) 없이 행성 · 성운 멤버가 되지 않게 막는다.
|
||||||
|
* 앱은 약관 화면부터 보내지만(app_router 게이트) 서버도 같은 기준을 지킨다 — 10/6 실기기 판정.
|
||||||
|
*/
|
||||||
|
function requireTerms(user: Data): void {
|
||||||
|
if (!user?.termsAgreedAt) fail(ERR.permissionDenied, '이용 약관에 동의한 뒤 가입할 수 있습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
function writeJoin(tx: Transaction, uid: string, fandomId: string, fandom: Data): void {
|
function writeJoin(tx: Transaction, uid: string, fandomId: string, fandom: Data): void {
|
||||||
const db = getFirestore();
|
const db = getFirestore();
|
||||||
const memberNo = Number(fandom?.memberSeq ?? fandom?.memberCount ?? 0) + 1;
|
const memberNo = Number(fandom?.memberSeq ?? fandom?.memberCount ?? 0) + 1;
|
||||||
@@ -199,6 +207,7 @@ export const fnNebulaCreate = onCall(async (req) => {
|
|||||||
const [todaySnap, nameSnap, userSnap] = await Promise.all([
|
const [todaySnap, nameSnap, userSnap] = await Promise.all([
|
||||||
tx.get(todayRef), tx.get(nameRef), tx.get(userRef),
|
tx.get(todayRef), tx.get(nameRef), tx.get(userRef),
|
||||||
]);
|
]);
|
||||||
|
requireTerms(userSnap.data());
|
||||||
if (todaySnap.exists) fail(ERR.nebulaDailyLimit, '이미 오늘 성운을 생성하셨습니다.');
|
if (todaySnap.exists) fail(ERR.nebulaDailyLimit, '이미 오늘 성운을 생성하셨습니다.');
|
||||||
if (nameSnap.exists) fail(ERR.fandomNameTaken, '이미 사용중인 이름입니다.');
|
if (nameSnap.exists) fail(ERR.fandomNameTaken, '이미 사용중인 이름입니다.');
|
||||||
const { patch } = primaryPatch(userSnap.data(), fandom.id, policy.primaryCooldownDays, now);
|
const { patch } = primaryPatch(userSnap.data(), fandom.id, policy.primaryCooldownDays, now);
|
||||||
@@ -243,6 +252,7 @@ export const fnFandomJoin = onCall(async (req) => {
|
|||||||
]);
|
]);
|
||||||
if (!fandomSnap.exists) fail(ERR.permissionDenied, '행성을 찾을 수 없습니다.');
|
if (!fandomSnap.exists) fail(ERR.permissionDenied, '행성을 찾을 수 없습니다.');
|
||||||
if (memberSnap.exists) return { fandomId, joined: false };
|
if (memberSnap.exists) return { fandomId, joined: false };
|
||||||
|
requireTerms(userSnap.data());
|
||||||
writeJoin(tx, uid, fandomId, fandomSnap.data());
|
writeJoin(tx, uid, fandomId, fandomSnap.data());
|
||||||
// 원장 S20(확인 전 유지 기준) — 창립자가 혼자 떠나 탄생 권한자가 빈 성운은 다음 가입자가 이어받는다.
|
// 원장 S20(확인 전 유지 기준) — 창립자가 혼자 떠나 탄생 권한자가 빈 성운은 다음 가입자가 이어받는다.
|
||||||
if (fandomSnap.get('stage') === 0 && !fandomSnap.get('founderUid')) {
|
if (fandomSnap.get('stage') === 0 && !fandomSnap.get('founderUid')) {
|
||||||
|
|||||||
@@ -11,3 +11,20 @@ export function publicNameOf(user: { get(field: string): unknown } | null | unde
|
|||||||
const v = typeof raw === 'string' ? raw.trim() : '';
|
const v = typeof raw === 'string' ? raw.trim() : '';
|
||||||
return v.length === 0 || v.includes('@') ? PUBLIC_NAME_FALLBACK : v;
|
return v.length === 0 || v.includes('@') ? PUBLIC_NAME_FALLBACK : v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다국어 값 — 문자열이거나 언어별 지도(`{ko, en, …}`, 운영 Quest 제목 등 설계서 §2.8).
|
||||||
|
* 지도면 `locale`(`users/{uid}.locale`, 예 `zh-CN`) → 그 언어 코드 → ko → en → 아무 값.
|
||||||
|
* 앱 `SnapshotReader.textOr` 와 같은 계산. 알림 문구에 지도를 그대로 넣으면 「[object Object]」가 된다.
|
||||||
|
*/
|
||||||
|
export function localizedText(value: unknown, locale?: unknown): string {
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (!value || typeof value !== 'object') return '';
|
||||||
|
const map = value as Record<string, unknown>;
|
||||||
|
const tag = typeof locale === 'string' ? locale : '';
|
||||||
|
for (const k of [tag, tag.split('-')[0], 'ko', 'en']) {
|
||||||
|
const v = k ? map[k] : undefined;
|
||||||
|
if (typeof v === 'string' && v) return v;
|
||||||
|
}
|
||||||
|
return Object.values(map).find((v): v is string => typeof v === 'string' && v.length > 0) ?? '';
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { FieldValue, getFirestore } from 'firebase-admin/firestore';
|
|||||||
import { onDocumentCreated, onDocumentUpdated, onDocumentWritten } from 'firebase-functions/v2/firestore';
|
import { onDocumentCreated, onDocumentUpdated, onDocumentWritten } from 'firebase-functions/v2/firestore';
|
||||||
import { logger } from 'firebase-functions';
|
import { logger } from 'firebase-functions';
|
||||||
|
|
||||||
|
import { localizedText } from '../core/names.js';
|
||||||
import { PUSH_CATEGORIES, sendUserPush, type UserPushOutcome } from './push.js';
|
import { PUSH_CATEGORIES, sendUserPush, type UserPushOutcome } from './push.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,8 +100,9 @@ export async function notifyQuestDone(
|
|||||||
const count = typeof after.count === 'number' ? after.count : 0;
|
const count = typeof after.count === 'number' ? after.count : 0;
|
||||||
if (count < 1) return false;
|
if (count < 1) return false;
|
||||||
if (before && before.dayKey === after.dayKey && (before.count as number | undefined ?? 0) >= count) 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 db = getFirestore();
|
||||||
const title = (quest.get('title') as string | undefined) ?? '';
|
const [quest, user] = await Promise.all([db.doc(`quests/${questId}`).get(), db.doc(`users/${uid}`).get()]);
|
||||||
|
const title = localizedText(quest.get('title'), user.get('locale'));
|
||||||
return notifyOnce(uid, `quest_${questId}_${String(after.dayKey)}_${count}`, {
|
return notifyOnce(uid, `quest_${questId}_${String(after.dayKey)}_${count}`, {
|
||||||
eventType: 'quest', type: 'essential',
|
eventType: 'quest', type: 'essential',
|
||||||
title: `[${title}] 퀘스트를 완료했어요!`, source: '퀘스트',
|
title: `[${title}] 퀘스트를 완료했어요!`, source: '퀘스트',
|
||||||
|
|||||||
@@ -67,3 +67,14 @@ it('퀘스트 완료는 횟수가 오른 때만 본인에게, 상세 경로를
|
|||||||
expect(quest[0]!.title).toBe('[만보기] 퀘스트를 완료했어요!');
|
expect(quest[0]!.title).toBe('[만보기] 퀘스트를 완료했어요!');
|
||||||
expect(quest[0]!.targetPath).toBe('quests/test-inbox-quest');
|
expect(quest[0]!.targetPath).toBe('quests/test-inbox-quest');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('운영 Quest 의 다국어 제목은 사용자 언어(없으면 ko)로 푼다', async () => {
|
||||||
|
const db = getFirestore();
|
||||||
|
await db.doc('quests/test-inbox-quest').set({ title: { ko: '신곡 스트리밍', en: 'Stream the single' } });
|
||||||
|
await db.doc(`users/${B}`).set({ locale: 'en' });
|
||||||
|
await notifyQuestDone(A, 'test-inbox-quest', undefined, { dayKey: '2026-10-06', count: 1 });
|
||||||
|
await notifyQuestDone(B, 'test-inbox-quest', undefined, { dayKey: '2026-10-06', count: 1 });
|
||||||
|
const titles = async (uid: string) => (await inbox(uid)).filter((n) => n.eventType === 'quest').map((n) => n.title);
|
||||||
|
expect(await titles(A)).toContain('[신곡 스트리밍] 퀘스트를 완료했어요!');
|
||||||
|
expect(await titles(B)).toEqual(['[Stream the single] 퀘스트를 완료했어요!']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
import { FieldValue, getFirestore, Timestamp } from 'firebase-admin/firestore';
|
import { FieldValue, getFirestore, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
|
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
|
||||||
@@ -36,9 +36,14 @@ const user = async (uid = UID) => (await db().doc(`users/${uid}`).get()).data()
|
|||||||
const fandom = async (id: string) => (await db().doc(`fandoms/${id}`).get()).data() ?? {};
|
const fandom = async (id: string) => (await db().doc(`fandoms/${id}`).get()).data() ?? {};
|
||||||
|
|
||||||
beforeAll(() => { bootAdmin(); });
|
beforeAll(() => { bootAdmin(); });
|
||||||
|
// 가입 · 성운 생성은 약관 동의(`termsAgreedAt`)가 있어야 한다 — 시험 계정은 동의를 마친 상태로 시작한다.
|
||||||
|
const agree = (uid: string) => db().doc(`users/${uid}`).set({ termsAgreedAt: Timestamp.now() }, { merge: true });
|
||||||
|
beforeEach(async () => {
|
||||||
|
for (const uid of [UID, OTHER, 'test-planet-create-third']) await agree(uid);
|
||||||
|
});
|
||||||
afterAll(async () => { await shutdownAdmin(); });
|
afterAll(async () => { await shutdownAdmin(); });
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
for (const uid of [UID, OTHER]) {
|
for (const uid of [UID, OTHER, 'test-planet-create-third']) {
|
||||||
await wipeUser(uid);
|
await wipeUser(uid);
|
||||||
await db().recursiveDelete(db().collection(`users/${uid}/nebulaCreates`));
|
await db().recursiveDelete(db().collection(`users/${uid}/nebulaCreates`));
|
||||||
await db().recursiveDelete(db().collection(`users/${uid}/memberships`));
|
await db().recursiveDelete(db().collection(`users/${uid}/memberships`));
|
||||||
@@ -64,6 +69,7 @@ describe('성운 생성', () => {
|
|||||||
|
|
||||||
it('대표 변경 30일 대기 중이면 성운 · 멤버십만 만들고 대표는 그대로 둔다(C-15)', async () => {
|
it('대표 변경 30일 대기 중이면 성운 · 멤버십만 만들고 대표는 그대로 둔다(C-15)', async () => {
|
||||||
await db().doc(`users/${UID}`).set({
|
await db().doc(`users/${UID}`).set({
|
||||||
|
termsAgreedAt: Timestamp.now(),
|
||||||
primaryFandomId: 'old', fandomId: 'old', primaryChangedAt: Timestamp.fromMillis(Date.now() - 86_400_000),
|
primaryFandomId: 'old', fandomId: 'old', primaryChangedAt: Timestamp.fromMillis(Date.now() - 86_400_000),
|
||||||
});
|
});
|
||||||
const { fandomId, primaryChanged } = await call(fnNebulaCreate, { name: 'Cooldown' });
|
const { fandomId, primaryChanged } = await call(fnNebulaCreate, { name: 'Cooldown' });
|
||||||
@@ -106,6 +112,14 @@ describe('가입 · 탈퇴 · 대표 팬덤', () => {
|
|||||||
expect((await user()).fandomIds).toEqual([id]);
|
expect((await user()).fandomIds).toEqual([id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('약관에 동의하지 않은 계정은 가입 · 성운 생성을 거부한다', async () => {
|
||||||
|
const { fandomId } = await call(fnNebulaCreate, { name: 'Terms Gate' });
|
||||||
|
await db().doc(`users/${OTHER}`).set({ termsAgreedAt: FieldValue.delete() }, { merge: true });
|
||||||
|
expect(await codeOf(() => call(fnFandomJoin, { fandomId }, OTHER))).toBe('E-PERM-01');
|
||||||
|
expect(await codeOf(() => call(fnNebulaCreate, { name: 'Terms Gate Two' }, OTHER))).toBe('E-PERM-01');
|
||||||
|
expect((await fandom(fandomId as string)).memberCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('없는 팬덤 가입 · 가입하지 않은 팬덤을 대표로 지정은 거부한다', async () => {
|
it('없는 팬덤 가입 · 가입하지 않은 팬덤을 대표로 지정은 거부한다', async () => {
|
||||||
expect(await codeOf(() => call(fnFandomJoin, { fandomId: 'missing-fandom' }))).toBe('E-PERM-01');
|
expect(await codeOf(() => call(fnFandomJoin, { fandomId: 'missing-fandom' }))).toBe('E-PERM-01');
|
||||||
const { fandomId } = await call(fnNebulaCreate, { name: 'NotMine' }, OTHER);
|
const { fandomId } = await call(fnNebulaCreate, { name: 'NotMine' }, OTHER);
|
||||||
|
|||||||
+8
-1
@@ -194,7 +194,14 @@ class _FancakeAppState extends State<FancakeApp> with WidgetsBindingObserver {
|
|||||||
..showSnackBar(
|
..showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
content: Text(notice.title.isEmpty ? notice.body : notice.title),
|
// action 이 있으면 기본으로 남아 있으므로(Flutter 3.29+) 시간이 지나면 닫히게 한다.
|
||||||
|
persist: false,
|
||||||
|
duration: const Duration(seconds: 4),
|
||||||
|
content: Text(
|
||||||
|
[notice.title, notice.body].where((s) => s.isNotEmpty).join('\n'),
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
action: SnackBarAction(
|
action: SnackBarAction(
|
||||||
label: (_messengerKey.currentContext ?? context).l10n.commonView,
|
label: (_messengerKey.currentContext ?? context).l10n.commonView,
|
||||||
onPressed: () => _openFromPush(notice),
|
onPressed: () => _openFromPush(notice),
|
||||||
|
|||||||
Reference in New Issue
Block a user