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 = { 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 = { 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, have: Record | 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 { 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 { 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 | 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 | 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)?.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 { const db = getFirestore(); const user = (await db.doc(`users/${uid}`).get()).data(); const state = (user?.consentState as Record | 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)?.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 }; });