import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { createClient } from "redis"; import type { RedisClientType } from "redis"; import * as crypto from "crypto"; @Injectable() export class WebhookIdempotencyService { private readonly logger = new Logger(WebhookIdempotencyService.name); private client: RedisClientType | null = null; private readonly ttlSeconds = 86400; // 24 hours constructor(private readonly configService: ConfigService) {} private async getClient(): Promise { if (this.client?.isOpen) return this.client; const redisUrl = this.configService.get( "REDIS_URL", "redis://:redis_secret@localhost:6379" ); this.client = createClient({ url: redisUrl }); this.client.on("error", (err) => this.logger.error("Redis error", err)); await this.client.connect(); return this.client; } /** * Check if webhook event was already processed. * Returns true if this is a duplicate (already processed). * Uses SETNX for atomic check-and-set. */ async checkAndMark(eventKey: string): Promise { const client = await this.getClient(); const key = `webhook:idempotency:${eventKey}`; // SET key value NX EX ttl - only sets if not exists const result = await client.set(key, "1", { NX: true, EX: this.ttlSeconds, }); // result is "OK" if set (first time), null if already exists (duplicate) return result === null; } /** * Generate a unique event key from provider and webhook payload */ generateEventKey(provider: string, body: any): string { const eventId = this.generateEventId(provider, body); return `${provider}:${eventId}`; } /** * Generate a unique event ID from webhook payload */ generateEventId(provider: string, body: any): string { switch (provider) { case "calcom": // Cal.com uses booking UID or event ID return body?.payload?.uid || body?.uid || body?.bookingId || body?.payload?.id || cryptoRandomId(); case "chatwoot": // Chatwoot uses conversation ID + message ID return `${body?.conversation?.id || "unknown"}:${body?.id || body?.message?.id || cryptoRandomId()}`; case "formbricks": // Formbricks uses response ID return body?.responseId || body?.data?.id || body?.id || cryptoRandomId(); case "listmonk": // Listmonk uses subscriber ID + event type const event = body?.event || "unknown"; const subId = body?.data?.subscriber?.id || body?.subscriber_id || "unknown"; return `${event}:${subId}`; case "documenso": return body?.documentId || body?.data?.id || cryptoRandomId(); case "livekit": return `${body?.event || "unknown"}:${body?.room?.name || body?.roomName || cryptoRandomId()}`; default: return cryptoRandomId(); } } } function cryptoRandomId(): string { return crypto.randomBytes(16).toString("hex"); }