feat: Autotask webhook integration, TicketNotes, Datto RMM, workflow engine, Veeam agents/alarms, AI triage, misc improvements
This commit is contained in:
parent
347cf4e298
commit
d7c3dc7168
74 changed files with 37844 additions and 322 deletions
358
lib/services/autotask-webhook-manager.ts
Normal file
358
lib/services/autotask-webhook-manager.ts
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
/**
|
||||
* Autotask Webhook Manager
|
||||
* Registers, manages, and deregisters webhooks with the Autotask REST API.
|
||||
* Autotask webhooks must be created via API — there is no GUI-based creation.
|
||||
*
|
||||
* Supported entities: Companies, Contacts, ConfigurationItems, Tickets, TicketNotes
|
||||
*/
|
||||
|
||||
import { AutotaskClient } from './autotask-client';
|
||||
import { postgresClient } from './postgres-client';
|
||||
import {
|
||||
WebhookEntityType,
|
||||
WEBHOOK_SUPPORTED_ENTITIES,
|
||||
AutotaskWebhookRegistration,
|
||||
AutotaskWebhookRegistrationResult,
|
||||
} from '../types/webhook';
|
||||
|
||||
/**
|
||||
* Maps our WebhookEntityType enum to the Autotask REST API webhook entity name.
|
||||
* e.g. "Tickets" → "TicketWebhooks"
|
||||
*/
|
||||
function getWebhookEntityName(entityType: WebhookEntityType): string {
|
||||
const map: Record<string, string> = {
|
||||
Companies: 'CompanyWebhooks',
|
||||
Contacts: 'ContactWebhooks',
|
||||
ConfigurationItems: 'ConfigurationItemWebhooks',
|
||||
Tickets: 'TicketWebhooks',
|
||||
TicketNotes: 'TicketNoteWebhooks',
|
||||
};
|
||||
return map[entityType] || `${entityType}Webhooks`;
|
||||
}
|
||||
|
||||
export class AutotaskWebhookManager {
|
||||
private client: AutotaskClient;
|
||||
private baseUrl: string;
|
||||
private webhookBaseUrl: string;
|
||||
private secretKey: string;
|
||||
private notificationEmail: string;
|
||||
|
||||
constructor() {
|
||||
this.client = new AutotaskClient({
|
||||
apiUrl: process.env.AUTOTASK_API_URL || '',
|
||||
username: process.env.AUTOTASK_USERNAME || '',
|
||||
password: process.env.AUTOTASK_SECRET || '',
|
||||
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
|
||||
});
|
||||
this.baseUrl = process.env.AUTOTASK_API_URL || '';
|
||||
this.webhookBaseUrl = process.env.WEBHOOK_BASE_URL || '';
|
||||
this.secretKey = process.env.AUTOTASK_WEBHOOK_SECRET || '';
|
||||
this.notificationEmail = process.env.AUTOTASK_USERNAME?.replace('@', '+webhooks@') || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Register webhooks for all supported entities
|
||||
*/
|
||||
async registerAll(): Promise<AutotaskWebhookRegistrationResult[]> {
|
||||
const results: AutotaskWebhookRegistrationResult[] = [];
|
||||
|
||||
for (const entityType of WEBHOOK_SUPPORTED_ENTITIES) {
|
||||
try {
|
||||
const result = await this.registerWebhook(entityType);
|
||||
results.push(result);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[WEBHOOK-MGR] Failed to register ${entityType}:`, msg);
|
||||
results.push({
|
||||
entityType,
|
||||
webhookId: 0,
|
||||
fieldsRegistered: 0,
|
||||
excludedResources: 0,
|
||||
success: false,
|
||||
error: msg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a webhook for a single entity type
|
||||
*/
|
||||
async registerWebhook(entityType: WebhookEntityType): Promise<AutotaskWebhookRegistrationResult> {
|
||||
const webhookEntityName = getWebhookEntityName(entityType);
|
||||
console.log(`[WEBHOOK-MGR] Registering webhook for ${entityType} via ${webhookEntityName}`);
|
||||
|
||||
// 1. Check if we already have a webhook registered
|
||||
const existing = await this.getExistingWebhookId(entityType);
|
||||
if (existing) {
|
||||
console.log(`[WEBHOOK-MGR] Webhook already registered for ${entityType} (ID: ${existing}), deleting first`);
|
||||
await this.deleteWebhook(entityType, existing);
|
||||
}
|
||||
|
||||
// 2. Create the webhook
|
||||
const webhookUrl = `${this.webhookBaseUrl}/api/webhooks/autotask`;
|
||||
const deactivationUrl = `${this.webhookBaseUrl}/api/webhooks/autotask?deactivated=true`;
|
||||
|
||||
const registration: AutotaskWebhookRegistration = {
|
||||
IsActive: true,
|
||||
DeactivationUrl: deactivationUrl,
|
||||
IsSubscribedToCreateEvents: true,
|
||||
IsSubscribedToUpdateEvents: true,
|
||||
IsSubscribedToDeleteEvents: true,
|
||||
Name: `Pulse - ${entityType}`,
|
||||
SecretKey: this.secretKey,
|
||||
SendThresholdExceededNotification: true,
|
||||
WebhookUrl: webhookUrl,
|
||||
NotificationEmailAddress: this.notificationEmail,
|
||||
};
|
||||
|
||||
const createUrl = `${this.baseUrl}/${webhookEntityName}`;
|
||||
const createResponse = await this.apiCall<{ itemId: number }>(createUrl, 'POST', registration);
|
||||
const webhookId = createResponse.itemId;
|
||||
|
||||
console.log(`[WEBHOOK-MGR] Created webhook for ${entityType}, ID: ${webhookId}`);
|
||||
|
||||
// 3. Discover and add trigger fields
|
||||
let fieldsRegistered = 0;
|
||||
try {
|
||||
fieldsRegistered = await this.addTriggerFields(webhookEntityName, webhookId);
|
||||
} catch (error) {
|
||||
console.warn(`[WEBHOOK-MGR] Could not add trigger fields for ${entityType}:`, error);
|
||||
}
|
||||
|
||||
// 4. Exclude the API user resource to prevent infinite loops
|
||||
let excludedResources = 0;
|
||||
try {
|
||||
excludedResources = await this.excludeApiResource(webhookEntityName, webhookId);
|
||||
} catch (error) {
|
||||
console.warn(`[WEBHOOK-MGR] Could not exclude API resource for ${entityType}:`, error);
|
||||
}
|
||||
|
||||
// 5. Save webhook ID to database
|
||||
await this.saveWebhookConfig(entityType, webhookId);
|
||||
|
||||
return {
|
||||
entityType,
|
||||
webhookId,
|
||||
fieldsRegistered,
|
||||
excludedResources,
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover available webhook fields and register them as triggers.
|
||||
* The fields endpoint is {Entity}WebhookFields/entityInformation/fields
|
||||
* (a separate entity, not a sub-path of the webhook).
|
||||
*/
|
||||
private async addTriggerFields(webhookEntityName: string, webhookId: number): Promise<number> {
|
||||
// Derive the fields entity name: "TicketWebhooks" → "TicketWebhookFields"
|
||||
const fieldsEntityName = webhookEntityName.replace('Webhooks', 'WebhookFields');
|
||||
const fieldsUrl = `${this.baseUrl}/${fieldsEntityName}/entityInformation/fields`;
|
||||
const fieldsResponse = await this.apiCall<{ fields: any[] }>(fieldsUrl, 'GET');
|
||||
|
||||
// Find the fieldID picklist to discover available trigger fields
|
||||
const fieldIdField = fieldsResponse.fields?.find((f: any) => f.name === 'fieldID');
|
||||
if (!fieldIdField?.picklistValues) {
|
||||
console.warn(`[WEBHOOK-MGR] No picklist values found for ${fieldsEntityName}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const activeFields = fieldIdField.picklistValues.filter((p: any) => p.isActive);
|
||||
let registered = 0;
|
||||
|
||||
// Register each available field as both a trigger and display-always field
|
||||
for (const field of activeFields) {
|
||||
try {
|
||||
const addFieldUrl = `${this.baseUrl}/${webhookEntityName}/${webhookId}/Fields`;
|
||||
await this.apiCall(addFieldUrl, 'POST', {
|
||||
FieldID: parseInt(field.value),
|
||||
IsDisplayAlwaysField: true,
|
||||
IsSubscribedField: true,
|
||||
WebhookID: webhookId,
|
||||
});
|
||||
registered++;
|
||||
} catch (error) {
|
||||
// Some fields may not support being triggers — that's OK
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
if (!msg.includes('already exists')) {
|
||||
console.debug(`[WEBHOOK-MGR] Could not add field ${field.label} (${field.value}): ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[WEBHOOK-MGR] Registered ${registered}/${activeFields.length} trigger fields for webhook ${webhookId}`);
|
||||
return registered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclude the API integration resource from triggering webhooks (prevents infinite loops)
|
||||
*/
|
||||
private async excludeApiResource(webhookEntityName: string, webhookId: number): Promise<number> {
|
||||
// Find the API resource by looking up the username
|
||||
const apiUsername = process.env.AUTOTASK_USERNAME || '';
|
||||
if (!apiUsername) return 0;
|
||||
|
||||
try {
|
||||
const resources = await this.client.getFieldInfo('Resources');
|
||||
// The API user is typically an API-only resource; we look it up by email
|
||||
const resource = await this.client.getResourceByEmail(apiUsername.split('@')[0] + '@' + apiUsername.split('@')[1]?.replace('@', ''));
|
||||
|
||||
if (!resource) {
|
||||
console.warn(`[WEBHOOK-MGR] Could not find API resource for ${apiUsername}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const excludeUrl = `${this.baseUrl}/${webhookEntityName}/${webhookId}/ExcludedResources`;
|
||||
await this.apiCall(excludeUrl, 'POST', {
|
||||
ResourceID: resource.id,
|
||||
WebhookID: webhookId,
|
||||
});
|
||||
|
||||
console.log(`[WEBHOOK-MGR] Excluded resource ${resource.id} (${apiUsername}) from webhook ${webhookId}`);
|
||||
return 1;
|
||||
} catch (error) {
|
||||
console.warn(`[WEBHOOK-MGR] Failed to exclude API resource:`, error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all webhooks for an entity type
|
||||
*/
|
||||
async listWebhooks(entityType: WebhookEntityType): Promise<any[]> {
|
||||
const webhookEntityName = getWebhookEntityName(entityType);
|
||||
const url = `${this.baseUrl}/${webhookEntityName}/query`;
|
||||
|
||||
try {
|
||||
const response = await this.apiCall<{ items: any[] }>(url, 'POST', {
|
||||
filter: [{ op: 'gte', field: 'id', value: 0 }],
|
||||
});
|
||||
return response.items || [];
|
||||
} catch (error) {
|
||||
console.error(`[WEBHOOK-MGR] Failed to list webhooks for ${entityType}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a webhook from Autotask
|
||||
*/
|
||||
async deleteWebhook(entityType: WebhookEntityType, webhookId: number): Promise<void> {
|
||||
const webhookEntityName = getWebhookEntityName(entityType);
|
||||
const url = `${this.baseUrl}/${webhookEntityName}/${webhookId}`;
|
||||
|
||||
try {
|
||||
await this.apiCall(url, 'DELETE');
|
||||
console.log(`[WEBHOOK-MGR] Deleted webhook ${webhookId} for ${entityType}`);
|
||||
} catch (error) {
|
||||
console.warn(`[WEBHOOK-MGR] Failed to delete webhook ${webhookId}:`, error);
|
||||
}
|
||||
|
||||
// Remove from database
|
||||
await postgresClient.query(
|
||||
`UPDATE webhook_configs SET autotask_webhook_id = NULL WHERE entity_type = $1`,
|
||||
[entityType]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deregister all webhooks
|
||||
*/
|
||||
async deregisterAll(): Promise<void> {
|
||||
for (const entityType of WEBHOOK_SUPPORTED_ENTITIES) {
|
||||
const webhookId = await this.getExistingWebhookId(entityType);
|
||||
if (webhookId) {
|
||||
await this.deleteWebhook(entityType, webhookId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of all registered webhooks
|
||||
*/
|
||||
async getStatus(): Promise<Array<{
|
||||
entityType: WebhookEntityType;
|
||||
webhookId: number | null;
|
||||
isActive: boolean;
|
||||
isConfigured: boolean;
|
||||
}>> {
|
||||
const results = [];
|
||||
|
||||
for (const entityType of WEBHOOK_SUPPORTED_ENTITIES) {
|
||||
const config = await postgresClient.query<{
|
||||
autotask_webhook_id: string | null;
|
||||
is_active: boolean;
|
||||
}>(
|
||||
`SELECT autotask_webhook_id, is_active FROM webhook_configs WHERE entity_type = $1`,
|
||||
[entityType]
|
||||
);
|
||||
|
||||
const row = config.rows[0];
|
||||
results.push({
|
||||
entityType,
|
||||
webhookId: row?.autotask_webhook_id ? parseInt(row.autotask_webhook_id) : null,
|
||||
isActive: row?.is_active ?? false,
|
||||
isConfigured: !!row?.autotask_webhook_id,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// --- Private helpers ---
|
||||
|
||||
private async getExistingWebhookId(entityType: WebhookEntityType): Promise<number | null> {
|
||||
const result = await postgresClient.query<{ autotask_webhook_id: string }>(
|
||||
`SELECT autotask_webhook_id FROM webhook_configs WHERE entity_type = $1 AND autotask_webhook_id IS NOT NULL`,
|
||||
[entityType]
|
||||
);
|
||||
if (result.rows.length > 0 && result.rows[0].autotask_webhook_id) {
|
||||
return parseInt(result.rows[0].autotask_webhook_id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async saveWebhookConfig(entityType: WebhookEntityType, webhookId: number): Promise<void> {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO webhook_configs (entity_type, event_types, is_active, autotask_webhook_id)
|
||||
VALUES ($1, $2, true, $3)
|
||||
ON CONFLICT (entity_type) DO UPDATE SET
|
||||
autotask_webhook_id = $3,
|
||||
is_active = true,
|
||||
updated_at = NOW()`,
|
||||
[entityType, JSON.stringify(['create', 'update', 'delete']), String(webhookId)]
|
||||
);
|
||||
}
|
||||
|
||||
private async apiCall<T = any>(url: string, method: string, body?: any): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Username': process.env.AUTOTASK_USERNAME || '',
|
||||
'Secret': process.env.AUTOTASK_SECRET || '',
|
||||
'APIIntegrationcode': process.env.AUTOTASK_API_INTEGRATION_CODE || '',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
const options: RequestInit = { method, headers };
|
||||
if (body && method !== 'GET' && method !== 'DELETE') {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Autotask API ${method} ${url} failed (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
// DELETE returns no body
|
||||
if (method === 'DELETE') {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue