'use server'; import { revalidatePath } from 'next/cache'; import { verifySession } from '@/lib/auth/dal'; import { createDecorationItem, deleteDecorationItem, updateDecorationItem, uploadDecorationItemImage, } from '@/lib/data/repositories/decoration-item-repository'; import type { 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 속성·기본값 채움은 편의일 뿐 신뢰 경계가 아니다. * * **이미지 업로드도 이 계층을 지난다** — 브라우저는 백엔드를 직접 호출하지 않으므로(토큰이 * httpOnly 세션 안에만 있다) 폼이 실어 보낸 File을 여기서 받아 Repository를 통해 백엔드에 * 중계하고, 돌려받은 파일 ID를 그대로 아이템 저장에 쓴다. * * `DecorationItemFormState` 타입과 그 초깃값은 이 파일이 아니라 domain 계층에 있다 — Next.js가 * `'use server'` 파일에서 함수가 아닌 값을 export하는 것을 런타임에 거부하기 때문이다 * (`decoration-item-form.ts`의 해당 타입 주석 참조). */ const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.'; const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.'; const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.'; const IMAGE_UPLOAD_FAILED_MESSAGE = '이미지를 업로드하지 못했습니다.'; 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); } function parseItemSn(formData: FormData): number | null { const value = parseFormNumber(formData, 'itemSn'); return Number.isInteger(value) && value > 0 ? value : null; } /** * 새로 선택한 파일과 폼이 유지하고 있던 기존 파일 ID를 함께 읽는다. 등록 화면에는 기존 ID가 * 없으므로 파일을 고르지 않으면 둘 다 비고, 그 상태는 도메인 검증(`imageFileId` 필수)이 잡는다. */ function readImageInput(formData: FormData): { newFile: File | null; currentImageFileId: string; } { const file = formData.get('imageFile'); return { newFile: file instanceof File && file.size > 0 ? file : null, currentImageFileId: readString(formData, 'imageFileId').trim(), }; } /** * 업로드 전 검증 단계에서만 쓰는 자리표시자 — "새 파일이 선택됐으므로 이미지 필수 조건은 * 충족될 예정"이라는 뜻이다. 실제 저장 값으로는 절대 쓰이지 않는다(업로드 성공 후 진짜 파일 * ID로 교체된다). * * 검증을 업로드보다 먼저 하기 위해 필요하다 — 순서를 반대로 하면 다른 항목이 잘못됐을 때 * 파일만 백엔드에 올라간 채 저장은 실패해 고아 파일이 쌓인다(파일 삭제 API를 부를 방법도 없다). */ const PENDING_UPLOAD_PLACEHOLDER = 'pending-upload'; /** * 등록·수정이 공유하는 입력 항목을 읽는다. 유형·카테고리는 select/radio 값을 그대로 읽고, * 허용 목록을 벗어난 값(위조된 요청 포함)은 뒤이은 검증이 걸러낸다 — 여기서 하는 캐스팅은 타입을 * 맞추는 것일 뿐 신뢰를 부여하지 않는다. */ function readEditableValues( formData: FormData, imageFileId: string ): DecorationItemEditableValues { return { itemType: readString(formData, 'itemType') as DecorationItemType, name: readString(formData, 'name'), categoryCode: readString(formData, 'categoryCode'), points: parseFormNumber(formData, 'points'), description: readString(formData, 'description'), isActive: readString(formData, 'isActive') === 'true', sortOrder: parseFormNumber(formData, 'sortOrder'), imageFileId, }; } /** 시안 ADM_ITM_102_p — 꾸미기 아이템 등록. 아이템ID(itemSn)는 백엔드가 자동 채번한다. */ export async function createDecorationItemAction( _prevState: DecorationItemFormState, formData: FormData ): Promise { await verifySession(); const { newFile, currentImageFileId } = readImageInput(formData); const validation = validateDecorationItemCreate( readEditableValues( formData, newFile ? PENDING_UPLOAD_PLACEHOLDER : currentImageFileId ) ); if (!validation.ok) { return { status: 'error', errors: validation.errors }; } let imageFileId = currentImageFileId; if (newFile) { try { imageFileId = await uploadDecorationItemImage(newFile); } catch { return { status: 'error', message: IMAGE_UPLOAD_FAILED_MESSAGE }; } } try { await createDecorationItem({ ...validation.values, imageFileId }); } catch { return { status: 'error', message: SAVE_FAILED_MESSAGE }; } revalidatePath(DECORATION_ITEMS_PATH); return { status: 'success' }; } /** 시안 ADM_ITM_103_p — 꾸미기 아이템 수정. 아이템ID는 읽기 전용이라 변경 대상이 아니다. */ export async function updateDecorationItemAction( _prevState: DecorationItemFormState, formData: FormData ): Promise { await verifySession(); const itemSn = parseItemSn(formData); if (itemSn === null) { return { status: 'error', message: INVALID_REQUEST_MESSAGE }; } const { newFile, currentImageFileId } = readImageInput(formData); const validation = validateDecorationItemUpdate( readEditableValues( formData, newFile ? PENDING_UPLOAD_PLACEHOLDER : currentImageFileId ) ); if (!validation.ok) { return { status: 'error', errors: validation.errors }; } let imageFileId = currentImageFileId; if (newFile) { try { imageFileId = await uploadDecorationItemImage(newFile); } catch { return { status: 'error', message: IMAGE_UPLOAD_FAILED_MESSAGE }; } } try { await updateDecorationItem(itemSn, { ...validation.values, imageFileId }); } catch { return { status: 'error', message: SAVE_FAILED_MESSAGE }; } revalidatePath(DECORATION_ITEMS_PATH); return { status: 'success' }; } /** * 시안 ADM_ITM_101 — 삭제. 확인 얼럿은 화면(`decoration-item-row-actions.tsx`)이 띄우고, * 여기서는 인증과 입력만 확인한다. * * 폼 제출이 아니라 얼럿의 [삭제] 클릭에 반응하는 단발 호출이라 `useActionState`의 * (prevState, formData) 규약 대신 식별자를 직접 받는다. */ export async function deleteDecorationItemAction( itemSn: number ): Promise { await verifySession(); if (!Number.isInteger(itemSn) || itemSn <= 0) { return { status: 'error', message: INVALID_REQUEST_MESSAGE }; } try { await deleteDecorationItem(itemSn); } catch { return { status: 'error', message: DELETE_FAILED_MESSAGE }; } revalidatePath(DECORATION_ITEMS_PATH); return { status: 'success' }; }