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 { unstable_rethrow } from 'next/navigation';
import { revalidatePath } from 'next/cache';
import { verifySession } from '@/lib/auth/dal';
import {
BackendRequestError,
COMMUNICATION_ERROR_CODE,
} from '@/lib/http/backend-fetch';
import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
import {
createDecorationItem,
deleteDecorationItem,
updateDecorationItem,
uploadDecorationItemImage,
} from '@/lib/data/repositories/decoration-item-repository';
import { CODE_GROUP } from '@/lib/domain/common-code';
import type { DecorationItemType } from '@/lib/domain/decoration-item';
import {
validateDecorationItemCreate,
validateDecorationItemImage,
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 describeUploadFailure(error: unknown): string {
if (error instanceof BackendRequestError && error.code !== COMMUNICATION_ERROR_CODE) {
return `${IMAGE_UPLOAD_FAILED_MESSAGE} (${error.message})`;
}
return 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<DecorationItemFormState> {
await verifySession();
const { newFile, currentImageFileId } = readImageInput(formData);
const validation = validateDecorationItemCreate(
readEditableValues(
formData,
newFile ? PENDING_UPLOAD_PLACEHOLDER : currentImageFileId
),
await fetchCommonCodes(CODE_GROUP.decorationItemCategory)
);
if (!validation.ok) {
return { status: 'error', errors: validation.errors };
}
let imageFileId = currentImageFileId;
if (newFile) {
// 백엔드 저장소 설정에도 확장자·크기 제한이 있지만 걸리면 사유 없이 실패한다 — 시안이
// 못박은 한도를 여기서 먼저 재서 어느 항목이 왜 잘못됐는지 알려 준다.
const imageError = validateDecorationItemImage(newFile);
if (imageError) {
return { status: 'error', errors: { imageFileId: imageError } };
}
try {
imageFileId = await uploadDecorationItemImage(newFile);
} catch (error) {
// 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다.
unstable_rethrow(error);
return { status: 'error', errors: { imageFileId: describeUploadFailure(error) } };
}
}
try {
await createDecorationItem({ ...validation.values, imageFileId });
} catch (error) {
unstable_rethrow(error);
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<DecorationItemFormState> {
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
),
await fetchCommonCodes(CODE_GROUP.decorationItemCategory)
);
if (!validation.ok) {
return { status: 'error', errors: validation.errors };
}
let imageFileId = currentImageFileId;
if (newFile) {
// 백엔드 저장소 설정에도 확장자·크기 제한이 있지만 걸리면 사유 없이 실패한다 — 시안이
// 못박은 한도를 여기서 먼저 재서 어느 항목이 왜 잘못됐는지 알려 준다.
const imageError = validateDecorationItemImage(newFile);
if (imageError) {
return { status: 'error', errors: { imageFileId: imageError } };
}
try {
imageFileId = await uploadDecorationItemImage(newFile);
} catch (error) {
unstable_rethrow(error);
return { status: 'error', errors: { imageFileId: describeUploadFailure(error) } };
}
}
try {
await updateDecorationItem(itemSn, { ...validation.values, imageFileId });
} catch (error) {
unstable_rethrow(error);
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<DecorationItemFormState> {
await verifySession();
if (!Number.isInteger(itemSn) || itemSn <= 0) {
return { status: 'error', message: INVALID_REQUEST_MESSAGE };
}
try {
await deleteDecorationItem(itemSn);
} catch (error) {
unstable_rethrow(error);
return { status: 'error', message: DELETE_FAILED_MESSAGE };
}
revalidatePath(DECORATION_ITEMS_PATH);
return { status: 'success' };
}