From d424832a7bfb7195d740f5116a6da9f9a43d13c3 Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Wed, 4 Mar 2026 15:23:31 +0900 Subject: [PATCH 01/30] feat(apply): extend form type with optional consent fields --- types/body.ts | 2 ++ 1 file changed, 2 insertions(+) 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 } From 9c58708430da569d4e95c30f08819e360b355db7 Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Wed, 4 Mar 2026 15:23:38 +0900 Subject: [PATCH 02/30] feat(privacy): add consent policy constants and snapshot builder --- lib/privacy-consent.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 lib/privacy-consent.ts diff --git a/lib/privacy-consent.ts b/lib/privacy-consent.ts new file mode 100644 index 0000000..c6f9ec7 --- /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, + }; +} From dca5fbdb6fa80b0de73c2c8fe5456520f2db6b3e Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Wed, 4 Mar 2026 15:23:43 +0900 Subject: [PATCH 03/30] feat(apply): add policy links versions and operation notices --- .../application-form.constants.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/app/apply/components/application-form/application-form.constants.ts b/app/apply/components/application-form/application-form.constants.ts index 0adcdb0..026ab8a 100644 --- a/app/apply/components/application-form/application-form.constants.ts +++ b/app/apply/components/application-form/application-form.constants.ts @@ -1,5 +1,15 @@ import { BodyDiagnosisFormData } from '@/types/body'; import { IS_E2E_TEST_MODE } from '@/lib/e2e-mode'; +import { + CONSENT_NOTICE_VERSION, + DATA_RETENTION_POLICY, + PRIVACY_POLICY_PATH, + PRIVACY_POLICY_VERSION, + RIGHTS_REQUEST_CHANNEL, + TERMS_OF_SERVICE_PATH, + TERMS_OF_SERVICE_VERSION, + THIRD_PARTY_NOTICE, +} from '@/lib/privacy-consent'; export const MAX_UPLOAD_IMAGE_COUNT = 3; export const MAX_UPLOAD_IMAGE_SIZE_MB = 5; @@ -8,6 +18,26 @@ export const ALLOWED_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png'] as const; export const SUBMIT_DELAY_MS = IS_E2E_TEST_MODE ? 0 : 2000; +export const REQUIRED_AGREEMENT_GUIDE_TEXT = + '신청을 진행하려면 개인정보 수집·이용 및 서비스 이용약관 동의가 필요합니다.'; + +export const POLICY_LINKS = { + privacy: PRIVACY_POLICY_PATH, + terms: TERMS_OF_SERVICE_PATH, +} as const; + +export const POLICY_VERSIONS = { + privacy: PRIVACY_POLICY_VERSION, + terms: TERMS_OF_SERVICE_VERSION, + consentNotice: CONSENT_NOTICE_VERSION, +} as const; + +export const PRIVACY_OPERATION_NOTICE = { + retention: DATA_RETENTION_POLICY, + rights: RIGHTS_REQUEST_CHANNEL, + thirdParty: THIRD_PARTY_NOTICE, +} as const; + export const INITIAL_APPLICATION_FORM_DATA: BodyDiagnosisFormData = { name: '', phone: '', @@ -17,5 +47,7 @@ export const INITIAL_APPLICATION_FORM_DATA: BodyDiagnosisFormData = { weight: '', agreePrivacy: false, agreeService: false, + agreePhotoProcessing: false, + agreeMarketing: false, paymentMethod: '', }; From 2c3a5efb3465dd5646f091019449825f17ae2dc2 Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Wed, 4 Mar 2026 15:23:49 +0900 Subject: [PATCH 04/30] feat(apply): return validation errors for required consent --- .../application-form.validation.ts | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/app/apply/components/application-form/application-form.validation.ts b/app/apply/components/application-form/application-form.validation.ts index db236b2..42a6edd 100644 --- a/app/apply/components/application-form/application-form.validation.ts +++ b/app/apply/components/application-form/application-form.validation.ts @@ -18,7 +18,16 @@ const isPositiveNumberText = (value: string) => { return Number.isInteger(parsedValue) && parsedValue > 0; }; -export const isApplicationFormValid = (formData: BodyDiagnosisFormData) => { +export type ApplicationFormValidation = { + isValid: boolean; + errors: { + requiredAgreement: string | null; + }; +}; + +export const validateApplicationForm = ( + formData: BodyDiagnosisFormData, +): ApplicationFormValidation => { const hasAllRequiredTextValues = REQUIRED_TEXT_FIELDS.every((field) => hasTextValue(formData[field]), ); @@ -26,5 +35,17 @@ export const isApplicationFormValid = (formData: BodyDiagnosisFormData) => { const hasValidHeight = isPositiveNumberText(formData.height); const hasValidWeight = isPositiveNumberText(formData.weight); - return hasAllRequiredTextValues && hasAllRequiredAgreements && hasValidHeight && hasValidWeight; + const requiredAgreement = hasAllRequiredAgreements + ? null + : '필수 동의 항목(개인정보 수집·이용, 서비스 이용약관)에 동의해 주세요.'; + + return { + isValid: hasAllRequiredTextValues && hasAllRequiredAgreements && hasValidHeight && hasValidWeight, + errors: { + requiredAgreement, + }, + }; }; + +export const isApplicationFormValid = (formData: BodyDiagnosisFormData) => + validateApplicationForm(formData).isValid; From 850fe3baf2dafec91213231641c2a4b2b72ec4e6 Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Wed, 4 Mar 2026 15:23:53 +0900 Subject: [PATCH 05/30] refactor(apply): expose form error state from form hook --- .../components/application-form/useApplicationFormState.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/apply/components/application-form/useApplicationFormState.ts b/app/apply/components/application-form/useApplicationFormState.ts index 658e0b3..6603195 100644 --- a/app/apply/components/application-form/useApplicationFormState.ts +++ b/app/apply/components/application-form/useApplicationFormState.ts @@ -1,7 +1,7 @@ import { useMemo, useState } from 'react'; import { BodyDiagnosisFormData } from '@/types/body'; import { INITIAL_APPLICATION_FORM_DATA } from '@/app/apply/components/application-form/application-form.constants'; -import { isApplicationFormValid } from '@/app/apply/components/application-form/application-form.validation'; +import { validateApplicationForm } from '@/app/apply/components/application-form/application-form.validation'; export function useApplicationFormState() { const [formData, setFormData] = useState(INITIAL_APPLICATION_FORM_DATA); @@ -13,11 +13,12 @@ export function useApplicationFormState() { setFormData((prev) => ({ ...prev, [field]: value })); }; - const isFormValid = useMemo(() => isApplicationFormValid(formData), [formData]); + const validationResult = useMemo(() => validateApplicationForm(formData), [formData]); return { formData, - isFormValid, + isFormValid: validationResult.isValid, + formErrors: validationResult.errors, updateField, }; } From 8a3a3cfcb0e9cfd81d2061936075bfbbfe1b7026 Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Wed, 4 Mar 2026 15:24:03 +0900 Subject: [PATCH 06/30] feat(apply): redesign consent UI with required optional split --- .../application-form/ApplicationForm.tsx | 132 +++++++++++++----- 1 file changed, 95 insertions(+), 37 deletions(-) diff --git a/app/apply/components/application-form/ApplicationForm.tsx b/app/apply/components/application-form/ApplicationForm.tsx index 6e8c90d..a07b9eb 100644 --- a/app/apply/components/application-form/ApplicationForm.tsx +++ b/app/apply/components/application-form/ApplicationForm.tsx @@ -1,20 +1,27 @@ -'use client'; +'use client'; +import Link from 'next/link'; +import Image from 'next/image'; +import { Camera, CreditCard, Smartphone, Upload, X } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; -import { Camera, CreditCard, Smartphone, Upload, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import Image from 'next/image'; import { Checkbox } from '@/components/ui/checkbox'; import { FormField } from '@/components/ui/form-field'; import { useApplicationFormState } from '@/app/apply/components/application-form/useApplicationFormState'; import { useImagePreviewUpload } from '@/app/apply/components/application-form/useImagePreviewUpload'; import { useApplicationSubmit } from '@/app/apply/components/application-form/useApplicationSubmit'; +import { + POLICY_LINKS, + POLICY_VERSIONS, + PRIVACY_OPERATION_NOTICE, + REQUIRED_AGREEMENT_GUIDE_TEXT, +} from '@/app/apply/components/application-form/application-form.constants'; export default function ApplicationForm() { - const { formData, isFormValid, updateField } = useApplicationFormState(); + const { formData, isFormValid, formErrors, updateField } = useApplicationFormState(); const { fileInputRef, previewUrls, @@ -28,6 +35,8 @@ export default function ApplicationForm() { isFormValid, }); + const shouldShowRequiredAgreementError = !formData.agreePrivacy || !formData.agreeService; + return (
@@ -35,14 +44,13 @@ export default function ApplicationForm() { 신청 정보 입력 - {/* Personal Information */}
updateField('name', e.target.value)} + onChange={(event) => updateField('name', event.target.value)} placeholder='홍길동' className='mt-1' /> @@ -52,7 +60,7 @@ export default function ApplicationForm() { updateField('phone', e.target.value)} + onChange={(event) => updateField('phone', event.target.value)} placeholder='010-1234-5678' className='mt-1' /> @@ -65,7 +73,7 @@ export default function ApplicationForm() { id='email' type='email' value={formData.email} - onChange={(e) => updateField('email', e.target.value)} + onChange={(event) => updateField('email', event.target.value)} placeholder='example@email.com' className='mt-1' /> @@ -91,33 +99,32 @@ export default function ApplicationForm() {
- + updateField('height', e.target.value)} + onChange={(event) => updateField('height', event.target.value)} placeholder='165' className='mt-1' /> - + updateField('weight', e.target.value)} + onChange={(event) => updateField('weight', event.target.value)} placeholder='55' className='mt-1' />
- {/* Photo Upload Section */} - + - 더 정확한 진단을 위해 전신 사진을 업로드해주세요. (최대 3장) + 더 정확한 진단을 위해 전신 사진을 업로드할 수 있습니다. (최대 3장) @@ -131,7 +138,7 @@ export default function ApplicationForm() { className='hidden' /> -

사진을 드래그하거나 클릭하여 업로드

+

사진을 드래그하거나 클릭해 업로드하세요.

- {uploadError &&

{uploadError}

} + {uploadError ?

{uploadError}

: null} - {/* Uploaded Images Preview */} - {previewUrls.length > 0 && ( + {previewUrls.length > 0 ? (
{previewUrls.map((previewUrl, index) => ( -
+
{`업로드된 ))}
- )} + ) : null} - {/* Privacy Agreement */}
-

개인정보 수집 및 이용 동의

+

개인정보 및 약관 동의

- + updateField('agreePrivacy', checked === true)} /> - - + + updateField('agreeService', checked === true)} /> - + + + updateField('agreePhotoProcessing', checked === true)} + /> + + + + + updateField('agreeMarketing', checked === true)} + /> + + +
+ +
+

+ 필수 문서: + {' '} + + 개인정보처리방침 + + {' '} + (v{POLICY_VERSIONS.privacy}), + {' '} + + 이용약관 + + {' '} + (v{POLICY_VERSIONS.terms}) +

+

{PRIVACY_OPERATION_NOTICE.thirdParty}

+

권리행사(열람/정정/삭제) 요청: {PRIVACY_OPERATION_NOTICE.rights}

+ + {shouldShowRequiredAgreementError ? ( +

+ {formErrors.requiredAgreement ?? REQUIRED_AGREEMENT_GUIDE_TEXT} +

+ ) : null}
- {/* Payment Method */}
- {/* Submit Button */}
- {submitError &&

{submitError}

} + {submitError ?

{submitError}

: null}

- 런칭 기념 무료 이벤트! 신청 완료 후 즉시 골격진단을 시작할 수 있습니다. + 이벤트 기간 무료 신청 완료 후 즉시 골격진단이 시작됩니다.

From 504969230191b2faa007530ea5523219902ce56e Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Wed, 4 Mar 2026 15:24:11 +0900 Subject: [PATCH 07/30] feat(firebase): persist consent snapshot and consent log records --- firebase.ts | 57 ++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/firebase.ts b/firebase.ts index 3c6ab85..506d3e4 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 { initializeApp } from 'firebase/app'; +import { + addDoc, + collection, + doc, + getDoc, + getFirestore, + serverTimestamp, + setDoc, +} 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); @@ -37,12 +46,29 @@ export const applyBodyDiagnosis = async (req: BodyDiagnosisFormData) => { } const phoneId = normalizePhone(req.phone); - const newReq = { - ...req, - phone: phoneId, - createdAt: new Date().toLocaleString().toString(), - }; - await setDoc(doc(db, 'apply', phoneId), newReq); // 문서 ID = phone + const requestId = `${phoneId}-${Date.now()}`; + const consentSnapshot = createConsentSnapshot(req, requestId); + + await setDoc( + doc(db, 'apply', phoneId), + { + ...req, + phone: phoneId, + requestId, + consentSnapshot, + retentionPolicy: DATA_RETENTION_POLICY, + rightsRequestChannel: RIGHTS_REQUEST_CHANNEL, + thirdPartyNotice: THIRD_PARTY_NOTICE, + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), + }, + { merge: true }, + ); + + await addDoc(collection(db, 'apply', phoneId, 'consent_logs'), { + ...consentSnapshot, + createdAt: serverTimestamp(), + }); }; const assertPhoneId = (raw: string) => { @@ -52,7 +78,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 +93,7 @@ export async function saveSurveyAnswers(phone: string, answers: string[]): Promi completedAt: serverTimestamp(), }, { merge: true }, - ); // 필요시 덮어쓰기 허용 + ); return ref.id; } From c45c0c515f8be25a1712eef9facf93b77c2abef7 Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Wed, 4 Mar 2026 15:24:15 +0900 Subject: [PATCH 08/30] feat(privacy): add privacy policy page --- app/privacy/page.tsx | 71 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 app/privacy/page.tsx diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx new file mode 100644 index 0000000..33a6776 --- /dev/null +++ b/app/privacy/page.tsx @@ -0,0 +1,71 @@ +import type { Metadata } from 'next'; +import PageBackground from '@/components/common/page-background/page-background'; +import PageContainer from '@/components/common/page-container/page-container'; +import { + CONSENT_NOTICE_VERSION, + DATA_RETENTION_POLICY, + PRIVACY_POLICY_VERSION, + RIGHTS_REQUEST_CHANNEL, + TERMS_OF_SERVICE_VERSION, + THIRD_PARTY_NOTICE, +} from '@/lib/privacy-consent'; + +export const metadata: Metadata = { + title: '개인정보처리방침', + description: 'Style Me 개인정보 처리 기준과 이용자 권리 안내', + alternates: { + canonical: '/privacy', + }, +}; + +export default function PrivacyPage() { + return ( + + +
+

개인정보처리방침

+

버전: v{PRIVACY_POLICY_VERSION}

+ +
+

1. 수집 항목

+

+ 이름, 연락처, 이메일, 성별, 키/몸무게, 선택 항목(사진 업로드 동의/마케팅 동의), 결제수단 +

+
+ +
+

2. 이용 목적

+

+ 골격 진단 서비스 제공, 결과 안내, 서비스 품질 개선, 고객 문의 대응 +

+
+ +
+

3. 보관 및 파기

+

{DATA_RETENTION_POLICY}

+
+ +
+

4. 처리위탁/제3자 관련 안내

+

{THIRD_PARTY_NOTICE}

+
+ +
+

5. 이용자 권리 행사

+

+ 열람/정정/삭제 요청은 다음 채널로 접수할 수 있습니다: {RIGHTS_REQUEST_CHANNEL} +

+
+ +
+

6. 동의 버전 관리

+

+ 개인정보처리방침 v{PRIVACY_POLICY_VERSION}, 이용약관 v{TERMS_OF_SERVICE_VERSION}, 고지문 + v{CONSENT_NOTICE_VERSION} 기준으로 동의 이력을 저장합니다. +

+
+
+
+
+ ); +} From 284e35916ed3b4ea4822ebe99c257d1a7dea4b36 Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Wed, 4 Mar 2026 15:24:21 +0900 Subject: [PATCH 09/30] feat(terms): add terms of service page --- app/terms/page.tsx | 53 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 app/terms/page.tsx diff --git a/app/terms/page.tsx b/app/terms/page.tsx new file mode 100644 index 0000000..69c9079 --- /dev/null +++ b/app/terms/page.tsx @@ -0,0 +1,53 @@ +import type { Metadata } from 'next'; +import PageBackground from '@/components/common/page-background/page-background'; +import PageContainer from '@/components/common/page-container/page-container'; +import { TERMS_OF_SERVICE_VERSION } from '@/lib/privacy-consent'; + +export const metadata: Metadata = { + title: '서비스 이용약관', + description: 'Style Me 서비스 이용약관 안내', + alternates: { + canonical: '/terms', + }, +}; + +export default function TermsPage() { + return ( + + +
+

서비스 이용약관

+

버전: v{TERMS_OF_SERVICE_VERSION}

+ +
+

1. 서비스 목적

+

+ 본 서비스는 이용자가 입력한 정보 기반으로 골격 진단 및 스타일링 가이드를 제공합니다. +

+
+ +
+

2. 이용자 의무

+

+ 이용자는 본인 정보를 정확히 입력해야 하며, 타인의 정보를 무단으로 입력해서는 안 됩니다. +

+
+ +
+

3. 이용 제한

+

+ 비정상적 요청, 시스템 악용, 부정 사용이 확인되는 경우 서비스 이용이 제한될 수 있습니다. +

+
+ +
+

4. 약관 변경

+

+ 약관이 변경되는 경우 적용일 전에 공지하며, 변경 버전이 신청 화면 및 정책 문서에 반영됩니다. +

+
+
+
+
+ ); +} From 90ea88e5a18d6f48f334c04f326e702c8d4fbe69 Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Wed, 4 Mar 2026 15:24:28 +0900 Subject: [PATCH 10/30] chore(seo): allow policy pages in robots rules --- app/robots.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/robots.ts b/app/robots.ts index 42b6388..e3021cf 100644 --- a/app/robots.ts +++ b/app/robots.ts @@ -8,7 +8,7 @@ export default function robots(): MetadataRoute.Robots { rules: [ { userAgent: '*', - allow: ['/', '/apply'], + allow: ['/', '/apply', '/privacy', '/terms'], disallow: ['/survey', '/result', '/complete'], }, ], From 42fa876cf3050f1fa16d34403826637bf237111b Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Wed, 4 Mar 2026 15:24:32 +0900 Subject: [PATCH 11/30] chore(seo): include policy pages in sitemap --- app/sitemap.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/sitemap.ts b/app/sitemap.ts index 9aac822..37a7f93 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -18,5 +18,17 @@ export default function sitemap(): MetadataRoute.Sitemap { changeFrequency: 'weekly', priority: 0.8, }, + { + url: `${siteUrl}/privacy`, + lastModified, + changeFrequency: 'monthly', + priority: 0.4, + }, + { + url: `${siteUrl}/terms`, + lastModified, + changeFrequency: 'monthly', + priority: 0.4, + }, ]; } From a89f7ab90350482799ef0c6c78e5b7726dcdd260 Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Wed, 4 Mar 2026 15:24:36 +0900 Subject: [PATCH 12/30] docs(privacy): add consent operation and QA guide --- docs/privacy-consent-ops.md | 62 +++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docs/privacy-consent-ops.md 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` 모두에 기록 From 53e5b7f5fabdf0dc9fd8fdd733e45129221b6f66 Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Wed, 4 Mar 2026 16:59:51 +0900 Subject: [PATCH 13/30] fix(firebase): make apply and consent log writes atomic --- firebase.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/firebase.ts b/firebase.ts index 506d3e4..d5c5751 100644 --- a/firebase.ts +++ b/firebase.ts @@ -1,12 +1,12 @@ import { initializeApp } from 'firebase/app'; import { - addDoc, collection, doc, getDoc, getFirestore, serverTimestamp, setDoc, + writeBatch, } from 'firebase/firestore'; import { getAnalytics, isSupported } from 'firebase/analytics'; import { BodyDiagnosisFormData } from '@/types/body'; @@ -45,12 +45,15 @@ export const applyBodyDiagnosis = async (req: BodyDiagnosisFormData) => { return; } - const phoneId = normalizePhone(req.phone); + const phoneId = assertPhoneId(req.phone); const requestId = `${phoneId}-${Date.now()}`; const consentSnapshot = createConsentSnapshot(req, requestId); + const batch = writeBatch(db); + const applyRef = doc(db, 'apply', phoneId); + const consentLogRef = doc(collection(db, 'apply', phoneId, 'consent_logs')); - await setDoc( - doc(db, 'apply', phoneId), + batch.set( + applyRef, { ...req, phone: phoneId, @@ -65,10 +68,12 @@ export const applyBodyDiagnosis = async (req: BodyDiagnosisFormData) => { { merge: true }, ); - await addDoc(collection(db, 'apply', phoneId, 'consent_logs'), { + batch.set(consentLogRef, { ...consentSnapshot, createdAt: serverTimestamp(), }); + + await batch.commit(); }; const assertPhoneId = (raw: string) => { @@ -121,7 +126,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(), @@ -141,7 +147,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(), From 7595fa4fd0a0b873156b538dfeb586e524e1deda Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Wed, 4 Mar 2026 17:00:00 +0900 Subject: [PATCH 14/30] chore(nav): add policy links to home header --- app/(home)/components/header/Header.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/(home)/components/header/Header.tsx b/app/(home)/components/header/Header.tsx index dcdf950..b3658f0 100644 --- a/app/(home)/components/header/Header.tsx +++ b/app/(home)/components/header/Header.tsx @@ -1,4 +1,4 @@ -import SiteHeader from '@/components/common/site-header/site-header'; +import SiteHeader from '@/components/common/site-header/site-header'; export default function Header() { const navItems = [ @@ -6,6 +6,8 @@ export default function Header() { { label: '후기', href: '#reviews' }, { label: 'FAQ', href: '#faq' }, { label: '문의', href: '#contact' }, + { label: '개인정보처리방침', href: '/privacy' }, + { label: '이용약관', href: '/terms' }, ]; return ; From 9643f232fdf9ab2ec061bb99408b92a6e7f63bc1 Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Wed, 4 Mar 2026 17:00:05 +0900 Subject: [PATCH 15/30] chore(footer): update support email and remove phone --- app/(home)/components/footer/Footer.tsx | 38 +++++++++++++++---------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/app/(home)/components/footer/Footer.tsx b/app/(home)/components/footer/Footer.tsx index 2f2041a..9ed227f 100644 --- a/app/(home)/components/footer/Footer.tsx +++ b/app/(home)/components/footer/Footer.tsx @@ -1,4 +1,5 @@ -import { Heart, Mail, Phone } from 'lucide-react'; +import Link from 'next/link'; +import { Heart, Mail } from 'lucide-react'; export default function Footer() { return ( @@ -16,10 +17,10 @@ export default function Footer() {

- AI 기반 개인 맞춤 스타일링으로 당신만의 완벽한 스타일을 찾아보세요. 전문 - 스타일리스트가 설계한 정확한 진단과 맞춤형 가이드를 제공합니다. + AI 기반 개인 맞춤 스타일링 서비스로 당신에게 맞는 진단과 가이드를 제공합니다.

+

서비스

+

문의

- urmode@naver.com -
-
- - 010-6415-1548 + yourmode0604@gmail.com

평일 09:00 - 18:00

@@ -63,8 +56,23 @@ export default function Footer() {
-
+ +

© 2025 Style Me. All rights reserved.

+
+ + 개인정보처리방침 + + + 이용약관 + +
From fd8a1949eb00c53dee65981a9ce8d893b52ccea9 Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Wed, 4 Mar 2026 17:00:57 +0900 Subject: [PATCH 16/30] chore(nav): add policy links to survey header --- app/survey/components/header/Header.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/survey/components/header/Header.tsx b/app/survey/components/header/Header.tsx index 0710857..d440255 100644 --- a/app/survey/components/header/Header.tsx +++ b/app/survey/components/header/Header.tsx @@ -1,4 +1,4 @@ -import SiteHeader from '@/components/common/site-header/site-header'; +import SiteHeader from '@/components/common/site-header/site-header'; export default function Header() { const navItems = [ @@ -6,6 +6,8 @@ export default function Header() { { label: '서비스', href: '/public#service' }, { label: '후기', href: '/public#reviews' }, { label: 'FAQ', href: '/public#faq' }, + { label: '개인정보처리방침', href: '/privacy' }, + { label: '이용약관', href: '/terms' }, ]; return ; From d7a53b90d4d6a4535c5c405fd5c3e858a4f463d0 Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Wed, 4 Mar 2026 17:01:07 +0900 Subject: [PATCH 17/30] chore(auth): add policy links and update support contact --- components/auth-guard.tsx | 46 ++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/components/auth-guard.tsx b/components/auth-guard.tsx index d4c4d54..e35b38f 100644 --- a/components/auth-guard.tsx +++ b/components/auth-guard.tsx @@ -1,4 +1,4 @@ -'use client'; +'use client'; import type React from 'react'; @@ -41,8 +41,8 @@ function AuthSuccessMessage() {
-

인증 완료!

-

잠시 후 페이지로 이동합니다...

+

?몄쬆 ?꾨즺!

+

?좎떆 ???섏씠吏€濡??대룞?⑸땲??..

); } @@ -59,7 +59,7 @@ function AuthPhoneVerificationForm({
- 인증 중... + ?몄쬆 以?..
) : ( - '인증하기' + '?몄쬆?섍린' )}
-

아직 결제를 완료하지 않으셨나요?

+

?꾩쭅 寃곗젣瑜??꾨즺?섏? ?딆쑝?⑤굹??

-

💡 도움말

+

꼭 확인하세요

    -
  • • 결제 시 입력한 전화번호와 정확히 일치해야 합니다
  • -
  • • 인증은 24시간 동안 유효합니다
  • -
  • • 문제가 있으시면 고객센터로 문의해주세요
  • +
  • 결제 시 입력한 전화번호와 정확히 일치해야 합니다.
  • +
  • 인증은 24시간 동안 유효합니다.
  • +
  • 문제가 있으면 고객센터로 문의해 주세요.
@@ -134,7 +134,7 @@ export default function AuthGuard({ children, requiredPage, showHeader = true }: const normalizedPhoneNumber = phoneNumber.replace(/[^0-9]/g, ''); if (!normalizedPhoneNumber) { - setError('전화번호를 입력해주세요.'); + setError('?꾪솕踰덊샇瑜??낅젰?댁<?몄슂.'); return; } @@ -142,7 +142,7 @@ export default function AuthGuard({ children, requiredPage, showHeader = true }: const doesPaymentInfoExist = await verifyPhoneNumber(normalizedPhoneNumber); if (!doesPaymentInfoExist) { - setError('결제 정보를 찾을 수 없습니다. 먼저 결제를 완료해주세요.'); + setError('寃곗젣 ?뺣낫瑜?李얠쓣 ???놁뒿?덈떎. 癒쇱? 寃곗젣瑜??꾨즺?댁<?몄슂.'); return; } @@ -188,6 +188,8 @@ export default function AuthGuard({ children, requiredPage, showHeader = true }: navItems={[ { label: '홈', href: '/' }, { label: '신청하기', href: '/apply' }, + { label: '개인정보처리방침', href: '/privacy' }, + { label: '이용약관', href: '/terms' }, ]} /> )} @@ -198,10 +200,10 @@ export default function AuthGuard({ children, requiredPage, showHeader = true }:
-

접근 인증

+

?묎렐 ?몄쬆

- {requiredPage === 'complete' ? '결제 완료' : '골격진단'} 페이지는 결제를 완료한 - 고객만 이용할 수 있습니다. + {requiredPage === 'complete' ? '寃곗젣 ?꾨즺' : '怨④꺽吏꾨떒'} ?섏씠吏€??寃곗젣瑜??꾨즺?? + 怨좉컼留??댁슜?????덉뒿?덈떎.

@@ -209,7 +211,7 @@ export default function AuthGuard({ children, requiredPage, showHeader = true }: - 전화번호 인증 + ?꾪솕踰덊샇 ?몄쬆 @@ -229,10 +231,9 @@ export default function AuthGuard({ children, requiredPage, showHeader = true }:
-

문의사항이 있으시면 연락주세요

-
- 📧 contact@styleme.co.kr - 📞 1588-0000 +

문의사항이 있으면 연락해 주세요.

+
+ ✉ yourmode0604@gmail.com
@@ -243,3 +244,4 @@ export default function AuthGuard({ children, requiredPage, showHeader = true }: return <>{children}; } + From ec1ae8bff45cd92554bafbdb997dc5b18af4faf6 Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Wed, 4 Mar 2026 17:01:41 +0900 Subject: [PATCH 18/30] chore(auth): finalize policy nav and contact text cleanup --- components/auth-guard.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/components/auth-guard.tsx b/components/auth-guard.tsx index e35b38f..648e36e 100644 --- a/components/auth-guard.tsx +++ b/components/auth-guard.tsx @@ -59,7 +59,7 @@ function AuthPhoneVerificationForm({

?묎렐 ?몄쬆

- {requiredPage === 'complete' ? '寃곗젣 ?꾨즺' : '怨④꺽吏꾨떒'} ?섏씠吏€??寃곗젣瑜??꾨즺?? - 怨좉컼留??댁슜?????덉뒿?덈떎. + {requiredPage === 'complete' ? '결제 완료' : '골격진단'} 페이지는 결제를 완료한 + 고객만 이용할 수 있습니다.

@@ -211,7 +211,7 @@ export default function AuthGuard({ children, requiredPage, showHeader = true }: - ?꾪솕踰덊샇 ?몄쬆 + 전화번호 인증 From 99d0f4bd88c053b1ee0610f5aa1107c5dffa0213 Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Wed, 4 Mar 2026 17:39:44 +0900 Subject: [PATCH 19/30] fix(auth): restore readable Korean copy and policy links --- components/auth-guard.tsx | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/components/auth-guard.tsx b/components/auth-guard.tsx index 648e36e..47a5cde 100644 --- a/components/auth-guard.tsx +++ b/components/auth-guard.tsx @@ -1,4 +1,4 @@ -'use client'; +'use client'; import type React from 'react'; @@ -17,6 +17,7 @@ import PageContainer from '@/components/common/page-container/page-container'; import { setStorageJson, STORAGE_KEYS } from '@/lib/client-storage'; import { captureAppError, USER_ERROR_MESSAGES } from '@/lib/error-policy'; import { IS_E2E_TEST_MODE } from '@/lib/e2e-mode'; +import { PRIVACY_POLICY_PATH, TERMS_OF_SERVICE_PATH } from '@/lib/privacy-consent'; const AUTH_SUCCESS_REDIRECT_DELAY_MS = IS_E2E_TEST_MODE ? 0 : 1500; @@ -41,8 +42,8 @@ function AuthSuccessMessage() {
-

?몄쬆 ?꾨즺!

-

?좎떆 ???섏씠吏€濡??대룞?⑸땲??..

+

인증 완료!

+

잠시 후 페이지로 이동합니다...

); } @@ -88,21 +89,21 @@ function AuthPhoneVerificationForm({ {isPending ? (
- ?몄쬆 以?.. + 인증 중...
) : ( - '?몄쬆?섍린' + '인증하기' )}
-

?꾩쭅 寃곗젣瑜??꾨즺?섏? ?딆쑝?⑤굹??

+

아직 결제를 완료하지 않으셨나요?

@@ -188,8 +189,8 @@ export default function AuthGuard({ children, requiredPage, showHeader = true }: navItems={[ { label: '홈', href: '/' }, { label: '신청하기', href: '/apply' }, - { label: '개인정보처리방침', href: '/privacy' }, - { label: '이용약관', href: '/terms' }, + { label: '개인정보처리방침', href: PRIVACY_POLICY_PATH }, + { label: '이용약관', href: TERMS_OF_SERVICE_PATH }, ]} /> )} @@ -200,7 +201,7 @@ export default function AuthGuard({ children, requiredPage, showHeader = true }:
-

?묎렐 ?몄쬆

+

결제 인증

{requiredPage === 'complete' ? '결제 완료' : '골격진단'} 페이지는 결제를 완료한 고객만 이용할 수 있습니다. @@ -244,4 +245,3 @@ export default function AuthGuard({ children, requiredPage, showHeader = true }: return <>{children}; } - From 46b29e63dd421d0fef7aec6ca44c0f3a411bb8ad Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Wed, 4 Mar 2026 17:39:56 +0900 Subject: [PATCH 20/30] refactor(footer): use shared policy path constants --- app/(home)/components/footer/Footer.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/(home)/components/footer/Footer.tsx b/app/(home)/components/footer/Footer.tsx index 9ed227f..9a1375e 100644 --- a/app/(home)/components/footer/Footer.tsx +++ b/app/(home)/components/footer/Footer.tsx @@ -1,5 +1,6 @@ -import Link from 'next/link'; +import Link from 'next/link'; import { Heart, Mail } from 'lucide-react'; +import { PRIVACY_POLICY_PATH, TERMS_OF_SERVICE_PATH } from '@/lib/privacy-consent'; export default function Footer() { return ( @@ -61,13 +62,13 @@ export default function Footer() {

© 2025 Style Me. All rights reserved.

개인정보처리방침 이용약관 From 57ca2f60d020c9a0c8e6c2a2b3648b6baf2f1d87 Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Wed, 4 Mar 2026 17:39:56 +0900 Subject: [PATCH 21/30] refactor(home-header): use shared policy path constants --- app/(home)/components/header/Header.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/(home)/components/header/Header.tsx b/app/(home)/components/header/Header.tsx index b3658f0..f7602d7 100644 --- a/app/(home)/components/header/Header.tsx +++ b/app/(home)/components/header/Header.tsx @@ -1,4 +1,5 @@ -import SiteHeader from '@/components/common/site-header/site-header'; +import SiteHeader from '@/components/common/site-header/site-header'; +import { PRIVACY_POLICY_PATH, TERMS_OF_SERVICE_PATH } from '@/lib/privacy-consent'; export default function Header() { const navItems = [ @@ -6,8 +7,8 @@ export default function Header() { { label: '후기', href: '#reviews' }, { label: 'FAQ', href: '#faq' }, { label: '문의', href: '#contact' }, - { label: '개인정보처리방침', href: '/privacy' }, - { label: '이용약관', href: '/terms' }, + { label: '개인정보처리방침', href: PRIVACY_POLICY_PATH }, + { label: '이용약관', href: TERMS_OF_SERVICE_PATH }, ]; return ; From 3896561264fec5fd1e00a18254f577a4ac94a081 Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Wed, 4 Mar 2026 17:39:57 +0900 Subject: [PATCH 22/30] fix(apply): improve submit error copy --- app/apply/components/application-form/useApplicationSubmit.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/apply/components/application-form/useApplicationSubmit.ts b/app/apply/components/application-form/useApplicationSubmit.ts index df68337..287baad 100644 --- a/app/apply/components/application-form/useApplicationSubmit.ts +++ b/app/apply/components/application-form/useApplicationSubmit.ts @@ -27,7 +27,7 @@ export function useApplicationSubmit({ formData, isFormValid }: UseApplicationSu const weight = Number.parseInt(formData.weight, 10); if (Number.isNaN(height) || Number.isNaN(weight) || height <= 0 || weight <= 0) { - setSubmitError('키와 몸무게는 0보다 큰 숫자로 입력해주세요.'); + setSubmitError('키와 몸무게는 0보다 큰 숫자로 입력해 주세요.'); return; } @@ -61,4 +61,3 @@ export function useApplicationSubmit({ formData, isFormValid }: UseApplicationSu handleSubmit, }; } - From fe6705a3a6941604d32f5135be4a0d80f5da32a6 Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Wed, 4 Mar 2026 17:39:57 +0900 Subject: [PATCH 23/30] refactor(survey-header): use shared policy path constants --- app/survey/components/header/Header.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/survey/components/header/Header.tsx b/app/survey/components/header/Header.tsx index d440255..d96d949 100644 --- a/app/survey/components/header/Header.tsx +++ b/app/survey/components/header/Header.tsx @@ -1,4 +1,5 @@ -import SiteHeader from '@/components/common/site-header/site-header'; +import SiteHeader from '@/components/common/site-header/site-header'; +import { PRIVACY_POLICY_PATH, TERMS_OF_SERVICE_PATH } from '@/lib/privacy-consent'; export default function Header() { const navItems = [ @@ -6,8 +7,8 @@ export default function Header() { { label: '서비스', href: '/public#service' }, { label: '후기', href: '/public#reviews' }, { label: 'FAQ', href: '/public#faq' }, - { label: '개인정보처리방침', href: '/privacy' }, - { label: '이용약관', href: '/terms' }, + { label: '개인정보처리방침', href: PRIVACY_POLICY_PATH }, + { label: '이용약관', href: TERMS_OF_SERVICE_PATH }, ]; return ; From 3683bc97f40bf711f395bae6a2ebf789916aa39f Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Wed, 4 Mar 2026 17:39:57 +0900 Subject: [PATCH 24/30] fix(firebase): use opaque request id and block duplicate apply writes --- firebase.ts | 48 +++++++++++++++++++++++++++++------------------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/firebase.ts b/firebase.ts index d5c5751..12475f8 100644 --- a/firebase.ts +++ b/firebase.ts @@ -1,4 +1,4 @@ -import { initializeApp } from 'firebase/app'; +import { initializeApp } from 'firebase/app'; import { collection, doc, @@ -40,33 +40,43 @@ if (typeof window !== 'undefined') { const normalizePhone = (raw: string) => raw.replace(/\D/g, ''); +const generateOpaqueRequestId = () => { + const randomUuid = globalThis.crypto?.randomUUID?.(); + if (randomUuid) return randomUuid; + + const fallbackRandomToken = Math.random().toString(36).slice(2, 12); + return `req_${Date.now()}_${fallbackRandomToken}`; +}; + export const applyBodyDiagnosis = async (req: BodyDiagnosisFormData) => { if (IS_E2E_TEST_MODE) { return; } const phoneId = assertPhoneId(req.phone); - const requestId = `${phoneId}-${Date.now()}`; + const applyRef = doc(db, 'apply', phoneId); + const existingApply = await getDoc(applyRef); + + if (existingApply.exists()) { + throw new Error('application already exists for this phone'); + } + + const requestId = generateOpaqueRequestId(); const consentSnapshot = createConsentSnapshot(req, requestId); const batch = writeBatch(db); - const applyRef = doc(db, 'apply', phoneId); const consentLogRef = doc(collection(db, 'apply', phoneId, 'consent_logs')); - batch.set( - applyRef, - { - ...req, - phone: phoneId, - requestId, - consentSnapshot, - retentionPolicy: DATA_RETENTION_POLICY, - rightsRequestChannel: RIGHTS_REQUEST_CHANNEL, - thirdPartyNotice: THIRD_PARTY_NOTICE, - createdAt: serverTimestamp(), - updatedAt: serverTimestamp(), - }, - { merge: true }, - ); + batch.set(applyRef, { + ...req, + phone: phoneId, + requestId, + consentSnapshot, + retentionPolicy: DATA_RETENTION_POLICY, + rightsRequestChannel: RIGHTS_REQUEST_CHANNEL, + thirdPartyNotice: THIRD_PARTY_NOTICE, + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), + }); batch.set(consentLogRef, { ...consentSnapshot, @@ -154,4 +164,4 @@ export async function submitReview(req: ReviewRequest): Promise { createdAt: serverTimestamp(), }); return created.id; -} +} \ No newline at end of file From c68ea56cbe0a33e0821edb2ce6fb031953ad28cc Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Wed, 4 Mar 2026 17:39:58 +0900 Subject: [PATCH 25/30] fix(firestore): tighten rules to least privilege --- firestore.rules | 162 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 150 insertions(+), 12 deletions(-) diff --git a/firestore.rules b/firestore.rules index 65dbec7..f852b7c 100644 --- a/firestore.rules +++ b/firestore.rules @@ -1,19 +1,157 @@ -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 == + get(/databases/$(database)/documents/apply/$(phoneId)).data.requestId; + } + + match /apply/{phoneId} { + allow get: if isValidPhoneId(phoneId); + 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) + && exists(/databases/$(database)/documents/apply/$(phoneId)) + && hasConsentLogShape(phoneId); + allow read, 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; } } } From 6a5037fe4c195f7b7661d14b580a390607eab15f Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Wed, 4 Mar 2026 17:39:58 +0900 Subject: [PATCH 26/30] fix(privacy): normalize consent notice constants --- lib/privacy-consent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/privacy-consent.ts b/lib/privacy-consent.ts index c6f9ec7..c847e21 100644 --- a/lib/privacy-consent.ts +++ b/lib/privacy-consent.ts @@ -8,7 +8,7 @@ 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 RIGHTS_REQUEST_CHANNEL = '하단 문의하기'; export const THIRD_PARTY_NOTICE = '결제/메시지/이메일 발송 과정에서 처리위탁이 발생할 수 있으며, 상세 내용은 개인정보처리방침에서 확인할 수 있습니다.'; From bdb1d3d50d59e085a4b823ae66a09642ed92043b Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Thu, 5 Mar 2026 14:57:33 +0900 Subject: [PATCH 27/30] refactor(firebase): harden apply write flow and request id fallback --- firebase.ts | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/firebase.ts b/firebase.ts index 12475f8..03622ff 100644 --- a/firebase.ts +++ b/firebase.ts @@ -40,11 +40,19 @@ 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(2, 12); + const fallbackRandomToken = Math.random() + .toString(36) + .slice( + BASE36_FRACTION_START_INDEX, + BASE36_FRACTION_START_INDEX + FALLBACK_REQUEST_TOKEN_LENGTH, + ); return `req_${Date.now()}_${fallbackRandomToken}`; }; @@ -55,16 +63,16 @@ export const applyBodyDiagnosis = async (req: BodyDiagnosisFormData) => { const phoneId = assertPhoneId(req.phone); const applyRef = doc(db, 'apply', phoneId); - const existingApply = await getDoc(applyRef); - - if (existingApply.exists()) { - throw new Error('application already exists for this phone'); - } - const requestId = generateOpaqueRequestId(); const consentSnapshot = createConsentSnapshot(req, requestId); - const batch = writeBatch(db); 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, @@ -83,6 +91,11 @@ export const applyBodyDiagnosis = async (req: BodyDiagnosisFormData) => { createdAt: serverTimestamp(), }); + batch.set(applyIndexRef, { + requestId, + createdAt: serverTimestamp(), + }); + await batch.commit(); }; @@ -118,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(); } @@ -164,4 +178,4 @@ export async function submitReview(req: ReviewRequest): Promise { createdAt: serverTimestamp(), }); return created.id; -} \ No newline at end of file +} From dc0bc2b5fc1beb8a501200a7623a7022c07b37b6 Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Thu, 5 Mar 2026 14:58:12 +0900 Subject: [PATCH 28/30] fix(rules): align apply and consent writes with post-commit checks --- firestore.rules | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/firestore.rules b/firestore.rules index f852b7c..ca722ce 100644 --- a/firestore.rules +++ b/firestore.rules @@ -83,11 +83,11 @@ service cloud.firestore { && request.resource.data.requestId is string && request.resource.data.createdAt is timestamp && request.resource.data.requestId == - get(/databases/$(database)/documents/apply/$(phoneId)).data.requestId; + getAfter(/databases/$(database)/documents/apply/$(phoneId)).data.requestId; } match /apply/{phoneId} { - allow get: if isValidPhoneId(phoneId); + allow get: if false; allow list: if false; allow create: if isValidPhoneId(phoneId) && !exists(/databases/$(database)/documents/apply/$(phoneId)) @@ -97,12 +97,26 @@ service cloud.firestore { match /consent_logs/{logId} { allow create: if isValidPhoneId(phoneId) - && exists(/databases/$(database)/documents/apply/$(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']) From 97c82dec99f755e7a9645b3ff74a6a368da4e511 Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Thu, 5 Mar 2026 14:58:17 +0900 Subject: [PATCH 29/30] feat(apply): show duplicate application guidance on submit failure --- .../application-form/useApplicationSubmit.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/app/apply/components/application-form/useApplicationSubmit.ts b/app/apply/components/application-form/useApplicationSubmit.ts index 287baad..b5b788e 100644 --- a/app/apply/components/application-form/useApplicationSubmit.ts +++ b/app/apply/components/application-form/useApplicationSubmit.ts @@ -14,6 +14,18 @@ type UseApplicationSubmitParams = { const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const isAlreadyAppliedError = (error: unknown) => { + if (error instanceof Error && error.message === 'application already exists for this phone') { + return true; + } + + if (typeof error !== 'object' || error === null || !('code' in error)) { + return false; + } + + return (error as { code?: string }).code === 'permission-denied'; +}; + export function useApplicationSubmit({ formData, isFormValid }: UseApplicationSubmitParams) { const [isSubmitting, setIsSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); @@ -49,6 +61,11 @@ export function useApplicationSubmit({ formData, isFormValid }: UseApplicationSu feature: 'apply', action: 'submit-application', }); + if (isAlreadyAppliedError(error)) { + setSubmitError(USER_ERROR_MESSAGES.APPLICATION_ALREADY_EXISTS); + return; + } + setSubmitError(USER_ERROR_MESSAGES.GENERIC_RETRY); } finally { setIsSubmitting(false); From c6cdd89c5d3c0a44a93ba0a6a65dc1656ce4832a Mon Sep 17 00:00:00 2001 From: Tnalxmsk Date: Thu, 5 Mar 2026 14:58:30 +0900 Subject: [PATCH 30/30] chore(errors): add duplicate-application user message --- lib/error-policy.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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';