/** * Webhook Service * 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, getAutotaskEntityName } from '../utils/sync-helpers'; import { AutotaskClient } from './autotask-client'; import { workflowEngine } from './workflow-engine'; import { ticketWorkflowEngine } from './ticket-workflow-engine'; import '../services/workflow-steps'; // Register all workflow step executors import { WorkflowEvent, TicketData } from '../types/workflow'; import { detectPhishingTicket, DetectableTicket } from './phishing-detector'; 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= * 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=" 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 */ async processWebhook(payload: AutotaskWebhookPayload, sourceIp?: string, userAgent?: string): Promise { const startTime = Date.now(); try { // Log the webhook event await this.logWebhookEvent(payload, 'pending', sourceIp, userAgent); console.log(`[WEBHOOK] Processing ${payload.eventType} event for ${payload.entityType} #${payload.entityId}`); // Check if webhook is configured and active const isActive = await this.isWebhookActive(payload.entityType); if (!isActive) { console.log(`[WEBHOOK] Webhook disabled for ${payload.entityType}, skipping`); await this.updateWebhookLog(payload.eventId, 'processed', 'Webhook disabled for entity type'); return { success: true, eventId: payload.eventId, entityType: payload.entityType, entityId: payload.entityId, action: 'skipped', processingTime: Date.now() - startTime, }; } let action: 'created' | 'updated' | 'deleted' | 'skipped'; // Handle different event types switch (payload.eventType) { case WebhookEventType.CREATE: case WebhookEventType.UPDATE: action = await this.handleCreateOrUpdate(payload); break; case WebhookEventType.DELETE: action = await this.handleDelete(payload); break; default: throw new Error(`Unknown event type: ${payload.eventType}`); } const processingTime = Date.now() - startTime; // Update webhook log as processed 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) ); this.triggerPhishingDetection(payload).catch(err => console.error('[WEBHOOK] Phishing detection error:', err) ); } return { success: true, eventId: payload.eventId, entityType: payload.entityType, entityId: payload.entityId, action, processingTime, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); const processingTime = Date.now() - startTime; console.error(`[WEBHOOK] Failed to process webhook:`, errorMessage); // Update webhook log as failed await this.updateWebhookLog(payload.eventId, 'failed', errorMessage, processingTime); return { success: false, eventId: payload.eventId, entityType: payload.entityType, entityId: payload.entityId, action: 'skipped', error: errorMessage, processingTime, }; } } /** * Handle create or update events */ private async handleCreateOrUpdate(payload: AutotaskWebhookPayload): Promise<'created' | 'updated'> { // 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'; } // 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); return payload.eventType === WebhookEventType.CREATE ? 'created' : 'updated'; } /** * Handle delete events */ private async handleDelete(payload: AutotaskWebhookPayload): Promise<'deleted'> { const tableName = this.getTableNameFromWebhookEntity(payload.entityType); // Soft delete the entity const query = ` UPDATE ${tableName} SET is_deleted = true, deleted_at = NOW() WHERE id = $1 `; await postgresClient.query(query, [payload.entityId]); console.log(`[WEBHOOK] Soft deleted ${payload.entityType} #${payload.entityId}`); return 'deleted'; } /** * Upsert entity data to database */ private async upsertEntity(entityType: WebhookEntityType, entityData: Record): Promise { // Map webhook entity type to internal EntityType const internalEntityType = this.mapWebhookEntityType(entityType); // Map Autotask data to PostgreSQL schema const mappedData = mapAutotaskToDatabase(internalEntityType, entityData); if (!mappedData) { throw new Error(`Failed to map entity data for ${entityType}`); } // Get table name const tableName = getTableName(internalEntityType); // Build upsert query const keys = Object.keys(mappedData); const values = Object.values(mappedData); const placeholders = keys.map((_, i) => `$${i + 1}`).join(', '); const updateClause = keys .filter(k => k !== 'id') .map(k => `${k} = EXCLUDED.${k}`) .join(', '); const query = ` INSERT INTO ${tableName} (${keys.join(', ')}) VALUES (${placeholders}) ON CONFLICT (id) DO UPDATE SET ${updateClause}, updated_at = NOW() `; await postgresClient.query(query, values); console.log(`[WEBHOOK] Upserted ${entityType} #${mappedData.id}`); } /** * Log webhook event to database */ private async logWebhookEvent(payload: AutotaskWebhookPayload, status: 'pending' | 'processed' | 'failed', sourceIp?: string, userAgent?: string): Promise { const query = ` INSERT INTO webhook_logs (event_id, entity_type, entity_id, event_type, status, source_ip, user_agent, payload) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (event_id) DO NOTHING `; await postgresClient.query(query, [ payload.eventId, payload.entityType, payload.entityId, payload.eventType, status, sourceIp || null, userAgent || null, JSON.stringify(payload), ]); } /** * Update webhook log status */ private async updateWebhookLog( eventId: string, status: 'processed' | 'failed', errorMessage?: string, processingTime?: number ): Promise { const query = ` UPDATE webhook_logs SET status = $1, error_message = $2, processed_at = NOW(), processing_time_ms = $3 WHERE event_id = $4 `; await postgresClient.query(query, [status, errorMessage || null, processingTime || null, eventId]); } /** * Check if webhook is active for entity type */ private async isWebhookActive(entityType: WebhookEntityType): Promise { const query = ` SELECT is_active FROM webhook_configs WHERE entity_type = $1 `; const result = await postgresClient.query<{ is_active: boolean }>(query, [entityType]); if (result.rows.length === 0) { return false; // No config = disabled } return result.rows[0].is_active; } /** * Get recent webhook logs */ async getWebhookLogs(limit: number = 100, entityType?: string): Promise { let query = ` SELECT * FROM webhook_logs `; const params: any[] = []; if (entityType) { query += ` WHERE entity_type = $1`; params.push(entityType); } query += ` ORDER BY received_at DESC LIMIT $${params.length + 1}`; params.push(limit); const result = await postgresClient.query(query, params); return result.rows; } /** * Get webhook statistics */ async getWebhookStats(hours: number = 24): Promise<{ total: number; processed: number; failed: number; pending: number; byEntityType: Record; }> { const query = ` SELECT (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); if (result.rows.length === 0) { return { total: 0, processed: 0, failed: 0, pending: 0, byEntityType: {}, }; } const row = result.rows[0]; return { total: parseInt(row.total) || 0, processed: parseInt(row.processed) || 0, failed: parseInt(row.failed) || 0, pending: parseInt(row.pending) || 0, byEntityType: row.by_entity_type || {}, }; } /** * Map webhook entity type to internal EntityType */ private mapWebhookEntityType(webhookType: WebhookEntityType): EntityType { const mapping: Record = { [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, [WebhookEntityType.CONTACTS]: EntityType.CONTACTS, [WebhookEntityType.CONTRACTS]: EntityType.CONTRACTS, [WebhookEntityType.CONFIGURATION_ITEMS]: EntityType.CONFIGURATION_ITEMS, }; return mapping[webhookType]; } /** * Get table name from webhook entity type */ private getTableNameFromWebhookEntity(webhookType: WebhookEntityType): string { 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 { 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}`); // Fire-and-forget: trigger new ticket workflow engine if (event.ticket_data) { ticketWorkflowEngine.processTrigger(event.trigger_event, event.ticket_data).catch(err => { console.error('[WEBHOOK] Ticket workflow engine error:', err); }); } // DEPRECATED: old workflow engine (will be removed after testing period) // await workflowEngine.process(event); } /** * Trigger phishing detection for a new ticket. * Runs asynchronously — does not block webhook response. * * NOTE: `payload.entity` is never populated by the real Autotask webhook * flow (see lib/types/webhook.ts's normalizeWebhookPayload) — Autotask's * actual payload only carries Action/Guid/EntityType/Id/Fields/EventTime, * with no embedded entity body. Instead, read the ticket row back from * Postgres — by the time this fires, handleCreateOrUpdate() (called * earlier in the same processWebhook flow, see line ~95) has already * upserted the ticket, so the row is guaranteed to exist with current * title/description. */ private async triggerPhishingDetection(payload: AutotaskWebhookPayload): Promise { const row = await postgresClient.query<{ id: string; ticket_number: string | null; title: string | null; description: string | null; company_id: number | null; contact_id: number | null; created_by_contact_id: number | null; }>( `SELECT id, ticket_number, title, description, company_id, contact_id, created_by_contact_id FROM tickets WHERE id = $1`, [payload.entityId] ); const r = row.rows[0]; if (!r) { console.warn(`[WEBHOOK] Skipping phishing detection — ticket ${payload.entityId} not found in Postgres yet`); return; } const ticket: DetectableTicket = { id: Number(r.id), ticket_number: r.ticket_number, title: r.title, description: r.description, company_id: r.company_id, contact_id: r.contact_id, created_by_contact_id: r.created_by_contact_id, }; console.log(`[WEBHOOK] Triggering phishing detection for ticket ${payload.entityId}`); await detectPhishingTicket(ticket); } } // Export singleton instance export const webhookService = new WebhookService();