Files
tessera/src/models/custom-field-validation.ts
Gjermund Høsøien Wiggen 9679734e3f 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>
2026-06-15 21:29:28 +02:00

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 };
}