File name
Commit message
Commit date
08-14
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 {
joinPhoneNumber,
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)
);
}
/**
* 등록·수정이 공유하는 입력 항목을 읽는다. 휴대전화번호는 시안대로 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),
};
}
/**
* 쓰기 호출의 백엔드 실패를 폼 상태로 바꾼다. 실패하면 그 상태를, 성공하면 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 };
}