Files
vexplor/frontend/lib/utils/formValidation.ts
kjs 28ef7e1226 fix: Enhance error handling and validation messages in form data operations
- Integrated `formatPgError` utility to provide user-friendly error messages based on PostgreSQL error codes during form data operations.
- Updated error responses in `saveFormData`, `saveFormDataEnhanced`, `updateFormData`, and `updateFormDataPartial` to include specific messages based on the company context.
- Improved error handling in the frontend components to display relevant error messages from the server response, ensuring users receive clear feedback on save operations.
- Enhanced the required field validation by incorporating NOT NULL metadata checks across various components, improving the accuracy of form submissions.

These changes improve the overall user experience by providing clearer error messages and ensuring that required fields are properly validated based on both manual settings and database constraints.
2026-03-10 14:47:05 +09:00

668 lines
18 KiB
TypeScript

/**
* 화면관리 폼 데이터 검증 유틸리티
* 클라이언트 측에서 사전 검증을 수행하여 사용자 경험 향상
*/
import { WebType, DynamicWebType, isValidWebType, normalizeWebType } from "@/types/v2-web-types";
import { ColumnInfo, ComponentData, WidgetComponent } from "@/types/screen";
import { isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer";
// 검증 결과 타입
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. 필수 필드 검증 (NOT NULL 메타데이터 포함)
const requiredValidation = validateRequiredFields(formData, components, tableName);
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[],
tableName?: string,
): ValidationError[] => {
const errors: ValidationError[] = [];
const widgetComponents = components.filter((c) => c.type === "widget") as WidgetComponent[];
for (const component of widgetComponents) {
const fieldName = component.columnName || component.id;
// 수동 required + NOT NULL 메타데이터 기반 통합 체크
const isRequired = component.required || isColumnRequiredByMeta(tableName, fieldName);
if (!isRequired) continue;
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;
};