/** * 꾸미기 아이템 등록/수정 입력 규칙 — 순수 검증 로직만 담는다(외부 의존 없음). * * 시안(ADM_ITM_102_p/103_p) 기준 필수 항목: 유형·아이템명·카테고리·포인트·썸네일 이미지. * 설명은 선택이고, 정렬순서는 별도 필수(*) 표시가 없다 — 다만 화면이 항상 기본값을 채워 * 제출하므로 실질적으로 비는 경우가 없고, 그래도 숫자 형식은 여기서 검증한다(Server Action은 * UI를 거치지 않고 직접 호출될 수 있다 — 화면의 기본값 채움은 편의일 뿐 신뢰 경계가 아니다). * * **아이템ID는 입력 항목이 아니다** — 시안이 "등록 후 자동발급됩니다(Read Only)"로 명시하고, * 백엔드도 `itemSn`을 자동증가로 채운다. 그래서 등록·수정의 검증 대상이 동일하다(예전 mock * 구현은 사용자가 직접 입력하는 값이었으나 실제 백엔드에는 그런 컬럼이 없다). * * **이 파일이 검증의 단일 진실원천이다.** Server Action(`_actions.ts`)이 저장 직전에 여기를 거친다. */ import { isKnownCommonCode, type CommonCode } from '@/lib/domain/common-code'; import { DECORATION_ITEM_TYPE_OPTIONS, type DecorationItemType, } from '@/lib/domain/decoration-item'; const NAME_MAX_LENGTH = 100; const DESCRIPTION_MAX_LENGTH = 500; /** 등록·수정 양쪽에서 실제로 바뀔 수 있는 항목(= 백엔드 쓰기 API가 받는 항목). */ export type DecorationItemEditableValues = { itemType: DecorationItemType; name: string; categoryCode: string; points: number; description: string; isActive: boolean; sortOrder: number; /** * 업로드된 이미지의 파일 ID. 등록 시에는 새로 업로드한 값, 수정 시에는 새로 올리지 않았다면 * 기존 값이 그대로 들어온다(화면이 hidden 필드로 유지한다). */ imageFileId: string; }; /** 필드별 오류 메시지 — 키는 폼 필드 이름과 일치시켜 화면이 그대로 붙여 쓸 수 있게 한다. */ export type DecorationItemFormErrors = Partial< Record >; export type ValidationResult = | { ok: true; values: T } | { ok: false; errors: DecorationItemFormErrors }; /** * 등록/수정 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_DECORATION_ITEM_FORM_STATE`처럼 일반 객체 상수를 함께 export하면 그 파일의 Server * Action을 호출하는 즉시 500으로 깨진다. 빌드/타입체크는 통과하고 실제로 폼을 제출해야만 * 드러나는 런타임 전용 제약이라 여기로 옮겼다 — domain 계층은 `'use server'`가 없어 값 export에 * 제약이 없다). */ export type DecorationItemFormState = | { status: 'idle' } | { status: 'error'; message?: string; errors?: DecorationItemFormErrors } | { status: 'success' }; export const INITIAL_DECORATION_ITEM_FORM_STATE: DecorationItemFormState = { status: 'idle', }; function isDecorationItemType(value: string): value is DecorationItemType { return DECORATION_ITEM_TYPE_OPTIONS.some((option) => option.value === value); } /** * 등록·수정 공통 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다. * * `points`/`sortOrder`는 `_actions.ts`가 FormData 문자열을 숫자로 변환해 넘긴다 — 빈 문자열을 * `Number.isInteger`가 걸러낼 수 있도록 그 변환은 `Number('')`가 아니라 명시적으로 실패시키는 * 헬퍼(`_actions.ts`의 `parseFormNumber`)를 거친 값이어야 한다(빈 문자열을 그냥 `Number()`에 * 넘기면 0으로 조용히 통과해 필수 값 누락을 놓친다). */ function validateEditableValues( values: DecorationItemEditableValues, categories: readonly CommonCode[], errors: DecorationItemFormErrors ): DecorationItemEditableValues { const name = values.name.trim(); const description = values.description.trim(); if (!isDecorationItemType(values.itemType)) { errors.itemType = '유형을 선택해 주세요.'; } if (!name) { errors.name = '아이템명을 입력해 주세요.'; } else if (name.length > NAME_MAX_LENGTH) { errors.name = `아이템명은 ${NAME_MAX_LENGTH}자 이내로 입력해 주세요.`; } if (!isKnownCommonCode(categories, values.categoryCode)) { errors.categoryCode = '카테고리를 선택해 주세요.'; } if (!Number.isInteger(values.points) || values.points < 0) { errors.points = '포인트는 0 이상의 숫자로 입력해 주세요.'; } if (description.length > DESCRIPTION_MAX_LENGTH) { errors.description = `설명은 ${DESCRIPTION_MAX_LENGTH}자 이내로 입력해 주세요.`; } if (!Number.isInteger(values.sortOrder) || values.sortOrder < 1) { errors.sortOrder = '정렬순서는 1 이상의 숫자로 입력해 주세요.'; } // 시안이 썸네일 이미지를 필수(*)로 표시한다. 업로드 자체의 실패는 Server Action이 별도 // 메시지로 처리하고, 여기서는 "결과 파일 ID가 없는 상태"만 잡는다. if (!values.imageFileId.trim()) { errors.imageFileId = '썸네일 이미지를 등록해 주세요.'; } return { ...values, name, description }; } /** * 시안 ADM_ITM_102_p — 등록 검증. * * 허용 카테고리는 상수가 아니라 **인자로 받는다** — 값이 백엔드 공통코드(`ITEM_CATE_CD`)에서 * 오므로 이 파일이 알 수 없고, 알아서도 안 된다(domain 계층은 통신을 하지 않는다). */ export function validateDecorationItemCreate( values: DecorationItemEditableValues, categories: readonly CommonCode[] ): ValidationResult { const errors: DecorationItemFormErrors = {}; const editable = validateEditableValues(values, categories, errors); if (Object.keys(errors).length > 0) { return { ok: false, errors }; } return { ok: true, values: editable }; } /** 시안 ADM_ITM_103_p — 수정 검증. 아이템ID는 읽기 전용이라 검증 대상이 아니다. */ export function validateDecorationItemUpdate( values: DecorationItemEditableValues, categories: readonly CommonCode[] ): ValidationResult { return validateDecorationItemCreate(values, categories); }