diff --git a/packages/server/src/database/tenant/migrations/20250417120000_create_webhooks_table.ts b/packages/server/src/database/tenant/migrations/20250417120000_create_webhooks_table.ts new file mode 100644 index 000000000..ec345ec0b --- /dev/null +++ b/packages/server/src/database/tenant/migrations/20250417120000_create_webhooks_table.ts @@ -0,0 +1,35 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + 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 { + await knex.schema.dropTableIfExists('webhook_deliveries'); + await knex.schema.dropTableIfExists('webhooks'); +} diff --git a/packages/server/src/modules/App/App.module.ts b/packages/server/src/modules/App/App.module.ts index 8ae530926..f785fd6bf 100644 --- a/packages/server/src/modules/App/App.module.ts +++ b/packages/server/src/modules/App/App.module.ts @@ -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: [ diff --git a/packages/server/src/modules/Roles/Roles.types.ts b/packages/server/src/modules/Roles/Roles.types.ts index 258b76509..3b05c6c1a 100644 --- a/packages/server/src/modules/Roles/Roles.types.ts +++ b/packages/server/src/modules/Roles/Roles.types.ts @@ -60,7 +60,8 @@ export enum AbilitySubject { CreditNote = 'CreditNode', VendorCredit = 'VendorCredit', Project = 'Project', - TaxRate = 'TaxRate' + TaxRate = 'TaxRate', + Webhook = 'Webhook' } export interface IRoleCreatedPayload { diff --git a/packages/server/src/modules/Webhooks/WebhookDeliverer.service.ts b/packages/server/src/modules/Webhooks/WebhookDeliverer.service.ts new file mode 100644 index 000000000..5f464583f --- /dev/null +++ b/packages/server/src/modules/Webhooks/WebhookDeliverer.service.ts @@ -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, + @Inject(WebhookDelivery.name) + private readonly webhookDeliveryModel: TenantModelProxy, + 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, + ): Promise { + 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, + }); + } + } +} diff --git a/packages/server/src/modules/Webhooks/WebhookEvents.registry.ts b/packages/server/src/modules/Webhooks/WebhookEvents.registry.ts new file mode 100644 index 000000000..7ce30e974 --- /dev/null +++ b/packages/server/src/modules/Webhooks/WebhookEvents.registry.ts @@ -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' }, +]; diff --git a/packages/server/src/modules/Webhooks/WebhookSignature.service.ts b/packages/server/src/modules/Webhooks/WebhookSignature.service.ts new file mode 100644 index 000000000..bf90e16e0 --- /dev/null +++ b/packages/server/src/modules/Webhooks/WebhookSignature.service.ts @@ -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; + } +} diff --git a/packages/server/src/modules/Webhooks/Webhooks.application.ts b/packages/server/src/modules/Webhooks/Webhooks.application.ts new file mode 100644 index 000000000..a2fc66263 --- /dev/null +++ b/packages/server/src/modules/Webhooks/Webhooks.application.ts @@ -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); + } +} diff --git a/packages/server/src/modules/Webhooks/Webhooks.controller.ts b/packages/server/src/modules/Webhooks/Webhooks.controller.ts new file mode 100644 index 000000000..4f5c0416c --- /dev/null +++ b/packages/server/src/modules/Webhooks/Webhooks.controller.ts @@ -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); + } +} diff --git a/packages/server/src/modules/Webhooks/Webhooks.module.ts b/packages/server/src/modules/Webhooks/Webhooks.module.ts new file mode 100644 index 000000000..4f1a8df7e --- /dev/null +++ b/packages/server/src/modules/Webhooks/Webhooks.module.ts @@ -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 {} diff --git a/packages/server/src/modules/Webhooks/Webhooks.types.ts b/packages/server/src/modules/Webhooks/Webhooks.types.ts new file mode 100644 index 000000000..3bbd93262 --- /dev/null +++ b/packages/server/src/modules/Webhooks/Webhooks.types.ts @@ -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; + organizationId: string; + userId: number; + deliveryId: number; +} + +export interface WebhookEventMapping { + internalEvent: string; + entity: string; + action: string; + dataKey: string; + idKey: string; +} diff --git a/packages/server/src/modules/Webhooks/commands/CreateWebhook.service.ts b/packages/server/src/modules/Webhooks/commands/CreateWebhook.service.ts new file mode 100644 index 000000000..5ad3eaefa --- /dev/null +++ b/packages/server/src/modules/Webhooks/commands/CreateWebhook.service.ts @@ -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, + ) {} + + public async createWebhook(dto: CreateWebhookDto) { + const webhook = await this.webhookModel().query().insertAndFetch({ + ...dto, + method: dto.method || 'POST', + isActive: true, + } as any); + return webhook; + } +} diff --git a/packages/server/src/modules/Webhooks/commands/DeleteWebhook.service.ts b/packages/server/src/modules/Webhooks/commands/DeleteWebhook.service.ts new file mode 100644 index 000000000..810414d1f --- /dev/null +++ b/packages/server/src/modules/Webhooks/commands/DeleteWebhook.service.ts @@ -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, + ) {} + + 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; + } +} diff --git a/packages/server/src/modules/Webhooks/commands/EditWebhook.service.ts b/packages/server/src/modules/Webhooks/commands/EditWebhook.service.ts new file mode 100644 index 000000000..9753b19d8 --- /dev/null +++ b/packages/server/src/modules/Webhooks/commands/EditWebhook.service.ts @@ -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, + ) {} + + 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; + } +} diff --git a/packages/server/src/modules/Webhooks/commands/ToggleWebhookActive.service.ts b/packages/server/src/modules/Webhooks/commands/ToggleWebhookActive.service.ts new file mode 100644 index 000000000..0fc05d5ce --- /dev/null +++ b/packages/server/src/modules/Webhooks/commands/ToggleWebhookActive.service.ts @@ -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, + ) {} + + 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; + } +} diff --git a/packages/server/src/modules/Webhooks/constants.ts b/packages/server/src/modules/Webhooks/constants.ts new file mode 100644 index 000000000..96056118c --- /dev/null +++ b/packages/server/src/modules/Webhooks/constants.ts @@ -0,0 +1,2 @@ +export const WebhookDeliveryQueue = 'WebhookDeliveryQueue'; +export const WebhookDeliveryJob = 'WebhookDeliveryJob'; diff --git a/packages/server/src/modules/Webhooks/dtos/Webhook.dto.ts b/packages/server/src/modules/Webhooks/dtos/Webhook.dto.ts new file mode 100644 index 000000000..6e58af5f7 --- /dev/null +++ b/packages/server/src/modules/Webhooks/dtos/Webhook.dto.ts @@ -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; +} diff --git a/packages/server/src/modules/Webhooks/dtos/WebhookDeliveryResponse.dto.ts b/packages/server/src/modules/Webhooks/dtos/WebhookDeliveryResponse.dto.ts new file mode 100644 index 000000000..90096c507 --- /dev/null +++ b/packages/server/src/modules/Webhooks/dtos/WebhookDeliveryResponse.dto.ts @@ -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; + + @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; +} diff --git a/packages/server/src/modules/Webhooks/dtos/WebhookResponse.dto.ts b/packages/server/src/modules/Webhooks/dtos/WebhookResponse.dto.ts new file mode 100644 index 000000000..c3bdc7c43 --- /dev/null +++ b/packages/server/src/modules/Webhooks/dtos/WebhookResponse.dto.ts @@ -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; +} diff --git a/packages/server/src/modules/Webhooks/models/Webhook.model.ts b/packages/server/src/modules/Webhooks/models/Webhook.model.ts new file mode 100644 index 000000000..6f0e37ef9 --- /dev/null +++ b/packages/server/src/modules/Webhooks/models/Webhook.model.ts @@ -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']; + } +} diff --git a/packages/server/src/modules/Webhooks/models/WebhookDelivery.model.ts b/packages/server/src/modules/Webhooks/models/WebhookDelivery.model.ts new file mode 100644 index 000000000..20144fc93 --- /dev/null +++ b/packages/server/src/modules/Webhooks/models/WebhookDelivery.model.ts @@ -0,0 +1,26 @@ +import { BaseModel } from '@/models/Model'; + +export class WebhookDelivery extends BaseModel { + id!: number; + webhookId!: number; + eventType!: string; + payload!: Record; + 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']; + } +} diff --git a/packages/server/src/modules/Webhooks/processors/WebhookDelivery.processor.ts b/packages/server/src/modules/Webhooks/processors/WebhookDelivery.processor.ts new file mode 100644 index 000000000..954a09bd2 --- /dev/null +++ b/packages/server/src/modules/Webhooks/processors/WebhookDelivery.processor.ts @@ -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, + @Inject(WebhookDelivery.name) + private readonly webhookDeliveryModel: TenantModelProxy, + private readonly clsService: ClsService, + ) { + super(); + } + + @UseCls() + async process(job: Job) { + 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 = { + '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, + }); + } + } +} diff --git a/packages/server/src/modules/Webhooks/queries/GetWebhook.service.ts b/packages/server/src/modules/Webhooks/queries/GetWebhook.service.ts new file mode 100644 index 000000000..2149f30ef --- /dev/null +++ b/packages/server/src/modules/Webhooks/queries/GetWebhook.service.ts @@ -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, + ) {} + + public async getWebhook(webhookId: number) { + const webhook = await this.webhookModel().query().findById(webhookId); + if (!webhook) { + throw new NotFoundException('Webhook not found.'); + } + return webhook; + } +} diff --git a/packages/server/src/modules/Webhooks/queries/GetWebhookDeliveries.service.ts b/packages/server/src/modules/Webhooks/queries/GetWebhookDeliveries.service.ts new file mode 100644 index 000000000..c330b9ad4 --- /dev/null +++ b/packages/server/src/modules/Webhooks/queries/GetWebhookDeliveries.service.ts @@ -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, + ) {} + + 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, + }, + }; + } +} diff --git a/packages/server/src/modules/Webhooks/queries/GetWebhooks.service.ts b/packages/server/src/modules/Webhooks/queries/GetWebhooks.service.ts new file mode 100644 index 000000000..77def6d56 --- /dev/null +++ b/packages/server/src/modules/Webhooks/queries/GetWebhooks.service.ts @@ -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, + ) {} + + 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, + }, + }; + } +} diff --git a/packages/server/src/modules/Webhooks/subscribers/WebhooksEventSubscriber.ts b/packages/server/src/modules/Webhooks/subscribers/WebhooksEventSubscriber.ts new file mode 100644 index 000000000..5bd4bb21e --- /dev/null +++ b/packages/server/src/modules/Webhooks/subscribers/WebhooksEventSubscriber.ts @@ -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, + ); + }); + } + } +}