feat: Autotask webhook integration, TicketNotes, Datto RMM, workflow engine, Veeam agents/alarms, AI triage, misc improvements

This commit is contained in:
lorentz 2026-02-20 10:28:15 -05:00
parent 347cf4e298
commit d7c3dc7168
74 changed files with 37844 additions and 322 deletions

View 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();

View 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();
}
}

View file

@ -128,6 +128,6 @@ export class DattoRMMClientSimple {
}
// Then get devices for that site
return this.getDevicesBySite(site.id);
return this.getDevicesBySite(site.uid);
}
}

View file

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

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

View file

@ -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)
*/

View 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();

View 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();

View file

@ -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
*/

View file

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

View file

@ -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

View 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();

View file

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

View file

@ -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';

View file

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

View file

@ -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)

View file

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

View file

@ -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
View 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[];
}

View file

@ -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
*/

View file

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