- team_id on queues table (optional, can be overridden per-ticket) - Ticket creation auto-sets team_id from the queue's default - Queue admin form has team selector (scrip flow node 03) - Queue API (POST/PATCH) accepts team_id No enforcement — just a helpful default. Teams and queues are loosely coupled, not hierarchically locked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
588 lines
19 KiB
TypeScript
588 lines
19 KiB
TypeScript
import { Hono } from 'hono';
|
|
import { HTTPException } from 'hono/http-exception';
|
|
import type { Db } from '../db/index.ts';
|
|
import { tickets, transactions, customFieldValues, customFields, queues, lifecycles, queueCustomFields, teamMembers } 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 params = new URL(c.req.url).searchParams;
|
|
const queueId = c.req.query('queue_id');
|
|
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
|
|
}
|
|
}
|
|
|
|
// Text search: push to SQL via ilike on ticket columns + queue name join
|
|
if (query) {
|
|
const pattern = `%${query}%`;
|
|
conditions.push(
|
|
or(
|
|
ilike(tickets.subject, pattern),
|
|
ilike(tickets.status, pattern),
|
|
sql`${tickets.id}::text ILIKE ${pattern}`
|
|
)!
|
|
);
|
|
// Queue name search requires join — keep as post-filter
|
|
}
|
|
|
|
// 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,
|
|
});
|
|
|
|
// 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())
|
|
);
|
|
}
|
|
|
|
return c.json(filtered);
|
|
});
|
|
|
|
// 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';
|
|
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.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' });
|
|
}
|
|
|
|
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,
|
|
}));
|
|
|
|
return c.json({ ...ticket, custom_fields: customFieldsMapped });
|
|
});
|
|
|
|
// PATCH /:id — update ticket
|
|
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' });
|
|
}
|
|
|
|
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' });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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: '00000000-0000-0000-0000-000000000000',
|
|
});
|
|
}
|
|
|
|
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: '00000000-0000-0000-0000-000000000000',
|
|
});
|
|
}
|
|
|
|
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: '00000000-0000-0000-0000-000000000000',
|
|
});
|
|
}
|
|
|
|
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: '00000000-0000-0000-0000-000000000000',
|
|
});
|
|
}
|
|
|
|
// 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
|
|
const prepared = await scripEngine.prepare(id, txList as any);
|
|
const results = await scripEngine.commit(prepared);
|
|
|
|
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' });
|
|
}
|
|
|
|
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: '00000000-0000-0000-0000-000000000000',
|
|
ticket_id: id,
|
|
transaction_type: 'StatusChange',
|
|
field: 'status',
|
|
old_value: ticket.status,
|
|
new_value: parsed.status,
|
|
creator_id: '00000000-0000-0000-0000-000000000000',
|
|
});
|
|
}
|
|
|
|
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
|
|
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),
|
|
});
|
|
|
|
return c.json(result);
|
|
});
|
|
|
|
// 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' });
|
|
}
|
|
|
|
const transactionType = parsed.internal ? 'Comment' : 'Correspond';
|
|
|
|
const [tx] = await db.insert(transactions).values({
|
|
ticket_id: id,
|
|
transaction_type: transactionType,
|
|
data: { body: parsed.body },
|
|
creator_id: parsed.creator_id,
|
|
}).returning();
|
|
|
|
if (!tx) {
|
|
throw new HTTPException(500, { message: 'Failed to create comment' });
|
|
}
|
|
|
|
// Run scrips
|
|
const txList = [tx];
|
|
const prepared = await scripEngine.prepare(id, txList as any);
|
|
await scripEngine.commit(prepared);
|
|
|
|
return c.json(tx, 201);
|
|
});
|
|
|
|
// 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' });
|
|
}
|
|
|
|
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.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: '00000000-0000-0000-0000-000000000000',
|
|
}).returning();
|
|
|
|
const prepared = await scripEngine.prepare(id, [tx] as any);
|
|
await scripEngine.commit(prepared);
|
|
|
|
return c.json(tx, 200);
|
|
});
|
|
|
|
return router;
|
|
}
|