/** * 콘텐츠관리 등록·수정 폼의 입력 규칙 — 화면과 Server Action이 같은 규칙을 본다. * 검증의 최종 판정은 Server Action이 하고, 화면의 required는 편의일 뿐이다. */ import { ANSWER_MESSAGE_ETC, ANSWER_MESSAGE_OPTIONS, MIN_COMIC_CUTS, QUIZ_ANSWER_OPTIONS, QUIZ_LEVEL_OPTIONS, findSchoolLevel, gradeOptionsOf, type ContentKeyword, } from '@/lib/domain/content'; export const TITLE_MAX_LENGTH = 200; export const SUBJECT_MAX_LENGTH = 200; export const KEYWORD_MAX_LENGTH = 50; export const KEYWORD_DESCRIPTION_MAX_LENGTH = 200; export const MESSAGE_MAX_LENGTH = 2000; export const QUESTION_MAX_LENGTH = 500; export const ANSWER_MESSAGE_MAX_LENGTH = 200; export type ContentFormErrors = Record; export type ContentFormState = | { status: 'idle' } | { status: 'error'; message?: string; errors?: ContentFormErrors } | { status: 'success'; id?: string }; export const INITIAL_CONTENT_FORM_STATE: ContentFormState = { status: 'idle' }; export type GradeSelection = { schoolLevel: string; grades: string[]; }; export type ShortsFormValues = GradeSelection & { title: string; subject: string; keywords: ContentKeyword[]; message: string; videoFileId: string; thumbnailFileId: string; isVisible: boolean; }; export type ComicFormValues = GradeSelection & { name: string; subject: string; keywords: ContentKeyword[]; message: string; thumbnailFileId: string; cutFileIds: string[]; isVisible: boolean; }; export type QuizFormValues = GradeSelection & { level: string; keywords: ContentKeyword[]; question: string; answer: string; answerMessageCode: string; answerMessageText: string; explanation: string; isVisible: boolean; }; export type ValidationResult = | { ok: true; values: T } | { ok: false; errors: ContentFormErrors }; /** 빈 줄은 저장하지 않는다 — 폼이 늘 보여 주는 기본 2줄이 그대로 넘어오기 때문이다. */ export function normalizeKeywords( keywords: readonly ContentKeyword[] ): ContentKeyword[] { return keywords .map((item) => ({ keyword: item.keyword.trim().slice(0, KEYWORD_MAX_LENGTH), description: item.description .trim() .slice(0, KEYWORD_DESCRIPTION_MAX_LENGTH), })) .filter((item) => item.keyword !== '' || item.description !== ''); } function validateGrades( values: GradeSelection, errors: ContentFormErrors ): void { const level = findSchoolLevel(values.schoolLevel); if (!level) { errors.schoolLevel = '학교급을 선택해 주세요.'; return; } if (level.gradeCount === 0) { return; } const allowed = gradeOptionsOf(values.schoolLevel); if (values.grades.length === 0) { errors.grades = '학년을 하나 이상 선택해 주세요.'; return; } if (values.grades.some((grade) => !allowed.includes(grade))) { errors.grades = '선택할 수 없는 학년이 포함돼 있습니다.'; } } function validateKeywords( keywords: readonly ContentKeyword[], errors: ContentFormErrors ): void { if (keywords.some((item) => item.keyword === '' && item.description !== '')) { errors.keywords = '설명만 있는 줄이 있습니다. 키워드를 입력해 주세요.'; } } function requireText( value: string, maxLength: number, field: string, message: string, errors: ContentFormErrors ): string { const trimmed = value.trim(); if (trimmed === '') { errors[field] = message; } return trimmed.slice(0, maxLength); } export function validateShortsForm( values: ShortsFormValues ): ValidationResult { const errors: ContentFormErrors = {}; validateGrades(values, errors); const title = requireText( values.title, TITLE_MAX_LENGTH, 'title', '제목을 입력해 주세요.', errors ); const message = requireText( values.message, MESSAGE_MAX_LENGTH, 'message', '전달 메시지(해설)를 입력해 주세요.', errors ); const keywords = normalizeKeywords(values.keywords); validateKeywords(keywords, errors); if (Object.keys(errors).length > 0) { return { ok: false, errors }; } return { ok: true, values: { ...values, title, subject: values.subject.trim().slice(0, SUBJECT_MAX_LENGTH), message, keywords, }, }; } export function validateComicForm( values: ComicFormValues ): ValidationResult { const errors: ContentFormErrors = {}; validateGrades(values, errors); const name = requireText( values.name, TITLE_MAX_LENGTH, 'name', '콘텐츠명을 입력해 주세요.', errors ); const message = requireText( values.message, MESSAGE_MAX_LENGTH, 'message', '전달 메시지(해설)를 입력해 주세요.', errors ); const keywords = normalizeKeywords(values.keywords); validateKeywords(keywords, errors); const cutFileIds = values.cutFileIds.filter(Boolean); if (cutFileIds.length < MIN_COMIC_CUTS) { errors.cutFileIds = `만화 이미지를 ${MIN_COMIC_CUTS}컷 이상 등록해 주세요.`; } if (Object.keys(errors).length > 0) { return { ok: false, errors }; } return { ok: true, values: { ...values, name, subject: values.subject.trim().slice(0, SUBJECT_MAX_LENGTH), message, keywords, cutFileIds, }, }; } export function validateQuizForm( values: QuizFormValues ): ValidationResult { const errors: ContentFormErrors = {}; validateGrades(values, errors); if (!QUIZ_LEVEL_OPTIONS.some((option) => option.value === values.level)) { errors.level = '난이도를 선택해 주세요.'; } if (!(QUIZ_ANSWER_OPTIONS as readonly string[]).includes(values.answer)) { errors.answer = '정답을 선택해 주세요.'; } if ( values.answerMessageCode !== '' && !ANSWER_MESSAGE_OPTIONS.some( (option) => option.value === values.answerMessageCode ) ) { errors.answerMessageCode = '노출 문구를 다시 선택해 주세요.'; } const question = requireText( values.question, QUESTION_MAX_LENGTH, 'question', '질문을 입력해 주세요.', errors ); const explanation = requireText( values.explanation, MESSAGE_MAX_LENGTH, 'explanation', '해설을 입력해 주세요.', errors ); let answerMessageText = values.answerMessageText .trim() .slice(0, ANSWER_MESSAGE_MAX_LENGTH); if (values.answerMessageCode === ANSWER_MESSAGE_ETC) { if (answerMessageText === '') { errors.answerMessageText = '노출할 문구를 입력해 주세요.'; } } else { answerMessageText = ''; } const keywords = normalizeKeywords(values.keywords); validateKeywords(keywords, errors); if (Object.keys(errors).length > 0) { return { ok: false, errors }; } return { ok: true, values: { ...values, question, explanation, answerMessageText, keywords }, }; }