-
문의사항이 있으시면 연락주세요
-
-
📧 contact@styleme.co.kr
-
📞 1588-0000
+
문의사항이 있으면 연락해 주세요.
+
+ ✉ yourmode0604@gmail.com
diff --git a/docs/privacy-consent-ops.md b/docs/privacy-consent-ops.md
new file mode 100644
index 0000000..6455fd3
--- /dev/null
+++ b/docs/privacy-consent-ops.md
@@ -0,0 +1,62 @@
+# Apply 개인정보 운영 가이드
+
+## 목적
+
+- `/apply`에서 수집한 개인정보의 동의/보관/파기/권리행사 흐름을 운영 관점에서 일치시킨다.
+
+## 동의 로그 스키마
+
+- 문서: `apply/{phoneId}`
+- 필드:
+ - `requestId`: `"{phoneId}-{timestamp}"`
+ - `consentSnapshot`:
+ - `agreePrivacy`
+ - `agreeService`
+ - `agreePhotoProcessing`
+ - `agreeMarketing`
+ - `policyVersion`
+ - `termsVersion`
+ - `consentNoticeVersion`
+ - `agreedAtISO`
+ - `requestId`
+- 서브컬렉션: `apply/{phoneId}/consent_logs`
+ - 각 제출 시 `consentSnapshot` + `createdAt(serverTimestamp)` 기록
+
+## 보관/파기 운영 설계
+
+- 정책 문구: `서비스 종료 후 1년 보관 후 파기`
+- 운영 방식:
+ 1. 기준일 계산: `createdAt` 또는 `updatedAt` 기준 1년 초과 데이터 탐색
+ 2. 삭제 대상 검증: 파기 대상 레코드 수, 최근 요청 여부 확인
+ 3. 배치 삭제: `apply/{phoneId}` 및 `consent_logs` 하위 문서 삭제
+ 4. 실행 로그: 실행 시각, 삭제 건수, 실패 건수 별도 로그 저장
+- 권장 실행 주기: 주 1회 배치
+
+## 권리행사 처리 경로
+
+- 사용자 안내 문구: `하단 문의하기 폼 또는 카카오 채널`
+- 처리 절차:
+ 1. 요청 접수
+ 2. 본인 확인
+ 3. 열람/정정/삭제 요청 처리
+ 4. 처리 결과 회신 및 내부 이력 기록
+
+## QA 시나리오
+
+1. 필수 동의 미체크
+- 기대 결과: 제출 차단, 필수 동의 안내 문구 노출
+
+2. 선택 동의 거부
+- 기대 결과: 제출 가능, 저장 데이터에 선택 동의 `false` 반영
+
+3. 정책 링크 노출
+- 기대 결과: `/apply`에서 개인정보처리방침/이용약관 링크 클릭 가능
+
+4. 동의 버전 저장
+- 기대 결과: `consentSnapshot.policyVersion`, `termsVersion`, `consentNoticeVersion` 저장
+
+5. 동의 시각 저장
+- 기대 결과: `consentSnapshot.agreedAtISO` 및 `consent_logs.createdAt` 저장
+
+6. 요청 식별자 저장
+- 기대 결과: `requestId`가 `apply` 문서와 `consent_logs` 모두에 기록
diff --git a/firebase.ts b/firebase.ts
index 3c6ab85..03622ff 100644
--- a/firebase.ts
+++ b/firebase.ts
@@ -1,13 +1,23 @@
import { initializeApp } from 'firebase/app';
-import { addDoc, collection, doc, getDoc, getFirestore, setDoc, serverTimestamp } from 'firebase/firestore';
-import { BodyDiagnosisFormData } from '@/types/body';
+import {
+ collection,
+ doc,
+ getDoc,
+ getFirestore,
+ serverTimestamp,
+ setDoc,
+ writeBatch,
+} from 'firebase/firestore';
import { getAnalytics, isSupported } from 'firebase/analytics';
+import { BodyDiagnosisFormData } from '@/types/body';
import { IS_E2E_TEST_MODE } from '@/lib/e2e-mode';
-// TODO: Add SDKs for Firebase products that you want to use
-// https://firebase.google.com/docs/web/setup#available-libraries
+import {
+ createConsentSnapshot,
+ DATA_RETENTION_POLICY,
+ RIGHTS_REQUEST_CHANNEL,
+ THIRD_PARTY_NOTICE,
+} from '@/lib/privacy-consent';
-// Your web app's Firebase configuration
-// For Firebase JS SDK v7.20.0 and later, measurementId is optional
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
@@ -18,7 +28,6 @@ const firebaseConfig = {
measurementId: process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID,
};
-// Initialize Firebase
const app = initializeApp(firebaseConfig);
export const db = getFirestore(app);
@@ -31,18 +40,63 @@ if (typeof window !== 'undefined') {
const normalizePhone = (raw: string) => raw.replace(/\D/g, '');
+const BASE36_FRACTION_START_INDEX = 2;
+const FALLBACK_REQUEST_TOKEN_LENGTH = 10;
+
+const generateOpaqueRequestId = () => {
+ const randomUuid = globalThis.crypto?.randomUUID?.();
+ if (randomUuid) return randomUuid;
+
+ const fallbackRandomToken = Math.random()
+ .toString(36)
+ .slice(
+ BASE36_FRACTION_START_INDEX,
+ BASE36_FRACTION_START_INDEX + FALLBACK_REQUEST_TOKEN_LENGTH,
+ );
+ return `req_${Date.now()}_${fallbackRandomToken}`;
+};
+
export const applyBodyDiagnosis = async (req: BodyDiagnosisFormData) => {
if (IS_E2E_TEST_MODE) {
return;
}
- const phoneId = normalizePhone(req.phone);
- const newReq = {
+ const phoneId = assertPhoneId(req.phone);
+ const applyRef = doc(db, 'apply', phoneId);
+ const requestId = generateOpaqueRequestId();
+ const consentSnapshot = createConsentSnapshot(req, requestId);
+ const consentLogRef = doc(collection(db, 'apply', phoneId, 'consent_logs'));
+ const applyIndexRef = doc(db, 'apply_index', phoneId);
+ const batch = writeBatch(db);
+ const existingApplyIndex = await getDoc(applyIndexRef);
+
+ if (existingApplyIndex.exists()) {
+ throw new Error('application already exists for this phone');
+ }
+
+ batch.set(applyRef, {
...req,
phone: phoneId,
- createdAt: new Date().toLocaleString().toString(),
- };
- await setDoc(doc(db, 'apply', phoneId), newReq); // 문서 ID = phone
+ requestId,
+ consentSnapshot,
+ retentionPolicy: DATA_RETENTION_POLICY,
+ rightsRequestChannel: RIGHTS_REQUEST_CHANNEL,
+ thirdPartyNotice: THIRD_PARTY_NOTICE,
+ createdAt: serverTimestamp(),
+ updatedAt: serverTimestamp(),
+ });
+
+ batch.set(consentLogRef, {
+ ...consentSnapshot,
+ createdAt: serverTimestamp(),
+ });
+
+ batch.set(applyIndexRef, {
+ requestId,
+ createdAt: serverTimestamp(),
+ });
+
+ await batch.commit();
};
const assertPhoneId = (raw: string) => {
@@ -52,7 +106,6 @@ const assertPhoneId = (raw: string) => {
return id;
};
-// 설문 답변 저장
export async function saveSurveyAnswers(phone: string, answers: string[]): Promise
{
if (IS_E2E_TEST_MODE) {
return normalizePhone(phone);
@@ -68,7 +121,7 @@ export async function saveSurveyAnswers(phone: string, answers: string[]): Promi
completedAt: serverTimestamp(),
},
{ merge: true },
- ); // 필요시 덮어쓰기 허용
+ );
return ref.id;
}
@@ -78,7 +131,8 @@ export async function valueExists(collectionName: string, phone: string): Promis
}
const phoneId = assertPhoneId(phone);
- const snap = await getDoc(doc(db, collectionName, phoneId));
+ const targetCollection = collectionName === 'apply' ? 'apply_index' : collectionName;
+ const snap = await getDoc(doc(db, targetCollection, phoneId));
return snap.exists();
}
@@ -96,7 +150,8 @@ export async function submitContactInquiry(req: ContactInquiryRequest): Promise<
return 'test-contact-inquiry-id';
}
- const created = await addDoc(collection(db, 'contact_inquiries'), {
+ const created = doc(collection(db, 'contact_inquiries'));
+ await setDoc(created, {
...req,
status: 'new',
createdAt: serverTimestamp(),
@@ -116,7 +171,8 @@ export async function submitReview(req: ReviewRequest): Promise {
return 'test-review-id';
}
- const created = await addDoc(collection(db, 'reviews'), {
+ const created = doc(collection(db, 'reviews'));
+ await setDoc(created, {
...req,
status: 'pending',
createdAt: serverTimestamp(),
diff --git a/firestore.rules b/firestore.rules
index 65dbec7..ca722ce 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -1,19 +1,171 @@
-rules_version='2'
+rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
+ function isValidPhoneId(phoneId) {
+ return phoneId.matches('^[0-9]{10,11}$');
+ }
+
+ function hasApplyPayloadShape(phoneId) {
+ return request.resource.data.keys().hasOnly([
+ 'name',
+ 'phone',
+ 'email',
+ 'gender',
+ 'height',
+ 'weight',
+ 'agreePrivacy',
+ 'agreeService',
+ 'agreePhotoProcessing',
+ 'agreeMarketing',
+ 'paymentMethod',
+ 'requestId',
+ 'consentSnapshot',
+ 'retentionPolicy',
+ 'rightsRequestChannel',
+ 'thirdPartyNotice',
+ 'createdAt',
+ 'updatedAt',
+ ])
+ && request.resource.data.phone == phoneId
+ && request.resource.data.name is string
+ && request.resource.data.email is string
+ && request.resource.data.gender is string
+ && request.resource.data.height is string
+ && request.resource.data.weight is string
+ && request.resource.data.paymentMethod is string
+ && request.resource.data.agreePrivacy == true
+ && request.resource.data.agreeService == true
+ && request.resource.data.agreePhotoProcessing is bool
+ && request.resource.data.agreeMarketing is bool
+ && request.resource.data.requestId is string
+ && request.resource.data.retentionPolicy is string
+ && request.resource.data.rightsRequestChannel is string
+ && request.resource.data.thirdPartyNotice is string
+ && request.resource.data.createdAt is timestamp
+ && request.resource.data.updatedAt is timestamp;
+ }
+
+ function hasConsentSnapshotShape() {
+ return request.resource.data.consentSnapshot.agreePrivacy == true
+ && request.resource.data.consentSnapshot.agreeService == true
+ && request.resource.data.consentSnapshot.agreePhotoProcessing is bool
+ && request.resource.data.consentSnapshot.agreeMarketing is bool
+ && request.resource.data.consentSnapshot.policyVersion is string
+ && request.resource.data.consentSnapshot.termsVersion is string
+ && request.resource.data.consentSnapshot.consentNoticeVersion is string
+ && request.resource.data.consentSnapshot.agreedAtISO is string
+ && request.resource.data.consentSnapshot.requestId is string
+ && request.resource.data.consentSnapshot.requestId == request.resource.data.requestId;
+ }
+
+ function hasConsentLogShape(phoneId) {
+ return request.resource.data.keys().hasOnly([
+ 'agreePrivacy',
+ 'agreeService',
+ 'agreePhotoProcessing',
+ 'agreeMarketing',
+ 'policyVersion',
+ 'termsVersion',
+ 'consentNoticeVersion',
+ 'agreedAtISO',
+ 'requestId',
+ 'createdAt',
+ ])
+ && request.resource.data.agreePrivacy == true
+ && request.resource.data.agreeService == true
+ && request.resource.data.agreePhotoProcessing is bool
+ && request.resource.data.agreeMarketing is bool
+ && request.resource.data.policyVersion is string
+ && request.resource.data.termsVersion is string
+ && request.resource.data.consentNoticeVersion is string
+ && request.resource.data.agreedAtISO is string
+ && request.resource.data.requestId is string
+ && request.resource.data.createdAt is timestamp
+ && request.resource.data.requestId ==
+ getAfter(/databases/$(database)/documents/apply/$(phoneId)).data.requestId;
+ }
+
+ match /apply/{phoneId} {
+ allow get: if false;
+ allow list: if false;
+ allow create: if isValidPhoneId(phoneId)
+ && !exists(/databases/$(database)/documents/apply/$(phoneId))
+ && hasApplyPayloadShape(phoneId)
+ && hasConsentSnapshotShape();
+ allow update, delete: if false;
+
+ match /consent_logs/{logId} {
+ allow create: if isValidPhoneId(phoneId)
+ && existsAfter(/databases/$(database)/documents/apply/$(phoneId))
+ && hasConsentLogShape(phoneId);
+ allow read, update, delete: if false;
+ }
+ }
+
+ match /apply_index/{phoneId} {
+ allow get: if isValidPhoneId(phoneId);
+ allow list: if false;
+ allow create: if isValidPhoneId(phoneId)
+ && !exists(/databases/$(database)/documents/apply_index/$(phoneId))
+ && request.resource.data.keys().hasOnly(['requestId', 'createdAt'])
+ && request.resource.data.requestId is string
+ && request.resource.data.createdAt is timestamp
+ && existsAfter(/databases/$(database)/documents/apply/$(phoneId))
+ && getAfter(/databases/$(database)/documents/apply/$(phoneId)).data.requestId ==
+ request.resource.data.requestId;
+ allow update, delete: if false;
+ }
+
+ match /surveys/{phoneId} {
+ allow create, update: if isValidPhoneId(phoneId)
+ && request.resource.data.keys().hasOnly(['phone', 'answers', 'completedAt'])
+ && request.resource.data.phone == phoneId
+ && request.resource.data.answers is list
+ && request.resource.data.completedAt is timestamp;
+ allow read, delete: if false;
+ }
+
+ match /contact_inquiries/{inquiryId} {
+ allow create: if request.resource.data.keys().hasOnly([
+ 'name',
+ 'email',
+ 'topic',
+ 'message',
+ 'source',
+ 'status',
+ 'createdAt',
+ ])
+ && request.resource.data.name is string
+ && request.resource.data.email is string
+ && request.resource.data.topic is string
+ && request.resource.data.message is string
+ && request.resource.data.source in ['floating-button', 'footer']
+ && request.resource.data.status == 'new'
+ && request.resource.data.createdAt is timestamp;
+ allow read, update, delete: if false;
+ }
+
+ match /reviews/{reviewId} {
+ allow create: if request.resource.data.keys().hasOnly([
+ 'rating',
+ 'comment',
+ 'source',
+ 'status',
+ 'createdAt',
+ ])
+ && request.resource.data.rating is int
+ && request.resource.data.rating >= 1
+ && request.resource.data.rating <= 5
+ && request.resource.data.comment is string
+ && request.resource.data.source in ['result-page', 'pdf-download']
+ && request.resource.data.status == 'pending'
+ && request.resource.data.createdAt is timestamp;
+ allow read, update, delete: if false;
+ }
+
match /{document=**} {
- // This rule allows anyone with your database reference to view, edit,
- // and delete all data in your database. It is useful for getting
- // started, but it is configured to expire after 30 days because it
- // leaves your app open to attackers. At that time, all client
- // requests to your database will be denied.
- //
- // Make sure to write security rules for your app before that time, or
- // else all client requests to your database will be denied until you
- // update your rules.
- allow read, write: if request.time < timestamp.date(2026, 12, 30);
- allow create: if true;
+ allow read, write: if false;
}
}
}
diff --git a/lib/error-policy.ts b/lib/error-policy.ts
index 4085864..62f6b6f 100644
--- a/lib/error-policy.ts
+++ b/lib/error-policy.ts
@@ -3,9 +3,14 @@ import * as Sentry from '@sentry/nextjs';
const ERROR_POLICY_VERSION = 'v1';
export const USER_ERROR_MESSAGES = {
- GENERIC_RETRY: '오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
- RESULT_REQUEST_FAILED: '결과 요청에 실패했습니다. 잠시 후 다시 시도해주세요.',
- PDF_GENERATION_FAILED: 'PDF 생성에 실패했습니다. 잠시 후 다시 시도해주세요.',
+ GENERIC_RETRY:
+ '오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
+ APPLICATION_ALREADY_EXISTS:
+ '이미 해당 전화번호로 유효한 신청이 있습니다. 이름과 전화번호를 확인한 뒤 고객센터로 문의해주세요.',
+ RESULT_REQUEST_FAILED:
+ '결과 요청에 실패했습니다. 잠시 후 다시 시도해주세요.',
+ PDF_GENERATION_FAILED:
+ 'PDF 생성에 실패했습니다. 잠시 후 다시 시도해주세요.',
} as const;
type ErrorLayer = 'api' | 'ui' | 'storage' | 'firebase';
diff --git a/lib/privacy-consent.ts b/lib/privacy-consent.ts
new file mode 100644
index 0000000..c847e21
--- /dev/null
+++ b/lib/privacy-consent.ts
@@ -0,0 +1,42 @@
+import { BodyDiagnosisFormData } from '@/types/body';
+
+export const PRIVACY_POLICY_PATH = '/privacy';
+export const TERMS_OF_SERVICE_PATH = '/terms';
+
+export const PRIVACY_POLICY_VERSION = '2026-03-04';
+export const TERMS_OF_SERVICE_VERSION = '2026-03-04';
+export const CONSENT_NOTICE_VERSION = '2026-03-04';
+
+export const DATA_RETENTION_POLICY = '서비스 종료 후 1년 보관 후 파기';
+export const RIGHTS_REQUEST_CHANNEL = '하단 문의하기';
+export const THIRD_PARTY_NOTICE =
+ '결제/메시지/이메일 발송 과정에서 처리위탁이 발생할 수 있으며, 상세 내용은 개인정보처리방침에서 확인할 수 있습니다.';
+
+export type ConsentSnapshot = {
+ agreePrivacy: boolean;
+ agreeService: boolean;
+ agreePhotoProcessing: boolean;
+ agreeMarketing: boolean;
+ policyVersion: string;
+ termsVersion: string;
+ consentNoticeVersion: string;
+ agreedAtISO: string;
+ requestId: string;
+};
+
+export function createConsentSnapshot(
+ formData: BodyDiagnosisFormData,
+ requestId: string,
+): ConsentSnapshot {
+ return {
+ agreePrivacy: formData.agreePrivacy,
+ agreeService: formData.agreeService,
+ agreePhotoProcessing: formData.agreePhotoProcessing,
+ agreeMarketing: formData.agreeMarketing,
+ policyVersion: PRIVACY_POLICY_VERSION,
+ termsVersion: TERMS_OF_SERVICE_VERSION,
+ consentNoticeVersion: CONSENT_NOTICE_VERSION,
+ agreedAtISO: new Date().toISOString(),
+ requestId,
+ };
+}
diff --git a/types/body.ts b/types/body.ts
index f5d8fc4..ccb6018 100644
--- a/types/body.ts
+++ b/types/body.ts
@@ -7,5 +7,7 @@ export interface BodyDiagnosisFormData {
weight: string
agreePrivacy: boolean
agreeService: boolean
+ agreePhotoProcessing: boolean
+ agreeMarketing: boolean
paymentMethod: string
}