Files
tessera/src/scrip/engine.ts
Gjermund Høsøien Wiggen 70f0924d4b feat: auth system, scrip scheduler, UI widgets, and new API routes
- 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>
2026-06-15 20:42:17 +02:00

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;
}
}