/** * 관리자 등록/수정 입력 규칙 — 순수 검증 로직만 담는다(외부 의존 없음). * * 규칙은 시안(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 >; export type ValidationResult = | { 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`가 ``로 감싸여 있어 빈 값은 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 { 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 { 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 }; }