feat: Autotask webhook integration, TicketNotes, Datto RMM, workflow engine, Veeam agents/alarms, AI triage, misc improvements
This commit is contained in:
parent
347cf4e298
commit
d7c3dc7168
74 changed files with 37844 additions and 322 deletions
359
lib/services/ai-triage-service.ts
Normal file
359
lib/services/ai-triage-service.ts
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
/**
|
||||
* AI Triage Service
|
||||
* Handles AI-powered classification for ambiguous tickets and text enhancement.
|
||||
* Only called when robotic classifier can't confidently classify, or when
|
||||
* title/description cleanup is needed.
|
||||
*/
|
||||
|
||||
import { postgresClient } from './postgres-client';
|
||||
import {
|
||||
AiEnhancementResult,
|
||||
AiPromptTemplate,
|
||||
TicketData,
|
||||
WorkflowSettings,
|
||||
ClassificationResult,
|
||||
PromptPurpose,
|
||||
} from '../types/workflow';
|
||||
|
||||
export class AiTriageService {
|
||||
/**
|
||||
* Call the configured AI provider with a prompt.
|
||||
*/
|
||||
private async callProvider(
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
settings: WorkflowSettings,
|
||||
templateOverrides?: { provider?: string; model?: string; temperature?: number; max_tokens?: number }
|
||||
): Promise<string> {
|
||||
const provider = templateOverrides?.provider || settings.default_ai_provider;
|
||||
const temperature = templateOverrides?.temperature ?? 0.3;
|
||||
const maxTokens = templateOverrides?.max_tokens ?? 4000;
|
||||
|
||||
if (provider === 'anthropic') {
|
||||
return this.callAnthropic(
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
templateOverrides?.model || settings.anthropic_model,
|
||||
settings.anthropic_api_key,
|
||||
temperature,
|
||||
maxTokens
|
||||
);
|
||||
} else {
|
||||
return this.callOpenAI(
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
templateOverrides?.model || settings.openai_model,
|
||||
settings.openai_api_key,
|
||||
temperature,
|
||||
maxTokens
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call OpenAI API.
|
||||
*/
|
||||
private async callOpenAI(
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
model: string,
|
||||
apiKey: string,
|
||||
temperature: number,
|
||||
maxTokens: number
|
||||
): Promise<string> {
|
||||
if (!apiKey) throw new Error('OpenAI API key not configured');
|
||||
|
||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
response_format: { type: 'json_object' },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`OpenAI API error (${response.status}): ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.choices[0]?.message?.content || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Call Anthropic API.
|
||||
*/
|
||||
private async callAnthropic(
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
model: string,
|
||||
apiKey: string,
|
||||
temperature: number,
|
||||
maxTokens: number
|
||||
): Promise<string> {
|
||||
if (!apiKey) throw new Error('Anthropic API key not configured');
|
||||
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: maxTokens,
|
||||
temperature,
|
||||
system: systemPrompt,
|
||||
messages: [{ role: 'user', content: userPrompt }],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Anthropic API error (${response.status}): ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const textBlock = data.content?.find((b: any) => b.type === 'text');
|
||||
return textBlock?.text || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an active AI prompt template by purpose.
|
||||
*/
|
||||
async getTemplate(purpose: PromptPurpose): Promise<AiPromptTemplate | null> {
|
||||
const result = await postgresClient.query<AiPromptTemplate>(
|
||||
`SELECT * FROM ai_prompt_templates WHERE purpose = $1 AND is_active = true ORDER BY version DESC LIMIT 1`,
|
||||
[purpose]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate template variables in a prompt string.
|
||||
* Supports {{field}} syntax.
|
||||
*/
|
||||
private interpolate(template: string, vars: Record<string, any>): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
||||
const val = vars[key];
|
||||
return val !== undefined && val !== null ? String(val) : '';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify ambiguous fields that the robotic classifier couldn't handle.
|
||||
* Returns only the fields that AI classified.
|
||||
*/
|
||||
async classifyAmbiguous(
|
||||
ticket: TicketData,
|
||||
failedFields: string[],
|
||||
settings: WorkflowSettings
|
||||
): Promise<AiEnhancementResult> {
|
||||
const template = await this.getTemplate('ambiguous_classification');
|
||||
|
||||
// Load picklist data for AI context
|
||||
const [issueTypes, subIssueTypes, priorities] = await Promise.all([
|
||||
postgresClient.query(`SELECT value, label FROM issue_types WHERE is_deleted = false ORDER BY label`),
|
||||
postgresClient.query(`SELECT value, label, parent_value FROM sub_issue_types WHERE is_deleted = false ORDER BY label`),
|
||||
postgresClient.query(`SELECT value, label FROM priorities WHERE is_active = true ORDER BY value`),
|
||||
]);
|
||||
|
||||
const picklistContext = {
|
||||
issueTypes: JSON.stringify(issueTypes.rows),
|
||||
subIssueTypes: JSON.stringify(subIssueTypes.rows),
|
||||
priorities: JSON.stringify(priorities.rows),
|
||||
};
|
||||
|
||||
const systemPrompt = template?.system_prompt || this.getDefaultClassificationSystemPrompt();
|
||||
const userTemplate = template?.user_prompt_template || this.getDefaultClassificationUserPrompt();
|
||||
|
||||
const userPrompt = this.interpolate(userTemplate, {
|
||||
title: ticket.title,
|
||||
description: ticket.description || 'No description provided',
|
||||
ticket_category: ticket.ticket_category,
|
||||
failed_fields: failedFields.join(', '),
|
||||
...picklistContext,
|
||||
});
|
||||
|
||||
const aiResponse = await this.callProvider(systemPrompt, userPrompt, settings, {
|
||||
provider: template?.provider,
|
||||
model: template?.model,
|
||||
temperature: template?.temperature,
|
||||
max_tokens: template?.max_tokens,
|
||||
});
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(aiResponse);
|
||||
return {
|
||||
classification: {
|
||||
issue_type: parsed.issueType || parsed.issue_type,
|
||||
sub_issue_type: parsed.subIssueType || parsed.sub_issue_type,
|
||||
ticket_type: parsed.ticketType || parsed.ticket_type,
|
||||
priority: parsed.priority,
|
||||
},
|
||||
method: 'ai',
|
||||
};
|
||||
} catch {
|
||||
console.error('[AI-TRIAGE] Failed to parse AI classification response:', aiResponse);
|
||||
return { method: 'ai' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up a messy ticket title (email subject, too long, garbled).
|
||||
*/
|
||||
async cleanupTitle(
|
||||
ticket: TicketData,
|
||||
settings: WorkflowSettings
|
||||
): Promise<string | undefined> {
|
||||
if (!settings.ai_for_title_cleanup) return undefined;
|
||||
|
||||
const template = await this.getTemplate('title_cleanup');
|
||||
const systemPrompt = template?.system_prompt ||
|
||||
'You are a helpdesk ticket title cleaner. Given a ticket title, produce a clean, concise title (max 80 chars). Remove email prefixes (Re:, Fw:, Fwd:), ticket numbers, excessive punctuation, and redundant info. Return JSON: {"title": "cleaned title"}';
|
||||
|
||||
const userTemplate = template?.user_prompt_template ||
|
||||
'Clean up this ticket title:\n\nOriginal title: {{title}}\nDescription (for context): {{description}}';
|
||||
|
||||
const userPrompt = this.interpolate(userTemplate, {
|
||||
title: ticket.title,
|
||||
description: (ticket.description || '').substring(0, 500),
|
||||
});
|
||||
|
||||
const aiResponse = await this.callProvider(systemPrompt, userPrompt, settings, {
|
||||
provider: template?.provider,
|
||||
model: template?.model,
|
||||
temperature: template?.temperature ?? 0.2,
|
||||
max_tokens: template?.max_tokens ?? 200,
|
||||
});
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(aiResponse);
|
||||
return parsed.title || undefined;
|
||||
} catch {
|
||||
console.error('[AI-TRIAGE] Failed to parse title cleanup response:', aiResponse);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite/restructure a ticket description.
|
||||
*/
|
||||
async rewriteDescription(
|
||||
ticket: TicketData,
|
||||
branch: string,
|
||||
settings: WorkflowSettings
|
||||
): Promise<string | undefined> {
|
||||
if (!settings.ai_for_description_rewrite) return undefined;
|
||||
if (!ticket.description || ticket.description.length < 50) return undefined;
|
||||
|
||||
const purpose: PromptPurpose = branch === 'noc' ? 'noc_format' : 'description_rewrite';
|
||||
const template = await this.getTemplate(purpose);
|
||||
if (!template) return undefined;
|
||||
|
||||
const userPrompt = this.interpolate(template.user_prompt_template, {
|
||||
title: ticket.title,
|
||||
description: ticket.description,
|
||||
branch,
|
||||
});
|
||||
|
||||
const aiResponse = await this.callProvider(template.system_prompt, userPrompt, settings, {
|
||||
provider: template.provider,
|
||||
model: template.model,
|
||||
temperature: template.temperature,
|
||||
max_tokens: template.max_tokens,
|
||||
});
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(aiResponse);
|
||||
return parsed.description || undefined;
|
||||
} catch {
|
||||
// If not JSON, return raw text as description
|
||||
return aiResponse.trim() || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate troubleshooting steps for an incident ticket.
|
||||
*/
|
||||
async generateTroubleshootingSteps(
|
||||
ticket: TicketData,
|
||||
settings: WorkflowSettings
|
||||
): Promise<string | undefined> {
|
||||
if (!settings.ai_for_troubleshooting) return undefined;
|
||||
|
||||
const template = await this.getTemplate('troubleshooting_steps');
|
||||
const systemPrompt = template?.system_prompt ||
|
||||
'You are an IT helpdesk assistant. Given a support ticket, generate 3-5 concise troubleshooting steps. Return JSON: {"steps": "numbered list of steps"}';
|
||||
|
||||
const userTemplate = template?.user_prompt_template ||
|
||||
'Generate troubleshooting steps for this ticket:\n\nTitle: {{title}}\nDescription: {{description}}';
|
||||
|
||||
const userPrompt = this.interpolate(userTemplate, {
|
||||
title: ticket.title,
|
||||
description: (ticket.description || '').substring(0, 2000),
|
||||
});
|
||||
|
||||
const aiResponse = await this.callProvider(systemPrompt, userPrompt, settings, {
|
||||
provider: template?.provider,
|
||||
model: template?.model,
|
||||
temperature: template?.temperature ?? 0.3,
|
||||
max_tokens: template?.max_tokens ?? 1000,
|
||||
});
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(aiResponse);
|
||||
return parsed.steps || undefined;
|
||||
} catch {
|
||||
return aiResponse.trim() || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default prompts (used when no template is configured in DB)
|
||||
// ============================================================================
|
||||
|
||||
private getDefaultClassificationSystemPrompt(): string {
|
||||
return `You are an IT helpdesk ticket classifier. Given a ticket title and description, classify the ticket into the correct issue type, sub-issue type, ticket type, and priority.
|
||||
|
||||
Rules:
|
||||
- ticket_type: 1 = Service Request, 2 = Incident
|
||||
- Use the provided picklist data to select valid issue types and sub-issue types
|
||||
- Ensure sub_issue_type parent_value matches the issue_type value
|
||||
- Return ONLY valid picklist values
|
||||
|
||||
Return JSON format:
|
||||
{"issueType": <number>, "subIssueType": <number>, "ticketType": <number>, "priority": <number>}`;
|
||||
}
|
||||
|
||||
private getDefaultClassificationUserPrompt(): string {
|
||||
return `Classify this ticket. Only provide values for these fields that need classification: {{failed_fields}}
|
||||
|
||||
Title: {{title}}
|
||||
Description: {{description}}
|
||||
Ticket Category: {{ticket_category}}
|
||||
|
||||
Available Issue Types:
|
||||
{{issueTypes}}
|
||||
|
||||
Available Sub-Issue Types (with parent_value):
|
||||
{{subIssueTypes}}
|
||||
|
||||
Available Priorities:
|
||||
{{priorities}}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const aiTriageService = new AiTriageService();
|
||||
358
lib/services/autotask-webhook-manager.ts
Normal file
358
lib/services/autotask-webhook-manager.ts
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
/**
|
||||
* Autotask Webhook Manager
|
||||
* Registers, manages, and deregisters webhooks with the Autotask REST API.
|
||||
* Autotask webhooks must be created via API — there is no GUI-based creation.
|
||||
*
|
||||
* Supported entities: Companies, Contacts, ConfigurationItems, Tickets, TicketNotes
|
||||
*/
|
||||
|
||||
import { AutotaskClient } from './autotask-client';
|
||||
import { postgresClient } from './postgres-client';
|
||||
import {
|
||||
WebhookEntityType,
|
||||
WEBHOOK_SUPPORTED_ENTITIES,
|
||||
AutotaskWebhookRegistration,
|
||||
AutotaskWebhookRegistrationResult,
|
||||
} from '../types/webhook';
|
||||
|
||||
/**
|
||||
* Maps our WebhookEntityType enum to the Autotask REST API webhook entity name.
|
||||
* e.g. "Tickets" → "TicketWebhooks"
|
||||
*/
|
||||
function getWebhookEntityName(entityType: WebhookEntityType): string {
|
||||
const map: Record<string, string> = {
|
||||
Companies: 'CompanyWebhooks',
|
||||
Contacts: 'ContactWebhooks',
|
||||
ConfigurationItems: 'ConfigurationItemWebhooks',
|
||||
Tickets: 'TicketWebhooks',
|
||||
TicketNotes: 'TicketNoteWebhooks',
|
||||
};
|
||||
return map[entityType] || `${entityType}Webhooks`;
|
||||
}
|
||||
|
||||
export class AutotaskWebhookManager {
|
||||
private client: AutotaskClient;
|
||||
private baseUrl: string;
|
||||
private webhookBaseUrl: string;
|
||||
private secretKey: string;
|
||||
private notificationEmail: string;
|
||||
|
||||
constructor() {
|
||||
this.client = 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 || '',
|
||||
});
|
||||
this.baseUrl = process.env.AUTOTASK_API_URL || '';
|
||||
this.webhookBaseUrl = process.env.WEBHOOK_BASE_URL || '';
|
||||
this.secretKey = process.env.AUTOTASK_WEBHOOK_SECRET || '';
|
||||
this.notificationEmail = process.env.AUTOTASK_USERNAME?.replace('@', '+webhooks@') || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Register webhooks for all supported entities
|
||||
*/
|
||||
async registerAll(): Promise<AutotaskWebhookRegistrationResult[]> {
|
||||
const results: AutotaskWebhookRegistrationResult[] = [];
|
||||
|
||||
for (const entityType of WEBHOOK_SUPPORTED_ENTITIES) {
|
||||
try {
|
||||
const result = await this.registerWebhook(entityType);
|
||||
results.push(result);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[WEBHOOK-MGR] Failed to register ${entityType}:`, msg);
|
||||
results.push({
|
||||
entityType,
|
||||
webhookId: 0,
|
||||
fieldsRegistered: 0,
|
||||
excludedResources: 0,
|
||||
success: false,
|
||||
error: msg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a webhook for a single entity type
|
||||
*/
|
||||
async registerWebhook(entityType: WebhookEntityType): Promise<AutotaskWebhookRegistrationResult> {
|
||||
const webhookEntityName = getWebhookEntityName(entityType);
|
||||
console.log(`[WEBHOOK-MGR] Registering webhook for ${entityType} via ${webhookEntityName}`);
|
||||
|
||||
// 1. Check if we already have a webhook registered
|
||||
const existing = await this.getExistingWebhookId(entityType);
|
||||
if (existing) {
|
||||
console.log(`[WEBHOOK-MGR] Webhook already registered for ${entityType} (ID: ${existing}), deleting first`);
|
||||
await this.deleteWebhook(entityType, existing);
|
||||
}
|
||||
|
||||
// 2. Create the webhook
|
||||
const webhookUrl = `${this.webhookBaseUrl}/api/webhooks/autotask`;
|
||||
const deactivationUrl = `${this.webhookBaseUrl}/api/webhooks/autotask?deactivated=true`;
|
||||
|
||||
const registration: AutotaskWebhookRegistration = {
|
||||
IsActive: true,
|
||||
DeactivationUrl: deactivationUrl,
|
||||
IsSubscribedToCreateEvents: true,
|
||||
IsSubscribedToUpdateEvents: true,
|
||||
IsSubscribedToDeleteEvents: true,
|
||||
Name: `Pulse - ${entityType}`,
|
||||
SecretKey: this.secretKey,
|
||||
SendThresholdExceededNotification: true,
|
||||
WebhookUrl: webhookUrl,
|
||||
NotificationEmailAddress: this.notificationEmail,
|
||||
};
|
||||
|
||||
const createUrl = `${this.baseUrl}/${webhookEntityName}`;
|
||||
const createResponse = await this.apiCall<{ itemId: number }>(createUrl, 'POST', registration);
|
||||
const webhookId = createResponse.itemId;
|
||||
|
||||
console.log(`[WEBHOOK-MGR] Created webhook for ${entityType}, ID: ${webhookId}`);
|
||||
|
||||
// 3. Discover and add trigger fields
|
||||
let fieldsRegistered = 0;
|
||||
try {
|
||||
fieldsRegistered = await this.addTriggerFields(webhookEntityName, webhookId);
|
||||
} catch (error) {
|
||||
console.warn(`[WEBHOOK-MGR] Could not add trigger fields for ${entityType}:`, error);
|
||||
}
|
||||
|
||||
// 4. Exclude the API user resource to prevent infinite loops
|
||||
let excludedResources = 0;
|
||||
try {
|
||||
excludedResources = await this.excludeApiResource(webhookEntityName, webhookId);
|
||||
} catch (error) {
|
||||
console.warn(`[WEBHOOK-MGR] Could not exclude API resource for ${entityType}:`, error);
|
||||
}
|
||||
|
||||
// 5. Save webhook ID to database
|
||||
await this.saveWebhookConfig(entityType, webhookId);
|
||||
|
||||
return {
|
||||
entityType,
|
||||
webhookId,
|
||||
fieldsRegistered,
|
||||
excludedResources,
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover available webhook fields and register them as triggers.
|
||||
* The fields endpoint is {Entity}WebhookFields/entityInformation/fields
|
||||
* (a separate entity, not a sub-path of the webhook).
|
||||
*/
|
||||
private async addTriggerFields(webhookEntityName: string, webhookId: number): Promise<number> {
|
||||
// Derive the fields entity name: "TicketWebhooks" → "TicketWebhookFields"
|
||||
const fieldsEntityName = webhookEntityName.replace('Webhooks', 'WebhookFields');
|
||||
const fieldsUrl = `${this.baseUrl}/${fieldsEntityName}/entityInformation/fields`;
|
||||
const fieldsResponse = await this.apiCall<{ fields: any[] }>(fieldsUrl, 'GET');
|
||||
|
||||
// Find the fieldID picklist to discover available trigger fields
|
||||
const fieldIdField = fieldsResponse.fields?.find((f: any) => f.name === 'fieldID');
|
||||
if (!fieldIdField?.picklistValues) {
|
||||
console.warn(`[WEBHOOK-MGR] No picklist values found for ${fieldsEntityName}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const activeFields = fieldIdField.picklistValues.filter((p: any) => p.isActive);
|
||||
let registered = 0;
|
||||
|
||||
// Register each available field as both a trigger and display-always field
|
||||
for (const field of activeFields) {
|
||||
try {
|
||||
const addFieldUrl = `${this.baseUrl}/${webhookEntityName}/${webhookId}/Fields`;
|
||||
await this.apiCall(addFieldUrl, 'POST', {
|
||||
FieldID: parseInt(field.value),
|
||||
IsDisplayAlwaysField: true,
|
||||
IsSubscribedField: true,
|
||||
WebhookID: webhookId,
|
||||
});
|
||||
registered++;
|
||||
} catch (error) {
|
||||
// Some fields may not support being triggers — that's OK
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
if (!msg.includes('already exists')) {
|
||||
console.debug(`[WEBHOOK-MGR] Could not add field ${field.label} (${field.value}): ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[WEBHOOK-MGR] Registered ${registered}/${activeFields.length} trigger fields for webhook ${webhookId}`);
|
||||
return registered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclude the API integration resource from triggering webhooks (prevents infinite loops)
|
||||
*/
|
||||
private async excludeApiResource(webhookEntityName: string, webhookId: number): Promise<number> {
|
||||
// Find the API resource by looking up the username
|
||||
const apiUsername = process.env.AUTOTASK_USERNAME || '';
|
||||
if (!apiUsername) return 0;
|
||||
|
||||
try {
|
||||
const resources = await this.client.getFieldInfo('Resources');
|
||||
// The API user is typically an API-only resource; we look it up by email
|
||||
const resource = await this.client.getResourceByEmail(apiUsername.split('@')[0] + '@' + apiUsername.split('@')[1]?.replace('@', ''));
|
||||
|
||||
if (!resource) {
|
||||
console.warn(`[WEBHOOK-MGR] Could not find API resource for ${apiUsername}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const excludeUrl = `${this.baseUrl}/${webhookEntityName}/${webhookId}/ExcludedResources`;
|
||||
await this.apiCall(excludeUrl, 'POST', {
|
||||
ResourceID: resource.id,
|
||||
WebhookID: webhookId,
|
||||
});
|
||||
|
||||
console.log(`[WEBHOOK-MGR] Excluded resource ${resource.id} (${apiUsername}) from webhook ${webhookId}`);
|
||||
return 1;
|
||||
} catch (error) {
|
||||
console.warn(`[WEBHOOK-MGR] Failed to exclude API resource:`, error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all webhooks for an entity type
|
||||
*/
|
||||
async listWebhooks(entityType: WebhookEntityType): Promise<any[]> {
|
||||
const webhookEntityName = getWebhookEntityName(entityType);
|
||||
const url = `${this.baseUrl}/${webhookEntityName}/query`;
|
||||
|
||||
try {
|
||||
const response = await this.apiCall<{ items: any[] }>(url, 'POST', {
|
||||
filter: [{ op: 'gte', field: 'id', value: 0 }],
|
||||
});
|
||||
return response.items || [];
|
||||
} catch (error) {
|
||||
console.error(`[WEBHOOK-MGR] Failed to list webhooks for ${entityType}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a webhook from Autotask
|
||||
*/
|
||||
async deleteWebhook(entityType: WebhookEntityType, webhookId: number): Promise<void> {
|
||||
const webhookEntityName = getWebhookEntityName(entityType);
|
||||
const url = `${this.baseUrl}/${webhookEntityName}/${webhookId}`;
|
||||
|
||||
try {
|
||||
await this.apiCall(url, 'DELETE');
|
||||
console.log(`[WEBHOOK-MGR] Deleted webhook ${webhookId} for ${entityType}`);
|
||||
} catch (error) {
|
||||
console.warn(`[WEBHOOK-MGR] Failed to delete webhook ${webhookId}:`, error);
|
||||
}
|
||||
|
||||
// Remove from database
|
||||
await postgresClient.query(
|
||||
`UPDATE webhook_configs SET autotask_webhook_id = NULL WHERE entity_type = $1`,
|
||||
[entityType]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deregister all webhooks
|
||||
*/
|
||||
async deregisterAll(): Promise<void> {
|
||||
for (const entityType of WEBHOOK_SUPPORTED_ENTITIES) {
|
||||
const webhookId = await this.getExistingWebhookId(entityType);
|
||||
if (webhookId) {
|
||||
await this.deleteWebhook(entityType, webhookId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of all registered webhooks
|
||||
*/
|
||||
async getStatus(): Promise<Array<{
|
||||
entityType: WebhookEntityType;
|
||||
webhookId: number | null;
|
||||
isActive: boolean;
|
||||
isConfigured: boolean;
|
||||
}>> {
|
||||
const results = [];
|
||||
|
||||
for (const entityType of WEBHOOK_SUPPORTED_ENTITIES) {
|
||||
const config = await postgresClient.query<{
|
||||
autotask_webhook_id: string | null;
|
||||
is_active: boolean;
|
||||
}>(
|
||||
`SELECT autotask_webhook_id, is_active FROM webhook_configs WHERE entity_type = $1`,
|
||||
[entityType]
|
||||
);
|
||||
|
||||
const row = config.rows[0];
|
||||
results.push({
|
||||
entityType,
|
||||
webhookId: row?.autotask_webhook_id ? parseInt(row.autotask_webhook_id) : null,
|
||||
isActive: row?.is_active ?? false,
|
||||
isConfigured: !!row?.autotask_webhook_id,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// --- Private helpers ---
|
||||
|
||||
private async getExistingWebhookId(entityType: WebhookEntityType): Promise<number | null> {
|
||||
const result = await postgresClient.query<{ autotask_webhook_id: string }>(
|
||||
`SELECT autotask_webhook_id FROM webhook_configs WHERE entity_type = $1 AND autotask_webhook_id IS NOT NULL`,
|
||||
[entityType]
|
||||
);
|
||||
if (result.rows.length > 0 && result.rows[0].autotask_webhook_id) {
|
||||
return parseInt(result.rows[0].autotask_webhook_id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async saveWebhookConfig(entityType: WebhookEntityType, webhookId: number): Promise<void> {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO webhook_configs (entity_type, event_types, is_active, autotask_webhook_id)
|
||||
VALUES ($1, $2, true, $3)
|
||||
ON CONFLICT (entity_type) DO UPDATE SET
|
||||
autotask_webhook_id = $3,
|
||||
is_active = true,
|
||||
updated_at = NOW()`,
|
||||
[entityType, JSON.stringify(['create', 'update', 'delete']), String(webhookId)]
|
||||
);
|
||||
}
|
||||
|
||||
private async apiCall<T = any>(url: string, method: string, body?: any): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Username': process.env.AUTOTASK_USERNAME || '',
|
||||
'Secret': process.env.AUTOTASK_SECRET || '',
|
||||
'APIIntegrationcode': process.env.AUTOTASK_API_INTEGRATION_CODE || '',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
const options: RequestInit = { method, headers };
|
||||
if (body && method !== 'GET' && method !== 'DELETE') {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Autotask API ${method} ${url} failed (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
// DELETE returns no body
|
||||
if (method === 'DELETE') {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
|
@ -128,6 +128,6 @@ export class DattoRMMClientSimple {
|
|||
}
|
||||
|
||||
// Then get devices for that site
|
||||
return this.getDevicesBySite(site.id);
|
||||
return this.getDevicesBySite(site.uid);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
DattoRMMConfig,
|
||||
DattoRMMDevice,
|
||||
DattoRMMSite,
|
||||
DattoRMMAlert,
|
||||
DattoRMMApiResponse,
|
||||
DattoRMMError,
|
||||
} from '@/lib/types/datto-rmm';
|
||||
|
|
@ -381,4 +382,88 @@ export class DattoRMMClient {
|
|||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic paginated fetch — follows nextPageUrl until exhausted
|
||||
*/
|
||||
private async fetchAllPages<T>(
|
||||
endpoint: string,
|
||||
dataKey: string,
|
||||
pageSize = 250
|
||||
): Promise<T[]> {
|
||||
const allItems: T[] = [];
|
||||
let url: string | null = `https://concord-api.centrastage.net/api/v2${endpoint}?pageSize=${pageSize}`;
|
||||
|
||||
while (url) {
|
||||
const token = await this.getAccessToken();
|
||||
const resp: Response = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text();
|
||||
throw new Error(`Datto RMM API ${resp.status}: ${errText.substring(0, 200)}`);
|
||||
}
|
||||
|
||||
const body: any = await resp.json();
|
||||
const items = body[dataKey] || [];
|
||||
allItems.push(...items);
|
||||
|
||||
const nextUrl: string | null = body.pageDetails?.nextPageUrl ?? null;
|
||||
console.log(`[DATTO-RMM] ${dataKey}: fetched ${allItems.length} (page had ${items.length})`);
|
||||
|
||||
url = nextUrl;
|
||||
}
|
||||
|
||||
return allItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all sites (paginated)
|
||||
*/
|
||||
async getAllSites(): Promise<DattoRMMSite[]> {
|
||||
return this.fetchAllPages<DattoRMMSite>('/account/sites', 'sites');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all open alerts (paginated)
|
||||
*/
|
||||
async getAllOpenAlerts(): Promise<DattoRMMAlert[]> {
|
||||
return this.fetchAllPages<DattoRMMAlert>('/account/alerts/open', 'alerts');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all resolved alerts (paginated, recent only)
|
||||
*/
|
||||
async getRecentResolvedAlerts(maxPages = 4): Promise<DattoRMMAlert[]> {
|
||||
const allAlerts: DattoRMMAlert[] = [];
|
||||
let url: string | null = 'https://concord-api.centrastage.net/api/v2/account/alerts/resolved?pageSize=250';
|
||||
let pages = 0;
|
||||
|
||||
while (url && pages < maxPages) {
|
||||
const token = await this.getAccessToken();
|
||||
const resp: Response = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!resp.ok) break;
|
||||
|
||||
const body: any = await resp.json();
|
||||
const alerts = body.alerts || [];
|
||||
allAlerts.push(...alerts);
|
||||
url = body.pageDetails?.nextPageUrl ?? null;
|
||||
pages++;
|
||||
console.log(`[DATTO-RMM] resolved alerts: fetched ${allAlerts.length} (page ${pages})`);
|
||||
}
|
||||
|
||||
return allAlerts;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
319
lib/services/datto-rmm-sync-service.ts
Normal file
319
lib/services/datto-rmm-sync-service.ts
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
/**
|
||||
* Datto RMM Sync Service
|
||||
* Syncs sites, devices, and alerts from Datto RMM API to PostgreSQL
|
||||
*/
|
||||
|
||||
import postgresClient from './postgres-client';
|
||||
import { DattoRMMClient } from './datto-rmm-client';
|
||||
import { DattoRMMSite, DattoRMMDevice, DattoRMMAlert } from '@/lib/types/datto-rmm';
|
||||
|
||||
export interface DattoRMMSyncResult {
|
||||
syncId: string;
|
||||
syncType: 'full' | 'incremental';
|
||||
status: 'completed' | 'failed';
|
||||
startedAt: Date;
|
||||
completedAt: Date;
|
||||
duration: number;
|
||||
entities: DattoRMMEntitySyncResult[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface DattoRMMEntitySyncResult {
|
||||
entity: string;
|
||||
success: boolean;
|
||||
recordsUpserted: number;
|
||||
duration: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class DattoRMMSyncService {
|
||||
private client: DattoRMMClient;
|
||||
private isSyncing = false;
|
||||
|
||||
constructor(client?: DattoRMMClient) {
|
||||
if (client) {
|
||||
this.client = client;
|
||||
} else {
|
||||
this.client = new DattoRMMClient({
|
||||
apiUrl: process.env.DATTO_RMM_API_URL || 'https://concord-api.centrastage.net',
|
||||
apiKey: process.env.DATTO_RMM_API_KEY || '',
|
||||
apiSecret: process.env.DATTO_RMM_API_SECRET || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
isSyncInProgress(): boolean {
|
||||
return this.isSyncing;
|
||||
}
|
||||
|
||||
async fullSync(triggeredBy = 'system'): Promise<DattoRMMSyncResult> {
|
||||
return this.executeSync('full', triggeredBy);
|
||||
}
|
||||
|
||||
async incrementalSync(triggeredBy = 'system'): Promise<DattoRMMSyncResult> {
|
||||
return this.executeSync('incremental', triggeredBy);
|
||||
}
|
||||
|
||||
private async executeSync(syncType: 'full' | 'incremental', triggeredBy: string): Promise<DattoRMMSyncResult> {
|
||||
if (this.isSyncing) {
|
||||
throw new Error('A Datto RMM sync is already in progress');
|
||||
}
|
||||
|
||||
this.isSyncing = true;
|
||||
const syncId = `datto-rmm-${Date.now()}`;
|
||||
const startTime = new Date();
|
||||
const entityResults: DattoRMMEntitySyncResult[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
// Create sync history record
|
||||
let historyId: number | null = null;
|
||||
try {
|
||||
const histResult = await postgresClient.query<{ id: number }>(
|
||||
`INSERT INTO sync_history (entity_type, sync_type, status, started_at, records_added, records_updated, records_deleted, triggered_by)
|
||||
VALUES ($1, $2, $3, $4, 0, 0, 0, $5) RETURNING id`,
|
||||
['datto_rmm', syncType, 'started', startTime, triggeredBy]
|
||||
);
|
||||
historyId = histResult.rows[0].id;
|
||||
} catch (e) {
|
||||
console.warn('[DATTO-RMM-SYNC] Could not create sync history record:', e);
|
||||
}
|
||||
|
||||
console.log(`[DATTO-RMM-SYNC] Starting ${syncType} sync (${syncId})`);
|
||||
|
||||
try {
|
||||
const steps: Array<{ name: string; fn: () => Promise<number> }> = [
|
||||
{ name: 'sites', fn: () => this.syncSites() },
|
||||
{ name: 'devices', fn: () => this.syncDevices() },
|
||||
{ name: 'open_alerts', fn: () => this.syncOpenAlerts() },
|
||||
{ name: 'resolved_alerts', fn: () => this.syncResolvedAlerts() },
|
||||
];
|
||||
|
||||
for (const step of steps) {
|
||||
const stepStart = Date.now();
|
||||
try {
|
||||
const count = await step.fn();
|
||||
const duration = Date.now() - stepStart;
|
||||
entityResults.push({ entity: step.name, success: true, recordsUpserted: count, duration });
|
||||
console.log(`[DATTO-RMM-SYNC] ${step.name}: ${count} records in ${duration}ms`);
|
||||
} catch (error) {
|
||||
const duration = Date.now() - stepStart;
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
errors.push(`${step.name}: ${msg}`);
|
||||
entityResults.push({ entity: step.name, success: false, recordsUpserted: 0, duration, error: msg });
|
||||
console.error(`[DATTO-RMM-SYNC] ${step.name} failed:`, msg);
|
||||
}
|
||||
}
|
||||
|
||||
const completedAt = new Date();
|
||||
const duration = completedAt.getTime() - startTime.getTime();
|
||||
const status = errors.length === 0 ? 'completed' : 'failed';
|
||||
const totalRecords = entityResults.reduce((sum, r) => sum + r.recordsUpserted, 0);
|
||||
|
||||
console.log(`[DATTO-RMM-SYNC] Sync ${status} in ${duration}ms — ${totalRecords} total records`);
|
||||
|
||||
if (historyId) {
|
||||
try {
|
||||
await postgresClient.query(
|
||||
`UPDATE sync_history SET status = $1, completed_at = $2, records_added = $3, error_message = $4, entity_details = $5 WHERE id = $6`,
|
||||
[status, completedAt, totalRecords, errors.length > 0 ? errors.join('; ') : null, JSON.stringify(entityResults), historyId]
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('[DATTO-RMM-SYNC] Could not update sync history:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return { syncId, syncType, status, startedAt: startTime, completedAt, duration, entities: entityResults, errors };
|
||||
} catch (error) {
|
||||
const completedAt = new Date();
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error('[DATTO-RMM-SYNC] Sync failed catastrophically:', msg);
|
||||
|
||||
if (historyId) {
|
||||
try {
|
||||
await postgresClient.query(
|
||||
`UPDATE sync_history SET status = 'failed', completed_at = $1, error_message = $2 WHERE id = $3`,
|
||||
[completedAt, msg, historyId]
|
||||
);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
return {
|
||||
syncId, syncType, status: 'failed', startedAt: startTime, completedAt,
|
||||
duration: completedAt.getTime() - startTime.getTime(), entities: entityResults, errors: [msg],
|
||||
};
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sites ─────────────────────────────────────────────────────────────────────
|
||||
private async syncSites(): Promise<number> {
|
||||
const sites = await this.client.getAllSites();
|
||||
if (sites.length === 0) return 0;
|
||||
|
||||
// Build set of known company IDs for FK safety
|
||||
const knownCompanies = await postgresClient.query('SELECT id FROM companies');
|
||||
const companyIds = new Set(knownCompanies.rows.map((r: any) => r.id));
|
||||
|
||||
let count = 0;
|
||||
for (const s of sites) {
|
||||
const atCompanyId = s.autotaskCompanyId ? parseInt(s.autotaskCompanyId, 10) : null;
|
||||
const matchedCompanyId = atCompanyId && !isNaN(atCompanyId) && atCompanyId > 0 && companyIds.has(atCompanyId)
|
||||
? atCompanyId : null;
|
||||
|
||||
await postgresClient.query(
|
||||
`INSERT INTO datto_rmm_sites (id, uid, account_uid, name, description, notes, on_demand,
|
||||
autotask_company_id, autotask_company_name,
|
||||
number_of_devices, number_of_online_devices, number_of_offline_devices,
|
||||
portal_url, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
uid=EXCLUDED.uid, account_uid=EXCLUDED.account_uid, name=EXCLUDED.name,
|
||||
description=EXCLUDED.description, notes=EXCLUDED.notes, on_demand=EXCLUDED.on_demand,
|
||||
autotask_company_id=EXCLUDED.autotask_company_id, autotask_company_name=EXCLUDED.autotask_company_name,
|
||||
number_of_devices=EXCLUDED.number_of_devices, number_of_online_devices=EXCLUDED.number_of_online_devices,
|
||||
number_of_offline_devices=EXCLUDED.number_of_offline_devices,
|
||||
portal_url=EXCLUDED.portal_url, synced_at=NOW(), updated_at=NOW()`,
|
||||
[
|
||||
s.id, s.uid, s.accountUid || null, s.name, s.description || null, s.notes || null, s.onDemand,
|
||||
matchedCompanyId, s.autotaskCompanyName || null,
|
||||
s.devicesStatus?.numberOfDevices ?? 0,
|
||||
s.devicesStatus?.numberOfOnlineDevices ?? 0,
|
||||
s.devicesStatus?.numberOfOfflineDevices ?? 0,
|
||||
s.portalUrl || null,
|
||||
]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ── Devices ───────────────────────────────────────────────────────────────────
|
||||
private async syncDevices(): Promise<number> {
|
||||
const devices = await this.client.getAllDevices();
|
||||
if (devices.length === 0) return 0;
|
||||
|
||||
// Build set of known site IDs for FK safety
|
||||
const knownSites = await postgresClient.query('SELECT id FROM datto_rmm_sites');
|
||||
const siteIds = new Set(knownSites.rows.map((r: any) => r.id));
|
||||
|
||||
let count = 0;
|
||||
for (const d of devices) {
|
||||
const siteId = siteIds.has(d.siteId) ? d.siteId : null;
|
||||
|
||||
await postgresClient.query(
|
||||
`INSERT INTO datto_rmm_devices (id, uid, site_id, site_uid, site_name, hostname, description,
|
||||
device_type_category, device_type, operating_system, domain,
|
||||
int_ip_address, ext_ip_address, last_logged_in_user,
|
||||
last_seen, last_reboot, last_audit_date, creation_date,
|
||||
online, suspended, deleted, reboot_required, a64_bit,
|
||||
cag_version, display_version,
|
||||
antivirus_product, antivirus_status,
|
||||
patch_status, patches_approved_pending, patches_not_approved, patches_installed,
|
||||
software_status, portal_url, web_remote_url, warranty_date,
|
||||
snmp_enabled, device_class, network_probe, udf, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
uid=EXCLUDED.uid, site_id=EXCLUDED.site_id, site_uid=EXCLUDED.site_uid, site_name=EXCLUDED.site_name,
|
||||
hostname=EXCLUDED.hostname, description=EXCLUDED.description,
|
||||
device_type_category=EXCLUDED.device_type_category, device_type=EXCLUDED.device_type,
|
||||
operating_system=EXCLUDED.operating_system, domain=EXCLUDED.domain,
|
||||
int_ip_address=EXCLUDED.int_ip_address, ext_ip_address=EXCLUDED.ext_ip_address,
|
||||
last_logged_in_user=EXCLUDED.last_logged_in_user,
|
||||
last_seen=EXCLUDED.last_seen, last_reboot=EXCLUDED.last_reboot,
|
||||
last_audit_date=EXCLUDED.last_audit_date, creation_date=EXCLUDED.creation_date,
|
||||
online=EXCLUDED.online, suspended=EXCLUDED.suspended, deleted=EXCLUDED.deleted,
|
||||
reboot_required=EXCLUDED.reboot_required, a64_bit=EXCLUDED.a64_bit,
|
||||
cag_version=EXCLUDED.cag_version, display_version=EXCLUDED.display_version,
|
||||
antivirus_product=EXCLUDED.antivirus_product, antivirus_status=EXCLUDED.antivirus_status,
|
||||
patch_status=EXCLUDED.patch_status, patches_approved_pending=EXCLUDED.patches_approved_pending,
|
||||
patches_not_approved=EXCLUDED.patches_not_approved, patches_installed=EXCLUDED.patches_installed,
|
||||
software_status=EXCLUDED.software_status, portal_url=EXCLUDED.portal_url,
|
||||
web_remote_url=EXCLUDED.web_remote_url, warranty_date=EXCLUDED.warranty_date,
|
||||
snmp_enabled=EXCLUDED.snmp_enabled, device_class=EXCLUDED.device_class,
|
||||
network_probe=EXCLUDED.network_probe, udf=EXCLUDED.udf,
|
||||
synced_at=NOW(), updated_at=NOW()`,
|
||||
[
|
||||
d.id, d.uid, siteId, d.siteUid, d.siteName, d.hostname, d.description || null,
|
||||
d.deviceType?.category || null, d.deviceType?.type || null,
|
||||
d.operatingSystem || null, d.domain || null,
|
||||
d.intIpAddress || null, d.extIpAddress || null, d.lastLoggedInUser || null,
|
||||
d.lastSeen ? new Date(d.lastSeen) : null,
|
||||
d.lastReboot ? new Date(d.lastReboot) : null,
|
||||
d.lastAuditDate ? new Date(d.lastAuditDate) : null,
|
||||
d.creationDate ? new Date(d.creationDate) : null,
|
||||
d.online, d.suspended, d.deleted, d.rebootRequired ?? false, d.a64Bit ?? true,
|
||||
d.cagVersion || null, d.displayVersion || null,
|
||||
d.antivirus?.antivirusProduct || null, d.antivirus?.antivirusStatus || null,
|
||||
d.patchManagement?.patchStatus || null,
|
||||
d.patchManagement?.patchesApprovedPending ?? 0,
|
||||
d.patchManagement?.patchesNotApproved ?? 0,
|
||||
d.patchManagement?.patchesInstalled ?? 0,
|
||||
d.softwareStatus || null, d.portalUrl || null, d.webRemoteUrl || null,
|
||||
d.warrantyDate ? new Date(d.warrantyDate) : null,
|
||||
d.snmpEnabled ?? false, d.deviceClass || null, (d as any).networkProbe ?? false,
|
||||
d.udf ? JSON.stringify(d.udf) : null,
|
||||
]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ── Alerts ────────────────────────────────────────────────────────────────────
|
||||
private async upsertAlerts(alerts: DattoRMMAlert[]): Promise<number> {
|
||||
let count = 0;
|
||||
for (const a of alerts) {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO datto_rmm_alerts (alert_uid, device_uid, device_name, site_uid, site_name,
|
||||
priority, alert_context, alert_monitor_info, diagnostics,
|
||||
resolved, resolved_by, resolved_on, muted, ticket_number,
|
||||
autoresolve_mins, response_actions, timestamp, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,NOW())
|
||||
ON CONFLICT (alert_uid) DO UPDATE SET
|
||||
device_uid=EXCLUDED.device_uid, device_name=EXCLUDED.device_name,
|
||||
site_uid=EXCLUDED.site_uid, site_name=EXCLUDED.site_name,
|
||||
priority=EXCLUDED.priority, alert_context=EXCLUDED.alert_context,
|
||||
alert_monitor_info=EXCLUDED.alert_monitor_info, diagnostics=EXCLUDED.diagnostics,
|
||||
resolved=EXCLUDED.resolved, resolved_by=EXCLUDED.resolved_by,
|
||||
resolved_on=EXCLUDED.resolved_on, muted=EXCLUDED.muted,
|
||||
ticket_number=EXCLUDED.ticket_number, autoresolve_mins=EXCLUDED.autoresolve_mins,
|
||||
response_actions=EXCLUDED.response_actions,
|
||||
synced_at=NOW(), updated_at=NOW()`,
|
||||
[
|
||||
a.alertUid,
|
||||
a.alertSourceInfo?.deviceUid || null,
|
||||
a.alertSourceInfo?.deviceName || null,
|
||||
a.alertSourceInfo?.siteUid || null,
|
||||
a.alertSourceInfo?.siteName || null,
|
||||
a.priority || null,
|
||||
a.alertContext ? JSON.stringify(a.alertContext) : null,
|
||||
a.alertMonitorInfo ? JSON.stringify(a.alertMonitorInfo) : null,
|
||||
a.diagnostics || null,
|
||||
a.resolved,
|
||||
a.resolvedBy || null,
|
||||
a.resolvedOn ? new Date(a.resolvedOn) : null,
|
||||
a.muted,
|
||||
a.ticketNumber || null,
|
||||
a.autoresolveMins ?? null,
|
||||
a.responseActions ? JSON.stringify(a.responseActions) : null,
|
||||
new Date(a.timestamp),
|
||||
]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private async syncOpenAlerts(): Promise<number> {
|
||||
const alerts = await this.client.getAllOpenAlerts();
|
||||
if (alerts.length === 0) return 0;
|
||||
return this.upsertAlerts(alerts);
|
||||
}
|
||||
|
||||
private async syncResolvedAlerts(): Promise<number> {
|
||||
const alerts = await this.client.getRecentResolvedAlerts(4);
|
||||
if (alerts.length === 0) return 0;
|
||||
return this.upsertAlerts(alerts);
|
||||
}
|
||||
}
|
||||
|
|
@ -71,6 +71,15 @@ export class EntitySyncService {
|
|||
if (entity === EntityType.SUB_ISSUE_TYPES) {
|
||||
return await this.syncSubIssueTypes(isIncremental);
|
||||
}
|
||||
if (entity === EntityType.QUEUES) {
|
||||
return await this.syncQueues(isIncremental);
|
||||
}
|
||||
if (entity === EntityType.PRIORITIES) {
|
||||
return await this.syncPriorities(isIncremental);
|
||||
}
|
||||
if (entity === EntityType.TICKET_CATEGORIES) {
|
||||
return await this.syncTicketCategories(isIncremental);
|
||||
}
|
||||
|
||||
const trackingId = syncId || `${entity}_${Date.now()}`;
|
||||
const entityLogger = this.logger.child({ syncId: trackingId, entityType: entity });
|
||||
|
|
@ -889,6 +898,141 @@ export class EntitySyncService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync Queues (Picklist from Ticket field)
|
||||
*/
|
||||
async syncQueues(isIncremental: boolean = false): Promise<EntitySyncStats> {
|
||||
const picklistLogger = this.logger.child({ entityType: EntityType.QUEUES, syncType: 'picklist' });
|
||||
const syncStartTime = picklistLogger.start('Picklist sync');
|
||||
|
||||
try {
|
||||
const picklistValues = await this.autotaskClient.getPicklistValues('Tickets', 'queueID');
|
||||
|
||||
const records = Object.entries(picklistValues).map(([value, label]) => ({
|
||||
value: parseInt(value),
|
||||
label: label,
|
||||
is_active: true,
|
||||
sort_order: parseInt(value),
|
||||
synced_at: new Date(),
|
||||
}));
|
||||
|
||||
picklistLogger.info('Found picklist values', { recordCount: records.length });
|
||||
|
||||
const tableName = getTableName(EntityType.QUEUES);
|
||||
const existingQuery = `SELECT value FROM ${tableName}`;
|
||||
const existingResult = await postgresClient.query<{ value: number }>(existingQuery);
|
||||
const existingValues = new Set(existingResult.rows.map((r: any) => r.value));
|
||||
|
||||
const recordsAdded = records.filter((r: any) => !existingValues.has(r.value)).length;
|
||||
const recordsUpdated = records.filter((r: any) => existingValues.has(r.value)).length;
|
||||
|
||||
await postgresClient.bulkUpsert(tableName, records, ['value']);
|
||||
|
||||
const stats: EntitySyncStats = { recordsAdded, recordsUpdated, recordsDeleted: 0 };
|
||||
|
||||
picklistLogger.complete('Picklist sync', syncStartTime, {
|
||||
recordsAdded: stats.recordsAdded,
|
||||
recordsUpdated: stats.recordsUpdated,
|
||||
});
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
picklistLogger.fail('Picklist sync', syncStartTime, err);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync Priorities (Picklist from Ticket field)
|
||||
*/
|
||||
async syncPriorities(isIncremental: boolean = false): Promise<EntitySyncStats> {
|
||||
const picklistLogger = this.logger.child({ entityType: EntityType.PRIORITIES, syncType: 'picklist' });
|
||||
const syncStartTime = picklistLogger.start('Picklist sync');
|
||||
|
||||
try {
|
||||
const picklistValues = await this.autotaskClient.getPicklistValues('Tickets', 'priority');
|
||||
|
||||
const records = Object.entries(picklistValues).map(([value, label]) => ({
|
||||
value: parseInt(value),
|
||||
label: label,
|
||||
is_active: true,
|
||||
sort_order: parseInt(value),
|
||||
synced_at: new Date(),
|
||||
}));
|
||||
|
||||
picklistLogger.info('Found picklist values', { recordCount: records.length });
|
||||
|
||||
const tableName = getTableName(EntityType.PRIORITIES);
|
||||
const existingQuery = `SELECT value FROM ${tableName}`;
|
||||
const existingResult = await postgresClient.query<{ value: number }>(existingQuery);
|
||||
const existingValues = new Set(existingResult.rows.map((r: any) => r.value));
|
||||
|
||||
const recordsAdded = records.filter((r: any) => !existingValues.has(r.value)).length;
|
||||
const recordsUpdated = records.filter((r: any) => existingValues.has(r.value)).length;
|
||||
|
||||
await postgresClient.bulkUpsert(tableName, records, ['value']);
|
||||
|
||||
const stats: EntitySyncStats = { recordsAdded, recordsUpdated, recordsDeleted: 0 };
|
||||
|
||||
picklistLogger.complete('Picklist sync', syncStartTime, {
|
||||
recordsAdded: stats.recordsAdded,
|
||||
recordsUpdated: stats.recordsUpdated,
|
||||
});
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
picklistLogger.fail('Picklist sync', syncStartTime, err);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync Ticket Categories (Picklist from Ticket field)
|
||||
*/
|
||||
async syncTicketCategories(isIncremental: boolean = false): Promise<EntitySyncStats> {
|
||||
const picklistLogger = this.logger.child({ entityType: EntityType.TICKET_CATEGORIES, syncType: 'picklist' });
|
||||
const syncStartTime = picklistLogger.start('Picklist sync');
|
||||
|
||||
try {
|
||||
const picklistValues = await this.autotaskClient.getPicklistValues('Tickets', 'ticketCategory');
|
||||
|
||||
const records = Object.entries(picklistValues).map(([value, label]) => ({
|
||||
value: parseInt(value),
|
||||
label: label,
|
||||
is_active: true,
|
||||
sort_order: parseInt(value),
|
||||
synced_at: new Date(),
|
||||
}));
|
||||
|
||||
picklistLogger.info('Found picklist values', { recordCount: records.length });
|
||||
|
||||
const tableName = getTableName(EntityType.TICKET_CATEGORIES);
|
||||
const existingQuery = `SELECT value FROM ${tableName}`;
|
||||
const existingResult = await postgresClient.query<{ value: number }>(existingQuery);
|
||||
const existingValues = new Set(existingResult.rows.map((r: any) => r.value));
|
||||
|
||||
const recordsAdded = records.filter((r: any) => !existingValues.has(r.value)).length;
|
||||
const recordsUpdated = records.filter((r: any) => existingValues.has(r.value)).length;
|
||||
|
||||
await postgresClient.bulkUpsert(tableName, records, ['value']);
|
||||
|
||||
const stats: EntitySyncStats = { recordsAdded, recordsUpdated, recordsDeleted: 0 };
|
||||
|
||||
picklistLogger.complete('Picklist sync', syncStartTime, {
|
||||
recordsAdded: stats.recordsAdded,
|
||||
recordsUpdated: stats.recordsUpdated,
|
||||
});
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
picklistLogger.fail('Picklist sync', syncStartTime, err);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync Work Types (Picklist from TimeEntry field)
|
||||
*/
|
||||
|
|
|
|||
373
lib/services/robotic-classifier.ts
Normal file
373
lib/services/robotic-classifier.ts
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
/**
|
||||
* Robotic Classifier
|
||||
* DB-driven keyword classification engine for ticket triage.
|
||||
* Handles ~90% of classification deterministically without AI.
|
||||
*/
|
||||
|
||||
import { postgresClient } from './postgres-client';
|
||||
import {
|
||||
ClassificationRule,
|
||||
ClassificationResult,
|
||||
ClassificationStepResult,
|
||||
ConfidenceLevel,
|
||||
RuleType,
|
||||
TicketData,
|
||||
} from '../types/workflow';
|
||||
|
||||
// Cache TTL for classification rules (5 minutes)
|
||||
const RULES_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export class RoboticClassifier {
|
||||
private rulesCache: Map<RuleType, ClassificationRule[]> = new Map();
|
||||
private cacheLoadedAt: number = 0;
|
||||
|
||||
/**
|
||||
* Load classification rules from DB, grouped by rule_type.
|
||||
* Cached for RULES_CACHE_TTL_MS to avoid repeated DB queries.
|
||||
*/
|
||||
async loadRules(forceRefresh = false): Promise<Map<RuleType, ClassificationRule[]>> {
|
||||
const now = Date.now();
|
||||
if (!forceRefresh && this.rulesCache.size > 0 && now - this.cacheLoadedAt < RULES_CACHE_TTL_MS) {
|
||||
return this.rulesCache;
|
||||
}
|
||||
|
||||
const result = await postgresClient.query<ClassificationRule>(
|
||||
`SELECT * FROM classification_rules WHERE is_active = true ORDER BY rule_type, sort_order`
|
||||
);
|
||||
|
||||
this.rulesCache.clear();
|
||||
for (const rule of result.rows) {
|
||||
const existing = this.rulesCache.get(rule.rule_type) || [];
|
||||
existing.push(rule);
|
||||
this.rulesCache.set(rule.rule_type, existing);
|
||||
}
|
||||
|
||||
this.cacheLoadedAt = now;
|
||||
console.log(`[CLASSIFIER] Loaded ${result.rows.length} classification rules`);
|
||||
return this.rulesCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run full classification pipeline on a ticket.
|
||||
*/
|
||||
async classify(ticket: TicketData): Promise<ClassificationResult> {
|
||||
await this.loadRules();
|
||||
|
||||
const branch = await this.classifyByType('branch_routing', ticket);
|
||||
const ticketType = await this.classifyByType('ticket_type', ticket);
|
||||
const issueClassification = await this.classifyByType('issue_classification', ticket);
|
||||
|
||||
// Priority classification can use ticket_type result as context
|
||||
const priorityTicket = { ...ticket };
|
||||
if (ticketType?.value != null) {
|
||||
priorityTicket.ticket_type = ticketType.value;
|
||||
}
|
||||
const priority = await this.classifyByType('priority', priorityTicket);
|
||||
|
||||
// Queue routing can use priority result as context
|
||||
const queueTicket = { ...priorityTicket };
|
||||
if (priority?.value != null) {
|
||||
queueTicket.priority = priority.value;
|
||||
}
|
||||
const queue = await this.classifyByType('queue_routing', queueTicket);
|
||||
|
||||
// Determine overall confidence and whether AI is needed
|
||||
const stepResults = [branch, ticketType, issueClassification, priority, queue];
|
||||
const { overallConfidence, needsAi, aiReasons } = this.assessConfidence(
|
||||
stepResults,
|
||||
ticket
|
||||
);
|
||||
|
||||
return {
|
||||
branch: branch || this.defaultBranch(),
|
||||
ticket_type: ticketType,
|
||||
issue_classification: issueClassification,
|
||||
priority: priority,
|
||||
queue: queue,
|
||||
overall_confidence: overallConfidence,
|
||||
needs_ai: needsAi,
|
||||
ai_reasons: aiReasons,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run classification for a specific rule type.
|
||||
*/
|
||||
private async classifyByType(
|
||||
ruleType: RuleType,
|
||||
ticket: TicketData
|
||||
): Promise<ClassificationStepResult | null> {
|
||||
const rules = this.rulesCache.get(ruleType) || [];
|
||||
|
||||
for (const rule of rules) {
|
||||
if (this.evaluateRule(rule, ticket)) {
|
||||
return {
|
||||
field: rule.result_field,
|
||||
value: rule.result_value,
|
||||
field_2: rule.result_field_2 || undefined,
|
||||
value_2: rule.result_value_2 || undefined,
|
||||
confidence: rule.confidence,
|
||||
matched_rule_id: rule.id,
|
||||
matched_rule_name: rule.name,
|
||||
method: 'robotic',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a single classification rule against ticket data.
|
||||
*/
|
||||
private evaluateRule(rule: ClassificationRule, ticket: TicketData): boolean {
|
||||
const fieldValue = this.getFieldValue(rule.match_field, ticket);
|
||||
|
||||
if (fieldValue === null || fieldValue === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.evaluateMatch(
|
||||
rule.match_operator,
|
||||
fieldValue,
|
||||
rule.match_value,
|
||||
rule.match_case_sensitive
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value of a field from the ticket data.
|
||||
* For 'title_or_description', returns a combined check target.
|
||||
*/
|
||||
private getFieldValue(
|
||||
matchField: string,
|
||||
ticket: TicketData
|
||||
): string | number | null {
|
||||
switch (matchField) {
|
||||
case 'title':
|
||||
return ticket.title || null;
|
||||
case 'description':
|
||||
return ticket.description || null;
|
||||
case 'title_or_description':
|
||||
// Return combined text for pattern matching
|
||||
return [ticket.title, ticket.description].filter(Boolean).join(' ') || null;
|
||||
case 'ticket_category':
|
||||
return ticket.ticket_category;
|
||||
case 'ticket_type':
|
||||
return ticket.ticket_type;
|
||||
case 'priority':
|
||||
return ticket.priority;
|
||||
case 'policy_name':
|
||||
return ticket.policy_name || null;
|
||||
case 'device_name':
|
||||
return ticket.device_name || null;
|
||||
case 'creator_resource_id':
|
||||
return ticket.creator_resource_id;
|
||||
case 'person_id':
|
||||
return ticket.person_id;
|
||||
case 'company_id':
|
||||
return ticket.company_id;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a match operation.
|
||||
*/
|
||||
private evaluateMatch(
|
||||
operator: string,
|
||||
fieldValue: string | number,
|
||||
matchValue: any,
|
||||
caseSensitive: boolean
|
||||
): boolean {
|
||||
switch (operator) {
|
||||
case 'contains':
|
||||
return this.evaluateContains(fieldValue, matchValue, caseSensitive);
|
||||
case 'starts_with':
|
||||
return this.evaluateStartsWith(fieldValue, matchValue, caseSensitive);
|
||||
case 'regex':
|
||||
return this.evaluateRegex(fieldValue, matchValue, caseSensitive);
|
||||
case 'equals':
|
||||
return this.evaluateEquals(fieldValue, matchValue);
|
||||
case 'in':
|
||||
return this.evaluateIn(fieldValue, matchValue);
|
||||
case 'not_in':
|
||||
return !this.evaluateIn(fieldValue, matchValue);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains: check if field contains any of the match values.
|
||||
* matchValue can be a string or array of strings.
|
||||
*/
|
||||
private evaluateContains(
|
||||
fieldValue: string | number,
|
||||
matchValue: any,
|
||||
caseSensitive: boolean
|
||||
): boolean {
|
||||
const text = String(fieldValue);
|
||||
const searchText = caseSensitive ? text : text.toLowerCase();
|
||||
const patterns = Array.isArray(matchValue) ? matchValue : [matchValue];
|
||||
|
||||
return patterns.some((pattern: string) => {
|
||||
const searchPattern = caseSensitive ? String(pattern) : String(pattern).toLowerCase();
|
||||
return searchText.includes(searchPattern);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts with: check if field starts with the match value.
|
||||
*/
|
||||
private evaluateStartsWith(
|
||||
fieldValue: string | number,
|
||||
matchValue: any,
|
||||
caseSensitive: boolean
|
||||
): boolean {
|
||||
const text = String(fieldValue);
|
||||
const searchText = caseSensitive ? text : text.toLowerCase();
|
||||
const patterns = Array.isArray(matchValue) ? matchValue : [matchValue];
|
||||
|
||||
return patterns.some((pattern: string) => {
|
||||
const searchPattern = caseSensitive ? String(pattern) : String(pattern).toLowerCase();
|
||||
return searchText.startsWith(searchPattern);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex: evaluate a regex pattern against the field value.
|
||||
*/
|
||||
private evaluateRegex(
|
||||
fieldValue: string | number,
|
||||
matchValue: any,
|
||||
caseSensitive: boolean
|
||||
): boolean {
|
||||
const text = String(fieldValue);
|
||||
const pattern = String(matchValue);
|
||||
try {
|
||||
const flags = caseSensitive ? '' : 'i';
|
||||
const regex = new RegExp(pattern, flags);
|
||||
return regex.test(text);
|
||||
} catch {
|
||||
console.error(`[CLASSIFIER] Invalid regex pattern: ${pattern}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Equals: exact match (handles numbers and strings).
|
||||
*/
|
||||
private evaluateEquals(
|
||||
fieldValue: string | number,
|
||||
matchValue: any
|
||||
): boolean {
|
||||
// Compare as numbers if both are numeric
|
||||
const numField = Number(fieldValue);
|
||||
const numMatch = Number(matchValue);
|
||||
if (!isNaN(numField) && !isNaN(numMatch)) {
|
||||
return numField === numMatch;
|
||||
}
|
||||
return String(fieldValue).toLowerCase() === String(matchValue).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* In: check if field value is in the match value array.
|
||||
*/
|
||||
private evaluateIn(
|
||||
fieldValue: string | number,
|
||||
matchValue: any
|
||||
): boolean {
|
||||
const values = Array.isArray(matchValue) ? matchValue : [matchValue];
|
||||
const numField = Number(fieldValue);
|
||||
|
||||
return values.some((v: any) => {
|
||||
const numV = Number(v);
|
||||
if (!isNaN(numField) && !isNaN(numV)) {
|
||||
return numField === numV;
|
||||
}
|
||||
return String(fieldValue).toLowerCase() === String(v).toLowerCase();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Default branch when no branch routing rule matches.
|
||||
*/
|
||||
private defaultBranch(): ClassificationStepResult {
|
||||
return {
|
||||
field: 'branch',
|
||||
value: 'service_desk',
|
||||
confidence: 'high',
|
||||
matched_rule_id: null,
|
||||
matched_rule_name: 'Default (service_desk)',
|
||||
method: 'robotic',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assess overall confidence and determine if AI enhancement is needed.
|
||||
*/
|
||||
private assessConfidence(
|
||||
results: (ClassificationStepResult | null)[],
|
||||
ticket: TicketData
|
||||
): { overallConfidence: ConfidenceLevel; needsAi: boolean; aiReasons: string[] } {
|
||||
const aiReasons: string[] = [];
|
||||
|
||||
// Check for missing classifications
|
||||
const [branch, ticketType, issueClass, priority, queue] = results;
|
||||
|
||||
if (!ticketType) {
|
||||
aiReasons.push('No ticket type classification matched');
|
||||
}
|
||||
if (!issueClass) {
|
||||
aiReasons.push('No issue type classification matched');
|
||||
}
|
||||
if (!priority) {
|
||||
aiReasons.push('No priority classification matched');
|
||||
}
|
||||
|
||||
// Check for low confidence results
|
||||
for (const result of results) {
|
||||
if (result && result.confidence === 'low') {
|
||||
aiReasons.push(`Low confidence on ${result.field}: ${result.matched_rule_name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if title looks like it needs cleanup (email subject, too long, garbled)
|
||||
if (ticket.title) {
|
||||
if (ticket.title.length > 150) {
|
||||
aiReasons.push('Title is very long (possible email subject)');
|
||||
}
|
||||
if (/^(re:|fw:|fwd:)/i.test(ticket.title)) {
|
||||
aiReasons.push('Title is a forwarded/replied email subject');
|
||||
}
|
||||
}
|
||||
|
||||
// Determine overall confidence
|
||||
let overallConfidence: ConfidenceLevel;
|
||||
if (aiReasons.length === 0) {
|
||||
overallConfidence = 'high';
|
||||
} else if (aiReasons.length <= 2) {
|
||||
overallConfidence = 'medium';
|
||||
} else {
|
||||
overallConfidence = 'low';
|
||||
}
|
||||
|
||||
return {
|
||||
overallConfidence,
|
||||
needsAi: aiReasons.length > 0,
|
||||
aiReasons,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh the rules cache.
|
||||
*/
|
||||
async refreshCache(): Promise<void> {
|
||||
await this.loadRules(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const roboticClassifier = new RoboticClassifier();
|
||||
151
lib/services/triage-validator.ts
Normal file
151
lib/services/triage-validator.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* Triage Validator
|
||||
* Validates classification output against DB picklists.
|
||||
* Ensures issueType/subIssueType parent-child relationships are correct,
|
||||
* priority exists, queue exists, etc.
|
||||
*/
|
||||
|
||||
import { postgresClient } from './postgres-client';
|
||||
import {
|
||||
ClassificationResult,
|
||||
ValidationResult,
|
||||
ValidationError,
|
||||
} from '../types/workflow';
|
||||
|
||||
// Cache TTL for picklist data (10 minutes)
|
||||
const PICKLIST_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
interface PicklistCache {
|
||||
issueTypes: Set<number>;
|
||||
subIssueTypes: Map<number, number>; // value → parent_value
|
||||
priorities: Set<number>;
|
||||
queues: Set<number>;
|
||||
ticketCategories: Set<number>;
|
||||
loadedAt: number;
|
||||
}
|
||||
|
||||
export class TriageValidator {
|
||||
private cache: PicklistCache | null = null;
|
||||
|
||||
/**
|
||||
* Load picklist data from DB into cache.
|
||||
*/
|
||||
private async loadPicklists(forceRefresh = false): Promise<PicklistCache> {
|
||||
const now = Date.now();
|
||||
if (!forceRefresh && this.cache && now - this.cache.loadedAt < PICKLIST_CACHE_TTL_MS) {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
const [issueTypes, subIssueTypes, priorities, queues, ticketCategories] = await Promise.all([
|
||||
postgresClient.query(`SELECT value FROM issue_types WHERE is_deleted = false`),
|
||||
postgresClient.query(`SELECT value, parent_value FROM sub_issue_types WHERE is_deleted = false`),
|
||||
postgresClient.query(`SELECT value FROM priorities WHERE is_active = true`),
|
||||
postgresClient.query(`SELECT value FROM queues WHERE is_active = true`),
|
||||
postgresClient.query(`SELECT value FROM ticket_categories WHERE is_active = true`),
|
||||
]);
|
||||
|
||||
this.cache = {
|
||||
issueTypes: new Set(issueTypes.rows.map((r: any) => Number(r.value))),
|
||||
subIssueTypes: new Map(
|
||||
subIssueTypes.rows.map((r: any) => [Number(r.value), Number(r.parent_value)])
|
||||
),
|
||||
priorities: new Set(priorities.rows.map((r: any) => Number(r.value))),
|
||||
queues: new Set(queues.rows.map((r: any) => Number(r.value))),
|
||||
ticketCategories: new Set(ticketCategories.rows.map((r: any) => Number(r.value))),
|
||||
loadedAt: now,
|
||||
};
|
||||
|
||||
console.log(
|
||||
`[VALIDATOR] Loaded picklists: ${this.cache.issueTypes.size} issueTypes, ` +
|
||||
`${this.cache.subIssueTypes.size} subIssueTypes, ${this.cache.priorities.size} priorities, ` +
|
||||
`${this.cache.queues.size} queues, ${this.cache.ticketCategories.size} ticketCategories`
|
||||
);
|
||||
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a classification result against DB picklists.
|
||||
*/
|
||||
async validate(classification: ClassificationResult): Promise<ValidationResult> {
|
||||
const picklists = await this.loadPicklists();
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
// Validate issue type
|
||||
if (classification.issue_classification) {
|
||||
const issueTypeValue = Number(classification.issue_classification.value);
|
||||
if (!picklists.issueTypes.has(issueTypeValue)) {
|
||||
errors.push({
|
||||
field: 'issue_type',
|
||||
message: `Issue type ${issueTypeValue} not found in picklist`,
|
||||
value: issueTypeValue,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate sub-issue type
|
||||
if (classification.issue_classification.value_2 != null) {
|
||||
const subIssueTypeValue = Number(classification.issue_classification.value_2);
|
||||
if (!picklists.subIssueTypes.has(subIssueTypeValue)) {
|
||||
errors.push({
|
||||
field: 'sub_issue_type',
|
||||
message: `Sub-issue type ${subIssueTypeValue} not found in picklist`,
|
||||
value: subIssueTypeValue,
|
||||
});
|
||||
} else {
|
||||
// Validate parent-child relationship
|
||||
const parentValue = picklists.subIssueTypes.get(subIssueTypeValue);
|
||||
if (parentValue !== issueTypeValue) {
|
||||
errors.push({
|
||||
field: 'sub_issue_type',
|
||||
message: `Sub-issue type ${subIssueTypeValue} has parent ${parentValue}, but issue type is ${issueTypeValue}`,
|
||||
value: subIssueTypeValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate priority
|
||||
if (classification.priority) {
|
||||
const priorityValue = Number(classification.priority.value);
|
||||
if (!picklists.priorities.has(priorityValue)) {
|
||||
errors.push({
|
||||
field: 'priority',
|
||||
message: `Priority ${priorityValue} not found in picklist`,
|
||||
value: priorityValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate queue
|
||||
if (classification.queue) {
|
||||
const queueValue = Number(classification.queue.value);
|
||||
if (!picklists.queues.has(queueValue)) {
|
||||
errors.push({
|
||||
field: 'queue_id',
|
||||
message: `Queue ${queueValue} not found in picklist`,
|
||||
value: queueValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.warn(`[VALIDATOR] Validation failed with ${errors.length} error(s):`, errors);
|
||||
}
|
||||
|
||||
return {
|
||||
is_valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh the picklist cache.
|
||||
*/
|
||||
async refreshCache(): Promise<void> {
|
||||
await this.loadPicklists(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const triageValidator = new TriageValidator();
|
||||
|
|
@ -6,6 +6,8 @@ import {
|
|||
VspcBackupAgentJob,
|
||||
VspcProtectedWorkload,
|
||||
VspcRepository,
|
||||
VspcBackupAgent,
|
||||
VspcAlarm,
|
||||
} from '@/lib/types/veeam';
|
||||
|
||||
export interface VeeamClientConfig {
|
||||
|
|
@ -129,16 +131,20 @@ export class VeeamClient {
|
|||
const url = this.buildUrl(path, params);
|
||||
const response = await this.makeApiCall<VspcListResponse<T>>(url);
|
||||
|
||||
if (response.data && response.data.length > 0) {
|
||||
allItems.push(...response.data);
|
||||
const pageData = response.data ?? [];
|
||||
if (pageData.length > 0) {
|
||||
allItems.push(...pageData);
|
||||
}
|
||||
|
||||
total = response.meta?.pagingInfo?.total ?? 0;
|
||||
offset += this.DEFAULT_PAGE_SIZE;
|
||||
|
||||
if (response.data.length > 0) {
|
||||
if (pageData.length > 0) {
|
||||
console.log(`Veeam VSPC: fetched ${allItems.length}/${total} from ${path}`);
|
||||
}
|
||||
|
||||
// Safety: if page returned nothing and we haven't hit total, stop
|
||||
if (pageData.length === 0) break;
|
||||
} while (offset < total);
|
||||
|
||||
return allItems;
|
||||
|
|
@ -208,6 +214,26 @@ export class VeeamClient {
|
|||
return repos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all backup agents (Veeam agent installs on managed machines)
|
||||
*/
|
||||
async getBackupAgents(): Promise<VspcBackupAgent[]> {
|
||||
console.log('Fetching Veeam VSPC backup agents...');
|
||||
const agents = await this.fetchAllPages<VspcBackupAgent>('/infrastructure/backupAgents');
|
||||
console.log(`Fetched ${agents.length} Veeam backup agents`);
|
||||
return agents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all active VSPC alarms
|
||||
*/
|
||||
async getActiveAlarms(): Promise<VspcAlarm[]> {
|
||||
console.log('Fetching Veeam VSPC active alarms...');
|
||||
const alarms = await this.fetchAllPages<VspcAlarm>('/alarms/active');
|
||||
console.log(`Fetched ${alarms.length} Veeam active alarms`);
|
||||
return alarms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test API connectivity by fetching a single organization
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import {
|
|||
VspcBackupAgentJob,
|
||||
VspcProtectedWorkload,
|
||||
VspcRepository,
|
||||
VspcBackupAgent,
|
||||
VspcAlarm,
|
||||
} from '@/lib/types/veeam';
|
||||
import { VeeamComplianceService } from './veeam-compliance-service';
|
||||
|
||||
|
|
@ -97,6 +99,8 @@ export class VeeamSyncService {
|
|||
{ name: 'backup_jobs', fn: () => this.syncBackupJobs() },
|
||||
{ name: 'backup_agent_jobs', fn: () => this.syncBackupAgentJobs() },
|
||||
{ name: 'protected_workloads', fn: () => this.syncProtectedWorkloads() },
|
||||
{ name: 'backup_agents', fn: () => this.syncBackupAgents() },
|
||||
{ name: 'alarms', fn: () => this.syncAlarms() },
|
||||
];
|
||||
|
||||
for (const step of steps) {
|
||||
|
|
@ -136,8 +140,8 @@ export class VeeamSyncService {
|
|||
if (historyId) {
|
||||
try {
|
||||
await postgresClient.query(
|
||||
`UPDATE sync_history SET status = $1, completed_at = $2, records_added = $3, error_message = $4 WHERE id = $5`,
|
||||
[status, completedAt, totalRecords, errors.length > 0 ? errors.join('; ') : null, historyId]
|
||||
`UPDATE sync_history SET status = $1, completed_at = $2, records_added = $3, error_message = $4, entity_details = $5 WHERE id = $6`,
|
||||
[status, completedAt, totalRecords, errors.length > 0 ? errors.join('; ') : null, JSON.stringify(entityResults), historyId]
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('[VEEAM-SYNC] Could not update sync history:', e);
|
||||
|
|
@ -399,4 +403,101 @@ export class VeeamSyncService {
|
|||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private async syncBackupAgents(): Promise<number> {
|
||||
const agents = await this.client.getBackupAgents();
|
||||
if (agents.length === 0) return 0;
|
||||
|
||||
const knownOrgs = await postgresClient.query('SELECT instance_uid FROM veeam_organizations');
|
||||
const orgUids = new Set(knownOrgs.rows.map((r: any) => r.instance_uid));
|
||||
|
||||
let count = 0;
|
||||
for (const a of agents) {
|
||||
const orgUid = orgUids.has(a.organizationUid) ? a.organizationUid : null;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO veeam_backup_agents (instance_uid, organization_uid, site_uid, management_agent_uid,
|
||||
name, agent_platform, status, management_agent_status, operation_mode, gui_mode,
|
||||
platform, version, version_status, management_mode, installation_type, activation_time,
|
||||
total_jobs_count, running_jobs_count, success_jobs_count, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,NOW())
|
||||
ON CONFLICT (instance_uid) DO UPDATE SET
|
||||
organization_uid=EXCLUDED.organization_uid, site_uid=EXCLUDED.site_uid,
|
||||
management_agent_uid=EXCLUDED.management_agent_uid, name=EXCLUDED.name,
|
||||
agent_platform=EXCLUDED.agent_platform, status=EXCLUDED.status,
|
||||
management_agent_status=EXCLUDED.management_agent_status, operation_mode=EXCLUDED.operation_mode,
|
||||
gui_mode=EXCLUDED.gui_mode, platform=EXCLUDED.platform, version=EXCLUDED.version,
|
||||
version_status=EXCLUDED.version_status, management_mode=EXCLUDED.management_mode,
|
||||
installation_type=EXCLUDED.installation_type, activation_time=EXCLUDED.activation_time,
|
||||
total_jobs_count=EXCLUDED.total_jobs_count, running_jobs_count=EXCLUDED.running_jobs_count,
|
||||
success_jobs_count=EXCLUDED.success_jobs_count, synced_at=NOW(), updated_at=NOW()`,
|
||||
[
|
||||
a.instanceUid, orgUid, a.siteUid || null, a.managementAgentUid || null,
|
||||
a.name || a.instanceUid, a.agentPlatform || null, a.status || null, a.managementAgentStatus || null,
|
||||
a.operationMode || null, a.guiMode || null, a.platform || null,
|
||||
a.version || null, a.versionStatus || null, a.managementMode || null,
|
||||
a.installationType || null, a.activationTime ? new Date(a.activationTime) : null,
|
||||
a.totalJobsCount ?? 0, a.runningJobsCount ?? 0, a.successJobsCount ?? 0,
|
||||
]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private async syncAlarms(): Promise<number> {
|
||||
const alarms = await this.client.getActiveAlarms();
|
||||
if (alarms.length === 0) return 0;
|
||||
|
||||
const knownOrgs = await postgresClient.query('SELECT instance_uid FROM veeam_organizations');
|
||||
const orgUids = new Set(knownOrgs.rows.map((r: any) => r.instance_uid));
|
||||
|
||||
let count = 0;
|
||||
for (const alarm of alarms) {
|
||||
const orgUid = alarm.object?.organizationUid && orgUids.has(alarm.object.organizationUid)
|
||||
? alarm.object.organizationUid : null;
|
||||
const lastStatus = alarm.lastActivation?.status || null;
|
||||
const resolved = lastStatus === 'Resolved';
|
||||
|
||||
await postgresClient.query(
|
||||
`INSERT INTO veeam_alarms (instance_uid, alarm_template_uid, repeat_count,
|
||||
object_uid, object_type, object_name, object_computer_name,
|
||||
organization_uid, location_uid, management_agent_uid,
|
||||
last_activation_uid, last_activation_time, last_activation_status,
|
||||
last_activation_message, last_activation_remark, area, resolved, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,NOW())
|
||||
ON CONFLICT (instance_uid) DO UPDATE SET
|
||||
alarm_template_uid=EXCLUDED.alarm_template_uid, repeat_count=EXCLUDED.repeat_count,
|
||||
object_uid=EXCLUDED.object_uid, object_type=EXCLUDED.object_type,
|
||||
object_name=EXCLUDED.object_name, object_computer_name=EXCLUDED.object_computer_name,
|
||||
organization_uid=EXCLUDED.organization_uid, location_uid=EXCLUDED.location_uid,
|
||||
management_agent_uid=EXCLUDED.management_agent_uid,
|
||||
last_activation_uid=EXCLUDED.last_activation_uid,
|
||||
last_activation_time=EXCLUDED.last_activation_time,
|
||||
last_activation_status=EXCLUDED.last_activation_status,
|
||||
last_activation_message=EXCLUDED.last_activation_message,
|
||||
last_activation_remark=EXCLUDED.last_activation_remark,
|
||||
area=EXCLUDED.area, resolved=EXCLUDED.resolved,
|
||||
synced_at=NOW(), updated_at=NOW()`,
|
||||
[
|
||||
alarm.instanceUid, alarm.alarmTemplateUid || null, alarm.repeatCount ?? 0,
|
||||
alarm.object?.objectUid || alarm.object?.instanceUid || null,
|
||||
alarm.object?.type || null,
|
||||
alarm.object?.objectName || null,
|
||||
alarm.object?.computerName || null,
|
||||
orgUid,
|
||||
alarm.object?.locationUid || null,
|
||||
alarm.object?.managementAgentUid || null,
|
||||
alarm.lastActivation?.instanceUid || null,
|
||||
alarm.lastActivation?.time ? new Date(alarm.lastActivation.time) : null,
|
||||
lastStatus,
|
||||
alarm.lastActivation?.message || null,
|
||||
alarm.lastActivation?.remark || null,
|
||||
alarm.area || null,
|
||||
resolved,
|
||||
]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,59 @@
|
|||
* Handles incoming webhooks from Autotask for real-time updates
|
||||
*/
|
||||
|
||||
import { createHmac } from 'crypto';
|
||||
import { postgresClient } from './postgres-client';
|
||||
import { AutotaskWebhookPayload, WebhookProcessingResult, WebhookLog, WebhookEventType, WebhookEntityType } from '../types/webhook';
|
||||
import { EntityType } from '../types/sync';
|
||||
import { mapAutotaskToDatabase } from '../utils/entity-mapper';
|
||||
import { getTableName } from '../utils/sync-helpers';
|
||||
import { getTableName, getAutotaskEntityName } from '../utils/sync-helpers';
|
||||
import { AutotaskClient } from './autotask-client';
|
||||
import { workflowEngine } from './workflow-engine';
|
||||
import { WorkflowEvent, TicketData } from '../types/workflow';
|
||||
|
||||
export class WebhookService {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify webhook signature.
|
||||
* Autotask sends: x-hook-signature: sha1=<base64>
|
||||
* Computed as HMAC-SHA1 of the raw body using the secret key.
|
||||
*/
|
||||
verifySignature(rawBody: string, signatureHeader: string | null): boolean {
|
||||
const secret = process.env.AUTOTASK_WEBHOOK_SECRET;
|
||||
if (!secret) {
|
||||
console.warn('[WEBHOOK] No AUTOTASK_WEBHOOK_SECRET configured, skipping signature verification');
|
||||
return true;
|
||||
}
|
||||
if (!signatureHeader) {
|
||||
console.warn('[WEBHOOK] No x-hook-signature header in webhook request');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Header format: "sha1=<base64>"
|
||||
const signature = signatureHeader.startsWith('sha1=')
|
||||
? signatureHeader.slice(5)
|
||||
: signatureHeader;
|
||||
|
||||
const computed = createHmac('sha1', secret).update(rawBody).digest('base64');
|
||||
const match = computed === signature;
|
||||
if (!match) {
|
||||
console.warn(`[WEBHOOK] Signature mismatch: expected=${computed}, received=${signature}`);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
/**
|
||||
* Process an incoming webhook from Autotask
|
||||
*/
|
||||
|
|
@ -60,7 +106,14 @@ export class WebhookService {
|
|||
await this.updateWebhookLog(payload.eventId, 'processed', undefined, processingTime);
|
||||
|
||||
console.log(`[WEBHOOK] Successfully processed ${payload.eventType} for ${payload.entityType} #${payload.entityId} in ${processingTime}ms`);
|
||||
|
||||
|
||||
// Trigger workflow engine for new tickets (fire-and-forget)
|
||||
if (payload.entityType === WebhookEntityType.TICKETS && payload.eventType === WebhookEventType.CREATE) {
|
||||
this.triggerWorkflowEngine(payload).catch(err =>
|
||||
console.error('[WEBHOOK] Workflow engine error:', err)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
eventId: payload.eventId,
|
||||
|
|
@ -95,17 +148,28 @@ export class WebhookService {
|
|||
* Handle create or update events
|
||||
*/
|
||||
private async handleCreateOrUpdate(payload: AutotaskWebhookPayload): Promise<'created' | 'updated'> {
|
||||
// If webhook includes full entity data, use it
|
||||
if (payload.entity) {
|
||||
// If webhook includes full entity data, use it directly
|
||||
if (payload.entity && Object.keys(payload.entity).length > 2) {
|
||||
await this.upsertEntity(payload.entityType, payload.entity);
|
||||
return payload.eventType === WebhookEventType.CREATE ? 'created' : 'updated';
|
||||
}
|
||||
|
||||
// Otherwise, fetch the entity from Autotask API
|
||||
// Note: This requires the AutotaskClient to fetch individual entities
|
||||
// For now, we'll log and skip - can be enhanced later
|
||||
console.warn(`[WEBHOOK] Entity data not included in webhook, skipping upsert for ${payload.entityType} #${payload.entityId}`);
|
||||
return 'updated';
|
||||
// Fetch the full entity from Autotask API
|
||||
const internalEntityType = this.mapWebhookEntityType(payload.entityType);
|
||||
const autotaskEntityName = getAutotaskEntityName(internalEntityType);
|
||||
|
||||
console.log(`[WEBHOOK] Fetching ${autotaskEntityName} #${payload.entityId} from Autotask API`);
|
||||
|
||||
const client = this.getAutotaskClient();
|
||||
const entity = await client.getEntityById(autotaskEntityName, payload.entityId);
|
||||
|
||||
if (!entity) {
|
||||
console.warn(`[WEBHOOK] Entity ${autotaskEntityName} #${payload.entityId} not found in Autotask API`);
|
||||
return payload.eventType === WebhookEventType.CREATE ? 'created' : 'updated';
|
||||
}
|
||||
|
||||
await this.upsertEntity(payload.entityType, entity as Record<string, any>);
|
||||
return payload.eventType === WebhookEventType.CREATE ? 'created' : 'updated';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -263,21 +327,16 @@ export class WebhookService {
|
|||
}> {
|
||||
const query = `
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE status = 'processed') as processed,
|
||||
COUNT(*) FILTER (WHERE status = 'failed') as failed,
|
||||
COUNT(*) FILTER (WHERE status = 'pending') as pending,
|
||||
jsonb_object_agg(entity_type, entity_count) as by_entity_type
|
||||
FROM (
|
||||
SELECT
|
||||
entity_type,
|
||||
COUNT(*) as entity_count
|
||||
FROM webhook_logs
|
||||
WHERE received_at >= NOW() - INTERVAL '${hours} hours'
|
||||
GROUP BY entity_type
|
||||
) entity_counts,
|
||||
webhook_logs
|
||||
WHERE received_at >= NOW() - INTERVAL '${hours} hours'
|
||||
(SELECT COUNT(*) FROM webhook_logs WHERE received_at >= NOW() - INTERVAL '${hours} hours') as total,
|
||||
(SELECT COUNT(*) FROM webhook_logs WHERE received_at >= NOW() - INTERVAL '${hours} hours' AND status = 'processed') as processed,
|
||||
(SELECT COUNT(*) FROM webhook_logs WHERE received_at >= NOW() - INTERVAL '${hours} hours' AND status = 'failed') as failed,
|
||||
(SELECT COUNT(*) FROM webhook_logs WHERE received_at >= NOW() - INTERVAL '${hours} hours' AND status = 'pending') as pending,
|
||||
(SELECT COALESCE(jsonb_object_agg(entity_type, entity_count), '{}'::jsonb) FROM (
|
||||
SELECT entity_type, COUNT(*) as entity_count
|
||||
FROM webhook_logs
|
||||
WHERE received_at >= NOW() - INTERVAL '${hours} hours'
|
||||
GROUP BY entity_type
|
||||
) ec) as by_entity_type
|
||||
`;
|
||||
|
||||
const result = await postgresClient.query(query);
|
||||
|
|
@ -309,6 +368,7 @@ export class WebhookService {
|
|||
const mapping: Record<WebhookEntityType, EntityType> = {
|
||||
[WebhookEntityType.COMPANIES]: EntityType.COMPANIES,
|
||||
[WebhookEntityType.TICKETS]: EntityType.TICKETS,
|
||||
[WebhookEntityType.TICKET_NOTES]: EntityType.TICKET_NOTES,
|
||||
[WebhookEntityType.TASKS]: EntityType.TASKS,
|
||||
[WebhookEntityType.PROJECTS]: EntityType.PROJECTS,
|
||||
[WebhookEntityType.TIME_ENTRIES]: EntityType.TIME_ENTRIES,
|
||||
|
|
@ -327,6 +387,45 @@ export class WebhookService {
|
|||
const entityType = this.mapWebhookEntityType(webhookType);
|
||||
return getTableName(entityType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger the workflow engine for a new ticket.
|
||||
* Runs asynchronously — does not block webhook response.
|
||||
*/
|
||||
private async triggerWorkflowEngine(payload: AutotaskWebhookPayload): Promise<void> {
|
||||
const event: WorkflowEvent = {
|
||||
trigger_event: 'ticket.created',
|
||||
entity_type: 'ticket',
|
||||
entity_id: payload.entityId,
|
||||
ticket_number: payload.fields?.ticketNumber || undefined,
|
||||
};
|
||||
|
||||
// If the webhook payload includes the full entity, build TicketData from it
|
||||
if (payload.entity) {
|
||||
event.ticket_data = {
|
||||
id: payload.entityId,
|
||||
ticket_number: payload.entity.ticketNumber || null,
|
||||
title: payload.entity.title || '',
|
||||
description: payload.entity.description || null,
|
||||
ticket_category: payload.entity.ticketCategory || null,
|
||||
ticket_type: payload.entity.ticketType || null,
|
||||
priority: payload.entity.priority || null,
|
||||
queue_id: payload.entity.queueID || null,
|
||||
issue_type: payload.entity.issueType || null,
|
||||
sub_issue_type: payload.entity.subIssueType || null,
|
||||
company_id: payload.entity.companyID || 0,
|
||||
contact_id: payload.entity.contactID || null,
|
||||
assigned_resource_id: payload.entity.assignedResourceID || null,
|
||||
creator_resource_id: payload.entity.creatorResourceID || null,
|
||||
person_id: payload.personId || null,
|
||||
status: payload.entity.status || null,
|
||||
source: payload.entity.source || null,
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[WEBHOOK] Triggering workflow engine for ticket ${payload.entityId}`);
|
||||
await workflowEngine.process(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
|
|
|
|||
954
lib/services/workflow-engine.ts
Normal file
954
lib/services/workflow-engine.ts
Normal file
|
|
@ -0,0 +1,954 @@
|
|||
/**
|
||||
* 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();
|
||||
|
|
@ -140,6 +140,19 @@ export interface Company {
|
|||
competitorID?: number;
|
||||
}
|
||||
|
||||
export interface TicketNote {
|
||||
id: number;
|
||||
ticketID: number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
noteType?: number;
|
||||
publish?: number;
|
||||
creatorResourceID?: number;
|
||||
creatorType?: number;
|
||||
lastActivityDate?: string;
|
||||
createDateTime?: string;
|
||||
}
|
||||
|
||||
export interface ConfigurationItem {
|
||||
id: number;
|
||||
companyID: number;
|
||||
|
|
|
|||
|
|
@ -435,6 +435,13 @@ export interface SyncHistoryRecord {
|
|||
triggered_by?: string | null;
|
||||
}
|
||||
|
||||
// Device Lifecycle Policy entity
|
||||
export interface DeviceLifecyclePolicy extends AuditFields {
|
||||
id: number;
|
||||
device_type: string;
|
||||
expected_months: number;
|
||||
}
|
||||
|
||||
// Time Entry entity
|
||||
export interface TimeEntry extends AuditFields {
|
||||
id: number;
|
||||
|
|
@ -492,9 +499,9 @@ export type Entity =
|
|||
| IssueType
|
||||
| SubIssueType
|
||||
| WorkType
|
||||
| TimeEntry;
|
||||
| TimeEntry
|
||||
| DeviceLifecyclePolicy;
|
||||
|
||||
// Table name type
|
||||
export type TableName =
|
||||
| 'companies'
|
||||
| 'resources'
|
||||
|
|
@ -510,4 +517,5 @@ export type TableName =
|
|||
| 'sub_issue_types'
|
||||
| 'work_types'
|
||||
| 'time_entries'
|
||||
| 'sync_history';
|
||||
| 'sync_history'
|
||||
| 'device_lifecycle_policies';
|
||||
|
|
|
|||
|
|
@ -62,28 +62,72 @@ export interface DattoRMMDevice {
|
|||
}
|
||||
|
||||
export interface DattoRMMSite {
|
||||
id: string;
|
||||
id: number;
|
||||
uid: string;
|
||||
accountUid?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
notes: string;
|
||||
notes: string | null;
|
||||
onDemand: boolean;
|
||||
splashtopAutoInstall?: boolean;
|
||||
proxySettings?: {
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
} | null;
|
||||
devicesStatus?: {
|
||||
numberOfDevices: number;
|
||||
numberOfOnlineDevices: number;
|
||||
numberOfOfflineDevices: number;
|
||||
};
|
||||
autotaskCompanyName?: string;
|
||||
autotaskCompanyId?: string;
|
||||
portalUrl?: string;
|
||||
devices?: DattoRMMDevice[];
|
||||
}
|
||||
|
||||
export interface DattoRMMAlert {
|
||||
alertUid: string;
|
||||
priority: string;
|
||||
diagnostics: string | null;
|
||||
resolved: boolean;
|
||||
resolvedBy: string | null;
|
||||
resolvedOn: number | null;
|
||||
muted: boolean;
|
||||
ticketNumber: string | null;
|
||||
timestamp: number;
|
||||
alertMonitorInfo?: {
|
||||
sendsEmails: boolean;
|
||||
createsTicket: boolean;
|
||||
};
|
||||
alertContext?: {
|
||||
'@class': string;
|
||||
[key: string]: any;
|
||||
};
|
||||
alertSourceInfo?: {
|
||||
deviceUid: string;
|
||||
deviceName: string;
|
||||
siteUid: string;
|
||||
siteName: string;
|
||||
};
|
||||
responseActions?: Array<{
|
||||
actionTime: number;
|
||||
actionType: string;
|
||||
description: string;
|
||||
actionReference: string | null;
|
||||
actionReferenceInt: string | null;
|
||||
}> | null;
|
||||
autoresolveMins?: number;
|
||||
}
|
||||
|
||||
export interface DattoRMMApiResponse<T> {
|
||||
items?: T[];
|
||||
item?: T;
|
||||
pageDetails?: {
|
||||
page: number;
|
||||
perPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
count: number;
|
||||
totalCount?: number;
|
||||
prevPageUrl: string | null;
|
||||
nextPageUrl: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,11 +14,15 @@ export enum EntityType {
|
|||
ISSUE_TYPES = 'issue_types',
|
||||
SUB_ISSUE_TYPES = 'sub_issue_types',
|
||||
WORK_TYPES = 'work_types',
|
||||
QUEUES = 'queues',
|
||||
PRIORITIES = 'priorities',
|
||||
TICKET_CATEGORIES = 'ticket_categories',
|
||||
BILLING_ITEMS = 'billing_items',
|
||||
CONFIGURATION_ITEMS = 'configuration_items',
|
||||
CONTACTS = 'contacts',
|
||||
CONTRACTS = 'contracts',
|
||||
TIME_ENTRIES = 'time_entries',
|
||||
TICKET_NOTES = 'ticket_notes',
|
||||
}
|
||||
|
||||
// Sync operation types
|
||||
|
|
@ -151,6 +155,9 @@ export const ENTITY_DEPENDENCIES: Record<EntityType, EntityType[]> = {
|
|||
[EntityType.ISSUE_TYPES]: [], // No dependencies
|
||||
[EntityType.SUB_ISSUE_TYPES]: [], // No dependencies
|
||||
[EntityType.WORK_TYPES]: [], // No dependencies
|
||||
[EntityType.QUEUES]: [], // No dependencies
|
||||
[EntityType.PRIORITIES]: [], // No dependencies
|
||||
[EntityType.TICKET_CATEGORIES]: [], // No dependencies
|
||||
[EntityType.CONTACTS]: [EntityType.COMPANIES], // Depends on companies
|
||||
[EntityType.PROJECTS]: [EntityType.COMPANIES, EntityType.RESOURCES], // Depends on companies and resources
|
||||
[EntityType.TICKETS]: [EntityType.COMPANIES, EntityType.RESOURCES, EntityType.CONTACTS], // Depends on companies, resources, contacts
|
||||
|
|
@ -159,6 +166,7 @@ export const ENTITY_DEPENDENCIES: Record<EntityType, EntityType[]> = {
|
|||
[EntityType.CONTRACTS]: [EntityType.COMPANIES, EntityType.CONTACTS], // Depends on companies and contacts
|
||||
[EntityType.BILLING_ITEMS]: [EntityType.COMPANIES, EntityType.TASKS, EntityType.TICKETS, EntityType.PROJECTS], // Depends on multiple entities
|
||||
[EntityType.TIME_ENTRIES]: [EntityType.COMPANIES, EntityType.RESOURCES, EntityType.CONTACTS, EntityType.PROJECTS, EntityType.TASKS, EntityType.TICKETS], // Depends on many entities
|
||||
[EntityType.TICKET_NOTES]: [EntityType.TICKETS], // Depends on tickets
|
||||
};
|
||||
|
||||
// Autotask API field names (for incremental sync)
|
||||
|
|
|
|||
|
|
@ -367,3 +367,57 @@ export interface VeeamComplianceSummary {
|
|||
backedUpNotContracted: number;
|
||||
computedAt: Date | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// New entity types: Backup Agents + Alarms
|
||||
// ============================================================================
|
||||
|
||||
export interface VspcBackupAgent {
|
||||
instanceUid: string;
|
||||
organizationUid: string;
|
||||
siteUid?: string;
|
||||
managementAgentUid?: string;
|
||||
name: string;
|
||||
agentPlatform: string;
|
||||
status: string;
|
||||
managementAgentStatus: string;
|
||||
operationMode: string;
|
||||
guiMode?: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
versionStatus?: string;
|
||||
managementMode?: string;
|
||||
installationType?: string;
|
||||
activationTime?: string;
|
||||
totalJobsCount: number;
|
||||
runningJobsCount: number;
|
||||
successJobsCount: number;
|
||||
}
|
||||
|
||||
export interface VspcAlarmActivation {
|
||||
instanceUid: string;
|
||||
time: string;
|
||||
status: string;
|
||||
message: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface VspcAlarmObject {
|
||||
instanceUid: string;
|
||||
type: string;
|
||||
organizationUid?: string;
|
||||
locationUid?: string;
|
||||
managementAgentUid?: string;
|
||||
computerName?: string;
|
||||
objectUid?: string;
|
||||
objectName?: string;
|
||||
}
|
||||
|
||||
export interface VspcAlarm {
|
||||
instanceUid: string;
|
||||
alarmTemplateUid: string;
|
||||
repeatCount: number;
|
||||
object: VspcAlarmObject;
|
||||
lastActivation: VspcAlarmActivation;
|
||||
area?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export enum WebhookEventType {
|
|||
export enum WebhookEntityType {
|
||||
COMPANIES = 'Companies',
|
||||
TICKETS = 'Tickets',
|
||||
TICKET_NOTES = 'TicketNotes',
|
||||
TASKS = 'Tasks',
|
||||
PROJECTS = 'Projects',
|
||||
TIME_ENTRIES = 'TimeEntries',
|
||||
|
|
@ -27,43 +28,125 @@ export enum WebhookEntityType {
|
|||
}
|
||||
|
||||
/**
|
||||
* Autotask webhook payload structure
|
||||
* Autotask entities that support webhooks via their REST API
|
||||
*/
|
||||
export const WEBHOOK_SUPPORTED_ENTITIES: WebhookEntityType[] = [
|
||||
WebhookEntityType.COMPANIES,
|
||||
WebhookEntityType.CONTACTS,
|
||||
WebhookEntityType.CONFIGURATION_ITEMS,
|
||||
WebhookEntityType.TICKETS,
|
||||
WebhookEntityType.TICKET_NOTES,
|
||||
];
|
||||
|
||||
/**
|
||||
* Autotask webhook registration request
|
||||
*/
|
||||
export interface AutotaskWebhookRegistration {
|
||||
IsActive: boolean;
|
||||
DeactivationUrl: string;
|
||||
IsSubscribedToCreateEvents?: boolean;
|
||||
IsSubscribedToUpdateEvents?: boolean;
|
||||
IsSubscribedToDeleteEvents?: boolean;
|
||||
Name: string;
|
||||
SecretKey: string;
|
||||
SendThresholdExceededNotification: boolean;
|
||||
WebhookUrl: string;
|
||||
NotificationEmailAddress: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Autotask webhook field trigger
|
||||
*/
|
||||
export interface AutotaskWebhookFieldTrigger {
|
||||
FieldID: number;
|
||||
IsDisplayAlwaysField: boolean;
|
||||
IsSubscribedField: boolean;
|
||||
WebhookID: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Autotask webhook registration result
|
||||
*/
|
||||
export interface AutotaskWebhookRegistrationResult {
|
||||
entityType: WebhookEntityType;
|
||||
webhookId: number;
|
||||
fieldsRegistered: number;
|
||||
excludedResources: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw Autotask webhook payload as actually sent by Autotask
|
||||
*/
|
||||
export interface AutotaskRawWebhookPayload {
|
||||
Action: string; // "Create", "Update", "Delete"
|
||||
Guid: string; // Unique event GUID
|
||||
EntityType: string; // Singular: "Ticket", "Company", "Contact", "ConfigurationItem", "TicketNote"
|
||||
Id: number; // Entity ID
|
||||
Fields?: Record<string, string>; // Changed/created fields
|
||||
EventTime: string; // ISO timestamp
|
||||
SequenceNumber?: number;
|
||||
PersonId?: number; // Resource who triggered the event
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized webhook payload used internally
|
||||
*/
|
||||
export interface AutotaskWebhookPayload {
|
||||
/**
|
||||
* Unique identifier for the webhook event
|
||||
*/
|
||||
eventId: string;
|
||||
|
||||
/**
|
||||
* Type of event (create, update, delete)
|
||||
*/
|
||||
eventType: WebhookEventType;
|
||||
|
||||
/**
|
||||
* Entity type that triggered the webhook
|
||||
*/
|
||||
entityType: WebhookEntityType;
|
||||
|
||||
/**
|
||||
* ID of the entity that changed
|
||||
*/
|
||||
entityId: number;
|
||||
|
||||
/**
|
||||
* Timestamp when the event occurred
|
||||
*/
|
||||
eventTimestamp: string;
|
||||
|
||||
/**
|
||||
* Optional: Full entity data (if configured in webhook)
|
||||
*/
|
||||
fields?: Record<string, string>;
|
||||
entity?: Record<string, any>;
|
||||
personId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional: Previous values for update events
|
||||
*/
|
||||
previousValues?: Record<string, any>;
|
||||
/**
|
||||
* Map Autotask singular EntityType to our WebhookEntityType enum
|
||||
*/
|
||||
const ENTITY_TYPE_MAP: Record<string, WebhookEntityType> = {
|
||||
'Company': WebhookEntityType.COMPANIES,
|
||||
'Contact': WebhookEntityType.CONTACTS,
|
||||
'ConfigurationItem': WebhookEntityType.CONFIGURATION_ITEMS,
|
||||
'Ticket': WebhookEntityType.TICKETS,
|
||||
'TicketNote': WebhookEntityType.TICKET_NOTES,
|
||||
};
|
||||
|
||||
/**
|
||||
* Map Autotask Action string to our WebhookEventType enum
|
||||
*/
|
||||
const ACTION_MAP: Record<string, WebhookEventType> = {
|
||||
'Create': WebhookEventType.CREATE,
|
||||
'Update': WebhookEventType.UPDATE,
|
||||
'Delete': WebhookEventType.DELETE,
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize a raw Autotask webhook payload into our internal format
|
||||
*/
|
||||
export function normalizeWebhookPayload(raw: AutotaskRawWebhookPayload): AutotaskWebhookPayload {
|
||||
const entityType = ENTITY_TYPE_MAP[raw.EntityType];
|
||||
if (!entityType) {
|
||||
throw new Error(`Unknown Autotask EntityType: ${raw.EntityType}`);
|
||||
}
|
||||
|
||||
const eventType = ACTION_MAP[raw.Action];
|
||||
if (!eventType) {
|
||||
throw new Error(`Unknown Autotask Action: ${raw.Action}`);
|
||||
}
|
||||
|
||||
return {
|
||||
eventId: raw.Guid,
|
||||
eventType,
|
||||
entityType,
|
||||
entityId: raw.Id,
|
||||
eventTimestamp: raw.EventTime,
|
||||
fields: raw.Fields,
|
||||
personId: raw.PersonId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
427
lib/types/workflow.ts
Normal file
427
lib/types/workflow.ts
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
/**
|
||||
* Workflow Engine Types
|
||||
* TypeScript definitions for the ticket triage workflow engine
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Enums & Constants
|
||||
// ============================================================================
|
||||
|
||||
export type RuleType =
|
||||
| 'branch_routing'
|
||||
| 'ticket_type'
|
||||
| 'issue_classification'
|
||||
| 'priority'
|
||||
| 'queue_routing';
|
||||
|
||||
export type MatchField =
|
||||
| 'title'
|
||||
| 'description'
|
||||
| 'title_or_description'
|
||||
| 'ticket_category'
|
||||
| 'policy_name'
|
||||
| 'device_name'
|
||||
| 'creator_resource_id'
|
||||
| 'priority'
|
||||
| 'ticket_type'
|
||||
| 'person_id'
|
||||
| 'company_id';
|
||||
|
||||
export type MatchOperator =
|
||||
| 'contains'
|
||||
| 'starts_with'
|
||||
| 'regex'
|
||||
| 'equals'
|
||||
| 'in'
|
||||
| 'not_in';
|
||||
|
||||
export type ConditionOperator =
|
||||
| 'equals'
|
||||
| 'not_equals'
|
||||
| 'in'
|
||||
| 'not_in'
|
||||
| 'contains'
|
||||
| 'not_contains'
|
||||
| 'regex'
|
||||
| 'gt'
|
||||
| 'lt'
|
||||
| 'is_null'
|
||||
| 'is_not_null';
|
||||
|
||||
export type ConfidenceLevel = 'high' | 'medium' | 'low';
|
||||
|
||||
export type Branch = 'service_desk' | 'noc' | 'soc';
|
||||
|
||||
export type ClassificationMethod = 'robotic' | 'ai' | 'hybrid';
|
||||
|
||||
export type ExecutionStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
|
||||
export type StepMethod = 'robotic' | 'ai' | 'skipped';
|
||||
|
||||
export type ActionType =
|
||||
| 'set_field'
|
||||
| 'classify'
|
||||
| 'ai_enhance'
|
||||
| 'create_note'
|
||||
| 'update_autotask'
|
||||
| 'delay'
|
||||
| 'skip';
|
||||
|
||||
export type PromptPurpose =
|
||||
| 'title_cleanup'
|
||||
| 'description_rewrite'
|
||||
| 'ambiguous_classification'
|
||||
| 'troubleshooting_steps'
|
||||
| 'noc_format'
|
||||
| 'soc_analysis';
|
||||
|
||||
export type TriggerEvent = 'ticket.created' | 'ticket.updated';
|
||||
|
||||
export type StepName =
|
||||
| 'filter'
|
||||
| 'branch_routing'
|
||||
| 'ticket_type'
|
||||
| 'issue_classification'
|
||||
| 'priority'
|
||||
| 'queue_routing'
|
||||
| 'validation'
|
||||
| 'ai_title'
|
||||
| 'ai_description'
|
||||
| 'ai_classification'
|
||||
| 'ai_troubleshooting'
|
||||
| 'autotask_update'
|
||||
| 'create_note';
|
||||
|
||||
// ============================================================================
|
||||
// Database Row Types
|
||||
// ============================================================================
|
||||
|
||||
export interface ClassificationRule {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
rule_type: RuleType;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
match_field: MatchField;
|
||||
match_operator: MatchOperator;
|
||||
match_value: any; // JSONB: string | string[] | regex pattern
|
||||
match_case_sensitive: boolean;
|
||||
result_field: string;
|
||||
result_value: any; // JSONB
|
||||
result_field_2: string | null;
|
||||
result_value_2: any | null;
|
||||
confidence: ConfidenceLevel;
|
||||
stop_on_match: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface WorkflowRule {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_active: boolean;
|
||||
sort_order: number;
|
||||
trigger_event: TriggerEvent;
|
||||
trigger_entity: string;
|
||||
stop_processing: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface WorkflowCondition {
|
||||
id: number;
|
||||
rule_id: number;
|
||||
condition_group: number;
|
||||
field: string;
|
||||
operator: ConditionOperator;
|
||||
value: any; // JSONB
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface WorkflowAction {
|
||||
id: number;
|
||||
rule_id: number;
|
||||
sort_order: number;
|
||||
action_type: ActionType;
|
||||
config: Record<string, any>;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface AiPromptTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
purpose: PromptPurpose;
|
||||
system_prompt: string;
|
||||
user_prompt_template: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
temperature: number;
|
||||
max_tokens: number;
|
||||
is_active: boolean;
|
||||
version: number;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface WorkflowExecution {
|
||||
id: number;
|
||||
trigger_event: string;
|
||||
entity_type: string;
|
||||
entity_id: number;
|
||||
ticket_number: string | null;
|
||||
status: ExecutionStatus;
|
||||
classification_method: ClassificationMethod | null;
|
||||
branch: Branch | null;
|
||||
started_at: Date;
|
||||
completed_at: Date | null;
|
||||
duration_ms: number | null;
|
||||
error_message: string | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface WorkflowExecutionStep {
|
||||
id: number;
|
||||
execution_id: number;
|
||||
step_name: StepName;
|
||||
step_order: number;
|
||||
status: ExecutionStatus;
|
||||
method: StepMethod | null;
|
||||
input_data: Record<string, any> | null;
|
||||
output_data: Record<string, any> | null;
|
||||
field_changes: Record<string, { before: any; after: any }> | null;
|
||||
classification_rule_id: number | null;
|
||||
confidence: ConfidenceLevel | null;
|
||||
ai_request: Record<string, any> | null;
|
||||
ai_response: string | null;
|
||||
attempt_number: number;
|
||||
error_message: string | null;
|
||||
duration_ms: number | null;
|
||||
started_at: Date | null;
|
||||
completed_at: Date | null;
|
||||
}
|
||||
|
||||
export interface WorkflowSetting {
|
||||
key: string;
|
||||
value: any; // JSONB
|
||||
description: string | null;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Runtime Types (used during processing)
|
||||
// ============================================================================
|
||||
|
||||
/** Ticket data as consumed by the workflow engine */
|
||||
export interface TicketData {
|
||||
id: number;
|
||||
ticket_number: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
ticket_category: number | null;
|
||||
ticket_type: number | null;
|
||||
priority: number | null;
|
||||
queue_id: number | null;
|
||||
issue_type: number | null;
|
||||
sub_issue_type: number | null;
|
||||
company_id: number;
|
||||
contact_id: number | null;
|
||||
assigned_resource_id: number | null;
|
||||
creator_resource_id: number | null;
|
||||
person_id: number | null;
|
||||
status: number | null;
|
||||
source: number | null;
|
||||
// Computed/extracted fields for classification
|
||||
policy_name?: string | null;
|
||||
device_name?: string | null;
|
||||
}
|
||||
|
||||
/** Result from a single classification step */
|
||||
export interface ClassificationStepResult {
|
||||
field: string;
|
||||
value: any;
|
||||
field_2?: string;
|
||||
value_2?: any;
|
||||
confidence: ConfidenceLevel;
|
||||
matched_rule_id: number | null;
|
||||
matched_rule_name: string | null;
|
||||
method: StepMethod;
|
||||
}
|
||||
|
||||
/** Full classification result from robotic classifier */
|
||||
export interface ClassificationResult {
|
||||
branch: ClassificationStepResult | null;
|
||||
ticket_type: ClassificationStepResult | null;
|
||||
issue_classification: ClassificationStepResult | null;
|
||||
priority: ClassificationStepResult | null;
|
||||
queue: ClassificationStepResult | null;
|
||||
overall_confidence: ConfidenceLevel;
|
||||
needs_ai: boolean;
|
||||
ai_reasons: string[];
|
||||
}
|
||||
|
||||
/** Validation result */
|
||||
export interface ValidationResult {
|
||||
is_valid: boolean;
|
||||
errors: ValidationError[];
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
field: string;
|
||||
message: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
/** AI enhancement result */
|
||||
export interface AiEnhancementResult {
|
||||
title?: string;
|
||||
description?: string;
|
||||
classification?: {
|
||||
issue_type?: number;
|
||||
sub_issue_type?: number;
|
||||
ticket_type?: number;
|
||||
priority?: number;
|
||||
};
|
||||
troubleshooting_steps?: string;
|
||||
method: 'ai';
|
||||
}
|
||||
|
||||
/** Field changes to write back to Autotask */
|
||||
export interface FieldChanges {
|
||||
[field: string]: {
|
||||
before: any;
|
||||
after: any;
|
||||
};
|
||||
}
|
||||
|
||||
/** Full execution result */
|
||||
export interface ExecutionResult {
|
||||
execution_id: number;
|
||||
status: ExecutionStatus;
|
||||
classification_method: ClassificationMethod;
|
||||
branch: Branch;
|
||||
field_changes: FieldChanges;
|
||||
steps: ExecutionStepSummary[];
|
||||
duration_ms: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ExecutionStepSummary {
|
||||
step_name: StepName;
|
||||
status: ExecutionStatus;
|
||||
method: StepMethod;
|
||||
confidence?: ConfidenceLevel;
|
||||
matched_rule?: string;
|
||||
duration_ms: number;
|
||||
}
|
||||
|
||||
/** Workflow event that triggers processing */
|
||||
export interface WorkflowEvent {
|
||||
trigger_event: TriggerEvent;
|
||||
entity_type: string;
|
||||
entity_id: number;
|
||||
ticket_number?: string;
|
||||
ticket_data?: TicketData;
|
||||
}
|
||||
|
||||
/** Workflow settings loaded into memory */
|
||||
export interface WorkflowSettings {
|
||||
workflow_engine_enabled: boolean;
|
||||
default_ai_provider: 'openai' | 'anthropic';
|
||||
openai_api_key: string;
|
||||
openai_model: string;
|
||||
anthropic_api_key: string;
|
||||
anthropic_model: string;
|
||||
ai_for_title_cleanup: boolean;
|
||||
ai_for_description_rewrite: boolean;
|
||||
ai_for_ambiguous_classification: boolean;
|
||||
ai_for_troubleshooting: boolean;
|
||||
autotask_update_delay_ms: number;
|
||||
max_ai_retries: number;
|
||||
classification_confidence_threshold: ConfidenceLevel;
|
||||
log_retention_days: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Types (request/response shapes for API routes)
|
||||
// ============================================================================
|
||||
|
||||
export interface ClassificationRuleInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
rule_type: RuleType;
|
||||
sort_order?: number;
|
||||
is_active?: boolean;
|
||||
match_field: MatchField;
|
||||
match_operator: MatchOperator;
|
||||
match_value: any;
|
||||
match_case_sensitive?: boolean;
|
||||
result_field: string;
|
||||
result_value: any;
|
||||
result_field_2?: string;
|
||||
result_value_2?: any;
|
||||
confidence?: ConfidenceLevel;
|
||||
stop_on_match?: boolean;
|
||||
}
|
||||
|
||||
export interface WorkflowRuleInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
is_active?: boolean;
|
||||
sort_order?: number;
|
||||
trigger_event: TriggerEvent;
|
||||
trigger_entity?: string;
|
||||
stop_processing?: boolean;
|
||||
conditions: WorkflowConditionInput[];
|
||||
actions: WorkflowActionInput[];
|
||||
}
|
||||
|
||||
export interface WorkflowConditionInput {
|
||||
condition_group?: number;
|
||||
field: string;
|
||||
operator: ConditionOperator;
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface WorkflowActionInput {
|
||||
sort_order?: number;
|
||||
action_type: ActionType;
|
||||
config?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AiPromptTemplateInput {
|
||||
name: string;
|
||||
purpose: PromptPurpose;
|
||||
system_prompt: string;
|
||||
user_prompt_template: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface WorkflowTestRequest {
|
||||
ticket_id: number;
|
||||
dry_run?: boolean; // default true
|
||||
}
|
||||
|
||||
export interface WorkflowTestResult {
|
||||
ticket: TicketData;
|
||||
classification: ClassificationResult;
|
||||
validation: ValidationResult;
|
||||
proposed_changes: FieldChanges;
|
||||
execution_steps: ExecutionStepSummary[];
|
||||
}
|
||||
|
||||
/** Workflow rule with its conditions and actions loaded */
|
||||
export interface WorkflowRuleWithDetails extends WorkflowRule {
|
||||
conditions: WorkflowCondition[];
|
||||
actions: WorkflowAction[];
|
||||
}
|
||||
|
||||
/** Execution with its steps loaded */
|
||||
export interface WorkflowExecutionWithSteps extends WorkflowExecution {
|
||||
steps: WorkflowExecutionStep[];
|
||||
}
|
||||
|
|
@ -57,6 +57,9 @@ export function mapAutotaskToDatabase(
|
|||
case EntityType.TIME_ENTRIES:
|
||||
mapped = mapTimeEntry(data);
|
||||
break;
|
||||
case EntityType.TICKET_NOTES:
|
||||
mapped = mapTicketNote(data);
|
||||
break;
|
||||
case EntityType.STATUSES:
|
||||
case EntityType.ISSUE_TYPES:
|
||||
case EntityType.SUB_ISSUE_TYPES:
|
||||
|
|
@ -201,6 +204,26 @@ function mapTicket(data: any): Record<string, any> {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map TicketNote entity
|
||||
*/
|
||||
function mapTicketNote(data: any): Record<string, any> {
|
||||
return {
|
||||
id: data.id,
|
||||
ticket_id: data.ticketID,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
note_type: data.noteType,
|
||||
publish: data.publish,
|
||||
creator_resource_id: data.creatorResourceID,
|
||||
creator_type: data.creatorType,
|
||||
last_activity_date: data.lastActivityDate,
|
||||
create_date_time: data.createDateTime,
|
||||
synced_at: data.synced_at,
|
||||
is_deleted: data.is_deleted || false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Task entity
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -56,6 +56,9 @@ export function getAllEntitiesInOrder(): EntityType[] {
|
|||
EntityType.ISSUE_TYPES,
|
||||
EntityType.SUB_ISSUE_TYPES,
|
||||
EntityType.WORK_TYPES,
|
||||
EntityType.QUEUES,
|
||||
EntityType.PRIORITIES,
|
||||
EntityType.TICKET_CATEGORIES,
|
||||
EntityType.CONTACTS,
|
||||
EntityType.PROJECTS,
|
||||
EntityType.TICKETS,
|
||||
|
|
@ -92,11 +95,15 @@ export function getAutotaskEntityName(entity: EntityType): string {
|
|||
[EntityType.ISSUE_TYPES]: 'IssueTypes',
|
||||
[EntityType.SUB_ISSUE_TYPES]: 'SubIssueTypes',
|
||||
[EntityType.WORK_TYPES]: 'WorkTypes',
|
||||
[EntityType.QUEUES]: 'Queues',
|
||||
[EntityType.PRIORITIES]: 'Priorities',
|
||||
[EntityType.TICKET_CATEGORIES]: 'TicketCategories',
|
||||
[EntityType.BILLING_ITEMS]: 'BillingItems',
|
||||
[EntityType.CONFIGURATION_ITEMS]: 'ConfigurationItems',
|
||||
[EntityType.CONTACTS]: 'Contacts',
|
||||
[EntityType.CONTRACTS]: 'Contracts',
|
||||
[EntityType.TIME_ENTRIES]: 'TimeEntries',
|
||||
[EntityType.TICKET_NOTES]: 'TicketNotes',
|
||||
};
|
||||
|
||||
return mapping[entity] || entity;
|
||||
|
|
@ -113,6 +120,9 @@ export function isPicklistEntity(entity: EntityType): boolean {
|
|||
EntityType.ISSUE_TYPES,
|
||||
EntityType.SUB_ISSUE_TYPES,
|
||||
EntityType.WORK_TYPES,
|
||||
EntityType.QUEUES,
|
||||
EntityType.PRIORITIES,
|
||||
EntityType.TICKET_CATEGORIES,
|
||||
].includes(entity);
|
||||
}
|
||||
|
||||
|
|
@ -133,10 +143,14 @@ export function getLastModifiedField(entity: EntityType): string {
|
|||
[EntityType.CONTRACTS]: 'lastModifiedDateTime',
|
||||
[EntityType.BILLING_ITEMS]: 'itemDate',
|
||||
[EntityType.TIME_ENTRIES]: 'dateWorked',
|
||||
[EntityType.TICKET_NOTES]: 'lastActivityDate',
|
||||
[EntityType.STATUSES]: 'lastModifiedDate',
|
||||
[EntityType.ISSUE_TYPES]: 'lastModifiedDate',
|
||||
[EntityType.SUB_ISSUE_TYPES]: 'lastModifiedDate',
|
||||
[EntityType.WORK_TYPES]: 'lastModifiedDate',
|
||||
[EntityType.QUEUES]: 'lastModifiedDate',
|
||||
[EntityType.PRIORITIES]: 'lastModifiedDate',
|
||||
[EntityType.TICKET_CATEGORIES]: 'lastModifiedDate',
|
||||
};
|
||||
|
||||
return mapping[entity] || 'lastModifiedDate';
|
||||
|
|
@ -159,10 +173,14 @@ export function getActiveField(entity: EntityType): string | null {
|
|||
[EntityType.CONTRACTS]: null, // Use status field instead
|
||||
[EntityType.BILLING_ITEMS]: null,
|
||||
[EntityType.TIME_ENTRIES]: null, // Time entries don't have active status
|
||||
[EntityType.TICKET_NOTES]: null, // Ticket notes don't have active status
|
||||
[EntityType.STATUSES]: 'isActive',
|
||||
[EntityType.ISSUE_TYPES]: 'isActive',
|
||||
[EntityType.SUB_ISSUE_TYPES]: 'isActive',
|
||||
[EntityType.WORK_TYPES]: 'isActive',
|
||||
[EntityType.QUEUES]: 'isActive',
|
||||
[EntityType.PRIORITIES]: 'isActive',
|
||||
[EntityType.TICKET_CATEGORIES]: 'isActive',
|
||||
};
|
||||
|
||||
return mapping[entity] || null;
|
||||
|
|
@ -245,10 +263,14 @@ export function buildDateRangeFilter(
|
|||
[EntityType.RESOURCES]: null,
|
||||
[EntityType.CONTACTS]: null,
|
||||
[EntityType.CONFIGURATION_ITEMS]: null,
|
||||
[EntityType.TICKET_NOTES]: null,
|
||||
[EntityType.STATUSES]: null,
|
||||
[EntityType.ISSUE_TYPES]: null,
|
||||
[EntityType.SUB_ISSUE_TYPES]: null,
|
||||
[EntityType.WORK_TYPES]: null,
|
||||
[EntityType.QUEUES]: null,
|
||||
[EntityType.PRIORITIES]: null,
|
||||
[EntityType.TICKET_CATEGORIES]: null,
|
||||
};
|
||||
|
||||
const dateField = dateFieldMapping[entity];
|
||||
|
|
@ -427,11 +449,15 @@ export function getEntityDisplayName(entity: EntityType): string {
|
|||
[EntityType.ISSUE_TYPES]: 'Issue Types',
|
||||
[EntityType.SUB_ISSUE_TYPES]: 'Sub-Issue Types',
|
||||
[EntityType.WORK_TYPES]: 'Work Types',
|
||||
[EntityType.QUEUES]: 'Queues',
|
||||
[EntityType.PRIORITIES]: 'Priorities',
|
||||
[EntityType.TICKET_CATEGORIES]: 'Ticket Categories',
|
||||
[EntityType.BILLING_ITEMS]: 'Billing Items',
|
||||
[EntityType.CONFIGURATION_ITEMS]: 'Configuration Items',
|
||||
[EntityType.CONTACTS]: 'Contacts',
|
||||
[EntityType.CONTRACTS]: 'Contracts',
|
||||
[EntityType.TIME_ENTRIES]: 'Time Entries',
|
||||
[EntityType.TICKET_NOTES]: 'Ticket Notes',
|
||||
};
|
||||
|
||||
return mapping[entity] || entity;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue