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:
@@ -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);
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
+284
@@ -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>;
|
||||
Reference in New Issue
Block a user