가입 약관 동의 기록 · 서비스 탈퇴 · 온보딩 6단계 · Coach Mark
This commit is contained in:
@@ -19,13 +19,19 @@ import { ERR, fail } from '../core/errors.js';
|
||||
*/
|
||||
export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => {
|
||||
const db = getFirestore();
|
||||
const batch = db.batch();
|
||||
|
||||
batch.set(db.doc(`users/${user.uid}`), {
|
||||
const userRef = db.doc(`users/${user.uid}`);
|
||||
const walletRef = db.doc(`users/${user.uid}/wallet/main`);
|
||||
// 이메일 — 10/5 「플랫폼이 이메일 제공을 승인·동의한 경우에만 수집, 없어도 가입·로그인 가능」.
|
||||
// Auth 계정 이메일이 비면(OIDC 등) 제공자 정보의 이메일을 쓴다. 카카오(커스텀 토큰)는
|
||||
// 계정을 만든 뒤에 연결 정보가 붙으므로 `resolveKakaoUid` 가 따로 쓴다.
|
||||
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,
|
||||
countryCode: 'KR',
|
||||
// 국가는 정하지 않는다 — 온보딩 프로필 단계가 기기 지역을 기본값으로 받아 쓴다
|
||||
// (해석 확인 Q-OB-02 A 「프로필 = 닉네임 · 국가 · 표시 언어」). 비어 있으면 국가 랭킹은 `ZZ`.
|
||||
email,
|
||||
locale: 'ko',
|
||||
fandomId: '',
|
||||
authProviders: user.providerData.map((p) => p.providerId),
|
||||
@@ -35,18 +41,25 @@ export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => {
|
||||
notificationPrefs: {},
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
}, { merge: true });
|
||||
|
||||
batch.set(db.doc(`users/${user.uid}/wallet/main`), {
|
||||
};
|
||||
const wallet: Record<string, unknown> = {
|
||||
activityBalance: 0,
|
||||
supportBalance: 0,
|
||||
pendingActivity: 0,
|
||||
reconcileStatus: 'ok',
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
}, { merge: true });
|
||||
};
|
||||
|
||||
await batch.commit();
|
||||
// 트리거가 늦게 돌면 그 사이 앱이 쓴 값(약관 동의 · 닉네임 · 국가 · 이메일)이 이미 있다.
|
||||
// 없는 필드만 채운다 — 덮어쓰면 온보딩 입력이 기본값으로 되돌아간다.
|
||||
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). */
|
||||
@@ -434,6 +447,92 @@ export const fnConsentRecord = onCall(async (req) => {
|
||||
return { consentId };
|
||||
});
|
||||
|
||||
/**
|
||||
* 가입 약관 필수 항목 — Figma `179:24230`(서비스 이용약관 · 위치기반 서비스 · 만 14세 이상)에
|
||||
* 10/3 통합회신 3절 4 · 10/5 「개인정보처리방침을 약관과 함께 전달」의 개인정보 처리방침을 더했다.
|
||||
* 항목 구성은 해석 확인 요청 E-Q1(회신 대기). 마케팅 수신은 선택 · 기본 OFF(별첨 B 「Push 알림」).
|
||||
*/
|
||||
export const SIGNUP_REQUIRED_TERMS = ['service', 'privacy', 'location', 'age'] as const;
|
||||
|
||||
/**
|
||||
* `fn-signup-consent` — 가입 약관 동의를 한 번에 기록한다(FR-CMN-04 · 원장 C-134).
|
||||
*
|
||||
* 항목마다 `consents` 에 append-only 행을 남기고(약관 버전 포함), 사용자 문서에 `termsAgreedAt` 을
|
||||
* 찍는다. 앱은 이 값이 없으면 로그인 직후 온보딩(약관부터)으로 보낸다 — 「로그인」 버튼으로 처음
|
||||
* 들어온 소셜 계정(카카오 커스텀 토큰 포함)이 약관 · 만 14세 확인 없이 지나가지 못하게 하는 기준이다.
|
||||
* 마케팅 동의는 Push Marketing 구분(`notificationPrefs.marketing`)과 같은 값으로 둔다.
|
||||
*/
|
||||
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 });
|
||||
}
|
||||
batch.set(db.doc(`users/${uid}`), {
|
||||
termsAgreedAt: FieldValue.serverTimestamp(),
|
||||
termsVersion: version,
|
||||
notificationPrefs: { marketing },
|
||||
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`).
|
||||
* - 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();
|
||||
await auth.deleteUser(uid);
|
||||
return { withdrawn: true };
|
||||
});
|
||||
|
||||
/**
|
||||
* `fn-admin-set-role` — 마스터 전용. 운영자 등급을 커스텀 클레임으로 심는다.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user