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"}
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
DATABASE_URL=postgres://tessera:password@localhost:5432/tessera
|
||||
SERVER_HOST=127.0.0.1
|
||||
SERVER_PORT=8080
|
||||
SERVER_PORT=9876
|
||||
|
||||
@@ -12,5 +12,9 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"nodemailer": "^8.0.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<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 {
|
||||
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<string, ActionExecutor> = {
|
||||
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<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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
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<string, unknown>,
|
||||
ticketId: ticketId,
|
||||
subject,
|
||||
body,
|
||||
};
|
||||
@@ -138,7 +176,7 @@ export class ScripEngine {
|
||||
return prepared;
|
||||
}
|
||||
|
||||
commit(prepared: PreparedScrip[]): ScripResult[] {
|
||||
async commit(prepared: PreparedScrip[]): Promise<ScripResult[]> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user