Files
tessera/src/scrip/actions.ts
Gjermund Høsøien Wiggen 1f238330c7 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"}
2026-06-07 21:38:56 +02:00

172 lines
6.1 KiB
TypeScript

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): Promise<{ success: boolean; message: string }>;
}
export interface ActionPayload {
scripId: string;
scripName: string;
actionType: string;
actionConfig: Record<string, unknown>;
ticketId?: string;
recipients?: string[];
subject?: string;
body?: string;
url?: string;
method?: string;
headers?: Record<string, string>;
field_id?: string;
value?: string;
}
export class SendEmail implements ActionExecutor {
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 {
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,
});
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 {
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'] ?? '');
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}` };
}
}
}
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(db),
CreateTransaction: new CreateTransaction(db),
};
}
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;
}