- 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>
1338 lines
45 KiB
TypeScript
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;
|
|
}
|