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 {
foxValidationMessage,
foxValidators,
type FoxValidationMessages,
type FoxValidator,
} from '@fox/core/validation';
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;
/**
* ID 규칙 — 검증기와 문구를 한 벌로 내보낸다. 등록 팝업의 입력 칸이 이것을 그대로 얹고,
* 아래 `validateAdminLoginId`(서버 검증·중복 확인)도 같은 배열을 돌린다. 규칙이 한 군데뿐이라
* 화면은 통과시키는데 저장은 거부하는 상태가 생기지 않는다.
*
* "영어 소문자와 숫자를 조합"은 두 조각으로 나뉜다 — 허용 문자를 `pattern`이 소문자·숫자로
* 묶고, 그 안에서 **두 종류가 다 있어야 한다**를 `characterKinds(2)`가 본다.
*/
export const ADMIN_LOGIN_ID_VALIDATORS: FoxValidator[] = [
foxValidators.required,
foxValidators.minLength(LOGIN_ID_MIN_LENGTH),
foxValidators.maxLength(LOGIN_ID_MAX_LENGTH),
foxValidators.pattern(/^[a-z0-9]+$/),
foxValidators.characterKinds(2),
];
/** 위 검증기의 오류를 시안 문구로 옮긴다. 길이 두 종류는 같은 말이라 한 문장으로 합친다. */
export const ADMIN_LOGIN_ID_MESSAGES: FoxValidationMessages = {
required: 'ID를 입력해 주세요.',
minlength: `ID는 ${LOGIN_ID_MIN_LENGTH}~${LOGIN_ID_MAX_LENGTH}자로 입력해 주세요.`,
maxlength: `ID는 ${LOGIN_ID_MIN_LENGTH}~${LOGIN_ID_MAX_LENGTH}자로 입력해 주세요.`,
pattern: ADMIN_LOGIN_ID_HELP_TEXT,
characterKinds: ADMIN_LOGIN_ID_HELP_TEXT,
};
/**
* 비밀번호 규칙 — **화면과 서버가 같은 값을 본다.** 팝업은 이 값을 `foxPasswordValidator`에
* 그대로 넘겨 입력 중에 안내하고, 서버 검증은 아래 `validateEditableValues`가 같은 값으로 판정한다.
* 한쪽만 고치면 화면은 통과시키고 저장은 거부하는 상태가 된다.
*/
export const ADMIN_PASSWORD_POLICY = {
minLength: 10,
kinds: 2,
} as const;
const NAME_MAX_LENGTH = 50;
const EMAIL_MAX_LENGTH = 100;
/**
* 이메일 규칙. **필수 여부만 두 시안이 다르다** — 등록(102_p)에는 `*`가 있고 수정(103_p)에는
* 없어서, 그 하나만 인자로 받고 나머지는 공유한다.
*/
export function adminEmailValidators(required: boolean): FoxValidator[] {
return [
...(required ? [foxValidators.required] : []),
foxValidators.email,
foxValidators.maxLength(EMAIL_MAX_LENGTH),
];
}
export const ADMIN_EMAIL_MESSAGES: FoxValidationMessages = {
required: '이메일을 입력해 주세요.',
email: '이메일 형식이 올바르지 않습니다.',
maxlength: `이메일은 ${EMAIL_MAX_LENGTH}자 이내로 입력해 주세요.`,
};
/** 앞 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 errors = foxValidators.compose(ADMIN_LOGIN_ID_VALIDATORS)(loginId.trim());
return foxValidationMessage(errors, ADMIN_LOGIN_ID_MESSAGES) ?? 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, '');
}
/**
* 등록·수정 공통 항목 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다.
*
* **비밀번호·이메일은 등록에서만 필수다.** 수정에서 비우면 "바꾸지 않음"이 된다 — 백엔드 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 = '휴대전화 번호를 정확히 입력해 주세요.';
}
const emailError = foxValidationMessage(
foxValidators.compose(adminEmailValidators(options.emailRequired))(email),
ADMIN_EMAIL_MESSAGES
);
if (emailError) {
errors.email = emailError;
}
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 };
}