wulf-pulse/lib/services/autotask-webhook-manager.ts

358 lines
12 KiB
TypeScript

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