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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user