From c4c62369e56d4d6f89a8d70711a2c47d6b49a92d Mon Sep 17 00:00:00 2001 From: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> Date: Thu, 22 Jan 2026 17:03:34 +0400 Subject: [PATCH] chore: Infrastructure scaffolding for Webhook's Producer/Consumer approach before wiring (#25954) * --INIT * fixes * better comments and some clean up * test and type fix * clean up * address feedback * fix erroneous booking_confirmed to booking_rescheduled --- packages/features/di/tokens.ts | 2 + .../features/di/webhooks/Webhooks.tokens.ts | 13 + .../di/webhooks/containers/webhook.ts | 118 ++++++- .../BookingWebhookDataFetcher.module.ts | 10 + .../modules/FormWebhookDataFetcher.module.ts | 10 + .../modules/OOOWebhookDataFetcher.module.ts | 10 + .../PaymentWebhookDataFetcher.module.ts | 10 + .../RecordingWebhookDataFetcher.module.ts | 10 + .../di/webhooks/modules/Webhook.module.ts | 7 +- .../modules/WebhookProducerService.module.ts | 18 + .../modules/WebhookTaskConsumer.module.ts | 33 ++ packages/features/tasker/tasker.ts | 6 +- packages/features/tasker/tasks/index.ts | 5 + .../features/tasker/tasks/webhookDelivery.ts | 39 +++ .../webhooks/lib/facade/WebhookFeature.ts | 132 +++++++ .../versioned/PayloadBuilderFactory.ts | 39 +-- .../lib/interface/IWebhookDataFetcher.ts | 33 ++ .../lib/interface/WebhookProducerService.ts | 187 ++++++++++ .../webhooks/lib/interface/infrastructure.ts | 11 +- .../webhooks/lib/interface/services.ts | 52 ++- .../lib/repository/WebhookRepository.ts | 34 +- .../lib/service/BookingWebhookService.ts | 15 +- .../webhooks/lib/service/OOOWebhookService.ts | 7 +- .../lib/service/WebhookTaskConsumer.ts | 130 +++++++ .../service/WebhookTaskerProducerService.ts | 224 ++++++++++++ .../__tests__/WebhookTaskConsumer.test.ts | 323 ++++++++++++++++++ .../WebhookTaskerProducerService.test.ts | 284 +++++++++++++++ .../lib/service/__tests__/fixtures.ts | 102 ++++++ .../BookingWebhookDataFetcher.ts | 50 +++ .../data-fetchers/FormWebhookDataFetcher.ts | 40 +++ .../data-fetchers/OOOWebhookDataFetcher.ts | 40 +++ .../PaymentWebhookDataFetcher.ts | 45 +++ .../RecordingWebhookDataFetcher.ts | 56 +++ ...triggerDelegationCredentialErrorWebhook.ts | 2 +- .../webhooks/lib/types/webhookTask.ts | 118 +++++++ 35 files changed, 2124 insertions(+), 91 deletions(-) create mode 100644 packages/features/di/webhooks/modules/BookingWebhookDataFetcher.module.ts create mode 100644 packages/features/di/webhooks/modules/FormWebhookDataFetcher.module.ts create mode 100644 packages/features/di/webhooks/modules/OOOWebhookDataFetcher.module.ts create mode 100644 packages/features/di/webhooks/modules/PaymentWebhookDataFetcher.module.ts create mode 100644 packages/features/di/webhooks/modules/RecordingWebhookDataFetcher.module.ts create mode 100644 packages/features/di/webhooks/modules/WebhookProducerService.module.ts create mode 100644 packages/features/di/webhooks/modules/WebhookTaskConsumer.module.ts create mode 100644 packages/features/tasker/tasks/webhookDelivery.ts create mode 100644 packages/features/webhooks/lib/facade/WebhookFeature.ts create mode 100644 packages/features/webhooks/lib/interface/IWebhookDataFetcher.ts create mode 100644 packages/features/webhooks/lib/interface/WebhookProducerService.ts create mode 100644 packages/features/webhooks/lib/service/WebhookTaskConsumer.ts create mode 100644 packages/features/webhooks/lib/service/WebhookTaskerProducerService.ts create mode 100644 packages/features/webhooks/lib/service/__tests__/WebhookTaskConsumer.test.ts create mode 100644 packages/features/webhooks/lib/service/__tests__/WebhookTaskerProducerService.test.ts create mode 100644 packages/features/webhooks/lib/service/__tests__/fixtures.ts create mode 100644 packages/features/webhooks/lib/service/data-fetchers/BookingWebhookDataFetcher.ts create mode 100644 packages/features/webhooks/lib/service/data-fetchers/FormWebhookDataFetcher.ts create mode 100644 packages/features/webhooks/lib/service/data-fetchers/OOOWebhookDataFetcher.ts create mode 100644 packages/features/webhooks/lib/service/data-fetchers/PaymentWebhookDataFetcher.ts create mode 100644 packages/features/webhooks/lib/service/data-fetchers/RecordingWebhookDataFetcher.ts create mode 100644 packages/features/webhooks/lib/types/webhookTask.ts diff --git a/packages/features/di/tokens.ts b/packages/features/di/tokens.ts index 4812b0f382..3f167eefea 100644 --- a/packages/features/di/tokens.ts +++ b/packages/features/di/tokens.ts @@ -6,6 +6,7 @@ import { HASHED_LINK_DI_TOKENS } from "@calcom/features/hashedLink/di/tokens"; import { OAUTH_DI_TOKENS } from "@calcom/features/oauth/di/tokens"; import { ORGANIZATION_DI_TOKENS } from "@calcom/features/ee/organizations/di/tokens"; import { WATCHLIST_DI_TOKENS } from "./watchlist/Watchlist.tokens"; +import { WEBHOOK_TOKENS } from "./webhooks/Webhooks.tokens"; export const DI_TOKENS = { PRISMA_CLIENT: Symbol("PrismaClient"), @@ -83,4 +84,5 @@ export const DI_TOKENS = { ...OAUTH_DI_TOKENS, ...WATCHLIST_DI_TOKENS, ...ORGANIZATION_DI_TOKENS, + ...WEBHOOK_TOKENS, }; diff --git a/packages/features/di/webhooks/Webhooks.tokens.ts b/packages/features/di/webhooks/Webhooks.tokens.ts index c3ae329222..79fe3bc1be 100644 --- a/packages/features/di/webhooks/Webhooks.tokens.ts +++ b/packages/features/di/webhooks/Webhooks.tokens.ts @@ -13,4 +13,17 @@ export const WEBHOOK_TOKENS = { // Repositories WEBHOOK_REPOSITORY: Symbol("IWebhookRepository"), + + // Producer/Consumer + WEBHOOK_PRODUCER_SERVICE: Symbol("IWebhookProducerService"), + WEBHOOK_PRODUCER_SERVICE_MODULE: Symbol("WebhookProducerService.module"), + WEBHOOK_TASK_CONSUMER: Symbol("WebhookTaskConsumer"), + WEBHOOK_TASK_CONSUMER_MODULE: Symbol("WebhookTaskConsumer.module"), + + // Data Fetchers (Strategy Pattern implementations for WebhookTaskConsumer) + BOOKING_DATA_FETCHER: Symbol("BookingWebhookDataFetcher"), + PAYMENT_DATA_FETCHER: Symbol("PaymentWebhookDataFetcher"), + FORM_DATA_FETCHER: Symbol("FormWebhookDataFetcher"), + RECORDING_DATA_FETCHER: Symbol("RecordingWebhookDataFetcher"), + OOO_DATA_FETCHER: Symbol("OOOWebhookDataFetcher"), } as const; diff --git a/packages/features/di/webhooks/containers/webhook.ts b/packages/features/di/webhooks/containers/webhook.ts index 085afce214..403a9b0914 100644 --- a/packages/features/di/webhooks/containers/webhook.ts +++ b/packages/features/di/webhooks/containers/webhook.ts @@ -1,15 +1,35 @@ -import { createContainer } from "@evyweb/ioctopus"; - +import type { WebhookFeature } from "@calcom/features/webhooks/lib/facade/WebhookFeature"; +import type { IWebhookRepository } from "@calcom/features/webhooks/lib/interface/IWebhookRepository"; +import type { + IBookingWebhookService, + IFormWebhookService, + IOOOWebhookService, + IRecordingWebhookService, + IWebhookService, +} from "@calcom/features/webhooks/lib/interface/services"; +import type { IWebhookProducerService } from "@calcom/features/webhooks/lib/interface/WebhookProducerService"; +import type { IWebhookNotifier } from "@calcom/features/webhooks/lib/interface/webhook"; +import type { WebhookTaskConsumer } from "@calcom/features/webhooks/lib/service/WebhookTaskConsumer"; +import { type Container, createContainer } from "@evyweb/ioctopus"; +import { moduleLoader as prismaModuleLoader } from "../../modules/Prisma"; import { moduleLoader as loggerModuleLoader } from "../../shared/services/logger.service"; import { taskerServiceModule } from "../../shared/services/tasker.service"; import { SHARED_TOKENS } from "../../shared/shared.tokens"; -import { WEBHOOK_TOKENS } from "../Webhooks.tokens"; +import { bookingWebhookDataFetcherModule } from "../modules/BookingWebhookDataFetcher.module"; +import { formWebhookDataFetcherModule } from "../modules/FormWebhookDataFetcher.module"; +import { oooWebhookDataFetcherModule } from "../modules/OOOWebhookDataFetcher.module"; +import { paymentWebhookDataFetcherModule } from "../modules/PaymentWebhookDataFetcher.module"; +import { recordingWebhookDataFetcherModule } from "../modules/RecordingWebhookDataFetcher.module"; import { webhookModule } from "../modules/Webhook.module"; +import { webhookProducerServiceModule } from "../modules/WebhookProducerService.module"; +import { webhookTaskConsumerModule } from "../modules/WebhookTaskConsumer.module"; +import { WEBHOOK_TOKENS } from "../Webhooks.tokens"; -export const webhookContainer = createContainer(); +const webhookContainer: Container = createContainer(); // Load shared infrastructure loggerModuleLoader.loadModule(webhookContainer); +prismaModuleLoader.loadModule(webhookContainer); webhookContainer.load(SHARED_TOKENS.TASKER, taskerServiceModule); // Load webhook module @@ -23,19 +43,87 @@ webhookContainer.load(WEBHOOK_TOKENS.PAYLOAD_BUILDER_FACTORY, webhookModule); webhookContainer.load(WEBHOOK_TOKENS.WEBHOOK_NOTIFICATION_HANDLER, webhookModule); webhookContainer.load(WEBHOOK_TOKENS.WEBHOOK_NOTIFIER, webhookModule); -// Service getters -export function getBookingWebhookService() { - return webhookContainer.get(WEBHOOK_TOKENS.BOOKING_WEBHOOK_SERVICE); +// Load Data Fetchers (Strategy Pattern implementations) +webhookContainer.load(WEBHOOK_TOKENS.BOOKING_DATA_FETCHER, bookingWebhookDataFetcherModule); +webhookContainer.load(WEBHOOK_TOKENS.PAYMENT_DATA_FETCHER, paymentWebhookDataFetcherModule); +webhookContainer.load(WEBHOOK_TOKENS.FORM_DATA_FETCHER, formWebhookDataFetcherModule); +webhookContainer.load(WEBHOOK_TOKENS.RECORDING_DATA_FETCHER, recordingWebhookDataFetcherModule); +webhookContainer.load(WEBHOOK_TOKENS.OOO_DATA_FETCHER, oooWebhookDataFetcherModule); + +// Load Producer/Consumer modules +webhookContainer.load(WEBHOOK_TOKENS.WEBHOOK_PRODUCER_SERVICE, webhookProducerServiceModule); +webhookContainer.load(WEBHOOK_TOKENS.WEBHOOK_TASK_CONSUMER, webhookTaskConsumerModule); + +export { webhookContainer }; + +/** + * Get the Webhook Task Consumer. + * + * This is used internally by the tasker handler (`webhookDelivery.ts`). + * For application code, use `getWebhookFeature().consumer` instead. + */ +export function getWebhookTaskConsumer(): WebhookTaskConsumer { + return webhookContainer.get(WEBHOOK_TOKENS.WEBHOOK_TASK_CONSUMER); } -export function getFormWebhookService() { - return webhookContainer.get(WEBHOOK_TOKENS.FORM_WEBHOOK_SERVICE); +/** + * Get the complete Webhook Feature facade (RECOMMENDED). + * + * This is the primary interface for webhook functionality. + * It provides access to all webhook services through a unified, type-safe API. + * + * Usage: + * ```typescript + * import { getWebhookFeature } from "@calcom/features/di/webhooks/containers/webhook"; + * + * const webhooks = getWebhookFeature(); + * + * // Queue a webhook (lightweight - Producer pattern) + * await webhooks.producer.queueBookingCreatedWebhook({ ... }); + * + * // Or use event-specific services (legacy - will be deprecated in Phase 6) + * await webhooks.booking.emitBookingCreated({ ... }); + * ``` + */ +export function getWebhookFeature(): WebhookFeature { + return { + producer: webhookContainer.get(WEBHOOK_TOKENS.WEBHOOK_PRODUCER_SERVICE), + consumer: webhookContainer.get(WEBHOOK_TOKENS.WEBHOOK_TASK_CONSUMER), + core: webhookContainer.get(WEBHOOK_TOKENS.WEBHOOK_SERVICE), + booking: webhookContainer.get(WEBHOOK_TOKENS.BOOKING_WEBHOOK_SERVICE), + form: webhookContainer.get(WEBHOOK_TOKENS.FORM_WEBHOOK_SERVICE), + recording: webhookContainer.get(WEBHOOK_TOKENS.RECORDING_WEBHOOK_SERVICE), + ooo: webhookContainer.get(WEBHOOK_TOKENS.OOO_WEBHOOK_SERVICE), + notifier: webhookContainer.get(WEBHOOK_TOKENS.WEBHOOK_NOTIFIER), + repository: webhookContainer.get(WEBHOOK_TOKENS.WEBHOOK_REPOSITORY), + }; } -export function getRecordingWebhookService() { - return webhookContainer.get(WEBHOOK_TOKENS.RECORDING_WEBHOOK_SERVICE); -} - -export function getWebhookNotifier() { - return webhookContainer.get(WEBHOOK_TOKENS.WEBHOOK_NOTIFIER); +/** + * Get only the webhook producer service + * + * Use this when you only need to queue webhooks, not consume or manage them. + * Lighter import footprint for better tree-shaking and faster module loading. + * + * Benefits: + * - Interface Segregation Principle: Import only what you need + * - Smaller bundle size: Avoid loading entire facade + * - Clearer intent: "I'm only queueing webhooks" + * + * Usage: + * ```typescript + * import { getWebhookProducer } from "@calcom/features/di/webhooks"; + * + * const producer = getWebhookProducer(); + * await producer.queueBookingCreatedWebhook({ + * bookingUid: booking.uid, + * eventTypeId: eventType.id, + * userId: user.id, + * }); + * ``` + * + * @returns Lightweight webhook producer service (no heavy dependencies) + */ +export function getWebhookProducer(): IWebhookProducerService { + return webhookContainer.get(WEBHOOK_TOKENS.WEBHOOK_PRODUCER_SERVICE); } diff --git a/packages/features/di/webhooks/modules/BookingWebhookDataFetcher.module.ts b/packages/features/di/webhooks/modules/BookingWebhookDataFetcher.module.ts new file mode 100644 index 0000000000..db2ded8e01 --- /dev/null +++ b/packages/features/di/webhooks/modules/BookingWebhookDataFetcher.module.ts @@ -0,0 +1,10 @@ +import { BookingWebhookDataFetcher } from "@calcom/features/webhooks/lib/service/data-fetchers/BookingWebhookDataFetcher"; +import { createModule } from "@evyweb/ioctopus"; +import { SHARED_TOKENS } from "../../shared/shared.tokens"; +import { WEBHOOK_TOKENS } from "../Webhooks.tokens"; + +export const bookingWebhookDataFetcherModule = createModule(); + +bookingWebhookDataFetcherModule + .bind(WEBHOOK_TOKENS.BOOKING_DATA_FETCHER) + .toClass(BookingWebhookDataFetcher, [SHARED_TOKENS.LOGGER]); diff --git a/packages/features/di/webhooks/modules/FormWebhookDataFetcher.module.ts b/packages/features/di/webhooks/modules/FormWebhookDataFetcher.module.ts new file mode 100644 index 0000000000..1747fcbe00 --- /dev/null +++ b/packages/features/di/webhooks/modules/FormWebhookDataFetcher.module.ts @@ -0,0 +1,10 @@ +import { FormWebhookDataFetcher } from "@calcom/features/webhooks/lib/service/data-fetchers/FormWebhookDataFetcher"; +import { createModule } from "@evyweb/ioctopus"; +import { SHARED_TOKENS } from "../../shared/shared.tokens"; +import { WEBHOOK_TOKENS } from "../Webhooks.tokens"; + +export const formWebhookDataFetcherModule = createModule(); + +formWebhookDataFetcherModule + .bind(WEBHOOK_TOKENS.FORM_DATA_FETCHER) + .toClass(FormWebhookDataFetcher, [SHARED_TOKENS.LOGGER]); diff --git a/packages/features/di/webhooks/modules/OOOWebhookDataFetcher.module.ts b/packages/features/di/webhooks/modules/OOOWebhookDataFetcher.module.ts new file mode 100644 index 0000000000..8ee0538d04 --- /dev/null +++ b/packages/features/di/webhooks/modules/OOOWebhookDataFetcher.module.ts @@ -0,0 +1,10 @@ +import { OOOWebhookDataFetcher } from "@calcom/features/webhooks/lib/service/data-fetchers/OOOWebhookDataFetcher"; +import { createModule } from "@evyweb/ioctopus"; +import { SHARED_TOKENS } from "../../shared/shared.tokens"; +import { WEBHOOK_TOKENS } from "../Webhooks.tokens"; + +export const oooWebhookDataFetcherModule = createModule(); + +oooWebhookDataFetcherModule + .bind(WEBHOOK_TOKENS.OOO_DATA_FETCHER) + .toClass(OOOWebhookDataFetcher, [SHARED_TOKENS.LOGGER]); diff --git a/packages/features/di/webhooks/modules/PaymentWebhookDataFetcher.module.ts b/packages/features/di/webhooks/modules/PaymentWebhookDataFetcher.module.ts new file mode 100644 index 0000000000..94ffd83ff6 --- /dev/null +++ b/packages/features/di/webhooks/modules/PaymentWebhookDataFetcher.module.ts @@ -0,0 +1,10 @@ +import { PaymentWebhookDataFetcher } from "@calcom/features/webhooks/lib/service/data-fetchers/PaymentWebhookDataFetcher"; +import { createModule } from "@evyweb/ioctopus"; +import { SHARED_TOKENS } from "../../shared/shared.tokens"; +import { WEBHOOK_TOKENS } from "../Webhooks.tokens"; + +export const paymentWebhookDataFetcherModule = createModule(); + +paymentWebhookDataFetcherModule + .bind(WEBHOOK_TOKENS.PAYMENT_DATA_FETCHER) + .toClass(PaymentWebhookDataFetcher, [SHARED_TOKENS.LOGGER]); diff --git a/packages/features/di/webhooks/modules/RecordingWebhookDataFetcher.module.ts b/packages/features/di/webhooks/modules/RecordingWebhookDataFetcher.module.ts new file mode 100644 index 0000000000..7ad417f7ac --- /dev/null +++ b/packages/features/di/webhooks/modules/RecordingWebhookDataFetcher.module.ts @@ -0,0 +1,10 @@ +import { RecordingWebhookDataFetcher } from "@calcom/features/webhooks/lib/service/data-fetchers/RecordingWebhookDataFetcher"; +import { createModule } from "@evyweb/ioctopus"; +import { SHARED_TOKENS } from "../../shared/shared.tokens"; +import { WEBHOOK_TOKENS } from "../Webhooks.tokens"; + +export const recordingWebhookDataFetcherModule = createModule(); + +recordingWebhookDataFetcherModule + .bind(WEBHOOK_TOKENS.RECORDING_DATA_FETCHER) + .toClass(RecordingWebhookDataFetcher, [SHARED_TOKENS.LOGGER]); diff --git a/packages/features/di/webhooks/modules/Webhook.module.ts b/packages/features/di/webhooks/modules/Webhook.module.ts index e5c61f35d3..5bc552cbeb 100644 --- a/packages/features/di/webhooks/modules/Webhook.module.ts +++ b/packages/features/di/webhooks/modules/Webhook.module.ts @@ -10,13 +10,16 @@ import { WebhookNotificationHandler } from "@calcom/features/webhooks/lib/servic import { WebhookNotifier } from "@calcom/features/webhooks/lib/service/WebhookNotifier"; import { WebhookService } from "@calcom/features/webhooks/lib/service/WebhookService"; +import { DI_TOKENS } from "../../tokens"; import { SHARED_TOKENS } from "../../shared/shared.tokens"; import { WEBHOOK_TOKENS } from "../Webhooks.tokens"; export const webhookModule = createModule(); -// Bind repository -webhookModule.bind(WEBHOOK_TOKENS.WEBHOOK_REPOSITORY).toClass(WebhookRepository); +// Bind repository with Prisma dependency +webhookModule + .bind(WEBHOOK_TOKENS.WEBHOOK_REPOSITORY) + .toClass(WebhookRepository, [DI_TOKENS.PRISMA_CLIENT]); // Bind services webhookModule diff --git a/packages/features/di/webhooks/modules/WebhookProducerService.module.ts b/packages/features/di/webhooks/modules/WebhookProducerService.module.ts new file mode 100644 index 0000000000..1671d0058c --- /dev/null +++ b/packages/features/di/webhooks/modules/WebhookProducerService.module.ts @@ -0,0 +1,18 @@ +import { createModule } from "@evyweb/ioctopus"; + +import { WebhookTaskerProducerService } from "@calcom/features/webhooks/lib/service/WebhookTaskerProducerService"; + +import { SHARED_TOKENS } from "../../shared/shared.tokens"; +import { WEBHOOK_TOKENS } from "../Webhooks.tokens"; + +/** + * Producer Service Module + * + * Binds the lightweight WebhookTaskerProducerService. + * Dependencies: Only Tasker and Logger (no heavy deps). + */ +export const webhookProducerServiceModule = createModule(); + +webhookProducerServiceModule + .bind(WEBHOOK_TOKENS.WEBHOOK_PRODUCER_SERVICE) + .toClass(WebhookTaskerProducerService, [SHARED_TOKENS.TASKER, SHARED_TOKENS.LOGGER]); diff --git a/packages/features/di/webhooks/modules/WebhookTaskConsumer.module.ts b/packages/features/di/webhooks/modules/WebhookTaskConsumer.module.ts new file mode 100644 index 0000000000..e22574c362 --- /dev/null +++ b/packages/features/di/webhooks/modules/WebhookTaskConsumer.module.ts @@ -0,0 +1,33 @@ +import type { IWebhookDataFetcher } from "@calcom/features/webhooks/lib/interface/IWebhookDataFetcher"; +import type { IWebhookRepository } from "@calcom/features/webhooks/lib/interface/IWebhookRepository"; +import type { ILogger } from "@calcom/features/webhooks/lib/interface/infrastructure"; +import { WebhookTaskConsumer } from "@calcom/features/webhooks/lib/service/WebhookTaskConsumer"; +import type { Container } from "@evyweb/ioctopus"; +import { createModule } from "@evyweb/ioctopus"; +import { SHARED_TOKENS } from "../../shared/shared.tokens"; +import { WEBHOOK_TOKENS } from "../Webhooks.tokens"; + +/** + * Consumer Module + * + * Binds the heavy WebhookTaskConsumer. + * Dependencies: WebhookRepository, Data Fetchers array (Strategy Pattern), Logger + * + * Uses Strategy Pattern: Data fetchers are injected as an array, + * consumer uses polymorphism to route to the correct fetcher. + */ +export const webhookTaskConsumerModule = createModule(); + +webhookTaskConsumerModule.bind(WEBHOOK_TOKENS.WEBHOOK_TASK_CONSUMER).toFactory((container: Container) => { + const webhookRepository = container.get(WEBHOOK_TOKENS.WEBHOOK_REPOSITORY); + const dataFetchers: IWebhookDataFetcher[] = [ + container.get(WEBHOOK_TOKENS.BOOKING_DATA_FETCHER), + container.get(WEBHOOK_TOKENS.PAYMENT_DATA_FETCHER), + container.get(WEBHOOK_TOKENS.FORM_DATA_FETCHER), + container.get(WEBHOOK_TOKENS.RECORDING_DATA_FETCHER), + container.get(WEBHOOK_TOKENS.OOO_DATA_FETCHER), + ]; + const logger = container.get(SHARED_TOKENS.LOGGER); + + return new WebhookTaskConsumer(webhookRepository, dataFetchers, logger); +}); diff --git a/packages/features/tasker/tasker.ts b/packages/features/tasker/tasker.ts index 244ef3e7a7..b647ae0533 100644 --- a/packages/features/tasker/tasker.ts +++ b/packages/features/tasker/tasker.ts @@ -1,7 +1,6 @@ -import type { z } from "zod"; - import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils"; import type { BookingAuditTaskConsumerPayload } from "@calcom/features/booking-audit/lib/types/bookingAuditTask"; +import type { z } from "zod"; export type TaskerTypes = "internal" | "redis"; type TaskPayloads = { @@ -43,6 +42,9 @@ type TaskPayloads = { sendAwaitingPaymentEmail: z.infer< typeof import("./tasks/sendAwaitingPaymentEmail").sendAwaitingPaymentEmailPayloadSchema >; + webhookDelivery: z.infer< + typeof import("@calcom/features/webhooks/lib/types/webhookTask").webhookTaskPayloadSchema + >; }; export type TaskTypes = keyof TaskPayloads; export type TaskHandler = (payload: string, taskId?: string) => Promise; diff --git a/packages/features/tasker/tasks/index.ts b/packages/features/tasker/tasks/index.ts index 18215a26f5..a0ad2dabc6 100644 --- a/packages/features/tasker/tasks/index.ts +++ b/packages/features/tasker/tasks/index.ts @@ -34,6 +34,7 @@ const tasks: Record Promise> = { sendAwaitingPaymentEmail: () => import("./sendAwaitingPaymentEmail").then((module) => module.sendAwaitingPaymentEmail), bookingAudit: () => import("./bookingAudit").then((module) => module.bookingAudit), + webhookDelivery: () => import("./webhookDelivery").then((module) => module.webhookDelivery), }; export const tasksConfig = { @@ -44,5 +45,9 @@ export const tasksConfig = { executeAIPhoneCall: { maxAttempts: 1, }, + webhookDelivery: { + minRetryIntervalMins: IS_PRODUCTION ? 5 : 1, + maxAttempts: 3, + }, }; export default tasks; diff --git a/packages/features/tasker/tasks/webhookDelivery.ts b/packages/features/tasker/tasks/webhookDelivery.ts new file mode 100644 index 0000000000..a5c449728a --- /dev/null +++ b/packages/features/tasker/tasks/webhookDelivery.ts @@ -0,0 +1,39 @@ +import { getWebhookTaskConsumer } from "@calcom/features/di/webhooks/containers/webhook"; +import { webhookTaskPayloadSchema } from "@calcom/features/webhooks/lib/types/webhookTask"; +import logger from "@calcom/lib/logger"; + +/** + * Webhook Delivery Task Handler + * + * This task is queued by WebhookTaskerProducerService and processed here. + * It delegates to WebhookTaskConsumer (via DI) which handles the heavy lifting: + * - Fetching webhook subscribers + * - Fetching event-specific data from database + * - Building versioned webhook payloads + * - Sending HTTP requests to subscriber URLs + * + * This handler can be deployed to trigger.dev for scalability. + */ + +const log = logger.getSubLogger({ prefix: ["webhookDelivery"] }); + +export async function webhookDelivery(payload: string, taskId?: string): Promise { + try { + if (!taskId) { + log.error("Task ID is required for webhook delivery consumer", { + taskId, + }); + throw new Error("Task ID is required for webhook delivery consumer"); + } + + const parsedPayload = webhookTaskPayloadSchema.parse(JSON.parse(payload)); + const consumer = getWebhookTaskConsumer(); + await consumer.processWebhookTask(parsedPayload, taskId); + } catch (error) { + log.error("Failed to process webhook delivery task", { + taskId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} diff --git a/packages/features/webhooks/lib/facade/WebhookFeature.ts b/packages/features/webhooks/lib/facade/WebhookFeature.ts new file mode 100644 index 0000000000..31ad7ab626 --- /dev/null +++ b/packages/features/webhooks/lib/facade/WebhookFeature.ts @@ -0,0 +1,132 @@ +import type { IWebhookRepository } from "../interface/IWebhookRepository"; +import type { + IBookingWebhookService, + IFormWebhookService, + IOOOWebhookService, + IRecordingWebhookService, + IWebhookService, +} from "../interface/services"; +import type { IWebhookProducerService } from "../interface/WebhookProducerService"; +import type { IWebhookNotifier } from "../interface/webhook"; +import type { WebhookTaskConsumer } from "../service/WebhookTaskConsumer"; + +/** + * WebhookFeature Facade + * + * Unified, type-safe API surface for the entire Webhooks feature. + * + * This facade provides access to: + * - Producer: Lightweight service for queueing webhook tasks + * - Consumer: Heavy service for processing webhook tasks + * - Core: Low-level webhook service (repository, processing, scheduling) + * - Event-specific services: Booking, Form, Recording, OOO webhooks + * - Notifier: High-level notification handler + * - Repository: Direct data access (use sparingly) + * + * Usage (recommended): + * ```typescript + * import { getWebhookFeature } from "@calcom/features/webhooks/di"; + * + * const webhooks = getWebhookFeature(); + * + * // Queue a webhook (lightweight, fast) + * await webhooks.producer.queueBookingCreatedWebhook({ + * triggerEvent: WebhookTriggerEvents.BOOKING_CREATED, + * bookingUid: booking.uid, + * eventTypeId: eventType.id, + * }); + * + * // Or use event-specific services (direct emission) + * await webhooks.booking.emitBookingCreated({ + * booking, + * eventType, + * evt, + * }); + * ``` + */ +export interface WebhookFeature { + /** + * Producer Service (lightweight) + * + * Queue webhook delivery tasks. No heavy dependencies. + * Use this for async webhook processing via task queue. + */ + producer: IWebhookProducerService; + + /** + * Consumer Service (heavy) + * + * Process webhook delivery tasks from queue. + * Fetches data, builds payloads, sends HTTP requests. + * + * Note: Typically called by task queue handler, not directly. + */ + consumer: WebhookTaskConsumer; + + /** + * Core Webhook Service + * + * Low-level webhook operations: get subscribers, process webhooks, schedule. + * Use this for advanced/custom webhook logic. + */ + core: IWebhookService; + + /** + * Booking Webhook Service + * + * Handle all booking-related webhook events: + * - BOOKING_CREATED + * - BOOKING_REQUESTED (pending confirmation) + * - BOOKING_RESCHEDULED + * - BOOKING_CANCELLED + * - BOOKING_REJECTED + * - BOOKING_PAYMENT_INITIATED + * - BOOKING_PAID + * - BOOKING_NO_SHOW_UPDATED + */ + booking: IBookingWebhookService; + + /** + * Form Webhook Service + * + * Handle form-related webhook events: + * - FORM_SUBMITTED + * - FORM_SUBMITTED_NO_EVENT + */ + form: IFormWebhookService; + + /** + * Recording Webhook Service + * + * Handle recording-related webhook events: + * - RECORDING_READY + * - RECORDING_TRANSCRIPTION_GENERATED + */ + recording: IRecordingWebhookService; + + /** + * Out-of-Office (OOO) Webhook Service + * + * Handle OOO-related webhook events: + * - OOO_CREATED + */ + ooo: IOOOWebhookService; + + /** + * Webhook Notifier + * + * High-level webhook notification handler. + * Orchestrates payload building and delivery. + */ + notifier: IWebhookNotifier; + + /** + * Webhook Repository + * + * @internal + * Direct data access for webhooks. + * Use sparingly - prefer services for business logic. + * Only exposed for advanced use cases and testing. + */ + repository: IWebhookRepository; +} diff --git a/packages/features/webhooks/lib/factory/versioned/PayloadBuilderFactory.ts b/packages/features/webhooks/lib/factory/versioned/PayloadBuilderFactory.ts index f1b55f08af..3ebb0ee7e9 100644 --- a/packages/features/webhooks/lib/factory/versioned/PayloadBuilderFactory.ts +++ b/packages/features/webhooks/lib/factory/versioned/PayloadBuilderFactory.ts @@ -1,8 +1,5 @@ import logger from "@calcom/lib/logger"; import { WebhookTriggerEvents } from "@calcom/prisma/enums"; - -import type { WebhookVersion } from "../../interface/IWebhookRepository"; - import type { AfterGuestsNoShowDTO, AfterHostsNoShowDTO, @@ -18,14 +15,11 @@ import type { TranscriptionGeneratedDTO, WebhookEventDTO, } from "../../dto/types"; +import type { WebhookVersion } from "../../interface/IWebhookRepository"; import type { WebhookPayload } from "../types"; const log = logger.getSubLogger({ prefix: ["WebhookPayloadBuilderFactory"] }); -/** - * Generic base interface for all payload builders - * Ensures type-safe input DTOs and output payloads - */ export interface IPayloadBuilder { build(dto: TInput): WebhookPayload; } @@ -65,10 +59,6 @@ export interface IDelegationPayloadBuilder extends IPayloadBuilder [WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR]: "delegation", }; -/** - * Type mapping: TriggerEvent → DTO type - * Used for compile-time type safety - * - * Note: WebhookTriggerEvents is a const object, so we use typeof to extract literal types - */ -type BookingTriggerEvents = +export type BookingTriggerEvents = | typeof WebhookTriggerEvents.BOOKING_CREATED | typeof WebhookTriggerEvents.BOOKING_RESCHEDULED | typeof WebhookTriggerEvents.BOOKING_CANCELLED @@ -141,25 +122,29 @@ type BookingTriggerEvents = | typeof WebhookTriggerEvents.BOOKING_PAID | typeof WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED; -type DelegationTriggerEvents = typeof WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR; +export type PaymentTriggerEvents = + | typeof WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED + | typeof WebhookTriggerEvents.BOOKING_PAID; -type FormTriggerEvents = +export type DelegationTriggerEvents = typeof WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR; + +export type FormTriggerEvents = | typeof WebhookTriggerEvents.FORM_SUBMITTED | typeof WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT; -type OOOTriggerEvents = typeof WebhookTriggerEvents.OOO_CREATED; +export type OOOTriggerEvents = typeof WebhookTriggerEvents.OOO_CREATED; -type RecordingTriggerEvents = +export type RecordingTriggerEvents = | typeof WebhookTriggerEvents.RECORDING_READY | typeof WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED; -type MeetingTriggerEvents = +export type MeetingTriggerEvents = | typeof WebhookTriggerEvents.MEETING_STARTED | typeof WebhookTriggerEvents.MEETING_ENDED | typeof WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW | typeof WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW; -type InstantMeetingTriggerEvents = typeof WebhookTriggerEvents.INSTANT_MEETING; +export type InstantMeetingTriggerEvents = typeof WebhookTriggerEvents.INSTANT_MEETING; /** * Factory that routes to version-specific payload builders diff --git a/packages/features/webhooks/lib/interface/IWebhookDataFetcher.ts b/packages/features/webhooks/lib/interface/IWebhookDataFetcher.ts new file mode 100644 index 0000000000..754b31180b --- /dev/null +++ b/packages/features/webhooks/lib/interface/IWebhookDataFetcher.ts @@ -0,0 +1,33 @@ +import type { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import type { WebhookTaskPayload } from "../types/webhookTask"; + +export interface SubscriberContext { + triggerEvent: WebhookTriggerEvents; + userId?: number; + eventTypeId?: number; + teamId?: number | null; + orgId?: number; + oAuthClientId?: string | null; +} + +/** + * Strategy interface for fetching webhook event data + * + * Each webhook category (booking, form, recording, etc.) implements this interface + * to provide domain-specific data fetching logic. + * + * Benefits: + * - Open/Closed: Add new webhook types without modifying consumer + * - Single Responsibility: Each fetcher owns one domain's logic + * - Dependency Inversion: Consumer depends on this interface, not concrete implementations + * + * Note: Uses WebhookTaskPayload (discriminated union) directly instead of generics + * since type narrowing happens at runtime via canHandle(). + */ +export interface IWebhookDataFetcher { + canHandle(triggerEvent: WebhookTriggerEvents): boolean; + + fetchEventData(payload: WebhookTaskPayload): Promise | null>; + + getSubscriberContext(payload: WebhookTaskPayload): SubscriberContext; +} diff --git a/packages/features/webhooks/lib/interface/WebhookProducerService.ts b/packages/features/webhooks/lib/interface/WebhookProducerService.ts new file mode 100644 index 0000000000..47155c281b --- /dev/null +++ b/packages/features/webhooks/lib/interface/WebhookProducerService.ts @@ -0,0 +1,187 @@ +/** + * Base parameters common to all webhook queue operations + */ +interface BaseQueueWebhookParams { + /** Unique identifier for this webhook operation (generated if not provided) */ + operationId?: string; + + /** Additional context data (kept minimal) */ + metadata?: Record; +} + +/** + * Parameters for queueing booking-related webhooks + * Used for: BOOKING_CREATED, BOOKING_CANCELLED, BOOKING_RESCHEDULED, + * BOOKING_REQUESTED, BOOKING_REJECTED, BOOKING_NO_SHOW_UPDATED + */ +export interface QueueBookingWebhookParams extends BaseQueueWebhookParams { + /** Booking UID (required) */ + bookingUid: string; + + /** Event Type ID */ + eventTypeId?: number; + + /** Team ID */ + teamId?: number | null; + + /** User ID */ + userId?: number; + + /** Organization ID */ + orgId?: number; + + /** OAuth Client ID (for platform webhooks) */ + oAuthClientId?: string | null; +} + +/** + * Parameters for queueing payment-related webhooks + * Used for: BOOKING_PAYMENT_INITIATED, BOOKING_PAID + */ +export interface QueuePaymentWebhookParams extends BaseQueueWebhookParams { + /** Booking UID (required) */ + bookingUid: string; + + /** Event Type ID */ + eventTypeId?: number; + + /** Team ID */ + teamId?: number | null; + + /** User ID */ + userId?: number; + + /** Organization ID */ + orgId?: number; + + /** OAuth Client ID (for platform webhooks) */ + oAuthClientId?: string | null; +} + +/** + * Parameters for queueing form-related webhooks + * Used for: FORM_SUBMITTED + */ +export interface QueueFormWebhookParams extends BaseQueueWebhookParams { + /** Form ID (required) */ + formId: string; + + /** Team ID */ + teamId?: number | null; + + /** User ID */ + userId?: number; + + /** OAuth Client ID (for platform webhooks) */ + oAuthClientId?: string | null; +} + +/** + * Parameters for queueing recording-related webhooks + * Used for: RECORDING_READY, RECORDING_TRANSCRIPTION_GENERATED + */ +export interface QueueRecordingWebhookParams extends BaseQueueWebhookParams { + /** Recording ID (required) */ + recordingId: string; + + /** Booking UID (required) */ + bookingUid: string; + + /** Event Type ID */ + eventTypeId?: number; + + /** Team ID */ + teamId?: number | null; + + /** User ID */ + userId?: number; + + /** OAuth Client ID (for platform webhooks) */ + oAuthClientId?: string | null; +} + +/** + * Parameters for queueing OOO-related webhooks + * Used for: OOO_CREATED + */ +export interface QueueOOOWebhookParams extends BaseQueueWebhookParams { + /** OOO Entry ID (required) */ + oooEntryId: number; + + /** User ID (required) */ + userId: number; + + /** Team ID */ + teamId?: number | null; + + /** OAuth Client ID (for platform webhooks) */ + oAuthClientId?: string | null; +} + +/** + * Lightweight Producer Service for queueing webhook delivery tasks. + * + * This service has NO heavy dependencies (no Prisma, no repositories). + * It only queues tasks via Tasker, which will be processed by WebhookTaskConsumer. + * + * This allows the producer to stay in the main app while the consumer + * can be deployed to trigger.dev for scalability. + */ +export interface IWebhookProducerService { + /** + * Queue a webhook delivery task for BOOKING_CREATED event + */ + queueBookingCreatedWebhook(params: QueueBookingWebhookParams): Promise; + + /** + * Queue a webhook delivery task for BOOKING_CANCELLED event + */ + queueBookingCancelledWebhook(params: QueueBookingWebhookParams): Promise; + + /** + * Queue a webhook delivery task for BOOKING_RESCHEDULED event + */ + queueBookingRescheduledWebhook(params: QueueBookingWebhookParams): Promise; + + /** + * Queue a webhook delivery task for BOOKING_REQUESTED event + * + * Note: This fires when bookings require confirmation (status = PENDING) + */ + queueBookingRequestedWebhook(params: QueueBookingWebhookParams): Promise; + + /** + * Queue a webhook delivery task for BOOKING_REJECTED event + */ + queueBookingRejectedWebhook(params: QueueBookingWebhookParams): Promise; + + /** + * Queue a webhook delivery task for BOOKING_PAYMENT_INITIATED event + */ + queueBookingPaymentInitiatedWebhook(params: QueuePaymentWebhookParams): Promise; + + /** + * Queue a webhook delivery task for BOOKING_PAID event + */ + queueBookingPaidWebhook(params: QueuePaymentWebhookParams): Promise; + + /** + * Queue a webhook delivery task for BOOKING_NO_SHOW_UPDATED event + */ + queueBookingNoShowUpdatedWebhook(params: QueueBookingWebhookParams): Promise; + + /** + * Queue a webhook delivery task for FORM_SUBMITTED event + */ + queueFormSubmittedWebhook(params: QueueFormWebhookParams): Promise; + + /** + * Queue a webhook delivery task for RECORDING_READY event + */ + queueRecordingReadyWebhook(params: QueueRecordingWebhookParams): Promise; + + /** + * Queue a webhook delivery task for OOO_CREATED event + */ + queueOOOCreatedWebhook(params: QueueOOOWebhookParams): Promise; +} diff --git a/packages/features/webhooks/lib/interface/infrastructure.ts b/packages/features/webhooks/lib/interface/infrastructure.ts index ffdcbec901..88a5d4c4c4 100644 --- a/packages/features/webhooks/lib/interface/infrastructure.ts +++ b/packages/features/webhooks/lib/interface/infrastructure.ts @@ -1,11 +1,6 @@ -export interface ITasker { - create( - taskName: string, - payload: string, - options?: { scheduledAt?: Date; referenceUid?: string } - ): Promise; - cancelWithReference(referenceUid: string, taskName: string): Promise; -} +import type { Tasker } from "@calcom/features/tasker/tasker"; + +export type ITasker = Tasker; export interface ILogger { debug(message: string, meta?: Record): void; diff --git a/packages/features/webhooks/lib/interface/services.ts b/packages/features/webhooks/lib/interface/services.ts index 930b61a3ec..2f7e526fae 100644 --- a/packages/features/webhooks/lib/interface/services.ts +++ b/packages/features/webhooks/lib/interface/services.ts @@ -1,18 +1,17 @@ import type { WebhookTriggerEvents } from "@calcom/prisma/enums"; - import type { WebhookSubscriber } from "../dto/types"; import type { WebhookPayload } from "../factory/types"; import type { - BookingPaymentInitiatedParams, - BookingCreatedParams, BookingCancelledParams, + BookingCreatedParams, + BookingNoShowParams, + BookingPaidParams, + BookingPaymentInitiatedParams, + BookingRejectedParams, BookingRequestedParams, BookingRescheduledParams, - BookingPaidParams, - BookingNoShowParams, - BookingRejectedParams, - ScheduleMeetingWebhooksParams, CancelScheduledMeetingWebhooksParams, + ScheduleMeetingWebhooksParams, ScheduleNoShowWebhooksParams, } from "../types/params"; @@ -168,3 +167,42 @@ export interface IRecordingWebhookService { isDryRun?: boolean; }): Promise; } + +// OOO Webhook Service Interface - Out-of-Office webhook operations +export interface IOOOWebhookService { + emitOOOCreated(params: { + oooEntry: { + id: number; + start: string; + end: string; + createdAt: string; + updatedAt: string; + notes: string | null; + reason: { + emoji?: string; + reason?: string; + }; + reasonId: number; + user: { + id: number; + name: string | null; + username: string | null; + timeZone: string; + email: string; + }; + toUser: { + id: number; + name?: string | null; + username?: string | null; + email?: string; + timeZone?: string; + } | null; + uuid: string; + }; + userId?: number | null; + teamId?: number | null; + orgId?: number | null; + platformClientId?: string; + isDryRun?: boolean; + }): Promise; +} diff --git a/packages/features/webhooks/lib/repository/WebhookRepository.ts b/packages/features/webhooks/lib/repository/WebhookRepository.ts index 4e472c88a6..cebcbce462 100644 --- a/packages/features/webhooks/lib/repository/WebhookRepository.ts +++ b/packages/features/webhooks/lib/repository/WebhookRepository.ts @@ -1,18 +1,19 @@ import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; -import { withReporting } from "@calcom/lib/sentryWrapper"; -import { prisma as defaultPrisma } from "@calcom/prisma"; import type { PrismaClient } from "@calcom/prisma"; +import { prisma as defaultPrisma } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; import type { TimeUnit, WebhookTriggerEvents } from "@calcom/prisma/enums"; -import { UserPermissionRole, MembershipRole } from "@calcom/prisma/enums"; - -import { parseWebhookVersion } from "../interface/IWebhookRepository"; - -import type { Webhook, WebhookSubscriber, WebhookGroup } from "../dto/types"; -import type { IWebhookRepository, WebhookVersion, ListWebhooksOptions } from "../interface/IWebhookRepository"; +import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums"; +import type { Webhook, WebhookGroup, WebhookSubscriber } from "../dto/types"; import { WebhookOutputMapper } from "../infrastructure/mappers/WebhookOutputMapper"; +import type { + IWebhookRepository, + ListWebhooksOptions, + WebhookVersion, +} from "../interface/IWebhookRepository"; +import { parseWebhookVersion } from "../interface/IWebhookRepository"; import type { GetSubscribersOptions } from "./types"; // Type for raw query results from the database @@ -29,8 +30,6 @@ interface WebhookQueryResult { priority: number; // This field is added by the query and removed before returning } - - const filterWebhooks = (webhook: { appId: string | null }) => { const appIds = [ "zapier", @@ -42,13 +41,17 @@ const filterWebhooks = (webhook: { appId: string | null }) => { }; export class WebhookRepository implements IWebhookRepository { - constructor(private prisma: PrismaClient = defaultPrisma) {} - private static _instance: WebhookRepository; + constructor(private readonly prisma: typeof defaultPrisma = defaultPrisma) {} + + /** + * Singleton accessor for backward compatibility. + * @deprecated Use DI container (getWebhookFeature().repository) instead + */ static getInstance(): WebhookRepository { if (!WebhookRepository._instance) { - WebhookRepository._instance = new WebhookRepository(); + WebhookRepository._instance = new WebhookRepository(defaultPrisma); } return WebhookRepository._instance; } @@ -583,8 +586,3 @@ export class WebhookRepository implements IWebhookRepository { return WebhookOutputMapper.toWebhookList(webhooks); } } - -export const webhookRepository = withReporting( - (options: GetSubscribersOptions) => WebhookRepository.getInstance().getSubscribers(options), - "WebhookRepository.getSubscribers" -); diff --git a/packages/features/webhooks/lib/service/BookingWebhookService.ts b/packages/features/webhooks/lib/service/BookingWebhookService.ts index 8cfb3c7d1a..86f146779e 100644 --- a/packages/features/webhooks/lib/service/BookingWebhookService.ts +++ b/packages/features/webhooks/lib/service/BookingWebhookService.ts @@ -13,8 +13,9 @@ import type { BookingRejectedDTO, WebhookSubscriber, } from "../dto/types"; -import type { IWebhookNotifier, IWebhookService, ITasker, IBookingWebhookService } from "../interface"; -import type { ILogger } from "../interface/infrastructure"; +import type { IWebhookService, IBookingWebhookService } from "../interface/services"; +import type { ITasker, ILogger } from "../interface/infrastructure"; +import type { IWebhookNotifier } from "../interface/webhook"; import type { BookingCreatedParams, BookingCancelledParams, @@ -27,7 +28,7 @@ import type { ScheduleMeetingWebhooksParams, CancelScheduledMeetingWebhooksParams, ScheduleNoShowWebhooksParams, -} from "../types"; +} from "../types/params"; export class BookingWebhookService implements IBookingWebhookService { private readonly log: ILogger; @@ -361,11 +362,11 @@ export class BookingWebhookService implements IBookingWebhookService { return tasker.create( "triggerHostNoShowWebhook", - JSON.stringify({ + { triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, bookingId: params.booking.id, webhook: { ...webhook, time: webhook.time, timeUnit: webhook.timeUnit as TimeUnit }, - }), + }, { scheduledAt } ); } @@ -392,11 +393,11 @@ export class BookingWebhookService implements IBookingWebhookService { return tasker.create( "triggerGuestNoShowWebhook", - JSON.stringify({ + { triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, bookingId: params.booking.id, webhook: { ...webhook, time: webhook.time, timeUnit: webhook.timeUnit as TimeUnit }, - }), + }, { scheduledAt } ); } diff --git a/packages/features/webhooks/lib/service/OOOWebhookService.ts b/packages/features/webhooks/lib/service/OOOWebhookService.ts index 498dcb5568..d6ef947077 100644 --- a/packages/features/webhooks/lib/service/OOOWebhookService.ts +++ b/packages/features/webhooks/lib/service/OOOWebhookService.ts @@ -1,12 +1,11 @@ import { WebhookTriggerEvents } from "@calcom/prisma/enums"; - import type { OOOCreatedDTO } from "../dto/types"; -import type { ITasker, ILogger } from "../interface/infrastructure"; -import type { IWebhookRepository } from "../interface/services"; +import type { ILogger, ITasker } from "../interface/infrastructure"; +import type { IOOOWebhookService, IWebhookRepository } from "../interface/services"; import type { IWebhookNotifier } from "../interface/webhook"; import { WebhookService } from "./WebhookService"; -export class OOOWebhookService extends WebhookService { +export class OOOWebhookService extends WebhookService implements IOOOWebhookService { constructor( private readonly notifier: IWebhookNotifier, repository: IWebhookRepository, diff --git a/packages/features/webhooks/lib/service/WebhookTaskConsumer.ts b/packages/features/webhooks/lib/service/WebhookTaskConsumer.ts new file mode 100644 index 0000000000..9f531e047f --- /dev/null +++ b/packages/features/webhooks/lib/service/WebhookTaskConsumer.ts @@ -0,0 +1,130 @@ +import type { IWebhookDataFetcher } from "../interface/IWebhookDataFetcher"; +import type { IWebhookRepository } from "../interface/IWebhookRepository"; +import type { ILogger } from "../interface/infrastructure"; +import type { WebhookTaskPayload } from "../types/webhookTask"; + +/** + * Webhook Task Consumer + * + * Processes webhook delivery tasks from the queue: + * 1. Fetches webhook subscribers + * 2. Fetches event-specific data from database (via injected data fetchers) + * 3. Builds and sends webhook payloads + * + * Architecture: + * - Uses Strategy Pattern: Data fetchers are injected, consumer orchestrates + * - Open/Closed: Add new webhook types by registering fetchers, no code modification + * - Single Responsibility: Consumer orchestrates, fetchers handle domain logic + * - Dependency Inversion: Depends on IWebhookDataFetcher interface + * + * Phase 0: Scaffold with placeholders for HTTP delivery + * Phase 1+: Full implementation with PayloadBuilders and HTTP client + */ +export class WebhookTaskConsumer { + private readonly log: ILogger; + + constructor( + private readonly webhookRepository: IWebhookRepository, + private readonly dataFetchers: IWebhookDataFetcher[], + logger: ILogger + ) { + this.log = logger.getSubLogger({ prefix: ["[WebhookTaskConsumer]"] }); + } + + /** + * Main entry point for processing webhook delivery tasks. + */ + async processWebhookTask(payload: WebhookTaskPayload, taskId: string): Promise { + this.log.info("Processing webhook delivery task", { + operationId: payload.operationId, + taskId, + triggerEvent: payload.triggerEvent, + }); + + try { + // Step 1: Get the appropriate data fetcher for this trigger event + const fetcher = this.getDataFetcher(payload.triggerEvent); + + if (!fetcher) { + this.log.error("No data fetcher found for trigger event", { + operationId: payload.operationId, + triggerEvent: payload.triggerEvent, + }); + throw new Error(`No data fetcher registered for trigger event: ${payload.triggerEvent}`); + } + + // Step 2: Fetch webhook subscribers + const subscriberContext = fetcher.getSubscriberContext(payload); + const subscribers = await this.webhookRepository.getSubscribers(subscriberContext); + + if (subscribers.length === 0) { + this.log.info("No webhook subscribers found", { operationId: payload.operationId }); + return; + } + + this.log.debug(`Found ${subscribers.length} webhook subscriber(s)`, { + operationId: payload.operationId, + }); + + // Step 3: Fetch event-specific data via data fetcher + const eventData = await fetcher.fetchEventData(payload); + + if (!eventData) { + this.log.warn("Event data not found", { + operationId: payload.operationId, + triggerEvent: payload.triggerEvent, + }); + return; + } + + // Step 4: Build and send webhooks to each subscriber + await this.sendWebhooksToSubscribers(subscribers, eventData, payload); + + this.log.info("Webhook delivery task completed", { + operationId: payload.operationId, + subscriberCount: subscribers.length, + }); + } catch (error) { + this.log.error("Failed to process webhook delivery task", { + operationId: payload.operationId, + taskId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + /** + * Get the appropriate data fetcher for the trigger event. + * + * Uses polymorphism via canHandle() method - each fetcher knows which events it handles. + */ + private getDataFetcher(triggerEvent: string): IWebhookDataFetcher | null { + return this.dataFetchers.find((fetcher) => fetcher.canHandle(triggerEvent as never)) || null; + } + + /** + * Build webhook payloads and send to each subscriber. + * + * TODO: Implement payload building using PayloadBuilders and HTTP sending. + * For Phase 0, this is a scaffold showing the pattern. + */ + private async sendWebhooksToSubscribers( + subscribers: unknown[], + eventData: Record, + payload: WebhookTaskPayload + ): Promise { + // TODO: For each subscriber: + // 1. Build versioned payload using PayloadBuilders + // 2. Send HTTP request to subscriber.subscriberUrl + // 3. Handle retries/failures + // 4. Log delivery status + + this.log.debug("Webhook sending not implemented yet (Phase 0 scaffold)", { + subscriberCount: subscribers.length, + triggerEvent: payload.triggerEvent, + }); + + // This will be implemented when we add PayloadBuilders and HTTP client dependencies + } +} diff --git a/packages/features/webhooks/lib/service/WebhookTaskerProducerService.ts b/packages/features/webhooks/lib/service/WebhookTaskerProducerService.ts new file mode 100644 index 0000000000..319993d9a5 --- /dev/null +++ b/packages/features/webhooks/lib/service/WebhookTaskerProducerService.ts @@ -0,0 +1,224 @@ +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import { v4 as uuidv4 } from "uuid"; +import type { BookingTriggerEvents, PaymentTriggerEvents } from "../factory/versioned/PayloadBuilderFactory"; +import type { ILogger, ITasker } from "../interface/infrastructure"; +import type { + IWebhookProducerService, + QueueBookingWebhookParams, + QueueFormWebhookParams, + QueueOOOWebhookParams, + QueuePaymentWebhookParams, + QueueRecordingWebhookParams, +} from "../interface/WebhookProducerService"; +import type { WebhookTaskPayload } from "../types/webhookTask"; + +/** + * Lightweight Producer Service for webhook delivery. + * + * DEPENDENCIES: Only Tasker and Logger (no Prisma, no repositories) + * + * This service queues minimal webhook tasks to be processed by WebhookTaskConsumer. + * The consumer handles the heavy lifting (DB queries, payload building, HTTP delivery). + */ +export class WebhookTaskerProducerService implements IWebhookProducerService { + private readonly log: ILogger; + + constructor( + private readonly tasker: ITasker, + logger: ILogger + ) { + this.log = logger.getSubLogger({ prefix: ["[WebhookTaskerProducerService]"] }); + } + + async queueBookingCreatedWebhook(params: QueueBookingWebhookParams): Promise { + await this.queueBookingWebhook(WebhookTriggerEvents.BOOKING_CREATED, params); + } + + async queueBookingCancelledWebhook(params: QueueBookingWebhookParams): Promise { + await this.queueBookingWebhook(WebhookTriggerEvents.BOOKING_CANCELLED, params); + } + + async queueBookingRescheduledWebhook(params: QueueBookingWebhookParams): Promise { + await this.queueBookingWebhook(WebhookTriggerEvents.BOOKING_RESCHEDULED, params); + } + + /** + * Queue a webhook for requested bookings. + * + * This fires when bookings require confirmation (status = PENDING). + */ + async queueBookingRequestedWebhook(params: QueueBookingWebhookParams): Promise { + await this.queueBookingWebhook(WebhookTriggerEvents.BOOKING_REQUESTED, params); + } + + async queueBookingRejectedWebhook(params: QueueBookingWebhookParams): Promise { + await this.queueBookingWebhook(WebhookTriggerEvents.BOOKING_REJECTED, params); + } + + async queueBookingPaymentInitiatedWebhook(params: QueuePaymentWebhookParams): Promise { + await this.queuePaymentWebhook(WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED, params); + } + + async queueBookingPaidWebhook(params: QueuePaymentWebhookParams): Promise { + await this.queuePaymentWebhook(WebhookTriggerEvents.BOOKING_PAID, params); + } + + async queueBookingNoShowUpdatedWebhook(params: QueueBookingWebhookParams): Promise { + await this.queueBookingWebhook(WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED, params); + } + + async queueFormSubmittedWebhook(params: QueueFormWebhookParams): Promise { + const operationId = params.operationId || uuidv4(); + + this.log.info("Queueing form webhook task", { + operationId, + triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED, + formId: params.formId, + }); + + const taskPayload: WebhookTaskPayload = { + operationId, + triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED, + formId: params.formId, + teamId: params.teamId, + userId: params.userId, + oAuthClientId: params.oAuthClientId, + metadata: params.metadata, + timestamp: new Date().toISOString(), + }; + + await this.queueTask(operationId, taskPayload); + } + + async queueRecordingReadyWebhook(params: QueueRecordingWebhookParams): Promise { + const operationId = params.operationId || uuidv4(); + + this.log.info("Queueing recording webhook task", { + operationId, + triggerEvent: WebhookTriggerEvents.RECORDING_READY, + recordingId: params.recordingId, + bookingUid: params.bookingUid, + }); + + const taskPayload: WebhookTaskPayload = { + operationId, + triggerEvent: WebhookTriggerEvents.RECORDING_READY, + recordingId: params.recordingId, + bookingUid: params.bookingUid, + eventTypeId: params.eventTypeId, + teamId: params.teamId, + userId: params.userId, + oAuthClientId: params.oAuthClientId, + metadata: params.metadata, + timestamp: new Date().toISOString(), + }; + + await this.queueTask(operationId, taskPayload); + } + + async queueOOOCreatedWebhook(params: QueueOOOWebhookParams): Promise { + const operationId = params.operationId || uuidv4(); + + this.log.info("Queueing OOO webhook task", { + operationId, + triggerEvent: WebhookTriggerEvents.OOO_CREATED, + oooEntryId: params.oooEntryId, + userId: params.userId, + }); + + const taskPayload: WebhookTaskPayload = { + operationId, + triggerEvent: WebhookTriggerEvents.OOO_CREATED, + oooEntryId: params.oooEntryId, + userId: params.userId, + teamId: params.teamId, + oAuthClientId: params.oAuthClientId, + metadata: params.metadata, + timestamp: new Date().toISOString(), + }; + + await this.queueTask(operationId, taskPayload); + } + + /** + * Internal helper to queue booking-related webhooks + */ + private async queueBookingWebhook( + triggerEvent: Exclude< + BookingTriggerEvents, + typeof WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED | typeof WebhookTriggerEvents.BOOKING_PAID + >, + params: QueueBookingWebhookParams + ): Promise { + const operationId = params.operationId || uuidv4(); + + this.log.info("Queueing booking webhook task", { + operationId, + triggerEvent, + bookingUid: params.bookingUid, + eventTypeId: params.eventTypeId, + }); + + const taskPayload: WebhookTaskPayload = { + operationId, + triggerEvent, + bookingUid: params.bookingUid, + eventTypeId: params.eventTypeId, + teamId: params.teamId, + userId: params.userId, + orgId: params.orgId, + oAuthClientId: params.oAuthClientId, + metadata: params.metadata, + timestamp: new Date().toISOString(), + }; + + await this.queueTask(operationId, taskPayload); + } + + /** + * Internal helper to queue payment-related webhooks + */ + private async queuePaymentWebhook( + triggerEvent: PaymentTriggerEvents, + params: QueuePaymentWebhookParams + ): Promise { + const operationId = params.operationId || uuidv4(); + + this.log.info("Queueing payment webhook task", { + operationId, + triggerEvent, + bookingUid: params.bookingUid, + }); + + const taskPayload: WebhookTaskPayload = { + operationId, + triggerEvent, + bookingUid: params.bookingUid, + eventTypeId: params.eventTypeId, + teamId: params.teamId, + userId: params.userId, + orgId: params.orgId, + oAuthClientId: params.oAuthClientId, + metadata: params.metadata, + timestamp: new Date().toISOString(), + }; + + await this.queueTask(operationId, taskPayload); + } + + /** + * Internal helper to queue task via Tasker + */ + private async queueTask(operationId: string, taskPayload: WebhookTaskPayload): Promise { + try { + await this.tasker.create("webhookDelivery", taskPayload); + this.log.info("Webhook delivery task queued successfully", { operationId }); + } catch (error) { + this.log.error("Failed to queue webhook delivery task", { + operationId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } +} diff --git a/packages/features/webhooks/lib/service/__tests__/WebhookTaskConsumer.test.ts b/packages/features/webhooks/lib/service/__tests__/WebhookTaskConsumer.test.ts new file mode 100644 index 0000000000..d748ca42ef --- /dev/null +++ b/packages/features/webhooks/lib/service/__tests__/WebhookTaskConsumer.test.ts @@ -0,0 +1,323 @@ +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { IWebhookDataFetcher } from "../../interface/IWebhookDataFetcher"; +import type { IWebhookRepository } from "../../interface/IWebhookRepository"; +import { WebhookVersion } from "../../interface/IWebhookRepository"; +import type { ILogger } from "../../interface/infrastructure"; +import type { WebhookTaskPayload } from "../../types/webhookTask"; +import { WebhookTaskConsumer } from "../WebhookTaskConsumer"; + +/** + * Unit Tests for WebhookTaskConsumer + * + * Tests the heavy Consumer service for processing webhook delivery tasks. + * + */ +describe("WebhookTaskConsumer", () => { + let consumer: WebhookTaskConsumer; + let mockWebhookRepository: IWebhookRepository; + let mockDataFetchers: IWebhookDataFetcher[]; + let mockLogger: ILogger; + + beforeEach(() => { + // Mock Repository + mockWebhookRepository = { + getSubscribers: vi.fn().mockResolvedValue([]), + getWebhookById: vi.fn(), + findByWebhookId: vi.fn(), + findByOrgIdAndTrigger: vi.fn(), + getFilteredWebhooksForUser: vi.fn(), + } as unknown as IWebhookRepository; + + // Mock Data Fetchers (Strategy Pattern implementations) + const createMockFetcher = (triggerEvents: string[]): IWebhookDataFetcher => ({ + canHandle: vi.fn((event) => triggerEvents.includes(event)), + fetchEventData: vi.fn().mockResolvedValue({ _scaffold: true }), + getSubscriberContext: vi.fn((payload: WebhookTaskPayload) => ({ + triggerEvent: payload.triggerEvent, + userId: "userId" in payload ? payload.userId : undefined, + eventTypeId: "eventTypeId" in payload ? payload.eventTypeId : undefined, + teamId: "teamId" in payload ? payload.teamId : undefined, + orgId: "orgId" in payload ? payload.orgId : undefined, + oAuthClientId: "oAuthClientId" in payload ? payload.oAuthClientId : undefined, + })), + }); + + mockDataFetchers = [ + createMockFetcher([ + WebhookTriggerEvents.BOOKING_CREATED, + WebhookTriggerEvents.BOOKING_CANCELLED, + WebhookTriggerEvents.BOOKING_RESCHEDULED, + WebhookTriggerEvents.BOOKING_REQUESTED, + WebhookTriggerEvents.BOOKING_REJECTED, + WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED, + ]), + createMockFetcher([WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED, WebhookTriggerEvents.BOOKING_PAID]), + createMockFetcher([WebhookTriggerEvents.FORM_SUBMITTED]), + createMockFetcher([ + WebhookTriggerEvents.RECORDING_READY, + WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED, + ]), + createMockFetcher([WebhookTriggerEvents.OOO_CREATED]), + ]; + + // Mock Logger + mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + getSubLogger: vi.fn().mockReturnThis(), + } as unknown as ILogger; + + consumer = new WebhookTaskConsumer(mockWebhookRepository, mockDataFetchers, mockLogger); + }); + + describe("Constructor & Dependencies", () => { + it("should be instantiable with Repository, Data Fetchers, and Logger", () => { + expect(consumer).toBeInstanceOf(WebhookTaskConsumer); + }); + + it("should create sub-logger with prefix", () => { + expect(mockLogger.getSubLogger).toHaveBeenCalledWith({ + prefix: ["[WebhookTaskConsumer]"], + }); + }); + }); + + describe("processWebhookTask - Basic Flow", () => { + it("should process a webhook task with no subscribers", async () => { + const payload: WebhookTaskPayload = { + operationId: "op-123", + triggerEvent: WebhookTriggerEvents.BOOKING_CREATED, + bookingUid: "booking-123", + eventTypeId: 456, + userId: 789, + timestamp: new Date().toISOString(), + }; + + await consumer.processWebhookTask(payload, "task-123"); + + expect(mockLogger.info).toHaveBeenCalledWith( + "Processing webhook delivery task", + expect.objectContaining({ + operationId: "op-123", + taskId: "task-123", + triggerEvent: WebhookTriggerEvents.BOOKING_CREATED, + }) + ); + + expect(mockWebhookRepository.getSubscribers).toHaveBeenCalledWith({ + triggerEvent: WebhookTriggerEvents.BOOKING_CREATED, + userId: 789, + eventTypeId: 456, + teamId: undefined, + orgId: undefined, + oAuthClientId: undefined, + }); + + expect(mockLogger.info).toHaveBeenCalledWith( + "No webhook subscribers found", + expect.objectContaining({ + operationId: "op-123", + }) + ); + }); + + it("should fetch subscribers based on task payload", async () => { + const payload: WebhookTaskPayload = { + operationId: "op-456", + triggerEvent: WebhookTriggerEvents.BOOKING_CANCELLED, + bookingUid: "booking-456", + eventTypeId: 789, + teamId: 111, + orgId: 222, + oAuthClientId: "oauth-client-123", + timestamp: new Date().toISOString(), + }; + + vi.mocked(mockWebhookRepository.getSubscribers).mockResolvedValueOnce([ + { + id: "sub-1", + subscriberUrl: "https://example.com/webhook", + payloadTemplate: null, + appId: null, + secret: "secret", + time: null, + timeUnit: null, + eventTriggers: [WebhookTriggerEvents.BOOKING_CANCELLED], + version: WebhookVersion.V_2021_10_20, + }, + ]); + + await consumer.processWebhookTask(payload, "task-456"); + + expect(mockWebhookRepository.getSubscribers).toHaveBeenCalledWith({ + triggerEvent: WebhookTriggerEvents.BOOKING_CANCELLED, + userId: undefined, // Not in payload + eventTypeId: 789, + teamId: 111, + orgId: 222, + oAuthClientId: "oauth-client-123", + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Found 1 webhook subscriber(s)", + expect.objectContaining({ + operationId: "op-456", + }) + ); + }); + }); + + describe("processWebhookTask - Event Type Routing", () => { + const testCases = [ + { + trigger: WebhookTriggerEvents.BOOKING_CREATED, + requiredField: "bookingUid", + }, + { + trigger: WebhookTriggerEvents.FORM_SUBMITTED, + requiredField: "formId", + }, + { + trigger: WebhookTriggerEvents.RECORDING_READY, + requiredField: "recordingId", + }, + { + trigger: WebhookTriggerEvents.OOO_CREATED, + requiredField: "oooEntryId", + }, + ]; + + testCases.forEach(({ trigger, requiredField }) => { + it(`should process ${trigger} event type (scaffold)`, async () => { + const payload: WebhookTaskPayload = { + operationId: "op-test", + triggerEvent: trigger, + [requiredField]: "test-id", + timestamp: new Date().toISOString(), + }; + + // Mock subscriber so we reach data fetching + vi.mocked(mockWebhookRepository.getSubscribers).mockResolvedValueOnce([ + { + id: "sub-1", + subscriberUrl: "https://example.com/webhook", + payloadTemplate: null, + appId: null, + secret: null, + time: null, + timeUnit: null, + eventTriggers: [trigger], + version: WebhookVersion.V_2021_10_20, + }, + ]); + + // Should not throw - scaffold implementation logs debug messages + await expect(consumer.processWebhookTask(payload, "task-test")).resolves.not.toThrow(); + + // Verify subscriber fetch was called + expect(mockWebhookRepository.getSubscribers).toHaveBeenCalled(); + }); + }); + }); + + describe("Error Handling", () => { + it("should log and rethrow error if repository fails", async () => { + const error = new Error("Repository error"); + vi.mocked(mockWebhookRepository.getSubscribers).mockRejectedValueOnce(error); + + const payload: WebhookTaskPayload = { + operationId: "op-error", + triggerEvent: WebhookTriggerEvents.BOOKING_CREATED, + bookingUid: "booking-123", + timestamp: new Date().toISOString(), + }; + + await expect(consumer.processWebhookTask(payload, "task-error")).rejects.toThrow("Repository error"); + + expect(mockLogger.error).toHaveBeenCalledWith( + "Failed to process webhook delivery task", + expect.objectContaining({ + operationId: "op-error", + taskId: "task-error", + error: "Repository error", + }) + ); + }); + + it("should warn if event data not found", async () => { + const payload: WebhookTaskPayload = { + operationId: "op-missing", + triggerEvent: WebhookTriggerEvents.BOOKING_CREATED, + // Missing bookingUid + timestamp: new Date().toISOString(), + }; + + vi.mocked(mockWebhookRepository.getSubscribers).mockResolvedValueOnce([ + { + id: "sub-1", + subscriberUrl: "https://example.com/webhook", + payloadTemplate: null, + appId: null, + secret: null, + time: null, + timeUnit: null, + eventTriggers: [WebhookTriggerEvents.BOOKING_CREATED], + version: WebhookVersion.V_2021_10_20, + }, + ]); + + // Mock the data fetcher to return null (simulating event not found) + const bookingFetcher = mockDataFetchers[0]; + (bookingFetcher.fetchEventData as ReturnType).mockResolvedValueOnce(null); + + await consumer.processWebhookTask(payload, "task-missing"); + + expect(mockLogger.warn).toHaveBeenCalledWith( + "Event data not found", + expect.objectContaining({ + operationId: "op-missing", + triggerEvent: WebhookTriggerEvents.BOOKING_CREATED, + }) + ); + }); + }); + + describe("Future Implementation Tests", () => { + it("TODO [When WebhookTaskConsumer.fetchBookingData() is implemented]: Test full booking data fetching", () => { + // When: BookingRepository is injected into WebhookTaskConsumer + // When: fetchBookingData() implementation is complete + // Test: Fetch booking, eventType, user, attendees from database + // Test: Verify correct data structure returned + expect(true).toBe(true); // Placeholder + }); + + it("TODO [When PayloadBuilders are integrated into sendWebhooksToSubscribers()]: Test payload building", () => { + // When: BookingPayloadBuilder is integrated (for booking events) + // When: FormPayloadBuilder is integrated (for form events) + // When: RecordingPayloadBuilder is integrated (for recording events) + // When: OOOPayloadBuilder is integrated (for OOO events) + // Test: Build versioned payloads, apply payload templates + expect(true).toBe(true); // Placeholder + }); + + it("TODO [When sendWebhooksToSubscribers() makes HTTP calls]: Test HTTP delivery", () => { + // When: HTTP client is integrated (or existing sendPayload is used) + // When: sendWebhooksToSubscribers() sends to subscriber.subscriberUrl + // Test: Mock HTTP calls, verify correct payload sent + // Test: Handle retries, timeouts, errors + expect(true).toBe(true); // Placeholder + }); + + it("TODO [When all services are wired]: Integration test for full Producer→Consumer flow", () => { + // When: All webhook services use Producer/Consumer pattern + // Test: Full flow - Producer → Tasker → Consumer → HTTP delivery + // Test: Verify webhook received by mock HTTP server + // Test: Retry logic with task processor + // Test: E2E with real database and task queue + expect(true).toBe(true); // Placeholder + }); + }); +}); diff --git a/packages/features/webhooks/lib/service/__tests__/WebhookTaskerProducerService.test.ts b/packages/features/webhooks/lib/service/__tests__/WebhookTaskerProducerService.test.ts new file mode 100644 index 0000000000..3c7a6f2a93 --- /dev/null +++ b/packages/features/webhooks/lib/service/__tests__/WebhookTaskerProducerService.test.ts @@ -0,0 +1,284 @@ +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ITasker } from "../../interface"; +import type { ILogger } from "../../interface/infrastructure"; +import { WebhookTaskerProducerService } from "../WebhookTaskerProducerService"; + +/** + * Unit Tests for WebhookTaskerProducerService + * + * Tests the lightweight Producer service for queueing webhook delivery tasks. + */ +describe("WebhookTaskerProducerService", () => { + let producer: WebhookTaskerProducerService; + let mockTasker: ITasker; + let mockLogger: ILogger; + + beforeEach(() => { + // Mock Tasker + mockTasker = { + create: vi.fn().mockResolvedValue("task-id-123"), + cleanup: vi.fn().mockResolvedValue(undefined), + cancel: vi.fn().mockResolvedValue("cancelled-task-id"), + cancelWithReference: vi.fn().mockResolvedValue("cancelled-ref-id"), + }; + + // Mock Logger + mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + getSubLogger: vi.fn().mockReturnThis(), + } as unknown as ILogger; + + producer = new WebhookTaskerProducerService(mockTasker, mockLogger); + }); + + describe("Constructor & Dependencies", () => { + it("should be instantiable with Tasker and Logger", () => { + expect(producer).toBeInstanceOf(WebhookTaskerProducerService); + }); + + it("should create sub-logger with prefix", () => { + expect(mockLogger.getSubLogger).toHaveBeenCalledWith({ + prefix: ["[WebhookTaskerProducerService]"], + }); + }); + }); + + describe("queueBookingCreatedWebhook", () => { + it("should queue a BOOKING_CREATED webhook task", async () => { + await producer.queueBookingCreatedWebhook({ + triggerEvent: WebhookTriggerEvents.BOOKING_CREATED, + bookingUid: "booking-123", + eventTypeId: 456, + userId: 789, + }); + + expect(mockTasker.create).toHaveBeenCalledWith( + "webhookDelivery", + expect.objectContaining({ + triggerEvent: WebhookTriggerEvents.BOOKING_CREATED, + bookingUid: "booking-123", + eventTypeId: 456, + userId: 789, + operationId: expect.any(String), + timestamp: expect.any(String), + }) + ); + }); + + it("should generate operationId if not provided", async () => { + await producer.queueBookingCreatedWebhook({ + triggerEvent: WebhookTriggerEvents.BOOKING_CREATED, + bookingUid: "booking-123", + }); + + const callArgs = vi.mocked(mockTasker.create).mock.calls[0]; + const payload = callArgs[1]; + + expect(payload).toHaveProperty("operationId"); + expect(payload.operationId).toMatch(/^[0-9a-f-]{36}$/); // UUID format + }); + + it("should use provided operationId", async () => { + await producer.queueBookingCreatedWebhook({ + triggerEvent: WebhookTriggerEvents.BOOKING_CREATED, + bookingUid: "booking-123", + operationId: "custom-op-id", + }); + + const callArgs = vi.mocked(mockTasker.create).mock.calls[0]; + const payload = callArgs[1]; + + expect(payload.operationId).toBe("custom-op-id"); + }); + + it("should log info messages", async () => { + await producer.queueBookingCreatedWebhook({ + bookingUid: "booking-123", + }); + + expect(mockLogger.info).toHaveBeenCalledWith( + "Queueing booking webhook task", + expect.objectContaining({ + operationId: expect.any(String), + triggerEvent: WebhookTriggerEvents.BOOKING_CREATED, + bookingUid: "booking-123", + }) + ); + + expect(mockLogger.info).toHaveBeenCalledWith( + "Webhook delivery task queued successfully", + expect.objectContaining({ + operationId: expect.any(String), + }) + ); + }); + }); + + describe("queueBookingCancelledWebhook", () => { + it("should queue a BOOKING_CANCELLED webhook task", async () => { + await producer.queueBookingCancelledWebhook({ + bookingUid: "booking-456", + }); + + expect(mockTasker.create).toHaveBeenCalledWith( + "webhookDelivery", + expect.objectContaining({ + triggerEvent: WebhookTriggerEvents.BOOKING_CANCELLED, + bookingUid: "booking-456", + }) + ); + }); + }); + + describe("Metadata Support", () => { + it("should include metadata if provided", async () => { + await producer.queueBookingCreatedWebhook({ + bookingUid: "booking-123", + metadata: { customField: "value" }, + }); + + const callArgs = vi.mocked(mockTasker.create).mock.calls[0]; + const payload = callArgs[1]; + + expect(payload.metadata).toEqual({ customField: "value" }); + }); + }); + + describe("Error Handling", () => { + it("should log and rethrow error if Tasker fails", async () => { + const error = new Error("Tasker failed"); + vi.mocked(mockTasker.create).mockRejectedValueOnce(error); + + await expect( + producer.queueBookingCreatedWebhook({ + bookingUid: "booking-123", + }) + ).rejects.toThrow("Tasker failed"); + + expect(mockLogger.error).toHaveBeenCalledWith( + "Failed to queue webhook delivery task", + expect.objectContaining({ + error: "Tasker failed", + }) + ); + }); + }); + + describe("All Event-Specific Methods", () => { + it("should have queueBookingCreatedWebhook", async () => { + await producer.queueBookingCreatedWebhook({ + bookingUid: "test-123", + }); + expect(mockTasker.create).toHaveBeenCalledWith( + "webhookDelivery", + expect.objectContaining({ triggerEvent: WebhookTriggerEvents.BOOKING_CREATED }) + ); + }); + + it("should have queueBookingCancelledWebhook", async () => { + await producer.queueBookingCancelledWebhook({ + bookingUid: "test-123", + }); + expect(mockTasker.create).toHaveBeenCalledWith( + "webhookDelivery", + expect.objectContaining({ triggerEvent: WebhookTriggerEvents.BOOKING_CANCELLED }) + ); + }); + + it("should have queueBookingRescheduledWebhook", async () => { + await producer.queueBookingRescheduledWebhook({ + bookingUid: "test-123", + }); + expect(mockTasker.create).toHaveBeenCalledWith( + "webhookDelivery", + expect.objectContaining({ triggerEvent: WebhookTriggerEvents.BOOKING_RESCHEDULED }) + ); + }); + + it("should have queueBookingRequestedWebhook", async () => { + await producer.queueBookingRequestedWebhook({ + bookingUid: "test-123", + }); + expect(mockTasker.create).toHaveBeenCalledWith( + "webhookDelivery", + expect.objectContaining({ triggerEvent: WebhookTriggerEvents.BOOKING_REQUESTED }) + ); + }); + + it("should have queueBookingRejectedWebhook", async () => { + await producer.queueBookingRejectedWebhook({ + bookingUid: "test-123", + }); + expect(mockTasker.create).toHaveBeenCalledWith( + "webhookDelivery", + expect.objectContaining({ triggerEvent: WebhookTriggerEvents.BOOKING_REJECTED }) + ); + }); + + it("should have queueBookingPaymentInitiatedWebhook", async () => { + await producer.queueBookingPaymentInitiatedWebhook({ + bookingUid: "test-123", + }); + expect(mockTasker.create).toHaveBeenCalledWith( + "webhookDelivery", + expect.objectContaining({ triggerEvent: WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED }) + ); + }); + + it("should have queueBookingPaidWebhook", async () => { + await producer.queueBookingPaidWebhook({ + bookingUid: "test-123", + }); + expect(mockTasker.create).toHaveBeenCalledWith( + "webhookDelivery", + expect.objectContaining({ triggerEvent: WebhookTriggerEvents.BOOKING_PAID }) + ); + }); + + it("should have queueBookingNoShowUpdatedWebhook", async () => { + await producer.queueBookingNoShowUpdatedWebhook({ + bookingUid: "test-123", + }); + expect(mockTasker.create).toHaveBeenCalledWith( + "webhookDelivery", + expect.objectContaining({ triggerEvent: WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED }) + ); + }); + + it("should have queueFormSubmittedWebhook", async () => { + await producer.queueFormSubmittedWebhook({ + formId: "form-123", + }); + expect(mockTasker.create).toHaveBeenCalledWith( + "webhookDelivery", + expect.objectContaining({ triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED }) + ); + }); + + it("should have queueRecordingReadyWebhook", async () => { + await producer.queueRecordingReadyWebhook({ + recordingId: "rec-123", + bookingUid: "booking-123", + }); + expect(mockTasker.create).toHaveBeenCalledWith( + "webhookDelivery", + expect.objectContaining({ triggerEvent: WebhookTriggerEvents.RECORDING_READY }) + ); + }); + + it("should have queueOOOCreatedWebhook", async () => { + await producer.queueOOOCreatedWebhook({ + oooEntryId: 123, + userId: 456, + }); + expect(mockTasker.create).toHaveBeenCalledWith( + "webhookDelivery", + expect.objectContaining({ triggerEvent: WebhookTriggerEvents.OOO_CREATED }) + ); + }); + }); +}); diff --git a/packages/features/webhooks/lib/service/__tests__/fixtures.ts b/packages/features/webhooks/lib/service/__tests__/fixtures.ts new file mode 100644 index 0000000000..e94635da23 --- /dev/null +++ b/packages/features/webhooks/lib/service/__tests__/fixtures.ts @@ -0,0 +1,102 @@ +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import type { CalendarEvent } from "@calcom/types/Calendar"; + +/** + * Test Fixtures for Webhook Payload Compatibility Testing + * + * These fixtures provide consistent test data for comparing + * current implementation vs new Producer/Consumer pattern. + */ + +export function createTestBooking() { + return { + id: 123, + uid: "test-booking-uid-123", + eventTypeId: 456, + userId: 789, + startTime: new Date("2024-01-15T10:00:00Z"), + endTime: new Date("2024-01-15T11:00:00Z"), + title: "Test Booking", + description: "Test booking description", + status: "ACCEPTED" as const, + smsReminderNumber: "+1234567890", + }; +} + +export function createTestEventType() { + return { + id: 456, + title: "30 Min Meeting", + slug: "30min", + length: 30, + description: "A 30 minute meeting", + teamId: null, + userId: 789, + }; +} + +export function createTestUser() { + return { + id: 789, + email: "test@example.com", + name: "Test User", + username: "testuser", + timeZone: "America/New_York", + }; +} + +export function createTestCalendarEvent(): CalendarEvent { + return { + type: "30min", + title: "Test Booking", + description: "Test booking description", + startTime: "2024-01-15T10:00:00Z", + endTime: "2024-01-15T11:00:00Z", + organizer: { + email: "test@example.com", + name: "Test User", + timeZone: "America/New_York", + language: { locale: "en" }, + }, + attendees: [ + { + email: "attendee@example.com", + name: "Test Attendee", + timeZone: "America/Los_Angeles", + language: { locale: "en" }, + }, + ], + uid: "test-booking-uid-123", + location: "Zoom", + }; +} + +export function createTestWebhookSubscriber() { + return { + id: "webhook-sub-1", + subscriberUrl: "https://example.com/webhook", + payloadTemplate: null, + appId: null, + secret: "test-secret-key", + time: null, + timeUnit: null, + eventTriggers: [WebhookTriggerEvents.BOOKING_CREATED, WebhookTriggerEvents.BOOKING_CANCELLED], + }; +} + +/** + * Test data for all webhook trigger events + */ +export const testTriggerEvents = { + bookingCreated: WebhookTriggerEvents.BOOKING_CREATED, + bookingCancelled: WebhookTriggerEvents.BOOKING_CANCELLED, + bookingRescheduled: WebhookTriggerEvents.BOOKING_RESCHEDULED, + bookingRequested: WebhookTriggerEvents.BOOKING_REQUESTED, + bookingRejected: WebhookTriggerEvents.BOOKING_REJECTED, + bookingPaymentInitiated: WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED, + bookingPaid: WebhookTriggerEvents.BOOKING_PAID, + bookingNoShowUpdated: WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED, + formSubmitted: WebhookTriggerEvents.FORM_SUBMITTED, + recordingReady: WebhookTriggerEvents.RECORDING_READY, + oooCreated: WebhookTriggerEvents.OOO_CREATED, +}; diff --git a/packages/features/webhooks/lib/service/data-fetchers/BookingWebhookDataFetcher.ts b/packages/features/webhooks/lib/service/data-fetchers/BookingWebhookDataFetcher.ts new file mode 100644 index 0000000000..bc42a386c2 --- /dev/null +++ b/packages/features/webhooks/lib/service/data-fetchers/BookingWebhookDataFetcher.ts @@ -0,0 +1,50 @@ +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import type { IWebhookDataFetcher, SubscriberContext } from "../../interface/IWebhookDataFetcher"; +import type { ILogger } from "../../interface/infrastructure"; +import type { BookingWebhookTaskPayload } from "../../types/webhookTask"; + +export class BookingWebhookDataFetcher implements IWebhookDataFetcher { + private readonly BOOKING_TRIGGERS = new Set([ + WebhookTriggerEvents.BOOKING_CREATED, + WebhookTriggerEvents.BOOKING_CANCELLED, + WebhookTriggerEvents.BOOKING_RESCHEDULED, + WebhookTriggerEvents.BOOKING_REQUESTED, + WebhookTriggerEvents.BOOKING_REJECTED, + WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED, + ]); + + constructor(private readonly logger: ILogger) {} + + canHandle(triggerEvent: WebhookTriggerEvents): boolean { + return this.BOOKING_TRIGGERS.has(triggerEvent as never); + } + + async fetchEventData(payload: BookingWebhookTaskPayload): Promise | null> { + const { bookingUid } = payload; + + if (!bookingUid) { + this.logger.warn("Missing bookingUid for booking webhook"); + return null; + } + + // TODO [Phase 1+]: Implement using BookingRepository (to be injected) + // const booking = await this.bookingRepository.findByUid(bookingUid); + // const eventType = await this.eventTypeRepository.findById(booking.eventTypeId); + // const organizer = await this.userRepository.findById(booking.userId); + // return { booking, eventType, organizer, attendees: booking.attendees }; + + this.logger.debug("Booking data fetch not implemented yet (Phase 0 scaffold)", { bookingUid }); + return { bookingUid, _scaffold: true }; + } + + getSubscriberContext(payload: BookingWebhookTaskPayload): SubscriberContext { + return { + triggerEvent: payload.triggerEvent, + userId: payload.userId, + eventTypeId: payload.eventTypeId, + teamId: payload.teamId, + orgId: payload.orgId, + oAuthClientId: payload.oAuthClientId, + }; + } +} diff --git a/packages/features/webhooks/lib/service/data-fetchers/FormWebhookDataFetcher.ts b/packages/features/webhooks/lib/service/data-fetchers/FormWebhookDataFetcher.ts new file mode 100644 index 0000000000..e4387a3272 --- /dev/null +++ b/packages/features/webhooks/lib/service/data-fetchers/FormWebhookDataFetcher.ts @@ -0,0 +1,40 @@ +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import type { IWebhookDataFetcher, SubscriberContext } from "../../interface/IWebhookDataFetcher"; +import type { ILogger } from "../../interface/infrastructure"; +import type { FormWebhookTaskPayload } from "../../types/webhookTask"; + +export class FormWebhookDataFetcher implements IWebhookDataFetcher { + constructor(private readonly logger: ILogger) {} + + canHandle(triggerEvent: WebhookTriggerEvents): boolean { + return triggerEvent === WebhookTriggerEvents.FORM_SUBMITTED; + } + + async fetchEventData(payload: FormWebhookTaskPayload): Promise | null> { + const { formId } = payload; + + if (!formId) { + this.logger.warn("Missing formId for form webhook"); + return null; + } + + // TODO [Phase 1+]: Implement using FormRepository + // const form = await this.formRepository.findById(formId); + // const responses = await this.formRepository.getResponses(formId); + // return { form, responses }; + + this.logger.debug("Form data fetch not implemented yet (Phase 0 scaffold)", { formId }); + return { formId, _scaffold: true }; + } + + getSubscriberContext(payload: FormWebhookTaskPayload): SubscriberContext { + return { + triggerEvent: payload.triggerEvent, + userId: payload.userId, + eventTypeId: undefined, + teamId: payload.teamId, + orgId: undefined, + oAuthClientId: payload.oAuthClientId, + }; + } +} diff --git a/packages/features/webhooks/lib/service/data-fetchers/OOOWebhookDataFetcher.ts b/packages/features/webhooks/lib/service/data-fetchers/OOOWebhookDataFetcher.ts new file mode 100644 index 0000000000..82cc58ff30 --- /dev/null +++ b/packages/features/webhooks/lib/service/data-fetchers/OOOWebhookDataFetcher.ts @@ -0,0 +1,40 @@ +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import type { IWebhookDataFetcher, SubscriberContext } from "../../interface/IWebhookDataFetcher"; +import type { ILogger } from "../../interface/infrastructure"; +import type { OOOWebhookTaskPayload } from "../../types/webhookTask"; + +export class OOOWebhookDataFetcher implements IWebhookDataFetcher { + constructor(private readonly logger: ILogger) {} + + canHandle(triggerEvent: WebhookTriggerEvents): boolean { + return triggerEvent === WebhookTriggerEvents.OOO_CREATED; + } + + async fetchEventData(payload: OOOWebhookTaskPayload): Promise | null> { + const { oooEntryId } = payload; + + if (!oooEntryId) { + this.logger.warn("Missing oooEntryId for OOO webhook"); + return null; + } + + // TODO [Phase 1+]: Implement using OOORepository + // const oooEntry = await this.oooRepository.findById(oooEntryId); + // const user = await this.userRepository.findById(oooEntry.userId); + // return { oooEntry, user }; + + this.logger.debug("OOO data fetch not implemented yet (Phase 0 scaffold)", { oooEntryId }); + return { oooEntryId, _scaffold: true }; + } + + getSubscriberContext(payload: OOOWebhookTaskPayload): SubscriberContext { + return { + triggerEvent: payload.triggerEvent, + userId: payload.userId, + eventTypeId: undefined, + teamId: payload.teamId, + orgId: undefined, + oAuthClientId: payload.oAuthClientId, + }; + } +} diff --git a/packages/features/webhooks/lib/service/data-fetchers/PaymentWebhookDataFetcher.ts b/packages/features/webhooks/lib/service/data-fetchers/PaymentWebhookDataFetcher.ts new file mode 100644 index 0000000000..0ec485038f --- /dev/null +++ b/packages/features/webhooks/lib/service/data-fetchers/PaymentWebhookDataFetcher.ts @@ -0,0 +1,45 @@ +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import type { IWebhookDataFetcher, SubscriberContext } from "../../interface/IWebhookDataFetcher"; +import type { ILogger } from "../../interface/infrastructure"; +import type { PaymentWebhookTaskPayload } from "../../types/webhookTask"; + +export class PaymentWebhookDataFetcher implements IWebhookDataFetcher { + private readonly PAYMENT_TRIGGERS = new Set([ + WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED, + WebhookTriggerEvents.BOOKING_PAID, + ]); + + constructor(private readonly logger: ILogger) {} + + canHandle(triggerEvent: WebhookTriggerEvents): boolean { + return this.PAYMENT_TRIGGERS.has(triggerEvent as never); + } + + async fetchEventData(payload: PaymentWebhookTaskPayload): Promise | null> { + const { bookingUid } = payload; + + if (!bookingUid) { + this.logger.warn("Missing bookingUid for payment webhook"); + return null; + } + + // TODO [Phase 1+]: Implement using BookingRepository and PaymentRepository + // const booking = await this.bookingRepository.findByUid(bookingUid); + // const payment = await this.paymentRepository.findByBookingId(booking.id); + // return { booking, payment, eventType, organizer, attendees }; + + this.logger.debug("Payment data fetch not implemented yet (Phase 0 scaffold)", { bookingUid }); + return { bookingUid, _scaffold: true }; + } + + getSubscriberContext(payload: PaymentWebhookTaskPayload): SubscriberContext { + return { + triggerEvent: payload.triggerEvent, + userId: payload.userId, + eventTypeId: payload.eventTypeId, + teamId: payload.teamId, + orgId: payload.orgId, + oAuthClientId: payload.oAuthClientId, + }; + } +} diff --git a/packages/features/webhooks/lib/service/data-fetchers/RecordingWebhookDataFetcher.ts b/packages/features/webhooks/lib/service/data-fetchers/RecordingWebhookDataFetcher.ts new file mode 100644 index 0000000000..effe58ba0f --- /dev/null +++ b/packages/features/webhooks/lib/service/data-fetchers/RecordingWebhookDataFetcher.ts @@ -0,0 +1,56 @@ +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import type { IWebhookDataFetcher, SubscriberContext } from "../../interface/IWebhookDataFetcher"; +import type { ILogger } from "../../interface/infrastructure"; +import type { RecordingWebhookTaskPayload } from "../../types/webhookTask"; + +export class RecordingWebhookDataFetcher implements IWebhookDataFetcher { + private readonly RECORDING_TRIGGERS = new Set([ + WebhookTriggerEvents.RECORDING_READY, + WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED, + ]); + + constructor(private readonly logger: ILogger) {} + + canHandle(triggerEvent: WebhookTriggerEvents): boolean { + return this.RECORDING_TRIGGERS.has(triggerEvent as never); + } + + async fetchEventData(payload: RecordingWebhookTaskPayload): Promise | null> { + const { recordingId } = payload; + + if (!recordingId) { + this.logger.warn("Missing recordingId for recording webhook"); + return null; + } + + // TODO [Phase 1+]: Implement recording data fetching + // Note: Recording files are stored by video providers. We receive recording_id from their webhook + // and generate our own proxy download link (using generateVideoToken + our API endpoint). + // This method fetches booking/event data from DB needed to build the webhook payload. + // + // Pattern: recordingId → booking → eventType → user → attendees + // Then generate downloadLink: `${WEBAPP_URL}/api/video/recording?token=${generateVideoToken(recordingId)}` + // + // const booking = await this.bookingRepository.findByUid(payload.bookingUid); + // const eventType = await this.eventTypeRepository.findById(booking.eventTypeId); + // const user = await this.userRepository.findById(booking.userId); + // const attendees = booking.attendees; + // const token = generateVideoToken(recordingId); + // const downloadLink = `${WEBAPP_URL}/api/video/recording?token=${token}`; + // return { booking, eventType, user, attendees, downloadLink }; + + this.logger.debug("Recording data fetch not implemented yet (Phase 0 scaffold)", { recordingId }); + return { recordingId, _scaffold: true }; + } + + getSubscriberContext(payload: RecordingWebhookTaskPayload): SubscriberContext { + return { + triggerEvent: payload.triggerEvent, + userId: payload.userId, + eventTypeId: payload.eventTypeId, + teamId: payload.teamId, + orgId: undefined, + oAuthClientId: payload.oAuthClientId, + }; + } +} diff --git a/packages/features/webhooks/lib/triggerDelegationCredentialErrorWebhook.ts b/packages/features/webhooks/lib/triggerDelegationCredentialErrorWebhook.ts index 0f63a808d8..29921171bf 100644 --- a/packages/features/webhooks/lib/triggerDelegationCredentialErrorWebhook.ts +++ b/packages/features/webhooks/lib/triggerDelegationCredentialErrorWebhook.ts @@ -61,7 +61,7 @@ export async function triggerDelegationCredentialErrorWebhook(params: { }, } satisfies DelegationCredentialErrorPayloadType; - const webhookPromises = webhooks.map((webhook) => + const webhookPromises = webhooks.map((webhook: (typeof webhooks)[number]) => sendPayload( webhook.secret, WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR, diff --git a/packages/features/webhooks/lib/types/webhookTask.ts b/packages/features/webhooks/lib/types/webhookTask.ts new file mode 100644 index 0000000000..4360e18bb3 --- /dev/null +++ b/packages/features/webhooks/lib/types/webhookTask.ts @@ -0,0 +1,118 @@ +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import { z } from "zod"; + +/** + * Base fields common to all webhook task payloads + */ +const baseWebhookTaskSchema = z.object({ + operationId: z.string(), + timestamp: z.string(), + metadata: z.record(z.unknown()).optional(), +}); + +/** + * Booking-related webhook task payload + * Used for: BOOKING_CREATED, BOOKING_CANCELLED, BOOKING_RESCHEDULED, + * BOOKING_REQUESTED, BOOKING_REJECTED, BOOKING_NO_SHOW_UPDATED + */ +export const bookingWebhookTaskPayloadSchema = baseWebhookTaskSchema.extend({ + triggerEvent: z.enum([ + WebhookTriggerEvents.BOOKING_CREATED, + WebhookTriggerEvents.BOOKING_CANCELLED, + WebhookTriggerEvents.BOOKING_RESCHEDULED, + WebhookTriggerEvents.BOOKING_REQUESTED, + WebhookTriggerEvents.BOOKING_REJECTED, + WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED, + ]), + bookingUid: z.string(), + eventTypeId: z.number().optional(), + teamId: z.number().nullable().optional(), + userId: z.number().optional(), + orgId: z.number().optional(), + oAuthClientId: z.string().nullable().optional(), +}); + +/** + * Payment-related webhook task payload + * Used for: BOOKING_PAYMENT_INITIATED, BOOKING_PAID + */ +export const paymentWebhookTaskPayloadSchema = baseWebhookTaskSchema.extend({ + triggerEvent: z.enum([WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED, WebhookTriggerEvents.BOOKING_PAID]), + bookingUid: z.string(), + eventTypeId: z.number().optional(), + teamId: z.number().nullable().optional(), + userId: z.number().optional(), + orgId: z.number().optional(), + oAuthClientId: z.string().nullable().optional(), +}); + +/** + * Form-related webhook task payload + * Used for: FORM_SUBMITTED + */ +export const formWebhookTaskPayloadSchema = baseWebhookTaskSchema.extend({ + triggerEvent: z.literal(WebhookTriggerEvents.FORM_SUBMITTED), + formId: z.string(), + teamId: z.number().nullable().optional(), + userId: z.number().optional(), + oAuthClientId: z.string().nullable().optional(), +}); + +/** + * Recording-related webhook task payload + * Used for: RECORDING_READY, RECORDING_TRANSCRIPTION_GENERATED + */ +export const recordingWebhookTaskPayloadSchema = baseWebhookTaskSchema.extend({ + triggerEvent: z.enum([ + WebhookTriggerEvents.RECORDING_READY, + WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED, + ]), + recordingId: z.string(), + bookingUid: z.string(), + eventTypeId: z.number().optional(), + teamId: z.number().nullable().optional(), + userId: z.number().optional(), + oAuthClientId: z.string().nullable().optional(), +}); + +/** + * OOO (Out of Office) webhook task payload + * Used for: OOO_CREATED + */ +export const oooWebhookTaskPayloadSchema = baseWebhookTaskSchema.extend({ + triggerEvent: z.literal(WebhookTriggerEvents.OOO_CREATED), + oooEntryId: z.number(), + userId: z.number(), + teamId: z.number().nullable().optional(), + oAuthClientId: z.string().nullable().optional(), +}); + +/** + * Discriminated union of all webhook task payload schemas + */ +export const webhookTaskPayloadSchema = z.discriminatedUnion("triggerEvent", [ + bookingWebhookTaskPayloadSchema, + paymentWebhookTaskPayloadSchema, + formWebhookTaskPayloadSchema, + recordingWebhookTaskPayloadSchema, + oooWebhookTaskPayloadSchema, +]); + +/** + * Webhook Task Payload Types + * + * These are the minimal payload structures queued by WebhookTaskerProducerService + * and processed by WebhookTaskConsumer. + * + * Each type contains only the IDs/references needed - the Consumer fetches full data from DB. + */ +export type BookingWebhookTaskPayload = z.infer; +export type PaymentWebhookTaskPayload = z.infer; +export type FormWebhookTaskPayload = z.infer; +export type RecordingWebhookTaskPayload = z.infer; +export type OOOWebhookTaskPayload = z.infer; + +/** + * Union type of all webhook task payloads + */ +export type WebhookTaskPayload = z.infer;