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:
174
src/scrip/engine.ts
Normal file
174
src/scrip/engine.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user