954 lines
31 KiB
TypeScript
954 lines
31 KiB
TypeScript
/**
|
|
* Workflow Engine
|
|
* Orchestrates the full ticket triage pipeline:
|
|
* 1. Check if enabled
|
|
* 2. Run exclusion filter rules
|
|
* 3. Run robotic classification
|
|
* 4. Validate classification
|
|
* 5. If needed → AI enhancement
|
|
* 6. Re-validate after AI
|
|
* 7. Delay (configurable)
|
|
* 8. Write back to Autotask
|
|
* 9. Create notes if applicable
|
|
* 10. Log full execution
|
|
*/
|
|
|
|
import { postgresClient } from './postgres-client';
|
|
import { AutotaskClient } from './autotask-client';
|
|
import { roboticClassifier } from './robotic-classifier';
|
|
import { triageValidator } from './triage-validator';
|
|
import { aiTriageService } from './ai-triage-service';
|
|
import {
|
|
WorkflowEvent,
|
|
ExecutionResult,
|
|
WorkflowSettings,
|
|
TicketData,
|
|
ClassificationResult,
|
|
FieldChanges,
|
|
ExecutionStepSummary,
|
|
WorkflowRuleWithDetails,
|
|
WorkflowCondition,
|
|
StepName,
|
|
ExecutionStatus,
|
|
StepMethod,
|
|
ConfidenceLevel,
|
|
Branch,
|
|
ClassificationMethod,
|
|
} from '../types/workflow';
|
|
|
|
export class WorkflowEngine {
|
|
private _autotaskClient: AutotaskClient | null = null;
|
|
|
|
private getAutotaskClient(): AutotaskClient {
|
|
if (!this._autotaskClient) {
|
|
this._autotaskClient = new AutotaskClient({
|
|
apiUrl: process.env.AUTOTASK_API_URL || '',
|
|
username: process.env.AUTOTASK_USERNAME || '',
|
|
password: process.env.AUTOTASK_SECRET || '',
|
|
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
|
|
});
|
|
}
|
|
return this._autotaskClient;
|
|
}
|
|
|
|
/**
|
|
* Load workflow settings from DB.
|
|
*/
|
|
async getSettings(): Promise<WorkflowSettings> {
|
|
const result = await postgresClient.query(
|
|
`SELECT key, value FROM workflow_settings`
|
|
);
|
|
|
|
const raw: Record<string, any> = {};
|
|
for (const row of result.rows) {
|
|
try {
|
|
raw[row.key] = JSON.parse(row.value);
|
|
} catch {
|
|
raw[row.key] = row.value;
|
|
}
|
|
}
|
|
|
|
return {
|
|
workflow_engine_enabled: raw.workflow_engine_enabled ?? false,
|
|
default_ai_provider: raw.default_ai_provider ?? 'openai',
|
|
openai_api_key: raw.openai_api_key ?? '',
|
|
openai_model: raw.openai_model ?? 'gpt-4o',
|
|
anthropic_api_key: raw.anthropic_api_key ?? '',
|
|
anthropic_model: raw.anthropic_model ?? 'claude-sonnet-4-20250514',
|
|
ai_for_title_cleanup: raw.ai_for_title_cleanup ?? true,
|
|
ai_for_description_rewrite: raw.ai_for_description_rewrite ?? true,
|
|
ai_for_ambiguous_classification: raw.ai_for_ambiguous_classification ?? true,
|
|
ai_for_troubleshooting: raw.ai_for_troubleshooting ?? true,
|
|
autotask_update_delay_ms: Number(raw.autotask_update_delay_ms) || 30000,
|
|
max_ai_retries: Number(raw.max_ai_retries) || 2,
|
|
classification_confidence_threshold: raw.classification_confidence_threshold ?? 'medium',
|
|
log_retention_days: Number(raw.log_retention_days) || 90,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Main entry point: process a workflow event.
|
|
*/
|
|
async process(event: WorkflowEvent): Promise<ExecutionResult> {
|
|
const startTime = Date.now();
|
|
const steps: ExecutionStepSummary[] = [];
|
|
const fieldChanges: FieldChanges = {};
|
|
let executionId: number = 0;
|
|
let classificationMethod: ClassificationMethod = 'robotic';
|
|
let branch: Branch = 'service_desk';
|
|
|
|
try {
|
|
// 1. Check if engine is enabled
|
|
const settings = await this.getSettings();
|
|
if (!settings.workflow_engine_enabled) {
|
|
console.log('[WORKFLOW] Engine is disabled, skipping');
|
|
return this.buildSkippedResult(0, 'Engine disabled');
|
|
}
|
|
|
|
// Create execution record
|
|
executionId = await this.createExecution(event);
|
|
|
|
// 2. Load ticket data
|
|
const ticket = event.ticket_data || await this.loadTicketData(event.entity_id);
|
|
if (!ticket) {
|
|
await this.updateExecution(executionId, 'failed', null, null, 'Ticket not found');
|
|
return this.buildSkippedResult(executionId, 'Ticket not found');
|
|
}
|
|
|
|
// 3. Run exclusion filter rules
|
|
const filterStep = await this.runFilterRules(ticket, event.trigger_event, executionId);
|
|
steps.push(filterStep);
|
|
if (filterStep.status === 'completed' && filterStep.method === 'skipped') {
|
|
await this.updateExecution(executionId, 'skipped', null, null);
|
|
return {
|
|
execution_id: executionId,
|
|
status: 'skipped',
|
|
classification_method: 'robotic',
|
|
branch: 'service_desk',
|
|
field_changes: {},
|
|
steps,
|
|
duration_ms: Date.now() - startTime,
|
|
};
|
|
}
|
|
|
|
// 4. Run robotic classification
|
|
const classifyStart = Date.now();
|
|
const classification = await roboticClassifier.classify(ticket);
|
|
branch = (classification.branch?.value as Branch) || 'service_desk';
|
|
|
|
// Log classification steps
|
|
const classificationSteps = this.buildClassificationSteps(classification, executionId, classifyStart);
|
|
steps.push(...classificationSteps);
|
|
|
|
// 5. Validate classification
|
|
const validationStep = await this.runValidation(classification, executionId);
|
|
steps.push(validationStep);
|
|
|
|
// 6. AI Enhancement (if needed)
|
|
let finalClassification = classification;
|
|
const confidenceThreshold = settings.classification_confidence_threshold;
|
|
|
|
if (
|
|
classification.needs_ai &&
|
|
settings.ai_for_ambiguous_classification &&
|
|
this.shouldUseAi(classification.overall_confidence, confidenceThreshold)
|
|
) {
|
|
classificationMethod = 'hybrid';
|
|
|
|
// AI for ambiguous classification
|
|
const failedFields = classification.ai_reasons
|
|
.filter(r => r.includes('No ') && r.includes('matched'))
|
|
.map(r => {
|
|
if (r.includes('ticket type')) return 'ticket_type';
|
|
if (r.includes('issue type')) return 'issue_type';
|
|
if (r.includes('priority')) return 'priority';
|
|
return '';
|
|
})
|
|
.filter(Boolean);
|
|
|
|
if (failedFields.length > 0) {
|
|
const aiClassifyStep = await this.runAiClassification(
|
|
ticket, failedFields, finalClassification, settings, executionId
|
|
);
|
|
steps.push(aiClassifyStep);
|
|
}
|
|
}
|
|
|
|
// AI for title cleanup
|
|
if (
|
|
settings.ai_for_title_cleanup &&
|
|
classification.ai_reasons.some(r => r.includes('Title'))
|
|
) {
|
|
classificationMethod = classificationMethod === 'robotic' ? 'hybrid' : classificationMethod;
|
|
const titleStep = await this.runAiTitleCleanup(ticket, settings, executionId);
|
|
steps.push(titleStep);
|
|
if (titleStep.status === 'completed' && (titleStep as any)._newTitle) {
|
|
fieldChanges['title'] = { before: ticket.title, after: (titleStep as any)._newTitle };
|
|
}
|
|
}
|
|
|
|
// 7. Build field changes for write-back
|
|
this.buildFieldChanges(ticket, finalClassification, fieldChanges);
|
|
|
|
// 8. Delay before Autotask write-back
|
|
if (Object.keys(fieldChanges).length > 0 && settings.autotask_update_delay_ms > 0) {
|
|
await this.delay(settings.autotask_update_delay_ms);
|
|
}
|
|
|
|
// 9. Write back to Autotask
|
|
if (Object.keys(fieldChanges).length > 0) {
|
|
const updateStep = await this.writeBackToAutotask(
|
|
ticket.id, fieldChanges, executionId
|
|
);
|
|
steps.push(updateStep);
|
|
}
|
|
|
|
// 10. Create troubleshooting note for incidents
|
|
const ticketTypeValue = finalClassification.ticket_type?.value ?? ticket.ticket_type;
|
|
if (
|
|
Number(ticketTypeValue) === 2 &&
|
|
settings.ai_for_troubleshooting
|
|
) {
|
|
classificationMethod = classificationMethod === 'robotic' ? 'hybrid' : classificationMethod;
|
|
const noteStep = await this.runAiTroubleshooting(ticket, settings, executionId);
|
|
steps.push(noteStep);
|
|
}
|
|
|
|
// 11. Update execution record
|
|
const duration = Date.now() - startTime;
|
|
await this.updateExecution(executionId, 'completed', classificationMethod, branch);
|
|
|
|
console.log(
|
|
`[WORKFLOW] Completed processing ticket #${ticket.ticket_number} ` +
|
|
`(${classificationMethod}, ${branch}) in ${duration}ms`
|
|
);
|
|
|
|
return {
|
|
execution_id: executionId,
|
|
status: 'completed',
|
|
classification_method: classificationMethod,
|
|
branch,
|
|
field_changes: fieldChanges,
|
|
steps,
|
|
duration_ms: duration,
|
|
};
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
console.error('[WORKFLOW] Pipeline error:', errorMessage);
|
|
|
|
if (executionId) {
|
|
await this.updateExecution(executionId, 'failed', classificationMethod, branch, errorMessage);
|
|
}
|
|
|
|
return {
|
|
execution_id: executionId,
|
|
status: 'failed',
|
|
classification_method: classificationMethod,
|
|
branch,
|
|
field_changes: fieldChanges,
|
|
steps,
|
|
duration_ms: Date.now() - startTime,
|
|
error: errorMessage,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Dry-run: process a ticket without writing back to Autotask.
|
|
*/
|
|
async dryRun(ticketId: number): Promise<ExecutionResult> {
|
|
const settings = await this.getSettings();
|
|
const ticket = await this.loadTicketData(ticketId);
|
|
if (!ticket) {
|
|
return this.buildSkippedResult(0, 'Ticket not found');
|
|
}
|
|
|
|
const startTime = Date.now();
|
|
const steps: ExecutionStepSummary[] = [];
|
|
|
|
// Run filter rules
|
|
const filterStep = await this.runFilterRules(ticket, 'ticket.created', 0);
|
|
steps.push(filterStep);
|
|
|
|
// Run classification
|
|
const classification = await roboticClassifier.classify(ticket);
|
|
const branch = (classification.branch?.value as Branch) || 'service_desk';
|
|
const classificationSteps = this.buildClassificationSteps(classification, 0, Date.now());
|
|
steps.push(...classificationSteps);
|
|
|
|
// Run validation
|
|
const validation = await triageValidator.validate(classification);
|
|
steps.push({
|
|
step_name: 'validation',
|
|
status: validation.is_valid ? 'completed' : 'failed',
|
|
method: 'robotic',
|
|
duration_ms: 0,
|
|
});
|
|
|
|
// Build proposed field changes
|
|
const fieldChanges: FieldChanges = {};
|
|
this.buildFieldChanges(ticket, classification, fieldChanges);
|
|
|
|
return {
|
|
execution_id: 0,
|
|
status: filterStep.method === 'skipped' ? 'skipped' : 'completed',
|
|
classification_method: 'robotic',
|
|
branch,
|
|
field_changes: fieldChanges,
|
|
steps,
|
|
duration_ms: Date.now() - startTime,
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// Pipeline Steps
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Run workflow filter rules (exclusion/inclusion).
|
|
*/
|
|
private async runFilterRules(
|
|
ticket: TicketData,
|
|
triggerEvent: string,
|
|
executionId: number
|
|
): Promise<ExecutionStepSummary> {
|
|
const stepStart = Date.now();
|
|
|
|
try {
|
|
const rules = await this.loadWorkflowRules(triggerEvent);
|
|
|
|
for (const rule of rules) {
|
|
if (this.evaluateConditions(rule.conditions, ticket)) {
|
|
// Check if any action is 'skip'
|
|
const skipAction = rule.actions.find(a => a.action_type === 'skip');
|
|
if (skipAction) {
|
|
const reason = skipAction.config?.reason || rule.name;
|
|
console.log(`[WORKFLOW] Ticket #${ticket.ticket_number} skipped: ${reason}`);
|
|
|
|
if (executionId > 0) {
|
|
await this.logStep(executionId, 'filter', 1, 'completed', 'skipped', {
|
|
rule_name: rule.name,
|
|
reason,
|
|
});
|
|
}
|
|
|
|
return {
|
|
step_name: 'filter',
|
|
status: 'completed',
|
|
method: 'skipped',
|
|
matched_rule: rule.name,
|
|
duration_ms: Date.now() - stepStart,
|
|
};
|
|
}
|
|
|
|
if (rule.stop_processing) break;
|
|
}
|
|
}
|
|
|
|
if (executionId > 0) {
|
|
await this.logStep(executionId, 'filter', 1, 'completed', 'robotic', {
|
|
result: 'passed_all_filters',
|
|
});
|
|
}
|
|
|
|
return {
|
|
step_name: 'filter',
|
|
status: 'completed',
|
|
method: 'robotic',
|
|
duration_ms: Date.now() - stepStart,
|
|
};
|
|
} catch (error) {
|
|
console.error('[WORKFLOW] Filter rules error:', error);
|
|
return {
|
|
step_name: 'filter',
|
|
status: 'failed',
|
|
method: 'robotic',
|
|
duration_ms: Date.now() - stepStart,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build step summaries from classification result.
|
|
*/
|
|
private buildClassificationSteps(
|
|
classification: ClassificationResult,
|
|
executionId: number,
|
|
startTime: number
|
|
): ExecutionStepSummary[] {
|
|
const steps: ExecutionStepSummary[] = [];
|
|
const entries: [StepName, any][] = [
|
|
['branch_routing', classification.branch],
|
|
['ticket_type', classification.ticket_type],
|
|
['issue_classification', classification.issue_classification],
|
|
['priority', classification.priority],
|
|
['queue_routing', classification.queue],
|
|
];
|
|
|
|
let order = 2;
|
|
for (const [stepName, result] of entries) {
|
|
steps.push({
|
|
step_name: stepName,
|
|
status: result ? 'completed' : 'completed',
|
|
method: result?.method || 'robotic',
|
|
confidence: result?.confidence,
|
|
matched_rule: result?.matched_rule_name || (result ? undefined : 'No match'),
|
|
duration_ms: 0,
|
|
});
|
|
|
|
if (executionId > 0 && result) {
|
|
this.logStep(executionId, stepName, order, 'completed', result.method, {
|
|
field: result.field,
|
|
value: result.value,
|
|
confidence: result.confidence,
|
|
matched_rule: result.matched_rule_name,
|
|
}, result.matched_rule_id).catch(err =>
|
|
console.error(`[WORKFLOW] Failed to log step ${stepName}:`, err)
|
|
);
|
|
}
|
|
order++;
|
|
}
|
|
|
|
return steps;
|
|
}
|
|
|
|
/**
|
|
* Run validation step.
|
|
*/
|
|
private async runValidation(
|
|
classification: ClassificationResult,
|
|
executionId: number
|
|
): Promise<ExecutionStepSummary> {
|
|
const stepStart = Date.now();
|
|
const validation = await triageValidator.validate(classification);
|
|
|
|
if (executionId > 0) {
|
|
await this.logStep(executionId, 'validation', 7, validation.is_valid ? 'completed' : 'failed', 'robotic', {
|
|
is_valid: validation.is_valid,
|
|
errors: validation.errors,
|
|
});
|
|
}
|
|
|
|
return {
|
|
step_name: 'validation',
|
|
status: validation.is_valid ? 'completed' : 'failed',
|
|
method: 'robotic',
|
|
duration_ms: Date.now() - stepStart,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Run AI classification for ambiguous fields.
|
|
*/
|
|
private async runAiClassification(
|
|
ticket: TicketData,
|
|
failedFields: string[],
|
|
classification: ClassificationResult,
|
|
settings: WorkflowSettings,
|
|
executionId: number
|
|
): Promise<ExecutionStepSummary> {
|
|
const stepStart = Date.now();
|
|
|
|
try {
|
|
const aiResult = await aiTriageService.classifyAmbiguous(ticket, failedFields, settings);
|
|
|
|
// Merge AI results into classification
|
|
if (aiResult.classification) {
|
|
if (aiResult.classification.issue_type != null && !classification.issue_classification) {
|
|
classification.issue_classification = {
|
|
field: 'issue_type',
|
|
value: aiResult.classification.issue_type,
|
|
field_2: 'sub_issue_type',
|
|
value_2: aiResult.classification.sub_issue_type,
|
|
confidence: 'medium',
|
|
matched_rule_id: null,
|
|
matched_rule_name: 'AI classification',
|
|
method: 'ai',
|
|
};
|
|
}
|
|
if (aiResult.classification.ticket_type != null && !classification.ticket_type) {
|
|
classification.ticket_type = {
|
|
field: 'ticket_type',
|
|
value: aiResult.classification.ticket_type,
|
|
confidence: 'medium',
|
|
matched_rule_id: null,
|
|
matched_rule_name: 'AI classification',
|
|
method: 'ai',
|
|
};
|
|
}
|
|
if (aiResult.classification.priority != null && !classification.priority) {
|
|
classification.priority = {
|
|
field: 'priority',
|
|
value: aiResult.classification.priority,
|
|
confidence: 'medium',
|
|
matched_rule_id: null,
|
|
matched_rule_name: 'AI classification',
|
|
method: 'ai',
|
|
};
|
|
}
|
|
}
|
|
|
|
if (executionId > 0) {
|
|
await this.logStep(executionId, 'ai_classification', 8, 'completed', 'ai', {
|
|
failed_fields: failedFields,
|
|
ai_result: aiResult.classification,
|
|
});
|
|
}
|
|
|
|
return {
|
|
step_name: 'ai_classification',
|
|
status: 'completed',
|
|
method: 'ai',
|
|
duration_ms: Date.now() - stepStart,
|
|
};
|
|
} catch (error) {
|
|
console.error('[WORKFLOW] AI classification error:', error);
|
|
|
|
if (executionId > 0) {
|
|
await this.logStep(executionId, 'ai_classification', 8, 'failed', 'ai', {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
|
|
return {
|
|
step_name: 'ai_classification',
|
|
status: 'failed',
|
|
method: 'ai',
|
|
duration_ms: Date.now() - stepStart,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run AI title cleanup.
|
|
*/
|
|
private async runAiTitleCleanup(
|
|
ticket: TicketData,
|
|
settings: WorkflowSettings,
|
|
executionId: number
|
|
): Promise<ExecutionStepSummary & { _newTitle?: string }> {
|
|
const stepStart = Date.now();
|
|
|
|
try {
|
|
const newTitle = await aiTriageService.cleanupTitle(ticket, settings);
|
|
|
|
if (executionId > 0) {
|
|
await this.logStep(executionId, 'ai_title', 9, 'completed', 'ai', {
|
|
original: ticket.title,
|
|
cleaned: newTitle,
|
|
});
|
|
}
|
|
|
|
return {
|
|
step_name: 'ai_title',
|
|
status: 'completed',
|
|
method: 'ai',
|
|
duration_ms: Date.now() - stepStart,
|
|
_newTitle: newTitle,
|
|
};
|
|
} catch (error) {
|
|
console.error('[WORKFLOW] AI title cleanup error:', error);
|
|
return {
|
|
step_name: 'ai_title',
|
|
status: 'failed',
|
|
method: 'ai',
|
|
duration_ms: Date.now() - stepStart,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run AI troubleshooting steps generation and create a ticket note.
|
|
*/
|
|
private async runAiTroubleshooting(
|
|
ticket: TicketData,
|
|
settings: WorkflowSettings,
|
|
executionId: number
|
|
): Promise<ExecutionStepSummary> {
|
|
const stepStart = Date.now();
|
|
|
|
try {
|
|
const steps = await aiTriageService.generateTroubleshootingSteps(ticket, settings);
|
|
if (!steps) {
|
|
return {
|
|
step_name: 'ai_troubleshooting',
|
|
status: 'completed',
|
|
method: 'skipped',
|
|
duration_ms: Date.now() - stepStart,
|
|
};
|
|
}
|
|
|
|
// Create ticket note in Autotask
|
|
const client = this.getAutotaskClient();
|
|
await client.createEntity('TicketNotes', {
|
|
ticketID: ticket.id,
|
|
title: 'Troubleshooting Steps (Auto-Generated)',
|
|
description: steps,
|
|
noteType: 1, // Internal
|
|
publish: 1,
|
|
});
|
|
|
|
if (executionId > 0) {
|
|
await this.logStep(executionId, 'create_note', 12, 'completed', 'ai', {
|
|
note_type: 'troubleshooting_steps',
|
|
content_length: steps.length,
|
|
});
|
|
}
|
|
|
|
return {
|
|
step_name: 'create_note',
|
|
status: 'completed',
|
|
method: 'ai',
|
|
duration_ms: Date.now() - stepStart,
|
|
};
|
|
} catch (error) {
|
|
console.error('[WORKFLOW] Troubleshooting note error:', error);
|
|
return {
|
|
step_name: 'create_note',
|
|
status: 'failed',
|
|
method: 'ai',
|
|
duration_ms: Date.now() - stepStart,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write classified fields back to Autotask.
|
|
*/
|
|
private async writeBackToAutotask(
|
|
ticketId: number,
|
|
fieldChanges: FieldChanges,
|
|
executionId: number
|
|
): Promise<ExecutionStepSummary> {
|
|
const stepStart = Date.now();
|
|
|
|
try {
|
|
// Build Autotask update payload (camelCase field names)
|
|
const updatePayload: Record<string, any> = { id: ticketId };
|
|
const fieldMap: Record<string, string> = {
|
|
ticket_type: 'ticketType',
|
|
issue_type: 'issueType',
|
|
sub_issue_type: 'subIssueType',
|
|
priority: 'priority',
|
|
queue_id: 'queueID',
|
|
title: 'title',
|
|
};
|
|
|
|
for (const [field, change] of Object.entries(fieldChanges)) {
|
|
const autotaskField = fieldMap[field] || field;
|
|
updatePayload[autotaskField] = change.after;
|
|
}
|
|
|
|
const client = this.getAutotaskClient();
|
|
await client.updateTicket(ticketId, updatePayload);
|
|
|
|
// Also update local DB
|
|
const dbUpdates: Record<string, any> = {};
|
|
for (const [field, change] of Object.entries(fieldChanges)) {
|
|
dbUpdates[field] = change.after;
|
|
}
|
|
if (Object.keys(dbUpdates).length > 0) {
|
|
const setClauses = Object.keys(dbUpdates).map((k, i) => `${k} = $${i + 2}`);
|
|
await postgresClient.query(
|
|
`UPDATE tickets SET ${setClauses.join(', ')}, updated_at = NOW() WHERE id = $1`,
|
|
[ticketId, ...Object.values(dbUpdates)]
|
|
);
|
|
}
|
|
|
|
if (executionId > 0) {
|
|
await this.logStep(executionId, 'autotask_update', 11, 'completed', 'robotic', {
|
|
fields_updated: Object.keys(fieldChanges),
|
|
});
|
|
}
|
|
|
|
console.log(`[WORKFLOW] Updated ticket ${ticketId} in Autotask: ${Object.keys(fieldChanges).join(', ')}`);
|
|
|
|
return {
|
|
step_name: 'autotask_update',
|
|
status: 'completed',
|
|
method: 'robotic',
|
|
duration_ms: Date.now() - stepStart,
|
|
};
|
|
} catch (error) {
|
|
console.error('[WORKFLOW] Autotask update error:', error);
|
|
|
|
if (executionId > 0) {
|
|
await this.logStep(executionId, 'autotask_update', 11, 'failed', 'robotic', {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
|
|
return {
|
|
step_name: 'autotask_update',
|
|
status: 'failed',
|
|
method: 'robotic',
|
|
duration_ms: Date.now() - stepStart,
|
|
};
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helpers
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Load ticket data from DB.
|
|
*/
|
|
private async loadTicketData(ticketId: number): Promise<TicketData | null> {
|
|
const result = await postgresClient.query(
|
|
`SELECT id, ticket_number, title, description, ticket_category, ticket_type,
|
|
priority, queue_id, issue_type, sub_issue_type, company_id, contact_id,
|
|
assigned_resource_id, status, source
|
|
FROM tickets WHERE id = $1`,
|
|
[ticketId]
|
|
);
|
|
|
|
if (result.rows.length === 0) return null;
|
|
|
|
const row = result.rows[0];
|
|
return {
|
|
...row,
|
|
// Try to extract device/policy info from title/description for queue routing
|
|
device_name: this.extractDeviceName(row.title, row.description),
|
|
policy_name: this.extractPolicyName(row.description),
|
|
creator_resource_id: null, // Not stored on tickets table — comes from webhook
|
|
person_id: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extract device name patterns from text (e.g., DT-1234, LT-5678).
|
|
*/
|
|
private extractDeviceName(title: string | null, description: string | null): string | null {
|
|
const text = [title, description].filter(Boolean).join(' ');
|
|
const match = text.match(/\b(DT|LT|SP|SRV|VM)-?\d{2,}/i);
|
|
return match ? match[0] : null;
|
|
}
|
|
|
|
/**
|
|
* Extract policy name from description (e.g., "Policy: Windows Workstation").
|
|
*/
|
|
private extractPolicyName(description: string | null): string | null {
|
|
if (!description) return null;
|
|
const match = description.match(/(?:policy|monitoring policy)[:\s]+([^\n]+)/i);
|
|
return match ? match[1].trim() : null;
|
|
}
|
|
|
|
/**
|
|
* Load workflow filter rules with conditions and actions.
|
|
*/
|
|
private async loadWorkflowRules(triggerEvent: string): Promise<WorkflowRuleWithDetails[]> {
|
|
const rulesResult = await postgresClient.query(
|
|
`SELECT * FROM workflow_rules WHERE is_active = true AND trigger_event = $1 ORDER BY sort_order`,
|
|
[triggerEvent]
|
|
);
|
|
|
|
const rules: WorkflowRuleWithDetails[] = [];
|
|
for (const rule of rulesResult.rows) {
|
|
const [conditionsResult, actionsResult] = await Promise.all([
|
|
postgresClient.query(`SELECT * FROM workflow_conditions WHERE rule_id = $1`, [rule.id]),
|
|
postgresClient.query(`SELECT * FROM workflow_actions WHERE rule_id = $1 ORDER BY sort_order`, [rule.id]),
|
|
]);
|
|
|
|
rules.push({
|
|
...rule,
|
|
conditions: conditionsResult.rows,
|
|
actions: actionsResult.rows,
|
|
});
|
|
}
|
|
|
|
return rules;
|
|
}
|
|
|
|
/**
|
|
* Evaluate workflow conditions against ticket data.
|
|
* AND within condition_group, OR between groups.
|
|
*/
|
|
private evaluateConditions(conditions: WorkflowCondition[], ticket: TicketData): boolean {
|
|
if (conditions.length === 0) return true;
|
|
|
|
// Group conditions by condition_group
|
|
const groups = new Map<number, WorkflowCondition[]>();
|
|
for (const cond of conditions) {
|
|
const group = groups.get(cond.condition_group) || [];
|
|
group.push(cond);
|
|
groups.set(cond.condition_group, group);
|
|
}
|
|
|
|
// OR between groups: at least one group must pass
|
|
for (const [, groupConditions] of groups) {
|
|
const groupPasses = groupConditions.every(cond => this.evaluateCondition(cond, ticket));
|
|
if (groupPasses) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Evaluate a single workflow condition.
|
|
*/
|
|
private evaluateCondition(condition: WorkflowCondition, ticket: TicketData): boolean {
|
|
const fieldValue = this.getTicketFieldValue(condition.field, ticket);
|
|
|
|
switch (condition.operator) {
|
|
case 'equals':
|
|
return this.isEqual(fieldValue, condition.value);
|
|
case 'not_equals':
|
|
return !this.isEqual(fieldValue, condition.value);
|
|
case 'in':
|
|
return this.isIn(fieldValue, condition.value);
|
|
case 'not_in':
|
|
return !this.isIn(fieldValue, condition.value);
|
|
case 'contains':
|
|
return fieldValue != null && String(fieldValue).toLowerCase().includes(String(condition.value).toLowerCase());
|
|
case 'not_contains':
|
|
return fieldValue == null || !String(fieldValue).toLowerCase().includes(String(condition.value).toLowerCase());
|
|
case 'regex':
|
|
try {
|
|
return fieldValue != null && new RegExp(String(condition.value), 'i').test(String(fieldValue));
|
|
} catch { return false; }
|
|
case 'gt':
|
|
return fieldValue != null && Number(fieldValue) > Number(condition.value);
|
|
case 'lt':
|
|
return fieldValue != null && Number(fieldValue) < Number(condition.value);
|
|
case 'is_null':
|
|
return fieldValue == null;
|
|
case 'is_not_null':
|
|
return fieldValue != null;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private getTicketFieldValue(field: string, ticket: TicketData): any {
|
|
return (ticket as any)[field] ?? null;
|
|
}
|
|
|
|
private isEqual(a: any, b: any): boolean {
|
|
if (a == null && b == null) return true;
|
|
if (a == null || b == null) return false;
|
|
return String(a) === String(b);
|
|
}
|
|
|
|
private isIn(value: any, list: any): boolean {
|
|
if (value == null) return false;
|
|
const arr = Array.isArray(list) ? list : [list];
|
|
return arr.some(v => String(v) === String(value));
|
|
}
|
|
|
|
/**
|
|
* Build field changes from classification result.
|
|
*/
|
|
private buildFieldChanges(
|
|
ticket: TicketData,
|
|
classification: ClassificationResult,
|
|
fieldChanges: FieldChanges
|
|
): void {
|
|
if (classification.ticket_type?.value != null && classification.ticket_type.value !== ticket.ticket_type) {
|
|
fieldChanges['ticket_type'] = { before: ticket.ticket_type, after: Number(classification.ticket_type.value) };
|
|
}
|
|
|
|
if (classification.issue_classification?.value != null && Number(classification.issue_classification.value) !== ticket.issue_type) {
|
|
fieldChanges['issue_type'] = { before: ticket.issue_type, after: Number(classification.issue_classification.value) };
|
|
}
|
|
|
|
if (classification.issue_classification?.value_2 != null && Number(classification.issue_classification.value_2) !== ticket.sub_issue_type) {
|
|
fieldChanges['sub_issue_type'] = { before: ticket.sub_issue_type, after: Number(classification.issue_classification.value_2) };
|
|
}
|
|
|
|
if (classification.priority?.value != null && Number(classification.priority.value) !== ticket.priority) {
|
|
fieldChanges['priority'] = { before: ticket.priority, after: Number(classification.priority.value) };
|
|
}
|
|
|
|
if (classification.queue?.value != null && Number(classification.queue.value) !== ticket.queue_id) {
|
|
fieldChanges['queue_id'] = { before: ticket.queue_id, after: Number(classification.queue.value) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determine if AI should be used based on confidence.
|
|
*/
|
|
private shouldUseAi(confidence: ConfidenceLevel, threshold: ConfidenceLevel): boolean {
|
|
const levels: Record<ConfidenceLevel, number> = { high: 3, medium: 2, low: 1 };
|
|
return levels[confidence] < levels[threshold];
|
|
}
|
|
|
|
/**
|
|
* Create a workflow execution record.
|
|
*/
|
|
private async createExecution(event: WorkflowEvent): Promise<number> {
|
|
const result = await postgresClient.query(
|
|
`INSERT INTO workflow_executions (trigger_event, entity_type, entity_id, ticket_number, status)
|
|
VALUES ($1, $2, $3, $4, 'running')
|
|
RETURNING id`,
|
|
[event.trigger_event, event.entity_type, event.entity_id, event.ticket_number || null]
|
|
);
|
|
return result.rows[0].id;
|
|
}
|
|
|
|
/**
|
|
* Update execution status.
|
|
*/
|
|
private async updateExecution(
|
|
executionId: number,
|
|
status: ExecutionStatus,
|
|
method: ClassificationMethod | null,
|
|
branch: Branch | null,
|
|
errorMessage?: string
|
|
): Promise<void> {
|
|
await postgresClient.query(
|
|
`UPDATE workflow_executions
|
|
SET status = $1, classification_method = $2, branch = $3,
|
|
completed_at = NOW(), duration_ms = EXTRACT(EPOCH FROM (NOW() - started_at)) * 1000,
|
|
error_message = $4
|
|
WHERE id = $5`,
|
|
[status, method, branch, errorMessage || null, executionId]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Log an execution step.
|
|
*/
|
|
private async logStep(
|
|
executionId: number,
|
|
stepName: string,
|
|
stepOrder: number,
|
|
status: ExecutionStatus,
|
|
method: StepMethod,
|
|
outputData?: Record<string, any>,
|
|
classificationRuleId?: number | null
|
|
): Promise<void> {
|
|
await postgresClient.query(
|
|
`INSERT INTO workflow_execution_steps
|
|
(execution_id, step_name, step_order, status, method, output_data, classification_rule_id, completed_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`,
|
|
[executionId, stepName, stepOrder, status, method, outputData ? JSON.stringify(outputData) : null, classificationRuleId || null]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Build a skipped execution result.
|
|
*/
|
|
private buildSkippedResult(executionId: number, reason: string): ExecutionResult {
|
|
return {
|
|
execution_id: executionId,
|
|
status: 'skipped',
|
|
classification_method: 'robotic',
|
|
branch: 'service_desk',
|
|
field_changes: {},
|
|
steps: [],
|
|
duration_ms: 0,
|
|
error: reason,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Delay for a specified number of milliseconds.
|
|
*/
|
|
private delay(ms: number): Promise<void> {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const workflowEngine = new WorkflowEngine();
|