/** * Webhook Service * * Handles webhook management and triggering */ import { Injectable, Logger, Inject } from '@nestjs/common'; import { HttpService } from '@nestjs/axios'; import { v4 as uuidv4 } from 'uuid'; import * as crypto from 'crypto'; import { firstValueFrom } from 'rxjs'; import { Webhook, WebhookEvent, WebhookStatus } from '@domain/entities/webhook.entity'; import { WebhookRepository, WEBHOOK_REPOSITORY, WebhookFilters, } from '@domain/ports/out/webhook.repository'; export interface CreateWebhookInput { organizationId: string; url: string; events: WebhookEvent[]; description?: string; headers?: Record; } export interface UpdateWebhookInput { url?: string; events?: WebhookEvent[]; description?: string; headers?: Record; } export interface WebhookPayload { event: WebhookEvent; timestamp: string; data: any; organizationId: string; } @Injectable() export class WebhookService { private readonly logger = new Logger(WebhookService.name); private readonly MAX_RETRIES = 3; private readonly RETRY_DELAY_MS = 5000; constructor( @Inject(WEBHOOK_REPOSITORY) private readonly webhookRepository: WebhookRepository, private readonly httpService: HttpService ) {} /** * Create a new webhook */ async createWebhook(input: CreateWebhookInput): Promise { const secret = this.generateSecret(); const webhook = Webhook.create({ id: uuidv4(), organizationId: input.organizationId, url: input.url, events: input.events, secret, description: input.description, headers: input.headers, }); await this.webhookRepository.save(webhook); this.logger.log(`Webhook created: ${webhook.id} for organization ${input.organizationId}`); return webhook; } /** * Get webhook by ID */ async getWebhookById(id: string): Promise { return this.webhookRepository.findById(id); } /** * Get webhooks by organization */ async getWebhooksByOrganization(organizationId: string): Promise { return this.webhookRepository.findByOrganization(organizationId); } /** * Get webhooks with filters */ async getWebhooks(filters: WebhookFilters): Promise { return this.webhookRepository.findByFilters(filters); } /** * Update webhook */ async updateWebhook(id: string, updates: UpdateWebhookInput): Promise { const webhook = await this.webhookRepository.findById(id); if (!webhook) { throw new Error('Webhook not found'); } const updatedWebhook = webhook.update(updates); await this.webhookRepository.save(updatedWebhook); this.logger.log(`Webhook updated: ${id}`); return updatedWebhook; } /** * Activate webhook */ async activateWebhook(id: string): Promise { const webhook = await this.webhookRepository.findById(id); if (!webhook) { throw new Error('Webhook not found'); } const activatedWebhook = webhook.activate(); await this.webhookRepository.save(activatedWebhook); this.logger.log(`Webhook activated: ${id}`); } /** * Deactivate webhook */ async deactivateWebhook(id: string): Promise { const webhook = await this.webhookRepository.findById(id); if (!webhook) { throw new Error('Webhook not found'); } const deactivatedWebhook = webhook.deactivate(); await this.webhookRepository.save(deactivatedWebhook); this.logger.log(`Webhook deactivated: ${id}`); } /** * Delete webhook */ async deleteWebhook(id: string): Promise { await this.webhookRepository.delete(id); this.logger.log(`Webhook deleted: ${id}`); } /** * Trigger webhooks for an event */ async triggerWebhooks(event: WebhookEvent, organizationId: string, data: any): Promise { try { const webhooks = await this.webhookRepository.findActiveByEvent(event, organizationId); if (webhooks.length === 0) { this.logger.debug(`No active webhooks found for event: ${event}`); return; } const payload: WebhookPayload = { event, timestamp: new Date().toISOString(), data, organizationId, }; // Trigger all webhooks in parallel await Promise.allSettled(webhooks.map(webhook => this.triggerWebhook(webhook, payload))); this.logger.log(`Triggered ${webhooks.length} webhooks for event: ${event}`); } catch (error: any) { this.logger.error( `Error triggering webhooks: ${error?.message || 'Unknown error'}`, error?.stack ); } } /** * Trigger a single webhook with retries */ private async triggerWebhook(webhook: Webhook, payload: WebhookPayload): Promise { let lastError: Error | null = null; for (let attempt = 0; attempt < this.MAX_RETRIES; attempt++) { try { if (attempt > 0) { await this.delay(this.RETRY_DELAY_MS * attempt); } // Generate signature const signature = this.generateSignature(payload, webhook.secret); // Prepare headers const headers = { 'Content-Type': 'application/json', 'X-Webhook-Signature': signature, 'X-Webhook-Event': payload.event, 'X-Webhook-Timestamp': payload.timestamp, ...webhook.headers, }; // Send HTTP request const response = await firstValueFrom( this.httpService.post(webhook.url, payload, { headers, timeout: 10000, // 10 seconds }) ); if (response && response.status >= 200 && response.status < 300) { // Success - record trigger const updatedWebhook = webhook.recordTrigger(); await this.webhookRepository.save(updatedWebhook); this.logger.log(`Webhook triggered successfully: ${webhook.id} (attempt ${attempt + 1})`); return; } lastError = new Error( `HTTP ${response?.status || 'Unknown'}: ${response?.statusText || 'Unknown error'}` ); } catch (error: any) { lastError = error; this.logger.warn( `Webhook trigger attempt ${attempt + 1} failed: ${webhook.id} - ${error?.message}` ); } } // All retries failed - mark webhook as failed const failedWebhook = webhook.markAsFailed(); await this.webhookRepository.save(failedWebhook); this.logger.error( `Webhook failed after ${this.MAX_RETRIES} attempts: ${webhook.id} - ${lastError?.message}` ); } /** * Generate webhook secret */ private generateSecret(): string { return crypto.randomBytes(32).toString('hex'); } /** * Generate HMAC signature for webhook payload */ private generateSignature(payload: any, secret: string): string { const hmac = crypto.createHmac('sha256', secret); hmac.update(JSON.stringify(payload)); return hmac.digest('hex'); } /** * Verify webhook signature */ verifySignature(payload: any, signature: string, secret: string): boolean { const expectedSignature = this.generateSignature(payload, secret); return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature)); } /** * Delay helper for retries */ private delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } }