File size: 3,052 Bytes
6abfd9d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
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<RedisClientType> {
    if (this.client?.isOpen) return this.client;

    const redisUrl = this.configService.get<string>(
      "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<boolean> {
    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");
}