가입 시 약관 동의 단계 추가 — 필수·선택 항목 구분 · 개인정보처리방침 고지 · 동의 기록 저장
This commit is contained in:
@@ -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` — 마스터 전용. 운영자 등급을 커스텀 클레임으로 심는다.
|
||||
*
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user