import { Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; import { existsSync, mkdirSync } from 'node:fs'; import { join, extname } from 'node:path'; import { writeFile } from 'node:fs/promises'; import { randomUUID } from 'node:crypto'; import type { Db } from '../db/index.ts'; import { config } from '../config.ts'; import { getUserId } from '../auth/middleware.ts'; import { requireRight } from '../auth/permissions.ts'; import { createNotification } from './notifications.ts'; import { tickets, transactions, customFieldValues, customFields, queues, lifecycles, queueCustomFields, teamMembers, transactionAttachments, ticketLinks } 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 queueId = c.req.query('queue_id'); // If filtering by queue, check view permission if (queueId) { await requireRight(c, db, queueId, 'ticket.view'); } const params = new URL(c.req.url).searchParams; 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 } } // Subject filter: supports "contains:", "is:", "is_not:", "starts_with:" const subjectFilter = c.req.query('subject'); if (subjectFilter) { const idx = subjectFilter.indexOf(':'); const op = idx > -1 ? subjectFilter.slice(0, idx) : 'contains'; const val = idx > -1 ? subjectFilter.slice(idx + 1) : subjectFilter; if (val) { if (op === 'is') conditions.push(eq(tickets.subject, val)); else if (op === 'is_not') conditions.push(sql`${tickets.subject} != ${val}`); else if (op === 'starts_with') conditions.push(ilike(tickets.subject, `${val}%`)); else conditions.push(ilike(tickets.subject, `%${val}%`)); // contains } } // Date filters: format "before:YYYY-MM-DD" or "after:YYYY-MM-DD" for (const [fieldName, column] of [['created', tickets.created_at], ['updated', tickets.updated_at]] as const) { const dateFilter = c.req.query(fieldName); if (dateFilter) { const idx = dateFilter.indexOf(':'); const op = idx > -1 ? dateFilter.slice(0, idx) : 'after'; const val = idx > -1 ? dateFilter.slice(idx + 1) : dateFilter; if (val) { if (op === 'before') conditions.push(sql`${column} <= ${val}::timestamptz`); else conditions.push(sql`${column} >= ${val}::timestamptz`); // after } } } // Text search across tickets, transactions, queue names, and custom fields if (query) { const pattern = `%${query}%`; conditions.push( or( ilike(tickets.subject, pattern), sql`${tickets.id}::text ILIKE ${pattern}`, // Queue name exists( db.select({ n: sql`1` }) .from(queues) .where(and( eq(queues.id, tickets.queue_id), ilike(queues.name, pattern) )) ), // Transaction bodies (comments, correspondence) exists( db.select({ n: sql`1` }) .from(transactions) .where(and( eq(transactions.ticket_id, tickets.id), sql`transactions.data->>'body' ILIKE ${pattern}` )) ), // Custom field values exists( db.select({ n: sql`1` }) .from(customFieldValues) .where(and( eq(customFieldValues.ticket_id, tickets.id), ilike(customFieldValues.value, pattern) )) ) )! ); } // 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, }); // Attach custom field values to all tickets if (result.length > 0) { const ticketIds = result.map((t) => t.id); const allCfValues = await db.query.customFieldValues.findMany({ where: (table, { inArray }) => inArray(table.ticket_id, ticketIds), }); const fieldIds = [...new Set(allCfValues.map((v) => v.custom_field_id))]; const allFields = fieldIds.length > 0 ? await db.query.customFields.findMany({ where: (table, { inArray }) => inArray(table.id, fieldIds), }) : []; const fieldMap = new Map(allFields.map((f) => [f.id, f])); const ticketsWithCf = result.map((ticket) => { const cfs = allCfValues .filter((v) => v.ticket_id === ticket.id) .map((v) => ({ id: v.id, custom_field_id: v.custom_field_id, ticket_id: v.ticket_id, value: v.value, created_at: v.created_at?.toISOString(), custom_field: fieldMap.has(v.custom_field_id) ? { id: v.custom_field_id, key: fieldMap.get(v.custom_field_id)!.key, name: fieldMap.get(v.custom_field_id)!.name, field_type: fieldMap.get(v.custom_field_id)!.field_type, 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, } : undefined, })); return { ...ticket, custom_fields: cfs }; }); return c.json(ticketsWithCf); } return c.json(result); }); // POST / — create ticket router.post('/', async (c) => { const body = await c.req.json(); const parsed = CreateTicketSchema.parse(body); await requireRight(c, db, parsed.queue_id, 'ticket.create'); const creatorId = getUserId(c); 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.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 [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' }); } await requireRight(c, db, ticket.queue_id, 'ticket.view'); 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, })); // Blocking dependencies: tickets this one DependsOn that aren't resolved yet const dependsOnLinks = await db.query.ticketLinks.findMany({ where: (t, { and, eq: eqFn }) => and(eqFn(t.ticket_id, id), eqFn(t.link_type, 'DependsOn')), }); const blockingIds = dependsOnLinks.map((l) => l.target_ticket_id); let blockedBy: Array<{ id: number; subject: string; status: string }> = []; if (blockingIds.length > 0) { const blockingTickets = await db.query.tickets.findMany({ where: (t, { inArray }) => inArray(t.id, blockingIds), }); const queue = await db.query.queues.findFirst({ where: eq(queues.id, ticket.queue_id), }); let inactiveStatuses: string[] = []; if (queue?.lifecycle_id) { const lc = await db.query.lifecycles.findFirst({ where: eq(lifecycles.id, queue.lifecycle_id) }); if (lc) inactiveStatuses = (lc.definition as any)?.statuses?.inactive ?? []; } blockedBy = blockingTickets .filter((t) => !inactiveStatuses.includes(t.status)) .map((t) => ({ id: t.id, subject: t.subject, status: t.status })); } return c.json({ ...ticket, custom_fields: customFieldsMapped, blocked_by: blockedBy }); }); 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' }); } await requireRight(c, db, ticket.queue_id, 'ticket.modify'); 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' }); } // Check transition-gating right if (result.requiredRight) { await requireRight(c, db, ticket.queue_id, result.requiredRight as any); } // Check dependency enforcement: can't resolve/close if this ticket DependsOn unresolved tickets const inactiveStatuses = lifecycleDef.statuses.inactive; if (inactiveStatuses.includes(parsed.status)) { const dependsOnLinks = await db.query.ticketLinks.findMany({ where: (t, { and, eq: eqFn }) => and( eqFn(t.ticket_id, id), eqFn(t.link_type, 'DependsOn'), ), }); for (const link of dependsOnLinks) { const target = await db.query.tickets.findFirst({ where: eq(tickets.id, link.target_ticket_id), }); if (target && !inactiveStatuses.includes(target.status)) { throw new HTTPException(422, { message: `Cannot resolve: this ticket depends on ticket ${target.id} (${target.subject}) which is still ${target.status}. Resolve or close that ticket first.`, }); } } } } } } 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: getUserId(c), }); } 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: getUserId(c), }); } 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: getUserId(c), }); } 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: getUserId(c), }); } // 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 — use TransactionBatch when multiple changes, TransactionCreate for single const stage = txList.length > 1 ? 'TransactionBatch' as const : 'TransactionCreate' as const; const prepared = await scripEngine.prepare(id, txList as any, stage); const results = await scripEngine.commit(prepared); // Notify on assignment change if (parsed.owner_id !== undefined && parsed.owner_id !== ticket.owner_id) { if (parsed.owner_id) { await createNotification(db, { user_id: parsed.owner_id, ticket_id: id, type: 'assigned', title: `You were assigned to ticket ${id}`, body: ticket.subject, }); } } 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' }); } await requireRight(c, db, ticket.queue_id, 'ticket.modify'); 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: getUserId(c), ticket_id: id, transaction_type: 'StatusChange', field: 'status', old_value: ticket.status, new_value: parsed.status, creator_id: getUserId(c), }); } 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 (with attachments) 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), }); // Fetch attachments for these transactions const txIds = result.map((tx) => tx.id); if (txIds.length > 0) { const attachments = await db.query.transactionAttachments.findMany({ where: inArray(transactionAttachments.transaction_id, txIds), }); const attachmentsByTxId = new Map(); for (const att of attachments) { if (!att.transaction_id) continue; const list = attachmentsByTxId.get(att.transaction_id); if (list) { list.push(att); } else { attachmentsByTxId.set(att.transaction_id, [att]); } } const resultWithAttachments = result.map((tx) => ({ ...tx, attachments: attachmentsByTxId.get(tx.id) ?? [], })); return c.json(resultWithAttachments); } return c.json(result.map((tx) => ({ ...tx, attachments: [] }))); }); // POST /:id/attachments — upload file attachments for a ticket router.post('/:id/attachments', async (c) => { const ticketId = Number(c.req.param('id')); const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, ticketId), }); if (!ticket) { throw new HTTPException(404, { message: 'Ticket not found' }); } await requireRight(c, db, ticket.queue_id, 'ticket.reply'); const formData = await c.req.formData(); const files = formData.getAll('files') as File[]; if (files.length === 0) { throw new HTTPException(422, { message: 'No files provided' }); } const now = new Date(); const year = now.getFullYear().toString(); const month = String(now.getMonth() + 1).padStart(2, '0'); const dir = join(config.UPLOAD_DIR, year, month); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } const MIME_MAP: Record = { '.txt': 'text/plain', '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', '.json': 'application/json', '.xml': 'application/xml', '.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.zip': 'application/zip', '.gz': 'application/gzip', '.csv': 'text/csv', '.mp4': 'video/mp4', '.mp3': 'audio/mpeg', '.md': 'text/markdown', '.yaml': 'text/yaml', '.yml': 'text/yaml', }; const result: Array<{ id: string; filename: string; mime_type: string; size_bytes: number }> = []; for (const file of files) { if (!(file instanceof File)) continue; const ext = extname(file.name).toLowerCase(); const storedName = `${randomUUID()}${ext}`; const storagePath = join(dir, storedName); const buffer = Buffer.from(await file.arrayBuffer()); await writeFile(storagePath, buffer); const mimeType = file.type || MIME_MAP[ext] || 'application/octet-stream'; const [saved] = await db.insert(transactionAttachments).values({ filename: file.name, mime_type: mimeType, size_bytes: buffer.length, storage_path: storagePath, }).returning(); if (saved) { result.push({ id: saved.id, filename: saved.filename, mime_type: saved.mime_type, size_bytes: saved.size_bytes, }); } } return c.json({ attachments: result }, 201); }); // 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' }); } await requireRight(c, db, ticket.queue_id, parsed.internal ? 'ticket.comment' : 'ticket.reply'); const transactionType = parsed.internal ? 'Comment' : 'Correspond'; const attachmentIds = parsed.attachment_ids ?? []; const txData: Record = { body: parsed.body }; if (attachmentIds.length > 0) { txData.attachment_ids = attachmentIds; } const timeWorked = parsed.time_worked_minutes ?? 0; const [tx] = await db.insert(transactions).values({ ticket_id: id, transaction_type: transactionType, data: txData, time_worked_minutes: timeWorked, creator_id: parsed.creator_id, }).returning(); if (!tx) { throw new HTTPException(500, { message: 'Failed to create comment' }); } // Link pre-uploaded attachment records to this transaction if (attachmentIds.length > 0) { await db.update(transactionAttachments) .set({ transaction_id: tx.id }) .where(inArray(transactionAttachments.id, attachmentIds)); } // Run scrips const txList = [tx]; const prepared = await scripEngine.prepare(id, txList as any); await scripEngine.commit(prepared); // Notify ticket owner and creator const commenterId = getUserId(c); const notifyTargets = new Set([ticket.owner_id, ticket.creator_id].filter(Boolean) as string[]); notifyTargets.delete(commenterId); for (const userId of notifyTargets) { await createNotification(db, { user_id: userId, ticket_id: id, type: 'commented', title: `New ${transactionType === 'Comment' ? 'internal note' : 'reply'} on ticket ${id}`, body: parsed.body.slice(0, 200), }); } return c.json(tx, 201); }); // GET /:id/links — list links for a ticket (with target ticket info) router.get('/:id/links', 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' }); } await requireRight(c, db, ticket.queue_id, 'ticket.view'); const links = await db.query.ticketLinks.findMany({ where: eq(ticketLinks.ticket_id, id), orderBy: asc(ticketLinks.created_at), }); // Enrich with target ticket info const targetIds = [...new Set(links.map((l) => l.target_ticket_id))]; const targetTickets = targetIds.length > 0 ? await db.query.tickets.findMany({ where: (table, { inArray }) => inArray(table.id, targetIds), }) : []; const ticketById = new Map(targetTickets.map((t) => [t.id, t])); const enriched = links.map((link) => { const target = ticketById.get(link.target_ticket_id); return { ...link, target_ticket: target ? { id: target.id, subject: target.subject, status: target.status } : null, }; }); return c.json(enriched); }); // POST /:id/links — create a link to another ticket router.post('/:id/links', async (c) => { const id = Number(c.req.param('id')); const body = await c.req.json(); const targetTicketId = Number(body.target_ticket_id); const linkType = String(body.link_type || 'RelatedTo'); if (!targetTicketId || isNaN(targetTicketId)) { throw new HTTPException(422, { message: 'target_ticket_id is required' }); } if (targetTicketId === id) { throw new HTTPException(422, { message: 'Cannot link a ticket to itself' }); } const validTypes = ['DependsOn', 'Blocks', 'RefersTo', 'RelatedTo', 'Duplicates', 'MemberOf']; if (!validTypes.includes(linkType)) { throw new HTTPException(422, { message: `Invalid link_type. Must be one of: ${validTypes.join(', ')}` }); } const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, id), }); if (!ticket) { throw new HTTPException(404, { message: 'Ticket not found' }); } await requireRight(c, db, ticket.queue_id, 'ticket.modify'); const target = await db.query.tickets.findFirst({ where: eq(tickets.id, targetTicketId), }); if (!target) { throw new HTTPException(404, { message: 'Target ticket not found' }); } // Check for duplicate const existing = await db.query.ticketLinks.findFirst({ where: (table, { and, eq: eqFn }) => and( eqFn(table.ticket_id, id), eqFn(table.target_ticket_id, targetTicketId), eqFn(table.link_type, linkType), ), }); if (existing) { throw new HTTPException(422, { message: 'This link already exists' }); } const creatorId = body.creator_id || getUserId(c); const [link] = await db.insert(ticketLinks).values({ ticket_id: id, target_ticket_id: targetTicketId, link_type: linkType, creator_id: creatorId, }).returning(); if (!link) { throw new HTTPException(500, { message: 'Failed to create link' }); } // Create transactions on both tickets const linkData = { link_type: linkType, link_id: link.id, target_ticket_id: targetTicketId, target_subject: target.subject, }; const reverseLinkData = { link_type: linkType, link_id: link.id, target_ticket_id: id, target_subject: ticket.subject, }; const [txSource] = await db.insert(transactions).values({ ticket_id: id, transaction_type: 'LinkCreate', field: linkType, old_value: null, new_value: String(targetTicketId), data: linkData, creator_id: creatorId, }).returning(); const [txTarget] = await db.insert(transactions).values({ ticket_id: targetTicketId, transaction_type: 'LinkCreate', field: linkType, old_value: null, new_value: String(id), data: reverseLinkData, creator_id: creatorId, }).returning(); // Run scrips on source ticket if (txSource) { const prepared = await scripEngine.prepare(id, [txSource] as any); await scripEngine.commit(prepared); } // Run scrips on target ticket if (txTarget) { const prepared = await scripEngine.prepare(targetTicketId, [txTarget] as any); await scripEngine.commit(prepared); } // Include target ticket info in response return c.json({ ...link, target_ticket: { id: target.id, subject: target.subject, status: target.status }, }, 201); }); // DELETE /:id/links/:linkId — remove a link router.delete('/:id/links/:linkId', async (c) => { const id = Number(c.req.param('id')); const linkId = c.req.param('linkId'); const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, id), }); if (!ticket) { throw new HTTPException(404, { message: 'Ticket not found' }); } await requireRight(c, db, ticket.queue_id, 'ticket.modify'); const link = await db.query.ticketLinks.findFirst({ where: eq(ticketLinks.id, linkId), }); if (!link || link.ticket_id !== id) { throw new HTTPException(404, { message: 'Link not found' }); } await db.delete(ticketLinks).where(eq(ticketLinks.id, linkId)); const target = await db.query.tickets.findFirst({ where: eq(tickets.id, link.target_ticket_id), }); const creatorId = getUserId(c); const [txSource] = await db.insert(transactions).values({ ticket_id: id, transaction_type: 'LinkDelete', field: link.link_type, old_value: String(link.target_ticket_id), new_value: null, data: { link_type: link.link_type, target_ticket_id: link.target_ticket_id, target_subject: target?.subject ?? 'unknown', }, creator_id: creatorId, }).returning(); // Run scrips on source ticket if (txSource) { const prepared = await scripEngine.prepare(id, [txSource] as any); await scripEngine.commit(prepared); } return c.json({ ok: true }); }); // POST /:id/merge — merge this ticket into another router.post('/:id/merge', async (c) => { const id = Number(c.req.param('id')); const body = await c.req.json(); const targetId = Number(body.target_ticket_id); if (!targetId || isNaN(targetId) || targetId === id) { throw new HTTPException(422, { message: 'target_ticket_id must be a different ticket ID' }); } const source = await db.query.tickets.findFirst({ where: eq(tickets.id, id) }); if (!source) throw new HTTPException(404, { message: 'Source ticket not found' }); const target = await db.query.tickets.findFirst({ where: eq(tickets.id, targetId) }); if (!target) throw new HTTPException(404, { message: 'Target ticket not found' }); await requireRight(c, db, source.queue_id, 'ticket.modify'); await requireRight(c, db, target.queue_id, 'ticket.modify'); const creatorId = getUserId(c); // Move transactions await db.update(transactions) .set({ ticket_id: targetId } as any) .where(eq(transactions.ticket_id, id)); // Move attachments const sourceTxs = await db.query.transactions.findMany({ where: eq(transactions.ticket_id, id) }); // (attachments are linked via transaction_id which stays the same, no-op) // Move custom field values const sourceCfs = await db.query.customFieldValues.findMany({ where: eq(customFieldValues.ticket_id, id), }); for (const cf of sourceCfs) { const existing = await db.query.customFieldValues.findFirst({ where: (t, { and, eq: eqFn }) => and( eqFn(t.custom_field_id, cf.custom_field_id), eqFn(t.ticket_id, targetId), eqFn(t.value, cf.value), ), }); if (existing) { await db.delete(customFieldValues).where(eq(customFieldValues.id, cf.id)); } else { await db.update(customFieldValues) .set({ ticket_id: targetId } as any) .where(eq(customFieldValues.id, cf.id)); } } // Move ticket links (update source references to target) await db.update(ticketLinks) .set({ ticket_id: targetId } as any) .where(eq(ticketLinks.ticket_id, id)); // Update links pointing TO this ticket to point to target instead await db.update(ticketLinks) .set({ target_ticket_id: targetId } as any) .where(eq(ticketLinks.target_ticket_id, id)); // Close the source ticket await db.update(tickets).set({ status: 'closed', updated_at: new Date(), } as any).where(eq(tickets.id, id)); // Create merge transactions on both tickets await db.insert(transactions).values({ ticket_id: targetId, transaction_type: 'Comment', data: { body: `Ticket ${source.id} (${source.subject}) was merged into this ticket.` }, creator_id: creatorId, }); await db.insert(transactions).values({ ticket_id: id, transaction_type: 'StatusChange', field: 'status', old_value: source.status, new_value: 'closed', data: { merged_into: targetId, body: `Merged into ticket ${targetId} (${target.subject}).` }, creator_id: creatorId, }); // Create a duplicate link await db.insert(ticketLinks).values({ ticket_id: id, target_ticket_id: targetId, link_type: 'Duplicates', creator_id: creatorId, }).onConflictDoNothing(); return c.json({ ok: true, target_id: targetId }); }); // POST /batch — bulk update tickets router.post('/batch', async (c) => { const body = await c.req.json(); const ticketIds: number[] = (body.ticket_ids ?? []).map(Number).filter((n: number) => !isNaN(n) && n > 0); const { status, owner_id, team_id } = body; if (ticketIds.length === 0) { throw new HTTPException(422, { message: 'ticket_ids is required and must be an array of ticket IDs' }); } if (ticketIds.length > 100) { throw new HTTPException(422, { message: 'Maximum 100 tickets per batch update' }); } const results: Array<{ id: number; ok: boolean; error?: string }> = []; for (const id of ticketIds) { const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, id) }); if (!ticket) { results.push({ id, ok: false, error: 'Ticket not found' }); continue; } await requireRight(c, db, ticket.queue_id, 'ticket.modify'); try { const txList: any[] = []; const updateData: Record = { updated_at: new Date() }; if (status !== undefined && status !== ticket.status) { // Validate lifecycle transition 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, status); if (!result.valid) { results.push({ id, ok: false, error: result.error ?? 'Invalid transition' }); continue; } // Dependency enforcement const inactiveStatuses = lifecycleDef.statuses.inactive; if (inactiveStatuses.includes(status)) { const dependsOnLinks = await db.query.ticketLinks.findMany({ where: (t, { and, eq: eqFn }) => and(eqFn(t.ticket_id, id), eqFn(t.link_type, 'DependsOn')), }); let blocked = false; for (const link of dependsOnLinks) { const target = await db.query.tickets.findFirst({ where: eq(tickets.id, link.target_ticket_id) }); if (target && !inactiveStatuses.includes(target.status)) { results.push({ id, ok: false, error: `Blocked by ticket ${target.id} (${target.subject}) — still ${target.status}` }); blocked = true; break; } } if (blocked) continue; } } } txList.push({ ticket_id: id, transaction_type: 'StatusChange', field: 'status', old_value: ticket.status, new_value: status, creator_id: getUserId(c), }); updateData.status = status; } if (owner_id !== undefined && owner_id !== ticket.owner_id) { txList.push({ ticket_id: id, transaction_type: 'SetOwner', field: 'owner_id', old_value: ticket.owner_id ?? null, new_value: owner_id, creator_id: getUserId(c), }); updateData.owner_id = owner_id; } if (team_id !== undefined && team_id !== (ticket as any).team_id) { txList.push({ ticket_id: id, transaction_type: 'SetTeam', field: 'team_id', old_value: (ticket as any).team_id ?? null, new_value: team_id, creator_id: getUserId(c), }); updateData.team_id = team_id; } await db.update(tickets).set(updateData as any).where(eq(tickets.id, id)); if (txList.length > 0) { await db.insert(transactions).values(txList as any); } results.push({ id, ok: true }); } catch (err) { results.push({ id, ok: false, error: err instanceof Error ? err.message : String(err) }); } } return c.json({ results }); }); // 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' }); } await requireRight(c, db, ticket.queue_id, 'ticket.modify'); 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.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` }); } } 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: getUserId(c), }).returning(); const prepared = await scripEngine.prepare(id, [tx] as any); await scripEngine.commit(prepared); return c.json(tx, 200); }); return router; }