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:
Gjermund Høsøien Wiggen
2026-06-15 21:29:28 +02:00
parent 70f0924d4b
commit 9679734e3f
15 changed files with 4501 additions and 125 deletions

View File

@@ -95,6 +95,8 @@ export const customFields = pgTable('custom_fields', {
values: jsonb('values'),
max_values: integer('max_values').notNull().default(1),
pattern: text('pattern'),
validation_config: jsonb('validation_config').default(sql`'{}'::jsonb`),
default_value: text('default_value'),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
});

View File

@@ -441,30 +441,30 @@ async function main() {
const impactField = await ensureCustomField(db, {
key: 'impact',
name: 'Impact',
field_type: 'select',
field_type: 'SelectOne',
values: ['Low', 'Medium', 'High', 'Critical'],
});
const locationField = await ensureCustomField(db, {
key: 'location',
name: 'Location',
field_type: 'text',
field_type: 'Text',
});
const assetField = await ensureCustomField(db, {
key: 'asset_tag',
name: 'Asset tag',
field_type: 'text',
field_type: 'Text',
pattern: '^ASSET-[0-9]{4}$',
});
const channelField = await ensureCustomField(db, {
key: 'channel',
name: 'Channel',
field_type: 'select',
field_type: 'SelectOne',
values: ['Portal', 'Email', 'Phone', 'Walk-up', 'Monitoring'],
});
const outcomeField = await ensureCustomField(db, {
key: 'resolution_outcome',
name: 'Resolution outcome',
field_type: 'select',
field_type: 'SelectOne',
values: ['Completed', 'Workaround', 'Duplicate', 'Declined'],
});

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

View File

@@ -1,13 +1,71 @@
import type { InferSelectModel } from 'drizzle-orm';
import { z } from 'zod';
import { customFields } from '../db/schema.ts';
export type CustomField = InferSelectModel<typeof customFields>;
export const CustomFieldType = {
Text: 'Text',
Textarea: 'Textarea',
SelectOne: 'SelectOne',
SelectMultiple: 'SelectMultiple',
Text: 'Text',
Date: 'Date',
DateTime: 'DateTime',
Number: 'Number',
} as const;
export type CustomFieldType = (typeof CustomFieldType)[keyof typeof CustomFieldType];
export const CUSTOM_FIELD_TYPES = Object.values(CustomFieldType) as [string, ...string[]];
// Validation config per type
export const NumberValidationConfig = z.object({
min: z.number().optional(),
max: z.number().optional(),
}).optional();
export const DateValidationConfig = z.object({
min_date: z.string().optional(),
max_date: z.string().optional(),
}).optional();
export const DateTimeValidationConfig = z.object({
min_date: z.string().optional(),
max_date: z.string().optional(),
}).optional();
export const TextValidationConfig = z.object({
min_length: z.number().int().min(0).optional(),
max_length: z.number().int().min(0).optional(),
pattern: z.string().optional(),
}).optional();
export const SelectValidationConfig = z.object({
options: z.array(z.string()).optional(),
}).optional();
// Generic validation config — permissive at the API boundary, refined per type in validation
export const CustomFieldValidationConfig = z.record(z.unknown()).nullable().default(null);
// Schemas for create/update
export const CreateCustomFieldSchema = z.object({
key: z.string().optional(),
name: z.string().min(1),
field_type: z.enum(CUSTOM_FIELD_TYPES as [string, ...string[]]),
values: z.unknown().nullable().optional(),
max_values: z.number().int().min(1).optional().default(1),
pattern: z.string().nullable().optional(),
validation_config: z.record(z.unknown()).nullable().optional(),
default_value: z.string().nullable().optional(),
});
export const UpdateCustomFieldSchema = z.object({
key: z.string().optional(),
name: z.string().min(1).optional(),
field_type: z.enum(CUSTOM_FIELD_TYPES as [string, ...string[]]).optional(),
values: z.unknown().nullable().optional(),
max_values: z.number().int().min(1).optional(),
pattern: z.string().nullable().optional(),
validation_config: z.record(z.unknown()).nullable().optional(),
default_value: z.string().nullable().optional(),
});

View File

@@ -3,6 +3,8 @@ import { HTTPException } from 'hono/http-exception';
import type { Db } from '../db/index.ts';
import { customFields, queueCustomFields } from '../db/schema.ts';
import { and, asc, eq } from 'drizzle-orm';
import { CreateCustomFieldSchema, UpdateCustomFieldSchema } from '../models/custom-field.ts';
import { ZodError } from 'zod';
function makeFieldKey(value: string): string {
const key = value
@@ -13,6 +15,10 @@ function makeFieldKey(value: string): string {
return key || 'field';
}
function formatZodError(err: ZodError): string {
return err.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
}
export function createCustomFieldsRouter(db: Db): Hono {
const router = new Hono();
@@ -25,20 +31,28 @@ export function createCustomFieldsRouter(db: Db): Hono {
router.post('/', async (c) => {
const body = await c.req.json();
const { name, field_type, values, max_values, pattern } = body;
const key = makeFieldKey(String(body.key ?? name ?? ''));
if (!name || !field_type) {
throw new HTTPException(400, { message: 'name and field_type are required' });
let parsed;
try {
parsed = CreateCustomFieldSchema.parse(body);
} catch (err) {
if (err instanceof ZodError) {
throw new HTTPException(400, { message: formatZodError(err) });
}
throw err;
}
const key = makeFieldKey(String(parsed.key ?? parsed.name ?? ''));
const [cf] = await db.insert(customFields).values({
key,
name,
field_type,
values: values ?? null,
max_values: max_values ?? 1,
pattern: pattern ?? null,
name: parsed.name,
field_type: parsed.field_type,
values: (parsed.values as any) ?? null,
max_values: parsed.max_values ?? 1,
pattern: parsed.pattern ?? null,
validation_config: (parsed.validation_config as any) ?? null,
default_value: parsed.default_value ?? null,
}).returning();
if (!cf) {
@@ -52,6 +66,16 @@ export function createCustomFieldsRouter(db: Db): Hono {
const id = c.req.param('id');
const body = await c.req.json();
let parsed;
try {
parsed = UpdateCustomFieldSchema.parse(body);
} catch (err) {
if (err instanceof ZodError) {
throw new HTTPException(400, { message: formatZodError(err) });
}
throw err;
}
const existing = await db.query.customFields.findFirst({
where: eq(customFields.id, id),
});
@@ -61,12 +85,18 @@ export function createCustomFieldsRouter(db: Db): Hono {
}
const updateData: Partial<typeof customFields.$inferInsert> = {};
if (body.key !== undefined) updateData.key = makeFieldKey(String(body.key));
if (body.name !== undefined) updateData.name = String(body.name);
if (body.field_type !== undefined) updateData.field_type = String(body.field_type);
if (body.values !== undefined) updateData.values = body.values ?? null;
if (body.max_values !== undefined) updateData.max_values = Number(body.max_values);
if (body.pattern !== undefined) updateData.pattern = body.pattern ? String(body.pattern) : null;
if (parsed.key !== undefined) updateData.key = makeFieldKey(String(parsed.key));
if (parsed.name !== undefined) updateData.name = String(parsed.name);
if (parsed.field_type !== undefined) updateData.field_type = String(parsed.field_type);
if (parsed.values !== undefined) updateData.values = parsed.values ?? null;
if (parsed.max_values !== undefined) updateData.max_values = Number(parsed.max_values);
if (parsed.pattern !== undefined) updateData.pattern = parsed.pattern ? String(parsed.pattern) : null;
if (parsed.validation_config !== undefined) {
updateData.validation_config = (parsed.validation_config as any) ?? null;
}
if (parsed.default_value !== undefined) {
updateData.default_value = parsed.default_value ?? null;
}
const [updated] = await db.update(customFields)
.set(updateData)

View File

@@ -15,6 +15,7 @@ import { CreateTicketSchema, UpdateTicketSchema, CommentSchema } from '../models
import { ScripEngine } from '../scrip/engine.ts';
import { LifecycleValidator } from '../lifecycle/validator.ts';
import type { LifecycleDefinition } from '../lifecycle/validator.ts';
import { validateCustomFieldValue } from '../models/custom-field-validation.ts';
export function createTicketsRouter(db: Db): Hono {
const router = new Hono();
@@ -43,12 +44,18 @@ export function createTicketsRouter(db: Db): Hono {
const teamId = c.req.query('team_id');
const query = c.req.query('q')?.trim() ?? '';
const limit = c.req.query('limit') ? Number(c.req.query('limit')) : undefined;
// Parse cf.* filters with operator support: cf.budget=gt:100, cf.due_date=before:2026-07-01
const cfFilters = [...params.entries()]
.filter(([key, value]) => key.startsWith('cf.') && value.trim())
.map(([key, value]) => ({
key: key.slice(3),
value: value.trim(),
}));
.map(([key, value]) => {
const raw = value.trim();
// Check for operator prefixes
const opMatch = raw.match(/^(gt|gte|lt|lte|before|after|contains):(.+)$/);
if (opMatch) {
return { key: key.slice(3), operator: opMatch[1], value: opMatch[2] };
}
return { key: key.slice(3), operator: 'eq' as const, value: raw };
});
// Build SQL WHERE conditions
const conditions: ReturnType<typeof eq>[] = [];
@@ -143,22 +150,70 @@ export function createTicketsRouter(db: Db): Hono {
);
}
// Custom field filters: use EXISTS subquery
for (const cf of cfFilters) {
conditions.push(
exists(
db.select({ n: sql`1` })
.from(customFieldValues)
.innerJoin(customFields, eq(customFieldValues.custom_field_id, customFields.id))
.where(
and(
eq(customFieldValues.ticket_id, tickets.id),
eq(customFields.key, cf.key),
eq(customFieldValues.value, cf.value)
// Custom field filters: type-aware operators
if (cfFilters.length > 0) {
// Fetch the custom fields to know their types
const cfKeys = cfFilters.map((cf) => cf.key);
const cfRecords = await db.query.customFields.findMany({
where: (table, { inArray }) => inArray(table.key, cfKeys),
});
const cfByKey = new Map(cfRecords.map((cf) => [cf.key, cf]));
for (const cf of cfFilters) {
const field = cfByKey.get(cf.key);
const fieldType = field?.field_type;
// Build the value comparison based on operator and field type
let valueCondition;
if (cf.operator === 'eq') {
valueCondition = eq(customFieldValues.value, cf.value);
} else if (cf.operator === 'contains') {
valueCondition = ilike(customFieldValues.value, `%${cf.value}%`);
} else if ((cf.operator === 'gt' || cf.operator === 'gte' ||
cf.operator === 'lt' || cf.operator === 'lte') &&
fieldType === 'Number') {
const numVal = Number(cf.value);
if (!isNaN(numVal)) {
if (cf.operator === 'gt') {
valueCondition = sql`${customFieldValues.value}::numeric > ${numVal}`;
} else if (cf.operator === 'gte') {
valueCondition = sql`${customFieldValues.value}::numeric >= ${numVal}`;
} else if (cf.operator === 'lt') {
valueCondition = sql`${customFieldValues.value}::numeric < ${numVal}`;
} else if (cf.operator === 'lte') {
valueCondition = sql`${customFieldValues.value}::numeric <= ${numVal}`;
}
} else {
valueCondition = sql`FALSE`; // Invalid number
}
} else if ((cf.operator === 'before' || cf.operator === 'after') &&
(fieldType === 'Date' || fieldType === 'DateTime')) {
const dateVal = cf.value;
if (cf.operator === 'before') {
valueCondition = sql`${customFieldValues.value} <= ${dateVal}`;
} else {
valueCondition = sql`${customFieldValues.value} >= ${dateVal}`;
}
} else {
// Fallback: exact match for unsupported operator/type combos
valueCondition = eq(customFieldValues.value, cf.value);
}
conditions.push(
exists(
db.select({ n: sql`1` })
.from(customFieldValues)
.innerJoin(customFields, eq(customFieldValues.custom_field_id, customFields.id))
.where(
and(
eq(customFieldValues.ticket_id, tickets.id),
eq(customFields.key, cf.key),
valueCondition!
)
)
)
)
);
)
);
}
}
const result = await db.query.tickets.findMany({
@@ -198,6 +253,8 @@ export function createTicketsRouter(db: Db): Hono {
values: fieldMap.get(v.custom_field_id)!.values,
max_values: fieldMap.get(v.custom_field_id)!.max_values,
pattern: fieldMap.get(v.custom_field_id)!.pattern,
validation_config: fieldMap.get(v.custom_field_id)!.validation_config,
default_value: fieldMap.get(v.custom_field_id)!.default_value,
} : undefined,
}));
return { ...ticket, custom_fields: cfs };
@@ -261,32 +318,9 @@ export function createTicketsRouter(db: Db): Hono {
if (!field) {
throw new HTTPException(422, { message: 'Custom field not found' });
}
if (Array.isArray(field.values) && field.values.length > 0) {
const allowed = new Set(field.values.map((option) => String(option)));
if (!allowed.has(value)) {
throw new HTTPException(422, { message: `${field.name}: value is not an allowed option` });
}
}
if (field.field_type === 'date') {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new HTTPException(422, { message: `${field.name}: value must be a date in YYYY-MM-DD format` });
}
const parsed = new Date(value);
if (isNaN(parsed.getTime())) {
throw new HTTPException(422, { message: `${field.name}: invalid date` });
}
}
if (field.field_type === 'datetime') {
const parsed = new Date(value);
if (isNaN(parsed.getTime())) {
throw new HTTPException(422, { message: `${field.name}: value must be a valid ISO 8601 datetime` });
}
}
if (field.pattern) {
const regex = new RegExp(field.pattern);
if (!regex.test(value)) {
throw new HTTPException(422, { message: `${field.name}: value does not match the required pattern` });
}
const validation = validateCustomFieldValue(field, value);
if (!validation.valid) {
throw new HTTPException(422, { message: `${field.name}: ${validation.error}` });
}
}
}
@@ -1263,33 +1297,10 @@ export function createTicketsRouter(db: Db): Hono {
throw new HTTPException(404, { message: 'Custom field not found' });
}
if (value && Array.isArray(field.values) && field.values.length > 0) {
const allowed = new Set(field.values.map((option) => String(option)));
if (!allowed.has(value)) {
throw new HTTPException(422, { message: `${field.name}: value is not an allowed option` });
}
}
if (value && field.field_type === 'date') {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new HTTPException(422, { message: `${field.name}: value must be a date in YYYY-MM-DD format` });
}
const parsed = new Date(value);
if (isNaN(parsed.getTime())) {
throw new HTTPException(422, { message: `${field.name}: invalid date` });
}
}
if (value && field.field_type === 'datetime') {
const parsed = new Date(value);
if (isNaN(parsed.getTime())) {
throw new HTTPException(422, { message: `${field.name}: value must be a valid ISO 8601 datetime` });
}
}
if (value && field.pattern) {
const regex = new RegExp(field.pattern);
if (!regex.test(value)) {
throw new HTTPException(422, { message: `${field.name}: value does not match the required pattern` });
if (value) {
const validation = validateCustomFieldValue(field, value);
if (!validation.valid) {
throw new HTTPException(422, { message: `${field.name}: ${validation.error}` });
}
}