/** * 꾸미기 아이템 등록/수정 입력 규칙 — 순수 검증 로직만 담는다(외부 의존 없음). * * 시안(ADM_ITM_102_p/103_p) 기준 필수 항목: 유형·아이템ID(등록 전용)·아이템명·카테고리·포인트. * 설명은 선택이고, 정렬순서는 시안에 별도 필수(*) 표시가 없다 — 다만 화면이 항상 기본값(현재 * 유형의 총 등록 개수+1, 또는 수정 시 기존 값)을 채워 제출하므로 실질적으로 비어 있는 경우가 * 없고, 그래도 숫자 형식은 여기서 검증한다(Server Action은 UI를 거치지 않고 직접 호출될 수 * 있다 — 화면의 기본값 채움은 편의일 뿐 신뢰 경계가 아니다). * * **이 파일이 검증의 단일 진실원천이다.** Server Action(`_actions.ts`)이 저장 직전에 여기를 * 거친다. * * 아이템ID는 백엔드 코드 체계가 없어(사용자 확정 — 이 도메인 전체가 mock) 시안 예시 * (`ITEM-HAT-002`)를 참고한 느슨한 형식만 강제한다: 영문/숫자 세그먼트를 하이픈으로 구분. * * **등록과 수정의 검증을 분리한 이유**: 수정 팝업에서 아이템ID는 읽기 전용이다(시안 * ADM_ITM_103_p). 수정은 실제로 바뀔 수 있는 항목(유형/아이템명/카테고리/포인트/설명/사용여부/ * 정렬순서)만 검증한다. */ import { DECORATION_ITEM_CATEGORIES, DECORATION_ITEM_TYPE_OPTIONS, type DecorationItemCategory, type DecorationItemType, } from '@/lib/domain/decoration-item'; export const DECORATION_ITEM_ID_HELP_TEXT = '영문·숫자와 하이픈(-)으로 입력해 주세요. 예: ITEM-HAT-002'; const ITEM_ID_MIN_LENGTH = 3; const ITEM_ID_MAX_LENGTH = 50; const ITEM_ID_PATTERN = /^[A-Za-z0-9]+(-[A-Za-z0-9]+)*$/; const NAME_MAX_LENGTH = 100; const DESCRIPTION_MAX_LENGTH = 500; /** 등록·수정 양쪽에서 실제로 바뀔 수 있는 항목. */ export type DecorationItemEditableValues = { itemType: DecorationItemType; name: string; category: DecorationItemCategory; points: number; description: string; isActive: boolean; sortOrder: number; }; /** 등록은 위 항목에 더해 아이템ID를 입력받는다(수정에서는 읽기 전용). */ export type DecorationItemCreateValues = DecorationItemEditableValues & { itemId: string; }; /** 필드별 오류 메시지 — 키는 폼 필드 이름과 일치시켜 화면이 그대로 붙여 쓸 수 있게 한다. */ export type DecorationItemFormErrors = Partial< Record >; export type ValidationResult = | { ok: true; values: T } | { ok: false; errors: DecorationItemFormErrors }; /** * 등록/수정 Server Action의 `useActionState` 결과 상태. 원래는 admins처럼 `_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); } function isDecorationItemCategory( value: string ): value is DecorationItemCategory { return (DECORATION_ITEM_CATEGORIES as readonly string[]).includes(value); } /** * 아이템ID 형식 검증 — 문제가 있으면 안내 문구, 없으면 null. 중복 확인은 여기서 하지 않는다 * (Repository/Server Action의 몫 — `decoration-item-repository.ts` 주석 참조). */ export function validateDecorationItemId(itemId: string): string | null { const value = itemId.trim(); if (!value) { return '아이템 ID를 입력해 주세요.'; } if (value.length < ITEM_ID_MIN_LENGTH || value.length > ITEM_ID_MAX_LENGTH) { return `아이템 ID는 ${ITEM_ID_MIN_LENGTH}~${ITEM_ID_MAX_LENGTH}자로 입력해 주세요.`; } if (!ITEM_ID_PATTERN.test(value)) { return DECORATION_ITEM_ID_HELP_TEXT; } return null; } /** * 등록·수정 공통 항목 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다. * * `points`/`sortOrder`는 `_actions.ts`가 FormData 문자열을 숫자로 변환해 넘긴다 — 빈 문자열을 * `Number.isInteger`가 걸러낼 수 있도록 그 변환은 `Number('')`가 아니라 명시적으로 실패시키는 * 헬퍼(`_actions.ts`의 `parseFormNumber`)를 거친 값이어야 한다(빈 문자열을 그냥 `Number()`에 * 넘기면 0으로 조용히 통과해 필수 값 누락을 놓친다). */ function validateEditableValues( values: DecorationItemEditableValues, 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 (!isDecorationItemCategory(values.category)) { errors.category = '카테고리를 선택해 주세요.'; } 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 이상의 숫자로 입력해 주세요.'; } return { ...values, name, description }; } /** 시안 ADM_ITM_102_p — 등록 검증(아이템ID 포함). */ export function validateDecorationItemCreate( values: DecorationItemCreateValues ): ValidationResult { const errors: DecorationItemFormErrors = {}; const itemIdError = validateDecorationItemId(values.itemId); if (itemIdError) { errors.itemId = itemIdError; } const editable = validateEditableValues(values, errors); if (Object.keys(errors).length > 0) { return { ok: false, errors }; } return { ok: true, values: { ...editable, itemId: values.itemId.trim() } }; } /** 시안 ADM_ITM_103_p — 수정 검증. 아이템ID는 읽기 전용이라 검증 대상이 아니다(파일 상단 주석). */ export function validateDecorationItemUpdate( values: DecorationItemEditableValues ): ValidationResult { const errors: DecorationItemFormErrors = {}; const editable = validateEditableValues(values, errors); if (Object.keys(errors).length > 0) { return { ok: false, errors }; } return { ok: true, values: editable }; }