From 1f238330c7ed3462280e6d9b116a7b13cd057113 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gjermund=20H=C3=B8s=C3=B8ien=20Wiggen?= Date: Sun, 7 Jun 2026 21:38:56 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20scrip=20engine=20implementation=20?= =?UTF-8?q?=E2=80=94=20real=20SMTP,=20webhooks,=20DB=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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"} --- .env.example | 2 +- package.json | 4 + src/config.ts | 5 ++ src/routes/tickets.ts | 4 +- src/scrip/actions.ts | 163 +++++++++++++++++++++++++++++++++------- src/scrip/conditions.ts | 29 +++++-- src/scrip/engine.ts | 58 +++++++++++--- 7 files changed, 217 insertions(+), 48 deletions(-) diff --git a/.env.example b/.env.example index cb59438..5b6202b 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,3 @@ DATABASE_URL=postgres://tessera:password@localhost:5432/tessera SERVER_HOST=127.0.0.1 -SERVER_PORT=8080 +SERVER_PORT=9876 diff --git a/package.json b/package.json index c9ca319..03debb3 100644 --- a/package.json +++ b/package.json @@ -12,5 +12,9 @@ }, "peerDependencies": { "typescript": "^5" + }, + "dependencies": { + "@types/nodemailer": "^8.0.0", + "nodemailer": "^8.0.10" } } diff --git a/src/config.ts b/src/config.ts index 9188efd..aa59af0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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); diff --git a/src/routes/tickets.ts b/src/routes/tickets.ts index 69492c0..311900e 100644 --- a/src/routes/tickets.ts +++ b/src/routes/tickets.ts @@ -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 }); }); diff --git a/src/scrip/actions.ts b/src/scrip/actions.ts index c86fb5a..b33006c 100644 --- a/src/scrip/actions.ts +++ b/src/scrip/actions.ts @@ -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; + 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'], - }); - return { success: true, message: `Email queued: "${payload.subject ?? 'No subject'}"` }; + 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 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'], - }); - return { success: true, message: `Webhook fired: ${payload.url ?? 'unknown URL'}` }; + 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; + 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, + }); + 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 }); - return { success: true, message: `Custom field ${fieldId} set to "${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 = { - SendEmail: new SendEmail(), - Webhook: new Webhook(), - SetCustomField: new SetCustomField(), -}; +export class CreateTransaction implements ActionExecutor { + constructor(private db: Db) {} -export function getActionExecutor(type: string): ActionExecutor | null { - return actionRegistry[type] ?? null; + 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 { actionRegistry }; +export function createActionRegistry(db: Db): Record { + return { + SendEmail: new SendEmail(), + Webhook: new Webhook(), + SetCustomField: new SetCustomField(db), + CreateTransaction: new CreateTransaction(db), + }; +} + +let _actionRegistry: Record | null = null; + +export function getActionRegistry(db: Db): Record { + if (!_actionRegistry) { + _actionRegistry = createActionRegistry(db); + } + return _actionRegistry; +} + +export function getActionExecutor(db: Db, type: string): ActionExecutor | null { + return getActionRegistry(db)[type] ?? null; +} diff --git a/src/scrip/conditions.ts b/src/scrip/conditions.ts index d179226..69fa639 100644 --- a/src/scrip/conditions.ts +++ b/src/scrip/conditions.ts @@ -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()); + }); } } diff --git a/src/scrip/engine.ts b/src/scrip/engine.ts index 80c40a2..3690b32 100644 --- a/src/scrip/engine.ts +++ b/src/scrip/engine.ts @@ -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 = {}; + 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, + ticketId: ticketId, subject, body, }; @@ -138,7 +176,7 @@ export class ScripEngine { return prepared; } - commit(prepared: PreparedScrip[]): ScripResult[] { + async commit(prepared: PreparedScrip[]): Promise { 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,