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,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,