feat: canonical custom field types, validation, and type-aware querying
- 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>
This commit is contained in:
157
src/models/custom-field-validation.ts
Normal file
157
src/models/custom-field-validation.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user