File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
/**
* 게시판 등록/수정/답변 입력 규칙 — 순수 검증 로직만 담는다(외부 의존 없음).
*
* **이 파일이 검증의 단일 진실원천이다.** Server Action이 저장 직전에 여기를 거치고, 화면의
* 글자수 상한 표시도 여기 상수를 그대로 쓴다 — 규칙이 화면과 서버에서 갈라지는 것을 막는다.
* 화면의 required 속성은 편의일 뿐 신뢰 경계가 아니다(Server Action은 UI를 거치지 않고 직접
* 호출될 수 있다).
*
* 규칙의 근거는 시안(A_BOA_007_p / 008_p / 013_p / 015_p / 016_p)이다 — 백엔드에 게시판 입력
* 검증이 전혀 없어(요청 VO에 Bean Validation 애너테이션이 하나도 없다) 시안이 유일한 근거다.
*/
import {
ANSWER_STATUS_OPTIONS,
BOARD_CATEGORY_OPTIONS,
type BoardType,
} from '@/lib/domain/board-post';
import { BOARD_TYPE_OPTIONS } from '@/lib/domain/board-post-query';
/** 본문 글자수 상한 — 시안 하단의 "50/1000자" 표기 기준. */
export const CONTENT_MAX_LENGTH = 1000;
/** 제목 상한. 시안에 수치가 없어 통상적인 게시판 제목 길이로 둔다(DB 컬럼 길이는 미확인). */
export const TITLE_MAX_LENGTH = 200;
/** 답변 내용 상한 — 시안 A_BOA_013_p ④ "텍스트 입력, 글자수 세기 적용". */
export const ANSWER_MAX_LENGTH = 1000;
export type BoardPostFormValues = {
categoryCode: string;
typeCode: string;
title: string;
content: string;
attachmentId: string;
isPinned: boolean;
isVisible: boolean;
isPushEnabled: boolean;
startDate: string;
endDate: string;
};
export type BoardPostFormErrors = Partial<
Record<keyof BoardPostFormValues, string>
>;
export type InquiryAnswerFormValues = {
answerStatusCode: string;
answerContent: string;
attachmentId: string;
};
export type InquiryAnswerFormErrors = Partial<
Record<keyof InquiryAnswerFormValues, string>
>;
export type ValidationResult<V, E> =
| { ok: true; values: V }
| { ok: false; errors: E };
/**
* 게시판 Server Action의 `useActionState` 결과 상태. 원래는 `_actions.ts`(`'use server'` 파일)에
* 있었으나, Next.js는 **`'use server'` 파일이 async 함수 외의 값을 export하는 것을 런타임에
* 거부한다**("A 'use server' file can only export async functions, found object") —
* `INITIAL_*` 같은 객체 상수를 함께 export하면 그 파일의 Server Action을 호출하는 즉시 500으로
* 깨진다. 빌드·타입체크는 통과하고 실제로 폼을 제출해야만 드러나는 런타임 전용 제약이라 여기로
* 옮겼다 — domain 계층은 `'use server'`가 없어 값 export에 제약이 없다
* (`admin-member-form.ts`·`decoration-item-form.ts`의 동일 패턴 참조).
*/
export type BoardPostFormState =
| { status: 'idle' }
| { status: 'error'; message?: string; errors?: BoardPostFormErrors }
| { status: 'success' };
export const INITIAL_BOARD_POST_FORM_STATE: BoardPostFormState = {
status: 'idle',
};
export type InquiryAnswerFormState =
| { status: 'idle' }
| { status: 'error'; message?: string; errors?: InquiryAnswerFormErrors }
| { status: 'success' };
export const INITIAL_INQUIRY_ANSWER_FORM_STATE: InquiryAnswerFormState = {
status: 'idle',
};
/** `YYYY-MM-DD` 형식인지. 달력 위젯 없이 입력될 수 있으므로 형식을 직접 본다. */
function isIsoDate(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return false;
}
// 2026-02-31처럼 형식은 맞지만 존재하지 않는 날짜를 걸러낸다.
const parsed = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().startsWith(value);
}
/**
* 등록·수정 공통 검증.
*
* `isCategoryEditable`로 등록과 수정을 가른다 — 수정 시 구분은 읽기 전용이라(시안 008_p ① /
* 016_p ①) 값이 오지 않거나 기존 값 그대로 온다. 그 값에까지 "선택 필수"를 적용하면 코드 표가
* 바뀐 뒤 기존 게시물을 저장할 수 없게 되므로, 수정에서는 구분을 검증하지 않는다.
*/
export function validateBoardPostForm(
values: BoardPostFormValues,
options: { boardType: BoardType; isCategoryEditable: boolean }
): ValidationResult<BoardPostFormValues, BoardPostFormErrors> {
const errors: BoardPostFormErrors = {};
const title = values.title.trim();
const content = values.content.trim();
if (options.isCategoryEditable) {
const isKnownCategory = BOARD_CATEGORY_OPTIONS.some(
(option) => option.value === values.categoryCode
);
if (!isKnownCategory) {
errors.categoryCode = '구분을 선택해 주세요.';
}
}
// 유형은 공지사항에 없는 항목이라 해당 게시판에서만 검증한다.
const typeOptions = BOARD_TYPE_OPTIONS[options.boardType];
if (typeOptions.length > 0) {
const isKnownType = typeOptions.some(
(option) => option.value === values.typeCode
);
if (!isKnownType) {
errors.typeCode = '유형을 선택해 주세요.';
}
}
if (!title) {
errors.title = '제목을 입력해 주세요.';
} else if (title.length > TITLE_MAX_LENGTH) {
errors.title = `제목은 ${TITLE_MAX_LENGTH}자 이내로 입력해 주세요.`;
}
if (!content) {
errors.content = '내용을 입력해 주세요.';
} else if (content.length > CONTENT_MAX_LENGTH) {
errors.content = `내용은 ${CONTENT_MAX_LENGTH}자 이내로 입력해 주세요.`;
}
// 노출기간은 선택 항목이지만, 넣는다면 형식과 선후 관계는 맞아야 한다.
const startDate = values.startDate.trim();
const endDate = values.endDate.trim();
if (startDate && !isIsoDate(startDate)) {
errors.startDate = '게시시작일시 형식이 올바르지 않습니다.';
}
if (endDate && !isIsoDate(endDate)) {
errors.endDate = '게시종료일시 형식이 올바르지 않습니다.';
}
if (
!errors.startDate &&
!errors.endDate &&
startDate &&
endDate &&
startDate > endDate
) {
errors.endDate = '게시종료일시는 게시시작일시보다 뒤여야 합니다.';
}
if (Object.keys(errors).length > 0) {
return { ok: false, errors };
}
return {
ok: true,
values: { ...values, title, content, startDate, endDate },
};
}
/**
* 1:1문의 답변 검증(시안 A_BOA_013_p).
*
* 답변 내용은 **답변완료로 바꿀 때만 필수**다 — 답변대기·처리중으로 상태만 바꿔 두는 것도 정상
* 흐름이기 때문이다(시안 ③ "답변완료 선택 시에만 FO에 답변 내용 노출").
*/
export function validateInquiryAnswerForm(
values: InquiryAnswerFormValues,
options: { doneStatusCode: string }
): ValidationResult<InquiryAnswerFormValues, InquiryAnswerFormErrors> {
const errors: InquiryAnswerFormErrors = {};
const answerContent = values.answerContent.trim();
const isKnownStatus = ANSWER_STATUS_OPTIONS.some(
(option) => option.value === values.answerStatusCode
);
if (!isKnownStatus) {
errors.answerStatusCode = '진행상태를 선택해 주세요.';
}
if (values.answerStatusCode === options.doneStatusCode && !answerContent) {
errors.answerContent = '답변완료로 저장하려면 답변 내용을 입력해 주세요.';
}
if (answerContent.length > ANSWER_MAX_LENGTH) {
errors.answerContent = `답변 내용은 ${ANSWER_MAX_LENGTH}자 이내로 입력해 주세요.`;
}
if (Object.keys(errors).length > 0) {
return { ok: false, errors };
}
return { ok: true, values: { ...values, answerContent } };
}