File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
/**
* 꾸미기 아이템 등록/수정 입력 규칙 — 순수 검증 로직만 담는다(외부 의존 없음).
*
* 시안(ADM_ITM_102_p/103_p) 기준 필수 항목: 유형·아이템명·카테고리·포인트·썸네일 이미지.
* 설명은 선택이고, 정렬순서는 별도 필수(*) 표시가 없다 — 다만 화면이 항상 기본값을 채워
* 제출하므로 실질적으로 비는 경우가 없고, 그래도 숫자 형식은 여기서 검증한다(Server Action은
* UI를 거치지 않고 직접 호출될 수 있다 — 화면의 기본값 채움은 편의일 뿐 신뢰 경계가 아니다).
*
* **아이템ID는 입력 항목이 아니다** — 시안이 "등록 후 자동발급됩니다(Read Only)"로 명시하고,
* 백엔드도 `itemSn`을 자동증가로 채운다. 그래서 등록·수정의 검증 대상이 동일하다(예전 mock
* 구현은 사용자가 직접 입력하는 값이었으나 실제 백엔드에는 그런 컬럼이 없다).
*
* **이 파일이 검증의 단일 진실원천이다.** Server Action(`_actions.ts`)이 저장 직전에 여기를 거친다.
*/
import {
DECORATION_ITEM_CATEGORIES,
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<keyof DecorationItemEditableValues, string>
>;
export type ValidationResult<T> =
| { 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);
}
function isDecorationItemCategoryCode(value: string): boolean {
return DECORATION_ITEM_CATEGORIES.some((category) => category.code === value);
}
/**
* 등록·수정 공통 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다.
*
* `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 (!isDecorationItemCategoryCode(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 — 등록 검증. */
export function validateDecorationItemCreate(
values: DecorationItemEditableValues
): ValidationResult<DecorationItemEditableValues> {
const errors: DecorationItemFormErrors = {};
const editable = validateEditableValues(values, 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
): ValidationResult<DecorationItemEditableValues> {
return validateDecorationItemCreate(values);
}