feat: add users and templates routes, enhance existing API routes

New routes:
- GET /users — list all users
- GET/POST /templates — list and create templates
- PATCH /templates/:id — update template
- POST /templates/preview — render template with ticket/demo context

Enhanced routes:
- tickets: custom field support on create, status classification helper
- custom-fields: PATCH endpoint, auto-generate short key from name
- lifecycles: PATCH endpoint
- queues: PATCH endpoint

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-09 10:43:08 +02:00
parent e960df61ad
commit 54ef6fcc5b
6 changed files with 675 additions and 28 deletions

View File

@@ -1,8 +1,8 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import type { Db } from '../db/index.ts';
import { tickets, transactions, customFieldValues, customFields, queues, lifecycles } from '../db/schema.ts';
import { eq, asc } from 'drizzle-orm';
import { tickets, transactions, customFieldValues, customFields, queues, lifecycles, queueCustomFields } from '../db/schema.ts';
import { and, eq, asc } from 'drizzle-orm';
import { CreateTicketSchema, UpdateTicketSchema, CommentSchema } from '../models/ticket.ts';
import { ScripEngine } from '../scrip/engine.ts';
import { LifecycleValidator } from '../lifecycle/validator.ts';
@@ -13,21 +13,106 @@ export function createTicketsRouter(db: Db): Hono {
const scripEngine = new ScripEngine(db);
const lifecycleValidator = new LifecycleValidator();
function statusClass(def: LifecycleDefinition, status: string): 'initial' | 'active' | 'inactive' | 'unknown' {
if (def.statuses.initial.includes(status)) return 'initial';
if (def.statuses.active.includes(status)) return 'active';
if (def.statuses.inactive.includes(status)) return 'inactive';
return 'unknown';
}
// GET / — list tickets
router.get('/', async (c) => {
const params = new URL(c.req.url).searchParams;
const queueId = c.req.query('queue_id');
const status = c.req.query('status');
const ownerId = c.req.query('owner_id');
const query = c.req.query('q')?.trim().toLowerCase() ?? '';
const cfFilters = [...params.entries()]
.filter(([key, value]) => key.startsWith('cf.') && value.trim())
.map(([key, value]) => ({
key: key.slice(3),
value: value.trim().toLowerCase(),
}));
const result = await db.query.tickets.findMany({
where: (t, { and, eq }) => {
const conditions = [];
if (queueId) conditions.push(eq(t.queue_id, queueId));
if (status) conditions.push(eq(t.status, status));
return conditions.length > 0 ? and(...conditions) : undefined;
},
let result = await db.query.tickets.findMany({
orderBy: asc(tickets.created_at),
});
if (queueId) {
result = result.filter((ticket) => ticket.queue_id === queueId);
}
if (status) {
result = result.filter((ticket) => ticket.status === status);
}
if (ownerId) {
result = ownerId === 'unassigned'
? result.filter((ticket) => !ticket.owner_id)
: result.filter((ticket) => ticket.owner_id === ownerId);
}
const needsCustomFields = query || cfFilters.length > 0;
const valuesByTicket = new Map<number, { fieldId: string; fieldKey: string; fieldName: string; value: string }[]>();
if (needsCustomFields && result.length > 0) {
const ticketIds = result.map((ticket) => ticket.id);
const cfValues = await db.query.customFieldValues.findMany({
where: (table, { inArray }) => inArray(table.ticket_id, ticketIds),
});
const fieldIds = [...new Set(cfValues.map((value) => value.custom_field_id))];
const fields = fieldIds.length > 0
? await db.query.customFields.findMany({
where: (table, { inArray }) => inArray(table.id, fieldIds),
})
: [];
const fieldMap = new Map(fields.map((field) => [field.id, field]));
for (const value of cfValues) {
const rows = valuesByTicket.get(value.ticket_id) ?? [];
rows.push({
fieldId: value.custom_field_id,
fieldKey: fieldMap.get(value.custom_field_id)?.key ?? value.custom_field_id,
fieldName: fieldMap.get(value.custom_field_id)?.name ?? value.custom_field_id,
value: value.value,
});
valuesByTicket.set(value.ticket_id, rows);
}
}
if (query) {
const queuesForSearch = await db.query.queues.findMany();
const queueNameById = new Map(queuesForSearch.map((queue) => [queue.id, queue.name]));
result = result.filter((ticket) => {
const customFields = valuesByTicket.get(ticket.id) ?? [];
return (
ticket.subject.toLowerCase().includes(query) ||
String(ticket.id).includes(query) ||
ticket.status.toLowerCase().includes(query) ||
(queueNameById.get(ticket.queue_id) ?? '').toLowerCase().includes(query) ||
customFields.some((field) =>
field.fieldName.toLowerCase().includes(query) ||
field.fieldKey.toLowerCase().includes(query) ||
field.value.toLowerCase().includes(query)
)
);
});
}
if (cfFilters.length > 0) {
result = result.filter((ticket) => {
const customFields = valuesByTicket.get(ticket.id) ?? [];
return cfFilters.every((filter) =>
customFields.some((field) =>
(
field.fieldId === filter.key ||
field.fieldKey.toLowerCase() === filter.key.toLowerCase() ||
field.fieldName.toLowerCase() === filter.key.toLowerCase()
) &&
field.value.toLowerCase() === filter.value
)
);
});
}
return c.json(result);
});
@@ -35,26 +120,118 @@ export function createTicketsRouter(db: Db): Hono {
router.post('/', async (c) => {
const body = await c.req.json();
const parsed = CreateTicketSchema.parse(body);
const creatorId = '00000000-0000-0000-0000-000000000000';
const customFieldInput = parsed.custom_fields ?? {};
const customFieldEntries = Object.entries(customFieldInput)
.map(([fieldId, value]) => [fieldId, value.trim()] as const)
.filter(([, value]) => value);
const queue = await db.query.queues.findFirst({
where: eq(queues.id, parsed.queue_id),
});
if (!queue) {
throw new HTTPException(422, { message: 'Queue not found' });
}
let initialStatus = 'new';
if (queue.lifecycle_id) {
const lifecycle = await db.query.lifecycles.findFirst({
where: eq(lifecycles.id, queue.lifecycle_id),
});
const definition = lifecycle?.definition as LifecycleDefinition | undefined;
initialStatus = definition?.statuses.initial[0] ?? initialStatus;
}
let assignedFields: typeof customFields.$inferSelect[] = [];
if (customFieldEntries.length > 0) {
const assignments = await db.query.queueCustomFields.findMany({
where: eq(queueCustomFields.queue_id, parsed.queue_id),
});
const assignedIds = new Set(assignments.map((assignment) => assignment.custom_field_id));
const requestedIds = customFieldEntries.map(([fieldId]) => fieldId);
for (const fieldId of requestedIds) {
if (!assignedIds.has(fieldId)) {
throw new HTTPException(422, { message: 'Custom field is not assigned to this queue' });
}
}
assignedFields = await db.query.customFields.findMany({
where: (table, { inArray }) => inArray(table.id, requestedIds),
});
const fieldById = new Map(assignedFields.map((field) => [field.id, field]));
for (const [fieldId, value] of customFieldEntries) {
const field = fieldById.get(fieldId);
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.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 [ticket] = await db.insert(tickets).values({
subject: parsed.subject,
queue_id: parsed.queue_id,
status: 'new',
creator_id: '00000000-0000-0000-0000-000000000000',
status: initialStatus,
creator_id: creatorId,
}).returning();
if (!ticket) {
throw new HTTPException(500, { message: 'Failed to create ticket' });
}
// Record transaction
await db.insert(transactions).values({
ticket_id: ticket.id,
transaction_type: 'Create',
field: 'status',
new_value: 'new',
creator_id: '00000000-0000-0000-0000-000000000000',
});
const txList = [
{
ticket_id: ticket.id,
transaction_type: 'Create',
field: 'status',
new_value: initialStatus,
creator_id: creatorId,
},
];
if (parsed.description) {
txList.push({
ticket_id: ticket.id,
transaction_type: 'Correspond',
field: null,
new_value: null,
data: { body: parsed.description },
creator_id: creatorId,
} as any);
}
const fieldById = new Map(assignedFields.map((field) => [field.id, field]));
for (const [fieldId, value] of customFieldEntries) {
await db.insert(customFieldValues).values({
ticket_id: ticket.id,
custom_field_id: fieldId,
value,
});
txList.push({
ticket_id: ticket.id,
transaction_type: 'CustomFieldChange',
field: fieldById.get(fieldId)?.key ?? fieldId,
new_value: value,
creator_id: creatorId,
} as any);
}
const createdTransactions = await db.insert(transactions).values(txList as any).returning();
const prepared = await scripEngine.prepare(ticket.id, createdTransactions as any);
await scripEngine.commit(prepared);
return c.json(ticket, 201);
});
@@ -104,6 +281,8 @@ export function createTicketsRouter(db: Db): Hono {
throw new HTTPException(404, { message: 'Ticket not found' });
}
let lifecycleDef: LifecycleDefinition | null = null;
// Validate lifecycle transition if status is changing
if (parsed.status) {
const queue = await db.query.queues.findFirst({
@@ -116,8 +295,8 @@ export function createTicketsRouter(db: Db): Hono {
});
if (lifecycle) {
const def = lifecycle.definition as LifecycleDefinition;
const result = lifecycleValidator.validateTransition(def, ticket.status, parsed.status);
lifecycleDef = lifecycle.definition as LifecycleDefinition;
const result = lifecycleValidator.validateTransition(lifecycleDef, ticket.status, parsed.status);
if (!result.valid) {
throw new HTTPException(422, { message: result.error ?? 'Invalid transition' });
}
@@ -149,7 +328,7 @@ export function createTicketsRouter(db: Db): Hono {
});
}
if (parsed.owner_id && parsed.owner_id !== ticket.owner_id) {
if (parsed.owner_id !== undefined && parsed.owner_id !== ticket.owner_id) {
txList.push({
ticket_id: id,
transaction_type: 'SetOwner' as const,
@@ -163,8 +342,28 @@ export function createTicketsRouter(db: Db): Hono {
// Update the ticket
const updateData: Record<string, unknown> = {};
if (parsed.subject) updateData.subject = parsed.subject;
if (parsed.status) updateData.status = parsed.status;
if (parsed.owner_id) updateData.owner_id = parsed.owner_id;
if (parsed.status) {
updateData.status = parsed.status;
if (lifecycleDef && parsed.status !== ticket.status) {
const fromClass = statusClass(lifecycleDef, ticket.status);
const toClass = statusClass(lifecycleDef, parsed.status);
const now = new Date();
if (fromClass === 'initial' && (toClass === 'active' || toClass === 'inactive') && !ticket.started_at) {
updateData.started_at = now;
}
if ((fromClass === 'initial' || fromClass === 'active') && toClass === 'inactive') {
updateData.resolved_at = now;
}
if (fromClass === 'inactive' && toClass !== 'inactive') {
updateData.resolved_at = null;
}
}
}
if (parsed.owner_id !== undefined) updateData.owner_id = parsed.owner_id;
updateData.updated_at = new Date();
const [updated] = await db.update(tickets)
@@ -198,6 +397,26 @@ export function createTicketsRouter(db: Db): Hono {
throw new HTTPException(404, { message: 'Ticket not found' });
}
if (parsed.status) {
const queue = await db.query.queues.findFirst({
where: eq(queues.id, ticket.queue_id),
});
if (queue?.lifecycle_id) {
const lifecycle = await db.query.lifecycles.findFirst({
where: eq(lifecycles.id, queue.lifecycle_id),
});
if (lifecycle) {
const lifecycleDef = lifecycle.definition as LifecycleDefinition;
const result = lifecycleValidator.validateTransition(lifecycleDef, ticket.status, parsed.status);
if (!result.valid) {
throw new HTTPException(422, { message: result.error ?? 'Invalid transition' });
}
}
}
}
const txList: any[] = [];
if (parsed.status && parsed.status !== ticket.status) {
@@ -266,5 +485,93 @@ export function createTicketsRouter(db: Db): Hono {
return c.json(tx, 201);
});
// PATCH /:id/custom-fields/:fieldId — set or clear a custom field value
router.patch('/:id/custom-fields/:fieldId', async (c) => {
const id = Number(c.req.param('id'));
const fieldId = c.req.param('fieldId');
const body = await c.req.json();
const value = typeof body.value === 'string' ? body.value.trim() : '';
const ticket = await db.query.tickets.findFirst({
where: eq(tickets.id, id),
});
if (!ticket) {
throw new HTTPException(404, { message: 'Ticket not found' });
}
const assignment = await db.query.queueCustomFields.findFirst({
where: and(
eq(queueCustomFields.queue_id, ticket.queue_id),
eq(queueCustomFields.custom_field_id, fieldId),
),
});
if (!assignment) {
throw new HTTPException(422, { message: 'Custom field is not assigned to this ticket queue' });
}
const field = await db.query.customFields.findFirst({
where: eq(customFields.id, fieldId),
});
if (!field) {
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.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 existing = await db.query.customFieldValues.findMany({
where: and(
eq(customFieldValues.ticket_id, id),
eq(customFieldValues.custom_field_id, fieldId),
),
});
const oldValue = existing.map((item) => item.value).join(', ');
await db.delete(customFieldValues).where(and(
eq(customFieldValues.ticket_id, id),
eq(customFieldValues.custom_field_id, fieldId),
));
if (value) {
await db.insert(customFieldValues).values({
ticket_id: id,
custom_field_id: fieldId,
value,
});
}
await db.update(tickets)
.set({ updated_at: new Date() } as any)
.where(eq(tickets.id, id));
const [tx] = await db.insert(transactions).values({
ticket_id: id,
transaction_type: 'CustomFieldChange',
field: field.key,
old_value: oldValue || null,
new_value: value || null,
creator_id: '00000000-0000-0000-0000-000000000000',
}).returning();
const prepared = await scripEngine.prepare(id, [tx] as any);
await scripEngine.commit(prepared);
return c.json(tx, 200);
});
return router;
}