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:
62
src/scrip/actions.ts
Normal file
62
src/scrip/actions.ts
Normal 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
41
src/scrip/conditions.ts
Normal 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
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;
|
||||
}
|
||||
}
|
||||
39
src/scrip/templates.ts
Normal file
39
src/scrip/templates.ts
Normal 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user