'use server'; import { revalidatePath } from 'next/cache'; import { verifySession } from '@/lib/auth/dal'; import { createAdminMember, deleteAdminMember, isAdminLoginIdTaken, updateAdminMember, } from '@/lib/data/repositories/admin-member-repository'; import { ADMIN_MENU_OPTIONS } from '@/lib/domain/admin-member'; import { joinPhoneNumber, validateAdminLoginId, validateAdminMemberCreate, validateAdminMemberUpdate, type AdminMemberEditableValues, type AdminMemberFormErrors, } from '@/lib/domain/admin-member-form'; import { ADMIN_MEMBERS_PATH } from '@/lib/domain/admin-member-query'; /** * 관리자 회원 등록/수정/삭제 Server Action. * * **모든 Action이 `verifySession()`으로 시작한다** — Server Action은 UI를 거치지 않고 직접 * POST될 수 있어 이 확인이 유일한 최종 방어선이다(설계서 §8). * * 검증은 화면이 아니라 여기서 확정한다(`lib/domain/admin-member-form.ts`의 규칙을 호출) — * 화면의 required 속성은 편의일 뿐 신뢰 경계가 아니다. * * 실제 저장은 Repository에 맡긴다. 백엔드에 등록/수정/삭제 API가 아직 없어 Repository가 mock * 저장소로 위임하고 있지만, **이 파일은 그 사실을 알지 못한다** — 백엔드 API가 생겨도 이 파일은 * 바뀌지 않는다. */ export type AdminMemberFormState = | { status: 'idle' } | { status: 'error'; message?: string; errors?: AdminMemberFormErrors } | { status: 'success' }; export const INITIAL_ADMIN_MEMBER_FORM_STATE: AdminMemberFormState = { status: 'idle', }; const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.'; const SELF_DELETE_MESSAGE = '현재 로그인한 본인 계정은 삭제할 수 없습니다.'; const DUPLICATE_LOGIN_ID_MESSAGE = '이미 사용 중인 ID입니다.'; function readString(formData: FormData, key: string): string { const value = formData.get(key); return typeof value === 'string' ? value : ''; } /** 체크박스처럼 같은 이름으로 여러 번 오는 값 — 허용 목록에 있는 것만 남긴다. */ function readMenuCodes(formData: FormData): string[] { return formData .getAll('menuCodes') .filter((value): value is string => typeof value === 'string') .filter((value) => ADMIN_MENU_OPTIONS.some((option) => option.value === value) ); } /** * 등록·수정이 공유하는 입력 항목을 읽는다. 휴대전화번호는 시안대로 3칸으로 나뉘어 오므로 여기서 * 하나의 값으로 합친다 — 형식이 어긋나면 빈 문자열이 되어 검증에서 걸린다. */ function readEditableValues(formData: FormData): AdminMemberEditableValues { const phoneNumber = joinPhoneNumber([ readString(formData, 'phoneNumber1'), readString(formData, 'phoneNumber2'), readString(formData, 'phoneNumber3'), ]); return { password: readString(formData, 'password'), phoneNumber: phoneNumber ?? '', email: readString(formData, 'email'), roleCode: readString(formData, 'roleCode'), menuCodes: readMenuCodes(formData), }; } /** 시안 ADM_ADM_102_p — 관리자 등록. */ export async function createAdminMemberAction( _prevState: AdminMemberFormState, formData: FormData ): Promise { await verifySession(); const validation = validateAdminMemberCreate({ ...readEditableValues(formData), name: readString(formData, 'name'), loginId: readString(formData, 'loginId'), }); if (!validation.ok) { return { status: 'error', errors: validation.errors }; } const { name, loginId, phoneNumber, email, roleCode, menuCodes } = validation.values; // 화면의 [중복 확인]은 편의 기능일 뿐이라 저장 직전에 다시 확인한다 — 확인을 누르지 않고 // 직접 제출하는 경로가 열려 있고, 확인 후 저장까지의 사이에 선점될 수도 있다. if (await isAdminLoginIdTaken(loginId)) { return { status: 'error', errors: { loginId: DUPLICATE_LOGIN_ID_MESSAGE } }; } // 비밀번호는 형식만 검증하고 값은 넘기지 않는다 — 저장할 백엔드 API가 아직 없고, mock // 저장소에 평문 비밀번호를 보관하지 않기 때문이다(`admin-member-store.ts` 주석 참조). await createAdminMember({ name, loginId, phoneNumber, email, roleCode, menuCodes, }); revalidatePath(ADMIN_MEMBERS_PATH); return { status: 'success' }; } /** 시안 ADM_ADM_103_p — 관리자 수정. 이름·ID는 읽기 전용이라 변경 대상이 아니다. */ export async function updateAdminMemberAction( _prevState: AdminMemberFormState, formData: FormData ): Promise { await verifySession(); const id = readString(formData, 'id'); if (!id) { return { status: 'error', message: INVALID_REQUEST_MESSAGE }; } const validation = validateAdminMemberUpdate(readEditableValues(formData)); if (!validation.ok) { return { status: 'error', errors: validation.errors }; } const { phoneNumber, email, roleCode, menuCodes } = validation.values; await updateAdminMember(id, { phoneNumber, email, roleCode, menuCodes, // 시안 ③의 "잠김여부"는 활성/비활성으로 표기되고 활성이 곧 잠기지 않은 상태다. isLocked: readString(formData, 'isLocked') === 'true', }); revalidatePath(ADMIN_MEMBERS_PATH); return { status: 'success' }; } /** * 시안 ADM_ADM_101 ⑤ — 삭제. 확인 얼럿은 화면이 띄우고, 여기서는 권한과 자기 계정만 확인한다. * * 폼 제출이 아니라 얼럿의 [삭제] 클릭에 반응하는 단발 호출이라 `useActionState`의 * (prevState, formData) 규약 대신 id를 직접 받는다. */ export async function deleteAdminMemberAction( id: string ): Promise { const admin = await verifySession(); if (!id) { return { status: 'error', message: INVALID_REQUEST_MESSAGE }; } // 시안의 "최고관리자 본인 계정 삭제 방지". 세션의 adminId는 백엔드 accessToken의 `adminId` // 클레임이고 그 값이 곧 목록의 `admUserId`라(EgovJwtTokenUtil.generateAccessToken) 그대로 // 비교할 수 있다. 역할과 무관하게 "로그인한 본인"을 막는다 — 자기 계정을 지워 스스로 // 로그인 불가 상태가 되는 것은 어떤 역할이든 사고이기 때문이다. if (id === admin.id) { return { status: 'error', message: SELF_DELETE_MESSAGE }; } await deleteAdminMember(id); revalidatePath(ADMIN_MEMBERS_PATH); return { status: 'success' }; } export type LoginIdCheckResult = | { status: 'idle' } | { status: 'available'; loginId: string } | { status: 'unavailable'; message: string }; /** * 시안 ADM_ADM_102_p ① — ID 중복 확인. * * 폼 제출이 아니라 버튼 클릭에 반응하는 단발 호출이라 `useActionState`의 (prevState, formData) * 규약 대신 값을 직접 받는다 — 등록 폼 안에 또 다른 폼을 중첩할 수 없기 때문이다(HTML 제약). * 클라이언트에서 일반 함수처럼 `await` 한다. * * 형식이 맞지 않는 ID는 중복 여부를 물을 필요도 없이 되돌린다. 확인에 성공하면 **검사한 ID를 함께** * 돌려주는데, 화면이 "확인한 ID"와 "지금 입력창의 ID"를 비교해 확인 후 값을 고친 경우를 잡아내기 * 위해서다. */ export async function checkAdminLoginId( loginId: string ): Promise { await verifySession(); const normalized = loginId.trim(); const formatError = validateAdminLoginId(normalized); if (formatError) { return { status: 'unavailable', message: formatError }; } if (await isAdminLoginIdTaken(normalized)) { return { status: 'unavailable', message: DUPLICATE_LOGIN_ID_MESSAGE }; } return { status: 'available', loginId: normalized }; }