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

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;
}
}