wulf-pulse/lib/services/webhook-service.ts
lorentz cf04f07c58 feat(quick-260717-a19): add idempotency guard + retry-parse on ticket.update
- parseAndStoreMessage (Defect 3): short-circuit with
  { stored: false, reason: 'already-parsed' } when a messages row already
  exists for the report, before any Autotask attachment fetch
- webhook-service (Defect 2): new retryPhishingParseOnUpdate wired into
  ticket.update fire-and-forget path; retries the missing-EML parse for a
  flagged, unparsed, auto_parse-gated report — no new cron/polling, reuses
  existing update traffic, safe to fire repeatedly thanks to the new
  idempotency guard
- Adjust eml-service test mock default so the new leading existence-check
  query doesn't short-circuit existing happy-path tests; add new test for
  the already-parsed short-circuit
2026-07-17 07:20:48 -04:00

633 lines
23 KiB
TypeScript

/**
* 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';
import { groupReportIntoCampaign } from './campaign-grouping-service';
import { getCompanyAutomationGate } from './phishing-automation-gate';
import { parseAndStoreMessage } from './phishing-eml-service';
import { classifyCampaign, Verdict } from './campaign-classifier';
import { autoPostAcknowledgment } from './remediation-service';
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=<base64>
* 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=<base64>"
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<WebhookProcessingResult> {
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)
);
}
// Defect 2 (quick task 260717-a19): the CREATE-time .eml parse attempt
// can race the Autotask attachment being available, permanently
// starving the classifier of evidence with no retry. Reuse the
// ticket.update traffic every flagged ticket already receives to
// retry the missing-EML parse only — never re-run detect/group/
// classify/report here. Safe to fire on every update because
// parseAndStoreMessage is now idempotent (Defect 3).
if (payload.entityType === WebhookEntityType.TICKETS && payload.eventType === WebhookEventType.UPDATE) {
this.retryPhishingParseOnUpdate(payload).catch(err =>
console.error('[WEBHOOK] Phishing retry-parse 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<string, any>);
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<string, any>): Promise<void> {
// 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<void> {
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<void> {
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<boolean> {
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<WebhookLog[]> {
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<WebhookLog>(query, params);
return result.rows;
}
/**
* Get webhook statistics
*/
async getWebhookStats(hours: number = 24): Promise<{
total: number;
processed: number;
failed: number;
pending: number;
byEntityType: Record<string, number>;
}> {
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, EntityType> = {
[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<void> {
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<void> {
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}`);
const detection = await detectPhishingTicket(ticket);
// D-01/D-08: automatic path short-circuits if already grouped.
if (detection.flagged && detection.reportId) {
const grouped = await groupReportIntoCampaign(detection.reportId, { skipIfAlreadyGrouped: true });
if (grouped?.campaignId) {
await this.runGatedPhishingStages({
campaignId: grouped.campaignId,
companyId: r.company_id,
reportId: detection.reportId,
ticketId: Number(r.id),
});
}
}
}
/**
* Defect 2 fix (quick task 260717-a19): retries the missing-EML parse for
* an already-flagged phishing report on ticket.update webhook traffic.
*
* Autotask's attachment-available timing can lag the ticket.created
* webhook by more than the CREATE-time parse attempt allows for, leaving
* a flagged report permanently without a `messages` row (no retry existed
* before this fix). Rather than add new polling/cron, this reuses the
* ticket.update events a flagged ticket already receives (5+ observed on
* Seubert ticket 699456) to attempt the parse again — bounded to exactly
* one `parseAndStoreMessage` call per update, gated on auto_parse, and
* only when there is still no messages row (parseAndStoreMessage's own
* Defect 3 guard makes repeat calls safe regardless).
*
* Deliberately narrow: no detection, grouping, classify, or report here —
* only the missing-EML retry-parse.
*/
private async retryPhishingParseOnUpdate(payload: AutotaskWebhookPayload): Promise<void> {
const reportRow = await postgresClient.query<{ id: string; company_id: number | null }>(
`SELECT id::text AS id, company_id FROM reports WHERE ticket_id = $1`,
[payload.entityId]
);
const report = reportRow.rows[0];
if (!report) {
// Not a flagged phishing ticket — nothing to retry.
return;
}
const messageRow = await postgresClient.query<{ id: string }>(
`SELECT id FROM messages WHERE report_id = $1 LIMIT 1`,
[report.id]
);
if (messageRow.rows.length > 0) {
// Already parsed — nothing to retry.
return;
}
const gate = await getCompanyAutomationGate(report.company_id);
if (!gate.autoParse) {
return;
}
try {
await parseAndStoreMessage({ reportId: report.id, ticketId: Number(payload.entityId) });
} catch (err) {
console.error('[WEBHOOK] retryPhishingParseOnUpdate parse error', err);
}
}
/**
* Phase 23 D-04/D-06/D-07: runs the opted-in parse -> classify -> report
* chain for a company after detection + grouping have already run
* unconditionally. Each stage is independently gated by
* `phishing_automation_gate` and isolated in its own try/catch so a
* failure in one stage never blocks the webhook response or aborts a
* later stage (T-23-09).
*
* auto_report auto-posts EXCLUSIVELY the acknowledge_user thank-you note,
* and only when the campaign's current verdict is USER_AWARENESS (D-04).
* Every other verdict/action remains proposed-only and manual-approval
* gated — this method never calls approve/remediate/block/purge/warn_user.
*/
private async runGatedPhishingStages(input: {
campaignId: string;
companyId: number | null;
reportId: string;
ticketId: number;
}): Promise<void> {
const { campaignId, companyId, reportId, ticketId } = input;
const gate = await getCompanyAutomationGate(companyId);
if (gate.autoParse) {
try {
await parseAndStoreMessage({ reportId, ticketId });
} catch (err) {
console.error('[WEBHOOK] auto_parse stage error', err);
}
}
let verdict: Verdict | null = null;
if (gate.autoClassify) {
try {
const result = await classifyCampaign(campaignId);
verdict = result.verdict;
} catch (err) {
console.error('[WEBHOOK] auto_classify stage error', err);
}
}
if (gate.autoReport) {
try {
if (verdict === null) {
const latest = await postgresClient.query<{ verdict: string | null }>(
`SELECT verdict FROM classifications WHERE campaign_id = $1 ORDER BY created_at DESC LIMIT 1`,
[campaignId]
);
verdict = (latest.rows[0]?.verdict as Verdict | undefined) ?? null;
}
if (verdict === 'USER_AWARENESS') {
await autoPostAcknowledgment(campaignId, 'system:auto_report');
}
} catch (err) {
console.error('[WEBHOOK] auto_report stage error', err);
}
}
}
}
// Export singleton instance
export const webhookService = new WebhookService();