/** * 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 = { Companies: 'CompanyWebhooks', Contacts: 'ContactWebhooks', ConfigurationItems: 'ConfigurationItemWebhooks', Tickets: 'TicketWebhooks', TicketNotes: 'TicketNoteWebhooks', TimeEntries: 'TimeEntryWebhooks', Tasks: 'TaskWebhooks', Projects: 'ProjectWebhooks', Contracts: 'ContractWebhooks', }; 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 { 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 { 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 { // 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 { // 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 { 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 { 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 { 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> { 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 { 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 { 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(url: string, method: string, body?: any): Promise { const headers: Record = { '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(); } }