File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
/**
* 콘텐츠관리 등록·수정 폼의 입력 규칙 — 화면과 Server Action이 같은 규칙을 본다.
* 검증의 최종 판정은 Server Action이 하고, 화면의 required는 편의일 뿐이다.
*/
import {
ANSWER_MESSAGE_ETC,
ANSWER_MESSAGE_OPTIONS,
MIN_COMIC_CUTS,
QUIZ_ANSWER_OPTIONS,
QUIZ_LEVEL_OPTIONS,
gradesOf,
type ContentKeyword,
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<string, string>;
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 = {
/** `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[];
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<T> =
| { 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<ShortsFormValues> {
const errors: ContentFormErrors = {};
validateGrades(values, codes, 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,
codes: SchoolGradeOptions
): ValidationResult<ComicFormValues> {
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
): ValidationResult<QuizFormValues> {
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 = '정답을 선택해 주세요.';
}
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 },
};
}