/** * 콘텐츠관리 등록·수정 폼의 입력 규칙 — 화면과 Server Action이 같은 규칙을 본다. * 검증의 최종 판정은 Server Action이 하고, 화면의 required는 편의일 뿐이다. */ import { ANSWER_MESSAGE_ETC, MIN_COMIC_CUTS, QUIZ_ANSWER_OPTIONS, QUIZ_LEVEL_OPTIONS, gradesOf, type QuizAnswer, type ContentKeyword, type QuizAnswerMessage, type GradesBySchool, } from '@/lib/domain/content'; import type { CommonCode } from '@/lib/domain/common-code'; 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 class ContentInputError extends Error { constructor(message: string) { super(message); this.name = 'ContentInputError'; } } export type GradeSelection = { /** `COM_SCHUL_CD` 코드값. */ schoolCode: string; /** `GRD_CD` 코드값 — 백엔드가 받는 것도 이 값이다. */ grades: string[]; }; /** 학년 검증에 필요한 코드표. Server Action이 저장 직전에 다시 조회해 넘긴다. */ export type SchoolGradeOptions = { schools: readonly CommonCode[]; gradesBySchool: GradesBySchool; }; // 전달 메시지(해설)는 기획에서 빠졌다 — 만화는 그대로라 그쪽에만 남는다. export type ShortsFormValues = GradeSelection & { title: string; subject: string; keywords: ContentKeyword[]; 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; /** 정답 선택지마다 한 줄씩의 노출 문구. 정답이 아닌 쪽도 함께 보낸다(사용자 확정). */ answerMessages: QuizAnswerMessage[]; 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, codes: SchoolGradeOptions, errors: ContentFormErrors ): void { if (!codes.schools.some((school) => school.code === values.schoolCode)) { errors.schoolCode = '학교를 선택해 주세요.'; return; } const allowed = gradesOf(codes.gradesBySchool, values.schoolCode); // 학년 코드가 하나도 없는 학교(전체연령 등)는 학년을 고르지 않는다. if (allowed.length === 0) { return; } if (values.grades.length === 0) { errors.grades = '학년을 하나 이상 선택해 주세요.'; return; } if ( values.grades.some( (grade) => !allowed.some((option) => option.code === 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, codes: SchoolGradeOptions ): ValidationResult { const errors: ContentFormErrors = {}; validateGrades(values, codes, errors); const title = requireText( values.title, TITLE_MAX_LENGTH, 'title', '제목을 입력해 주세요.', 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), keywords, }, }; } export function validateComicForm( values: ComicFormValues, codes: SchoolGradeOptions ): ValidationResult { const errors: ContentFormErrors = {}; validateGrades(values, codes, 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, codes: SchoolGradeOptions, messageOptionsByAnswer: Record ): ValidationResult { const errors: ContentFormErrors = {}; validateGrades(values, codes, 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 = '정답을 선택해 주세요.'; } const question = requireText( values.question, QUESTION_MAX_LENGTH, 'question', '질문을 입력해 주세요.', errors ); const explanation = requireText( values.explanation, MESSAGE_MAX_LENGTH, 'explanation', '해설을 입력해 주세요.', errors ); // 정답 선택지 줄을 각각 검사한다 — 정답이 아닌 쪽도 보내므로 모두 유효해야 한다. const answerMessages = values.answerMessages.map((message) => { const allowedCodes = messageOptionsByAnswer[message.answer]; if ( message.code !== '' && message.code !== ANSWER_MESSAGE_ETC && !allowedCodes.some((option) => option.code === message.code) ) { errors[`answerMessage${message.answer}Code`] = '노출 문구를 다시 선택해 주세요.'; } // 백엔드 칸이 하나뿐이라(`cntntsQuizDescr`) 여기서 문구를 확정한다 — 코드를 골랐으면 // 그 코드명이, 기타면 직접 적은 값이 그대로 저장·노출된다. let text = message.text.trim().slice(0, ANSWER_MESSAGE_MAX_LENGTH); if (message.code === ANSWER_MESSAGE_ETC) { if (text === '') { errors[`answerMessage${message.answer}Text`] = `${message.answer}에 노출할 문구를 입력해 주세요.`; } } else { text = allowedCodes.find((option) => option.code === message.code)?.label ?? ''; } return { ...message, text }; }); 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, answerMessages, keywords }, }; }