File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
/**
* 관리자 등록/수정 입력 규칙 — 순수 검증 로직만 담는다(외부 의존 없음).
*
* 규칙은 시안(ADM_ADM_102_p / ADM_ADM_103_p)의 안내 문구를 그대로 옮긴 것이다:
* - ID : "영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인합니다."
* - 비밀번호: "영어 소문자, 숫자, 특수문자 중 2종류 이상 조합, 최소 10자리 이상"
*
* **이 파일이 검증의 단일 진실원천이다.** Server Action(`_actions.ts`)이 저장 직전에 여기를
* 거치고, 화면의 안내 문구도 여기 상수를 그대로 쓴다 — 규칙이 화면과 서버에서 갈라지는 것을 막는다.
* 화면 입력 단계의 즉시 피드백이 아니라 **제출 시 서버 검증**이 최종 방어선이다(Server Action은
* UI를 거치지 않고 직접 호출될 수 있다).
*
* **등록과 수정의 검증을 분리한 이유**: 수정 팝업에서 이름·ID는 읽기 전용이다(시안 103_p ①).
* 그 값에까지 등록용 형식 규칙을 다시 적용하면, 규칙이 생기기 전에 만들어졌거나 규칙 밖에서
* 발급된 기존 계정(예: 숫자가 없는 ID)이 **자기 정보를 저장할 수 없게 된다** — 바꾸지도 않는
* 필드 때문에. 그래서 수정은 실제로 바뀔 수 있는 항목만 검증한다.
*
* 백엔드에 등록/수정 API가 생기면 백엔드 규칙과 대조해 이 파일을 맞춰야 한다 — 지금은 시안이
* 유일한 근거다(백엔드에 관리자 등록 관련 코드 자체가 없다).
*/
import {
ADMIN_MENU_OPTIONS,
ADMIN_ROLE_OPTIONS,
type AdminRoleCode,
} from '@/lib/domain/admin-member';
export const ADMIN_LOGIN_ID_HELP_TEXT =
'영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인합니다.';
export const ADMIN_PASSWORD_HELP_TEXT =
'영어 소문자, 숫자, 특수문자 중 2종류 이상 조합, 최소 10자리 이상';
const LOGIN_ID_MIN_LENGTH = 4;
const LOGIN_ID_MAX_LENGTH = 20;
const PASSWORD_MIN_LENGTH = 10;
const NAME_MAX_LENGTH = 50;
const EMAIL_MAX_LENGTH = 100;
/** 휴대전화번호는 시안처럼 3칸으로 나뉘어 입력된다. 앞자리는 010 등 3자리, 가운데 3~4자리, 끝 4자리. */
const PHONE_PART_PATTERNS = [/^\d{3}$/, /^\d{3,4}$/, /^\d{4}$/] as const;
/** 등록·수정 양쪽에서 실제로 바뀔 수 있는 항목. */
export type AdminMemberEditableValues = {
password: string;
phoneNumber: string;
email: string;
roleCode: AdminRoleCode;
menuCodes: string[];
};
/** 등록은 위 항목에 더해 이름·ID를 입력받는다(수정에서는 읽기 전용). */
export type AdminMemberCreateValues = AdminMemberEditableValues & {
name: string;
loginId: string;
};
/** 필드별 오류 메시지 — 키는 폼 필드 이름과 일치시켜 화면이 그대로 붙여 쓸 수 있게 한다. */
export type AdminMemberFormErrors = Partial<
Record<keyof AdminMemberCreateValues, string>
>;
export type ValidationResult<T> =
| { ok: true; values: T }
| { ok: false; errors: AdminMemberFormErrors };
/**
* 등록/수정 Server Action의 `useActionState` 결과 상태. 원래는 `_actions.ts`(`'use server'`
* 파일)에 있었으나, Next.js는 **`'use server'` 파일이 async 함수 외의 값을 export하는 것을
* 런타임에 거부한다**("A 'use server' file can only export async functions, found object" —
* `INITIAL_ADMIN_MEMBER_FORM_STATE`처럼 일반 객체 상수를 함께 export하면 그 파일의 Server
* Action을 호출하는 즉시 500으로 깨진다. 빌드/타입체크는 통과하고 실제로 폼을 제출해야만
* 드러나는 런타임 전용 제약이라 여기로 옮겼다 — domain 계층은 `'use server'`가 없어 값 export에
* 제약이 없다(`decoration-item-form.ts`의 동일 패턴 참조).
*/
export type AdminMemberFormState =
| { status: 'idle' }
| { status: 'error'; message?: string; errors?: AdminMemberFormErrors }
| { status: 'success' };
export const INITIAL_ADMIN_MEMBER_FORM_STATE: AdminMemberFormState = {
status: 'idle',
};
/**
* 비밀번호 조합 종류 수 — 영문 소문자 / 숫자 / 특수문자 중 몇 종류가 섞였는지 센다.
* 시안이 "2종류 이상"만 요구하므로 대문자는 별도 종류로 세지 않는다(있어도 무방하다).
*/
function countCharacterKinds(password: string): number {
const kinds = [/[a-z]/, /\d/, /[^a-zA-Z0-9]/];
return kinds.filter((pattern) => pattern.test(password)).length;
}
/**
* ID 형식 검증 — 문제가 있으면 안내 문구, 없으면 null.
*
* 중복 확인(`checkAdminLoginId`)도 이 함수를 그대로 쓴다 — 중복을 묻기 전에 형식부터 봐야 하고,
* 그 판단 기준이 등록 시점과 달라지면 안 되기 때문이다.
*/
export function validateAdminLoginId(loginId: string): string | null {
const value = loginId.trim();
if (!value) {
return 'ID를 입력해 주세요.';
}
if (value.length < LOGIN_ID_MIN_LENGTH || value.length > LOGIN_ID_MAX_LENGTH) {
return `ID는 ${LOGIN_ID_MIN_LENGTH}~${LOGIN_ID_MAX_LENGTH}자로 입력해 주세요.`;
}
// "영어 소문자, 숫자를 조합" — 허용 문자를 두 종류로 제한하고, 둘 다 포함되어야 한다.
if (!/^[a-z0-9]+$/.test(value) || !/[a-z]/.test(value) || !/\d/.test(value)) {
return ADMIN_LOGIN_ID_HELP_TEXT;
}
return null;
}
/**
* 휴대전화번호 3칸을 하나의 문자열(`010-1234-5678`)로 합친다. 비어 있는 칸이 하나라도 있거나
* 형식이 맞지 않으면 null — 부분적으로 채워진 번호를 저장하지 않기 위해서다.
*/
export function joinPhoneNumber(parts: string[]): string | null {
if (parts.length !== PHONE_PART_PATTERNS.length) {
return null;
}
const trimmed = parts.map((part) => part.trim());
const isValid = trimmed.every((part, index) =>
PHONE_PART_PATTERNS[index].test(part)
);
return isValid ? trimmed.join('-') : null;
}
/** 저장된 번호를 다시 3칸으로 나눈다(수정 팝업의 초기값). 형식이 다르면 빈 칸들을 돌려준다. */
export function splitPhoneNumber(phoneNumber: string | null): string[] {
const parts = (phoneNumber ?? '').split('-');
return parts.length === PHONE_PART_PATTERNS.length ? parts : ['', '', ''];
}
function isValidEmail(email: string): boolean {
// 공백 없는 `로컬부@도메인.최상위` 정도만 본다 — 이메일의 완전한 문법 검증은 정규식으로
// 할 수 없고, 실제 유효성은 발송으로만 확인된다. 오탈자를 걸러내는 것이 목적이다.
return (
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length <= EMAIL_MAX_LENGTH
);
}
/**
* 등록·수정 공통 항목 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다.
*
* `isPasswordRequired`로 등록과 수정을 가른다 — 등록은 비밀번호가 필수지만(시안 102_p ①),
* 수정은 [비밀번호 변경]을 눌러 입력한 경우에만 검사한다(비우면 기존 비밀번호 유지).
*/
function validateEditableValues(
values: AdminMemberEditableValues,
errors: AdminMemberFormErrors,
options: { isPasswordRequired: boolean }
): AdminMemberEditableValues {
const password = values.password;
const phoneNumber = values.phoneNumber.trim();
const email = values.email.trim();
if (options.isPasswordRequired || password) {
if (
password.length < PASSWORD_MIN_LENGTH ||
countCharacterKinds(password) < 2
) {
errors.password = ADMIN_PASSWORD_HELP_TEXT;
}
}
if (!phoneNumber) {
errors.phoneNumber = '휴대전화 번호를 정확히 입력해 주세요.';
}
// 이메일은 선택 항목이라 비어 있는 것 자체는 오류가 아니다(시안에 필수 표시가 없다).
if (email && !isValidEmail(email)) {
errors.email = '이메일 형식이 올바르지 않습니다.';
}
if (!ADMIN_ROLE_OPTIONS.some((option) => option.value === values.roleCode)) {
errors.roleCode = '역할을 선택해 주세요.';
}
const menuCodes = values.menuCodes.filter((code) =>
ADMIN_MENU_OPTIONS.some((option) => option.value === code)
);
if (menuCodes.length === 0) {
errors.menuCodes = '메뉴를 1개 이상 선택해 주세요.';
}
return { password, phoneNumber, email, roleCode: values.roleCode, menuCodes };
}
/** 시안 ADM_ADM_102_p — 등록 검증(이름·ID 포함). */
export function validateAdminMemberCreate(
values: AdminMemberCreateValues
): ValidationResult<AdminMemberCreateValues> {
const errors: AdminMemberFormErrors = {};
const name = values.name.trim();
if (!name) {
errors.name = '이름을 입력해 주세요.';
} else if (name.length > NAME_MAX_LENGTH) {
errors.name = `이름은 ${NAME_MAX_LENGTH}자 이내로 입력해 주세요.`;
}
const loginIdError = validateAdminLoginId(values.loginId);
if (loginIdError) {
errors.loginId = loginIdError;
}
const editable = validateEditableValues(values, errors, {
isPasswordRequired: true,
});
if (Object.keys(errors).length > 0) {
return { ok: false, errors };
}
return {
ok: true,
values: { ...editable, name, loginId: values.loginId.trim() },
};
}
/** 시안 ADM_ADM_103_p — 수정 검증. 이름·ID는 읽기 전용이라 검증 대상이 아니다(파일 상단 주석). */
export function validateAdminMemberUpdate(
values: AdminMemberEditableValues
): ValidationResult<AdminMemberEditableValues> {
const errors: AdminMemberFormErrors = {};
const editable = validateEditableValues(values, errors, {
isPasswordRequired: false,
});
if (Object.keys(errors).length > 0) {
return { ok: false, errors };
}
return { ok: true, values: editable };
}