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>
This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-15 20:42:17 +02:00
parent 1d4dc38d06
commit 70f0924d4b
59 changed files with 21795 additions and 321 deletions

View File

@@ -1,7 +1,15 @@
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 { tickets, transactions, customFieldValues, customFields, queues, lifecycles, queueCustomFields, teamMembers } from '../db/schema.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';
@@ -22,8 +30,14 @@ export function createTicketsRouter(db: Db): Hono {
// GET / — list tickets
router.get('/', async (c) => {
const params = new URL(c.req.url).searchParams;
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');
@@ -63,17 +77,70 @@ export function createTicketsRouter(db: Db): Hono {
}
}
// Text search: push to SQL via ilike on ticket columns + queue name join
// 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),
ilike(tickets.status, pattern),
sql`${tickets.id}::text ILIKE ${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)
))
)
)!
);
// Queue name search requires join — keep as post-filter
}
// Custom field filters: use EXISTS subquery
@@ -100,19 +167,9 @@ export function createTicketsRouter(db: Db): Hono {
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())
);
}
// Attach custom field values to all tickets
if (filtered.length > 0) {
const ticketIds = filtered.map((t) => t.id);
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),
});
@@ -124,7 +181,7 @@ export function createTicketsRouter(db: Db): Hono {
: [];
const fieldMap = new Map(allFields.map((f) => [f.id, f]));
const ticketsWithCf = filtered.map((ticket) => {
const ticketsWithCf = result.map((ticket) => {
const cfs = allCfValues
.filter((v) => v.ticket_id === ticket.id)
.map((v) => ({
@@ -149,14 +206,16 @@ export function createTicketsRouter(db: Db): Hono {
return c.json(ticketsWithCf);
}
return c.json(filtered);
return c.json(result);
});
// 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';
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)
@@ -208,6 +267,21 @@ export function createTicketsRouter(db: Db): Hono {
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)) {
@@ -285,6 +359,8 @@ export function createTicketsRouter(db: Db): Hono {
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),
});
@@ -301,10 +377,32 @@ export function createTicketsRouter(db: Db): Hono {
custom_field: cfMap.get(v.custom_field_id) ?? null,
}));
return c.json({ ...ticket, custom_fields: customFieldsMapped });
});
// 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 }));
}
// PATCH /:id — update ticket
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();
@@ -318,6 +416,8 @@ export function createTicketsRouter(db: Db): Hono {
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
@@ -337,6 +437,34 @@ export function createTicketsRouter(db: Db): Hono {
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.`,
});
}
}
}
}
}
}
@@ -350,7 +478,7 @@ export function createTicketsRouter(db: Db): Hono {
field: 'subject',
old_value: ticket.subject,
new_value: parsed.subject,
creator_id: '00000000-0000-0000-0000-000000000000',
creator_id: getUserId(c),
});
}
@@ -361,7 +489,7 @@ export function createTicketsRouter(db: Db): Hono {
field: 'status',
old_value: ticket.status,
new_value: parsed.status,
creator_id: '00000000-0000-0000-0000-000000000000',
creator_id: getUserId(c),
});
}
@@ -372,7 +500,7 @@ export function createTicketsRouter(db: Db): Hono {
field: 'owner_id',
old_value: ticket.owner_id ?? null,
new_value: parsed.owner_id,
creator_id: '00000000-0000-0000-0000-000000000000',
creator_id: getUserId(c),
});
}
@@ -383,7 +511,7 @@ export function createTicketsRouter(db: Db): Hono {
field: 'team_id',
old_value: (ticket as any).team_id ?? null,
new_value: parsed.team_id,
creator_id: '00000000-0000-0000-0000-000000000000',
creator_id: getUserId(c),
});
}
@@ -425,10 +553,24 @@ export function createTicketsRouter(db: Db): Hono {
await db.insert(transactions).values(txList as any);
}
// Run scrips
const prepared = await scripEngine.prepare(id, 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 });
});
@@ -446,6 +588,8 @@ export function createTicketsRouter(db: Db): Hono {
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),
@@ -470,13 +614,13 @@ export function createTicketsRouter(db: Db): Hono {
if (parsed.status && parsed.status !== ticket.status) {
txList.push({
id: '00000000-0000-0000-0000-000000000000',
id: getUserId(c),
ticket_id: id,
transaction_type: 'StatusChange',
field: 'status',
old_value: ticket.status,
new_value: parsed.status,
creator_id: '00000000-0000-0000-0000-000000000000',
creator_id: getUserId(c),
});
}
@@ -487,7 +631,7 @@ export function createTicketsRouter(db: Db): Hono {
return c.json({ prepared_scrips: results });
});
// GET /:id/transactions — list transactions for ticket
// GET /:id/transactions — list transactions for ticket (with attachments)
router.get('/:id/transactions', async (c) => {
const id = Number(c.req.param('id'));
@@ -496,7 +640,105 @@ export function createTicketsRouter(db: Db): Hono {
orderBy: asc(transactions.created_at),
});
return c.json(result);
// 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)
@@ -513,12 +755,23 @@ export function createTicketsRouter(db: Db): Hono {
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: { body: parsed.body },
data: txData,
time_worked_minutes: timeWorked,
creator_id: parsed.creator_id,
}).returning();
@@ -526,14 +779,454 @@ export function createTicketsRouter(db: Db): Hono {
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'));
@@ -549,6 +1242,8 @@ export function createTicketsRouter(db: Db): Hono {
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),
@@ -575,6 +1270,22 @@ export function createTicketsRouter(db: Db): Hono {
}
}
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)) {
@@ -613,7 +1324,7 @@ export function createTicketsRouter(db: Db): Hono {
field: field.key,
old_value: oldValue || null,
new_value: value || null,
creator_id: '00000000-0000-0000-0000-000000000000',
creator_id: getUserId(c),
}).returning();
const prepared = await scripEngine.prepare(id, [tx] as any);