1
0

feat(server): add webhooks system

- Add webhooks module with CRUD operations
- Add webhook deliveries with BullMQ queue processing
- Add webhook signature verification service
- Add webhook event subscriber for system events
- Add webhooks migration for webhooks and webhook_deliveries tables
- Register WebhooksModule in App module
- Add Webhook ability subject to roles
This commit is contained in:
Ahmed Bouhuolia
2026-04-17 14:40:14 +02:00
parent 52c97f1401
commit f69b935939
25 changed files with 1043 additions and 1 deletions
@@ -0,0 +1,35 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('webhooks', (table) => {
table.increments('id').primary();
table.string('name', 255).notNullable();
table.string('url', 5000).notNullable();
table.string('secret', 255).nullable();
table.string('entity', 100).notNullable();
table.json('events').notNullable();
table.enum('method', ['POST', 'PUT', 'DELETE']).defaultTo('POST');
table.json('headers').nullable();
table.boolean('is_active').defaultTo(true);
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
});
await knex.schema.createTable('webhook_deliveries', (table) => {
table.increments('id').primary();
table.integer('webhook_id').unsigned().notNullable().references('id').inTable('webhooks').onDelete('CASCADE');
table.string('event_type', 255).notNullable();
table.json('payload').notNullable();
table.integer('response_status').nullable();
table.text('response_body').nullable();
table.integer('attempt_count').defaultTo(1);
table.text('error_message').nullable();
table.timestamp('delivered_at').nullable();
table.timestamp('created_at').defaultTo(knex.fn.now());
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists('webhook_deliveries');
await knex.schema.dropTableIfExists('webhooks');
}
@@ -100,6 +100,7 @@ import { ContactsModule } from '../Contacts/Contacts.module';
import { BankingPlaidModule } from '../BankingPlaid/BankingPlaid.module';
import { BankingCategorizeModule } from '../BankingCategorize/BankingCategorize.module';
import { ExchangeRatesModule } from '../ExchangeRates/ExchangeRates.module';
import { WebhooksModule } from '../Webhooks/Webhooks.module';
import { TenantModelsInitializeModule } from '../Tenancy/TenantModelsInitialize.module';
import { BillLandedCostsModule } from '../BillLandedCosts/BillLandedCosts.module';
import { SocketModule } from '../Socket/Socket.module';
@@ -258,6 +259,7 @@ import { AppThrottleModule } from './AppThrottle.module';
ContactsModule,
SocketModule,
ExchangeRatesModule,
WebhooksModule,
],
controllers: [AppController],
providers: [
@@ -60,7 +60,8 @@ export enum AbilitySubject {
CreditNote = 'CreditNode',
VendorCredit = 'VendorCredit',
Project = 'Project',
TaxRate = 'TaxRate'
TaxRate = 'TaxRate',
Webhook = 'Webhook'
}
export interface IRoleCreatedPayload {
@@ -0,0 +1,69 @@
import { Injectable, Inject } from '@nestjs/common';
import { Queue } from 'bullmq';
import { InjectQueue } from '@nestjs/bullmq';
import { Webhook } from './models/Webhook.model';
import { WebhookDelivery } from './models/WebhookDelivery.model';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { WebhookDeliveryQueue, WebhookDeliveryJob } from './constants';
import { ClsService } from 'nestjs-cls';
@Injectable()
export class WebhookDelivererService {
constructor(
@InjectQueue(WebhookDeliveryQueue)
private readonly webhookDeliveryQueue: Queue,
@Inject(Webhook.name)
private readonly webhookModel: TenantModelProxy<typeof Webhook>,
@Inject(WebhookDelivery.name)
private readonly webhookDeliveryModel: TenantModelProxy<typeof WebhookDelivery>,
private readonly clsService: ClsService,
) {}
/**
* Finds active webhooks matching the entity and action, then enqueues delivery jobs.
*/
public async enqueueMatchingWebhooks(
entity: string,
action: string,
data: Record<string, any>,
): Promise<void> {
const webhooks = await this.webhookModel()
.query()
.where('entity', entity)
.where('is_active', true);
const matchingWebhooks = webhooks.filter((webhook) => {
return webhook.events.includes(action);
});
if (matchingWebhooks.length === 0) {
return;
}
const eventType = `${entity}.${action}`;
const payload = { event: eventType, data };
const organizationId = this.clsService.get('organizationId');
const userId = this.clsService.get('userId');
for (const webhook of matchingWebhooks) {
const delivery = await this.webhookDeliveryModel()
.query()
.insert({
webhookId: webhook.id,
eventType,
payload,
attemptCount: 0,
});
await this.webhookDeliveryQueue.add(WebhookDeliveryJob, {
webhookId: webhook.id,
eventType,
payload,
organizationId,
userId,
deliveryId: delivery.id,
});
}
}
}
@@ -0,0 +1,106 @@
import { events } from '@/common/events/events';
import { WebhookEventMapping } from './Webhooks.types';
export const WEBHOOK_EVENT_MAPPINGS: WebhookEventMapping[] = [
// Sale Invoices
{ internalEvent: events.saleInvoice.onCreated, entity: 'sale_invoice', action: 'created', dataKey: 'saleInvoice', idKey: 'saleInvoiceId' },
{ internalEvent: events.saleInvoice.onEdited, entity: 'sale_invoice', action: 'edited', dataKey: 'saleInvoice', idKey: 'saleInvoiceId' },
{ internalEvent: events.saleInvoice.onDeleted, entity: 'sale_invoice', action: 'deleted', dataKey: 'oldSaleInvoice', idKey: 'saleInvoiceId' },
{ internalEvent: events.saleInvoice.onDelivered, entity: 'sale_invoice', action: 'delivered', dataKey: 'saleInvoice', idKey: 'saleInvoiceId' },
{ internalEvent: events.saleInvoice.onPublished, entity: 'sale_invoice', action: 'published', dataKey: 'saleInvoice', idKey: 'saleInvoiceId' },
{ internalEvent: events.saleInvoice.onWrittenoff, entity: 'sale_invoice', action: 'writtenoff', dataKey: 'saleInvoice', idKey: 'saleInvoiceId' },
{ internalEvent: events.saleInvoice.onWrittenoffCanceled, entity: 'sale_invoice', action: 'writtenoff_canceled', dataKey: 'saleInvoice', idKey: 'saleInvoiceId' },
// Sale Estimates
{ internalEvent: events.saleEstimate.onCreated, entity: 'sale_estimate', action: 'created', dataKey: 'saleEstimate', idKey: 'saleEstimateId' },
{ internalEvent: events.saleEstimate.onEdited, entity: 'sale_estimate', action: 'edited', dataKey: 'saleEstimate', idKey: 'saleEstimateId' },
{ internalEvent: events.saleEstimate.onDeleted, entity: 'sale_estimate', action: 'deleted', dataKey: 'saleEstimate', idKey: 'saleEstimateId' },
{ internalEvent: events.saleEstimate.onPublished, entity: 'sale_estimate', action: 'published', dataKey: 'saleEstimate', idKey: 'saleEstimateId' },
{ internalEvent: events.saleEstimate.onApproved, entity: 'sale_estimate', action: 'approved', dataKey: 'saleEstimate', idKey: 'saleEstimateId' },
{ internalEvent: events.saleEstimate.onRejected, entity: 'sale_estimate', action: 'rejected', dataKey: 'saleEstimate', idKey: 'saleEstimateId' },
{ internalEvent: events.saleEstimate.onConvertedToInvoice, entity: 'sale_estimate', action: 'converted_to_invoice', dataKey: 'saleEstimate', idKey: 'saleEstimateId' },
// Sale Receipts
{ internalEvent: events.saleReceipt.onCreated, entity: 'sale_receipt', action: 'created', dataKey: 'saleReceipt', idKey: 'saleReceiptId' },
{ internalEvent: events.saleReceipt.onEdited, entity: 'sale_receipt', action: 'edited', dataKey: 'saleReceipt', idKey: 'saleReceiptId' },
{ internalEvent: events.saleReceipt.onDeleted, entity: 'sale_receipt', action: 'deleted', dataKey: 'saleReceipt', idKey: 'saleReceiptId' },
{ internalEvent: events.saleReceipt.onPublished, entity: 'sale_receipt', action: 'published', dataKey: 'saleReceipt', idKey: 'saleReceiptId' },
{ internalEvent: events.saleReceipt.onClosed, entity: 'sale_receipt', action: 'closed', dataKey: 'saleReceipt', idKey: 'saleReceiptId' },
// Payment Receive
{ internalEvent: events.paymentReceive.onCreated, entity: 'payment_receive', action: 'created', dataKey: 'paymentReceive', idKey: 'paymentReceiveId' },
{ internalEvent: events.paymentReceive.onEdited, entity: 'payment_receive', action: 'edited', dataKey: 'paymentReceive', idKey: 'paymentReceiveId' },
{ internalEvent: events.paymentReceive.onDeleted, entity: 'payment_receive', action: 'deleted', dataKey: 'paymentReceive', idKey: 'paymentReceiveId' },
{ internalEvent: events.paymentReceive.onPublished, entity: 'payment_receive', action: 'published', dataKey: 'paymentReceive', idKey: 'paymentReceiveId' },
// Credit Notes
{ internalEvent: events.creditNote.onCreated, entity: 'credit_note', action: 'created', dataKey: 'creditNote', idKey: 'creditNoteId' },
{ internalEvent: events.creditNote.onEdited, entity: 'credit_note', action: 'edited', dataKey: 'creditNote', idKey: 'creditNoteId' },
{ internalEvent: events.creditNote.onDeleted, entity: 'credit_note', action: 'deleted', dataKey: 'creditNote', idKey: 'creditNoteId' },
{ internalEvent: events.creditNote.onOpened, entity: 'credit_note', action: 'opened', dataKey: 'creditNote', idKey: 'creditNoteId' },
{ internalEvent: events.creditNote.onRefundCreated, entity: 'credit_note', action: 'refund_created', dataKey: 'refundCreditNote', idKey: 'refundCreditNoteId' },
{ internalEvent: events.creditNote.onRefundDeleted, entity: 'credit_note', action: 'refund_deleted', dataKey: 'refundCreditNote', idKey: 'refundCreditNoteId' },
{ internalEvent: events.creditNote.onApplyToInvoicesCreated, entity: 'credit_note', action: 'apply_to_invoices_created', dataKey: 'creditNoteApplyInvoice', idKey: 'creditNoteApplyInvoiceId' },
{ internalEvent: events.creditNote.onApplyToInvoicesDeleted, entity: 'credit_note', action: 'apply_to_invoices_deleted', dataKey: 'creditNoteApplyInvoice', idKey: 'creditNoteApplyInvoiceId' },
// Bills
{ internalEvent: events.bill.onCreated, entity: 'bill', action: 'created', dataKey: 'bill', idKey: 'billId' },
{ internalEvent: events.bill.onEdited, entity: 'bill', action: 'edited', dataKey: 'bill', idKey: 'billId' },
{ internalEvent: events.bill.onDeleted, entity: 'bill', action: 'deleted', dataKey: 'oldBill', idKey: 'billId' },
{ internalEvent: events.bill.onPublished, entity: 'bill', action: 'published', dataKey: 'bill', idKey: 'billId' },
{ internalEvent: events.bill.onOpened, entity: 'bill', action: 'opened', dataKey: 'bill', idKey: 'billId' },
// Bill Payments
{ internalEvent: events.billPayment.onCreated, entity: 'bill_payment', action: 'created', dataKey: 'billPayment', idKey: 'billPaymentId' },
{ internalEvent: events.billPayment.onEdited, entity: 'bill_payment', action: 'edited', dataKey: 'billPayment', idKey: 'billPaymentId' },
{ internalEvent: events.billPayment.onDeleted, entity: 'bill_payment', action: 'deleted', dataKey: 'billPayment', idKey: 'billPaymentId' },
{ internalEvent: events.billPayment.onPublished, entity: 'bill_payment', action: 'published', dataKey: 'billPayment', idKey: 'billPaymentId' },
// Vendor Credits
{ internalEvent: events.vendorCredit.onCreated, entity: 'vendor_credit', action: 'created', dataKey: 'vendorCredit', idKey: 'vendorCreditId' },
{ internalEvent: events.vendorCredit.onEdited, entity: 'vendor_credit', action: 'edited', dataKey: 'vendorCredit', idKey: 'vendorCreditId' },
{ internalEvent: events.vendorCredit.onDeleted, entity: 'vendor_credit', action: 'deleted', dataKey: 'vendorCredit', idKey: 'vendorCreditId' },
{ internalEvent: events.vendorCredit.onOpened, entity: 'vendor_credit', action: 'opened', dataKey: 'vendorCredit', idKey: 'vendorCreditId' },
{ internalEvent: events.vendorCredit.onRefundCreated, entity: 'vendor_credit', action: 'refund_created', dataKey: 'refundVendorCredit', idKey: 'refundVendorCreditId' },
{ internalEvent: events.vendorCredit.onRefundDeleted, entity: 'vendor_credit', action: 'refund_deleted', dataKey: 'refundVendorCredit', idKey: 'refundVendorCreditId' },
{ internalEvent: events.vendorCredit.onApplyToInvoicesCreated, entity: 'vendor_credit', action: 'apply_to_bills_created', dataKey: 'vendorCreditApplyBill', idKey: 'vendorCreditApplyBillId' },
{ internalEvent: events.vendorCredit.onApplyToInvoicesDeleted, entity: 'vendor_credit', action: 'apply_to_bills_deleted', dataKey: 'vendorCreditApplyBill', idKey: 'vendorCreditApplyBillId' },
// Customers
{ internalEvent: events.customers.onCreated, entity: 'customer', action: 'created', dataKey: 'customer', idKey: 'customerId' },
{ internalEvent: events.customers.onEdited, entity: 'customer', action: 'edited', dataKey: 'customer', idKey: 'customerId' },
{ internalEvent: events.customers.onDeleted, entity: 'customer', action: 'deleted', dataKey: 'customer', idKey: 'customerId' },
{ internalEvent: events.customers.onActivated, entity: 'customer', action: 'activated', dataKey: 'customer', idKey: 'customerId' },
{ internalEvent: events.customers.onOpeningBalanceChanged, entity: 'customer', action: 'opening_balance_changed', dataKey: 'customer', idKey: 'customerId' },
// Vendors
{ internalEvent: events.vendors.onCreated, entity: 'vendor', action: 'created', dataKey: 'vendor', idKey: 'vendorId' },
{ internalEvent: events.vendors.onEdited, entity: 'vendor', action: 'edited', dataKey: 'vendor', idKey: 'vendorId' },
{ internalEvent: events.vendors.onDeleted, entity: 'vendor', action: 'deleted', dataKey: 'vendor', idKey: 'vendorId' },
{ internalEvent: events.vendors.onActivated, entity: 'vendor', action: 'activated', dataKey: 'vendor', idKey: 'vendorId' },
{ internalEvent: events.vendors.onOpeningBalanceChanged, entity: 'vendor', action: 'opening_balance_changed', dataKey: 'vendor', idKey: 'vendorId' },
// Items
{ internalEvent: events.item.onCreated, entity: 'item', action: 'created', dataKey: 'item', idKey: 'itemId' },
{ internalEvent: events.item.onEdited, entity: 'item', action: 'edited', dataKey: 'item', idKey: 'itemId' },
{ internalEvent: events.item.onDeleted, entity: 'item', action: 'deleted', dataKey: 'item', idKey: 'itemId' },
{ internalEvent: events.item.onActivated, entity: 'item', action: 'activated', dataKey: 'item', idKey: 'itemId' },
{ internalEvent: events.item.onInactivated, entity: 'item', action: 'inactivated', dataKey: 'item', idKey: 'itemId' },
// Item Categories
{ internalEvent: events.itemCategory.onCreated, entity: 'item_category', action: 'created', dataKey: 'itemCategory', idKey: 'itemCategoryId' },
{ internalEvent: events.itemCategory.onEdited, entity: 'item_category', action: 'edited', dataKey: 'itemCategory', idKey: 'itemCategoryId' },
{ internalEvent: events.itemCategory.onDeleted, entity: 'item_category', action: 'deleted', dataKey: 'itemCategory', idKey: 'itemCategoryId' },
// Inventory Adjustments
{ internalEvent: events.inventoryAdjustment.onCreated, entity: 'inventory_adjustment', action: 'created', dataKey: 'inventoryAdjustment', idKey: 'inventoryAdjustmentId' },
{ internalEvent: events.inventoryAdjustment.onDeleted, entity: 'inventory_adjustment', action: 'deleted', dataKey: 'inventoryAdjustment', idKey: 'inventoryAdjustmentId' },
{ internalEvent: events.inventoryAdjustment.onPublished, entity: 'inventory_adjustment', action: 'published', dataKey: 'inventoryAdjustment', idKey: 'inventoryAdjustmentId' },
// Warehouse Transfers
{ internalEvent: events.warehouseTransfer.onCreated, entity: 'warehouse_transfer', action: 'created', dataKey: 'warehouseTransfer', idKey: 'warehouseTransferId' },
{ internalEvent: events.warehouseTransfer.onEdited, entity: 'warehouse_transfer', action: 'edited', dataKey: 'warehouseTransfer', idKey: 'warehouseTransferId' },
{ internalEvent: events.warehouseTransfer.onDeleted, entity: 'warehouse_transfer', action: 'deleted', dataKey: 'warehouseTransfer', idKey: 'warehouseTransferId' },
{ internalEvent: events.warehouseTransfer.onInitiated, entity: 'warehouse_transfer', action: 'initiated', dataKey: 'warehouseTransfer', idKey: 'warehouseTransferId' },
{ internalEvent: events.warehouseTransfer.onTransferred, entity: 'warehouse_transfer', action: 'transferred', dataKey: 'warehouseTransfer', idKey: 'warehouseTransferId' },
];
@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { createHmac } from 'crypto';
@Injectable()
export class WebhookSignatureService {
/**
* Generates a Stripe-style signature header.
* @param {string} payload - Raw JSON payload.
* @param {string} secret - Webhook secret.
* @returns {string} Signature header value.
*/
public generateSignature(payload: string, secret: string): string {
const timestamp = Math.floor(Date.now() / 1000);
const signedPayload = `${timestamp}.${payload}`;
const signature = createHmac('sha256', secret).update(signedPayload).digest('hex');
return `t=${timestamp},v1=${signature}`;
}
/**
* Verifies a Stripe-style signature header.
* @param {string} payload - Raw JSON payload.
* @param {string} secret - Webhook secret.
* @param {string} header - Signature header value.
* @returns {boolean}
*/
public verifySignature(payload: string, secret: string, header: string): boolean {
const expected = this.generateSignature(payload, secret);
return expected === header;
}
}
@@ -0,0 +1,50 @@
import { Injectable } from '@nestjs/common';
import { CreateWebhookService } from './commands/CreateWebhook.service';
import { EditWebhookService } from './commands/EditWebhook.service';
import { DeleteWebhookService } from './commands/DeleteWebhook.service';
import { ToggleWebhookActiveService } from './commands/ToggleWebhookActive.service';
import { GetWebhookService } from './queries/GetWebhook.service';
import { GetWebhooksService } from './queries/GetWebhooks.service';
import { GetWebhookDeliveriesService } from './queries/GetWebhookDeliveries.service';
import { CreateWebhookDto, EditWebhookDto } from './dtos/Webhook.dto';
@Injectable()
export class WebhooksApplication {
constructor(
private readonly createWebhookService: CreateWebhookService,
private readonly editWebhookService: EditWebhookService,
private readonly deleteWebhookService: DeleteWebhookService,
private readonly toggleWebhookActiveService: ToggleWebhookActiveService,
private readonly getWebhookService: GetWebhookService,
private readonly getWebhooksService: GetWebhooksService,
private readonly getWebhookDeliveriesService: GetWebhookDeliveriesService,
) {}
public createWebhook(dto: CreateWebhookDto) {
return this.createWebhookService.createWebhook(dto);
}
public editWebhook(webhookId: number, dto: EditWebhookDto) {
return this.editWebhookService.editWebhook(webhookId, dto);
}
public deleteWebhook(webhookId: number) {
return this.deleteWebhookService.deleteWebhook(webhookId);
}
public toggleWebhookActive(webhookId: number) {
return this.toggleWebhookActiveService.toggleWebhookActive(webhookId);
}
public getWebhook(webhookId: number) {
return this.getWebhookService.getWebhook(webhookId);
}
public getWebhooks(filterBy?: string, page?: number, pageSize?: number) {
return this.getWebhooksService.getWebhooks(filterBy, page, pageSize);
}
public getWebhookDeliveries(webhookId: number, page?: number, pageSize?: number) {
return this.getWebhookDeliveriesService.getDeliveries(webhookId, page, pageSize);
}
}
@@ -0,0 +1,164 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import { WebhooksApplication } from './Webhooks.application';
import {
ApiExtraModels,
ApiOperation,
ApiResponse,
ApiTags,
getSchemaPath,
} from '@nestjs/swagger';
import { CreateWebhookDto, EditWebhookDto } from './dtos/Webhook.dto';
import { WebhookResponseDto } from './dtos/WebhookResponse.dto';
import { WebhookDeliveryResponseDto } from './dtos/WebhookDeliveryResponse.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator';
import { PermissionGuard } from '@/modules/Roles/Permission.guard';
import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard';
import { AbilitySubject } from '@/modules/Roles/Roles.types';
import { WebhookAction } from './Webhooks.types';
@Controller('webhooks')
@ApiTags('Webhooks')
@ApiExtraModels(WebhookResponseDto, WebhookDeliveryResponseDto)
@ApiCommonHeaders()
@UseGuards(AuthorizationGuard, PermissionGuard)
export class WebhooksController {
constructor(private readonly webhooksApplication: WebhooksApplication) {}
@Post()
@RequirePermission(WebhookAction.Create, AbilitySubject.Webhook)
@ApiOperation({ summary: 'Create a new webhook.' })
@ApiResponse({
status: 201,
description: 'The webhook has been successfully created.',
schema: { $ref: getSchemaPath(WebhookResponseDto) },
})
public createWebhook(@Body() dto: CreateWebhookDto) {
return this.webhooksApplication.createWebhook(dto);
}
@Put(':id')
@RequirePermission(WebhookAction.Edit, AbilitySubject.Webhook)
@ApiOperation({ summary: 'Edit the given webhook.' })
@ApiResponse({
status: 200,
description: 'The webhook has been successfully updated.',
schema: { $ref: getSchemaPath(WebhookResponseDto) },
})
public editWebhook(
@Param('id') webhookId: number,
@Body() dto: EditWebhookDto,
) {
return this.webhooksApplication.editWebhook(webhookId, dto);
}
@Delete(':id')
@RequirePermission(WebhookAction.Delete, AbilitySubject.Webhook)
@ApiOperation({ summary: 'Delete the given webhook.' })
@ApiResponse({
status: 200,
description: 'The webhook has been successfully deleted.',
schema: { $ref: getSchemaPath(WebhookResponseDto) },
})
public deleteWebhook(@Param('id') webhookId: number) {
return this.webhooksApplication.deleteWebhook(webhookId);
}
@Get(':id')
@RequirePermission(WebhookAction.View, AbilitySubject.Webhook)
@ApiOperation({ summary: 'Retrieves the webhook details.' })
@ApiResponse({
status: 200,
description: 'The webhook details have been successfully retrieved.',
schema: { $ref: getSchemaPath(WebhookResponseDto) },
})
public getWebhook(@Param('id') webhookId: number) {
return this.webhooksApplication.getWebhook(webhookId);
}
@Get()
@RequirePermission(WebhookAction.View, AbilitySubject.Webhook)
@ApiOperation({ summary: 'Retrieves the webhooks list.' })
@ApiResponse({
status: 200,
description: 'The webhooks have been successfully retrieved.',
schema: {
type: 'object',
properties: {
data: {
type: 'array',
items: { $ref: getSchemaPath(WebhookResponseDto) },
},
pagination: {
type: 'object',
properties: {
total: { type: 'number' },
page: { type: 'number' },
pageSize: { type: 'number' },
},
},
},
},
})
public getWebhooks(
@Query('filter_by') filterBy?: string,
@Query('page') page?: number,
@Query('page_size') pageSize?: number,
) {
return this.webhooksApplication.getWebhooks(filterBy, page, pageSize);
}
@Post(':id/toggle')
@RequirePermission(WebhookAction.Edit, AbilitySubject.Webhook)
@ApiOperation({ summary: 'Toggle the webhook active state.' })
@ApiResponse({
status: 200,
description: 'The webhook active state has been toggled.',
schema: { $ref: getSchemaPath(WebhookResponseDto) },
})
public toggleWebhookActive(@Param('id') webhookId: number) {
return this.webhooksApplication.toggleWebhookActive(webhookId);
}
@Get(':id/deliveries')
@RequirePermission(WebhookAction.View, AbilitySubject.Webhook)
@ApiOperation({ summary: 'Retrieves the webhook delivery logs.' })
@ApiResponse({
status: 200,
description: 'The delivery logs have been successfully retrieved.',
schema: {
type: 'object',
properties: {
data: {
type: 'array',
items: { $ref: getSchemaPath(WebhookDeliveryResponseDto) },
},
pagination: {
type: 'object',
properties: {
total: { type: 'number' },
page: { type: 'number' },
pageSize: { type: 'number' },
},
},
},
},
})
public getWebhookDeliveries(
@Param('id') webhookId: number,
@Query('page') page?: number,
@Query('page_size') pageSize?: number,
) {
return this.webhooksApplication.getWebhookDeliveries(webhookId, page, pageSize);
}
}
@@ -0,0 +1,54 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { BullBoardModule } from '@bull-board/nestjs';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { RegisterTenancyModel } from '../Tenancy/TenancyModels/Tenancy.module';
import { WebhooksController } from './Webhooks.controller';
import { WebhooksApplication } from './Webhooks.application';
import { CreateWebhookService } from './commands/CreateWebhook.service';
import { EditWebhookService } from './commands/EditWebhook.service';
import { DeleteWebhookService } from './commands/DeleteWebhook.service';
import { ToggleWebhookActiveService } from './commands/ToggleWebhookActive.service';
import { GetWebhookService } from './queries/GetWebhook.service';
import { GetWebhooksService } from './queries/GetWebhooks.service';
import { GetWebhookDeliveriesService } from './queries/GetWebhookDeliveries.service';
import { WebhookSignatureService } from './WebhookSignature.service';
import { WebhookDelivererService } from './WebhookDeliverer.service';
import { WebhookDeliveryProcessor } from './processors/WebhookDelivery.processor';
import { WebhooksEventSubscriber } from './subscribers/WebhooksEventSubscriber';
import { Webhook } from './models/Webhook.model';
import { WebhookDelivery } from './models/WebhookDelivery.model';
import { WebhookDeliveryQueue } from './constants';
const models = [
RegisterTenancyModel(Webhook),
RegisterTenancyModel(WebhookDelivery),
];
@Module({
imports: [
...models,
BullModule.registerQueue({ name: WebhookDeliveryQueue }),
BullBoardModule.forFeature({
name: WebhookDeliveryQueue,
adapter: BullMQAdapter,
}),
],
controllers: [WebhooksController],
providers: [
WebhooksApplication,
CreateWebhookService,
EditWebhookService,
DeleteWebhookService,
ToggleWebhookActiveService,
GetWebhookService,
GetWebhooksService,
GetWebhookDeliveriesService,
WebhookSignatureService,
WebhookDelivererService,
WebhookDeliveryProcessor,
WebhooksEventSubscriber,
],
exports: [...models, WebhookDelivererService],
})
export class WebhooksModule {}
@@ -0,0 +1,28 @@
export enum WebhookAction {
Create = 'Create',
Edit = 'Edit',
Delete = 'Delete',
View = 'View',
}
export interface WebhookHeader {
param_name: string;
param_value: string;
}
export interface WebhookDeliveryQueueJobPayload {
webhookId: number;
eventType: string;
payload: Record<string, any>;
organizationId: string;
userId: number;
deliveryId: number;
}
export interface WebhookEventMapping {
internalEvent: string;
entity: string;
action: string;
dataKey: string;
idKey: string;
}
@@ -0,0 +1,21 @@
import { Injectable, Inject } from '@nestjs/common';
import { Webhook } from '../models/Webhook.model';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { CreateWebhookDto } from '../dtos/Webhook.dto';
@Injectable()
export class CreateWebhookService {
constructor(
@Inject(Webhook.name)
private readonly webhookModel: TenantModelProxy<typeof Webhook>,
) {}
public async createWebhook(dto: CreateWebhookDto) {
const webhook = await this.webhookModel().query().insertAndFetch({
...dto,
method: dto.method || 'POST',
isActive: true,
} as any);
return webhook;
}
}
@@ -0,0 +1,20 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { Webhook } from '../models/Webhook.model';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class DeleteWebhookService {
constructor(
@Inject(Webhook.name)
private readonly webhookModel: TenantModelProxy<typeof Webhook>,
) {}
public async deleteWebhook(webhookId: number) {
const webhook = await this.webhookModel().query().findById(webhookId);
if (!webhook) {
throw new NotFoundException('Webhook not found.');
}
await this.webhookModel().query().deleteById(webhookId);
return webhook;
}
}
@@ -0,0 +1,23 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { Webhook } from '../models/Webhook.model';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { EditWebhookDto } from '../dtos/Webhook.dto';
@Injectable()
export class EditWebhookService {
constructor(
@Inject(Webhook.name)
private readonly webhookModel: TenantModelProxy<typeof Webhook>,
) {}
public async editWebhook(webhookId: number, dto: EditWebhookDto) {
const webhook = await this.webhookModel().query().findById(webhookId);
if (!webhook) {
throw new NotFoundException('Webhook not found.');
}
const updated = await this.webhookModel()
.query()
.patchAndFetchById(webhookId, { ...dto } as any);
return updated;
}
}
@@ -0,0 +1,22 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { Webhook } from '../models/Webhook.model';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class ToggleWebhookActiveService {
constructor(
@Inject(Webhook.name)
private readonly webhookModel: TenantModelProxy<typeof Webhook>,
) {}
public async toggleWebhookActive(webhookId: number) {
const webhook = await this.webhookModel().query().findById(webhookId);
if (!webhook) {
throw new NotFoundException('Webhook not found.');
}
const updated = await this.webhookModel()
.query()
.patchAndFetchById(webhookId, { isActive: !webhook.isActive } as any);
return updated;
}
}
@@ -0,0 +1,2 @@
export const WebhookDeliveryQueue = 'WebhookDeliveryQueue';
export const WebhookDeliveryJob = 'WebhookDeliveryJob';
@@ -0,0 +1,97 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsOptional, IsArray, IsBoolean, IsEnum, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class WebhookHeaderDto {
@IsString()
@IsNotEmpty()
@ApiProperty({ description: 'Name of the header parameter.' })
param_name: string;
@IsString()
@IsNotEmpty()
@ApiProperty({ description: 'Value of the header parameter.' })
param_value: string;
}
export class CreateWebhookDto {
@IsString()
@IsNotEmpty()
@ApiProperty({ description: 'Name of the webhook.', example: 'Invoice Webhook' })
name: string;
@IsString()
@IsNotEmpty()
@ApiProperty({ description: 'Target URL for the webhook callback.', example: 'https://example.com/webhook' })
url: string;
@IsString()
@IsNotEmpty()
@ApiProperty({ description: 'Entity type the webhook subscribes to.', example: 'sale_invoice' })
entity: string;
@IsArray()
@IsString({ each: true })
@ApiProperty({ description: 'List of events that trigger this webhook.', example: ['created', 'edited', 'deleted'] })
events: string[];
@IsEnum(['POST', 'PUT', 'DELETE'] as const)
@IsOptional()
@ApiPropertyOptional({ description: 'HTTP method for the webhook.', example: 'POST', default: 'POST' })
method?: 'POST' | 'PUT' | 'DELETE';
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => WebhookHeaderDto)
@ApiPropertyOptional({ description: 'Custom headers to include in the webhook request.', type: [WebhookHeaderDto] })
headers?: WebhookHeaderDto[];
@IsString()
@IsOptional()
@ApiPropertyOptional({ description: 'Secret key for HMAC signature.', example: 'whsec_123456' })
secret?: string;
}
export class EditWebhookDto {
@IsString()
@IsNotEmpty()
@IsOptional()
@ApiPropertyOptional({ description: 'Name of the webhook.', example: 'Invoice Webhook' })
name?: string;
@IsString()
@IsNotEmpty()
@IsOptional()
@ApiPropertyOptional({ description: 'Target URL for the webhook callback.', example: 'https://example.com/webhook' })
url?: string;
@IsString()
@IsNotEmpty()
@IsOptional()
@ApiPropertyOptional({ description: 'Entity type the webhook subscribes to.', example: 'sale_invoice' })
entity?: string;
@IsArray()
@IsString({ each: true })
@IsOptional()
@ApiPropertyOptional({ description: 'List of events that trigger this webhook.', example: ['created', 'edited', 'deleted'] })
events?: string[];
@IsEnum(['POST', 'PUT', 'DELETE'] as const)
@IsOptional()
@ApiPropertyOptional({ description: 'HTTP method for the webhook.', example: 'POST' })
method?: 'POST' | 'PUT' | 'DELETE';
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => WebhookHeaderDto)
@ApiPropertyOptional({ description: 'Custom headers to include in the webhook request.', type: [WebhookHeaderDto] })
headers?: WebhookHeaderDto[];
@IsString()
@IsOptional()
@ApiPropertyOptional({ description: 'Secret key for HMAC signature.', example: 'whsec_123456' })
secret?: string;
}
@@ -0,0 +1,33 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class WebhookDeliveryResponseDto {
@ApiProperty({ description: 'The unique identifier of the delivery log', example: 1 })
id: number;
@ApiProperty({ description: 'ID of the associated webhook', example: 1 })
webhookId: number;
@ApiProperty({ description: 'The event type that was delivered.', example: 'sale_invoice.created' })
eventType: string;
@ApiProperty({ description: 'The payload that was sent.', type: 'object' })
payload: Record<string, any>;
@ApiPropertyOptional({ description: 'HTTP response status code.', example: 200, nullable: true })
responseStatus?: number;
@ApiPropertyOptional({ description: 'HTTP response body.', example: 'OK', nullable: true })
responseBody?: string;
@ApiProperty({ description: 'Number of delivery attempts.', example: 1 })
attemptCount: number;
@ApiPropertyOptional({ description: 'Error message if delivery failed.', example: 'Connection timeout', nullable: true })
errorMessage?: string;
@ApiPropertyOptional({ description: 'Timestamp when the delivery succeeded.', example: '2024-01-01T00:00:00.000Z', nullable: true })
deliveredAt?: string;
@ApiProperty({ description: 'Creation timestamp.', example: '2024-01-01T00:00:00.000Z' })
createdAt: string;
}
@@ -0,0 +1,36 @@
import { ApiProperty } from '@nestjs/swagger';
export class WebhookResponseDto {
@ApiProperty({ description: 'The unique identifier of the webhook', example: 1 })
id: number;
@ApiProperty({ description: 'Name of the webhook.', example: 'Invoice Webhook' })
name: string;
@ApiProperty({ description: 'Target URL for the webhook callback.', example: 'https://example.com/webhook' })
url: string;
@ApiProperty({ description: 'Entity type the webhook subscribes to.', example: 'sale_invoice' })
entity: string;
@ApiProperty({ description: 'List of events that trigger this webhook.', example: ['created', 'edited', 'deleted'] })
events: string[];
@ApiProperty({ description: 'HTTP method for the webhook.', example: 'POST' })
method: string;
@ApiProperty({ description: 'Custom headers to include in the webhook request.', type: 'array' })
headers: Array<{ param_name: string; param_value: string }>;
@ApiProperty({ description: 'Secret key for HMAC signature.', example: 'whsec_123456', nullable: true })
secret: string | null;
@ApiProperty({ description: 'Whether the webhook is active.', example: true })
isActive: boolean;
@ApiProperty({ description: 'Creation timestamp.', example: '2024-01-01T00:00:00.000Z' })
createdAt: string;
@ApiProperty({ description: 'Last update timestamp.', example: '2024-01-01T00:00:00.000Z' })
updatedAt: string;
}
@@ -0,0 +1,27 @@
import { BaseModel } from '@/models/Model';
export class Webhook extends BaseModel {
id!: number;
name!: string;
url!: string;
secret?: string;
entity!: string;
events!: string[];
method!: 'POST' | 'PUT' | 'DELETE';
headers?: Array<{ param_name: string; param_value: string }>;
isActive!: boolean;
createdAt!: Date;
updatedAt!: Date;
static get tableName() {
return 'webhooks';
}
get timestamps() {
return ['createdAt', 'updatedAt'];
}
static get jsonAttributes() {
return ['events', 'headers'];
}
}
@@ -0,0 +1,26 @@
import { BaseModel } from '@/models/Model';
export class WebhookDelivery extends BaseModel {
id!: number;
webhookId!: number;
eventType!: string;
payload!: Record<string, any>;
responseStatus?: number;
responseBody?: string;
attemptCount!: number;
errorMessage?: string;
deliveredAt?: Date;
createdAt!: Date;
static get tableName() {
return 'webhook_deliveries';
}
get timestamps() {
return ['createdAt'];
}
static get jsonAttributes() {
return ['payload'];
}
}
@@ -0,0 +1,94 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { Scope, Inject } from '@nestjs/common';
import { ClsService, UseCls } from 'nestjs-cls';
import axios, { AxiosError } from 'axios';
import { WebhookDeliveryQueue, WebhookDeliveryJob } from '../constants';
import { WebhookDeliveryQueueJobPayload } from '../Webhooks.types';
import { WebhookSignatureService } from '../WebhookSignature.service';
import { Webhook } from '../models/Webhook.model';
import { WebhookDelivery } from '../models/WebhookDelivery.model';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Processor({
name: WebhookDeliveryQueue,
scope: Scope.REQUEST,
})
export class WebhookDeliveryProcessor extends WorkerHost {
constructor(
private readonly webhookSignatureService: WebhookSignatureService,
@Inject(Webhook.name)
private readonly webhookModel: TenantModelProxy<typeof Webhook>,
@Inject(WebhookDelivery.name)
private readonly webhookDeliveryModel: TenantModelProxy<typeof WebhookDelivery>,
private readonly clsService: ClsService,
) {
super();
}
@UseCls()
async process(job: Job<WebhookDeliveryQueueJobPayload>) {
const { webhookId, payload, organizationId, deliveryId } = job.data;
this.clsService.set('organizationId', organizationId);
const webhook = await this.webhookModel().query().findById(webhookId);
if (!webhook || !webhook.isActive) {
return;
}
const body = JSON.stringify(payload);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (webhook.secret) {
headers['X-Webhook-Signature'] = this.webhookSignatureService.generateSignature(body, webhook.secret);
}
if (webhook.headers) {
for (const h of webhook.headers) {
headers[h.param_name] = h.param_value;
}
}
let responseStatus: number | null = null;
let responseBody: string | null = null;
let errorMessage: string | null = null;
let deliveredAt: Date | null = null;
try {
const response = await axios.request({
url: webhook.url,
method: webhook.method,
headers,
data: body,
timeout: 30000,
maxBodyLength: Infinity,
maxContentLength: Infinity,
});
responseStatus = response.status;
responseBody = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
deliveredAt = new Date();
} catch (error) {
const axiosError = error as AxiosError;
if (axiosError.response) {
responseStatus = axiosError.response.status;
responseBody = typeof axiosError.response.data === 'string' ? axiosError.response.data : JSON.stringify(axiosError.response.data);
}
errorMessage = axiosError.message;
throw error;
} finally {
await this.webhookDeliveryModel()
.query()
.findById(deliveryId)
.patch({
responseStatus,
responseBody,
attemptCount: job.attemptsMade + 1,
errorMessage,
deliveredAt,
});
}
}
}
@@ -0,0 +1,19 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { Webhook } from '../models/Webhook.model';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class GetWebhookService {
constructor(
@Inject(Webhook.name)
private readonly webhookModel: TenantModelProxy<typeof Webhook>,
) {}
public async getWebhook(webhookId: number) {
const webhook = await this.webhookModel().query().findById(webhookId);
if (!webhook) {
throw new NotFoundException('Webhook not found.');
}
return webhook;
}
}
@@ -0,0 +1,28 @@
import { Injectable, Inject } from '@nestjs/common';
import { WebhookDelivery } from '../models/WebhookDelivery.model';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class GetWebhookDeliveriesService {
constructor(
@Inject(WebhookDelivery.name)
private readonly webhookDeliveryModel: TenantModelProxy<typeof WebhookDelivery>,
) {}
public async getDeliveries(webhookId: number, page: number = 1, pageSize: number = 20) {
const results = await this.webhookDeliveryModel()
.query()
.where('webhook_id', webhookId)
.orderBy('createdAt', 'desc')
.page(page - 1, pageSize);
return {
data: results.results,
pagination: {
total: results.total,
page,
pageSize,
},
};
}
}
@@ -0,0 +1,30 @@
import { Injectable, Inject } from '@nestjs/common';
import { Webhook } from '../models/Webhook.model';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class GetWebhooksService {
constructor(
@Inject(Webhook.name)
private readonly webhookModel: TenantModelProxy<typeof Webhook>,
) {}
public async getWebhooks(filterBy?: string, page: number = 1, pageSize: number = 20) {
let query = this.webhookModel().query().orderBy('createdAt', 'desc');
if (filterBy && filterBy !== 'all') {
query = query.where('entity', filterBy);
}
const results = await query.page(page - 1, pageSize);
return {
data: results.results,
pagination: {
total: results.total,
page,
pageSize,
},
};
}
}
@@ -0,0 +1,25 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { WebhookDelivererService } from '../WebhookDeliverer.service';
import { WEBHOOK_EVENT_MAPPINGS } from '../WebhookEvents.registry';
@Injectable()
export class WebhooksEventSubscriber implements OnModuleInit {
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly webhookDeliverer: WebhookDelivererService,
) {}
onModuleInit() {
for (const mapping of WEBHOOK_EVENT_MAPPINGS) {
this.eventEmitter.on(mapping.internalEvent, async (payload: any) => {
const data = payload?.[mapping.dataKey] ?? { id: payload?.[mapping.idKey] };
await this.webhookDeliverer.enqueueMatchingWebhooks(
mapping.entity,
mapping.action,
data,
);
});
}
}
}