Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/admin/hooks/forms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ export * from './schemas/community.schema';
export * from './schemas/ios-refund.schema';
export * from './schemas/sometime-article.schema';
export * from './schemas/longform.schema';
export * from './schemas/policy-document.schema';
83 changes: 83 additions & 0 deletions app/admin/hooks/forms/schemas/policy-document.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { z } from 'zod';

export const policyDocumentTypeSchema = z.enum([
'TERMS_OF_SERVICE',
'PRIVACY_POLICY',
'DATA_COLLECTION_CONSENT',
'SENSITIVE_INFO_CONSENT',
'THIRD_PARTY_PROVISION',
'MARKETING_CONSENT',
'REFUND_POLICY',
'LBS_TERMS',
'LOCATION_INFO_CONSENT',
'CHILD_SAFETY_POLICY',
]);

export const policyDocumentSchema = z
.object({
documentType: policyDocumentTypeSchema,
version: z.string().min(1, '버전을 입력해주세요.'),
diffSummary: z.string().min(1, '신구대조 내용을 입력해주세요.'),
changeReason: z.string().min(1, '변경사유를 입력해주세요.'),
contentUrl: z
.string()
.optional()
.refine((v) => !v || /^https?:\/\/.+/.test(v), {
message: '올바른 URL 형식을 입력해주세요.',
}),
noticeStartedAt: z.string().min(1, '공지 시작일을 입력해주세요.'),
effectiveAt: z.string().min(1, '시행일을 입력해주세요.'),
noticeVisibleUntil: z.string().optional(),
isMandatory: z.boolean(),

// 5축 (개인정보처리방침 / 수집이용동의 / 민감정보동의 / 제3자제공동의)
axisCollectionItems: z.boolean().optional(),
axisPurpose: z.boolean().optional(),
axisRetentionPeriod: z.boolean().optional(),
axisThirdParty: z.boolean().optional(),
axisSensitiveInfo: z.boolean().optional(),

// 불리·중대 변경 (이용약관 / 환불정책)
adverseOrMaterial: z.boolean().optional(),

// 마케팅 수신동의 확대 항목
marketingMediaExpanded: z.boolean().optional(),
marketingAdTypeExpanded: z.boolean().optional(),
marketingNightExpanded: z.boolean().optional(),

// 위치기반서비스 범위 확대
locationScopeExpanded: z.boolean().optional(),

// 재동의 예외 처리
reconsentOverride: z.boolean(),
reconsentOverrideReason: z.string().optional(),

// 민감정보 동의 - 개인정보처리방침 §23③ 반영 확인
privacyPolicyDisclosureConfirmed: z.boolean().optional(),

// 필수 항목 - 최소수집 원칙 확인
minimalCollectionConfirmed: z.boolean().optional(),
})
.superRefine((data, ctx) => {
if (data.reconsentOverride && !data.reconsentOverrideReason?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['reconsentOverrideReason'],
message: '재동의 예외 처리 시 사유를 입력해주세요.',
Comment on lines +62 to +66

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate override-reason validation by document type

If an operator checks 재동의 예외 처리 while on the default terms/refund form, leaves the reason blank, and then switches to any non-adverse document type, the override controls are hidden but react-hook-form keeps reconsentOverride set. This unconditional validation then attaches an error to the hidden reconsentOverrideReason field, so the form cannot be submitted and the user has no visible way to fix it except switching back; scope this check to the same document types that render/send the override fields or clear/unregister it on type changes.

Useful? React with 👍 / 👎.

});
}

if (data.noticeStartedAt && data.effectiveAt) {
const start = new Date(data.noticeStartedAt);
const effective = new Date(data.effectiveAt);
if (!Number.isNaN(start.getTime()) && !Number.isNaN(effective.getTime()) && effective < start) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['effectiveAt'],
message: '시행일은 공지 시작일 이후여야 합니다.',
});
}
}
});

export type PolicyDocumentFormValues = z.infer<typeof policyDocumentSchema>;
59 changes: 58 additions & 1 deletion app/admin/hooks/use-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@
CreateNoticeRequest,
CreatePresetRequest,
CreateSometimeArticleRequest,
PolicyDocumentStatus,

Check warning on line 12 in app/admin/hooks/use-content.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused import of 'PolicyDocumentStatus'.

See more on https://sonarcloud.io/project/issues?id=Smartnewb_Project-Solo&issues=AZ9A1AsZ7f4k_Nbhw3Po&open=AZ9A1AsZ7f4k_Nbhw3Po&pullRequest=40
PolicyDocumentType,

Check warning on line 13 in app/admin/hooks/use-content.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused import of 'PolicyDocumentType'.

See more on https://sonarcloud.io/project/issues?id=Smartnewb_Project-Solo&issues=AZ9A1AsZ7f4k_Nbhw3Pp&open=AZ9A1AsZ7f4k_Nbhw3Pp&pullRequest=40
PublishCardNewsRequest,
PublishNoticeRequest,
PushResendNoticeRequest,
RegisterPolicyDocumentRequest,
UpdateBannerOrderRequest,
UpdateBannerRequest,
UpdateCardNewsRequest,
Expand All @@ -22,7 +25,7 @@
UpdateVideoRequest,
VideoStatus,
} from '@/types/admin';
import type { AppReviewsParams } from '@/app/services/admin/content';
import type { AppReviewsParams, PolicyDocumentListParams } from '@/app/services/admin/content';

// Query keys
export const contentKeys = {
Expand Down Expand Up @@ -55,6 +58,12 @@
videos: () => [...contentKeys.all, 'videos'] as const,
videoList: (params?: object) => [...contentKeys.videos(), 'list', params] as const,
videoDetail: (id: string) => [...contentKeys.videos(), 'detail', id] as const,
policyDocuments: () => [...contentKeys.all, 'policy-documents'] as const,
policyDocumentList: (params?: PolicyDocumentListParams) =>
[...contentKeys.policyDocuments(), 'list', params] as const,
policyDocumentDetail: (id: string) => [...contentKeys.policyDocuments(), 'detail', id] as const,
policyDocumentConsentProgress: (id: string) =>
[...contentKeys.policyDocuments(), 'consent-progress', id] as const,
};

// ==================== Background Presets ====================
Expand Down Expand Up @@ -569,3 +578,51 @@
},
});
}

// ==================== 정책 개정 등록 ====================

export function usePolicyDocumentList(params: PolicyDocumentListParams = {}) {
return useQuery({
queryKey: contentKeys.policyDocumentList(params),
queryFn: () => AdminService.policyDocuments.getList(params),
});
}

export function usePolicyDocument(id: string) {
return useQuery({
queryKey: contentKeys.policyDocumentDetail(id),
queryFn: () => AdminService.policyDocuments.get(id),
enabled: !!id,
});
}

export function useRegisterPolicyDocument() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: RegisterPolicyDocumentRequest) => AdminService.policyDocuments.register(data),
onSuccess: (result) => {
if (result.saved) {
queryClient.invalidateQueries({ queryKey: contentKeys.policyDocuments() });
}
},
});
}

export function usePublishPolicyDocument() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => AdminService.policyDocuments.publish(id),
onSuccess: (_result, id) => {
queryClient.invalidateQueries({ queryKey: contentKeys.policyDocuments() });
queryClient.invalidateQueries({ queryKey: contentKeys.policyDocumentDetail(id) });
},
});
}

export function usePolicyConsentProgress(id: string) {
return useQuery({
queryKey: contentKeys.policyDocumentConsentProgress(id),
queryFn: () => AdminService.policyDocuments.consentProgress(id),
enabled: !!id,
});
}
Loading