feat: implement full scrip action engine with real executors

- SendEmail: real nodemailer transport with SMTP config, dynamic recipient resolution
  (static + ticket creator/owner lookup), Handlebars template support
- Webhook: HTTP POST/any method with configurable headers and JSON body
- FetchMetadata: external HTTP fetch, Handlebars URL/body templating,
  auto-adds result as comment/correspondence on ticket
- RunScript: arbitrary async JS execution with helpers (addComment,
  createTransaction, updateTicket, touchTicket), ticket context, and
  Drizzle ORM access
- SetCustomField: lookup by id/key/name, clear+insert value, record
  CustomFieldChange transaction
- CreateTransaction: insert arbitrary transaction record
- Add OnCustomFieldChange condition
- Pass condition_config to evaluator in engine

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-09 10:42:59 +02:00
parent 9e884546f2
commit e960df61ad
4 changed files with 371 additions and 15 deletions

View File

@@ -1,8 +1,11 @@
import nodemailer from 'nodemailer';
import type { Transporter } from 'nodemailer';
import Handlebars from 'handlebars';
import { config } from '../config.ts';
import type { Db } from '../db/index.ts';
import { customFieldValues, transactions } from '../db/schema.ts';
import * as schema from '../db/schema.ts';
import { customFieldValues, tickets, transactions, users } from '../db/schema.ts';
import { and, eq, inArray } from 'drizzle-orm';
export interface ActionExecutor {
execute(payload: ActionPayload): Promise<{ success: boolean; message: string }>;
@@ -21,12 +24,15 @@ export interface ActionPayload {
method?: string;
headers?: Record<string, string>;
field_id?: string;
field_key?: string;
value?: string;
}
export class SendEmail implements ActionExecutor {
private transporter: Transporter | null = null;
constructor(private db: Db) {}
private getTransporter(): Transporter {
if (!this.transporter) {
this.transporter = nodemailer.createTransport({
@@ -39,8 +45,55 @@ export class SendEmail implements ActionExecutor {
return this.transporter;
}
private async resolveRecipients(payload: ActionPayload): Promise<string[]> {
const configuredRecipients = payload.recipients ?? (payload.actionConfig['recipients'] as string[] | undefined) ?? [];
const recipients = new Set(configuredRecipients.filter(Boolean));
const sources = payload.actionConfig['recipient_sources'] ?? payload.actionConfig['recipient_source'];
const recipientSources = Array.isArray(sources)
? sources.map((source) => String(source))
: sources
? [String(sources)]
: [];
if (recipientSources.length === 0 || !payload.ticketId) {
return Array.from(recipients);
}
const ticket = await this.db.query.tickets.findFirst({
where: eq(tickets.id, payload.ticketId),
});
if (!ticket) {
return Array.from(recipients);
}
const userIds = new Set<string>();
for (const source of recipientSources) {
if (['requester', 'requestor', 'requestors', 'creator', 'ticket_creator'].includes(source)) {
userIds.add(ticket.creator_id);
}
if (['owner', 'ticket_owner'].includes(source) && ticket.owner_id) {
userIds.add(ticket.owner_id);
}
}
if (userIds.size === 0) {
return Array.from(recipients);
}
const rows = await this.db.query.users.findMany({
where: inArray(users.id, Array.from(userIds)),
});
for (const user of rows) {
if (user.email) recipients.add(user.email);
}
return Array.from(recipients);
}
async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> {
const recipients = payload.recipients ?? (payload.actionConfig['recipients'] as string[] | undefined) ?? [];
const recipients = await this.resolveRecipients(payload);
const subject = payload.subject ?? (payload.actionConfig['subject'] as string | undefined);
const body = payload.body ?? (payload.actionConfig['body'] as string | undefined);
@@ -91,25 +144,279 @@ export class Webhook implements ActionExecutor {
}
}
function parseResponseBody(text: string): unknown {
if (!text.trim()) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
function renderHandlebars(template: string, context: Record<string, unknown>): string {
const instance = Handlebars.create();
instance.registerHelper('json', (value: unknown) => JSON.stringify(value, null, 2));
return instance.compile(template)(context);
}
export class FetchMetadata implements ActionExecutor {
constructor(private db: Db) {}
async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> {
const ticketId = payload.ticketId ?? Number(payload.actionConfig['ticket_id'] ?? 0);
const rawUrl = String(payload.actionConfig['url'] ?? '');
const method = String(payload.actionConfig['method'] ?? 'GET').toUpperCase();
const headers = (payload.actionConfig['headers'] ?? {}) as Record<string, string>;
const requestBodyTemplate = String(payload.actionConfig['body'] ?? '');
const commentTemplate = String(
payload.actionConfig['comment_template'] ??
'External metadata\n\n{{json metadata}}',
);
const internal = payload.actionConfig['internal'] !== false;
if (!ticketId || !rawUrl) {
return { success: false, message: 'FetchMetadata: missing ticket_id or URL' };
}
try {
const ticket = await this.db.query.tickets.findFirst({
where: eq(tickets.id, ticketId),
});
if (!ticket) {
return { success: false, message: `FetchMetadata: ticket ${ticketId} not found` };
}
const baseContext = {
ticket: {
id: ticket.id,
subject: ticket.subject,
status: ticket.status,
queue_id: ticket.queue_id,
owner_id: ticket.owner_id,
creator_id: ticket.creator_id,
created_at: ticket.created_at?.toISOString(),
updated_at: ticket.updated_at?.toISOString(),
},
};
const url = renderHandlebars(rawUrl, baseContext);
const requestBody = requestBodyTemplate
? renderHandlebars(requestBodyTemplate, baseContext)
: undefined;
const response = await fetch(url, {
method,
headers: { Accept: 'application/json', ...headers },
body: ['GET', 'HEAD'].includes(method) ? undefined : requestBody,
});
const responseText = await response.text();
const metadata = parseResponseBody(responseText);
if (!response.ok) {
return { success: false, message: `FetchMetadata failed: HTTP ${response.status}` };
}
const commentBody = renderHandlebars(commentTemplate, {
...baseContext,
metadata,
response: {
status: response.status,
ok: response.ok,
body: metadata,
text: responseText,
},
});
await this.db.insert(transactions).values({
ticket_id: ticketId,
transaction_type: internal ? 'Comment' : 'Correspond',
data: {
body: commentBody,
metadata,
source_url: url,
},
creator_id: '00000000-0000-0000-0000-000000000000',
});
await this.db.update(tickets)
.set({ updated_at: new Date() } as any)
.where(eq(tickets.id, ticketId));
return { success: true, message: `Metadata fetched and added to ticket ${ticketId}` };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, message: `FetchMetadata failed: ${message}` };
}
}
}
type ScriptResult = string | { success?: boolean; message?: string } | undefined | null;
export class RunScript implements ActionExecutor {
constructor(private db: Db) {}
async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> {
const script = String(payload.actionConfig['script'] ?? payload.actionConfig['code'] ?? '');
const ticketId = payload.ticketId ?? Number(payload.actionConfig['ticket_id'] ?? 0);
if (!script.trim()) {
return { success: false, message: 'RunScript: no script configured' };
}
if (!ticketId) {
return { success: false, message: 'RunScript: missing ticket_id' };
}
try {
const ticket = await this.db.query.tickets.findFirst({
where: eq(tickets.id, ticketId),
});
if (!ticket) {
return { success: false, message: `RunScript: ticket ${ticketId} not found` };
}
const helpers = {
addComment: async (body: string, options?: { internal?: boolean; creator_id?: string }) => {
const [tx] = await this.db.insert(transactions).values({
ticket_id: ticketId,
transaction_type: options?.internal === false ? 'Correspond' : 'Comment',
data: { body },
creator_id: options?.creator_id ?? '00000000-0000-0000-0000-000000000000',
}).returning();
return tx;
},
createTransaction: async (data: {
transaction_type: string;
field?: string | null;
old_value?: string | null;
new_value?: string | null;
data?: unknown;
creator_id?: string;
}) => {
const [tx] = await this.db.insert(transactions).values({
ticket_id: ticketId,
transaction_type: data.transaction_type,
field: data.field ?? null,
old_value: data.old_value ?? null,
new_value: data.new_value ?? null,
data: data.data,
creator_id: data.creator_id ?? '00000000-0000-0000-0000-000000000000',
}).returning();
return tx;
},
updateTicket: async (data: Partial<typeof tickets.$inferInsert>) => {
const [updated] = await this.db.update(tickets)
.set({ ...data, updated_at: new Date() } as any)
.where(eq(tickets.id, ticketId))
.returning();
return updated;
},
touchTicket: async () => {
await this.db.update(tickets)
.set({ updated_at: new Date() } as any)
.where(eq(tickets.id, ticketId));
},
};
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
const fn = new AsyncFunction(
'context',
`"use strict";
const { ticket, payload, actionConfig, helpers, db, schema, orm, fetch, console } = context;
${script}`,
) as (context: Record<string, unknown>) => Promise<ScriptResult>;
const result = await fn({
ticket,
payload,
actionConfig: payload.actionConfig,
helpers,
db: this.db,
schema,
orm: { and, eq, inArray },
fetch,
console,
});
if (typeof result === 'string') {
return { success: true, message: result };
}
if (result && typeof result === 'object') {
return {
success: result.success !== false,
message: result.message ?? 'RunScript completed',
};
}
return { success: true, message: 'RunScript completed' };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, message: `RunScript 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 fieldRef =
payload.field_id ??
payload.field_key ??
String(payload.actionConfig['field_id'] ?? payload.actionConfig['field_key'] ?? payload.actionConfig['field'] ?? '');
const value = payload.value ?? String(payload.actionConfig['value'] ?? '');
const ticketId = payload.ticketId ?? Number(payload.actionConfig['ticket_id'] ?? 0);
if (!fieldId || !value || !ticketId) {
return { success: false, message: 'SetCustomField: missing field_id, value, or ticket_id' };
if (!fieldRef || !value || !ticketId) {
return { success: false, message: 'SetCustomField: missing field reference, value, or ticket_id' };
}
try {
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(fieldRef);
const field = await this.db.query.customFields.findFirst({
where: (row, { or, eq }) =>
isUuid
? or(eq(row.id, fieldRef), eq(row.key, fieldRef), eq(row.name, fieldRef))
: or(eq(row.key, fieldRef), eq(row.name, fieldRef)),
});
if (!field) {
return { success: false, message: `SetCustomField: unknown field ${fieldRef}` };
}
const existing = await this.db.query.customFieldValues.findMany({
where: and(
eq(customFieldValues.ticket_id, ticketId),
eq(customFieldValues.custom_field_id, field.id),
),
});
const oldValue = existing.map((row) => row.value).join(', ');
await this.db.delete(customFieldValues).where(and(
eq(customFieldValues.ticket_id, ticketId),
eq(customFieldValues.custom_field_id, field.id),
));
await this.db.insert(customFieldValues).values({
custom_field_id: fieldId,
custom_field_id: field.id,
ticket_id: ticketId,
value,
});
return { success: true, message: `Custom field ${fieldId} set to "${value}"` };
await this.db.update(tickets)
.set({ updated_at: new Date() } as any)
.where(eq(tickets.id, ticketId));
await this.db.insert(transactions).values({
ticket_id: ticketId,
transaction_type: 'CustomFieldChange',
field: field.key,
old_value: oldValue || null,
new_value: value,
creator_id: '00000000-0000-0000-0000-000000000000',
});
return { success: true, message: `${field.name} set to "${value}"` };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, message: `SetCustomField failed: ${message}` };
@@ -150,8 +457,10 @@ export class CreateTransaction implements ActionExecutor {
export function createActionRegistry(db: Db): Record<string, ActionExecutor> {
return {
SendEmail: new SendEmail(),
SendEmail: new SendEmail(db),
Webhook: new Webhook(),
FetchMetadata: new FetchMetadata(db),
RunScript: new RunScript(db),
SetCustomField: new SetCustomField(db),
CreateTransaction: new CreateTransaction(db),
};

View File

@@ -7,8 +7,29 @@ export interface ConditionEvaluateContext {
lifecycleDef?: LifecycleDefinition;
}
export interface ConditionConfig {
from_status?: unknown;
to_status?: unknown;
field_key?: unknown;
field_id?: unknown;
field?: unknown;
old_value?: unknown;
new_value?: unknown;
value?: unknown;
}
export interface ConditionEvaluator {
evaluate(ticket: Ticket, transactions: Transaction[], context?: ConditionEvaluateContext): boolean;
evaluate(ticket: Ticket, transactions: Transaction[], context?: ConditionEvaluateContext, config?: ConditionConfig): boolean;
}
function matchesStatusFilter(value: string | null, filter: unknown): boolean {
if (filter === undefined || filter === null || filter === '') return true;
if (value === null) return false;
const normalizedValue = value.toLowerCase();
if (Array.isArray(filter)) {
return filter.map((item) => String(item).toLowerCase()).includes(normalizedValue);
}
return normalizedValue === String(filter).toLowerCase();
}
export class OnCreate implements ConditionEvaluator {
@@ -18,19 +39,25 @@ export class OnCreate implements ConditionEvaluator {
}
export class OnStatusChange implements ConditionEvaluator {
evaluate(_ticket: Ticket, transactions: Transaction[]): boolean {
return transactions.some((tx) => tx.transaction_type === 'StatusChange');
evaluate(_ticket: Ticket, transactions: Transaction[], _context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
return transactions.some((tx) =>
tx.transaction_type === 'StatusChange' &&
matchesStatusFilter(tx.old_value, config?.from_status) &&
matchesStatusFilter(tx.new_value, config?.to_status)
);
}
}
export class OnResolve implements ConditionEvaluator {
private lifecycleValidator = new LifecycleValidator();
evaluate(_ticket: Ticket, transactions: Transaction[], context?: ConditionEvaluateContext): boolean {
evaluate(_ticket: Ticket, transactions: Transaction[], context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
const lifecycleDef = context?.lifecycleDef;
return transactions.some((tx) => {
if (tx.transaction_type !== 'StatusChange' || tx.new_value === null) return false;
if (!matchesStatusFilter(tx.old_value, config?.from_status)) return false;
if (!matchesStatusFilter(tx.new_value, config?.to_status)) return false;
if (lifecycleDef) {
return this.lifecycleValidator.isResolvedStatus(lifecycleDef, tx.new_value);
@@ -41,10 +68,25 @@ export class OnResolve implements ConditionEvaluator {
}
}
export class OnCustomFieldChange implements ConditionEvaluator {
evaluate(_ticket: Ticket, transactions: Transaction[], _context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
const fieldFilter = config?.field_key ?? config?.field_id ?? config?.field;
const newValueFilter = config?.new_value ?? config?.value;
return transactions.some((tx) =>
tx.transaction_type === 'CustomFieldChange' &&
matchesStatusFilter(tx.field, fieldFilter) &&
matchesStatusFilter(tx.old_value, config?.old_value) &&
matchesStatusFilter(tx.new_value, newValueFilter)
);
}
}
const conditionRegistry: Record<string, ConditionEvaluator> = {
OnCreate: new OnCreate(),
OnStatusChange: new OnStatusChange(),
OnResolve: new OnResolve(),
OnCustomFieldChange: new OnCustomFieldChange(),
};
export function getConditionEvaluator(type: string): ConditionEvaluator | null {

View File

@@ -4,7 +4,7 @@ import type { Transaction } from '../models/transaction.ts';
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 type { ConditionConfig, ConditionEvaluateContext } from './conditions.ts';
import { getActionExecutor } from './actions.ts';
import type { ActionPayload } from './actions.ts';
import { TemplateRenderer } from './templates.ts';
@@ -103,7 +103,12 @@ export class ScripEngine {
continue;
}
if (!evaluator.evaluate(ticketRecord as unknown as Ticket, transactions, conditionContext)) {
if (!evaluator.evaluate(
ticketRecord as unknown as Ticket,
transactions,
conditionContext,
scrip.condition_config as ConditionConfig,
)) {
continue;
}

View File

@@ -17,7 +17,7 @@ export class TemplateRenderer {
export interface TemplateContext {
ticket: {
id: string;
id: number;
subject: string;
status: string;
queue_id: string;