import { Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; import type { Db } from '../db/index.ts'; import { tickets, transactions, customFieldValues, customFields, queues, lifecycles, queueCustomFields, teamMembers } from '../db/schema.ts'; import { and, eq, asc, or, ilike, isNull, inArray, exists, sql } from 'drizzle-orm'; import { CreateTicketSchema, UpdateTicketSchema, CommentSchema } 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(); 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 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; const cfFilters = [...params.entries()] .filter(([key, value]) => key.startsWith('cf.') && value.trim()) .map(([key, value]) => ({ key: key.slice(3), value: value.trim(), })); // Build SQL WHERE conditions const conditions: ReturnType[] = []; if (queueId) { conditions.push(eq(tickets.queue_id, queueId)); } if (status) { conditions.push(eq(tickets.status, status)); } if (ownerId) { conditions.push( ownerId === 'unassigned' ? isNull(tickets.owner_id) : eq(tickets.owner_id, ownerId) ); } if (teamId) { // Resolve team members and filter tickets by those owner_ids const members = await db.query.teamMembers.findMany({ where: eq(teamMembers.team_id, teamId), }); const memberIds = members.map((m) => m.user_id); if (memberIds.length > 0) { conditions.push(inArray(tickets.owner_id, memberIds)); } else { conditions.push(isNull(tickets.owner_id)); // empty team = no results } } // Text search: push to SQL via ilike on ticket columns + queue name join if (query) { const pattern = `%${query}%`; conditions.push( or( ilike(tickets.subject, pattern), ilike(tickets.status, pattern), sql`${tickets.id}::text ILIKE ${pattern}` )! ); // Queue name search requires join — keep as post-filter } // 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) ) ) ) ); } const result = await db.query.tickets.findMany({ where: conditions.length > 0 ? and(...conditions) : undefined, orderBy: asc(tickets.created_at), limit, }); // Post-filter for queue name text search (requires in-memory join) let filtered = result; if (query) { const queuesForSearch = await db.query.queues.findMany(); const queueNameById = new Map(queuesForSearch.map((queue) => [queue.id, queue.name])); filtered = result.filter((ticket) => (queueNameById.get(ticket.queue_id) ?? '').toLowerCase().includes(query.toLowerCase()) ); } return c.json(filtered); }); // POST / — create ticket 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: initialStatus, creator_id: creatorId, team_id: (queue as any).team_id ?? null, }).returning(); if (!ticket) { throw new HTTPException(500, { message: 'Failed to create ticket' }); } 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); const results = await scripEngine.commit(prepared); return c.json({ ticket, scrip_results: results }, 201); }); // GET /:id — get ticket with custom field values router.get('/:id', async (c) => { const id = Number(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), }); const cfIds = [...new Set(cfValues.map(v => v.custom_field_id))]; const cfRecords = cfIds.length > 0 ? await db.query.customFields.findMany({ where: (fields, { inArray }) => inArray(fields.id, cfIds), }) : []; const cfMap = new Map(cfRecords.map(cf => [cf.id, cf])); const customFieldsMapped = cfValues.map(v => ({ ...v, custom_field: cfMap.get(v.custom_field_id) ?? null, })); return c.json({ ...ticket, custom_fields: customFieldsMapped }); }); // PATCH /:id — update ticket router.patch('/:id', async (c) => { const id = Number(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' }); } let lifecycleDef: LifecycleDefinition | null = null; // 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) { 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 = []; 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 !== undefined && 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', }); } if (parsed.team_id !== undefined && parsed.team_id !== (ticket as any).team_id) { txList.push({ ticket_id: id, transaction_type: 'SetTeam' as const, field: 'team_id', old_value: (ticket as any).team_id ?? null, new_value: parsed.team_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 (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; if (parsed.team_id !== undefined) updateData.team_id = parsed.team_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 = await 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 = Number(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' }); } 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) { 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 = await scripEngine.commit(preparedWithDryRun); return c.json({ prepared_scrips: results }); }); // GET /:id/transactions — list transactions for ticket router.get('/:id/transactions', async (c) => { const id = Number(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); }); // POST /:id/comment — add a comment (reply or internal note) router.post('/:id/comment', async (c) => { const id = Number(c.req.param('id')); const body = await c.req.json(); const parsed = CommentSchema.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 transactionType = parsed.internal ? 'Comment' : 'Correspond'; const [tx] = await db.insert(transactions).values({ ticket_id: id, transaction_type: transactionType, data: { body: parsed.body }, creator_id: parsed.creator_id, }).returning(); if (!tx) { throw new HTTPException(500, { message: 'Failed to create comment' }); } // Run scrips const txList = [tx]; const prepared = await scripEngine.prepare(id, txList as any); await scripEngine.commit(prepared); 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; }