Spaces:
Running
Running
Download apps/api/src/integrations/integrations-webhook.controller.ts from Benaah/MicProtocol: direct link, hf CLI and curl.
- Browser
- Download file 33.1 kB
-
https://huggingface.co/spaces/Benaah/MicProtocol/resolve/main/apps/api/src/integrations/integrations-webhook.controller.ts
- Command line
-
hf download hf://spaces/Benaah/MicProtocol/apps/api/src/integrations/integrations-webhook.controller.ts
-
curl -L -o integrations-webhook.controller.ts https://huggingface.co/spaces/Benaah/MicProtocol/resolve/main/apps/api/src/integrations/integrations-webhook.controller.ts
33.1 kB
| import { | |
| Controller, | |
| Post, | |
| Body, | |
| Headers, | |
| Param, | |
| Req, | |
| HttpCode, | |
| HttpStatus, | |
| Logger, | |
| UnauthorizedException, | |
| } from "@nestjs/common"; | |
| import { ConfigService } from "@nestjs/config"; | |
| import { PrismaService } from "../prisma/prisma.service"; | |
| import type { IntegrationProvider } from "@prisma/client"; | |
| import * as crypto from "crypto"; | |
| import type { Request } from "express"; | |
| /** Extended Request with raw body (added by NestJS rawBody middleware) */ | |
| interface RawBodyRequest extends Request { | |
| rawBody?: Buffer; | |
| } | |
| import { WebhookIdempotencyService } from "./webhook-idempotency.service"; | |
| import { SelfHostedClientsService } from "./clients/self-hosted-clients.service"; | |
| import { ChatwootSyncService } from "../support/chatwoot-sync.service"; | |
| import { EventsGateway } from "../websocket/events.gateway"; | |
| function timingSafeCompare(provided?: string, expected?: string): boolean { | |
| if (!provided || !expected) return false; | |
| const bufProvided = Buffer.from(provided); | |
| const bufExpected = Buffer.from(expected); | |
| if (bufProvided.length !== bufExpected.length) return false; | |
| return crypto.timingSafeEqual(bufProvided, bufExpected); | |
| } | |
| /** | |
| * Verify a raw-body HMAC webhook signature (Chatwoot). | |
| * | |
| * Chatwoot signs the RAW request body with the shared webhook secret and | |
| * ships the digest in `X-Chatwoot-Signature` as `sha256=<hex>` (older | |
| * installs may use `sha1=<hex>`). Some versions include the | |
| * `X-Chatwoot-Timestamp` in the signed content (`<ts>.<rawBody>`) — we | |
| * accept both forms since both still require a valid HMAC under the shared | |
| * secret. We recompute over the raw body (captured via Nest's | |
| * `rawBody: true` — see main.ts) and compare in constant time. | |
| */ | |
| function verifyRawBodyHmac( | |
| rawBody: Buffer | undefined, | |
| signature: string | undefined, | |
| secret: string, | |
| timestamp?: string, | |
| allowSha1 = false, | |
| ): boolean { | |
| if (!rawBody || !signature || !secret) return false; | |
| const provided = signature.replace(/^sha256=/i, "").replace(/^sha1=/i, ""); | |
| const messages = [rawBody]; | |
| if (timestamp) { | |
| messages.push(Buffer.from(`${timestamp}.${rawBody.toString("utf8")}`, "utf8")); | |
| } | |
| const candidates: string[] = []; | |
| for (const msg of messages) { | |
| candidates.push(crypto.createHmac("sha256", secret).update(msg).digest("hex")); | |
| if (allowSha1) { | |
| candidates.push(crypto.createHmac("sha1", secret).update(msg).digest("hex")); | |
| } | |
| } | |
| return candidates.some((expected) => timingSafeCompare(provided, expected)); | |
| } | |
| // ═══ Webhook Payload Types ═════════════════════════════════ | |
| interface ChatwootWebhookPayload { | |
| event: string; | |
| message_type?: string; | |
| content?: string; | |
| conversation?: { | |
| id: number; | |
| status: string; | |
| contact_inbox?: { contact_id: number }; | |
| assignee?: { id: number }; | |
| }; | |
| contact?: { | |
| id: number; | |
| name: string; | |
| email: string; | |
| phone_number?: string; | |
| }; | |
| message?: { | |
| id: number; | |
| content: string; | |
| message_type: "incoming" | "outgoing" | "activity"; | |
| }; | |
| } | |
| interface DocumensoWebhookPayload { | |
| event: string; | |
| documentId?: number; | |
| document?: { | |
| id: number; | |
| title: string; | |
| status: string; | |
| }; | |
| data?: { | |
| id?: number; | |
| }; | |
| } | |
| interface FormbricksWebhookPayload { | |
| event?: string; | |
| type?: string; | |
| surveyId?: string; | |
| responseId?: string; | |
| data?: { | |
| id?: string; | |
| surveyId?: string; | |
| userId?: string; | |
| finished?: boolean; | |
| data?: Record<string, unknown>; | |
| }; | |
| } | |
| interface ListmonkWebhookPayload { | |
| event: string; | |
| subscriber_id?: number; | |
| subscriber?: { | |
| id: number; | |
| email: string; | |
| name: string; | |
| status: string; | |
| lists?: Array<{ id: number; name: string }>; | |
| }; | |
| data?: { | |
| subscriber?: { | |
| id: number; | |
| email: string; | |
| name: string; | |
| status: string; | |
| lists?: Array<{ id: number; name: string }>; | |
| }; | |
| }; | |
| } | |
| interface LiveKitWebhookPayload { | |
| [key: string]: unknown; | |
| event: string; | |
| room?: { | |
| sid: string; | |
| name: string; | |
| empty_timeout: number; | |
| }; | |
| egressInfo?: { | |
| roomName?: string; | |
| status?: string; | |
| }; | |
| recording?: { | |
| sid: string; | |
| room_sid: string; | |
| name: string; | |
| status: string; | |
| }; | |
| participant?: { | |
| id: string; | |
| identity: string; | |
| name: string; | |
| }; | |
| track?: { | |
| sid: string; | |
| name: string; | |
| kind: string; | |
| }; | |
| } | |
| /** Generic webhook body for Cal.com (already well-structured in handler) */ | |
| type CalComWebhookPayload = Record<string, unknown>; | |
| /** | |
| * Verify a Standard Webhooks spec signature (Formbricks Hub). | |
| * | |
| * Formbricks implements the Standard Webhooks spec: | |
| * - `webhook-id` – unique event ID | |
| * - `webhook-timestamp` – unix seconds of the delivery | |
| * - `webhook-signature` – space-separated `v1,<base64>` digests | |
| * | |
| * The signed content is `${id}.${timestamp}.${rawBody}`; the digest is | |
| * base64(HMAC-SHA256(secretBytes, content)) where secretBytes is the | |
| * `whsec_`-prefixed secret with the prefix stripped and base64-decoded | |
| * (falls back to the raw string if it doesn't round-trip). At least one | |
| * of the presented signatures must match; deliveries older than 5 minutes | |
| * are rejected to blunt replay of captured webhooks. | |
| */ | |
| function verifyStandardWebhooksSignature( | |
| rawBody: Buffer | undefined, | |
| signatureHeader: string | undefined, | |
| idHeader: string | undefined, | |
| timestampHeader: string | undefined, | |
| secret: string, | |
| ): boolean { | |
| if (!rawBody || !signatureHeader || !idHeader || !timestampHeader || !secret) { | |
| return false; | |
| } | |
| const nowSec = Math.floor(Date.now() / 1000); | |
| const ts = Number(timestampHeader); | |
| if (!Number.isFinite(ts) || Math.abs(nowSec - ts) > 300) return false; | |
| let secretBytes: Buffer; | |
| const stripped = secret.replace(/^whsec_/i, ""); | |
| try { | |
| const decoded = Buffer.from(stripped, "base64"); | |
| // Guard against silent truncation of non-base64 secrets: only use the | |
| // decoded bytes if the round-trip is lossless. | |
| if (decoded.toString("base64").replace(/=+$/, "") === stripped.replace(/=+$/, "")) { | |
| secretBytes = decoded; | |
| } else { | |
| secretBytes = Buffer.from(secret, "utf8"); | |
| } | |
| } catch { | |
| secretBytes = Buffer.from(secret, "utf8"); | |
| } | |
| const signedContent = `${idHeader}.${timestampHeader}.${rawBody.toString("utf8")}`; | |
| const expected = crypto | |
| .createHmac("sha256", secretBytes) | |
| .update(signedContent) | |
| .digest("base64"); | |
| return signatureHeader.split(/\s+/).some((entry) => { | |
| const [version, sig] = entry.split(",", 2); | |
| if (version !== "v1" || !sig) return false; | |
| return timingSafeCompare(sig, expected); | |
| }); | |
| } | |
| ("integrations/webhooks") | |
| export class IntegrationsWebhookController { | |
| private readonly logger = new Logger(IntegrationsWebhookController.name); | |
| constructor( | |
| private readonly configService: ConfigService, | |
| private readonly prisma: PrismaService, | |
| private readonly idempotency: WebhookIdempotencyService, | |
| private readonly selfHostedClients: SelfHostedClientsService, | |
| private readonly chatwootSync: ChatwootSyncService, | |
| private readonly eventsGateway: EventsGateway, | |
| ) {} | |
| /** | |
| * SECURITY: fail closed. When a webhook secret is not configured we reject | |
| * the request in production instead of silently accepting unauthenticated | |
| * webhook deliveries (which would let attackers forge booking/cancel/ | |
| * sync events). | |
| */ | |
| private requireSecret(envName: string, configured: string): void { | |
| if (!configured && process.env.NODE_ENV === "production") { | |
| this.logger.error(`${envName} is not configured — rejecting webhook`); | |
| throw new UnauthorizedException(`${envName} is not configured`); | |
| } | |
| } | |
| private async checkIdempotency(provider: string, body: Record<string, unknown> | ChatwootWebhookPayload | DocumensoWebhookPayload | FormbricksWebhookPayload | ListmonkWebhookPayload | LiveKitWebhookPayload): Promise<boolean> { | |
| const eventKey = this.idempotency.generateEventKey(provider, body); | |
| const isDuplicate = await this.idempotency.checkAndMark(eventKey); | |
| if (isDuplicate) { | |
| this.logger.warn(`Duplicate webhook ignored: ${provider}:${eventKey}`); | |
| } | |
| return isDuplicate; | |
| } | |
| /** | |
| * Verify a LiveKit webhook JWT: HS256-signed with LIVEKIT_API_SECRET, | |
| * with `iss` = LIVEKIT_API_KEY, rejecting expired tokens. When the raw | |
| * body is available (Nest `rawBody: true`), it also verifies the JWT's | |
| * `sha256` claim — standard base64 of sha256(raw body) — matching the | |
| * reference implementation in `livekit-server-sdk`'s WebhookReceiver. | |
| * This binds the signed token to the exact delivered bytes, so a captured | |
| * JWT cannot be replayed against a different payload. | |
| */ | |
| private verifyLiveKitJwt( | |
| authorization: string | undefined, | |
| apiKey: string, | |
| apiSecret: string, | |
| rawBody?: Buffer | string, | |
| ): boolean { | |
| if (!authorization?.startsWith("Bearer ")) return false; | |
| const token = authorization.slice("Bearer ".length).trim(); | |
| const parts = token.split("."); | |
| if (parts.length !== 3) return false; | |
| const [headerB64, payloadB64, signatureB64] = parts; | |
| try { | |
| const payload = JSON.parse( | |
| Buffer.from(payloadB64, "base64url").toString("utf8"), | |
| ); | |
| if (payload.iss !== apiKey) return false; | |
| const nowSec = Math.floor(Date.now() / 1000); | |
| if (typeof payload.exp === "number" && payload.exp < nowSec) return false; | |
| if (typeof payload.nbf === "number" && payload.nbf > nowSec + 60) return false; | |
| const signingInput = `${headerB64}.${payloadB64}`; | |
| const expected = crypto | |
| .createHmac("sha256", apiSecret) | |
| .update(signingInput) | |
| .digest("base64url"); | |
| if (!timingSafeCompare(expected, signatureB64)) return false; | |
| // LiveKit always includes the body hash; require it and verify it. | |
| if (typeof payload.sha256 !== "string") return false; | |
| if (rawBody === undefined) { | |
| // Cannot bind the token to the delivered bytes without the raw | |
| // body. Fail closed in production, tolerate in dev (the body hash | |
| // feature depends on Nest rawBody:true, which is configured). | |
| return process.env.NODE_ENV !== "production"; | |
| } | |
| const bodyBuf = | |
| typeof rawBody === "string" ? Buffer.from(rawBody, "utf8") : rawBody; | |
| const bodyHash = crypto | |
| .createHash("sha256") | |
| .update(bodyBuf) | |
| .digest("base64"); | |
| return timingSafeCompare(payload.sha256, bodyHash); | |
| } catch { | |
| return false; | |
| } | |
| } | |
| ("calcom") | |
| (HttpStatus.OK) | |
| async handleCalComWebhook( | |
| () body: CalComWebhookPayload, | |
| ("x-cal-secret-key") secret?: string, | |
| ) { | |
| const expected = this.configService.get<string>("CALCOM_WEBHOOK_SECRET", ""); | |
| this.requireSecret("CALCOM_WEBHOOK_SECRET", expected); | |
| if (expected && !timingSafeCompare(secret, expected)) { | |
| throw new UnauthorizedException("Invalid Cal.com webhook secret"); | |
| } | |
| // Idempotency check | |
| if (await this.checkIdempotency("calcom", body)) { | |
| return { received: true, duplicate: true }; | |
| } | |
| const eventType = String(body?.triggerEvent || body?.event || "unknown"); | |
| const payload = (body?.payload || body) as CalComWebhookPayload; | |
| this.logger.log(`Cal.com webhook received: ${eventType}`); | |
| // Handle all Cal.com event types | |
| await this.handleCalComEvent(eventType, payload); | |
| return { received: true, event: eventType }; | |
| } | |
| private async handleCalComEvent(eventType: string, payload: CalComWebhookPayload) { | |
| const uid = String(payload?.uid || payload?.bookingId || ""); | |
| if (!uid) return; | |
| const booking = await this.prisma.booking.findFirst({ | |
| where: { OR: [{ calendarEventId: uid }, { meetLink: { contains: uid } }] }, | |
| include: { | |
| episode: { | |
| include: { | |
| podcast: { | |
| select: { workspaceId: true }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }); | |
| if (!booking) { | |
| this.logger.warn(`No booking found for Cal.com UID: ${uid}`); | |
| return; | |
| } | |
| switch (eventType) { | |
| case "BOOKING_CREATED": | |
| case "BOOKING_CONFIRMED": | |
| await this.prisma.booking.update({ | |
| where: { id: booking.id }, | |
| data: { isConfirmed: true, updatedAt: new Date() }, | |
| }); | |
| this.logger.log(`Booking ${booking.id} confirmed via Cal.com`); | |
| if (booking.episode?.podcast?.workspaceId) { | |
| this.eventsGateway.notifyCalComBookingCreated( | |
| booking.episode.podcast.workspaceId, | |
| { id: booking.id, isConfirmed: true }, | |
| ); | |
| } | |
| break; | |
| case "BOOKING_CANCELLED": | |
| case "BOOKING_REJECTED": | |
| await this.prisma.booking.update({ | |
| where: { id: booking.id }, | |
| data: { isConfirmed: false, updatedAt: new Date() }, | |
| }); | |
| this.logger.log(`Booking ${booking.id} cancelled via Cal.com`); | |
| if (booking.episode?.podcast?.workspaceId) { | |
| this.eventsGateway.notifyCalComBookingCancelled( | |
| booking.episode.podcast.workspaceId, | |
| booking.id, | |
| ); | |
| } | |
| break; | |
| case "BOOKING_RESCHEDULED": | |
| const newStart = String(payload?.startTime || payload?.rescheduledStartTime || ""); | |
| const newEnd = String(payload?.endTime || payload?.rescheduledEndTime || ""); | |
| if (newStart && newEnd) { | |
| await this.prisma.booking.update({ | |
| where: { id: booking.id }, | |
| data: { | |
| startTime: new Date(newStart), | |
| endTime: new Date(newEnd), | |
| updatedAt: new Date(), | |
| }, | |
| }); | |
| this.logger.log(`Booking ${booking.id} rescheduled to ${newStart}`); | |
| if (booking.episode?.podcast?.workspaceId) { | |
| this.eventsGateway.notifyCalComBookingRescheduled( | |
| booking.episode.podcast.workspaceId, | |
| booking.id, | |
| ); | |
| } | |
| } | |
| break; | |
| case "BOOKING_NO_SHOW": | |
| this.logger.log(`Booking ${booking.id} no-show (no status field available)`); | |
| break; | |
| case "BOOKING_ATTENDEE_ADDED": | |
| case "BOOKING_ATTENDEE_REMOVED": | |
| // Attendee changes - could sync to episode guests | |
| this.logger.log(`Attendee change for booking ${booking.id}: ${eventType}`); | |
| break; | |
| default: | |
| this.logger.debug(`Unhandled Cal.com event type: ${eventType}`); | |
| } | |
| } | |
| ("chatwoot/:accountId") | |
| (HttpStatus.OK) | |
| async handleChatwootWebhook( | |
| ("accountId") accountId: string, | |
| () req: RawBodyRequest, | |
| () body: ChatwootWebhookPayload, | |
| ("x-chatwoot-signature") signature?: string, | |
| ("x-chatwoot-timestamp") timestamp?: string, | |
| ) { | |
| const expected = this.configService.get<string>("CHATWOOT_WEBHOOK_SECRET", ""); | |
| this.requireSecret("CHATWOOT_WEBHOOK_SECRET", expected); | |
| // Chatwoot signs the raw body (HMAC-SHA1 or SHA256), with some | |
| // versions prefixing the timestamp into the signed content. | |
| if ( | |
| expected && | |
| !verifyRawBodyHmac(req.rawBody, signature, expected, timestamp, true) | |
| ) { | |
| throw new UnauthorizedException("Invalid Chatwoot webhook signature"); | |
| } | |
| // Idempotency check | |
| if (await this.checkIdempotency("chatwoot", body)) { | |
| return { received: true, duplicate: true }; | |
| } | |
| const event = body?.event || "unknown"; | |
| this.logger.log(`Chatwoot webhook: ${event} for account ${accountId}`); | |
| await this.handleChatwootEvent(event, body, accountId); | |
| return { received: true, event, accountId }; | |
| } | |
| private async handleChatwootEvent(event: string, body: ChatwootWebhookPayload, accountId: string) { | |
| switch (event) { | |
| case "message_created": | |
| if (body?.message_type === "incoming" && body?.content) { | |
| const conversationId = body?.conversation?.id; | |
| const contactId = body?.conversation?.contact_inbox?.contact_id; | |
| const messageId = body?.message?.id; | |
| this.logger.log(`Incoming Chatwoot message in conversation ${conversationId} from contact ${contactId}`); | |
| // Sync contact to Guest record (existing behavior) | |
| if (contactId) { | |
| await this.syncChatwootContact(contactId, accountId); | |
| } | |
| // Sync message into internal support ticket/record (new) | |
| if (conversationId && messageId && body.content) { | |
| await this.chatwootSync.syncIncomingMessage( | |
| accountId, | |
| conversationId, | |
| messageId, | |
| body.content, | |
| "incoming", | |
| ); | |
| } | |
| } | |
| break; | |
| case "conversation_created": | |
| this.logger.log(`New Chatwoot conversation: ${body?.conversation?.id}`); | |
| break; | |
| case "conversation_status_changed": { | |
| const newStatus = body?.conversation?.status; | |
| const conversationId = body?.conversation?.id; | |
| this.logger.log(`Conversation ${conversationId} status changed to ${newStatus}`); | |
| if (conversationId && newStatus) { | |
| await this.chatwootSync.syncConversationStatus(accountId, conversationId, newStatus); | |
| } | |
| break; | |
| } | |
| case "conversation_assigned": { | |
| const conversationId = body?.conversation?.id; | |
| const assigneeId = body?.conversation?.assignee?.id; | |
| this.logger.log(`Conversation ${conversationId} assigned to ${assigneeId}`); | |
| if (conversationId) { | |
| await this.chatwootSync.syncConversationAssignment(accountId, conversationId, assigneeId); | |
| } | |
| break; | |
| } | |
| case "contact_created": | |
| case "contact_updated": | |
| const contactId = body?.contact?.id; | |
| if (contactId) { | |
| await this.syncChatwootContact(contactId, accountId); | |
| } | |
| break; | |
| default: | |
| this.logger.debug(`Unhandled Chatwoot event: ${event}`); | |
| } | |
| } | |
| private async syncChatwootContact(contactId: number, accountId: string) { | |
| try { | |
| // Find the workspace that has this Chatwoot integration | |
| const integration = await this.prisma.workspaceIntegration.findFirst({ | |
| where: { | |
| provider: "chatwoot" as IntegrationProvider, | |
| status: "connected", | |
| metadata: { path: ["accountId"], equals: accountId }, | |
| }, | |
| }); | |
| if (!integration) { | |
| this.logger.warn(`No workspace integration found for Chatwoot account ${accountId}`); | |
| return; | |
| } | |
| // Get the API key from the integration | |
| const apiKey = integration.accessToken; | |
| if (!apiKey) { | |
| this.logger.warn(`No API key found for Chatwoot integration ${integration.id}`); | |
| return; | |
| } | |
| // Fetch contact details from Chatwoot | |
| const contact = await this.selfHostedClients.getChatwootContact(apiKey, contactId); | |
| if (!contact?.email) { | |
| this.logger.warn(`Chatwoot contact ${contactId} has no email, skipping sync`); | |
| return; | |
| } | |
| // Find the first podcast in the workspace to associate the guest | |
| const workspace = await this.prisma.workspace.findUnique({ | |
| where: { id: integration.workspaceId }, | |
| include: { podcasts: { take: 1 } }, | |
| }); | |
| const podcastId = workspace?.podcasts?.[0]?.id; | |
| if (!podcastId) { | |
| this.logger.warn(`No podcast found for workspace ${integration.workspaceId}, cannot create guest`); | |
| return; | |
| } | |
| // Upsert the guest record | |
| const existingGuest = await this.prisma.guest.findFirst({ | |
| where: { podcastId, email: contact.email }, | |
| }); | |
| if (existingGuest) { | |
| // Update existing guest with Chatwoot metadata | |
| const currentCustomFields = (existingGuest.customFields as Record<string, any>) || {}; | |
| await this.prisma.guest.update({ | |
| where: { id: existingGuest.id }, | |
| data: { | |
| fullName: contact.name || existingGuest.fullName, | |
| lastContactAt: new Date(), | |
| customFields: { | |
| ...currentCustomFields, | |
| chatwootContactId: contactId, | |
| chatwootAccountId: accountId, | |
| chatwootPhone: contact.phone_number, | |
| chatwootCustomAttributes: contact.custom_attributes, | |
| }, | |
| }, | |
| }); | |
| this.logger.log(`Updated guest ${existingGuest.id} from Chatwoot contact ${contactId}`); | |
| } else { | |
| // Create new guest from Chatwoot contact | |
| await this.prisma.guest.create({ | |
| data: { | |
| podcastId, | |
| email: contact.email, | |
| fullName: contact.name || contact.email.split("@")[0], | |
| status: "prospect", | |
| customFields: { | |
| chatwootContactId: contactId, | |
| chatwootAccountId: accountId, | |
| chatwootPhone: contact.phone_number, | |
| chatwootCustomAttributes: contact.custom_attributes, | |
| source: "chatwoot", | |
| }, | |
| }, | |
| }); | |
| this.logger.log(`Created guest from Chatwoot contact ${contactId} (${contact.email})`); | |
| } | |
| } catch (error: unknown) { | |
| const message = error instanceof Error ? error.message : String(error); | |
| this.logger.error(`Failed to sync Chatwoot contact ${contactId}: ${message}`); | |
| } | |
| } | |
| ("documenso") | |
| (HttpStatus.OK) | |
| async handleDocumensoWebhook( | |
| () body: DocumensoWebhookPayload, | |
| ("x-documenso-secret") secret?: string, | |
| ) { | |
| const expected = this.configService.get<string>("DOCUMENSO_WEBHOOK_SECRET", ""); | |
| this.requireSecret("DOCUMENSO_WEBHOOK_SECRET", expected); | |
| // Documenso passes the webhook secret plainly in `X-Documenso-Secret`; | |
| // verify with a constant-time comparison. | |
| if (expected && !timingSafeCompare(secret, expected)) { | |
| throw new UnauthorizedException("Invalid Documenso webhook secret"); | |
| } | |
| // Idempotency check | |
| if (await this.checkIdempotency("documenso", body)) { | |
| return { received: true, duplicate: true }; | |
| } | |
| const event = body?.event || "unknown"; | |
| const documentId = body?.documentId || body?.data?.id; | |
| this.logger.log(`Documenso webhook: ${event} docId=${documentId ?? ""}`); | |
| // Handle document status changes | |
| if (documentId && (event === "document_completed" || event === "document_declined")) { | |
| // Could update local document record status | |
| this.logger.log(`Document ${documentId} status: ${event}`); | |
| } | |
| return { received: true, event, documentId }; | |
| } | |
| ("formbricks") | |
| (HttpStatus.OK) | |
| async handleFormbricksWebhook( | |
| () req: RawBodyRequest, | |
| () body: FormbricksWebhookPayload, | |
| ("webhook-signature") signature?: string, | |
| ("webhook-id") webhookId?: string, | |
| ("webhook-timestamp") webhookTimestamp?: string, | |
| ) { | |
| const expected = this.configService.get<string>("FORMBRICKS_WEBHOOK_SECRET", ""); | |
| this.requireSecret("FORMBRICKS_WEBHOOK_SECRET", expected); | |
| // Formbricks (Hub) implements the Standard Webhooks spec: the signature | |
| // is base64 HMAC-SHA256 over `id.timestamp.rawBody`. | |
| if ( | |
| expected && | |
| !verifyStandardWebhooksSignature( | |
| req.rawBody, | |
| signature, | |
| webhookId, | |
| webhookTimestamp, | |
| expected, | |
| ) | |
| ) { | |
| throw new UnauthorizedException("Invalid Formbricks webhook signature"); | |
| } | |
| // Idempotency check | |
| if (await this.checkIdempotency("formbricks", body)) { | |
| return { received: true, duplicate: true }; | |
| } | |
| const event = body?.event || body?.type || "unknown"; | |
| const surveyId = body?.surveyId || body?.data?.surveyId; | |
| const responseId = body?.responseId || body?.data?.id; | |
| this.logger.log(`Formbricks webhook: ${event} surveyId=${surveyId ?? ""} responseId=${responseId ?? ""}`); | |
| // If survey response is submitted, log journey event or store metadata | |
| if (body?.data?.userId) { | |
| const guest = await this.prisma.guest.findFirst({ | |
| where: { id: body.data.userId }, | |
| }); | |
| if (guest) { | |
| await this.prisma.guestJourneyEvent.create({ | |
| data: { | |
| guestId: guest.id, | |
| eventType: "follow_up", | |
| title: "Completed Survey", | |
| details: `Completed survey ${surveyId || ""}`, | |
| metadata: JSON.parse(JSON.stringify(body?.data || {})), | |
| }, | |
| }); | |
| this.logger.log(`Recorded survey completion journey event for guest ${guest.id}`); | |
| } | |
| } | |
| return { received: true, event, surveyId, responseId }; | |
| } | |
| ("listmonk") | |
| (HttpStatus.OK) | |
| async handleListmonkWebhook( | |
| () body: ListmonkWebhookPayload, | |
| ("x-listmonk-secret") secret?: string, | |
| ) { | |
| const expected = this.configService.get<string>("LISTMONK_WEBHOOK_SECRET", ""); | |
| this.requireSecret("LISTMONK_WEBHOOK_SECRET", expected); | |
| if (expected && !timingSafeCompare(secret, expected)) { | |
| throw new UnauthorizedException("Invalid Listmonk webhook secret"); | |
| } | |
| // Idempotency check | |
| if (await this.checkIdempotency("listmonk", body)) { | |
| return { received: true, duplicate: true }; | |
| } | |
| const event = body?.event || "unknown"; | |
| const subscriberId = body?.data?.subscriber?.id || body?.subscriber_id; | |
| this.logger.log(`Listmonk webhook: ${event} subscriberId=${subscriberId ?? ""}`); | |
| await this.handleListmonkEvent(event, body); | |
| return { received: true, event, subscriberId }; | |
| } | |
| private async handleListmonkEvent(event: string, body: ListmonkWebhookPayload) { | |
| const subscriber = body?.data?.subscriber || body?.subscriber; | |
| if (!subscriber) return; | |
| const email = subscriber.email; | |
| const name = subscriber.name || ""; | |
| const status = subscriber.status || "enabled"; | |
| const lists = subscriber.lists || []; | |
| switch (event) { | |
| case "subscriber.created": | |
| case "subscriber.updated": | |
| // Upsert contact/guest in our system | |
| await this.syncListmonkSubscriber(subscriber); | |
| break; | |
| case "subscriber.deleted": | |
| // Mark as unsubscribed in our system | |
| await this.unsubscribeListmonkSubscriber(email); | |
| break; | |
| case "subscriber.subscribed": | |
| case "subscriber.unsubscribed": | |
| // Handle list-specific subscription changes | |
| await this.handleListmonkSubscriptionChange(email, lists, event); | |
| break; | |
| default: | |
| this.logger.debug(`Unhandled Listmonk event: ${event}`); | |
| } | |
| } | |
| private async syncListmonkSubscriber(subscriber: { id: number; email: string; name?: string; status?: string; lists?: Array<{ id: number; name: string }> }) { | |
| const email = subscriber.email; | |
| if (!email) return; | |
| try { | |
| const guest = await this.prisma.guest.findFirst({ | |
| where: { email }, | |
| }); | |
| if (guest) { | |
| await this.prisma.guest.update({ | |
| where: { id: guest.id }, | |
| data: { | |
| customFields: { | |
| listmonkSubscriberId: subscriber.id, | |
| listmonkStatus: subscriber.status, | |
| listmonkLists: subscriber.lists || [], | |
| }, | |
| }, | |
| }); | |
| this.logger.log(`Synced Listmonk subscriber ${subscriber.id} to guest ${guest.id}`); | |
| } else { | |
| this.logger.log(`Listmonk subscriber ${email} not found in guests, would create contact`); | |
| } | |
| } catch (error: unknown) { | |
| const message = error instanceof Error ? error.message : String(error); | |
| this.logger.error(`Failed to sync Listmonk subscriber ${subscriber.id}: ${message}`); | |
| } | |
| } | |
| private async unsubscribeListmonkSubscriber(email: string) { | |
| try { | |
| const guest = await this.prisma.guest.findFirst({ where: { email } }); | |
| if (guest) { | |
| await this.prisma.guest.update({ | |
| where: { id: guest.id }, | |
| data: { | |
| customFields: { | |
| listmonkUnsubscribedAt: new Date().toISOString(), | |
| }, | |
| }, | |
| }); | |
| this.logger.log(`Marked guest ${guest.id} as unsubscribed via Listmonk webhook`); | |
| } | |
| } catch (error: unknown) { | |
| const message = error instanceof Error ? error.message : String(error); | |
| this.logger.error(`Failed to unsubscribe guest for email ${email}: ${message}`); | |
| } | |
| } | |
| private async handleListmonkSubscriptionChange(email: string, lists: Array<{ id: number; name: string }>, event: string) { | |
| this.logger.log(`Listmonk subscription change for ${email}: ${event} on lists ${lists.map(l => l.id).join(", ")}`); | |
| // Could map specific list IDs to podcast/newsletter subscriptions | |
| } | |
| ("livekit") | |
| (HttpStatus.OK) | |
| async handleLiveKitWebhook( | |
| () req: RawBodyRequest, | |
| () body: LiveKitWebhookPayload, | |
| ("authorization") authorization?: string, | |
| ) { | |
| const apiKey = this.configService.get<string>("LIVEKIT_API_KEY", ""); | |
| const apiSecret = this.configService.get<string>("LIVEKIT_API_SECRET", ""); | |
| const rawBody = (req as RawBodyRequest).rawBody; | |
| // SECURITY: validate the LiveKit webhook JWT (HS256 signed with | |
| // LIVEKIT_API_SECRET) plus its `sha256` body claim over the raw body. | |
| // Fail closed in production when not configured. | |
| if (process.env.NODE_ENV === "production") { | |
| if ( | |
| !apiKey || | |
| !apiSecret || | |
| !this.verifyLiveKitJwt(authorization, apiKey, apiSecret, rawBody) | |
| ) { | |
| throw new UnauthorizedException("Invalid LiveKit webhook authorization"); | |
| } | |
| } else if (apiKey && apiSecret && !this.verifyLiveKitJwt(authorization, apiKey, apiSecret, rawBody)) { | |
| throw new UnauthorizedException("Invalid LiveKit webhook authorization"); | |
| } else if (!apiKey || !apiSecret) { | |
| this.logger.warn("LIVEKIT_API_KEY / LIVEKIT_API_SECRET not configured — LiveKit webhook verification skipped (dev)"); | |
| } | |
| // Idempotency check | |
| if (await this.checkIdempotency("livekit", body)) { | |
| return { received: true, duplicate: true }; | |
| } | |
| const event = String(body?.event || "unknown"); | |
| const roomName = | |
| body?.room?.name || body?.egressInfo?.roomName || String(body?.roomName || ""); | |
| this.logger.log(`LiveKit webhook: ${event} room=${roomName ?? ""}`); | |
| // Rooms are created as `episode-{episodeId}` (see LivekitService.roomName). | |
| if (event === "room_finished" && roomName) { | |
| const episodeId = this.extractEpisodeIdFromRoom(roomName); | |
| if (episodeId) { | |
| const session = await this.prisma.recordingSession.findUnique({ | |
| where: { episodeId }, | |
| }); | |
| if (session && session.status === "active") { | |
| await this.prisma.recordingSession.update({ | |
| where: { id: session.id }, | |
| data: { status: "completed", endedAt: new Date() }, | |
| }); | |
| this.logger.log(`Marked recording session ${session.id} as completed via LiveKit webhook`); | |
| } | |
| } else { | |
| this.logger.warn(`Could not extract episode ID from room name: ${roomName}`); | |
| } | |
| } | |
| // Server-side recording / egress finished. LiveKit emits egress_ended | |
| // with egressInfo; accept the legacy dotted variants too. | |
| if ( | |
| event === "egress_ended" || | |
| event === "egress_completed" || | |
| event === "recording.completed" || | |
| event === "recording_completed" | |
| ) { | |
| await this.handleLiveKitRecordingFinished(body); | |
| } | |
| // Handle participant events | |
| if (event === "participant_joined" || event === "participant_left") { | |
| const participant = body?.participant; | |
| this.logger.log(`Participant ${participant?.identity} ${event} in room ${roomName}`); | |
| } | |
| return { received: true, event, roomName }; | |
| } | |
| private extractEpisodeIdFromRoom(roomName: string): string | null { | |
| const match = roomName.match(/^episode-(.+)$/); | |
| return match ? match[1] : null; | |
| } | |
| private async handleLiveKitRecordingFinished(body: LiveKitWebhookPayload): Promise<void> { | |
| const egressInfo = body?.recording; | |
| const roomName = egressInfo?.name || body?.room?.name; | |
| if (!roomName) { | |
| this.logger.warn("LiveKit recording/egress event missing room name"); | |
| return; | |
| } | |
| const episodeId = this.extractEpisodeIdFromRoom(roomName); | |
| if (!episodeId) { | |
| this.logger.warn(`Could not extract episode ID from room name: ${roomName}`); | |
| return; | |
| } | |
| const session = await this.prisma.recordingSession.findUnique({ | |
| where: { episodeId }, | |
| }); | |
| if (!session) { | |
| this.logger.warn(`No recording session found for episode ${episodeId}`); | |
| return; | |
| } | |
| if (session.status === "completed" || session.status === "processing") { | |
| this.logger.log(`Session ${session.id} already ${session.status}, skipping`); | |
| return; | |
| } | |
| await this.prisma.recordingSession.update({ | |
| where: { id: session.id }, | |
| data: { status: "processing", endedAt: new Date() }, | |
| }); | |
| this.logger.log( | |
| `Session ${session.id} marked as processing after LiveKit recording finished (${egressInfo?.status ?? "unknown"})`, | |
| ); | |
| } | |
| } |