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:
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}` });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user