/** * 게시판 등록/수정/답변 입력 규칙 — 순수 검증 로직만 담는다(외부 의존 없음). * * **이 파일이 검증의 단일 진실원천이다.** Server Action이 저장 직전에 여기를 거치고, 화면의 * 글자수 상한 표시도 여기 상수를 그대로 쓴다 — 규칙이 화면과 서버에서 갈라지는 것을 막는다. * 화면의 required 속성은 편의일 뿐 신뢰 경계가 아니다(Server Action은 UI를 거치지 않고 직접 * 호출될 수 있다). * * 규칙의 근거는 시안(A_BOA_007_p / 008_p / 013_p / 015_p / 016_p)이다 — 백엔드에 게시판 입력 * 검증이 전혀 없어(요청 VO에 Bean Validation 애너테이션이 하나도 없다) 시안이 유일한 근거다. */ import { BOARD_CATEGORY_OPTIONS, IS_BOARD_CATEGORY_ENABLED, 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 >; export type InquiryAnswerFormValues = { answerStatusCode: string; answerContent: string; attachmentId: string; }; export type InquiryAnswerFormErrors = Partial< Record >; export type ValidationResult = | { 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; /** FAQ 유형(`BBS_FAQ_CD`) 코드값. 다른 게시판에서는 쓰이지 않는다. */ allowedTypeCodes?: readonly string[]; } ): ValidationResult { const errors: BoardPostFormErrors = {}; const title = values.title.trim(); const content = values.content.trim(); // 구분은 화면에서 감춰져 있어 값이 오지 않는다 — 플래그를 여기서도 봐야 등록이 막히지 // 않는다(칸·열·검색과 같은 스위치). if (IS_BOARD_CATEGORY_ENABLED && options.isCategoryEditable) { const isKnownCategory = BOARD_CATEGORY_OPTIONS.some( (option) => option.value === values.categoryCode ); if (!isKnownCategory) { errors.categoryCode = '구분을 선택해 주세요.'; } } // 유형은 공지사항에 없는 항목이라 해당 게시판에서만 검증한다. FAQ는 공통코드라 허용 목록을 // 호출부(Server Action)가 조회해 넘긴다. const allowedTypeCodes = options.boardType === 'faq' ? (options.allowedTypeCodes ?? []) : BOARD_TYPE_OPTIONS[options.boardType].map((option) => option.value); if (allowedTypeCodes.length > 0 && !allowedTypeCodes.includes(values.typeCode)) { 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; allowedStatusCodes: readonly string[] } ): ValidationResult { const errors: InquiryAnswerFormErrors = {}; const answerContent = values.answerContent.trim(); if (!options.allowedStatusCodes.includes(values.answerStatusCode)) { 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 } }; }