|
| 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 | +} |
0 commit comments