Skip to content

Commit 4c9ab94

Browse files
authored
Merge pull request #28 from topcoder-platform/PM-1073_trolley-webhooks
PM-1073 trolley webhook handling
2 parents baf6f42 + abfb9d0 commit 4c9ab94

File tree

13 files changed

+368
-4
lines changed

13 files changed

+368
-4
lines changed

.editorconfig

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# EditorConfig is awesome: https://EditorConfig.org
2+
3+
# top-most EditorConfig file
4+
root = true
5+
6+
[*]
7+
indent_style = space
8+
indent_size = 2
9+
end_of_line = lf
10+
charset = utf-8
11+
trim_trailing_whitespace = true
12+
insert_final_newline = true
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
-- CreateEnum
2+
CREATE TYPE "webhook_status" AS ENUM ('error', 'processed', 'logged');
3+
4+
-- CreateTable
5+
CREATE TABLE "trolley_webhook_log" (
6+
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
7+
"event_id" TEXT NOT NULL,
8+
"event_time" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
9+
"event_payload" TEXT NOT NULL,
10+
"event_model" TEXT,
11+
"event_action" TEXT,
12+
"status" "webhook_status" NOT NULL,
13+
"error_message" TEXT,
14+
"created_by" VARCHAR(80),
15+
"updated_by" VARCHAR(80),
16+
"created_at" TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP,
17+
"updated_at" TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP,
18+
19+
CONSTRAINT "trolley_webhook_log_pkey" PRIMARY KEY ("id")
20+
);
21+
22+
-- CreateIndex
23+
CREATE UNIQUE INDEX "trolley_webhook_log_event_id_key" ON "trolley_webhook_log"("event_id");

prisma/schema.prisma

+23-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
generator client {
2-
provider = "prisma-client-js"
2+
provider = "prisma-client-js"
33
previewFeatures = ["extendedIndexes"]
4-
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
4+
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
55
}
66

77
datasource db {
@@ -210,6 +210,27 @@ model winnings {
210210
origin origin? @relation(fields: [origin_id], references: [origin_id], onDelete: NoAction, onUpdate: NoAction)
211211
}
212212

213+
enum webhook_status {
214+
error
215+
processed
216+
logged
217+
}
218+
219+
model trolley_webhook_log {
220+
id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
221+
event_id String @unique
222+
event_time DateTime @default(now()) @db.Timestamp(6)
223+
event_payload String
224+
event_model String?
225+
event_action String?
226+
status webhook_status
227+
error_message String?
228+
created_by String? @db.VarChar(80)
229+
updated_by String? @db.VarChar(80)
230+
created_at DateTime? @default(now()) @db.Timestamp(6)
231+
updated_at DateTime? @default(now()) @db.Timestamp(6)
232+
}
233+
213234
enum action_type {
214235
INITIATE_WITHDRAWAL
215236
ADD_WITHDRAWAL_METHOD

src/api/api.module.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ import { TopcoderModule } from 'src/shared/topcoder/topcoder.module';
1515
import { OriginRepository } from './repository/origin.repo';
1616
import { TaxFormRepository } from './repository/taxForm.repo';
1717
import { PaymentMethodRepository } from './repository/paymentMethod.repo';
18+
import { WebhooksModule } from './webhooks/webhooks.module';
1819

1920
@Module({
20-
imports: [GlobalProvidersModule, TopcoderModule],
21+
imports: [WebhooksModule, GlobalProvidersModule, TopcoderModule],
2122
controllers: [
2223
HealthCheckController,
2324
AdminWinningController,
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Provider } from '@nestjs/common';
2+
import { PaymentHandler } from './payment.handler';
3+
import { getWebhooksEventHandlersProvider } from '../../webhooks.event-handlers.provider';
4+
5+
export const TrolleyWebhookHandlers: Provider[] = [
6+
getWebhooksEventHandlersProvider(
7+
'trolleyHandlerFns',
8+
'TrolleyWebhookHandlers',
9+
),
10+
11+
PaymentHandler,
12+
{
13+
provide: 'TrolleyWebhookHandlers',
14+
useFactory: (paymentHandler: PaymentHandler) => [paymentHandler],
15+
inject: [PaymentHandler],
16+
},
17+
];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { WebhookEvent } from '../../webhooks.decorators';
3+
import { TrolleyWebhookEvent } from '../trolley.types';
4+
5+
@Injectable()
6+
export class PaymentHandler {
7+
@WebhookEvent(TrolleyWebhookEvent.paymentCreated)
8+
async handlePaymentCreated(payload: any): Promise<any> {
9+
// TODO: Build out logic for payment.created event
10+
console.log('handling', TrolleyWebhookEvent.paymentCreated);
11+
12+
}
13+
14+
@WebhookEvent(TrolleyWebhookEvent.paymentUpdated)
15+
async handlePaymentUpdated(payload: any): Promise<any> {
16+
// TODO: Build out logic for payment.updated event
17+
console.log('handling', TrolleyWebhookEvent.paymentUpdated);
18+
}
19+
}
+138
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import crypto from 'crypto';
2+
import { Inject, Injectable } from '@nestjs/common';
3+
import { trolley_webhook_log, webhook_status } from '@prisma/client';
4+
import { PrismaService } from 'src/shared/global/prisma.service';
5+
6+
enum TrolleyHeaders {
7+
id = 'x-paymentrails-delivery',
8+
signature = 'x-paymentrails-signature',
9+
created = 'x-paymentrails-created',
10+
}
11+
12+
const trolleyWhHmac = process.env.TROLLEY_WH_HMAC;
13+
if (!trolleyWhHmac) {
14+
throw new Error('TROLLEY_WH_HMAC is not set!');
15+
}
16+
17+
/**
18+
* Service responsible for handling Trolley webhook operations.
19+
*/
20+
@Injectable()
21+
export class TrolleyService {
22+
constructor(
23+
@Inject('trolleyHandlerFns')
24+
private readonly handlers,
25+
private readonly prisma: PrismaService,
26+
) {}
27+
28+
/**
29+
* Validates the webhook signature to ensure the request is authentic.
30+
*
31+
* @param headers - The HTTP request headers containing the signature.
32+
* @param bodyPayload - The raw body payload of the webhook request.
33+
* @returns A boolean indicating whether the signature is valid.
34+
*/
35+
validateSignature(headers: Request['headers'], bodyPayload: string): boolean {
36+
const headerSignature = headers[TrolleyHeaders.signature] ?? '';
37+
if (!headerSignature || !headerSignature.match(/t=\d+,v1=[a-f0-9]{64}/i)) {
38+
return false;
39+
}
40+
41+
const headerSignatureValues = headerSignature.split(',');
42+
const t = headerSignatureValues[0].split('=')[1];
43+
const v1 = headerSignatureValues[1].split('=')[1];
44+
45+
const hmac = crypto.createHmac('sha256', trolleyWhHmac as string);
46+
hmac.update(`${t}${bodyPayload}`);
47+
const digest = hmac.digest('hex');
48+
49+
return digest === v1;
50+
}
51+
52+
/**
53+
* Validates whether the webhook event is unique by checking its ID against the database.
54+
*
55+
* @param headers - The HTTP request headers containing the webhook ID.
56+
* @returns A promise that resolves to a boolean indicating whether the webhook event is unique.
57+
*/
58+
async validateUnique(headers: Request['headers']): Promise<boolean> {
59+
const requestId = headers[TrolleyHeaders.id];
60+
61+
if (!requestId) {
62+
return false;
63+
}
64+
65+
const whEvent = await this.prisma.trolley_webhook_log.findUnique({
66+
where: { event_id: requestId },
67+
});
68+
return !whEvent;
69+
}
70+
71+
/**
72+
* Tracks the webhook events status by Updating or creating a record in the `trolley_webhook_log` table with the given event details.
73+
*
74+
* @param requestId - The unique identifier for the webhook event.
75+
* @param status - The status of the webhook event.
76+
* @param payload - (Optional) The payload associated with the webhook event.
77+
* @param meta - (Optional) Additional metadata for the webhook event, such as event time.
78+
* @returns A promise that resolves to the upserted `trolley_webhook_log` record.
79+
*/
80+
setEventState(
81+
requestId: string,
82+
status: webhook_status,
83+
payload?: any,
84+
meta?: Partial<trolley_webhook_log>,
85+
) {
86+
return this.prisma.trolley_webhook_log.upsert({
87+
where: {
88+
event_id: requestId,
89+
},
90+
create: {
91+
event_id: requestId,
92+
event_payload: JSON.stringify(payload ?? {}),
93+
event_time: meta?.event_time,
94+
event_model: payload?.model ?? '',
95+
event_action: payload?.action ?? '',
96+
status,
97+
created_by: 'system',
98+
} as trolley_webhook_log,
99+
update: {
100+
status,
101+
...meta,
102+
},
103+
});
104+
}
105+
106+
/**
107+
* Handles incoming webhook events by processing the payload and delegating
108+
* the event to the appropriate handler based on the model and action.
109+
*
110+
* @param headers - The headers of the incoming request, containing metadata
111+
* such as the event ID and creation time.
112+
* @param payload - The body of the webhook event, containing details such as
113+
* the model, action, and event-specific data.
114+
*/
115+
async handleEvent(headers: Request['headers'], payload: any) {
116+
const requestId = headers[TrolleyHeaders.id];
117+
118+
try {
119+
await this.setEventState(requestId, webhook_status.logged, payload, {
120+
event_time: headers[TrolleyHeaders.created],
121+
});
122+
123+
const { model, action, body } = payload;
124+
const handler = this.handlers.get(`${model}.${action}`);
125+
// do nothing if there's no handler for the event (event was logged in db)
126+
if (!handler) {
127+
return;
128+
}
129+
130+
await handler(body);
131+
await this.setEventState(requestId, webhook_status.processed);
132+
} catch (e) {
133+
await this.setEventState(requestId, webhook_status.error, void 0, {
134+
error_message: e.message ?? e,
135+
});
136+
}
137+
}
138+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export enum TrolleyWebhookEvent {
2+
paymentCreated = 'payment.created',
3+
paymentUpdated = 'payment.updated',
4+
}
5+
6+
export type TrolleyEventHandler = (eventPayload: any) => Promise<unknown>;
+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import {
2+
Controller,
3+
Post,
4+
Req,
5+
RawBodyRequest,
6+
ForbiddenException,
7+
} from '@nestjs/common';
8+
import { ApiTags } from '@nestjs/swagger';
9+
import { TrolleyService } from './trolley/trolley.service';
10+
import { Public } from 'src/core/auth/decorators';
11+
12+
@Public()
13+
@ApiTags('Webhooks')
14+
@Controller('webhooks')
15+
export class WebhooksController {
16+
constructor(private readonly trolleyService: TrolleyService) {}
17+
18+
/**
19+
* Handles incoming trolley webhooks.
20+
*
21+
* This method validates the webhook request by checking its signature and ensuring
22+
* it has not been processed before. If validation passes, it processes the webhook
23+
* payload and marks it as processed.
24+
*
25+
* @param request - The incoming webhook request containing headers, raw body, and parsed body.
26+
* @returns A success message if the webhook is processed successfully.
27+
* @throws {ForbiddenException} If the signature is invalid or the webhook has already been processed.
28+
*/
29+
@Post('trolley')
30+
async handleTrolleyWebhook(@Req() request: RawBodyRequest<Request>) {
31+
if (
32+
!this.trolleyService.validateSignature(
33+
request.headers,
34+
request.rawBody?.toString('utf-8') ?? '',
35+
)
36+
) {
37+
throw new ForbiddenException('Missing or invalid signature!');
38+
}
39+
40+
// do not proceed any further if event has already been processed
41+
if (!(await this.trolleyService.validateUnique(request.headers))) {
42+
return;
43+
}
44+
45+
return this.trolleyService.handleEvent(request.headers, request.body);
46+
}
47+
}
+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { SetMetadata } from '@nestjs/common';
2+
3+
export const WEBHOOK_EVENT_METADATA_KEY = 'WH_EVENT_TYPE';
4+
export const WebhookEvent = (...events: string[]) =>
5+
SetMetadata(WEBHOOK_EVENT_METADATA_KEY, events);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { Reflector } from '@nestjs/core';
2+
import { WEBHOOK_EVENT_METADATA_KEY } from './webhooks.decorators';
3+
4+
/**
5+
* Factory function to create a map of Trolley webhook event handlers.
6+
*
7+
* This function iterates over the provided handler classes and inspects their methods
8+
* to find those annotated with specific metadata indicating the Trolley webhook events
9+
* they handle. It then binds these methods to their respective event types and stores
10+
* them in a map for easy lookup.
11+
*
12+
* @param reflector - An instance of `Reflector` used to retrieve metadata from methods.
13+
* @param handlerClasses - An array of handler class instances containing methods
14+
* annotated with Trolley webhook event metadata.
15+
* @returns A `Map` where the keys are `TrolleyWebhookEvent` types and the values are
16+
* bound handler functions for those events.
17+
*/
18+
const whEventHandlersFactory = (reflector: Reflector, handlerClasses) => {
19+
const handlersMap = new Map<
20+
string,
21+
(eventPayload: any) => Promise<unknown>
22+
>();
23+
24+
for (const handlerClass of handlerClasses) {
25+
const prototype = Object.getPrototypeOf(handlerClass);
26+
for (const propertyName of Object.getOwnPropertyNames(prototype)) {
27+
const method = prototype[propertyName];
28+
if (typeof method !== 'function' || propertyName === 'constructor') {
29+
continue;
30+
}
31+
32+
const eventTypes = reflector.get<string[]>(
33+
WEBHOOK_EVENT_METADATA_KEY,
34+
method,
35+
);
36+
37+
if (eventTypes?.length > 0) {
38+
eventTypes.forEach((eventType) => {
39+
handlersMap.set(eventType, method.bind(handlerClass));
40+
});
41+
}
42+
}
43+
}
44+
45+
return handlersMap;
46+
};
47+
48+
/**
49+
* Creates a provider object for webhook event handlers.
50+
*
51+
* @param provide - The token that will be used to provide the dependency.
52+
* @param handlersKey - The key used to identify the specific handlers to inject.
53+
* @returns An object defining the provider with a factory function and its dependencies.
54+
*/
55+
export const getWebhooksEventHandlersProvider = (
56+
provide: string,
57+
handlersKey: string,
58+
) => ({
59+
provide,
60+
useFactory: whEventHandlersFactory,
61+
inject: [Reflector, handlersKey],
62+
});

0 commit comments

Comments
 (0)