'use server'; import { unstable_rethrow } from 'next/navigation'; import { revalidatePath } from 'next/cache'; import { verifySession } from '@/lib/auth/dal'; import { createCodeDetail, createCodeGroup, deleteCodeDetail, deleteCodeGroup, updateCodeDetail, updateCodeGroup, } from '@/lib/data/repositories/common-code-repository'; import { validateCommonCodeDetail, validateCommonCodeGroup, type CommonCodeFormState, } from '@/lib/domain/common-code-form'; import { COMMON_CODES_PATH } from '@/lib/domain/common-code-query'; /** * 코드관리 등록/수정/삭제 Server Action(기획 SYS_COD_001). * * **모든 Action이 `verifySession()`으로 시작한다** — Server Action은 UI를 거치지 않고 직접 * POST될 수 있어 이 확인이 유일한 최종 방어선이다. * * 검증은 화면이 아니라 여기서 확정한다(`lib/domain/common-code-form.ts`의 규칙을 호출) — * 화면의 required 속성과 읽기 전용 표시는 편의일 뿐 신뢰 경계가 아니다. * * **수정 대상(코드ID)은 읽기 전용 입력이 아니라 hidden 필드에서 읽는다** — 읽기 전용 칸은 * 위조될 수 있고, 위조되더라도 대상이 바뀌면 안 되기 때문이다. */ const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.'; const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.'; const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.'; function readString(formData: FormData, key: string): string { const value = formData.get(key); return typeof value === 'string' ? value : ''; } /** * 폼 숫자 입력을 파싱한다. `Number('')`이 조용히 `0`이 되는 함정을 피하려고 빈 문자열은 * 명시적으로 `NaN`으로 둔다 — 그래야 검증이 "값이 비었음"을 잡아낸다. */ function parseFormNumber(formData: FormData, key: string): number { const trimmed = readString(formData, key).trim(); return trimmed === '' ? NaN : Number(trimmed); } /* ── 공통코드 ─────────────────────────────────────────────────────────────── */ /** 기획 [공통코드 등록] 팝업. */ export async function createCodeGroupAction( _prevState: CommonCodeFormState, formData: FormData ): Promise { await verifySession(); const validation = validateCommonCodeGroup({ comCd: readString(formData, 'comCd'), name: readString(formData, 'name'), description: readString(formData, 'description'), }); if (!validation.ok) { return { status: 'error', errors: validation.errors }; } try { await createCodeGroup(validation.values); } catch (error) { // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다. unstable_rethrow(error); return { status: 'error', message: SAVE_FAILED_MESSAGE }; } revalidatePath(COMMON_CODES_PATH); return { status: 'success' }; } /** 기획 [공통코드 수정] 팝업 — 코드ID는 대상을 가리키는 값이라 바뀌지 않는다. */ export async function updateCodeGroupAction( _prevState: CommonCodeFormState, formData: FormData ): Promise { await verifySession(); const targetComCd = readString(formData, 'targetComCd').trim(); if (!targetComCd) { return { status: 'error', message: INVALID_REQUEST_MESSAGE }; } const validation = validateCommonCodeGroup({ comCd: targetComCd, name: readString(formData, 'name'), description: readString(formData, 'description'), }); if (!validation.ok) { return { status: 'error', errors: validation.errors }; } try { await updateCodeGroup(targetComCd, validation.values); } catch (error) { unstable_rethrow(error); return { status: 'error', message: SAVE_FAILED_MESSAGE }; } revalidatePath(COMMON_CODES_PATH); return { status: 'success' }; } /** * 기획 ④ 삭제 — 확인 얼럿은 화면이 띄우고 여기서는 인증과 입력만 확인한다. * * 폼 제출이 아니라 얼럿의 [삭제] 클릭에 반응하는 단발 호출이라 `useActionState`의 * (prevState, formData) 규약 대신 식별자를 직접 받는다. */ export async function deleteCodeGroupAction( comCd: string ): Promise { await verifySession(); if (!comCd) { return { status: 'error', message: INVALID_REQUEST_MESSAGE }; } try { await deleteCodeGroup(comCd); } catch (error) { unstable_rethrow(error); return { status: 'error', message: DELETE_FAILED_MESSAGE }; } revalidatePath(COMMON_CODES_PATH); return { status: 'success' }; } /* ── 상세코드 ─────────────────────────────────────────────────────────────── */ /** 기획 [상세코드 등록] 팝업 — 코드ID는 좌측에서 고른 공통코드다. */ export async function createCodeDetailAction( _prevState: CommonCodeFormState, formData: FormData ): Promise { await verifySession(); const validation = validateCommonCodeDetail({ comCd: readString(formData, 'comCd'), comDtlCd: readString(formData, 'comDtlCd'), name: readString(formData, 'name'), description: readString(formData, 'description'), sortSeq: parseFormNumber(formData, 'sortSeq'), }); if (!validation.ok) { return { status: 'error', errors: validation.errors }; } try { await createCodeDetail(validation.values); } catch (error) { unstable_rethrow(error); return { status: 'error', message: SAVE_FAILED_MESSAGE }; } revalidatePath(COMMON_CODES_PATH); return { status: 'success' }; } /** 기획 [상세코드 수정] 팝업 — 코드ID·코드는 대상을 가리키는 값이라 바뀌지 않는다. */ export async function updateCodeDetailAction( _prevState: CommonCodeFormState, formData: FormData ): Promise { await verifySession(); const targetComCd = readString(formData, 'targetComCd').trim(); const targetComDtlCd = readString(formData, 'targetComDtlCd').trim(); if (!targetComCd || !targetComDtlCd) { return { status: 'error', message: INVALID_REQUEST_MESSAGE }; } const validation = validateCommonCodeDetail({ comCd: targetComCd, comDtlCd: readString(formData, 'comDtlCd'), name: readString(formData, 'name'), description: readString(formData, 'description'), sortSeq: parseFormNumber(formData, 'sortSeq'), }); if (!validation.ok) { return { status: 'error', errors: validation.errors }; } try { await updateCodeDetail(targetComCd, targetComDtlCd, validation.values); } catch (error) { unstable_rethrow(error); return { status: 'error', message: SAVE_FAILED_MESSAGE }; } revalidatePath(COMMON_CODES_PATH); return { status: 'success' }; } export async function deleteCodeDetailAction( comCd: string, comDtlCd: string ): Promise { await verifySession(); if (!comCd || !comDtlCd) { return { status: 'error', message: INVALID_REQUEST_MESSAGE }; } try { await deleteCodeDetail(comCd, comDtlCd); } catch (error) { unstable_rethrow(error); return { status: 'error', message: DELETE_FAILED_MESSAGE }; } revalidatePath(COMMON_CODES_PATH); return { status: 'success' }; }