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 { CreateTicketSchema, UpdateTicketSchema } from '../models/ticket.ts'; import { ScripEngine } from '../scrip/engine.ts'; import { LifecycleValidator } from '../lifecycle/validator.ts'; import type { LifecycleDefinition } from '../lifecycle/validator.ts'; export function createTicketsRouter(db: Db): Hono { const router = new Hono(); const scripEngine = new ScripEngine(db); const lifecycleValidator = new LifecycleValidator(); // GET / — list tickets router.get('/', async (c) => { const queueId = c.req.query('queue_id'); const status = c.req.query('status'); 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; }, orderBy: asc(tickets.created_at), }); return c.json(result); }); // POST / — create ticket router.post('/', async (c) => { const body = await c.req.json(); const parsed = CreateTicketSchema.parse(body); const [ticket] = await db.insert(tickets).values({ subject: parsed.subject, queue_id: parsed.queue_id, status: 'new', creator_id: '00000000-0000-0000-0000-000000000000', }).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', }); return c.json(ticket, 201); }); // GET /:id — get ticket with custom field values router.get('/:id', async (c) => { const id = c.req.param('id'); const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, id), }); if (!ticket) { throw new HTTPException(404, { message: 'Ticket not found' }); } const cfValues = await db.query.customFieldValues.findMany({ where: eq(customFieldValues.ticket_id, id), with: { customField: true, }, }); return c.json({ ...ticket, custom_fields: cfValues }); }); // PATCH /:id — update ticket router.patch('/:id', async (c) => { const id = c.req.param('id'); const body = await c.req.json(); const parsed = UpdateTicketSchema.parse(body); const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, id), }); if (!ticket) { throw new HTTPException(404, { message: 'Ticket not found' }); } // Validate lifecycle transition if status is changing 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 def = lifecycle.definition as LifecycleDefinition; const result = lifecycleValidator.validateTransition(def, ticket.status, parsed.status); if (!result.valid) { throw new HTTPException(422, { message: result.error ?? 'Invalid transition' }); } } } } const txList = []; if (parsed.subject && parsed.subject !== ticket.subject) { txList.push({ ticket_id: id, transaction_type: 'StatusChange' as const, field: 'subject', old_value: ticket.subject, new_value: parsed.subject, creator_id: '00000000-0000-0000-0000-000000000000', }); } if (parsed.status && parsed.status !== ticket.status) { txList.push({ ticket_id: id, transaction_type: 'StatusChange' as const, field: 'status', old_value: ticket.status, new_value: parsed.status, creator_id: '00000000-0000-0000-0000-000000000000', }); } if (parsed.owner_id && parsed.owner_id !== ticket.owner_id) { txList.push({ ticket_id: id, transaction_type: 'SetOwner' as const, field: 'owner_id', old_value: ticket.owner_id ?? null, new_value: parsed.owner_id, creator_id: '00000000-0000-0000-0000-000000000000', }); } // Update the ticket const updateData: Record = {}; if (parsed.subject) updateData.subject = parsed.subject; if (parsed.status) updateData.status = parsed.status; if (parsed.owner_id) updateData.owner_id = parsed.owner_id; updateData.updated_at = new Date(); const [updated] = await db.update(tickets) .set(updateData as any) .where(eq(tickets.id, id)) .returning(); // Insert transactions if (txList.length > 0) { await db.insert(transactions).values(txList as any); } // Run scrips const prepared = await scripEngine.prepare(id, txList as any); const results = scripEngine.commit(prepared); return c.json({ ticket: updated, scrip_results: results }); }); // POST /:id/preview — dry-run scrips router.post('/:id/preview', async (c) => { const id = c.req.param('id'); const body = await c.req.json(); const parsed = UpdateTicketSchema.parse(body); const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, id), }); if (!ticket) { throw new HTTPException(404, { message: 'Ticket not found' }); } const txList: any[] = []; if (parsed.status && parsed.status !== ticket.status) { txList.push({ id: '00000000-0000-0000-0000-000000000000', ticket_id: id, transaction_type: 'StatusChange', field: 'status', old_value: ticket.status, new_value: parsed.status, creator_id: '00000000-0000-0000-0000-000000000000', }); } const prepared = await scripEngine.prepare(id, txList); const preparedWithDryRun = prepared.map((p) => ({ ...p, dryRun: true })); const results = scripEngine.commit(preparedWithDryRun); return c.json({ prepared_scrips: results }); }); // GET /:id/transactions — list transactions for ticket router.get('/:id/transactions', async (c) => { const id = c.req.param('id'); const result = await db.query.transactions.findMany({ where: eq(transactions.ticket_id, id), orderBy: asc(transactions.created_at), }); return c.json(result); }); return router; }