- Unify field types across all layers: Text, Textarea, SelectOne, SelectMultiple, Date, DateTime, Number - Add CustomFieldValidationConfig with type-specific Zod schemas - Add validateCustomFieldValue() dispatching per-type validation - Add validation_config (JSONB) and default_value columns to custom_fields - Replace ad-hoc date/datetime/pattern checks with centralized validator - Type-aware cf.* query operators: gt:, gte:, lt:, lte:, before:, after:, contains: - Type-aware admin builder UI with inline option editor, min/max, date ranges, constraints - Type-aware ticket detail rendering: Number inputs, Textarea, SelectMultiple checkbox groups - Backward compatible: legacy type names mapped to canonical; old pattern field still checked - Update seed data to canonical PascalCase types - Migration 0018: add validation_config and default_value to custom_fields Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
158 lines
4.7 KiB
TypeScript
158 lines
4.7 KiB
TypeScript
import type { CustomField } from './custom-field.ts';
|
|
|
|
export interface ValidationResult {
|
|
valid: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
/**
|
|
* Dispatch to the correct type-specific validator based on field.field_type.
|
|
*/
|
|
export function validateCustomFieldValue(
|
|
field: CustomField,
|
|
value: string,
|
|
): ValidationResult {
|
|
if (!value) return { valid: true };
|
|
|
|
const config = (field.validation_config ?? {}) as Record<string, unknown>;
|
|
|
|
// Backward-compatible pattern check (field-level pattern, not validation_config)
|
|
if (field.pattern) {
|
|
try {
|
|
const regex = new RegExp(field.pattern);
|
|
if (!regex.test(value)) {
|
|
return { valid: false, error: 'Value does not match the required pattern' };
|
|
}
|
|
} catch {
|
|
// Invalid regex — skip
|
|
}
|
|
}
|
|
|
|
switch (field.field_type) {
|
|
case 'Number':
|
|
return validateNumberValue(value, config);
|
|
case 'Date':
|
|
return validateDateValue(value, config);
|
|
case 'DateTime':
|
|
return validateDateTimeValue(value, config);
|
|
case 'SelectOne':
|
|
case 'SelectMultiple':
|
|
return validateSelectValue(value, field, config);
|
|
case 'Text':
|
|
case 'Textarea':
|
|
return validateTextValue(value, config);
|
|
default:
|
|
return { valid: true };
|
|
}
|
|
}
|
|
|
|
function validateNumberValue(
|
|
value: string,
|
|
config: Record<string, unknown>,
|
|
): ValidationResult {
|
|
const num = Number(value);
|
|
if (isNaN(num)) {
|
|
return { valid: false, error: 'Value must be a number' };
|
|
}
|
|
if (config.min !== undefined && num < Number(config.min)) {
|
|
return { valid: false, error: `Value must be at least ${config.min}` };
|
|
}
|
|
if (config.max !== undefined && num > Number(config.max)) {
|
|
return { valid: false, error: `Value must be at most ${config.max}` };
|
|
}
|
|
return { valid: true };
|
|
}
|
|
|
|
function validateDateValue(
|
|
value: string,
|
|
config: Record<string, unknown>,
|
|
): ValidationResult {
|
|
// Accept YYYY-MM-DD format
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
return { valid: false, error: 'Value must be a date in YYYY-MM-DD format' };
|
|
}
|
|
const parsed = new Date(value);
|
|
if (isNaN(parsed.getTime())) {
|
|
return { valid: false, error: 'Invalid date' };
|
|
}
|
|
if (config.min_date) {
|
|
const minDate = new Date(String(config.min_date));
|
|
if (!isNaN(minDate.getTime()) && parsed < minDate) {
|
|
return { valid: false, error: `Date must be on or after ${config.min_date}` };
|
|
}
|
|
}
|
|
if (config.max_date) {
|
|
const maxDate = new Date(String(config.max_date));
|
|
if (!isNaN(maxDate.getTime()) && parsed > maxDate) {
|
|
return { valid: false, error: `Date must be on or before ${config.max_date}` };
|
|
}
|
|
}
|
|
return { valid: true };
|
|
}
|
|
|
|
function validateDateTimeValue(
|
|
value: string,
|
|
config: Record<string, unknown>,
|
|
): ValidationResult {
|
|
const parsed = new Date(value);
|
|
if (isNaN(parsed.getTime())) {
|
|
return { valid: false, error: 'Value must be a valid ISO 8601 datetime' };
|
|
}
|
|
if (config.min_date) {
|
|
const minDate = new Date(String(config.min_date));
|
|
if (!isNaN(minDate.getTime()) && parsed < minDate) {
|
|
return { valid: false, error: `Datetime must be on or after ${config.min_date}` };
|
|
}
|
|
}
|
|
if (config.max_date) {
|
|
const maxDate = new Date(String(config.max_date));
|
|
if (!isNaN(maxDate.getTime()) && parsed > maxDate) {
|
|
return { valid: false, error: `Datetime must be on or before ${config.max_date}` };
|
|
}
|
|
}
|
|
return { valid: true };
|
|
}
|
|
|
|
function validateSelectValue(
|
|
value: string,
|
|
field: CustomField,
|
|
config: Record<string, unknown>,
|
|
): ValidationResult {
|
|
const allowed: string[] = [];
|
|
|
|
// Check validation_config.options first, then fall back to field.values
|
|
if (Array.isArray(config.options)) {
|
|
allowed.push(...config.options.map(String));
|
|
} else if (Array.isArray(field.values)) {
|
|
allowed.push(...field.values.map(String));
|
|
}
|
|
|
|
if (allowed.length > 0 && !allowed.includes(value)) {
|
|
return { valid: false, error: `Value is not an allowed option. Allowed: ${allowed.join(', ')}` };
|
|
}
|
|
return { valid: true };
|
|
}
|
|
|
|
function validateTextValue(
|
|
value: string,
|
|
config: Record<string, unknown>,
|
|
): ValidationResult {
|
|
if (config.min_length !== undefined && value.length < Number(config.min_length)) {
|
|
return { valid: false, error: `Value must be at least ${config.min_length} characters` };
|
|
}
|
|
if (config.max_length !== undefined && value.length > Number(config.max_length)) {
|
|
return { valid: false, error: `Value must be at most ${config.max_length} characters` };
|
|
}
|
|
if (config.pattern && typeof config.pattern === 'string') {
|
|
try {
|
|
const regex = new RegExp(config.pattern);
|
|
if (!regex.test(value)) {
|
|
return { valid: false, error: 'Value does not match the required pattern' };
|
|
}
|
|
} catch {
|
|
// Invalid regex — skip validation
|
|
}
|
|
}
|
|
return { valid: true };
|
|
}
|