- Add session-based authentication (login page, middleware, auth context) - Add cron-like scrip scheduler for time-based conditions - Add layout builder, scrip wizard, searchable select components - Add trend chart widget for dashboards - Add notifications, attachments, queue-permissions API routes - Add seed-users script - Update schema with 10 new migrations (0008-0017) - Apply redesign: Linear-inspired dark theme, conversation-centric UI - Gitignore runtime data directory Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
226 lines
6.7 KiB
TypeScript
226 lines
6.7 KiB
TypeScript
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<PreparedScrip[]> {
|
|
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<string, string> = {};
|
|
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<string, unknown>,
|
|
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<ScripResult[]> {
|
|
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;
|
|
}
|
|
}
|