Files
tessera/src/scrip/conditions.ts
Gjermund Høsøien Wiggen 653139ad0d feat: watcher/CC system, SLA engine, and rich text comments
- Watcher system: ticket_watchers table, watch/unwatch endpoints,
  notifications to watchers on comments and updates, watcher/cc
  recipient sources in SendEmail scrip action, watch toggle and
  watcher avatars in ticket detail UI
- SLA engine: sla_policies table, SLA deadline columns on tickets,
  CRUD routes, OnSlaBreach scrip condition, scheduler SLA calculation,
  deadlines set on create/reply, cleared on resolve, SLA indicators
  on ticket list and detail, SLA Policies tab in admin
- Rich text: marked-based markdown rendering with XSS safety,
  Write/Preview toggle in comment composer, styled prose output
2026-06-15 21:40:18 +02:00

163 lines
5.7 KiB
TypeScript

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;
customFields?: Record<string, string>; // key → value map of CF values
}
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;
link_type?: unknown;
breach_type?: unknown;
}
export interface ConditionEvaluator {
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 {
evaluate(_ticket: Ticket, transactions: Transaction[]): boolean {
return transactions.some((tx) => tx.transaction_type === 'Create');
}
}
export class OnStatusChange implements ConditionEvaluator {
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, 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);
}
return ['resolved', 'closed', 'completed', 'rejected', 'cancelled'].includes(tx.new_value.toLowerCase());
});
}
}
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)
);
}
}
export class OnLinkCreate implements ConditionEvaluator {
evaluate(_ticket: Ticket, transactions: Transaction[], _context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
return transactions.some((tx) => {
if (tx.transaction_type !== 'LinkCreate') return false;
if (config?.link_type) {
const linkType = tx.field;
if (!matchesStatusFilter(linkType, config.link_type)) return false;
}
return true;
});
}
}
export class OnSlaBreach implements ConditionEvaluator {
evaluate(ticket: Ticket, _transactions: Transaction[], _context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
const breachType = config?.breach_type ?? 'any';
// Check if ticket has sla_breached set
const breached = (ticket as any).sla_breached as string | null;
if (breachType === 'any') {
return breached === 'response' || breached === 'resolution' || breached === 'both';
}
if (breachType === 'response') {
return breached === 'response' || breached === 'both';
}
if (breachType === 'resolution') {
return breached === 'resolution' || breached === 'both';
}
return false;
}
}
export class OnOverdue implements ConditionEvaluator {
evaluate(_ticket: Ticket, _transactions: Transaction[], context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
const fieldKey = config?.field_key ?? config?.field_id ?? config?.field;
if (!fieldKey) return false;
const cfValue = context?.customFields?.[String(fieldKey)];
if (!cfValue) return false;
// Parse the date value
const dueDate = new Date(cfValue);
if (isNaN(dueDate.getTime())) return false;
// Check if overdue (past due date)
if (new Date() <= dueDate) return false;
// Check that ticket is still active (not in inactive state)
const lifecycleDef = context?.lifecycleDef;
if (lifecycleDef) {
const inactiveStates = lifecycleDef.statuses.inactive;
if (inactiveStates.includes(_ticket.status)) return false;
}
return true;
}
}
const conditionRegistry: Record<string, ConditionEvaluator> = {
OnCreate: new OnCreate(),
OnStatusChange: new OnStatusChange(),
OnResolve: new OnResolve(),
OnCustomFieldChange: new OnCustomFieldChange(),
OnLinkCreate: new OnLinkCreate(),
OnOverdue: new OnOverdue(),
OnSlaBreach: new OnSlaBreach(),
};
export function getConditionEvaluator(type: string): ConditionEvaluator | null {
return conditionRegistry[type] ?? null;
}
export { conditionRegistry };