635 lines
32 KiB
TypeScript
635 lines
32 KiB
TypeScript
import * as functionsV1 from 'firebase-functions/v1';
|
|
import { HttpsError, onCall } from 'firebase-functions/v2/https';
|
|
import { getFirestore, FieldValue, Timestamp, type Transaction } from 'firebase-admin/firestore';
|
|
import { getAuth } from 'firebase-admin/auth';
|
|
import { getStorage } from 'firebase-admin/storage';
|
|
import { logger } from 'firebase-functions/v2';
|
|
|
|
import { callerUid, str } from '../core/callable.js';
|
|
import { dayKey } from '../core/time.js';
|
|
import { ERR, fail } from '../core/errors.js';
|
|
|
|
/**
|
|
* `fn-auth-oncreate` — 계정 문서의 유일한 생성 주체.
|
|
*
|
|
* 보안 규칙이 `users` 의 클라이언트 `create` 를 닫아 두었으므로
|
|
* 이 트리거가 만들지 않으면 계정 문서가 존재하지 않는다. 생성 주체를 하나로
|
|
* 고정한 이유는 경합 때문이다 — 클라이언트가 먼저 만들면 서버 산출 필드가 빈
|
|
* 사용자 문서가 남는다.
|
|
*
|
|
* v2 에는 사용자 생성 배경 트리거가 없어 v1 Auth 트리거를 쓴다. v1 은 `setGlobalOptions`(v2 전용)를
|
|
* 따르지 않으므로 리전을 여기서 직접 서울로 고정한다.
|
|
*/
|
|
export const fnAuthOnCreate = functionsV1.region('asia-northeast3').auth.user().onCreate(async (user) => {
|
|
const db = getFirestore();
|
|
const userRef = db.doc(`users/${user.uid}`);
|
|
const walletRef = db.doc(`users/${user.uid}/wallet/main`);
|
|
// 이메일 — 10/5 「플랫폼이 이메일 제공을 승인·동의한 경우에만 수집, 없어도 가입·로그인 가능」.
|
|
// Auth 계정 이메일이 비면(OIDC 등) 제공자 정보의 이메일을 쓴다. 카카오(커스텀 토큰)는
|
|
// 계정을 만든 뒤에 연결 정보가 붙으므로 `exchangeSocial`(social_token.ts)이 따로 쓴다.
|
|
const email = user.email ?? user.providerData.find((p) => p.email)?.email ?? null;
|
|
const defaults: Record<string, unknown> = {
|
|
uid: user.uid,
|
|
displayName: user.displayName ?? '팬우주 여행자',
|
|
photoUrl: user.photoURL ?? null,
|
|
// 국가는 정하지 않는다 — 온보딩 프로필 단계가 기기 지역을 기본값으로 받아 쓴다
|
|
// (해석 확인 Q-OB-02 A 「프로필 = 닉네임 · 국가 · 표시 언어」). 비어 있으면 국가 랭킹은 `ZZ`.
|
|
email,
|
|
locale: 'ko',
|
|
fandomId: '',
|
|
authProviders: user.providerData.map((p) => p.providerId),
|
|
totalContributedPop: 0,
|
|
representativeBadgeId: null,
|
|
status: 'active',
|
|
notificationPrefs: {},
|
|
createdAt: FieldValue.serverTimestamp(),
|
|
updatedAt: FieldValue.serverTimestamp(),
|
|
};
|
|
const wallet: Record<string, unknown> = {
|
|
activityBalance: 0,
|
|
supportBalance: 0,
|
|
pendingActivity: 0,
|
|
reconcileStatus: 'ok',
|
|
createdAt: FieldValue.serverTimestamp(),
|
|
updatedAt: FieldValue.serverTimestamp(),
|
|
};
|
|
|
|
// 트리거가 늦게 돌면 그 사이 앱이 쓴 값(약관 동의 · 닉네임 · 국가 · 이메일)이 이미 있다.
|
|
// 없는 필드만 채운다 — 덮어쓰면 온보딩 입력이 기본값으로 되돌아간다.
|
|
await db.runTransaction(async (tx) => {
|
|
const [u, w] = await Promise.all([tx.get(userRef), tx.get(walletRef)]);
|
|
const missing = (base: Record<string, unknown>, have: Record<string, unknown> | undefined) =>
|
|
Object.fromEntries(Object.entries(base).filter(([k]) => have?.[k] == null));
|
|
tx.set(userRef, missing(defaults, u.data()), { merge: true });
|
|
tx.set(walletRef, missing(wallet, w.data()), { merge: true });
|
|
});
|
|
});
|
|
|
|
/** 행성 생성 화면이 고르는 값 — 앱 `PlanetCreateScreen.shapes` · `.colors` 와 같은 키(C-50). */
|
|
export const PLANET_SHAPES = ['shape1', 'shape2', 'shape3', 'shape4', 'shape5', 'shape6'];
|
|
/**
|
|
* 컬러 **기본 목록** — Figma 「행성 색 선택」 명세 `2066:17910`(흰 · 검 · 하늘 · 초록 · 노랑 · 주황 · 빨강 · 보라).
|
|
* 10/3 통합회신 #6 「최신 Figma 팬덤컬러를 기본으로 하고 발주사가 컬러 항목을 추가할 수 있는 구조, 특정 8색 고정 적용
|
|
* 안 함」에 따라 실제 허용 목록은 컬러 마스터 `config/planetColors.colors[{key, hex, visible}]` 가 정한다
|
|
* (Firestore 설계서 §2.17 · C-115). 마스터가 없을 때만 이 목록을 쓴다.
|
|
*/
|
|
export const PLANET_COLORS = ['white', 'black', 'blue', 'green', 'gold', 'orange', 'red', 'purple'];
|
|
|
|
/** 생성 화면이 고를 수 있는(노출 중인) 컬러 키 — 마스터의 `visible !== false` 항목. */
|
|
export async function planetColorKeys(): Promise<string[]> {
|
|
const raw = (await getFirestore().doc('config/planetColors').get()).get('colors');
|
|
if (!Array.isArray(raw)) return PLANET_COLORS;
|
|
const keys = raw.filter((c) => c && typeof c.key === 'string' && c.key && c.visible !== false)
|
|
.map((c) => c.key as string);
|
|
return keys.length > 0 ? keys : PLANET_COLORS;
|
|
}
|
|
|
|
/**
|
|
* 운영값 기본 — 🟡 `config/policy` 가 덮는다.
|
|
* - 성운 전환: 정책 원장 B-10 「10명 이상 + 1,000 POP 이상 + 기본 목표 7일」.
|
|
* `nebulaConversionDeadlineEnforced` — 7일을 넘긴 성운의 전환을 막을지. B-10 「미달성 시 유지」와
|
|
* 「7일 이내 판정」이 갈려 발주사 확인 중이며, 회신 전에는 막지 않는다(false).
|
|
* - 대표 팬덤: 정책 원장 C-15 「변경 무료 · 재변경까지 초기 30일 대기 · 운영자 조정 가능」.
|
|
* - `primaryOnLeave` — 대표 팬덤에서 탈퇴할 때 남은 가입 팬덤 가운데 무엇을 대표로 둘지.
|
|
* 정책 원장 C-101 확정값은 `latest`(가장 최근 가입). `earliest` · `none` 은 확정 전 남겨 둔 운영 스위치.
|
|
* 자동 지정은 C-15 대기에 세지 않는다.
|
|
*/
|
|
const FANDOM_DEFAULTS = {
|
|
nebulaMinMembers: 10,
|
|
nebulaMinPop: 1_000,
|
|
nebulaDays: 7,
|
|
nebulaConversionDeadlineEnforced: false,
|
|
primaryCooldownDays: 30,
|
|
primaryOnLeave: 'latest' as 'latest' | 'earliest' | 'none',
|
|
};
|
|
|
|
async function fandomPolicy(): Promise<typeof FANDOM_DEFAULTS> {
|
|
const raw = (await getFirestore().doc('config/policy').get()).data() ?? {};
|
|
return { ...FANDOM_DEFAULTS, ...raw } as typeof FANDOM_DEFAULTS;
|
|
}
|
|
|
|
/** 기능 명세 §2.4 성운명 — 2~20자 · 한글/영문/숫자/공백 · 연속 공백 · 앞뒤 공백 불가. */
|
|
export function isValidNebulaName(name: string): boolean {
|
|
return /^[가-힣A-Za-z0-9 ]{2,20}$/.test(name) && name === name.trim() && !name.includes(' ');
|
|
}
|
|
|
|
type Data = Record<string, unknown> | undefined;
|
|
|
|
/** 현재 대표 팬덤. `primaryFandomId` 가 없던 계정은 옛 `fandomId` 를 쓴다(설계서 §2.1 이관 3). */
|
|
function primaryOf(user: Data): string | null {
|
|
const id = (user?.primaryFandomId ?? user?.fandomId) as string | null | undefined;
|
|
return id ? id : null;
|
|
}
|
|
|
|
/**
|
|
* 대표 팬덤을 [fandomId] 로 바꾸는 필드 — 정책 원장 C-15.
|
|
*
|
|
* 대표가 없던 계정의 첫 지정은 「변경」이 아니므로 대기를 걸지 않고 시각도 찍지 않는다.
|
|
* 이미 대표가 있으면 `primaryChangedAt + 대기일` 이 지나야 바뀐다. `patch` 가 null 이면
|
|
* 바꾸지 않는다(같은 팬덤이거나 대기 중 — 대기 중이면 `blocked`). 옛 독자를 위해
|
|
* `fandomId` 도 같은 값으로 쓴다.
|
|
*/
|
|
function primaryPatch(user: Data, fandomId: string, cooldownDays: number, now: number):
|
|
{ patch: Record<string, unknown> | null; blocked: boolean } {
|
|
const current = primaryOf(user);
|
|
if (current === fandomId) return { patch: null, blocked: false };
|
|
const base = { primaryFandomId: fandomId, fandomId, updatedAt: FieldValue.serverTimestamp() };
|
|
if (!current) return { patch: base, blocked: false };
|
|
const last = user?.primaryChangedAt;
|
|
if (last instanceof Timestamp && now < last.toMillis() + cooldownDays * 86_400_000) {
|
|
return { patch: null, blocked: true };
|
|
}
|
|
return { patch: { ...base, primaryChangedAt: FieldValue.serverTimestamp() }, blocked: false };
|
|
}
|
|
|
|
/**
|
|
* 멤버십 한 건을 트랜잭션 안에서 쓴다. 호출 측이 「아직 멤버가 아님」을 확인한 뒤에만 부른다.
|
|
*
|
|
* - `fandoms/{id}/members/{uid}` — 가입 사실 · 가입 순번(`memberNo`, 정책 원장 B-68 #001~ 기록).
|
|
* 순번은 탈퇴로 줄지 않는 `memberSeq` 에서 뽑아 재사용하지 않는다.
|
|
* - `users/{uid}/memberships/{id}` — 「가입 또는 생성한 행성/성운」 목록(명세 §2.58)의 원천.
|
|
* - `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 {
|
|
const db = getFirestore();
|
|
const memberNo = Number(fandom?.memberSeq ?? fandom?.memberCount ?? 0) + 1;
|
|
tx.set(db.doc(`fandoms/${fandomId}/members/${uid}`), {
|
|
uid,
|
|
fandomId,
|
|
memberNo,
|
|
contributedPop: 0,
|
|
joinedAt: FieldValue.serverTimestamp(),
|
|
updatedAt: FieldValue.serverTimestamp(),
|
|
});
|
|
tx.set(db.doc(`users/${uid}/memberships/${fandomId}`), {
|
|
fandomId,
|
|
joinedAt: FieldValue.serverTimestamp(),
|
|
});
|
|
// LIVE 집계(Q-GM-03)가 접속자 문서 한 건으로 가입 팬덤을 읽는 비정규화 — 서버만 쓴다(규칙이 막음).
|
|
tx.set(db.doc(`users/${uid}`), { fandomIds: FieldValue.arrayUnion(fandomId) }, { merge: true });
|
|
tx.set(db.doc(`fandoms/${fandomId}`), {
|
|
memberCount: FieldValue.increment(1),
|
|
memberSeq: memberNo,
|
|
updatedAt: FieldValue.serverTimestamp(),
|
|
}, { merge: true });
|
|
}
|
|
|
|
/**
|
|
* `fn-nebula-create` — 성운(Stage0) 생성. 정책 원장 B-08 · B-09 · B-10 · C-15.
|
|
*
|
|
* 팬덤 문서 자체가 공용 성운/행성이다(팬덤 1 : 행성 1). 만든 사람은 창립 멤버 #001 이다.
|
|
* 확인 다이얼로그 「선택한 행성을 기본 행성으로 변경할까요?」대로 대표 팬덤을 바꾸되, C-15
|
|
* 30일 대기 중이면 성운과 멤버십만 만들고 대표는 그대로 둔다(`primaryChanged: false`).
|
|
* 이름 중복은 `fandomNames/{소문자 이름}` 문서 ID 로, 일 1회 제한은
|
|
* `users/{uid}/nebulaCreates/{날짜}` 문서 ID 로 막는다 — 동시 요청에도 원자적이다.
|
|
* 기존 팬덤의 이름 문서는 `tools/demo/migrate_v040.mjs fandom-names` 가 채운다.
|
|
*/
|
|
export const fnNebulaCreate = onCall(async (req) => {
|
|
const uid = callerUid(req);
|
|
const name = str(req.data, 'name');
|
|
if (!isValidNebulaName(name)) {
|
|
throw new HttpsError('invalid-argument', '성운 이름은 2~20자의 한글·영문·숫자로 입력해 주세요.');
|
|
}
|
|
const db = getFirestore();
|
|
const policy = await fandomPolicy();
|
|
const fandom = db.collection('fandoms').doc();
|
|
const now = Date.now();
|
|
|
|
const primaryChanged = await db.runTransaction(async (tx) => {
|
|
const todayRef = db.doc(`users/${uid}/nebulaCreates/${dayKey(new Date(now))}`);
|
|
const nameRef = db.doc(`fandomNames/${name.toLowerCase()}`);
|
|
const userRef = db.doc(`users/${uid}`);
|
|
const [todaySnap, nameSnap, userSnap] = await Promise.all([
|
|
tx.get(todayRef), tx.get(nameRef), tx.get(userRef),
|
|
]);
|
|
requireTerms(userSnap.data());
|
|
if (todaySnap.exists) fail(ERR.nebulaDailyLimit, '이미 오늘 성운을 생성하셨습니다.');
|
|
if (nameSnap.exists) fail(ERR.fandomNameTaken, '이미 사용중인 이름입니다.');
|
|
const { patch } = primaryPatch(userSnap.data(), fandom.id, policy.primaryCooldownDays, now);
|
|
|
|
tx.create(todayRef, { fandomId: fandom.id, createdAt: FieldValue.serverTimestamp() });
|
|
tx.create(nameRef, { fandomId: fandom.id, createdAt: FieldValue.serverTimestamp() });
|
|
tx.create(fandom, {
|
|
name,
|
|
stage: 0,
|
|
founderUid: uid,
|
|
memberCount: 0,
|
|
growthPop: 0,
|
|
fpiScore: 0,
|
|
nebulaDeadline: Timestamp.fromMillis(now + Number(policy.nebulaDays) * 86_400_000),
|
|
createdAt: FieldValue.serverTimestamp(),
|
|
updatedAt: FieldValue.serverTimestamp(),
|
|
});
|
|
writeJoin(tx, uid, fandom.id, undefined);
|
|
if (patch) tx.set(userRef, patch, { merge: true });
|
|
return patch !== null;
|
|
});
|
|
|
|
return { fandomId: fandom.id, primaryChanged };
|
|
});
|
|
|
|
/**
|
|
* `fn-fandom-join` — 행성/성운 가입. 정책 원장 C-15 「복수 팬덤 가입」.
|
|
*
|
|
* 이미 멤버면 아무것도 쓰지 않는다(두 번 눌러도 `memberCount` 는 한 번만 오른다).
|
|
* 대표 팬덤이 없던 계정은 처음 가입한 팬덤이 대표가 된다 — 그 외에는 대표를 바꾸지 않는다.
|
|
* 탄생 권한자가 비어 있는 성운(원장 S20)에 가입하면 그 사람이 탄생 권한자가 된다.
|
|
*/
|
|
export const fnFandomJoin = onCall(async (req) => {
|
|
const uid = callerUid(req);
|
|
const fandomId = str(req.data, 'fandomId');
|
|
const db = getFirestore();
|
|
return db.runTransaction(async (tx) => {
|
|
const [fandomSnap, memberSnap, userSnap] = await Promise.all([
|
|
tx.get(db.doc(`fandoms/${fandomId}`)),
|
|
tx.get(db.doc(`fandoms/${fandomId}/members/${uid}`)),
|
|
tx.get(db.doc(`users/${uid}`)),
|
|
]);
|
|
if (!fandomSnap.exists) fail(ERR.permissionDenied, '행성을 찾을 수 없습니다.');
|
|
if (memberSnap.exists) return { fandomId, joined: false };
|
|
requireTerms(userSnap.data());
|
|
writeJoin(tx, uid, fandomId, fandomSnap.data());
|
|
// 원장 S20(확인 전 유지 기준) — 창립자가 혼자 떠나 탄생 권한자가 빈 성운은 다음 가입자가 이어받는다.
|
|
if (fandomSnap.get('stage') === 0 && !fandomSnap.get('founderUid')) {
|
|
tx.set(fandomSnap.ref, { founderUid: uid }, { merge: true });
|
|
}
|
|
if (!primaryOf(userSnap.data())) {
|
|
tx.set(userSnap.ref, {
|
|
primaryFandomId: fandomId, fandomId, updatedAt: FieldValue.serverTimestamp(),
|
|
}, { merge: true });
|
|
}
|
|
return { fandomId, joined: true };
|
|
});
|
|
});
|
|
|
|
/**
|
|
* `fn-fandom-leave` — 탈퇴. 멤버가 아니면 아무것도 하지 않는다(두 번 눌러도 한 번만 내린다).
|
|
*
|
|
* 과거 기여 · Passport 기록(원장 · growthEvents · popTransactions)은 지우지 않는다(C-15).
|
|
* 순번은 `memberSeq` 를 되돌리지 않으므로 다른 사용자에게 다시 주지 않는다(C-100 · B-68).
|
|
*
|
|
* - 성운(Stage0) 창립자도 탄생 전 떠날 수 있다(정책 원장 C-100). 탄생 권한(`founderUid`)은 남은 멤버
|
|
* 가운데 `joinedAt` 이 가장 이른 사용자에게 넘기고 알림을 남긴다. 남은 멤버가 없으면 비워 두고
|
|
* 다음 가입자가 이어받는다(원장 S20 확인 전 유지 기준). `ownerUid`(행성 문서) 등 최초 생성자 기록은 그대로다.
|
|
* - 대표 팬덤을 떠나면 남은 팬덤 중 가장 최근 가입한 팬덤을 대표로 둔다(정책 원장 C-101). 자동 지정이라
|
|
* `primaryChangedAt` 을 찍지 않는다 — 사용자의 다음 직접 변경을 30일 대기로 막지 않는다.
|
|
*/
|
|
export const fnFandomLeave = onCall(async (req) => {
|
|
const uid = callerUid(req);
|
|
const fandomId = str(req.data, 'fandomId');
|
|
const db = getFirestore();
|
|
const policy = await fandomPolicy();
|
|
return db.runTransaction(async (tx) => {
|
|
const memberRef = db.doc(`fandoms/${fandomId}/members/${uid}`);
|
|
const [memberSnap, userSnap, fandomSnap] = await Promise.all([
|
|
tx.get(memberRef), tx.get(db.doc(`users/${uid}`)), tx.get(db.doc(`fandoms/${fandomId}`)),
|
|
]);
|
|
if (!memberSnap.exists) return { fandomId, left: false };
|
|
|
|
// 트랜잭션은 읽기를 쓰기보다 먼저 끝내야 한다 — 승계 · 대표 후보를 여기서 다 읽는다.
|
|
// 떠나는 사람을 빼고 하나가 남도록 두 건만 읽는다.
|
|
const handsOver = fandomSnap.get('stage') === 0 && fandomSnap.get('founderUid') === uid;
|
|
let nextFounder: string | null = null;
|
|
if (handsOver) {
|
|
const first = await tx.get(fandomSnap.ref.collection('members').orderBy('joinedAt', 'asc').limit(2));
|
|
nextFounder = first.docs.map((d) => d.id).find((id) => id !== uid) ?? null;
|
|
}
|
|
const wasPrimary = primaryOf(userSnap.data()) === fandomId;
|
|
let nextPrimary: string | null = null;
|
|
if (wasPrimary && policy.primaryOnLeave !== 'none') {
|
|
const rest = await tx.get(db.collection(`users/${uid}/memberships`)
|
|
.orderBy('joinedAt', policy.primaryOnLeave === 'earliest' ? 'asc' : 'desc').limit(2));
|
|
nextPrimary = rest.docs.map((d) => d.id).find((id) => id !== fandomId) ?? null;
|
|
}
|
|
|
|
tx.delete(memberRef);
|
|
tx.delete(db.doc(`users/${uid}/memberships/${fandomId}`));
|
|
tx.set(userSnap.ref, { fandomIds: FieldValue.arrayRemove(fandomId) }, { merge: true });
|
|
tx.update(fandomSnap.ref, {
|
|
memberCount: FieldValue.increment(-1),
|
|
...(handsOver ? { founderUid: nextFounder } : {}),
|
|
updatedAt: FieldValue.serverTimestamp(),
|
|
});
|
|
if (nextFounder) {
|
|
// 알림함(`users/{uid}/notifications`) 한 건 — 같은 승계를 두 번 적지 않게 ID 를 고정한다.
|
|
tx.set(db.doc(`users/${nextFounder}/notifications/founder_${fandomId}_${uid}`), {
|
|
type: 'essential',
|
|
title: `「${fandomSnap.get('name') ?? ''}」 성운의 행성 탄생 권한을 이어받았어요.`,
|
|
fandomId,
|
|
read: false,
|
|
createdAt: FieldValue.serverTimestamp(),
|
|
});
|
|
}
|
|
if (wasPrimary) {
|
|
tx.set(userSnap.ref, {
|
|
primaryFandomId: nextPrimary, fandomId: nextPrimary ?? '', updatedAt: FieldValue.serverTimestamp(),
|
|
}, { merge: true });
|
|
}
|
|
return {
|
|
fandomId,
|
|
left: true,
|
|
primaryFandomId: wasPrimary ? nextPrimary : primaryOf(userSnap.data()),
|
|
...(handsOver ? { founderUid: nextFounder } : {}),
|
|
};
|
|
});
|
|
});
|
|
|
|
/**
|
|
* `fn-primary-fandom-set` — 대표 팬덤 변경. 정책 원장 C-15 · 명세 §2.56 「기본 행성 변경」.
|
|
*
|
|
* 가입한 팬덤만 대표가 될 수 있다. 변경은 무료이고, 재변경은 `primaryChangedAt + 30일`
|
|
* (운영값 `primaryCooldownDays`) 이 지나야 한다. 과거 기여 · Passport 는 건드리지 않는다.
|
|
*/
|
|
export const fnPrimaryFandomSet = onCall(async (req) => {
|
|
const uid = callerUid(req);
|
|
const fandomId = str(req.data, 'fandomId');
|
|
const db = getFirestore();
|
|
const policy = await fandomPolicy();
|
|
const now = Date.now();
|
|
return db.runTransaction(async (tx) => {
|
|
const [memberSnap, userSnap] = await Promise.all([
|
|
tx.get(db.doc(`fandoms/${fandomId}/members/${uid}`)), tx.get(db.doc(`users/${uid}`)),
|
|
]);
|
|
if (!memberSnap.exists) fail(ERR.permissionDenied, '가입한 행성만 기본 행성으로 정할 수 있습니다.');
|
|
const { patch, blocked } = primaryPatch(userSnap.data(), fandomId, policy.primaryCooldownDays, now);
|
|
if (blocked) {
|
|
fail(ERR.primaryCooldown, `기본 행성은 변경 후 ${policy.primaryCooldownDays}일이 지나야 다시 바꿀 수 있습니다.`);
|
|
}
|
|
if (patch) tx.set(userSnap.ref, patch, { merge: true });
|
|
return { fandomId, changed: patch !== null };
|
|
});
|
|
});
|
|
|
|
/**
|
|
* `fn-planet-create` — 성운(Stage0) → 정식 행성(Stage1) 탄생. 정책 원장 B-09 · B-10 · C-50.
|
|
*
|
|
* 형태(`planetType`)와 컬러(`colorKey`) 두 가지만 이때 한 번 고른다. 행성 문서 ID 는
|
|
* 팬덤 안에서 고정(`planet`)이라 팬덤당 1건이 문서 ID 충돌로 원자적으로 지켜진다
|
|
* (Firestore 설계서 §2.4). 렌더링이 팬덤 문서 하나로 끝나도록 두 값을 팬덤 문서에도
|
|
* 싣는다. 생성 후 두 값을 바꾸는 경로는 없다(보안 규칙이 클라이언트 쓰기를 전부 닫음).
|
|
*
|
|
* 전환 조건(인원 · POP)은 **트랜잭션 안에서 읽은** 팬덤 문서로 판정한다 — 가입 · 탈퇴와
|
|
* 경합해도 낡은 `memberCount` 로 통과하지 않는다. 7일 목표는 `nebulaConversionDeadlineEnforced`
|
|
* 가 켜졌을 때만 거부 사유가 된다.
|
|
*/
|
|
export const fnPlanetCreate = onCall(async (req) => {
|
|
const uid = callerUid(req);
|
|
const fandomId = str(req.data, 'fandomId');
|
|
const planetType = str(req.data, 'planetType');
|
|
const colorKey = str(req.data, 'colorKey');
|
|
if (!PLANET_SHAPES.includes(planetType) || !(await planetColorKeys()).includes(colorKey)) {
|
|
throw new HttpsError('invalid-argument', '행성 형태 또는 색이 올바르지 않습니다.');
|
|
}
|
|
const db = getFirestore();
|
|
const policy = await fandomPolicy();
|
|
const fandomRef = db.doc(`fandoms/${fandomId}`);
|
|
|
|
await db.runTransaction(async (tx) => {
|
|
const [fandomSnap, planetSnap] = await Promise.all([
|
|
tx.get(fandomRef), tx.get(db.doc(`fandoms/${fandomId}/planets/planet`)),
|
|
]);
|
|
const fandom = fandomSnap.data();
|
|
if (!fandom) fail(ERR.permissionDenied, '성운을 찾을 수 없습니다.');
|
|
if (fandom.founderUid !== uid) fail(ERR.permissionDenied, '성운을 만든 멤버만 행성을 생성할 수 있습니다.');
|
|
if (planetSnap.exists || fandom.stage !== 0) fail(ERR.popDuplicate, '이미 행성이 생성되었습니다.');
|
|
if (Number(fandom.memberCount ?? 0) < Number(policy.nebulaMinMembers) ||
|
|
Number(fandom.growthPop ?? 0) < Number(policy.nebulaMinPop)) {
|
|
fail(ERR.nebulaNotReady, '성운 전환 조건을 아직 채우지 못했습니다.');
|
|
}
|
|
if (policy.nebulaConversionDeadlineEnforced === true &&
|
|
fandom.nebulaDeadline instanceof Timestamp && Date.now() > fandom.nebulaDeadline.toMillis()) {
|
|
fail(ERR.nebulaNotReady, '성운 목표 기간이 지났습니다.');
|
|
}
|
|
|
|
tx.create(planetSnap.ref, {
|
|
ownerUid: uid,
|
|
fandomId,
|
|
planetType,
|
|
colorKey,
|
|
createdAt: FieldValue.serverTimestamp(),
|
|
});
|
|
// 수치표 v1.4 「레벨 진행 기준점」 — 생성 시점 누적 POP 을 저장하고 이후 증가분부터 Lv.1→2 진행도에
|
|
// 반영한다. 성운 기여 POP 은 growthPop 에 그대로 남는다(「소멸·초기화 없음」).
|
|
const birthPop = Number(fandom.growthPop ?? 0);
|
|
tx.update(fandomRef, {
|
|
stage: 1,
|
|
level: 1,
|
|
levelPop: 0,
|
|
growthPopAtBirth: birthPop,
|
|
levelCountedGrowthPop: birthPop,
|
|
planetType,
|
|
colorKey,
|
|
stageChangedAt: FieldValue.serverTimestamp(),
|
|
updatedAt: FieldValue.serverTimestamp(),
|
|
});
|
|
});
|
|
|
|
return { fandomId };
|
|
});
|
|
|
|
/**
|
|
* 가입 이후 따로 받는 동의 — 위치기반 서비스(Q-E1 · 1차 검수 R02 「위치기능 최초 사용 전」)와 마케팅 수신.
|
|
* 가입 필수 항목(서비스 · 개인정보 수집·이용 · 만 14세)은 여기서 철회하지 않는다(탈퇴로만 끝난다).
|
|
*/
|
|
export const LATER_CONSENTS = ['location', 'marketing'] as const;
|
|
|
|
/**
|
|
* `fn-consent-record` — 약관 동의 · 철회 이력.
|
|
*
|
|
* append-only 다. 철회도 기존 행을 고치지 않고 `granted: false` 인 새 행으로 남긴다 — 행 ID 는
|
|
* `{항목}_{버전}_{grant|revoke}_{시각}` 이라 같은 항목을 동의 → 철회 → 재동의해도 앞 행이 덮이지 않는다.
|
|
* 동의 시점을 되짚을 수 없으면 분쟁에서 증명할 수단이 사라진다.
|
|
*
|
|
* 지금 상태는 사용자 문서 `consentState.{항목}`(서버만 쓴다 — 규칙의 본인 수정 목록에 없음)에 함께 둔다.
|
|
* 위치 Quest 인증(`fnQuestVerifyPlace`)이 이 값으로 동의 여부를 본다.
|
|
*/
|
|
export const fnConsentRecord = onCall(async (req) => {
|
|
const uid = callerUid(req);
|
|
const docType = str(req.data, 'docType');
|
|
const docVersion = str(req.data, 'docVersion');
|
|
if (!(LATER_CONSENTS as readonly string[]).includes(docType)) {
|
|
throw new HttpsError('invalid-argument', '기록할 수 없는 동의 항목입니다.');
|
|
}
|
|
const granted = (req.data as Record<string, unknown>)?.granted === true;
|
|
const consentId = `${docType}_${docVersion}_${granted ? 'grant' : 'revoke'}_${Date.now()}`;
|
|
const db = getFirestore();
|
|
const batch = db.batch();
|
|
batch.set(db.doc(`users/${uid}/consents/${consentId}`), {
|
|
docType,
|
|
docVersion,
|
|
granted,
|
|
ip: req.rawRequest?.ip ?? null,
|
|
userAgent: req.rawRequest?.headers?.['user-agent'] ?? null,
|
|
createdAt: FieldValue.serverTimestamp(),
|
|
});
|
|
batch.set(db.doc(`users/${uid}`), {
|
|
consentState: { [docType]: { granted, version: docVersion, at: FieldValue.serverTimestamp() } },
|
|
// 마케팅은 Push Marketing 구분과 같은 값이다(가입 때와 같은 규칙).
|
|
...(docType === 'marketing' ? { notificationPrefs: { marketing: granted } } : {}),
|
|
updatedAt: FieldValue.serverTimestamp(),
|
|
}, { merge: true });
|
|
await batch.commit();
|
|
return { consentId, granted };
|
|
});
|
|
|
|
/**
|
|
* 위치기반 서비스에 동의한 상태인가. `consentState.location` 이 있으면 그 값(철회 포함)을 따르고,
|
|
* 없으면 예전 가입 화면(위치 약관이 가입 필수였던 0.2.0 이전)에서 남긴 `location_{가입 약관 버전}_grant`
|
|
* 행으로 본다 — 이미 동의한 사용자에게 다시 묻지 않는다.
|
|
*/
|
|
export async function hasLocationConsent(uid: string): Promise<boolean> {
|
|
const db = getFirestore();
|
|
const user = (await db.doc(`users/${uid}`).get()).data();
|
|
const state = (user?.consentState as Record<string, { granted?: unknown }> | undefined)?.location;
|
|
if (state) return state.granted === true;
|
|
const version = user?.termsVersion;
|
|
if (typeof version !== 'string' || !version) return false;
|
|
return (await db.doc(`users/${uid}/consents/location_${version}_grant`).get()).exists;
|
|
}
|
|
|
|
/**
|
|
* 가입 약관 필수 항목 — 통합검토회신 v2.3 **Q-E1** · 1차 검수 회신서 **R02**.
|
|
*
|
|
* 서비스 이용약관 · 개인정보 수집·이용 동의 · 만 14세 이상 확인만 가입 필수다. **위치기반 서비스 약관은
|
|
* 가입 때 받지 않는다** — 위치기능을 처음 쓰기 직전에 따로 받는다([fnConsentRecord] `location`).
|
|
* 개인정보처리방침은 동의 항목이 아니라 고지(링크)라 기록하지 않는다. 마케팅 수신은 선택 · 기본 OFF.
|
|
*/
|
|
export const SIGNUP_REQUIRED_TERMS = ['service', 'privacy', 'age'] as const;
|
|
|
|
/**
|
|
* `fn-signup-consent` — 가입 약관 동의를 한 번에 기록한다(FR-CMN-04 · 정책 원장 C-134).
|
|
*
|
|
* 항목마다 `consents` 에 append-only 행을 남기고(약관 버전 포함), 사용자 문서에 `termsAgreedAt` 을
|
|
* 찍는다. 앱은 이 값이 없으면 로그인 직후 온보딩(약관부터)으로 보낸다 — 「로그인」 버튼으로 처음
|
|
* 들어온 소셜 계정(카카오 커스텀 토큰 포함)이 약관 · 만 14세 확인 없이 지나가지 못하게 하는 기준이다.
|
|
* 마케팅 동의는 Push Marketing 구분(`notificationPrefs.marketing`)과 같은 값으로 둔다.
|
|
* 예전 앱(0.2.0+10 이하)은 가입 화면에서 위치 약관을 받아 `location` 을 함께 보낸다. 사용자가 실제로
|
|
* 동의한 것이라 그대로 기록한다 — 버리면 그 앱으로 가입한 계정은 위치 동의 화면이 없어 위치 Quest 를 못 한다.
|
|
*/
|
|
export const fnSignupConsent = onCall(async (req) => {
|
|
const uid = callerUid(req);
|
|
const version = str(req.data, 'version');
|
|
const raw = (req.data as Record<string, unknown>)?.agreed;
|
|
const agreed = new Set(Array.isArray(raw) ? raw.filter((v): v is string => typeof v === 'string') : []);
|
|
if (!SIGNUP_REQUIRED_TERMS.every((t) => agreed.has(t))) {
|
|
throw new HttpsError('invalid-argument', '필수 약관에 모두 동의해 주세요.');
|
|
}
|
|
const marketing = agreed.has('marketing');
|
|
const db = getFirestore();
|
|
const batch = db.batch();
|
|
const meta = {
|
|
docVersion: version,
|
|
ip: req.rawRequest?.ip ?? null,
|
|
userAgent: req.rawRequest?.headers?.['user-agent'] ?? null,
|
|
createdAt: FieldValue.serverTimestamp(),
|
|
};
|
|
for (const docType of [...SIGNUP_REQUIRED_TERMS, 'marketing']) {
|
|
const granted = docType === 'marketing' ? marketing : true;
|
|
batch.set(db.doc(`users/${uid}/consents/${docType}_${version}_${granted ? 'grant' : 'revoke'}`),
|
|
{ docType, granted, ...meta }, { merge: true });
|
|
}
|
|
const legacyLocation = agreed.has('location');
|
|
if (legacyLocation) {
|
|
batch.set(db.doc(`users/${uid}/consents/location_${version}_grant`),
|
|
{ docType: 'location', granted: true, ...meta }, { merge: true });
|
|
}
|
|
batch.set(db.doc(`users/${uid}`), {
|
|
termsAgreedAt: FieldValue.serverTimestamp(),
|
|
termsVersion: version,
|
|
notificationPrefs: { marketing },
|
|
consentState: {
|
|
marketing: { granted: marketing, version, at: FieldValue.serverTimestamp() },
|
|
...(legacyLocation ? { location: { granted: true, version, at: FieldValue.serverTimestamp() } } : {}),
|
|
},
|
|
updatedAt: FieldValue.serverTimestamp(),
|
|
}, { merge: true });
|
|
await batch.commit();
|
|
return { termsVersion: version, marketing };
|
|
});
|
|
|
|
/**
|
|
* `fn-account-withdraw` — 서비스 탈퇴. Figma `554:22903` · `554:24885` · `554:24908`.
|
|
*
|
|
* - SNS 연동이 남아 있으면(로그인 수단 2개 이상) 「비정상 탈퇴」로 거부한다 — 「연동된 계정 해제 후
|
|
* 계정 탈퇴를 진행해주세요.」(`374:14699`).
|
|
* - 원장성 데이터(POP 원장 · 결제 · 정산 · 기여)와 작성한 게시물은 지우지 않는다(Figma 「게시물은 삭제되지
|
|
* 않고 데이터 보존」 · 별첨 B 「원장 보존 5년」). 사용자 문서의 개인식별정보(닉네임 · 표시 이름 · 이메일 ·
|
|
* 사진 · 푸시 토큰)는 지우고 `status: 'withdrawn'` · `withdrawnAt` 을 남긴다(별첨 B 「탈퇴 후 개인식별정보는
|
|
* 파기/비식별」, Admin 사용자 일람의 탈퇴일 · 상태 `557:40942`).
|
|
* - 본인이 올린 Storage 파일 중 **본인 전용 경로**를 지운다: `users/{uid}/profile/`(프로필 사진),
|
|
* `questProofs/{uid}/`(Quest 인증 사진). 삭제 실패는 로그만 남기고 탈퇴를 막지 않는다.
|
|
* **`users/{uid}/lounge/`(라운지 글 사진)는 지우지 않는다** — 글 처리 범위가 해석 확인 Q-E4 회신 대기라
|
|
* 글과 함께 「회신 후」에 정한다(글 문서가 이 경로를 가리키므로 먼저 지우면 깨진 사진이 남는다).
|
|
* - Auth 계정을 지운다. 같은 소셜 계정으로 다시 가입하면 새 계정이다.
|
|
*/
|
|
export const fnAccountWithdraw = onCall(async (req) => {
|
|
const uid = callerUid(req);
|
|
const auth = getAuth();
|
|
const user = await auth.getUser(uid);
|
|
if (user.providerData.length > 1) {
|
|
fail(ERR.withdrawLinked, '연동된 계정 해제 후 계정 탈퇴를 진행해주세요.');
|
|
}
|
|
const db = getFirestore();
|
|
const userRef = db.doc(`users/${uid}`);
|
|
const code = (await userRef.get()).get('inviteCode');
|
|
const batch = db.batch();
|
|
batch.set(userRef, {
|
|
status: 'withdrawn',
|
|
withdrawnAt: FieldValue.serverTimestamp(),
|
|
nickname: null,
|
|
displayName: null,
|
|
email: null,
|
|
photoUrl: null,
|
|
fcmTokens: FieldValue.delete(),
|
|
updatedAt: FieldValue.serverTimestamp(),
|
|
}, { merge: true });
|
|
// 탈퇴한 사람의 추천 코드로 새 귀속이 생기지 않게 닫는다.
|
|
if (typeof code === 'string' && code) batch.set(db.doc(`inviteCodes/${code}`), { active: false }, { merge: true });
|
|
await batch.commit();
|
|
for (const prefix of [`users/${uid}/profile/`, `questProofs/${uid}/`]) {
|
|
try {
|
|
await getStorage().bucket().deleteFiles({ prefix });
|
|
} catch (e) {
|
|
logger.error('withdraw: storage cleanup failed', { uid, prefix, error: String(e) });
|
|
}
|
|
}
|
|
await auth.deleteUser(uid);
|
|
return { withdrawn: true };
|
|
});
|
|
|
|
/**
|
|
* `fn-admin-set-role` — 마스터 전용. 운영자 등급을 커스텀 클레임으로 심는다.
|
|
*
|
|
* 클레임은 다음 토큰 갱신부터 적용되므로 `admins` 문서도 함께 갱신해 Admin 화면이
|
|
* 즉시 목록을 보여줄 수 있게 한다.
|
|
*/
|
|
export const fnAdminSetRole = onCall(async (req) => {
|
|
const actorUid = callerUid(req);
|
|
if (req.auth?.token?.role !== 'master') {
|
|
fail(ERR.permissionDenied, '마스터 권한이 필요합니다.');
|
|
}
|
|
const targetUid = str(req.data, 'uid');
|
|
const role = str(req.data, 'role');
|
|
if (!['master', 'ops', 'viewer'].includes(role)) {
|
|
fail(ERR.permissionDenied, '알 수 없는 권한 등급입니다.');
|
|
}
|
|
|
|
await getAuth().setCustomUserClaims(targetUid, { role });
|
|
await getFirestore().doc(`admins/${targetUid}`).set({
|
|
uid: targetUid,
|
|
role,
|
|
grantedBy: actorUid,
|
|
updatedAt: FieldValue.serverTimestamp(),
|
|
}, { merge: true });
|
|
|
|
return { uid: targetUid, role };
|
|
});
|