- Add QBO OAuth2 client with token refresh (lib/services/qbo-client.ts) - Add QBO sync service for invoices, payments, deposits, purchases, journal entries, reports (lib/services/qbo-sync-service.ts) - Add QBO types (lib/types/qbo.ts) - Add API routes: /api/qbo/auth, /api/qbo/sync, /api/qbo/disconnect - Add /admin/qbo status and sync management page - Add legal pages: /legal/eula, /legal/privacy (Intuit app assessment) - Add QBO nav link under Admin - Fix reports: remove invalid summarize_column_by, add accounting_method from Preferences API, add showrows=all&showcols=all - Add CashFlow report type alongside P&L and BalanceSheet - Add NoReportData check to skip empty report months - Add intuit_tid capture in error messages - Add redirect: follow for cluster routing - Migration 051: qbo_tokens, qbo_invoices, qbo_payments, qbo_deposits, qbo_transactions, qbo_reports tables Also includes earlier work: - Ping flap suppression pipeline step - Ticket digest reports with LLM analysis - Zabbix WAN monitor and gap analysis - Kiosk is_deleted filter fixes - Datto RMM ping target enrichment - Entity sync soft-delete detection
362 lines
12 KiB
TypeScript
362 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',
|
|
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<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();
|
|
}
|
|
}
|