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 { BackendRequestError } from '@/lib/http/backend-fetch';
import {
createAdminMember,
deleteAdminMember,
isAdminLoginIdTaken,
updateAdminMember,
} from '@/lib/data/repositories/admin-member-repository';
import { ADMIN_MENU_OPTIONS } from '@/lib/domain/admin-member';
import {
formatPhoneNumber,
validateAdminLoginId,
validateAdminMemberCreate,
validateAdminMemberUpdate,
type AdminMemberEditableValues,
type AdminMemberFormState,
} 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에 맡긴다 — 어느 항목이 백엔드로 가고 어느 것이 mock인지는 이 파일이
* 알지 못한다(현재 삭제만 mock이다).
*
* `AdminMemberFormState` 타입과 그 초깃값(`INITIAL_ADMIN_MEMBER_FORM_STATE`)은 이 파일이 아니라
* `lib/domain/admin-member-form.ts`에 있다 — Next.js가 `'use server'` 파일에서 함수가 아닌 값을
* export하는 것을 런타임에 거부하기 때문이다(해당 타입 주석 참조). 타입만 이 파일에서 다시 쓰는
* 것은 문제 없다 — 타입은 컴파일 시 지워져 런타임 export로 남지 않는다.
*/
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)
);
}
/**
* 등록·수정이 공유하는 입력 항목을 읽는다. 휴대전화번호는 숫자만 담겨 오므로 여기서 저장 형식으로
* 바꾼다 — 자릿수가 어긋나면 빈 문자열이 되어 검증에서 걸린다.
*/
function readEditableValues(formData: FormData): AdminMemberEditableValues {
const phoneNumber = formatPhoneNumber(readString(formData, 'phoneNumber'));
return {
password: readString(formData, 'password'),
phoneNumber: phoneNumber ?? '',
email: readString(formData, 'email'),
roleCode: readString(formData, 'roleCode'),
menuCodes: readMenuCodes(formData),
};
}
/**
* 쓰기 호출의 백엔드 실패를 폼 상태로 바꾼다. 실패하면 그 상태를, 성공하면 null을 돌려준다.
*
* 백엔드가 주는 문구는 그대로 보여준다 — 등록 시 아이디 선점처럼 사용자가 조치할 수 있는 사유가
* 이 경로로 온다("이미 등록된 아이디 입니다"). 통신 오류·타임아웃은 `backend-fetch`가 이미
* 일반화된 문구로 바꿔 두므로 내부 사정이 새어 나가지 않는다.
*/
async function runWrite(
write: () => Promise<void>
): Promise<AdminMemberFormState | null> {
try {
await write();
return null;
} catch (error) {
if (error instanceof BackendRequestError) {
return { status: 'error', message: error.message };
}
throw error;
}
}
/** 시안 ADM_ADM_102_p — 관리자 등록. */
export async function createAdminMemberAction(
_prevState: AdminMemberFormState,
formData: FormData
): Promise<AdminMemberFormState> {
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, password, phoneNumber, email, roleCode } =
validation.values;
// 화면의 [중복 확인]은 편의 기능일 뿐이라 저장 직전에 다시 확인한다 — 확인을 누르지 않고
// 직접 제출하는 경로가 열려 있고, 확인 후 저장까지의 사이에 선점될 수도 있다.
if (await isAdminLoginIdTaken(loginId)) {
return { status: 'error', errors: { loginId: DUPLICATE_LOGIN_ID_MESSAGE } };
}
// 메뉴 선택은 넘기지 않는다 — 백엔드에 저장할 곳이 없다(Repository 주석 참조).
const failure = await runWrite(() =>
createAdminMember({ name, loginId, password, phoneNumber, email, roleCode })
);
if (failure) {
return failure;
}
revalidatePath(ADMIN_MEMBERS_PATH);
return { status: 'success' };
}
/** 시안 ADM_ADM_103_p — 관리자 수정. 이름·ID는 읽기 전용이라 변경 대상이 아니다. */
export async function updateAdminMemberAction(
_prevState: AdminMemberFormState,
formData: FormData
): Promise<AdminMemberFormState> {
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 { password, phoneNumber, email, roleCode } = validation.values;
const failure = await runWrite(() =>
updateAdminMember(id, {
password,
phoneNumber,
email,
roleCode,
// 시안 ③의 "잠김여부"는 활성/비활성으로 표기되고 활성이 곧 잠기지 않은 상태다.
isLocked: readString(formData, 'isLocked') === 'true',
})
);
if (failure) {
return failure;
}
revalidatePath(ADMIN_MEMBERS_PATH);
return { status: 'success' };
}
/**
* 시안 ADM_ADM_101 ⑤ — 삭제. 확인 얼럿은 화면이 띄우고, 여기서는 권한과 자기 계정만 확인한다.
*
* 폼 제출이 아니라 얼럿의 [삭제] 클릭에 반응하는 단발 호출이라 `useActionState`의
* (prevState, formData) 규약 대신 id를 직접 받는다.
*/
export async function deleteAdminMemberAction(
id: string
): Promise<AdminMemberFormState> {
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<LoginIdCheckResult> {
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 };
}