File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
/**
* 꾸미기 아이템 등록/수정 입력 규칙 — 순수 검증 로직만 담는다(외부 의존 없음).
*
* 시안(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<keyof DecorationItemCreateValues, string>
>;
export type ValidationResult<T> =
| { ok: true; values: T }
| { ok: false; errors: DecorationItemFormErrors };
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<DecorationItemCreateValues> {
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<DecorationItemEditableValues> {
const errors: DecorationItemFormErrors = {};
const editable = validateEditableValues(values, errors);
if (Object.keys(errors).length > 0) {
return { ok: false, errors };
}
return { ok: true, values: editable };
}