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는 값을 그대로 받아
* 저장할 뿐 형식을 검사하지 않는다(`MngrAdminApiController`).
*/
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;
/**
* 비밀번호 규칙 — **화면과 서버가 같은 값을 본다.** 팝업은 이 값을 `foxPasswordValidator`에
* 그대로 넘겨 입력 중에 안내하고, 서버 검증은 아래 `validateEditableValues`가 같은 값으로 판정한다.
* 한쪽만 고치면 화면은 통과시키고 저장은 거부하는 상태가 된다.
*/
export const ADMIN_PASSWORD_POLICY = {
minLength: 10,
kinds: 2,
} as const;
const NAME_MAX_LENGTH = 50;
const EMAIL_MAX_LENGTH = 100;
/** 앞 3자리 + 가운데 3~4자리 + 끝 4자리. 화면은 숫자만 다루고 하이픈은 여기서 붙인다. */
const PHONE_DIGITS_PATTERN = /^(\d{3})(\d{3,4})(\d{4})$/;
/** 등록·수정 양쪽에서 실제로 바뀔 수 있는 항목. */
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;
}
/**
* 화면이 넘긴 숫자열(`01012345678`)을 저장 형식(`010-1234-5678`)으로 바꾼다. 자릿수가 맞지 않으면
* null — 부분적으로 채워진 번호를 저장하지 않기 위해서다.
*
* 화면(`FoxPhoneNumber`)은 하이픈을 그리기만 하고 값으로는 숫자만 내보낸다. 반대로 목록·엑셀은
* 하이픈이 있는 형태로 보여주므로, 두 표현 사이의 변환을 이 한 쌍이 책임진다.
*/
export function formatPhoneNumber(digits: string): string | null {
const matched = PHONE_DIGITS_PATTERN.exec(digits.trim());
return matched === null ? null : `${matched[1]}-${matched[2]}-${matched[3]}`;
}
/** 저장된 번호에서 숫자만 남긴다(수정 팝업의 초기값). */
export function toPhoneDigits(phoneNumber: string | null): string {
return (phoneNumber ?? '').replace(/\D/g, '');
}
function isValidEmail(email: string): boolean {
// 공백 없는 `로컬부@도메인.최상위` 정도만 본다 — 이메일의 완전한 문법 검증은 정규식으로
// 할 수 없고, 실제 유효성은 발송으로만 확인된다. 오탈자를 걸러내는 것이 목적이다.
return (
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length <= EMAIL_MAX_LENGTH
);
}
/**
* 등록·수정 공통 항목 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다.
*
* **비밀번호·이메일은 등록에서만 필수다.** 수정에서 비우면 "바꾸지 않음"이 된다 — 백엔드 UPDATE의
* `LOGIN_PW`가 `<if test='loginPw != null and loginPw != ""'>`로 감싸여 있어 빈 값은 SET 절에서
* 빠진다(예전에는 조건 없이 덮어써서, 빈 값을 보내면 그 계정이 로그인 불가가 됐다). 값이 있으면
* 등록·수정 모두 같은 형식 규칙을 통과해야 한다.
*/
function validateEditableValues(
values: AdminMemberEditableValues,
errors: AdminMemberFormErrors,
options: { passwordRequired: boolean; emailRequired: boolean }
): AdminMemberEditableValues {
const password = values.password;
const phoneNumber = values.phoneNumber.trim();
const email = values.email.trim();
if (!password) {
if (options.passwordRequired) {
errors.password = ADMIN_PASSWORD_HELP_TEXT;
}
} else if (
password.length < ADMIN_PASSWORD_POLICY.minLength ||
countCharacterKinds(password) < ADMIN_PASSWORD_POLICY.kinds
) {
errors.password = ADMIN_PASSWORD_HELP_TEXT;
}
if (!phoneNumber) {
errors.phoneNumber = '휴대전화 번호를 정확히 입력해 주세요.';
}
// 이메일 필수 여부는 두 시안이 다르다 — 등록(102_p)에는 `*`가 있고 수정(103_p)에는 없다.
if (!email) {
if (options.emailRequired) {
errors.email = '이메일을 입력해 주세요.';
}
} else if (!isValidEmail(email)) {
errors.email = '이메일 형식이 올바르지 않습니다.';
}
if (!ADMIN_ROLE_OPTIONS.some((option) => option.value === values.roleCode)) {
errors.roleCode = '역할을 선택해 주세요.';
}
// 메뉴 선택은 **필수가 아니다**(사용자 확정) — 백엔드에 관리자별 메뉴 권한이 없어 고른 값이
// 저장되지 않는데, 저장되지도 않는 값 때문에 등록이 막히면 안 된다. 허용 목록 밖의 코드만
// 걸러 둔다(권한 API가 생기면 여기에 필수 규칙을 되살린다).
const menuCodes = values.menuCodes.filter((code) =>
ADMIN_MENU_OPTIONS.some((option) => option.value === code)
);
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, {
passwordRequired: true,
emailRequired: 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, {
passwordRequired: false,
emailRequired: false,
});
if (Object.keys(errors).length > 0) {
return { ok: false, errors };
}
return { ok: true, values: editable };
}