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,
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<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 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<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 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<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,
messageOptionsByAnswer: Record<QuizAnswer, readonly CommonCode[]>
): 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 = '정답을 선택해 주세요.';
}
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 },
};
}