1차 보완(0.1.1+14) 내용 합치기

This commit is contained in:
Sn-Kinos
2026-10-16 17:58:55 +09:00
62 changed files with 2480 additions and 447 deletions
+60 -12
View File
@@ -26,7 +26,7 @@ export const fnAuthOnCreate = functionsV1.region('asia-northeast3').auth.user().
const walletRef = db.doc(`users/${user.uid}/wallet/main`);
// 이메일 — 10/5 「플랫폼이 이메일 제공을 승인·동의한 경우에만 수집, 없어도 가입·로그인 가능」.
// Auth 계정 이메일이 비면(OIDC 등) 제공자 정보의 이메일을 쓴다. 카카오(커스텀 토큰)는
// 계정을 만든 뒤에 연결 정보가 붙으므로 `resolveKakaoUid` 가 따로 쓴다.
// 계정을 만든 뒤에 연결 정보가 붙으므로 `exchangeSocial`(social_token.ts)이 따로 쓴다.
const email = user.email ?? user.providerData.find((p) => p.email)?.email ?? null;
const defaults: Record<string, unknown> = {
uid: user.uid,
@@ -436,36 +436,73 @@ export const fnPlanetCreate = onCall(async (req) => {
});
/**
* `fn-consent-record` — 약관 동의 이력.
* 가입 이후 따로 받는 동의 — 위치기반 서비스(Q-E1 · 1차 검수 R02 「위치기능 최초 사용 전」)와 마케팅 수신.
* 가입 필수 항목(서비스 · 개인정보 수집·이용 · 만 14세)은 여기서 철회하지 않는다(탈퇴로만 끝난다).
*/
export const LATER_CONSENTS = ['location', 'marketing'] as const;
/**
* `fn-consent-record` — 약관 동의 · 철회 이력.
*
* append-only 다. 철회도 기존 행을 고치지 않고 `granted: false` 인 새 행으로 남긴다.
* 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'}`;
await getFirestore().doc(`users/${uid}/consents/${consentId}`).set({
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 });
return { consentId };
await batch.commit();
return { consentId, granted };
});
/**
* 가입 약관 필수 항목 — Figma `179:24230`(서비스 이용약관 · 위치기반 서비스 · 만 14세 이상)에
* 10/3 통합회신 3절 4 · 10/5 「개인정보처리방침을 약관과 함께 전달」의 개인정보 처리방침을 더했다.
* 항목 구성은 해석 확인 요청 E-Q1(회신 대기). 마케팅 수신은 선택 · 기본 OFF(별첨 B 「Push 알림」).
* 위치기반 서비스에 동의한 상태인가. `consentState.location` 이 있으면 그 값(철회 포함)을 따르고,
* 없으면 예전 가입 화면(위치 약관이 가입 필수였던 0.2.0 이전)에서 남긴 `location_{가입 약관 버전}_grant`
* 행으로 본다 — 이미 동의한 사용자에게 다시 묻지 않는다.
*/
export const SIGNUP_REQUIRED_TERMS = ['service', 'privacy', 'location', 'age'] as const;
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).
@@ -474,6 +511,8 @@ export const SIGNUP_REQUIRED_TERMS = ['service', 'privacy', 'location', 'age'] a
* 찍는다. 앱은 이 값이 없으면 로그인 직후 온보딩(약관부터)으로 보낸다 — 「로그인」 버튼으로 처음
* 들어온 소셜 계정(카카오 커스텀 토큰 포함)이 약관 · 만 14세 확인 없이 지나가지 못하게 하는 기준이다.
* 마케팅 동의는 Push Marketing 구분(`notificationPrefs.marketing`)과 같은 값으로 둔다.
* 예전 앱(0.2.0+10 이하)은 가입 화면에서 위치 약관을 받아 `location` 을 함께 보낸다. 사용자가 실제로
* 동의한 것이라 그대로 기록한다 — 버리면 그 앱으로 가입한 계정은 위치 동의 화면이 없어 위치 Quest 를 못 한다.
*/
export const fnSignupConsent = onCall(async (req) => {
const uid = callerUid(req);
@@ -497,10 +536,19 @@ export const fnSignupConsent = onCall(async (req) => {
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();