TypeScript/Bun project scaffold
- Stack: Bun, Hono, Drizzle ORM, Zod, Handlebars, Pino - Models: ticket, queue, transaction, scrip, template, custom_field, user, lifecycle - Scrip engine: prepare/commit two-phase dispatch, template rendering, mock actions - Lifecycle validator: state machine transition validation with wildcard support - Routes: health, tickets (full CRUD + preview + transactions), queues, scrips, custom-fields, lifecycles - Middleware: Pino logging, error handler - Database: Drizzle ORM schema + initial migration (10 tables) - Type-check: passes (tsc --noEmit, zero errors)
This commit is contained in:
226
src/routes/tickets.ts
Normal file
226
src/routes/tickets.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { tickets, transactions, customFieldValues, customFields, queues, lifecycles } from '../db/schema.ts';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { CreateTicketSchema, UpdateTicketSchema } 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();
|
||||
|
||||
// GET / — list tickets
|
||||
router.get('/', async (c) => {
|
||||
const queueId = c.req.query('queue_id');
|
||||
const status = c.req.query('status');
|
||||
|
||||
const result = await db.query.tickets.findMany({
|
||||
where: (t, { and, eq }) => {
|
||||
const conditions = [];
|
||||
if (queueId) conditions.push(eq(t.queue_id, queueId));
|
||||
if (status) conditions.push(eq(t.status, status));
|
||||
return conditions.length > 0 ? and(...conditions) : undefined;
|
||||
},
|
||||
orderBy: asc(tickets.created_at),
|
||||
});
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// POST / — create ticket
|
||||
router.post('/', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const parsed = CreateTicketSchema.parse(body);
|
||||
|
||||
const [ticket] = await db.insert(tickets).values({
|
||||
subject: parsed.subject,
|
||||
queue_id: parsed.queue_id,
|
||||
status: 'new',
|
||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
||||
}).returning();
|
||||
|
||||
if (!ticket) {
|
||||
throw new HTTPException(500, { message: 'Failed to create ticket' });
|
||||
}
|
||||
|
||||
// Record transaction
|
||||
await db.insert(transactions).values({
|
||||
ticket_id: ticket.id,
|
||||
transaction_type: 'Create',
|
||||
field: 'status',
|
||||
new_value: 'new',
|
||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
||||
});
|
||||
|
||||
return c.json(ticket, 201);
|
||||
});
|
||||
|
||||
// GET /:id — get ticket with custom field values
|
||||
router.get('/:id', async (c) => {
|
||||
const id = 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),
|
||||
with: {
|
||||
customField: true,
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({ ...ticket, custom_fields: cfValues });
|
||||
});
|
||||
|
||||
// PATCH /:id — update ticket
|
||||
router.patch('/:id', async (c) => {
|
||||
const id = 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' });
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const def = lifecycle.definition as LifecycleDefinition;
|
||||
const result = lifecycleValidator.validateTransition(def, 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 && 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',
|
||||
});
|
||||
}
|
||||
|
||||
// Update the ticket
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (parsed.subject) updateData.subject = parsed.subject;
|
||||
if (parsed.status) updateData.status = parsed.status;
|
||||
if (parsed.owner_id) updateData.owner_id = parsed.owner_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 = 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 = 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' });
|
||||
}
|
||||
|
||||
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 = scripEngine.commit(preparedWithDryRun);
|
||||
|
||||
return c.json({ prepared_scrips: results });
|
||||
});
|
||||
|
||||
// GET /:id/transactions — list transactions for ticket
|
||||
router.get('/:id/transactions', async (c) => {
|
||||
const id = 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);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
Reference in New Issue
Block a user