File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
'use server';
import { revalidatePath } from 'next/cache';
import { verifySession } from '@/lib/auth/dal';
import {
createDecorationItem,
deleteDecorationItem,
isDecorationItemIdTaken,
updateDecorationItem,
} from '@/lib/data/repositories/decoration-item-repository';
import type {
DecorationItemCategory,
DecorationItemType,
} from '@/lib/domain/decoration-item';
import {
validateDecorationItemCreate,
validateDecorationItemUpdate,
type DecorationItemEditableValues,
type DecorationItemFormState,
} from '@/lib/domain/decoration-item-form';
import { DECORATION_ITEMS_PATH } from '@/lib/domain/decoration-item-query';
/**
* 꾸미기 아이템 등록/수정/삭제 Server Action.
*
* **모든 Action이 `verifySession()`으로 시작한다** — Server Action은 UI를 거치지 않고 직접
* POST될 수 있어 이 확인이 유일한 최종 방어선이다(설계서 §3 SRP 체크).
*
* 검증은 화면이 아니라 여기서 확정한다(`lib/domain/decoration-item-form.ts`의 규칙을 호출) —
* 화면의 required 속성·기본값 채움은 편의일 뿐 신뢰 경계가 아니다.
*
* 실제 저장은 Repository에 맡긴다. 백엔드에 이 도메인의 API가 전혀 없어(조회조차 없음, 사용자
* 확정 사항) Repository가 mock 저장소로 위임하고 있지만, **이 파일은 그 사실을 알지 못한다** —
* 백엔드 API가 생겨도 이 파일은 바뀌지 않는다.
*
* `DecorationItemFormState` 타입과 그 초깃값(`INITIAL_DECORATION_ITEM_FORM_STATE`)은 이
* 파일이 아니라 `lib/domain/decoration-item-form.ts`에 있다 — Next.js가 `'use server'`
* 파일에서 함수가 아닌 값(일반 객체 상수)을 export하는 것을 런타임에 거부하기 때문이다
* (`decoration-item-form.ts`의 해당 타입 주석 참조). 타입만 이 파일에서 다시 쓰는 것은
* 문제 없다 — 타입은 컴파일 시 지워져 런타임 export로 남지 않는다.
*/
const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.';
const DUPLICATE_ITEM_ID_MESSAGE = '이미 사용 중인 아이템 ID입니다.';
function readString(formData: FormData, key: string): string {
const value = formData.get(key);
return typeof value === 'string' ? value : '';
}
/**
* 폼 숫자 입력을 파싱한다. `Number('')`이 조용히 `0`이 되는 JS 함정을 피하려고 빈 문자열은
* 명시적으로 `NaN`으로 취급한다 — 그래야 `Number.isInteger` 검증이 "값이 비었음"을 정상적으로
* 잡아낸다(포인트 0은 유효한 값이라 "비어서 0"과 "실제로 0"을 구분해야 한다).
*/
function parseFormNumber(formData: FormData, key: string): number {
const trimmed = readString(formData, key).trim();
return trimmed === '' ? NaN : Number(trimmed);
}
/**
* 등록·수정이 공유하는 입력 항목을 읽는다. 유형·카테고리는 select/radio 값을 그대로 읽고,
* 허용 목록을 벗어난 값(위조된 요청 포함)은 뒤이은 `validateEditableValues`가 걸러낸다 — 여기서
* 하는 캐스팅은 타입을 맞추는 것일 뿐 신뢰를 부여하지 않는다.
*/
function readEditableValues(formData: FormData): DecorationItemEditableValues {
return {
itemType: readString(formData, 'itemType') as DecorationItemType,
name: readString(formData, 'name'),
category: readString(formData, 'category') as DecorationItemCategory,
points: parseFormNumber(formData, 'points'),
description: readString(formData, 'description'),
isActive: readString(formData, 'isActive') === 'true',
sortOrder: parseFormNumber(formData, 'sortOrder'),
};
}
/** 시안 ADM_ITM_102_p — 꾸미기 아이템 등록. */
export async function createDecorationItemAction(
_prevState: DecorationItemFormState,
formData: FormData
): Promise<DecorationItemFormState> {
await verifySession();
const validation = validateDecorationItemCreate({
...readEditableValues(formData),
itemId: readString(formData, 'itemId'),
});
if (!validation.ok) {
return { status: 'error', errors: validation.errors };
}
const { itemId, ...editable } = validation.values;
// 시안에는 admins의 [중복확인] 같은 버튼이 없지만, 아이템ID가 유일 식별자이자 수정/삭제
// 대상 키라 저장 직전에 반드시 다시 확인한다(decoration-item-repository.ts 주석 참조).
if (await isDecorationItemIdTaken(itemId)) {
return { status: 'error', errors: { itemId: DUPLICATE_ITEM_ID_MESSAGE } };
}
await createDecorationItem({ itemId, ...editable });
revalidatePath(DECORATION_ITEMS_PATH);
return { status: 'success' };
}
/** 시안 ADM_ITM_103_p — 꾸미기 아이템 수정. 아이템ID는 읽기 전용이라 변경 대상이 아니다. */
export async function updateDecorationItemAction(
_prevState: DecorationItemFormState,
formData: FormData
): Promise<DecorationItemFormState> {
await verifySession();
const itemId = readString(formData, 'itemId');
if (!itemId) {
return { status: 'error', message: INVALID_REQUEST_MESSAGE };
}
const validation = validateDecorationItemUpdate(readEditableValues(formData));
if (!validation.ok) {
return { status: 'error', errors: validation.errors };
}
await updateDecorationItem(itemId, validation.values);
revalidatePath(DECORATION_ITEMS_PATH);
return { status: 'success' };
}
/**
* 시안 ADM_ITM_101 — 삭제. 확인 얼럿은 화면(`decoration-item-row-actions.tsx`)이 띄우고,
* 여기서는 인증과 입력만 확인한다.
*
* 폼 제출이 아니라 얼럿의 [삭제] 클릭에 반응하는 단발 호출이라 `useActionState`의
* (prevState, formData) 규약 대신 id를 직접 받는다(admins의 `deleteAdminMemberAction`과 동일한
* 이유).
*/
export async function deleteDecorationItemAction(
itemId: string
): Promise<DecorationItemFormState> {
await verifySession();
if (!itemId) {
return { status: 'error', message: INVALID_REQUEST_MESSAGE };
}
await deleteDecorationItem(itemId);
revalidatePath(DECORATION_ITEMS_PATH);
return { status: 'success' };
}