feat: scrip engine implementation — real SMTP, webhooks, DB actions

- config.ts: add SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM
- engine.ts: prepare() queries real scrips from DB, matches by queue_id + condition_type, loads lifecycle for OnResolve context, renders Handlebars templates, builds PreparedScrip. commit() dispatches to real action executors.
- actions.ts: SendEmail via nodemailer SMTP, Webhook via fetch POST, SetCustomField writes to custom_field_values table
- conditions.ts: OnResolve uses LifecycleValidator.isResolvedStatus()
- tickets.ts: updated to pass lifecycleDef context to scrip engine
- nodemailer installed
- Port changed to 9876 (8080 occupied by Apache)

Verification: bun run src/index.ts starts server, GET /health returns {"status":"ok","version":"0.1.0"}
This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-07 21:38:56 +02:00
parent 1136227510
commit 1f238330c7
7 changed files with 217 additions and 48 deletions

View File

@@ -1,3 +1,3 @@
DATABASE_URL=postgres://tessera:password@localhost:5432/tessera
SERVER_HOST=127.0.0.1
SERVER_PORT=8080
SERVER_PORT=9876

View File

@@ -12,5 +12,9 @@
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"@types/nodemailer": "^8.0.0",
"nodemailer": "^8.0.10"
}
}

View File

@@ -4,6 +4,11 @@ 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),
SMTP_HOST: z.string().default('localhost'),
SMTP_PORT: z.coerce.number().int().positive().default(587),
SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(),
SMTP_FROM: z.string().default('tessera@localhost'),
});
export const config = configSchema.parse(process.env);

View File

@@ -170,7 +170,7 @@ export function createTicketsRouter(db: Db): Hono {
// Run scrips
const prepared = await scripEngine.prepare(id, txList as any);
const results = scripEngine.commit(prepared);
const results = await scripEngine.commit(prepared);
return c.json({ ticket: updated, scrip_results: results });
});
@@ -205,7 +205,7 @@ export function createTicketsRouter(db: Db): Hono {
const prepared = await scripEngine.prepare(id, txList);
const preparedWithDryRun = prepared.map((p) => ({ ...p, dryRun: true }));
const results = scripEngine.commit(preparedWithDryRun);
const results = await scripEngine.commit(preparedWithDryRun);
return c.json({ prepared_scrips: results });
});

View File

@@ -1,5 +1,11 @@
import nodemailer from 'nodemailer';
import type { Transporter } from 'nodemailer';
import { config } from '../config.ts';
import type { Db } from '../db/index.ts';
import { customFieldValues, transactions } from '../db/schema.ts';
export interface ActionExecutor {
execute(payload: ActionPayload): { success: boolean; message: string };
execute(payload: ActionPayload): Promise<{ success: boolean; message: string }>;
}
export interface ActionPayload {
@@ -7,6 +13,7 @@ export interface ActionPayload {
scripName: string;
actionType: string;
actionConfig: Record<string, unknown>;
ticketId?: string;
recipients?: string[];
subject?: string;
body?: string;
@@ -18,45 +25,147 @@ export interface ActionPayload {
}
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'],
private transporter: Transporter | null = null;
private getTransporter(): Transporter {
if (!this.transporter) {
this.transporter = nodemailer.createTransport({
host: config.SMTP_HOST,
port: config.SMTP_PORT,
secure: config.SMTP_PORT === 465,
...(config.SMTP_USER ? { auth: { user: config.SMTP_USER, pass: config.SMTP_PASS } } : {}),
});
return { success: true, message: `Email queued: "${payload.subject ?? 'No subject'}"` };
}
return this.transporter;
}
async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> {
const recipients = payload.recipients ?? (payload.actionConfig['recipients'] as string[] | undefined) ?? [];
const subject = payload.subject ?? (payload.actionConfig['subject'] as string | undefined);
const body = payload.body ?? (payload.actionConfig['body'] as string | undefined);
if (recipients.length === 0) {
return { success: false, message: 'SendEmail: no recipients configured' };
}
try {
await this.getTransporter().sendMail({
from: config.SMTP_FROM,
to: recipients.join(', '),
subject: subject ?? 'No subject',
text: body ?? '',
});
return { success: true, message: `Email sent to ${recipients.join(', ')}` };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, message: `SendEmail failed: ${message}` };
}
}
}
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'],
async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> {
const url = payload.url ?? (payload.actionConfig['url'] as string | undefined);
const method = payload.method ?? (payload.actionConfig['method'] as string | undefined) ?? 'POST';
const headers = (payload.headers ?? payload.actionConfig['headers'] ?? {}) as Record<string, string>;
const body = payload.body ?? (payload.actionConfig['body'] as string | undefined);
if (!url) {
return { success: false, message: 'Webhook: no URL configured' };
}
try {
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json', ...headers },
body: body ?? undefined,
});
return { success: true, message: `Webhook fired: ${payload.url ?? 'unknown URL'}` };
if (!response.ok) {
return { success: false, message: `Webhook failed: HTTP ${response.status}` };
}
return { success: true, message: `Webhook POST to ${url} succeeded` };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, message: `Webhook failed: ${message}` };
}
}
}
export class SetCustomField implements ActionExecutor {
execute(payload: ActionPayload): { success: boolean; message: string } {
constructor(private db: Db) {}
async execute(payload: ActionPayload): Promise<{ 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 });
const ticketId = payload.ticketId ?? String(payload.actionConfig['ticket_id'] ?? '');
if (!fieldId || !value || !ticketId) {
return { success: false, message: 'SetCustomField: missing field_id, value, or ticket_id' };
}
try {
await this.db.insert(customFieldValues).values({
custom_field_id: fieldId,
ticket_id: ticketId,
value,
});
return { success: true, message: `Custom field ${fieldId} set to "${value}"` };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, message: `SetCustomField failed: ${message}` };
}
}
}
const actionRegistry: Record<string, ActionExecutor> = {
export class CreateTransaction implements ActionExecutor {
constructor(private db: Db) {}
async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> {
const ticketId = payload.ticketId ?? String(payload.actionConfig['ticket_id'] ?? '');
const transactionType = String(payload.actionConfig['transaction_type'] ?? '');
const field = payload.actionConfig['field'] as string | undefined ?? null;
const oldValue = payload.actionConfig['old_value'] as string | undefined ?? null;
const newValue = payload.actionConfig['new_value'] as string | undefined ?? null;
if (!ticketId || !transactionType) {
return { success: false, message: 'CreateTransaction: missing ticket_id or transaction_type' };
}
try {
await this.db.insert(transactions).values({
ticket_id: ticketId,
transaction_type: transactionType,
field,
old_value: oldValue,
new_value: newValue,
creator_id: '00000000-0000-0000-0000-000000000000',
});
return { success: true, message: `Transaction ${transactionType} created for ticket ${ticketId}` };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, message: `CreateTransaction failed: ${message}` };
}
}
}
export function createActionRegistry(db: Db): Record<string, ActionExecutor> {
return {
SendEmail: new SendEmail(),
Webhook: new Webhook(),
SetCustomField: new SetCustomField(),
SetCustomField: new SetCustomField(db),
CreateTransaction: new CreateTransaction(db),
};
export function getActionExecutor(type: string): ActionExecutor | null {
return actionRegistry[type] ?? null;
}
export { actionRegistry };
let _actionRegistry: Record<string, ActionExecutor> | null = null;
export function getActionRegistry(db: Db): Record<string, ActionExecutor> {
if (!_actionRegistry) {
_actionRegistry = createActionRegistry(db);
}
return _actionRegistry;
}
export function getActionExecutor(db: Db, type: string): ActionExecutor | null {
return getActionRegistry(db)[type] ?? null;
}

View File

@@ -1,8 +1,14 @@
import type { Ticket } from '../models/ticket.ts';
import type { Transaction } from '../models/transaction.ts';
import { LifecycleValidator } from '../lifecycle/validator.ts';
import type { LifecycleDefinition } from '../lifecycle/validator.ts';
export interface ConditionEvaluateContext {
lifecycleDef?: LifecycleDefinition;
}
export interface ConditionEvaluator {
evaluate(ticket: Ticket, transactions: Transaction[]): boolean;
evaluate(ticket: Ticket, transactions: Transaction[], context?: ConditionEvaluateContext): boolean;
}
export class OnCreate implements ConditionEvaluator {
@@ -18,13 +24,20 @@ export class OnStatusChange implements ConditionEvaluator {
}
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()),
);
private lifecycleValidator = new LifecycleValidator();
evaluate(_ticket: Ticket, transactions: Transaction[], context?: ConditionEvaluateContext): boolean {
const lifecycleDef = context?.lifecycleDef;
return transactions.some((tx) => {
if (tx.transaction_type !== 'StatusChange' || tx.new_value === null) return false;
if (lifecycleDef) {
return this.lifecycleValidator.isResolvedStatus(lifecycleDef, tx.new_value);
}
return ['resolved', 'closed', 'completed', 'rejected', 'cancelled'].includes(tx.new_value.toLowerCase());
});
}
}

View File

@@ -1,13 +1,15 @@
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 { tickets, queues, scrips, lifecycles, customFieldValues, customFields } from '../db/schema.ts';
import { eq, asc, inArray } from 'drizzle-orm';
import { getConditionEvaluator } from './conditions.ts';
import type { ConditionEvaluateContext } 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';
import type { LifecycleDefinition } from '../lifecycle/validator.ts';
export interface PreparedScrip {
scripId: string;
@@ -57,6 +59,44 @@ export class ScripEngine {
return true;
});
const queue = await this.db.query.queues.findFirst({
where: eq(queues.id, ticketRecord.queue_id),
});
let lifecycleDef: LifecycleDefinition | undefined;
if (queue?.lifecycle_id) {
const lifecycle = await this.db.query.lifecycles.findFirst({
where: eq(lifecycles.id, queue.lifecycle_id),
});
if (lifecycle) {
lifecycleDef = lifecycle.definition as LifecycleDefinition;
}
}
const conditionContext: ConditionEvaluateContext = {
lifecycleDef,
};
const cfValues = await this.db.query.customFieldValues.findMany({
where: eq(customFieldValues.ticket_id, ticketId),
});
const cfIds = [...new Set(cfValues.map((v) => v.custom_field_id))];
const cfRecords = cfIds.length > 0
? await this.db.query.customFields.findMany({
where: (t, { inArray }) => inArray(t.id, cfIds),
})
: [];
const cfNameById = new Map(cfRecords.map((cf) => [cf.id, cf.name]));
const customFieldsMap: Record<string, string> = {};
for (const row of cfValues) {
const name = cfNameById.get(row.custom_field_id);
if (name) {
customFieldsMap[name] = row.value;
}
}
const prepared: PreparedScrip[] = [];
for (const scrip of matchingScrips) {
@@ -66,7 +106,7 @@ export class ScripEngine {
continue;
}
if (!evaluator.evaluate(ticketRecord as unknown as Ticket, transactions)) {
if (!evaluator.evaluate(ticketRecord as unknown as Ticket, transactions, conditionContext)) {
continue;
}
@@ -79,9 +119,6 @@ export class ScripEngine {
});
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 = {
@@ -104,7 +141,7 @@ export class ScripEngine {
old_value: latestTx.old_value,
new_value: latestTx.new_value,
},
custom_fields: {},
custom_fields: customFieldsMap,
};
const rendered = this.templateRenderer.render(
@@ -122,6 +159,7 @@ export class ScripEngine {
scripName: scrip.name,
actionType: scrip.action_type,
actionConfig: scrip.action_config as Record<string, unknown>,
ticketId: ticketId,
subject,
body,
};
@@ -138,7 +176,7 @@ export class ScripEngine {
return prepared;
}
commit(prepared: PreparedScrip[]): ScripResult[] {
async commit(prepared: PreparedScrip[]): Promise<ScripResult[]> {
const results: ScripResult[] = [];
for (const p of prepared) {
@@ -151,7 +189,7 @@ export class ScripEngine {
continue;
}
const executor = getActionExecutor(p.actionType);
const executor = getActionExecutor(this.db, p.actionType);
if (!executor) {
results.push({
scripId: p.scripId,
@@ -161,7 +199,7 @@ export class ScripEngine {
continue;
}
const result = executor.execute(p.actionPayload);
const result = await executor.execute(p.actionPayload);
results.push({
scripId: p.scripId,
success: result.success,