가입 시 약관 동의 단계 추가 — 필수·선택 항목 구분 · 개인정보처리방침 고지 · 동의 기록 저장

This commit is contained in:
Sn-Kinos
2026-10-11 23:43:25 +09:00
parent 9119b3beaa
commit c40bde2cd0
9 changed files with 564 additions and 51 deletions
+52 -1
View File
@@ -1,5 +1,5 @@
import * as functionsV1 from 'firebase-functions/v1';
import { onCall } from 'firebase-functions/v2/https';
import { HttpsError, onCall } from 'firebase-functions/v2/https';
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
import { getAuth } from 'firebase-admin/auth';
@@ -85,6 +85,57 @@ export const fnConsentRecord = onCall(async (req) => {
return { consentId };
});
/**
* 가입 약관 필수 항목 — 서비스 이용약관 · 개인정보 수집·이용 동의 · 만 14세 이상 확인.
*
* 위치기반 서비스 약관은 가입 때 받지 않는다(위치기능을 처음 쓰기 직전에 따로 받는다).
* 개인정보처리방침은 동의 항목이 아니라 고지(링크)라 기록하지 않는다. 마케팅 수신은 선택 · 기본 OFF.
*/
export const SIGNUP_REQUIRED_TERMS = ['service', 'privacy', 'age'] as const;
/**
* `fn-signup-consent` — 가입 약관 동의를 한 번에 기록한다.
*
* 항목마다 `consents` 에 append-only 행을 남기고(약관 버전 포함), 사용자 문서에 `termsAgreedAt` 을
* 찍는다. 앱은 이 값이 없으면 로그인 직후 약관 화면으로 보낸다 — 가입 · 로그인 버튼이나 제공자와
* 무관하게 약관 · 만 14세 확인 없이 지나가지 못하게 하는 기준이다.
* 마케팅 동의는 알림 설정의 마케팅 구분(`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 },
consentState: {
marketing: { granted: marketing, version, at: FieldValue.serverTimestamp() },
},
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
await batch.commit();
return { termsVersion: version, marketing };
});
/**
* `fn-admin-set-role` — 마스터 전용. 운영자 등급을 커스텀 클레임으로 심는다.
*
+1 -1
View File
@@ -4,7 +4,7 @@
import './core/bootstrap.js';
export {
fnAuthOnCreate, fnConsentRecord, fnAdminSetRole,
fnAuthOnCreate, fnConsentRecord, fnSignupConsent, fnAdminSetRole,
} from './account/account.js';
export { fnAuthKakao } from './account/kakao.js';
export { fnAuthLine } from './account/line.js';
@@ -0,0 +1,51 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getFirestore } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin } from './support.js';
import { fnSignupConsent } from '../src/account/account.js';
/** 가입 약관 동의 — 필수 3항 검사 · 항목별 동의 행 · 동의 시각 기록. */
const U = 'test-consent-user';
const db = () => getFirestore();
type CallReq = Parameters<typeof fnSignupConsent.run>[0];
const as = (uid: string, data: Record<string, unknown>) => ({ data, auth: { uid, token: {} } }) as CallReq;
const codeOf = async (p: Promise<unknown>) => {
try { await p; return 'ok'; } catch (e) { return (e as { code?: string }).code; }
};
const wipe = () => db().recursiveDelete(db().doc(`users/${U}`));
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await wipe(); await shutdownAdmin(); });
beforeEach(wipe);
describe('fnSignupConsent', () => {
it('필수(서비스 · 개인정보 수집·이용 · 만 14세) 하나라도 빠지면 거부하고 기록하지 않는다', async () => {
expect(await codeOf(fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'age'] }))))
.toBe('invalid-argument');
expect((await db().doc(`users/${U}`).get()).get('termsAgreedAt')).toBeUndefined();
});
it('필수만 동의 — 항목별 행 · 마케팅 기본 OFF · termsAgreedAt', async () => {
await fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'privacy', 'age'] }));
const user = (await db().doc(`users/${U}`).get()).data()!;
expect(user.termsAgreedAt).toBeDefined();
expect(user.termsVersion).toBe('v1');
expect(user.notificationPrefs.marketing).toBe(false);
const rows = (await db().collection(`users/${U}/consents`).get()).docs.map((d) => d.id).sort();
expect(rows).toEqual(['age_v1_grant', 'marketing_v1_revoke', 'privacy_v1_grant', 'service_v1_grant']);
});
it('마케팅 선택 동의는 알림 마케팅 구분과 같은 값이 된다', async () => {
await fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'privacy', 'age', 'marketing'] }));
expect((await db().doc(`users/${U}`).get()).get('notificationPrefs.marketing')).toBe(true);
expect((await db().doc(`users/${U}/consents/marketing_v1_grant`).get()).get('granted')).toBe(true);
});
it('로그인하지 않은 호출은 거부', async () => {
expect(await codeOf(fnSignupConsent.run({ data: { version: 'v1', agreed: [] } } as CallReq)))
.not.toBe('ok');
});
});