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:
Gjermund Høsøien Wiggen
2026-06-07 21:21:50 +02:00
parent 7be1810162
commit 1136227510
35 changed files with 2595 additions and 0 deletions

9
src/config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { z } from 'zod/v4';
const configSchema = z.object({
DATABASE_URL: z.string().min(1),
SERVER_HOST: z.string().default('127.0.0.1'),
SERVER_PORT: z.coerce.number().int().positive().default(8080),
});
export const config = configSchema.parse(process.env);

10
src/db/index.ts Normal file
View File

@@ -0,0 +1,10 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema.ts';
export function createDb(databaseUrl: string) {
const pool = new Pool({ connectionString: databaseUrl });
return drizzle(pool, { schema });
}
export type Db = ReturnType<typeof createDb>;

24
src/db/migrate.ts Normal file
View File

@@ -0,0 +1,24 @@
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.error('DATABASE_URL is required');
process.exit(1);
}
async function main() {
const pool = new Pool({ connectionString: databaseUrl });
const db = drizzle(pool);
await migrate(db, { migrationsFolder: './drizzle/migrations' });
console.log('Migrations complete');
await pool.end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

113
src/db/schema.ts Normal file
View File

@@ -0,0 +1,113 @@
import { pgTable, uuid, text, jsonb, integer, boolean, timestamp, unique, index } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
username: text('username').notNull().unique(),
email: text('email'),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
});
export const queues = pgTable('queues', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(),
description: text('description'),
lifecycle_id: uuid('lifecycle_id').references(() => lifecycles.id),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
});
export const lifecycles = pgTable('lifecycles', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(),
definition: jsonb('definition').notNull(),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
});
export const tickets = pgTable('tickets', {
id: uuid('id').primaryKey().defaultRandom(),
subject: text('subject').notNull(),
queue_id: uuid('queue_id').notNull().references(() => queues.id),
status: text('status').notNull(),
owner_id: uuid('owner_id').references(() => users.id),
creator_id: uuid('creator_id').notNull().references(() => users.id),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
updated_at: timestamp('updated_at', { withTimezone: true }).defaultNow(),
started_at: timestamp('started_at', { withTimezone: true }),
resolved_at: timestamp('resolved_at', { withTimezone: true }),
}, (table) => ({
queueIdIdx: index('tickets_queue_id_idx').on(table.queue_id),
statusIdx: index('tickets_status_idx').on(table.status),
}));
export const transactions = pgTable('transactions', {
id: uuid('id').primaryKey().defaultRandom(),
ticket_id: uuid('ticket_id').notNull().references(() => tickets.id, { onDelete: 'cascade' }),
transaction_type: text('transaction_type').notNull(),
field: text('field'),
old_value: text('old_value'),
new_value: text('new_value'),
data: jsonb('data'),
creator_id: uuid('creator_id').notNull().references(() => users.id),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
}, (table) => ({
ticketIdIdx: index('transactions_ticket_id_idx').on(table.ticket_id),
createdAtIdx: index('transactions_created_at_idx').on(table.created_at),
}));
export const templates = pgTable('templates', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
queue_id: uuid('queue_id').references(() => queues.id),
subject_template: text('subject_template').notNull(),
body_template: text('body_template').notNull(),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
});
export const scrips = pgTable('scrips', {
id: uuid('id').primaryKey().defaultRandom(),
queue_id: uuid('queue_id').references(() => queues.id),
name: text('name').notNull(),
description: text('description'),
condition_type: text('condition_type').notNull(),
condition_config: jsonb('condition_config').notNull().default(sql`'{}'::jsonb`),
action_type: text('action_type').notNull(),
action_config: jsonb('action_config').notNull().default(sql`'{}'::jsonb`),
template_id: uuid('template_id').references(() => templates.id),
stage: text('stage').notNull().default('TransactionCreate'),
sort_order: integer('sort_order').notNull().default(0),
disabled: boolean('disabled').notNull().default(false),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
}, (table) => ({
queueIdIdx: index('scrips_queue_id_idx').on(table.queue_id),
}));
export const customFields = pgTable('custom_fields', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
field_type: text('field_type').notNull(),
values: jsonb('values'),
max_values: integer('max_values').notNull().default(1),
pattern: text('pattern'),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
});
export const queueCustomFields = pgTable('queue_custom_fields', {
id: uuid('id').primaryKey().defaultRandom(),
queue_id: uuid('queue_id').notNull().references(() => queues.id),
custom_field_id: uuid('custom_field_id').notNull().references(() => customFields.id, { onDelete: 'cascade' }),
sort_order: integer('sort_order').notNull().default(0),
}, (table) => ({
uniqueQueueCf: unique('queue_custom_fields_queue_id_custom_field_id_unique').on(table.queue_id, table.custom_field_id),
}));
export const customFieldValues = pgTable('custom_field_values', {
id: uuid('id').primaryKey().defaultRandom(),
custom_field_id: uuid('custom_field_id').notNull().references(() => customFields.id, { onDelete: 'cascade' }),
ticket_id: uuid('ticket_id').notNull().references(() => tickets.id, { onDelete: 'cascade' }),
value: text('value').notNull(),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
}, (table) => ({
uniqueCfTicketValue: unique('custom_field_values_cf_id_ticket_id_value_unique').on(table.custom_field_id, table.ticket_id, table.value),
ticketIdIdx: index('custom_field_values_ticket_id_idx').on(table.ticket_id),
cfIdIdx: index('custom_field_values_custom_field_id_idx').on(table.custom_field_id),
}));

46
src/index.ts Normal file
View File

@@ -0,0 +1,46 @@
import { Hono } from 'hono';
import { config } from './config.ts';
import { createDb } from './db/index.ts';
import type { Db } from './db/index.ts';
import { errorHandler } from './middleware/error.ts';
import { requestLogger } from './middleware/logging.ts';
import healthRouter from './routes/health.ts';
import { createTicketsRouter } from './routes/tickets.ts';
import { createQueuesRouter } from './routes/queues.ts';
import { createScripsRouter } from './routes/scrips.ts';
import { createCustomFieldsRouter } from './routes/custom-fields.ts';
import { createLifecyclesRouter } from './routes/lifecycles.ts';
let db: Db | null = null;
function getDb(): Db {
if (!db) {
db = createDb(config.DATABASE_URL);
}
return db;
}
const app = new Hono();
app.use('*', requestLogger);
app.onError(errorHandler);
app.route('/health', healthRouter);
app.route('/tickets', createTicketsRouter(getDb()));
app.route('/queues', createQueuesRouter(getDb()));
app.route('/scrips', createScripsRouter(getDb()));
app.route('/custom-fields', createCustomFieldsRouter(getDb()));
app.route('/lifecycles', createLifecyclesRouter(getDb()));
export default app;
export { app };
// Start server when run directly
if (Bun.main === import.meta.path) {
Bun.serve({
fetch: app.fetch,
port: config.SERVER_PORT,
hostname: config.SERVER_HOST,
});
console.log(`Server running at http://${config.SERVER_HOST}:${config.SERVER_PORT}`);
}

View File

@@ -0,0 +1,73 @@
export interface LifecycleDefinition {
statuses: {
initial: string[];
active: string[];
inactive: string[];
};
transitions: Record<string, string[]>;
}
export interface ValidationResult {
valid: boolean;
error?: string;
}
export class LifecycleValidator {
validateTransition(
lifecycleDef: LifecycleDefinition,
fromStatus: string,
toStatus: string,
): ValidationResult {
const allStatuses = [
...lifecycleDef.statuses.initial,
...lifecycleDef.statuses.active,
...lifecycleDef.statuses.inactive,
];
if (!allStatuses.includes(toStatus)) {
return {
valid: false,
error: `Status "${toStatus}" is not defined in the lifecycle`,
};
}
// Check for allowed transitions
const allowedTransitions = this.getAllowedTransitions(lifecycleDef, fromStatus);
if (allowedTransitions.includes(toStatus)) {
return { valid: true };
}
// Also handle wildcard "*" -> any transition
const wildcardTransitions = this.getAllowedTransitions(lifecycleDef, '*');
if (wildcardTransitions.includes(toStatus)) {
return { valid: true };
}
return {
valid: false,
error: `Transition from "${fromStatus}" to "${toStatus}" is not allowed`,
};
}
isResolvedStatus(lifecycleDef: LifecycleDefinition, status: string): boolean {
return lifecycleDef.statuses.inactive.includes(status);
}
private getAllowedTransitions(
lifecycleDef: LifecycleDefinition,
fromStatus: string,
): string[] {
// Direct transition
if (lifecycleDef.transitions[fromStatus]) {
return lifecycleDef.transitions[fromStatus]!;
}
// Wildcard transitions
if (lifecycleDef.transitions['*']) {
return lifecycleDef.transitions['*']!;
}
return [];
}
}

15
src/middleware/error.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { Context } from 'hono';
import type { ErrorHandler } from 'hono';
import { HTTPException } from 'hono/http-exception';
import pino from 'pino';
const logger = pino({ name: 'tessera' });
export const errorHandler: ErrorHandler = (err: Error, c: Context): Response => {
if (err instanceof HTTPException) {
return c.json({ error: err.message }, err.status as any);
}
logger.error({ err }, 'Unhandled error');
return c.json({ error: 'Internal server error' }, 500);
};

16
src/middleware/logging.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { MiddlewareHandler } from 'hono';
import pino from 'pino';
const logger = pino({ name: 'tessera-http' });
export const requestLogger: MiddlewareHandler = async (c, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
logger.info({
method: c.req.method,
path: c.req.path,
status: c.res.status,
ms,
});
};

View File

@@ -0,0 +1,13 @@
import type { InferSelectModel } from 'drizzle-orm';
import { customFields } from '../db/schema.ts';
export type CustomField = InferSelectModel<typeof customFields>;
export const CustomFieldType = {
SelectOne: 'SelectOne',
SelectMultiple: 'SelectMultiple',
Text: 'Text',
Date: 'Date',
} as const;
export type CustomFieldType = (typeof CustomFieldType)[keyof typeof CustomFieldType];

4
src/models/lifecycle.ts Normal file
View File

@@ -0,0 +1,4 @@
import type { InferSelectModel } from 'drizzle-orm';
import { lifecycles } from '../db/schema.ts';
export type Lifecycle = InferSelectModel<typeof lifecycles>;

11
src/models/queue.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { InferSelectModel } from 'drizzle-orm';
import { z } from 'zod/v4';
import { queues } from '../db/schema.ts';
export type Queue = InferSelectModel<typeof queues>;
export const CreateQueueSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
lifecycle_id: z.string().uuid().optional(),
});

26
src/models/scrip.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { InferSelectModel } from 'drizzle-orm';
import { z } from 'zod/v4';
import { scrips } from '../db/schema.ts';
export type Scrip = InferSelectModel<typeof scrips>;
export const ScripStage = {
TransactionCreate: 'TransactionCreate',
TransactionBatch: 'TransactionBatch',
} as const;
export type ScripStage = (typeof ScripStage)[keyof typeof ScripStage];
export const CreateScripSchema = z.object({
queue_id: z.string().uuid().nullable().optional(),
name: z.string().min(1),
description: z.string().optional(),
condition_type: z.string().min(1),
condition_config: z.record(z.string(), z.unknown()).default({}),
action_type: z.string().min(1),
action_config: z.record(z.string(), z.unknown()).default({}),
template_id: z.string().uuid().optional(),
stage: z.enum(['TransactionCreate', 'TransactionBatch']).default('TransactionCreate'),
sort_order: z.number().int().default(0),
disabled: z.boolean().default(false),
});

16
src/models/ticket.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { InferSelectModel } from 'drizzle-orm';
import { z } from 'zod/v4';
import { tickets } from '../db/schema.ts';
export type Ticket = InferSelectModel<typeof tickets>;
export const CreateTicketSchema = z.object({
subject: z.string().min(1),
queue_id: z.string().uuid(),
});
export const UpdateTicketSchema = z.object({
subject: z.string().min(1).optional(),
status: z.string().min(1).optional(),
owner_id: z.string().uuid().optional(),
});

16
src/models/transaction.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { InferSelectModel } from 'drizzle-orm';
import { transactions } from '../db/schema.ts';
export type Transaction = InferSelectModel<typeof transactions>;
export const TransactionType = {
Create: 'Create',
StatusChange: 'StatusChange',
SetOwner: 'SetOwner',
AddWatcher: 'AddWatcher',
Comment: 'Comment',
CustomField: 'CustomField',
Correspond: 'Correspond',
} as const;
export type TransactionType = (typeof TransactionType)[keyof typeof TransactionType];

4
src/models/user.ts Normal file
View File

@@ -0,0 +1,4 @@
import type { InferSelectModel } from 'drizzle-orm';
import { users } from '../db/schema.ts';
export type User = InferSelectModel<typeof users>;

View File

@@ -0,0 +1,41 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import type { Db } from '../db/index.ts';
import { customFields } from '../db/schema.ts';
import { asc } from 'drizzle-orm';
export function createCustomFieldsRouter(db: Db): Hono {
const router = new Hono();
router.get('/', async (c) => {
const result = await db.query.customFields.findMany({
orderBy: asc(customFields.name),
});
return c.json(result);
});
router.post('/', async (c) => {
const body = await c.req.json();
const { name, field_type, values, max_values, pattern } = body;
if (!name || !field_type) {
throw new HTTPException(400, { message: 'name and field_type are required' });
}
const [cf] = await db.insert(customFields).values({
name,
field_type,
values: values ?? null,
max_values: max_values ?? 1,
pattern: pattern ?? null,
}).returning();
if (!cf) {
throw new HTTPException(500, { message: 'Failed to create custom field' });
}
return c.json(cf, 201);
});
return router;
}

9
src/routes/health.ts Normal file
View File

@@ -0,0 +1,9 @@
import { Hono } from 'hono';
const app = new Hono();
app.get('/', (c) => {
return c.json({ status: 'ok', version: '0.1.0' });
});
export default app;

38
src/routes/lifecycles.ts Normal file
View File

@@ -0,0 +1,38 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import type { Db } from '../db/index.ts';
import { lifecycles } from '../db/schema.ts';
import { asc } from 'drizzle-orm';
export function createLifecyclesRouter(db: Db): Hono {
const router = new Hono();
router.get('/', async (c) => {
const result = await db.query.lifecycles.findMany({
orderBy: asc(lifecycles.name),
});
return c.json(result);
});
router.post('/', async (c) => {
const body = await c.req.json();
const { name, definition } = body;
if (!name || !definition) {
throw new HTTPException(400, { message: 'name and definition are required' });
}
const [lifecycle] = await db.insert(lifecycles).values({
name,
definition,
}).returning();
if (!lifecycle) {
throw new HTTPException(500, { message: 'Failed to create lifecycle' });
}
return c.json(lifecycle, 201);
});
return router;
}

36
src/routes/queues.ts Normal file
View File

@@ -0,0 +1,36 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import type { Db } from '../db/index.ts';
import { queues } from '../db/schema.ts';
import { asc } from 'drizzle-orm';
import { CreateQueueSchema } from '../models/queue.ts';
export function createQueuesRouter(db: Db): Hono {
const router = new Hono();
router.get('/', async (c) => {
const result = await db.query.queues.findMany({
orderBy: asc(queues.name),
});
return c.json(result);
});
router.post('/', async (c) => {
const body = await c.req.json();
const parsed = CreateQueueSchema.parse(body);
const [queue] = await db.insert(queues).values({
name: parsed.name,
description: parsed.description ?? null,
lifecycle_id: parsed.lifecycle_id ?? null,
}).returning();
if (!queue) {
throw new HTTPException(500, { message: 'Failed to create queue' });
}
return c.json(queue, 201);
});
return router;
}

89
src/routes/scrips.ts Normal file
View File

@@ -0,0 +1,89 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import type { Db } from '../db/index.ts';
import { scrips } from '../db/schema.ts';
import { eq, asc } from 'drizzle-orm';
import { CreateScripSchema } from '../models/scrip.ts';
export function createScripsRouter(db: Db): Hono {
const router = new Hono();
router.get('/', async (c) => {
const result = await db.query.scrips.findMany({
orderBy: asc(scrips.sort_order),
});
return c.json(result);
});
router.post('/', async (c) => {
const body = await c.req.json();
const parsed = CreateScripSchema.parse(body);
const [scrip] = await db.insert(scrips).values({
queue_id: parsed.queue_id ?? null,
name: parsed.name,
description: parsed.description ?? null,
condition_type: parsed.condition_type,
condition_config: parsed.condition_config,
action_type: parsed.action_type,
action_config: parsed.action_config,
template_id: parsed.template_id ?? null,
stage: parsed.stage,
sort_order: parsed.sort_order,
disabled: parsed.disabled,
}).returning();
if (!scrip) {
throw new HTTPException(500, { message: 'Failed to create scrip' });
}
return c.json(scrip, 201);
});
router.get('/:id', async (c) => {
const id = c.req.param('id');
const scrip = await db.query.scrips.findFirst({
where: eq(scrips.id, id),
});
if (!scrip) {
throw new HTTPException(404, { message: 'Scrip not found' });
}
return c.json(scrip);
});
router.patch('/:id', async (c) => {
const id = c.req.param('id');
const body = await c.req.json();
const existing = await db.query.scrips.findFirst({
where: eq(scrips.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'Scrip not found' });
}
const updateData: Record<string, unknown> = {};
if (body.name !== undefined) updateData.name = body.name;
if (body.description !== undefined) updateData.description = body.description;
if (body.condition_type !== undefined) updateData.condition_type = body.condition_type;
if (body.condition_config !== undefined) updateData.condition_config = body.condition_config;
if (body.action_type !== undefined) updateData.action_type = body.action_type;
if (body.action_config !== undefined) updateData.action_config = body.action_config;
if (body.template_id !== undefined) updateData.template_id = body.template_id;
if (body.stage !== undefined) updateData.stage = body.stage;
if (body.sort_order !== undefined) updateData.sort_order = body.sort_order;
if (body.disabled !== undefined) updateData.disabled = body.disabled;
const [updated] = await db.update(scrips)
.set(updateData as any)
.where(eq(scrips.id, id))
.returning();
return c.json(updated);
});
return router;
}

226
src/routes/tickets.ts Normal file
View 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;
}

62
src/scrip/actions.ts Normal file
View File

@@ -0,0 +1,62 @@
export interface ActionExecutor {
execute(payload: ActionPayload): { success: boolean; message: string };
}
export interface ActionPayload {
scripId: string;
scripName: string;
actionType: string;
actionConfig: Record<string, unknown>;
recipients?: string[];
subject?: string;
body?: string;
url?: string;
method?: string;
headers?: Record<string, string>;
field_id?: string;
value?: string;
}
export class SendEmail implements ActionExecutor {
execute(payload: ActionPayload): { success: boolean; message: string } {
console.log('[SendEmail] Would send email:', {
subject: payload.subject ?? payload.actionConfig['subject'],
body: payload.body ?? payload.actionConfig['body'],
recipients: payload.recipients ?? payload.actionConfig['recipients'],
});
return { success: true, message: `Email queued: "${payload.subject ?? 'No subject'}"` };
}
}
export class Webhook implements ActionExecutor {
execute(payload: ActionPayload): { success: boolean; message: string } {
console.log('[Webhook] Would fire webhook:', {
url: payload.url ?? payload.actionConfig['url'],
method: payload.method ?? payload.actionConfig['method'] ?? 'POST',
headers: payload.headers ?? payload.actionConfig['headers'],
body: payload.body ?? payload.actionConfig['body'],
});
return { success: true, message: `Webhook fired: ${payload.url ?? 'unknown URL'}` };
}
}
export class SetCustomField implements ActionExecutor {
execute(payload: ActionPayload): { success: boolean; message: string } {
const fieldId = payload.field_id ?? String(payload.actionConfig['field_id'] ?? '');
const value = payload.value ?? String(payload.actionConfig['value'] ?? '');
console.log('[SetCustomField] Would set:', { field_id: fieldId, value });
return { success: true, message: `Custom field ${fieldId} set to "${value}"` };
}
}
const actionRegistry: Record<string, ActionExecutor> = {
SendEmail: new SendEmail(),
Webhook: new Webhook(),
SetCustomField: new SetCustomField(),
};
export function getActionExecutor(type: string): ActionExecutor | null {
return actionRegistry[type] ?? null;
}
export { actionRegistry };

41
src/scrip/conditions.ts Normal file
View File

@@ -0,0 +1,41 @@
import type { Ticket } from '../models/ticket.ts';
import type { Transaction } from '../models/transaction.ts';
export interface ConditionEvaluator {
evaluate(ticket: Ticket, transactions: Transaction[]): boolean;
}
export class OnCreate implements ConditionEvaluator {
evaluate(_ticket: Ticket, transactions: Transaction[]): boolean {
return transactions.some((tx) => tx.transaction_type === 'Create');
}
}
export class OnStatusChange implements ConditionEvaluator {
evaluate(_ticket: Ticket, transactions: Transaction[]): boolean {
return transactions.some((tx) => tx.transaction_type === 'StatusChange');
}
}
export class OnResolve implements ConditionEvaluator {
evaluate(_ticket: Ticket, transactions: Transaction[]): boolean {
return transactions.some(
(tx) =>
tx.transaction_type === 'StatusChange' &&
tx.new_value !== null &&
['resolved', 'closed', 'completed', 'rejected', 'cancelled'].includes(tx.new_value.toLowerCase()),
);
}
}
const conditionRegistry: Record<string, ConditionEvaluator> = {
OnCreate: new OnCreate(),
OnStatusChange: new OnStatusChange(),
OnResolve: new OnResolve(),
};
export function getConditionEvaluator(type: string): ConditionEvaluator | null {
return conditionRegistry[type] ?? null;
}
export { conditionRegistry };

174
src/scrip/engine.ts Normal file
View File

@@ -0,0 +1,174 @@
import type { Db } from '../db/index.ts';
import type { Ticket } from '../models/ticket.ts';
import type { Transaction } from '../models/transaction.ts';
import { tickets, queues, scrips } from '../db/schema.ts';
import { eq, asc } from 'drizzle-orm';
import { getConditionEvaluator } from './conditions.ts';
import { getActionExecutor } from './actions.ts';
import type { ActionPayload } from './actions.ts';
import { TemplateRenderer } from './templates.ts';
import type { TemplateContext } from './templates.ts';
export interface PreparedScrip {
scripId: string;
scripName: string;
actionType: string;
actionPayload: ActionPayload;
dryRun: boolean;
}
export interface ScripResult {
scripId: string;
success: boolean;
message: string;
}
export class ScripEngine {
private db: Db;
private templateRenderer: TemplateRenderer;
constructor(db: Db) {
this.db = db;
this.templateRenderer = new TemplateRenderer();
}
async prepare(
ticketId: string,
transactions: Transaction[],
): Promise<PreparedScrip[]> {
const ticketRecord = await this.db.query.tickets.findFirst({
where: eq(tickets.id, ticketId),
});
if (!ticketRecord) {
return [];
}
const transactionTypes = [...new Set(transactions.map((tx) => tx.transaction_type))];
const allScrips = await this.db.query.scrips.findMany({
orderBy: asc(scrips.sort_order),
});
const matchingScrips = allScrips.filter((scrip) => {
if (scrip.disabled) return false;
if (scrip.queue_id !== null && scrip.queue_id !== ticketRecord.queue_id) return false;
if (!transactionTypes.includes(scrip.condition_type)) return false;
return true;
});
const prepared: PreparedScrip[] = [];
for (const scrip of matchingScrips) {
const evaluator = getConditionEvaluator(scrip.condition_type);
if (!evaluator) {
console.log(`[ScripEngine] Unknown condition type: ${scrip.condition_type}`);
continue;
}
if (!evaluator.evaluate(ticketRecord as unknown as Ticket, transactions)) {
continue;
}
let subject: string | undefined;
let body: string | undefined;
if (scrip.template_id) {
const template = await this.db.query.templates.findFirst({
where: (t, { eq }) => eq(t.id, scrip.template_id!),
});
if (template) {
const queue = await this.db.query.queues.findFirst({
where: eq(queues.id, ticketRecord.queue_id),
});
const latestTx = transactions[transactions.length - 1]!;
const context: TemplateContext = {
ticket: {
id: ticketRecord.id,
subject: ticketRecord.subject,
status: ticketRecord.status,
queue_id: ticketRecord.queue_id,
owner_id: ticketRecord.owner_id,
creator_id: ticketRecord.creator_id,
created_at: ticketRecord.created_at?.toISOString() ?? new Date().toISOString(),
updated_at: ticketRecord.updated_at?.toISOString() ?? new Date().toISOString(),
},
queue: {
name: queue?.name ?? 'unknown',
},
transaction: {
type: latestTx.transaction_type,
field: latestTx.field,
old_value: latestTx.old_value,
new_value: latestTx.new_value,
},
custom_fields: {},
};
const rendered = this.templateRenderer.render(
template.subject_template,
template.body_template,
context,
);
subject = rendered.subject;
body = rendered.body;
}
}
const actionPayload: ActionPayload = {
scripId: scrip.id,
scripName: scrip.name,
actionType: scrip.action_type,
actionConfig: scrip.action_config as Record<string, unknown>,
subject,
body,
};
prepared.push({
scripId: scrip.id,
scripName: scrip.name,
actionType: scrip.action_type,
actionPayload,
dryRun: false,
});
}
return prepared;
}
commit(prepared: PreparedScrip[]): ScripResult[] {
const results: ScripResult[] = [];
for (const p of prepared) {
if (p.dryRun) {
results.push({
scripId: p.scripId,
success: true,
message: `Dry run: would execute ${p.actionType}`,
});
continue;
}
const executor = getActionExecutor(p.actionType);
if (!executor) {
results.push({
scripId: p.scripId,
success: false,
message: `Unknown action type: ${p.actionType}`,
});
continue;
}
const result = executor.execute(p.actionPayload);
results.push({
scripId: p.scripId,
success: result.success,
message: result.message,
});
}
return results;
}
}

39
src/scrip/templates.ts Normal file
View File

@@ -0,0 +1,39 @@
import Handlebars from 'handlebars';
export class TemplateRenderer {
render(
subjectTemplate: string,
bodyTemplate: string,
context: TemplateContext,
): { subject: string; body: string } {
const subjectCompiled = Handlebars.compile(subjectTemplate);
const bodyCompiled = Handlebars.compile(bodyTemplate);
return {
subject: subjectCompiled(context),
body: bodyCompiled(context),
};
}
}
export interface TemplateContext {
ticket: {
id: string;
subject: string;
status: string;
queue_id: string;
owner_id: string | null;
creator_id: string;
created_at: string;
updated_at: string;
};
queue: {
name: string;
};
transaction: {
type: string;
field: string | null;
old_value: string | null;
new_value: string | null;
};
custom_fields: Record<string, string>;
}