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

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