ERP-node/frontend/lib/utils/formValidation.ts

664 lines
17 KiB
TypeScript

/**
* 화면관리 폼 데이터 검증 유틸리티
* 클라이언트 측에서 사전 검증을 수행하여 사용자 경험 향상
*/
import { WebType, DynamicWebType, isValidWebType, normalizeWebType } from "@/types/unified-web-types";
import { ColumnInfo, ComponentData, WidgetComponent } from "@/types/screen";
// 검증 결과 타입
export interface ValidationResult {
isValid: boolean;
errors: ValidationError[];
warnings: ValidationWarning[];
}
export interface ValidationError {
field: string;
code: string;
message: string;
severity: "error" | "warning";
value?: any;
}
export interface ValidationWarning {
field: string;
code: string;
message: string;
suggestion?: string;
}
// 필드 검증 결과
export interface FieldValidationResult {
isValid: boolean;
error?: ValidationError;
transformedValue?: any;
}
// 스키마 검증 결과
export interface SchemaValidationResult {
isValid: boolean;
missingColumns: string[];
invalidTypes: { field: string; expected: WebType; actual: string }[];
suggestions: string[];
}
/**
* 폼 데이터 전체 검증
*/
export const validateFormData = async (
formData: Record<string, any>,
components: ComponentData[],
tableColumns: ColumnInfo[],
tableName: string,
): Promise<ValidationResult> => {
const errors: ValidationError[] = [];
const warnings: ValidationWarning[] = [];
try {
// 1. 스키마 검증 (컬럼 존재 여부, 타입 일치)
const schemaValidation = validateFormSchema(formData, components, tableColumns);
if (!schemaValidation.isValid) {
errors.push(
...schemaValidation.missingColumns.map((col) => ({
field: col,
code: "COLUMN_NOT_EXISTS",
message: `테이블 '${tableName}'에 '${col}' 컬럼이 존재하지 않습니다.`,
severity: "error" as const,
})),
);
errors.push(
...schemaValidation.invalidTypes.map((type) => ({
field: type.field,
code: "INVALID_WEB_TYPE",
message: `필드 '${type.field}'의 웹타입이 올바르지 않습니다. 예상: ${type.expected}, 실제: ${type.actual}`,
severity: "error" as const,
})),
);
}
// 2. 필수 필드 검증
const requiredValidation = validateRequiredFields(formData, components);
errors.push(...requiredValidation);
// 3. 데이터 타입 검증 및 변환
const widgetComponents = components.filter((c) => c.type === "widget") as WidgetComponent[];
for (const component of widgetComponents) {
const fieldName = component.columnName || component.id;
const value = formData[fieldName];
if (value !== undefined && value !== null && value !== "") {
const fieldValidation = validateFieldValue(
fieldName,
value,
component.widgetType,
component.webTypeConfig,
component.validationRules,
);
if (!fieldValidation.isValid && fieldValidation.error) {
errors.push(fieldValidation.error);
}
}
}
// 4. 비즈니스 로직 검증 (커스텀 규칙)
const businessValidation = await validateBusinessRules(formData, tableName, components);
errors.push(...businessValidation.errors);
warnings.push(...businessValidation.warnings);
} catch (error) {
errors.push({
field: "form",
code: "VALIDATION_ERROR",
message: `검증 중 오류가 발생했습니다: ${error}`,
severity: "error",
});
}
return {
isValid: errors.filter((e) => e.severity === "error").length === 0,
errors,
warnings,
};
};
/**
* 스키마 검증 (컬럼 존재 여부, 타입 일치)
*/
export const validateFormSchema = (
formData: Record<string, any>,
components: ComponentData[],
tableColumns: ColumnInfo[],
): SchemaValidationResult => {
const missingColumns: string[] = [];
const invalidTypes: { field: string; expected: WebType; actual: string }[] = [];
const suggestions: string[] = [];
const columnMap = new Map(tableColumns.map((col) => [col.columnName, col]));
const widgetComponents = components.filter((c) => c.type === "widget") as WidgetComponent[];
for (const component of widgetComponents) {
const fieldName = component.columnName;
if (!fieldName) continue;
// 컬럼 존재 여부 확인
const columnInfo = columnMap.get(fieldName);
if (!columnInfo) {
missingColumns.push(fieldName);
// 유사한 컬럼명 제안
const similar = findSimilarColumns(fieldName, tableColumns);
if (similar.length > 0) {
suggestions.push(`'${fieldName}' 대신 '${similar.join("', '")}'을 사용하시겠습니까?`);
}
continue;
}
// 웹타입 일치 여부 확인
const componentWebType = normalizeWebType(component.widgetType);
const columnWebType = columnInfo.webType ? normalizeWebType(columnInfo.webType) : null;
if (columnWebType && componentWebType !== columnWebType) {
invalidTypes.push({
field: fieldName,
expected: columnWebType,
actual: componentWebType,
});
}
// 웹타입 유효성 확인
if (!isValidWebType(component.widgetType)) {
invalidTypes.push({
field: fieldName,
expected: "text", // 기본값
actual: component.widgetType,
});
}
}
return {
isValid: missingColumns.length === 0 && invalidTypes.length === 0,
missingColumns,
invalidTypes,
suggestions,
};
};
/**
* 필수 필드 검증
*/
export const validateRequiredFields = (
formData: Record<string, any>,
components: ComponentData[],
): ValidationError[] => {
const errors: ValidationError[] = [];
const widgetComponents = components.filter((c) => c.type === "widget") as WidgetComponent[];
for (const component of widgetComponents) {
if (!component.required) continue;
const fieldName = component.columnName || component.id;
const value = formData[fieldName];
if (
value === undefined ||
value === null ||
(typeof value === "string" && value.trim() === "") ||
(Array.isArray(value) && value.length === 0)
) {
errors.push({
field: fieldName,
code: "REQUIRED_FIELD",
message: `'${component.label || fieldName}'은(는) 필수 입력 항목입니다.`,
severity: "error",
value,
});
}
}
return errors;
};
/**
* 개별 필드 값 검증
*/
export const validateFieldValue = (
fieldName: string,
value: any,
webType: DynamicWebType,
config?: Record<string, any>,
rules?: any[],
): FieldValidationResult => {
try {
const normalizedWebType = normalizeWebType(webType);
// 타입별 검증
switch (normalizedWebType) {
case "number":
return validateNumberField(fieldName, value, config);
case "decimal":
return validateDecimalField(fieldName, value, config);
case "date":
return validateDateField(fieldName, value, config);
case "datetime":
return validateDateTimeField(fieldName, value, config);
case "email":
return validateEmailField(fieldName, value, config);
case "tel":
return validateTelField(fieldName, value, config);
case "url":
return validateUrlField(fieldName, value, config);
case "text":
case "textarea":
return validateTextField(fieldName, value, config);
case "boolean":
case "checkbox":
return validateBooleanField(fieldName, value, config);
default:
// 기본 문자열 검증
return validateTextField(fieldName, value, config);
}
} catch (error) {
return {
isValid: false,
error: {
field: fieldName,
code: "VALIDATION_ERROR",
message: `필드 '${fieldName}' 검증 중 오류: ${error}`,
severity: "error",
value,
},
};
}
};
/**
* 숫자 필드 검증
*/
const validateNumberField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
const numValue = Number(value);
if (isNaN(numValue)) {
return {
isValid: false,
error: {
field: fieldName,
code: "INVALID_NUMBER",
message: `'${fieldName}'에는 숫자만 입력할 수 있습니다.`,
severity: "error",
value,
},
};
}
if (!Number.isInteger(numValue)) {
return {
isValid: false,
error: {
field: fieldName,
code: "NOT_INTEGER",
message: `'${fieldName}'에는 정수만 입력할 수 있습니다.`,
severity: "error",
value,
},
};
}
// 범위 검증
if (config?.min !== undefined && numValue < config.min) {
return {
isValid: false,
error: {
field: fieldName,
code: "VALUE_TOO_SMALL",
message: `'${fieldName}'의 값은 ${config.min} 이상이어야 합니다.`,
severity: "error",
value,
},
};
}
if (config?.max !== undefined && numValue > config.max) {
return {
isValid: false,
error: {
field: fieldName,
code: "VALUE_TOO_LARGE",
message: `'${fieldName}'의 값은 ${config.max} 이하여야 합니다.`,
severity: "error",
value,
},
};
}
return { isValid: true, transformedValue: numValue };
};
/**
* 소수 필드 검증
*/
const validateDecimalField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
const numValue = Number(value);
if (isNaN(numValue)) {
return {
isValid: false,
error: {
field: fieldName,
code: "INVALID_DECIMAL",
message: `'${fieldName}'에는 숫자만 입력할 수 있습니다.`,
severity: "error",
value,
},
};
}
// 소수점 자릿수 검증
if (config?.decimalPlaces !== undefined) {
const decimalPart = value.toString().split(".")[1];
if (decimalPart && decimalPart.length > config.decimalPlaces) {
return {
isValid: false,
error: {
field: fieldName,
code: "TOO_MANY_DECIMAL_PLACES",
message: `'${fieldName}'의 소수점은 ${config.decimalPlaces}자리까지만 입력할 수 있습니다.`,
severity: "error",
value,
},
};
}
}
return { isValid: true, transformedValue: numValue };
};
/**
* 날짜 필드 검증
*/
const validateDateField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
const dateValue = new Date(value);
if (isNaN(dateValue.getTime())) {
return {
isValid: false,
error: {
field: fieldName,
code: "INVALID_DATE",
message: `'${fieldName}'에는 올바른 날짜를 입력해주세요.`,
severity: "error",
value,
},
};
}
// 날짜 범위 검증
if (config?.minDate) {
const minDate = new Date(config.minDate);
if (dateValue < minDate) {
return {
isValid: false,
error: {
field: fieldName,
code: "DATE_TOO_EARLY",
message: `'${fieldName}'의 날짜는 ${config.minDate} 이후여야 합니다.`,
severity: "error",
value,
},
};
}
}
if (config?.maxDate) {
const maxDate = new Date(config.maxDate);
if (dateValue > maxDate) {
return {
isValid: false,
error: {
field: fieldName,
code: "DATE_TOO_LATE",
message: `'${fieldName}'의 날짜는 ${config.maxDate} 이전이어야 합니다.`,
severity: "error",
value,
},
};
}
}
return { isValid: true, transformedValue: dateValue.toISOString().split("T")[0] };
};
/**
* 날짜시간 필드 검증
*/
const validateDateTimeField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
const dateValue = new Date(value);
if (isNaN(dateValue.getTime())) {
return {
isValid: false,
error: {
field: fieldName,
code: "INVALID_DATETIME",
message: `'${fieldName}'에는 올바른 날짜시간을 입력해주세요.`,
severity: "error",
value,
},
};
}
return { isValid: true, transformedValue: dateValue.toISOString() };
};
/**
* 이메일 필드 검증
*/
const validateEmailField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
return {
isValid: false,
error: {
field: fieldName,
code: "INVALID_EMAIL",
message: `'${fieldName}'에는 올바른 이메일 주소를 입력해주세요.`,
severity: "error",
value,
},
};
}
return { isValid: true, transformedValue: value };
};
/**
* 전화번호 필드 검증
*/
const validateTelField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
// 기본 전화번호 형식 검증 (한국)
const telRegex = /^(\d{2,3}-?\d{3,4}-?\d{4}|\d{10,11})$/;
if (!telRegex.test(value.replace(/\s/g, ""))) {
return {
isValid: false,
error: {
field: fieldName,
code: "INVALID_TEL",
message: `'${fieldName}'에는 올바른 전화번호를 입력해주세요.`,
severity: "error",
value,
},
};
}
return { isValid: true, transformedValue: value };
};
/**
* URL 필드 검증
*/
const validateUrlField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
try {
new URL(value);
return { isValid: true, transformedValue: value };
} catch {
return {
isValid: false,
error: {
field: fieldName,
code: "INVALID_URL",
message: `'${fieldName}'에는 올바른 URL을 입력해주세요.`,
severity: "error",
value,
},
};
}
};
/**
* 텍스트 필드 검증
*/
const validateTextField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
const strValue = String(value);
// 길이 검증
if (config?.minLength && strValue.length < config.minLength) {
return {
isValid: false,
error: {
field: fieldName,
code: "TOO_SHORT",
message: `'${fieldName}'은(는) 최소 ${config.minLength}자 이상이어야 합니다.`,
severity: "error",
value,
},
};
}
if (config?.maxLength && strValue.length > config.maxLength) {
return {
isValid: false,
error: {
field: fieldName,
code: "TOO_LONG",
message: `'${fieldName}'은(는) 최대 ${config.maxLength}자까지만 입력할 수 있습니다.`,
severity: "error",
value,
},
};
}
// 패턴 검증
if (config?.pattern) {
const regex = new RegExp(config.pattern);
if (!regex.test(strValue)) {
return {
isValid: false,
error: {
field: fieldName,
code: "PATTERN_MISMATCH",
message: `'${fieldName}'의 형식이 올바르지 않습니다.`,
severity: "error",
value,
},
};
}
}
return { isValid: true, transformedValue: strValue };
};
/**
* 불린 필드 검증
*/
const validateBooleanField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
let boolValue: boolean;
if (typeof value === "boolean") {
boolValue = value;
} else if (typeof value === "string") {
boolValue = value.toLowerCase() === "true" || value === "1";
} else if (typeof value === "number") {
boolValue = value === 1;
} else {
boolValue = Boolean(value);
}
return { isValid: true, transformedValue: boolValue };
};
/**
* 비즈니스 로직 검증 (커스텀)
*/
const validateBusinessRules = async (
formData: Record<string, any>,
tableName: string,
components: ComponentData[],
): Promise<{ errors: ValidationError[]; warnings: ValidationWarning[] }> => {
const errors: ValidationError[] = [];
const warnings: ValidationWarning[] = [];
// 여기에 테이블별 비즈니스 로직 검증 추가
// 예: 중복 체크, 외래키 제약조건, 커스텀 규칙 등
return { errors, warnings };
};
/**
* 유사한 컬럼명 찾기 (오타 제안용)
*/
const findSimilarColumns = (targetColumn: string, columns: ColumnInfo[], threshold: number = 0.6): string[] => {
const similar: string[] = [];
for (const column of columns) {
const similarity = calculateStringSimilarity(targetColumn, column.columnName);
if (similarity >= threshold) {
similar.push(column.columnName);
}
}
return similar.slice(0, 3); // 최대 3개까지
};
/**
* 문자열 유사도 계산 (Levenshtein distance 기반)
*/
const calculateStringSimilarity = (str1: string, str2: string): number => {
const len1 = str1.length;
const len2 = str2.length;
const matrix: number[][] = [];
for (let i = 0; i <= len1; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= len2; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= len1; i++) {
for (let j = 1; j <= len2; j++) {
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
}
}
const distance = matrix[len1][len2];
const maxLen = Math.max(len1, len2);
return maxLen === 0 ? 1 : (maxLen - distance) / maxLen;
};