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
This commit is contained in:
Syed Ali Shahbaz
2026-01-22 18:33:34 +05:30
committed by GitHub
parent a63bf11c3f
commit c4c62369e5
35 changed files with 2124 additions and 91 deletions
+2
View File
@@ -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,
};
@@ -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;
@@ -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<WebhookTaskConsumer>(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<IWebhookProducerService>(WEBHOOK_TOKENS.WEBHOOK_PRODUCER_SERVICE),
consumer: webhookContainer.get<WebhookTaskConsumer>(WEBHOOK_TOKENS.WEBHOOK_TASK_CONSUMER),
core: webhookContainer.get<IWebhookService>(WEBHOOK_TOKENS.WEBHOOK_SERVICE),
booking: webhookContainer.get<IBookingWebhookService>(WEBHOOK_TOKENS.BOOKING_WEBHOOK_SERVICE),
form: webhookContainer.get<IFormWebhookService>(WEBHOOK_TOKENS.FORM_WEBHOOK_SERVICE),
recording: webhookContainer.get<IRecordingWebhookService>(WEBHOOK_TOKENS.RECORDING_WEBHOOK_SERVICE),
ooo: webhookContainer.get<IOOOWebhookService>(WEBHOOK_TOKENS.OOO_WEBHOOK_SERVICE),
notifier: webhookContainer.get<IWebhookNotifier>(WEBHOOK_TOKENS.WEBHOOK_NOTIFIER),
repository: webhookContainer.get<IWebhookRepository>(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<IWebhookProducerService>(WEBHOOK_TOKENS.WEBHOOK_PRODUCER_SERVICE);
}
@@ -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]);
@@ -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]);
@@ -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]);
@@ -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]);
@@ -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]);
@@ -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
@@ -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]);
@@ -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<IWebhookRepository>(WEBHOOK_TOKENS.WEBHOOK_REPOSITORY);
const dataFetchers: IWebhookDataFetcher[] = [
container.get<IWebhookDataFetcher>(WEBHOOK_TOKENS.BOOKING_DATA_FETCHER),
container.get<IWebhookDataFetcher>(WEBHOOK_TOKENS.PAYMENT_DATA_FETCHER),
container.get<IWebhookDataFetcher>(WEBHOOK_TOKENS.FORM_DATA_FETCHER),
container.get<IWebhookDataFetcher>(WEBHOOK_TOKENS.RECORDING_DATA_FETCHER),
container.get<IWebhookDataFetcher>(WEBHOOK_TOKENS.OOO_DATA_FETCHER),
];
const logger = container.get<ILogger>(SHARED_TOKENS.LOGGER);
return new WebhookTaskConsumer(webhookRepository, dataFetchers, logger);
});
+4 -2
View File
@@ -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<void>;
+5
View File
@@ -34,6 +34,7 @@ const tasks: Record<TaskTypes, () => Promise<TaskHandler>> = {
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;
@@ -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<void> {
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;
}
}
@@ -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;
}
@@ -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<TInput extends WebhookEventDTO = WebhookEventDTO> {
build(dto: TInput): WebhookPayload;
}
@@ -65,10 +59,6 @@ export interface IDelegationPayloadBuilder extends IPayloadBuilder<DelegationCre
build(dto: DelegationCredentialErrorDTO): WebhookPayload;
}
/**
* Set of all payload builders for a specific webhook version
* Each builder is properly typed for its respective event DTOs
*/
export interface PayloadBuilderSet {
booking: IBookingPayloadBuilder;
form: IFormPayloadBuilder;
@@ -79,9 +69,6 @@ export interface PayloadBuilderSet {
delegation: IDelegationPayloadBuilder;
}
/**
* Builder categories - used for explicit routing
*/
type BuilderCategory = keyof PayloadBuilderSet;
/**
@@ -125,13 +112,7 @@ const TRIGGER_TO_BUILDER_CATEGORY: Record<WebhookTriggerEvents, BuilderCategory>
[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
@@ -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<Record<string, unknown> | null>;
getSubscriberContext(payload: WebhookTaskPayload): SubscriberContext;
}
@@ -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<string, unknown>;
}
/**
* 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<void>;
/**
* Queue a webhook delivery task for BOOKING_CANCELLED event
*/
queueBookingCancelledWebhook(params: QueueBookingWebhookParams): Promise<void>;
/**
* Queue a webhook delivery task for BOOKING_RESCHEDULED event
*/
queueBookingRescheduledWebhook(params: QueueBookingWebhookParams): Promise<void>;
/**
* Queue a webhook delivery task for BOOKING_REQUESTED event
*
* Note: This fires when bookings require confirmation (status = PENDING)
*/
queueBookingRequestedWebhook(params: QueueBookingWebhookParams): Promise<void>;
/**
* Queue a webhook delivery task for BOOKING_REJECTED event
*/
queueBookingRejectedWebhook(params: QueueBookingWebhookParams): Promise<void>;
/**
* Queue a webhook delivery task for BOOKING_PAYMENT_INITIATED event
*/
queueBookingPaymentInitiatedWebhook(params: QueuePaymentWebhookParams): Promise<void>;
/**
* Queue a webhook delivery task for BOOKING_PAID event
*/
queueBookingPaidWebhook(params: QueuePaymentWebhookParams): Promise<void>;
/**
* Queue a webhook delivery task for BOOKING_NO_SHOW_UPDATED event
*/
queueBookingNoShowUpdatedWebhook(params: QueueBookingWebhookParams): Promise<void>;
/**
* Queue a webhook delivery task for FORM_SUBMITTED event
*/
queueFormSubmittedWebhook(params: QueueFormWebhookParams): Promise<void>;
/**
* Queue a webhook delivery task for RECORDING_READY event
*/
queueRecordingReadyWebhook(params: QueueRecordingWebhookParams): Promise<void>;
/**
* Queue a webhook delivery task for OOO_CREATED event
*/
queueOOOCreatedWebhook(params: QueueOOOWebhookParams): Promise<void>;
}
@@ -1,11 +1,6 @@
export interface ITasker {
create(
taskName: string,
payload: string,
options?: { scheduledAt?: Date; referenceUid?: string }
): Promise<string>;
cancelWithReference(referenceUid: string, taskName: string): Promise<string | null>;
}
import type { Tasker } from "@calcom/features/tasker/tasker";
export type ITasker = Tasker;
export interface ILogger {
debug(message: string, meta?: Record<string, unknown>): void;
@@ -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<void>;
}
// 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<void>;
}
@@ -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"
);
@@ -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 }
);
}
@@ -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,
@@ -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<void> {
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<string, unknown>,
payload: WebhookTaskPayload
): Promise<void> {
// 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
}
}
@@ -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<void> {
await this.queueBookingWebhook(WebhookTriggerEvents.BOOKING_CREATED, params);
}
async queueBookingCancelledWebhook(params: QueueBookingWebhookParams): Promise<void> {
await this.queueBookingWebhook(WebhookTriggerEvents.BOOKING_CANCELLED, params);
}
async queueBookingRescheduledWebhook(params: QueueBookingWebhookParams): Promise<void> {
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<void> {
await this.queueBookingWebhook(WebhookTriggerEvents.BOOKING_REQUESTED, params);
}
async queueBookingRejectedWebhook(params: QueueBookingWebhookParams): Promise<void> {
await this.queueBookingWebhook(WebhookTriggerEvents.BOOKING_REJECTED, params);
}
async queueBookingPaymentInitiatedWebhook(params: QueuePaymentWebhookParams): Promise<void> {
await this.queuePaymentWebhook(WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED, params);
}
async queueBookingPaidWebhook(params: QueuePaymentWebhookParams): Promise<void> {
await this.queuePaymentWebhook(WebhookTriggerEvents.BOOKING_PAID, params);
}
async queueBookingNoShowUpdatedWebhook(params: QueueBookingWebhookParams): Promise<void> {
await this.queueBookingWebhook(WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED, params);
}
async queueFormSubmittedWebhook(params: QueueFormWebhookParams): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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;
}
}
}
@@ -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<typeof vi.fn>).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
});
});
});
@@ -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 })
);
});
});
});
@@ -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,
};
@@ -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<Record<string, unknown> | 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,
};
}
}
@@ -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<Record<string, unknown> | 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,
};
}
}
@@ -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<Record<string, unknown> | 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,
};
}
}
@@ -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<Record<string, unknown> | 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,
};
}
}
@@ -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<Record<string, unknown> | 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,
};
}
}
@@ -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,
@@ -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<typeof bookingWebhookTaskPayloadSchema>;
export type PaymentWebhookTaskPayload = z.infer<typeof paymentWebhookTaskPayloadSchema>;
export type FormWebhookTaskPayload = z.infer<typeof formWebhookTaskPayloadSchema>;
export type RecordingWebhookTaskPayload = z.infer<typeof recordingWebhookTaskPayloadSchema>;
export type OOOWebhookTaskPayload = z.infer<typeof oooWebhookTaskPayloadSchema>;
/**
* Union type of all webhook task payloads
*/
export type WebhookTaskPayload = z.infer<typeof webhookTaskPayloadSchema>;