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, lifecycles, customFieldValues, customFields } from '../db/schema.ts'; import { eq, asc, inArray } from 'drizzle-orm'; import { getConditionEvaluator } 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'; import type { TemplateContext } from './templates.ts'; import type { LifecycleDefinition } from '../lifecycle/validator.ts'; export interface PreparedScrip { scripId: string; scripName: string; actionType: string; actionPayload: ActionPayload; dryRun: boolean; } export interface ScripResult { scripId: string; success: boolean; message: string; } export class ScripEngine { private db: Db; private templateRenderer: TemplateRenderer; constructor(db: Db) { this.db = db; this.templateRenderer = new TemplateRenderer(); } async prepare( ticketId: number, transactions: Transaction[], stage: 'TransactionCreate' | 'TransactionBatch' = 'TransactionCreate', ): Promise { const ticketRecord = await this.db.query.tickets.findFirst({ where: eq(tickets.id, ticketId), }); if (!ticketRecord) { return []; } const allScrips = await this.db.query.scrips.findMany({ orderBy: asc(scrips.sort_order), }); const matchingScrips = allScrips.filter((scrip) => { if (scrip.disabled) return false; if (scrip.queue_id !== null && scrip.queue_id !== ticketRecord.queue_id) return false; if (scrip.stage !== stage) return false; // Filter by applicable transaction types — if set, at least one tx must match if (scrip.applicable_trans_types) { const types = scrip.applicable_trans_types.split(',').map((t) => t.trim()).filter(Boolean); if (types.length > 0 && !types.includes('Any')) { const txTypes = new Set(transactions.map((tx) => tx.transaction_type)); if (!types.some((t) => txTypes.has(t))) return false; } } 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 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 conditionContext: ConditionEvaluateContext = { lifecycleDef, customFields: customFieldsMap, }; const prepared: PreparedScrip[] = []; for (const scrip of matchingScrips) { const evaluator = getConditionEvaluator(scrip.condition_type); if (!evaluator) { console.log(`[ScripEngine] Unknown condition type: ${scrip.condition_type}`); continue; } if (!evaluator.evaluate( ticketRecord as unknown as Ticket, transactions, conditionContext, scrip.condition_config as ConditionConfig, )) { continue; } let subject: string | undefined; let body: string | undefined; if (scrip.template_id) { const template = await this.db.query.templates.findFirst({ where: (t, { eq }) => eq(t.id, scrip.template_id!), }); if (template) { const latestTx = transactions[transactions.length - 1]!; const context: TemplateContext = { ticket: { id: ticketRecord.id, subject: ticketRecord.subject, status: ticketRecord.status, queue_id: ticketRecord.queue_id, owner_id: ticketRecord.owner_id, creator_id: ticketRecord.creator_id, created_at: ticketRecord.created_at?.toISOString() ?? new Date().toISOString(), updated_at: ticketRecord.updated_at?.toISOString() ?? new Date().toISOString(), }, queue: { name: queue?.name ?? 'unknown', }, transaction: { type: latestTx.transaction_type, field: latestTx.field, old_value: latestTx.old_value, new_value: latestTx.new_value, }, custom_fields: customFieldsMap, }; const rendered = this.templateRenderer.render( template.subject_template, template.body_template, context, ); subject = rendered.subject; body = rendered.body; } } const actionPayload: ActionPayload = { scripId: scrip.id, scripName: scrip.name, actionType: scrip.action_type, actionConfig: scrip.action_config as Record, ticketId: ticketId, subject, body, }; prepared.push({ scripId: scrip.id, scripName: scrip.name, actionType: scrip.action_type, actionPayload, dryRun: false, }); } return prepared; } async commit(prepared: PreparedScrip[]): Promise { const results: ScripResult[] = []; for (const p of prepared) { if (p.dryRun) { results.push({ scripId: p.scripId, success: true, message: `Dry run: would execute ${p.actionType}`, }); continue; } const executor = getActionExecutor(this.db, p.actionType); if (!executor) { results.push({ scripId: p.scripId, success: false, message: `Unknown action type: ${p.actionType}`, }); continue; } const result = await executor.execute(p.actionPayload); results.push({ scripId: p.scripId, success: result.success, message: result.message, }); } return results; } }