Some checks failed
CI/CD Pipeline - Xpeditis PreProd / Frontend - Docker Build & Push (push) Blocked by required conditions
CI/CD Pipeline - Xpeditis PreProd / Deploy to PreProd Server (push) Blocked by required conditions
CI/CD Pipeline - Xpeditis PreProd / Run Smoke Tests (push) Blocked by required conditions
CI/CD Pipeline - Xpeditis PreProd / Backend - Build & Test (push) Failing after 5m53s
CI/CD Pipeline - Xpeditis PreProd / Backend - Docker Build & Push (push) Has been skipped
CI/CD Pipeline - Xpeditis PreProd / Frontend - Build & Test (push) Has been cancelled
- Replace all ../../domain/ imports with @domain/ across 67 files - Configure NestJS to use tsconfig.build.json with rootDir - Add tsc-alias to resolve path aliases after build - This fixes 'Cannot find module' TypeScript compilation errors Fixed files: - 30 files in application layer - 37 files in infrastructure layer
275 lines
7.3 KiB
TypeScript
275 lines
7.3 KiB
TypeScript
/**
|
|
* 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<string, string>;
|
|
}
|
|
|
|
export interface UpdateWebhookInput {
|
|
url?: string;
|
|
events?: WebhookEvent[];
|
|
description?: string;
|
|
headers?: Record<string, string>;
|
|
}
|
|
|
|
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<Webhook> {
|
|
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<Webhook | null> {
|
|
return this.webhookRepository.findById(id);
|
|
}
|
|
|
|
/**
|
|
* Get webhooks by organization
|
|
*/
|
|
async getWebhooksByOrganization(organizationId: string): Promise<Webhook[]> {
|
|
return this.webhookRepository.findByOrganization(organizationId);
|
|
}
|
|
|
|
/**
|
|
* Get webhooks with filters
|
|
*/
|
|
async getWebhooks(filters: WebhookFilters): Promise<Webhook[]> {
|
|
return this.webhookRepository.findByFilters(filters);
|
|
}
|
|
|
|
/**
|
|
* Update webhook
|
|
*/
|
|
async updateWebhook(id: string, updates: UpdateWebhookInput): Promise<Webhook> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
}
|