diff --git a/src/scrip/actions.ts b/src/scrip/actions.ts index d19ebba..3e2b09e 100644 --- a/src/scrip/actions.ts +++ b/src/scrip/actions.ts @@ -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; 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 { + 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(); + 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 { + 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; + 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) => { + 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) => Promise; + + 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 { 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), }; diff --git a/src/scrip/conditions.ts b/src/scrip/conditions.ts index 69fa639..eacfc28 100644 --- a/src/scrip/conditions.ts +++ b/src/scrip/conditions.ts @@ -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 = { OnCreate: new OnCreate(), OnStatusChange: new OnStatusChange(), OnResolve: new OnResolve(), + OnCustomFieldChange: new OnCustomFieldChange(), }; export function getConditionEvaluator(type: string): ConditionEvaluator | null { diff --git a/src/scrip/engine.ts b/src/scrip/engine.ts index 1962a21..6e6f910 100644 --- a/src/scrip/engine.ts +++ b/src/scrip/engine.ts @@ -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; } diff --git a/src/scrip/templates.ts b/src/scrip/templates.ts index b411a78..17b5c7e 100644 --- a/src/scrip/templates.ts +++ b/src/scrip/templates.ts @@ -17,7 +17,7 @@ export class TemplateRenderer { export interface TemplateContext { ticket: { - id: string; + id: number; subject: string; status: string; queue_id: string;