Files
fanuzu/app/firebase/functions/src/account/account.ts
T

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 };
});