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:
+35
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user