Files
tessera/src/routes/tickets.ts
Gjermund Høsøien Wiggen 70f0924d4b feat: auth system, scrip scheduler, UI widgets, and new API routes
- Add session-based authentication (login page, middleware, auth context)
- Add cron-like scrip scheduler for time-based conditions
- Add layout builder, scrip wizard, searchable select components
- Add trend chart widget for dashboards
- Add notifications, attachments, queue-permissions API routes
- Add seed-users script
- Update schema with 10 new migrations (0008-0017)
- Apply redesign: Linear-inspired dark theme, conversation-centric UI
- Gitignore runtime data directory

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:42:17 +02:00

1338 lines
45 KiB
TypeScript

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<typeof eq>[] = [];
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:<text>", "is:<text>", "is_not:<text>", "starts_with:<text>"
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<string, unknown> = {};
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<string, typeof attachments>();
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<string, string> = {
'.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<string, unknown> = { 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<string, unknown> = { 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;
}