From 2e9532e9dd36091b9109ad8e696c6d6d0f2d5e99 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 16 Apr 2025 17:33:43 +0300 Subject: [PATCH 1/5] PM-1073 - trolley webhook handler --- .editorconfig | 12 ++ .../migration.sql | 23 +++ prisma/schema.prisma | 25 +++- src/api/api.module.ts | 3 +- .../webhooks/trolley-handlers/decorators.ts | 5 + src/api/webhooks/trolley-handlers/index.ts | 61 ++++++++ .../trolley-handlers/payment.handler.ts | 19 +++ src/api/webhooks/trolley.service.ts | 135 ++++++++++++++++++ src/api/webhooks/webhooks.controller.ts | 53 +++++++ src/api/webhooks/webhooks.module.ts | 11 ++ src/api/webhooks/webhooks.types.ts | 6 + src/main.ts | 4 +- 12 files changed, 353 insertions(+), 4 deletions(-) create mode 100644 .editorconfig create mode 100644 prisma/migrations/20250416100509_add_trolley_webhook_log_table/migration.sql create mode 100644 src/api/webhooks/trolley-handlers/decorators.ts create mode 100644 src/api/webhooks/trolley-handlers/index.ts create mode 100644 src/api/webhooks/trolley-handlers/payment.handler.ts create mode 100644 src/api/webhooks/trolley.service.ts create mode 100644 src/api/webhooks/webhooks.controller.ts create mode 100644 src/api/webhooks/webhooks.module.ts create mode 100644 src/api/webhooks/webhooks.types.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..64d3d6a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true \ No newline at end of file diff --git a/prisma/migrations/20250416100509_add_trolley_webhook_log_table/migration.sql b/prisma/migrations/20250416100509_add_trolley_webhook_log_table/migration.sql new file mode 100644 index 0000000..1f1a6cb --- /dev/null +++ b/prisma/migrations/20250416100509_add_trolley_webhook_log_table/migration.sql @@ -0,0 +1,23 @@ +-- CreateEnum +CREATE TYPE "webhook_status" AS ENUM ('error', 'processed', 'processing'); + +-- CreateTable +CREATE TABLE "trolley_webhook_log" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "event_id" TEXT NOT NULL, + "event_time" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "event_payload" TEXT NOT NULL, + "event_model" TEXT, + "event_action" TEXT, + "status" "webhook_status" NOT NULL, + "error_message" TEXT, + "created_by" VARCHAR(80), + "updated_by" VARCHAR(80), + "created_at" TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "trolley_webhook_log_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "trolley_webhook_log_event_id_key" ON "trolley_webhook_log"("event_id"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f703b53..6bb03c4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,7 +1,7 @@ generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" previewFeatures = ["extendedIndexes"] - binaryTargets = ["native", "linux-musl-openssl-3.0.x"] + binaryTargets = ["native", "linux-musl-openssl-3.0.x"] } datasource db { @@ -210,6 +210,27 @@ model winnings { origin origin? @relation(fields: [origin_id], references: [origin_id], onDelete: NoAction, onUpdate: NoAction) } +enum webhook_status { + error + processed + processing +} + +model trolley_webhook_log { + id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + event_id String @unique + event_time DateTime @default(now()) @db.Timestamp(6) + event_payload String + event_model String? + event_action String? + status webhook_status + error_message String? + created_by String? @db.VarChar(80) + updated_by String? @db.VarChar(80) + created_at DateTime? @default(now()) @db.Timestamp(6) + updated_at DateTime? @default(now()) @db.Timestamp(6) +} + enum action_type { INITIATE_WITHDRAWAL ADD_WITHDRAWAL_METHOD diff --git a/src/api/api.module.ts b/src/api/api.module.ts index 767fc84..53bd92a 100644 --- a/src/api/api.module.ts +++ b/src/api/api.module.ts @@ -15,9 +15,10 @@ import { TopcoderModule } from 'src/shared/topcoder/topcoder.module'; import { OriginRepository } from './repository/origin.repo'; import { TaxFormRepository } from './repository/taxForm.repo'; import { PaymentMethodRepository } from './repository/paymentMethod.repo'; +import { WebhooksModule } from './webhooks/webhooks.module'; @Module({ - imports: [GlobalProvidersModule, TopcoderModule], + imports: [WebhooksModule, GlobalProvidersModule, TopcoderModule], controllers: [ HealthCheckController, AdminWinningController, diff --git a/src/api/webhooks/trolley-handlers/decorators.ts b/src/api/webhooks/trolley-handlers/decorators.ts new file mode 100644 index 0000000..95ad5f0 --- /dev/null +++ b/src/api/webhooks/trolley-handlers/decorators.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; + +export const WEBHOOK_EVENT_METADATA_KEY = 'WH_EVENT_TYPE'; +export const WebhookEvent = (...events: string[]) => + SetMetadata(WEBHOOK_EVENT_METADATA_KEY, events); diff --git a/src/api/webhooks/trolley-handlers/index.ts b/src/api/webhooks/trolley-handlers/index.ts new file mode 100644 index 0000000..2360ad1 --- /dev/null +++ b/src/api/webhooks/trolley-handlers/index.ts @@ -0,0 +1,61 @@ +import { Provider } from '@nestjs/common'; +import { PaymentHandler } from './payment.handler'; +import { TrolleyWebhookEvent } from '../webhooks.types'; +import { Reflector } from '@nestjs/core'; +import { WEBHOOK_EVENT_METADATA_KEY } from './decorators'; + +/** + * Factory function to create a map of Trolley webhook event handlers. + * + * This function iterates over the provided handler classes and inspects their methods + * to find those annotated with specific metadata indicating the Trolley webhook events + * they handle. It then binds these methods to their respective event types and stores + * them in a map for easy lookup. + * + * @param reflector - An instance of `Reflector` used to retrieve metadata from methods. + * @param handlerClasses - An array of handler class instances containing methods + * annotated with Trolley webhook event metadata. + * @returns A `Map` where the keys are `TrolleyWebhookEvent` types and the values are + * bound handler functions for those events. + */ +const trolleyHandlerFnsFactory = (reflector: Reflector, handlerClasses) => { + const handlersMap = new Map void>(); + + for (const handlerClass of handlerClasses) { + const prototype = Object.getPrototypeOf(handlerClass); + for (const propertyName of Object.getOwnPropertyNames(prototype)) { + const method = prototype[propertyName]; + if (typeof method !== 'function' || propertyName === 'constructor') { + continue; + } + + const eventTypes = reflector.get( + WEBHOOK_EVENT_METADATA_KEY, + method, + ); + + if (eventTypes?.length > 0) { + eventTypes.forEach((eventType) => { + handlersMap.set(eventType, method.bind(handlerClass)); + console.log(`Found event handler: ${eventType} -> ${propertyName}`); + }); + } + } + } + + return handlersMap; +}; + +export const TrolleyWebhookHandlersProviders: Provider[] = [ + PaymentHandler, + { + provide: 'TrolleyWebhookHandlers', + useFactory: (paymentHandler: PaymentHandler) => [paymentHandler], + inject: [PaymentHandler], + }, + { + provide: 'trolleyHandlerFns', + useFactory: trolleyHandlerFnsFactory, + inject: [Reflector, 'TrolleyWebhookHandlers'], + }, +]; diff --git a/src/api/webhooks/trolley-handlers/payment.handler.ts b/src/api/webhooks/trolley-handlers/payment.handler.ts new file mode 100644 index 0000000..abb9d13 --- /dev/null +++ b/src/api/webhooks/trolley-handlers/payment.handler.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { TrolleyWebhookEvent } from '../webhooks.types'; +import { WebhookEvent } from './decorators'; + +@Injectable() +export class PaymentHandler { + @WebhookEvent(TrolleyWebhookEvent.paymentCreated) + async handlePaymentCreated(payload: any): Promise { + // TODO: Build out logic for payment.created event + console.log('handling', TrolleyWebhookEvent.paymentCreated); + + } + + @WebhookEvent(TrolleyWebhookEvent.paymentUpdated) + async handlePaymentUpdated(payload: any): Promise { + // TODO: Build out logic for payment.updated event + console.log('handling', TrolleyWebhookEvent.paymentUpdated); + } +} diff --git a/src/api/webhooks/trolley.service.ts b/src/api/webhooks/trolley.service.ts new file mode 100644 index 0000000..853e957 --- /dev/null +++ b/src/api/webhooks/trolley.service.ts @@ -0,0 +1,135 @@ +import crypto from 'crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import { trolley_webhook_log, webhook_status } from '@prisma/client'; +import { PrismaService } from 'src/shared/global/prisma.service'; + +enum TrolleyHeaders { + id = 'x-paymentrails-delivery', + signature = 'x-paymentrails-signature', + created = 'x-paymentrails-created', +} + +const trolleyWhHmac = process.env.TROLLEY_WH_HMAC; +if (!trolleyWhHmac) { + throw new Error('TROLLEY_WH_HMAC is not set!'); +} + +/** + * Service responsible for handling Trolley webhook operations. + */ +@Injectable() +export class TrolleyService { + constructor( + @Inject('trolleyHandlerFns') + private readonly handlers, + private readonly prisma: PrismaService, + ) {} + + /** + * Validates the webhook signature to ensure the request is authentic. + * + * @param headers - The HTTP request headers containing the signature. + * @param bodyPayload - The raw body payload of the webhook request. + * @returns A boolean indicating whether the signature is valid. + */ + validateSignature(headers: Request['headers'], bodyPayload: string): boolean { + if (!headers[TrolleyHeaders.signature]) { + return false; + } + + const headerSignatureValues = ( + headers[TrolleyHeaders.signature] ?? '' + ).split(','); + + const t = headerSignatureValues[0].split('=')[1]; + const v1 = headerSignatureValues[1].split('=')[1]; + + const hmac = crypto.createHmac('sha256', trolleyWhHmac as string); + hmac.update(`${t}${bodyPayload}`); + const digest = hmac.digest('hex'); + + return digest === v1; + } + + /** + * Validates whether the webhook event is unique by checking its ID against the database. + * + * @param headers - The HTTP request headers containing the webhook ID. + * @returns A promise that resolves to a boolean indicating whether the webhook event is unique. + */ + async validateUnique(headers: Request['headers']): Promise { + const requestId = headers[TrolleyHeaders.id]; + const whEvent = await this.prisma.trolley_webhook_log.findUnique({ + where: { event_id: requestId }, + }); + return !whEvent; + } + + /** + * Tracks the webhook events status by Updating or creating a record in the `trolley_webhook_log` table with the given event details. + * + * @param requestId - The unique identifier for the webhook event. + * @param status - The status of the webhook event. + * @param payload - (Optional) The payload associated with the webhook event. + * @param meta - (Optional) Additional metadata for the webhook event, such as event time. + * @returns A promise that resolves to the upserted `trolley_webhook_log` record. + */ + setEventState( + requestId: string, + status: webhook_status, + payload?: any, + meta?: Partial, + ) { + return this.prisma.trolley_webhook_log.upsert({ + where: { + event_id: requestId, + }, + create: { + event_id: requestId, + event_payload: JSON.stringify(payload ?? {}), + event_time: meta?.event_time, + event_model: payload?.model ?? '', + event_action: payload?.action ?? '', + status, + created_by: 'system', + } as trolley_webhook_log, + update: { + status, + ...meta, + }, + }); + } + + /** + * Handles incoming webhook events by processing the payload and delegating + * the event to the appropriate handler based on the model and action. + * + * @param headers - The headers of the incoming request, containing metadata + * such as the event ID and creation time. + * @param payload - The body of the webhook event, containing details such as + * the model, action, and event-specific data. + */ + async handleEvent(headers: Request['headers'], payload: any) { + const requestId = headers[TrolleyHeaders.id]; + + try { + await this.setEventState(requestId, webhook_status.processing, payload, { + event_time: headers[TrolleyHeaders.created], + }); + + const { model, action, body } = payload; + const handler = this.handlers.get(`${model}.${action}`); + if (!handler) { + throw new Error('Webhook event handler not found!'); + } + + await handler(body); + await this.setEventState(requestId, webhook_status.processed); + } catch (e) { + console.log(e); + await this.setEventState(requestId, webhook_status.error, void 0, { + error_message: e.message ?? e, + }); + } + } +} diff --git a/src/api/webhooks/webhooks.controller.ts b/src/api/webhooks/webhooks.controller.ts new file mode 100644 index 0000000..64468bb --- /dev/null +++ b/src/api/webhooks/webhooks.controller.ts @@ -0,0 +1,53 @@ +import { + Controller, + Post, + BadRequestException, + Req, + RawBodyRequest, + InternalServerErrorException, + ConflictException, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { TrolleyService } from './trolley.service'; +import { Public } from 'src/core/auth/decorators'; + +@Public() +@ApiTags('Webhooks') +@Controller('webhooks') +export class WebhooksController { + constructor(private readonly trolleyService: TrolleyService) {} + + /** + * Handles incoming trolley webhooks. + * + * This method validates the webhook request by checking its signature and ensuring + * it has not been processed before. If validation passes, it processes the webhook + * payload and marks it as processed. + * + * @param request - The incoming webhook request containing headers, raw body, and parsed body. + * @returns A success message if the webhook is processed successfully. + * @throws {BadRequestException} If the signature is invalid or the webhook has already been processed. + */ + @Post('trolley') + async handleTrolleyWebhook(@Req() request: RawBodyRequest) { + if ( + !this.trolleyService.validateSignature( + request.headers, + request.rawBody?.toString('utf-8') ?? '', + ) + ) { + throw new BadRequestException('Missing or invalid signature!'); + } + + if (!(await this.trolleyService.validateUnique(request.headers))) { + throw new ConflictException('Webhook already processed!'); + } + + try { + return this.trolleyService.handleEvent(request.headers, request.body); + } catch (e) { + console.log('Error processing the webhook!', e); + throw new InternalServerErrorException('Error processing the webhook!'); + } + } +} diff --git a/src/api/webhooks/webhooks.module.ts b/src/api/webhooks/webhooks.module.ts new file mode 100644 index 0000000..b8cfcd9 --- /dev/null +++ b/src/api/webhooks/webhooks.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TrolleyService } from './trolley.service'; +import { WebhooksController } from './webhooks.controller'; +import { TrolleyWebhookHandlersProviders } from './trolley-handlers'; + +@Module({ + imports: [], + controllers: [WebhooksController], + providers: [...TrolleyWebhookHandlersProviders, TrolleyService], +}) +export class WebhooksModule {} diff --git a/src/api/webhooks/webhooks.types.ts b/src/api/webhooks/webhooks.types.ts new file mode 100644 index 0000000..62f18e4 --- /dev/null +++ b/src/api/webhooks/webhooks.types.ts @@ -0,0 +1,6 @@ +export enum TrolleyWebhookEvent { + paymentCreated = 'payment.created', + paymentUpdated = 'payment.updated', +} + +export type TrolleyEventHandler = (eventPayload: any) => Promise; diff --git a/src/main.ts b/src/main.ts index 2e681a8..5e5ce02 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,7 +7,9 @@ import { ApiModule } from './api/api.module'; import { AppModule } from './app.module'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { + rawBody: true, + }); // Global prefix for all routes is configured as `/v5/finance` app.setGlobalPrefix(process.env.API_BASE ?? '/v5/finance'); From cf0aadb9045d7d36cbf0c315e3f3b0358a3bb012 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 16 Apr 2025 17:47:24 +0300 Subject: [PATCH 2/5] PM-1073 - rework structure for webhooks --- src/api/webhooks/trolley/handlers/index.ts | 17 ++++++++ .../handlers}/payment.handler.ts | 4 +- .../webhooks/{ => trolley}/trolley.service.ts | 2 +- .../trolley.types.ts} | 0 src/api/webhooks/webhooks.controller.ts | 2 +- .../decorators.ts => webhooks.decorators.ts} | 0 ...ts => webhooks.event-handlers.provider.ts} | 39 +++++++++---------- src/api/webhooks/webhooks.module.ts | 6 +-- 8 files changed, 43 insertions(+), 27 deletions(-) create mode 100644 src/api/webhooks/trolley/handlers/index.ts rename src/api/webhooks/{trolley-handlers => trolley/handlers}/payment.handler.ts (83%) rename src/api/webhooks/{ => trolley}/trolley.service.ts (98%) rename src/api/webhooks/{webhooks.types.ts => trolley/trolley.types.ts} (100%) rename src/api/webhooks/{trolley-handlers/decorators.ts => webhooks.decorators.ts} (100%) rename src/api/webhooks/{trolley-handlers/index.ts => webhooks.event-handlers.provider.ts} (65%) diff --git a/src/api/webhooks/trolley/handlers/index.ts b/src/api/webhooks/trolley/handlers/index.ts new file mode 100644 index 0000000..e2b5198 --- /dev/null +++ b/src/api/webhooks/trolley/handlers/index.ts @@ -0,0 +1,17 @@ +import { Provider } from '@nestjs/common'; +import { PaymentHandler } from './payment.handler'; +import { getWebhooksEventHandlersProvider } from '../../webhooks.event-handlers.provider'; + +export const TrolleyWebhookHandlers: Provider[] = [ + getWebhooksEventHandlersProvider( + 'trolleyHandlerFns', + 'TrolleyWebhookHandlers', + ), + + PaymentHandler, + { + provide: 'TrolleyWebhookHandlers', + useFactory: (paymentHandler: PaymentHandler) => [paymentHandler], + inject: [PaymentHandler], + }, +]; diff --git a/src/api/webhooks/trolley-handlers/payment.handler.ts b/src/api/webhooks/trolley/handlers/payment.handler.ts similarity index 83% rename from src/api/webhooks/trolley-handlers/payment.handler.ts rename to src/api/webhooks/trolley/handlers/payment.handler.ts index abb9d13..a66e691 100644 --- a/src/api/webhooks/trolley-handlers/payment.handler.ts +++ b/src/api/webhooks/trolley/handlers/payment.handler.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { TrolleyWebhookEvent } from '../webhooks.types'; -import { WebhookEvent } from './decorators'; +import { WebhookEvent } from '../../webhooks.decorators'; +import { TrolleyWebhookEvent } from '../trolley.types'; @Injectable() export class PaymentHandler { diff --git a/src/api/webhooks/trolley.service.ts b/src/api/webhooks/trolley/trolley.service.ts similarity index 98% rename from src/api/webhooks/trolley.service.ts rename to src/api/webhooks/trolley/trolley.service.ts index 853e957..59e4da7 100644 --- a/src/api/webhooks/trolley.service.ts +++ b/src/api/webhooks/trolley/trolley.service.ts @@ -120,7 +120,7 @@ export class TrolleyService { const { model, action, body } = payload; const handler = this.handlers.get(`${model}.${action}`); if (!handler) { - throw new Error('Webhook event handler not found!'); + throw new Error('Event handler not found!'); } await handler(body); diff --git a/src/api/webhooks/webhooks.types.ts b/src/api/webhooks/trolley/trolley.types.ts similarity index 100% rename from src/api/webhooks/webhooks.types.ts rename to src/api/webhooks/trolley/trolley.types.ts diff --git a/src/api/webhooks/webhooks.controller.ts b/src/api/webhooks/webhooks.controller.ts index 64468bb..2d46994 100644 --- a/src/api/webhooks/webhooks.controller.ts +++ b/src/api/webhooks/webhooks.controller.ts @@ -8,7 +8,7 @@ import { ConflictException, } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { TrolleyService } from './trolley.service'; +import { TrolleyService } from './trolley/trolley.service'; import { Public } from 'src/core/auth/decorators'; @Public() diff --git a/src/api/webhooks/trolley-handlers/decorators.ts b/src/api/webhooks/webhooks.decorators.ts similarity index 100% rename from src/api/webhooks/trolley-handlers/decorators.ts rename to src/api/webhooks/webhooks.decorators.ts diff --git a/src/api/webhooks/trolley-handlers/index.ts b/src/api/webhooks/webhooks.event-handlers.provider.ts similarity index 65% rename from src/api/webhooks/trolley-handlers/index.ts rename to src/api/webhooks/webhooks.event-handlers.provider.ts index 2360ad1..e56385d 100644 --- a/src/api/webhooks/trolley-handlers/index.ts +++ b/src/api/webhooks/webhooks.event-handlers.provider.ts @@ -1,8 +1,5 @@ -import { Provider } from '@nestjs/common'; -import { PaymentHandler } from './payment.handler'; -import { TrolleyWebhookEvent } from '../webhooks.types'; import { Reflector } from '@nestjs/core'; -import { WEBHOOK_EVENT_METADATA_KEY } from './decorators'; +import { WEBHOOK_EVENT_METADATA_KEY } from './webhooks.decorators'; /** * Factory function to create a map of Trolley webhook event handlers. @@ -18,8 +15,8 @@ import { WEBHOOK_EVENT_METADATA_KEY } from './decorators'; * @returns A `Map` where the keys are `TrolleyWebhookEvent` types and the values are * bound handler functions for those events. */ -const trolleyHandlerFnsFactory = (reflector: Reflector, handlerClasses) => { - const handlersMap = new Map void>(); +const whEventHandlersFactory = (reflector: Reflector, handlerClasses) => { + const handlersMap = new Map void>(); for (const handlerClass of handlerClasses) { const prototype = Object.getPrototypeOf(handlerClass); @@ -29,7 +26,7 @@ const trolleyHandlerFnsFactory = (reflector: Reflector, handlerClasses) => { continue; } - const eventTypes = reflector.get( + const eventTypes = reflector.get( WEBHOOK_EVENT_METADATA_KEY, method, ); @@ -46,16 +43,18 @@ const trolleyHandlerFnsFactory = (reflector: Reflector, handlerClasses) => { return handlersMap; }; -export const TrolleyWebhookHandlersProviders: Provider[] = [ - PaymentHandler, - { - provide: 'TrolleyWebhookHandlers', - useFactory: (paymentHandler: PaymentHandler) => [paymentHandler], - inject: [PaymentHandler], - }, - { - provide: 'trolleyHandlerFns', - useFactory: trolleyHandlerFnsFactory, - inject: [Reflector, 'TrolleyWebhookHandlers'], - }, -]; +/** + * Creates a provider object for webhook event handlers. + * + * @param provide - The token that will be used to provide the dependency. + * @param handlersKey - The key used to identify the specific handlers to inject. + * @returns An object defining the provider with a factory function and its dependencies. + */ +export const getWebhooksEventHandlersProvider = ( + provide: string, + handlersKey: string, +) => ({ + provide, + useFactory: whEventHandlersFactory, + inject: [Reflector, handlersKey], +}); diff --git a/src/api/webhooks/webhooks.module.ts b/src/api/webhooks/webhooks.module.ts index b8cfcd9..6e12590 100644 --- a/src/api/webhooks/webhooks.module.ts +++ b/src/api/webhooks/webhooks.module.ts @@ -1,11 +1,11 @@ import { Module } from '@nestjs/common'; -import { TrolleyService } from './trolley.service'; import { WebhooksController } from './webhooks.controller'; -import { TrolleyWebhookHandlersProviders } from './trolley-handlers'; +import { TrolleyService } from './trolley/trolley.service'; +import { TrolleyWebhookHandlers } from './trolley/handlers'; @Module({ imports: [], controllers: [WebhooksController], - providers: [...TrolleyWebhookHandlersProviders, TrolleyService], + providers: [...TrolleyWebhookHandlers, TrolleyService], }) export class WebhooksModule {} From 3c745aa56b1c40bb03b4414df815f187acfa80b9 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 16 Apr 2025 18:12:44 +0300 Subject: [PATCH 3/5] PM-1073 PR feedback --- src/api/webhooks/trolley/trolley.service.ts | 14 ++++++++------ .../webhooks/webhooks.event-handlers.provider.ts | 6 ++++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/api/webhooks/trolley/trolley.service.ts b/src/api/webhooks/trolley/trolley.service.ts index 59e4da7..1127c1b 100644 --- a/src/api/webhooks/trolley/trolley.service.ts +++ b/src/api/webhooks/trolley/trolley.service.ts @@ -33,14 +33,12 @@ export class TrolleyService { * @returns A boolean indicating whether the signature is valid. */ validateSignature(headers: Request['headers'], bodyPayload: string): boolean { - if (!headers[TrolleyHeaders.signature]) { + const headerSignature = headers[TrolleyHeaders.signature] ?? ''; + if (!headerSignature || !headerSignature.match(/t=\d+,v1=[a-f0-9]{64}/i)) { return false; } - const headerSignatureValues = ( - headers[TrolleyHeaders.signature] ?? '' - ).split(','); - + const headerSignatureValues = headerSignature.split(','); const t = headerSignatureValues[0].split('=')[1]; const v1 = headerSignatureValues[1].split('=')[1]; @@ -59,6 +57,11 @@ export class TrolleyService { */ async validateUnique(headers: Request['headers']): Promise { const requestId = headers[TrolleyHeaders.id]; + + if (!requestId) { + return false; + } + const whEvent = await this.prisma.trolley_webhook_log.findUnique({ where: { event_id: requestId }, }); @@ -126,7 +129,6 @@ export class TrolleyService { await handler(body); await this.setEventState(requestId, webhook_status.processed); } catch (e) { - console.log(e); await this.setEventState(requestId, webhook_status.error, void 0, { error_message: e.message ?? e, }); diff --git a/src/api/webhooks/webhooks.event-handlers.provider.ts b/src/api/webhooks/webhooks.event-handlers.provider.ts index e56385d..b379c07 100644 --- a/src/api/webhooks/webhooks.event-handlers.provider.ts +++ b/src/api/webhooks/webhooks.event-handlers.provider.ts @@ -16,7 +16,10 @@ import { WEBHOOK_EVENT_METADATA_KEY } from './webhooks.decorators'; * bound handler functions for those events. */ const whEventHandlersFactory = (reflector: Reflector, handlerClasses) => { - const handlersMap = new Map void>(); + const handlersMap = new Map< + string, + (eventPayload: any) => Promise + >(); for (const handlerClass of handlerClasses) { const prototype = Object.getPrototypeOf(handlerClass); @@ -34,7 +37,6 @@ const whEventHandlersFactory = (reflector: Reflector, handlerClasses) => { if (eventTypes?.length > 0) { eventTypes.forEach((eventType) => { handlersMap.set(eventType, method.bind(handlerClass)); - console.log(`Found event handler: ${eventType} -> ${propertyName}`); }); } } From c603a5f20079394118c6dcfec5c4821d0b5b1a50 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 17 Apr 2025 14:19:49 +0300 Subject: [PATCH 4/5] PM-1073 - PR feedback: do not throw error in webhooks if event received --- .../migration.sql | 2 +- prisma/schema.prisma | 2 +- src/api/webhooks/trolley/trolley.service.ts | 5 +++-- src/api/webhooks/webhooks.controller.ts | 12 +++--------- 4 files changed, 8 insertions(+), 13 deletions(-) diff --git a/prisma/migrations/20250416100509_add_trolley_webhook_log_table/migration.sql b/prisma/migrations/20250416100509_add_trolley_webhook_log_table/migration.sql index 1f1a6cb..1e50613 100644 --- a/prisma/migrations/20250416100509_add_trolley_webhook_log_table/migration.sql +++ b/prisma/migrations/20250416100509_add_trolley_webhook_log_table/migration.sql @@ -1,5 +1,5 @@ -- CreateEnum -CREATE TYPE "webhook_status" AS ENUM ('error', 'processed', 'processing'); +CREATE TYPE "webhook_status" AS ENUM ('error', 'processed', 'logged'); -- CreateTable CREATE TABLE "trolley_webhook_log" ( diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6bb03c4..dbdbfd0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -213,7 +213,7 @@ model winnings { enum webhook_status { error processed - processing + logged } model trolley_webhook_log { diff --git a/src/api/webhooks/trolley/trolley.service.ts b/src/api/webhooks/trolley/trolley.service.ts index 1127c1b..00b89a1 100644 --- a/src/api/webhooks/trolley/trolley.service.ts +++ b/src/api/webhooks/trolley/trolley.service.ts @@ -116,14 +116,15 @@ export class TrolleyService { const requestId = headers[TrolleyHeaders.id]; try { - await this.setEventState(requestId, webhook_status.processing, payload, { + await this.setEventState(requestId, webhook_status.logged, payload, { event_time: headers[TrolleyHeaders.created], }); const { model, action, body } = payload; const handler = this.handlers.get(`${model}.${action}`); + // do nothing if there's no handler for the event (event was logged in db) if (!handler) { - throw new Error('Event handler not found!'); + return; } await handler(body); diff --git a/src/api/webhooks/webhooks.controller.ts b/src/api/webhooks/webhooks.controller.ts index 2d46994..cefef19 100644 --- a/src/api/webhooks/webhooks.controller.ts +++ b/src/api/webhooks/webhooks.controller.ts @@ -4,8 +4,6 @@ import { BadRequestException, Req, RawBodyRequest, - InternalServerErrorException, - ConflictException, } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { TrolleyService } from './trolley/trolley.service'; @@ -39,15 +37,11 @@ export class WebhooksController { throw new BadRequestException('Missing or invalid signature!'); } + // do not proceed any further if event has already been processed if (!(await this.trolleyService.validateUnique(request.headers))) { - throw new ConflictException('Webhook already processed!'); + return; } - try { - return this.trolleyService.handleEvent(request.headers, request.body); - } catch (e) { - console.log('Error processing the webhook!', e); - throw new InternalServerErrorException('Error processing the webhook!'); - } + return this.trolleyService.handleEvent(request.headers, request.body); } } From abfb9d04a3b2942048a2b1ec81c758b94e2cbc12 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 18 Apr 2025 12:28:16 +0300 Subject: [PATCH 5/5] PM-1073 - throw forbid exception --- src/api/webhooks/webhooks.controller.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/api/webhooks/webhooks.controller.ts b/src/api/webhooks/webhooks.controller.ts index cefef19..e574ef6 100644 --- a/src/api/webhooks/webhooks.controller.ts +++ b/src/api/webhooks/webhooks.controller.ts @@ -1,9 +1,9 @@ import { Controller, Post, - BadRequestException, Req, RawBodyRequest, + ForbiddenException, } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { TrolleyService } from './trolley/trolley.service'; @@ -24,7 +24,7 @@ export class WebhooksController { * * @param request - The incoming webhook request containing headers, raw body, and parsed body. * @returns A success message if the webhook is processed successfully. - * @throws {BadRequestException} If the signature is invalid or the webhook has already been processed. + * @throws {ForbiddenException} If the signature is invalid or the webhook has already been processed. */ @Post('trolley') async handleTrolleyWebhook(@Req() request: RawBodyRequest) { @@ -34,7 +34,7 @@ export class WebhooksController { request.rawBody?.toString('utf-8') ?? '', ) ) { - throw new BadRequestException('Missing or invalid signature!'); + throw new ForbiddenException('Missing or invalid signature!'); } // do not proceed any further if event has already been processed