chore: add webhook architecture skeleton (#23247)

* add migration guide and dto

* add factory

* add notifier

* add repo

* add services

* coderabbit review --1

* coderabbit review --2

* coderabbit review --3

* further improvement

* --

* fix

* bookingWebhookFactory consideration and type fixes

* cleanup

* fix types

* DI part1

* DI --part 2

* remove migrationGuide as we're WIP

* using evyweb for DI -- 1

* DI --final

* separate func instead of private class

* adds a todo migration file

* adjust structure

* address feedback

* remove todo_migrate

* --1

* fix type

* address feedback

* add TODO comment

* address requested changes --1

* address feedback --2

* restructure as per feedback

* rename camelcase
This commit is contained in:
Syed Ali Shahbaz
2025-09-12 20:02:50 +00:00
committed by GitHub
parent 3915348d03
commit 110615b509
30 changed files with 3076 additions and 0 deletions
+513
View File
@@ -0,0 +1,513 @@
import type { TGetTranscriptAccessLink } from "@calcom/app-store/dailyvideo/zod";
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/trpc/utils";
import type { TimeUnit, WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
export interface BaseEventDTO {
triggerEvent: WebhookTriggerEvents;
createdAt: string;
bookingId?: number;
eventTypeId?: number | null;
userId?: number | null;
teamId?: number | null;
orgId?: number | null;
platformClientId?: string | null;
}
export interface BookingCreatedDTO extends BaseEventDTO {
triggerEvent: typeof WebhookTriggerEvents.BOOKING_CREATED;
evt: CalendarEvent;
eventType: EventTypeInfo & {
id: number;
};
booking: {
id: number;
eventTypeId: number | null;
userId: number | null;
startTime: Date;
smsReminderNumber?: string | null;
};
status: "ACCEPTED" | "PENDING";
metadata?: Record<string, unknown>;
platformParams?: {
platformClientId?: string;
platformRescheduleUrl?: string;
platformCancelUrl?: string;
platformBookingUrl?: string;
};
}
export interface BookingCancelledDTO extends BaseEventDTO {
triggerEvent: typeof WebhookTriggerEvents.BOOKING_CANCELLED;
evt: CalendarEvent;
eventType: EventTypeInfo & {
id: number;
};
booking: {
id: number;
eventTypeId: number | null;
userId: number | null;
smsReminderNumber?: string | null;
};
cancelledBy?: string;
cancellationReason?: string;
}
export interface BookingRejectedDTO extends BaseEventDTO {
triggerEvent: typeof WebhookTriggerEvents.BOOKING_REJECTED;
evt: CalendarEvent;
eventType: EventTypeInfo & {
id: number;
};
booking: {
id: number;
eventTypeId: number | null;
userId: number | null;
smsReminderNumber?: string | null;
};
status: string;
}
export interface BookingRequestedDTO extends BaseEventDTO {
triggerEvent: typeof WebhookTriggerEvents.BOOKING_REQUESTED;
evt: CalendarEvent;
eventType: EventTypeInfo & {
id: number;
};
booking: {
id: number;
eventTypeId: number | null;
userId: number | null;
};
}
export interface BookingRescheduledDTO extends BaseEventDTO {
triggerEvent: typeof WebhookTriggerEvents.BOOKING_RESCHEDULED;
evt: CalendarEvent;
eventType: EventTypeInfo & {
id: number;
};
booking: {
id: number;
eventTypeId: number | null;
userId: number | null;
smsReminderNumber?: string | null;
};
rescheduleId?: number;
rescheduleUid?: string;
rescheduleStartTime?: string;
rescheduleEndTime?: string;
rescheduledBy?: string;
}
export interface BookingPaidDTO extends BaseEventDTO {
triggerEvent: typeof WebhookTriggerEvents.BOOKING_PAID;
evt: CalendarEvent;
eventType: EventTypeInfo & {
id: number;
};
booking: {
id: number;
eventTypeId: number | null;
userId: number | null;
};
paymentId?: number;
paymentData?: Record<string, unknown>;
}
export interface BookingPaymentInitiatedDTO extends BaseEventDTO {
triggerEvent: typeof WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED;
evt: CalendarEvent;
eventType: EventTypeInfo & {
id: number;
};
booking: {
id: number;
eventTypeId: number | null;
userId: number | null;
};
paymentId?: number;
paymentData?: Record<string, unknown>;
}
export interface BookingNoShowDTO extends BaseEventDTO {
triggerEvent: typeof WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED;
message: string;
bookingUid: string;
bookingId?: number;
attendees: { email: string; noShow: boolean }[];
}
export interface OOOCreatedDTO extends BaseEventDTO {
triggerEvent: typeof WebhookTriggerEvents.OOO_CREATED;
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;
timeZone?: string;
email?: string;
} | null;
uuid: string;
};
}
export interface FormSubmittedDTO extends BaseEventDTO {
triggerEvent: typeof WebhookTriggerEvents.FORM_SUBMITTED;
form: {
id: string;
name: string;
};
response: {
id: number;
data: FORM_SUBMITTED_WEBHOOK_RESPONSES;
};
}
export interface RecordingReadyDTO extends BaseEventDTO {
triggerEvent: typeof WebhookTriggerEvents.RECORDING_READY;
evt: CalendarEvent;
downloadLink: string;
}
export interface TranscriptionGeneratedDTO extends BaseEventDTO {
triggerEvent: typeof WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED;
evt: CalendarEvent;
downloadLinks?: {
transcription?: Array<{
format: string;
link: string;
}>;
recording?: string;
};
}
export interface FormSubmittedNoEventDTO extends BaseEventDTO {
triggerEvent: typeof WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT;
form: {
id: string;
name: string;
};
response: {
id: number;
data: FORM_SUBMITTED_WEBHOOK_RESPONSES;
};
}
export interface MeetingStartedDTO extends BaseEventDTO {
triggerEvent: typeof WebhookTriggerEvents.MEETING_STARTED;
booking: {
id: number;
startTime: Date;
endTime: Date;
title: string;
description: string | null;
customInputs: Record<string, unknown> | null;
responses: Record<string, unknown> | null;
userFieldsResponses: Record<string, unknown> | null;
location: string | null;
status: string;
user: {
username: string | null;
name: string | null;
email: string;
timeZone: string;
locale: string | null;
} | null;
eventType: {
title: string;
description: string | null;
requiresConfirmation: boolean;
price: number;
currency: string;
length: number;
team: {
logoUrl: string | null;
parent: {
logoUrl: string | null;
name: string;
} | null;
} | null;
} | null;
attendees: {
name: string;
email: string;
timeZone: string;
}[];
};
}
export interface MeetingEndedDTO extends BaseEventDTO {
triggerEvent: typeof WebhookTriggerEvents.MEETING_ENDED;
booking: {
id: number;
startTime: Date;
endTime: Date;
title: string;
description: string | null;
customInputs: Record<string, unknown> | null;
responses: Record<string, unknown> | null;
userFieldsResponses: Record<string, unknown> | null;
location: string | null;
status: string;
user: {
username: string | null;
name: string | null;
email: string;
timeZone: string;
locale: string | null;
} | null;
eventType: {
title: string;
description: string | null;
requiresConfirmation: boolean;
price: number;
currency: string;
length: number;
team: {
logoUrl: string | null;
parent: {
logoUrl: string | null;
name: string;
} | null;
} | null;
} | null;
attendees: {
name: string;
email: string;
timeZone: string;
}[];
};
}
export interface InstantMeetingDTO extends BaseEventDTO {
triggerEvent: typeof WebhookTriggerEvents.INSTANT_MEETING;
title: string;
body: string;
icon?: string;
url?: string;
actions?: { action: string; title: string; type: string; image: string | null }[];
requireInteraction?: boolean;
type: string;
}
export interface AfterHostsNoShowDTO extends BaseEventDTO {
triggerEvent: typeof WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW;
bookingId: number;
webhook: {
id: string;
subscriberUrl: string;
time: number;
timeUnit: string;
};
}
export interface AfterGuestsNoShowDTO extends BaseEventDTO {
triggerEvent: typeof WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW;
bookingId: number;
webhook: {
id: string;
subscriberUrl: string;
time: number;
timeUnit: string;
};
}
export type WebhookEventDTO =
| BookingCreatedDTO
| BookingCancelledDTO
| BookingRejectedDTO
| BookingRequestedDTO
| BookingRescheduledDTO
| BookingPaidDTO
| BookingPaymentInitiatedDTO
| BookingNoShowDTO
| OOOCreatedDTO
| FormSubmittedDTO
| FormSubmittedNoEventDTO
| RecordingReadyDTO
| TranscriptionGeneratedDTO
| MeetingStartedDTO
| MeetingEndedDTO
| InstantMeetingDTO
| AfterHostsNoShowDTO
| AfterGuestsNoShowDTO;
// Service layer interfaces
export interface WebhookTriggerArgs {
trigger: WebhookTriggerEvents;
evt: CalendarEvent;
booking: {
id: number;
eventTypeId: number | null;
userId: number | null;
startTime?: Date;
smsReminderNumber?: string | null;
};
eventType: {
id: number;
title: string;
description: string | null;
requiresConfirmation: boolean;
price: number;
currency: string;
length: number;
teamId?: number | null;
} | null;
teamId?: number | null;
orgId?: number | null;
platformClientId?: string;
isDryRun?: boolean;
status?: "ACCEPTED" | "PENDING";
metadata?: Record<string, unknown>;
platformParams?: {
platformClientId?: string;
platformRescheduleUrl?: string;
platformCancelUrl?: string;
platformBookingUrl?: string;
};
cancelledBy?: string;
cancellationReason?: string;
rescheduleId?: number;
rescheduleUid?: string;
rescheduleStartTime?: string;
rescheduleEndTime?: string;
rescheduledBy?: string;
paymentId?: number;
paymentData?: Record<string, unknown>;
}
export interface WebhookSubscriber {
id: string;
subscriberUrl: string;
payloadTemplate: string | null;
appId: string | null;
secret: string | null;
time?: number | null;
timeUnit?: TimeUnit | null;
eventTriggers: WebhookTriggerEvents[];
}
export interface WebhookDeliveryResult {
ok: boolean;
status: number;
message?: string;
duration?: number;
subscriberUrl: string;
webhookId: string;
}
// Legacy types moved from sendPayload.ts for backward compatibility
export type EventTypeInfo = {
eventTitle?: string | null;
eventDescription?: string | null;
requiresConfirmation?: boolean | null;
price?: number | null;
currency?: string | null;
length?: number | null;
};
export type UTCOffset = {
utcOffset?: number | null;
};
export type WithUTCOffsetType<T> = T & {
user?: Person & UTCOffset;
} & {
organizer?: Person & UTCOffset;
} & {
attendees?: (Person & UTCOffset)[];
};
export type BookingNoShowUpdatedPayload = {
message: string;
bookingUid: string;
bookingId?: number;
attendees: { email: string; noShow: boolean }[];
};
export type TranscriptionGeneratedPayload = {
downloadLinks?: {
transcription: TGetTranscriptAccessLink["transcription"];
recording: string;
};
};
export type OOOEntryPayloadType = {
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;
timeZone?: string;
email?: string;
} | null;
uuid: string;
};
};
export type EventPayloadType = CalendarEvent &
TranscriptionGeneratedPayload &
EventTypeInfo & {
metadata?: { [key: string]: string | number | boolean | null };
bookingId?: number;
status?: string;
smsReminderNumber?: string;
rescheduleId?: number;
rescheduleUid?: string;
rescheduleStartTime?: string;
rescheduleEndTime?: string;
downloadLink?: string;
paymentId?: number;
rescheduledBy?: string;
cancelledBy?: string;
paymentData?: Record<string, unknown>;
};
// dto/types.ts
export type BookingWebhookEventDTO =
| BookingCreatedDTO
| BookingCancelledDTO
| BookingRequestedDTO
| BookingRescheduledDTO
| BookingPaidDTO
| BookingPaymentInitiatedDTO
| BookingRejectedDTO
| BookingNoShowDTO;
@@ -0,0 +1,209 @@
import { getUTCOffsetByTimezone } from "@calcom/lib/dayjs";
import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { EventTypeInfo, BookingWebhookEventDTO } from "../dto/types";
import type { WebhookPayload } from "./types";
type BookingExtraDataMap = {
[WebhookTriggerEvents.BOOKING_CREATED]: null;
[WebhookTriggerEvents.BOOKING_CANCELLED]: { cancelledBy?: string; cancellationReason?: string };
[WebhookTriggerEvents.BOOKING_REQUESTED]: null;
[WebhookTriggerEvents.BOOKING_REJECTED]: null;
[WebhookTriggerEvents.BOOKING_RESCHEDULED]: {
rescheduleId?: number;
rescheduleUid?: string;
rescheduleStartTime?: string;
rescheduleEndTime?: string;
rescheduledBy?: string;
};
[WebhookTriggerEvents.BOOKING_PAID]: { paymentId?: number; paymentData?: Record<string, unknown> };
[WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED]: {
paymentId?: number;
paymentData?: Record<string, unknown>;
};
};
interface CreateBookingWebhookPayloadParams<T extends keyof BookingExtraDataMap> {
booking: {
id: number;
eventTypeId: number | null;
userId: number | null;
smsReminderNumber?: string | null;
};
eventType: EventTypeInfo;
evt: CalendarEvent;
status: BookingStatus;
triggerEvent: WebhookTriggerEvents;
createdAt: string;
extra?: BookingExtraDataMap[T];
}
function createBookingWebhookPayload<T extends keyof BookingExtraDataMap>(
params: CreateBookingWebhookPayloadParams<T>
): WebhookPayload {
const utcOffsetOrganizer = getUTCOffsetByTimezone(params.evt.organizer?.timeZone, params.evt.startTime);
const organizer = { ...params.evt.organizer, utcOffset: utcOffsetOrganizer };
return {
triggerEvent: params.triggerEvent,
createdAt: params.createdAt,
payload: {
...params.evt,
bookingId: params.booking.id,
startTime: params.evt.startTime,
endTime: params.evt.endTime,
title: params.evt.title,
type: params.evt.type,
organizer,
attendees:
params.evt.attendees?.map((a) => ({
...a,
utcOffset: getUTCOffsetByTimezone(a.timeZone, params.evt.startTime),
})) ?? [],
location: params.evt.location,
uid: params.evt.uid,
customInputs: params.evt.customInputs,
responses: params.evt.responses,
userFieldsResponses: params.evt.userFieldsResponses,
status: params.status,
eventTitle: params.eventType?.eventTitle,
eventDescription: params.eventType?.eventDescription,
requiresConfirmation: params.eventType?.requiresConfirmation,
price: params.eventType?.price,
currency: params.eventType?.currency,
length: params.eventType?.length,
smsReminderNumber: params.booking.smsReminderNumber || undefined,
description: params.evt.description || params.evt.additionalNotes,
...(params.extra || {}),
},
};
}
const BOOKING_WEBHOOK_EVENTS: WebhookTriggerEvents[] = [
WebhookTriggerEvents.BOOKING_CREATED,
WebhookTriggerEvents.BOOKING_CANCELLED,
WebhookTriggerEvents.BOOKING_REQUESTED,
WebhookTriggerEvents.BOOKING_RESCHEDULED,
WebhookTriggerEvents.BOOKING_REJECTED,
WebhookTriggerEvents.BOOKING_PAID,
WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED,
WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED,
];
export class BookingPayloadBuilder {
canHandle(triggerEvent: WebhookTriggerEvents): boolean {
return BOOKING_WEBHOOK_EVENTS.includes(triggerEvent);
}
build(dto: BookingWebhookEventDTO): WebhookPayload {
switch (dto.triggerEvent) {
case WebhookTriggerEvents.BOOKING_CREATED:
return createBookingWebhookPayload({
booking: dto.booking,
eventType: dto.eventType,
evt: dto.evt,
status: BookingStatus.ACCEPTED,
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
});
case WebhookTriggerEvents.BOOKING_CANCELLED:
return createBookingWebhookPayload({
booking: dto.booking,
eventType: dto.eventType,
evt: dto.evt,
status: BookingStatus.CANCELLED,
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
extra: {
cancelledBy: dto.cancelledBy,
cancellationReason: dto.cancellationReason,
},
});
case WebhookTriggerEvents.BOOKING_REQUESTED:
return createBookingWebhookPayload({
booking: dto.booking,
eventType: dto.eventType,
evt: dto.evt,
status: BookingStatus.PENDING,
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
});
case WebhookTriggerEvents.BOOKING_REJECTED:
return createBookingWebhookPayload({
booking: dto.booking,
eventType: dto.eventType,
evt: dto.evt,
status: BookingStatus.REJECTED,
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
});
case WebhookTriggerEvents.BOOKING_RESCHEDULED:
return createBookingWebhookPayload({
booking: dto.booking,
eventType: dto.eventType,
evt: dto.evt,
status: BookingStatus.ACCEPTED,
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
extra: {
rescheduleId: dto.rescheduleId,
rescheduleUid: dto.rescheduleUid,
rescheduleStartTime: dto.rescheduleStartTime,
rescheduleEndTime: dto.rescheduleEndTime,
rescheduledBy: dto.rescheduledBy,
},
});
case WebhookTriggerEvents.BOOKING_PAID:
return createBookingWebhookPayload({
booking: dto.booking,
eventType: dto.eventType,
evt: dto.evt,
status: BookingStatus.ACCEPTED,
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
extra: {
paymentId: dto.paymentId,
paymentData: dto.paymentData,
},
});
case WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED:
return createBookingWebhookPayload({
booking: dto.booking,
eventType: dto.eventType,
evt: dto.evt,
status: BookingStatus.ACCEPTED,
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
extra: {
paymentId: dto.paymentId,
paymentData: dto.paymentData,
},
});
case WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED:
return {
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
payload: {
bookingUid: dto.bookingUid,
bookingId: dto.bookingId,
attendees: dto.attendees,
message: dto.message,
},
};
default: {
// TypeScript exhaustiveness check - this should never happen if all cases are covered
const _exhaustiveCheck: never = dto;
throw new Error(`Unsupported booking trigger: ${JSON.stringify(_exhaustiveCheck)}`);
}
}
}
}
@@ -0,0 +1,60 @@
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/trpc/utils";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { FormSubmittedDTO, FormSubmittedNoEventDTO } from "../dto/types";
import type { WebhookPayload } from "./types";
export class FormPayloadBuilder {
canHandle(triggerEvent: WebhookTriggerEvents): boolean {
return (
triggerEvent === WebhookTriggerEvents.FORM_SUBMITTED ||
triggerEvent === WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT
);
}
build(dto: FormSubmittedDTO | FormSubmittedNoEventDTO): WebhookPayload {
// Type the responses properly using the routing-forms type
const responses = dto.response.data;
// Create properly typed payload with primitive types
const payload: {
formId: string;
formName: string;
teamId: number | null;
responses: FORM_SUBMITTED_WEBHOOK_RESPONSES;
[key: string]: unknown; // For backward compatibility fields
} = {
formId: dto.form.id,
formName: dto.form.name,
teamId: dto.teamId ?? null,
responses,
};
// Add unwrapped response fields at root level for backwards compatibility
// This ensures both `value` (deprecated) and `response` (new) are available
Object.entries(responses).forEach(([fieldKey, fieldValue]) => {
if (fieldValue && typeof fieldValue === "object") {
// Each field should have both `value` (deprecated) and `response` (new)
const responseField = fieldValue as {
value?: unknown;
response?: unknown;
};
// Add the field value directly to payload root for backward compatibility
// This preserves the legacy behavior where field values were at root level
if (responseField.value !== undefined) {
payload[fieldKey] = responseField.value;
} else if (responseField.response !== undefined) {
// Fallback to response if value is not present
payload[fieldKey] = responseField.response;
}
}
});
return {
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
payload,
};
}
}
@@ -0,0 +1,26 @@
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { InstantMeetingDTO } from "../dto/types";
import type { WebhookPayload } from "./types";
export class InstantMeetingBuilder {
canHandle(triggerEvent: WebhookTriggerEvents): boolean {
return triggerEvent === WebhookTriggerEvents.INSTANT_MEETING;
}
build(dto: InstantMeetingDTO): WebhookPayload {
return {
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
payload: {
title: dto.title,
body: dto.body,
icon: dto.icon,
url: dto.url,
actions: dto.actions,
requireInteraction: dto.requireInteraction,
type: dto.type,
},
};
}
}
@@ -0,0 +1,21 @@
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { MeetingStartedDTO, MeetingEndedDTO } from "../dto/types";
import type { WebhookPayload } from "./types";
export class MeetingPayloadBuilder {
canHandle(triggerEvent: WebhookTriggerEvents): boolean {
return (
triggerEvent === WebhookTriggerEvents.MEETING_STARTED ||
triggerEvent === WebhookTriggerEvents.MEETING_ENDED
);
}
build(dto: MeetingStartedDTO | MeetingEndedDTO): WebhookPayload {
return {
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
payload: { ...dto.booking },
};
}
}
@@ -0,0 +1,18 @@
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { OOOCreatedDTO } from "../dto/types";
import type { WebhookPayload } from "./types";
export class OOOPayloadBuilder {
canHandle(triggerEvent: WebhookTriggerEvents): boolean {
return triggerEvent === WebhookTriggerEvents.OOO_CREATED;
}
build(dto: OOOCreatedDTO): WebhookPayload {
return {
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
payload: { oooEntry: dto.oooEntry },
};
}
}
@@ -0,0 +1,31 @@
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { RecordingReadyDTO, TranscriptionGeneratedDTO } from "../dto/types";
import type { WebhookPayload } from "./types";
export class RecordingPayloadBuilder {
canHandle(triggerEvent: WebhookTriggerEvents): boolean {
return (
triggerEvent === WebhookTriggerEvents.RECORDING_READY ||
triggerEvent === WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED
);
}
build(dto: RecordingReadyDTO | TranscriptionGeneratedDTO): WebhookPayload {
if (dto.triggerEvent === WebhookTriggerEvents.RECORDING_READY) {
return {
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
payload: { downloadLink: dto.downloadLink },
};
}
return {
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
payload: {
downloadLinks: dto.downloadLinks,
},
};
}
}
@@ -0,0 +1,94 @@
import type { EventPayloadType, OOOEntryPayloadType, BookingNoShowUpdatedPayload } from "../dto/types";
export interface FormSubmittedPayload {
formId: string;
formName: string;
teamId?: number | null;
redirect?: Record<string, unknown>;
responses: Record<string, unknown>;
}
export interface RecordingPayload {
downloadLink?: string;
downloadLinks?: {
transcription?: Array<{
format: string;
link: string;
}>;
recording?: string;
};
}
export interface MeetingPayload {
id: number;
startTime: Date;
endTime: Date;
title: string;
description: string | null;
customInputs: Record<string, unknown> | null;
responses: Record<string, unknown> | null;
userFieldsResponses: Record<string, unknown> | null;
location: string | null;
status: string;
user: {
username: string | null;
name: string | null;
email: string;
timeZone: string;
locale: string | null;
} | null;
eventType: {
title: string;
description: string | null;
requiresConfirmation: boolean;
price: number;
currency: string;
length: number;
team: {
logoUrl: string | null;
parent: {
logoUrl: string | null;
name: string;
} | null;
} | null;
} | null;
attendees: {
name: string;
email: string;
timeZone: string;
}[];
}
export interface InstantMeetingPayload {
title: string;
body: string;
icon?: string;
url?: string;
actions?: { action: string; title: string; type: string; image: string | null }[];
requireInteraction?: boolean;
type: string;
}
export interface NoShowWebhookPayload {
bookingId: number;
webhook: {
id: string;
subscriberUrl: string;
time: number;
timeUnit: string;
};
}
export interface WebhookPayload {
triggerEvent: string;
createdAt: string;
payload:
| EventPayloadType
| OOOEntryPayloadType
| BookingNoShowUpdatedPayload
| FormSubmittedPayload
| RecordingPayload
| MeetingPayload
| InstantMeetingPayload
| NoShowWebhookPayload;
}
@@ -0,0 +1,16 @@
export interface ITasker {
create(
taskName: string,
payload: string,
options?: { scheduledAt?: Date; referenceUid?: string }
): Promise<string>;
cancelWithReference(referenceUid: string, taskName: string): Promise<string | null>;
}
export interface ILogger {
debug(message: string, meta?: Record<string, unknown>): void;
info(message: string, meta?: Record<string, unknown>): void;
warn(message: string, meta?: Record<string, unknown>): void;
error(message: string, meta?: Record<string, unknown>): void;
getSubLogger(options: { prefix: string[] }): ILogger;
}
@@ -0,0 +1,16 @@
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { WebhookSubscriber } from "../dto/types";
export interface GetSubscribersOptions {
userId?: number | null;
eventTypeId?: number | null;
triggerEvent: WebhookTriggerEvents;
teamId?: number | number[] | null;
orgId?: number | null;
oAuthClientId?: string | null;
}
export interface IWebhookRepository {
getSubscribers(options: GetSubscribersOptions): Promise<WebhookSubscriber[]>;
}
@@ -0,0 +1,170 @@
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { WebhookSubscriber } from "../dto/types";
import type { WebhookPayload } from "../factory/types";
import type {
BookingPaymentInitiatedParams,
BookingCreatedParams,
BookingCancelledParams,
BookingRequestedParams,
BookingRescheduledParams,
BookingPaidParams,
BookingNoShowParams,
BookingRejectedParams,
ScheduleMeetingWebhooksParams,
CancelScheduledMeetingWebhooksParams,
ScheduleNoShowWebhooksParams,
} from "../types/params";
export interface GetSubscribersOptions {
userId?: number | null;
eventTypeId?: number | null;
triggerEvent: WebhookTriggerEvents;
teamId?: number | number[] | null;
orgId?: number | null;
oAuthClientId?: string | null;
}
// Repository Interface - Data access operations
export interface IWebhookRepository {
getSubscribers(options: GetSubscribersOptions): Promise<WebhookSubscriber[]>;
}
// Processing Interface - Webhook processing operations
export interface IWebhookProcessor {
processWebhooks(
trigger: WebhookTriggerEvents,
payload: WebhookPayload,
subscribers: WebhookSubscriber[]
): Promise<void>;
}
// Scheduling Interface - Webhook scheduling operations
export interface IWebhookScheduler {
scheduleTimeBasedWebhook(
trigger: WebhookTriggerEvents,
scheduledAt: Date,
bookingData: Record<string, unknown>,
subscriber: WebhookSubscriber,
evt: Record<string, unknown>,
isDryRun?: boolean
): Promise<void>;
cancelScheduledWebhooks(
bookingId: number,
triggers: WebhookTriggerEvents[],
isDryRun?: boolean
): Promise<void>;
scheduleDelayedWebhooks(
trigger: WebhookTriggerEvents,
payload: WebhookPayload,
scheduledAt: Date,
options?: { teamId?: number | number[] | null; orgId?: number | null },
subscribers?: WebhookSubscriber[],
isDryRun?: boolean
): Promise<void>;
}
// Combined interface for backward compatibility (can be removed later)
export interface IWebhookService extends IWebhookRepository, IWebhookProcessor, IWebhookScheduler {}
// Event Emission Interface - Direct parameter-based methods
export interface IBookingEventEmitter {
emitBookingPaymentInitiated(params: BookingPaymentInitiatedParams): Promise<void>;
emitBookingCreated(params: BookingCreatedParams): Promise<void>;
emitBookingCancelled(params: BookingCancelledParams): Promise<void>;
emitBookingRequested(params: BookingRequestedParams): Promise<void>;
emitBookingRescheduled(params: BookingRescheduledParams): Promise<void>;
emitBookingPaid(params: BookingPaidParams): Promise<void>;
emitBookingNoShow(params: BookingNoShowParams): Promise<void>;
emitBookingRejected(params: BookingRejectedParams): Promise<void>;
}
// Scheduling Interface - Webhook scheduling operations
export interface IBookingScheduler {
scheduleMeetingWebhooks(params: ScheduleMeetingWebhooksParams): Promise<void>;
cancelScheduledMeetingWebhooks(params: CancelScheduledMeetingWebhooksParams): Promise<void>;
scheduleNoShowWebhooks(params: ScheduleNoShowWebhooksParams): Promise<void>;
}
// Combined interface for backward compatibility (can be removed later)
export interface IBookingWebhookService extends IBookingEventEmitter, IBookingScheduler {}
// Form Event Emission Interface - Form event emission operations
export interface IFormEventEmitter {
emitFormSubmitted(params: {
form: { id: string; name: string };
response: { id: number; data: Record<string, unknown> };
eventTypeId?: number | null;
userId?: number | null;
teamId?: number | null;
orgId?: number | null;
platformClientId?: string;
isDryRun?: boolean;
}): Promise<void>;
emitFormSubmittedNoEvent(params: {
form: { id: string; name: string };
response: { id: number; data: Record<string, unknown> };
userId?: number | null;
teamId?: number | null;
orgId?: number | null;
platformClientId?: string;
isDryRun?: boolean;
}): Promise<void>;
}
// Form Scheduling Interface - Form webhook scheduling operations
export interface IFormScheduler {
scheduleDelayedFormWebhooks(params: {
responseId: number;
form: {
id: string;
name: string;
teamId?: number | null;
};
responses: Record<string, unknown>;
redirect?: Record<string, unknown>;
teamId?: number | null;
orgId?: number | null;
delayMinutes?: number;
}): Promise<void>;
}
// Combined interface for backward compatibility (can be removed later)
export interface IFormWebhookService extends IFormEventEmitter, IFormScheduler {}
export interface IRecordingWebhookService {
emitRecordingReady(params: {
evt: import("@calcom/types/Calendar").CalendarEvent;
downloadLink: string;
booking?: {
id: number;
eventTypeId?: number | null;
userId?: number | null;
};
teamId?: number | null;
orgId?: number | null;
platformClientId?: string;
isDryRun?: boolean;
}): Promise<void>;
emitTranscriptionGenerated(params: {
evt: import("@calcom/types/Calendar").CalendarEvent;
downloadLinks?: {
transcription?: Array<{
format: string;
link: string;
}>;
recording?: string;
};
booking?: {
id: number;
eventTypeId?: number | null;
userId?: number | null;
};
teamId?: number | null;
orgId?: number | null;
platformClientId?: string;
isDryRun?: boolean;
}): Promise<void>;
}
@@ -0,0 +1,8 @@
import type { WebhookEventDTO } from "../dto/types";
export interface IWebhookNotifier {
emitWebhook(dto: WebhookEventDTO, isDryRun?: boolean): Promise<void>;
}
export interface IWebhookNotificationHandler {
handleNotification(dto: WebhookEventDTO, isDryRun?: boolean): Promise<void>;
}
@@ -0,0 +1,209 @@
import { withReporting } from "@calcom/lib/sentryWrapper";
import { prisma as defaultPrisma } from "@calcom/prisma";
import type { PrismaClient } from "@calcom/prisma";
import type { TimeUnit, WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { WebhookSubscriber } from "../dto/types";
import type { IWebhookRepository } from "../interface/repository";
import type { GetSubscribersOptions } from "./types";
// Type for raw query results from the database
interface WebhookQueryResult {
id: string;
subscriberUrl: string;
payloadTemplate: string | null;
appId: string | null;
secret: string | null;
time: number | null;
timeUnit: TimeUnit | null;
eventTriggers: WebhookTriggerEvents[];
priority: number; // This field is added by the query and removed before returning
}
export class WebhookRepository implements IWebhookRepository {
constructor(private prisma: PrismaClient = defaultPrisma) {}
async getSubscribers(options: GetSubscribersOptions): Promise<WebhookSubscriber[]> {
const teamId = options.teamId;
const userId = options.userId;
const eventTypeId = options.eventTypeId;
const teamIds = Array.isArray(teamId) ? teamId : teamId ? [teamId] : undefined;
const orgId = options.orgId;
const oAuthClientId = options.oAuthClientId;
let managedParentEventTypeId: number | undefined;
if (eventTypeId) {
const managedChildEventType = await this.prisma.eventType.findFirst({
where: {
id: eventTypeId,
parentId: {
not: null,
},
},
select: {
parentId: true,
},
});
managedParentEventTypeId = managedChildEventType?.parentId ?? undefined;
}
const webhooks = await this.getSubscribersRaw({
userId,
eventTypeId,
managedParentEventTypeId,
teamIds: teamIds && orgId ? [...teamIds, orgId] : teamIds || (orgId ? [orgId] : undefined),
oAuthClientId,
triggerEvent: options.triggerEvent,
});
return webhooks.map((webhook) => ({
id: webhook.id,
subscriberUrl: webhook.subscriberUrl,
payloadTemplate: webhook.payloadTemplate,
appId: webhook.appId,
secret: webhook.secret,
time: webhook.time,
timeUnit: webhook.timeUnit as TimeUnit | null,
eventTriggers: webhook.eventTriggers as WebhookTriggerEvents[],
}));
}
/**
* Raw SQL query using UNION for better index utilization than complex ORs
* Each UNION branch can use its own optimal index
*/
private async getSubscribersRaw(params: {
userId?: number | null;
eventTypeId?: number | null;
managedParentEventTypeId?: number | null;
teamIds?: number[];
oAuthClientId?: string | null;
triggerEvent: WebhookTriggerEvents;
}): Promise<WebhookSubscriber[]> {
const { userId, eventTypeId, managedParentEventTypeId, teamIds, oAuthClientId, triggerEvent } = params;
// Use static SQL with IS NOT NULL guards and PostgreSQL ANY() for arrays
const results = await this.prisma.$queryRaw<WebhookQueryResult[]>`
-- Platform webhooks (highest priority)
SELECT
id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers",
1 as priority
FROM "Webhook"
WHERE active = true
AND platform = true
AND ${triggerEvent}::"WebhookTriggerEvents" = ANY("eventTriggers")
UNION ALL
-- User-specific webhooks (only if userId provided)
SELECT
id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers",
2 as priority
FROM "Webhook"
WHERE active = true
AND ${userId} IS NOT NULL
AND "userId" = ${userId}
AND ${triggerEvent}::"WebhookTriggerEvents" = ANY("eventTriggers")
AND platform = false
UNION ALL
-- Event type webhooks (only if eventTypeId provided)
SELECT
id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers",
3 as priority
FROM "Webhook"
WHERE active = true
AND ${eventTypeId} IS NOT NULL
AND "eventTypeId" = ${eventTypeId}
AND ${triggerEvent}::"WebhookTriggerEvents" = ANY("eventTriggers")
AND platform = false
UNION ALL
-- Parent event type webhooks (only if managedParentEventTypeId provided)
SELECT
id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers",
4 as priority
FROM "Webhook"
WHERE active = true
AND ${managedParentEventTypeId} IS NOT NULL
AND "eventTypeId" = ${managedParentEventTypeId}
AND ${triggerEvent}::"WebhookTriggerEvents" = ANY("eventTriggers")
AND platform = false
UNION ALL
-- Team webhooks (only if teamIds provided and not empty)
SELECT
id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers",
5 as priority
FROM "Webhook"
WHERE active = true
AND ${teamIds} IS NOT NULL
AND cardinality(${teamIds}::int[]) > 0
AND "teamId" = ANY(${teamIds}::int[])
AND ${triggerEvent}::"WebhookTriggerEvents" = ANY("eventTriggers")
AND platform = false
UNION ALL
-- OAuth client webhooks (only if oAuthClientId provided)
SELECT
id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers",
6 as priority
FROM "Webhook"
WHERE active = true
AND ${oAuthClientId} IS NOT NULL
AND "platformOAuthClientId" = ${oAuthClientId}
AND ${triggerEvent}::"WebhookTriggerEvents" = ANY("eventTriggers")
AND platform = false
ORDER BY priority, id
`;
const uniqueWebhooks = new Map<string, WebhookSubscriber>();
for (const webhook of results) {
if (!uniqueWebhooks.has(webhook.id)) {
const { priority: _priority, ...webhookData } = webhook;
uniqueWebhooks.set(webhook.id, webhookData);
}
}
return Array.from(uniqueWebhooks.values());
}
async getWebhookById(id: string): Promise<WebhookSubscriber | null> {
const webhook = await this.prisma.webhook.findUnique({
where: { id },
select: {
id: true,
subscriberUrl: true,
payloadTemplate: true,
appId: true,
secret: true,
time: true,
timeUnit: true,
eventTriggers: true,
},
});
if (!webhook) return null;
return {
id: webhook.id,
subscriberUrl: webhook.subscriberUrl,
payloadTemplate: webhook.payloadTemplate,
appId: webhook.appId,
secret: webhook.secret,
time: webhook.time,
timeUnit: webhook.timeUnit as TimeUnit | null,
eventTriggers: webhook.eventTriggers,
};
}
}
export const webhookRepository = withReporting(
(options: GetSubscribersOptions) => new WebhookRepository().getSubscribers(options),
"WebhookRepository.getSubscribers"
);
@@ -0,0 +1,10 @@
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
export interface GetSubscribersOptions {
userId?: number | null;
eventTypeId?: number | null;
triggerEvent: WebhookTriggerEvents;
teamId?: number | number[] | null;
orgId?: number | null;
oAuthClientId?: string | null;
}
@@ -0,0 +1,438 @@
import dayjs from "@calcom/dayjs";
import type { TimeUnit } from "@calcom/prisma/enums";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type {
BookingCreatedDTO,
BookingCancelledDTO,
BookingRequestedDTO,
BookingRescheduledDTO,
BookingPaidDTO,
BookingPaymentInitiatedDTO,
BookingNoShowDTO,
BookingRejectedDTO,
WebhookSubscriber,
} from "../dto/types";
import type { IWebhookNotifier, IWebhookService, ITasker, IBookingWebhookService } from "../interface";
import type { ILogger } from "../interface/infrastructure";
import type {
BookingCreatedParams,
BookingCancelledParams,
BookingRequestedParams,
BookingRescheduledParams,
BookingPaidParams,
BookingPaymentInitiatedParams,
BookingNoShowParams,
BookingRejectedParams,
ScheduleMeetingWebhooksParams,
CancelScheduledMeetingWebhooksParams,
ScheduleNoShowWebhooksParams,
} from "../types";
export class BookingWebhookService implements IBookingWebhookService {
private readonly log: ILogger;
constructor(
private readonly webhookNotifier: IWebhookNotifier,
private readonly webhookService: IWebhookService,
private readonly tasker: ITasker,
logger: ILogger
) {
this.log = logger.getSubLogger({ prefix: ["[BookingWebhookService]"] });
}
private getTasker(): ITasker {
return this.tasker;
}
async emitBookingPaymentInitiated(params: BookingPaymentInitiatedParams): Promise<void> {
if (!params.eventType) {
throw new Error("eventType is required for booking webhook events");
}
const dto: BookingPaymentInitiatedDTO = {
triggerEvent: WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED,
createdAt: new Date().toISOString(),
bookingId: params.booking.id,
eventTypeId: params.eventType.id,
userId: params.booking.userId,
teamId: params.teamId,
orgId: params.orgId,
platformClientId: params.platformClientId,
evt: params.evt,
eventType: params.eventType,
booking: params.booking,
paymentId: params.paymentId,
paymentData: params.paymentData,
};
await this.webhookNotifier.emitWebhook(dto, params.isDryRun);
}
async emitBookingCreated(params: BookingCreatedParams): Promise<void> {
if (!params.eventType) {
throw new Error("eventType is required for booking webhook events");
}
const dto: BookingCreatedDTO = {
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
createdAt: new Date().toISOString(),
bookingId: params.booking.id,
eventTypeId: params.eventType.id,
userId: params.booking.userId,
teamId: params.teamId,
orgId: params.orgId,
platformClientId: params.platformClientId ?? params.platformParams?.platformClientId,
evt: params.evt,
eventType: params.eventType,
booking: {
...params.booking,
startTime: params.booking.startTime ?? new Date(params.evt.startTime),
},
status: params.status || "ACCEPTED",
metadata: params.metadata,
platformParams: params.platformParams,
};
await this.webhookNotifier.emitWebhook(dto, params.isDryRun);
}
async emitBookingCancelled(params: BookingCancelledParams): Promise<void> {
if (!params.eventType) {
throw new Error("eventType is required for booking webhook events");
}
const dto: BookingCancelledDTO = {
triggerEvent: WebhookTriggerEvents.BOOKING_CANCELLED,
createdAt: new Date().toISOString(),
bookingId: params.booking.id,
eventTypeId: params.eventType.id,
userId: params.booking.userId,
teamId: params.teamId,
orgId: params.orgId,
platformClientId: params.platformClientId,
evt: params.evt,
eventType: params.eventType,
booking: params.booking,
cancelledBy: params.cancelledBy,
cancellationReason: params.cancellationReason,
};
await this.webhookNotifier.emitWebhook(dto, params.isDryRun);
}
async emitBookingRequested(params: BookingRequestedParams): Promise<void> {
if (!params.eventType) {
throw new Error("eventType is required for booking webhook events");
}
const dto: BookingRequestedDTO = {
triggerEvent: WebhookTriggerEvents.BOOKING_REQUESTED,
createdAt: new Date().toISOString(),
bookingId: params.booking.id,
eventTypeId: params.eventType.id,
userId: params.booking.userId,
teamId: params.teamId,
orgId: params.orgId,
platformClientId: params.platformClientId,
evt: params.evt,
eventType: params.eventType,
booking: params.booking,
};
await this.webhookNotifier.emitWebhook(dto, params.isDryRun);
}
async emitBookingRescheduled(params: BookingRescheduledParams): Promise<void> {
if (!params.eventType) {
throw new Error("eventType is required for booking webhook events");
}
const dto: BookingRescheduledDTO = {
triggerEvent: WebhookTriggerEvents.BOOKING_RESCHEDULED,
createdAt: new Date().toISOString(),
bookingId: params.booking.id,
eventTypeId: params.eventType.id,
userId: params.booking.userId,
teamId: params.teamId,
orgId: params.orgId,
platformClientId: params.platformClientId,
evt: params.evt,
eventType: params.eventType,
booking: params.booking,
rescheduleId: params.rescheduleId,
rescheduleUid: params.rescheduleUid,
rescheduleStartTime: params.rescheduleStartTime,
rescheduleEndTime: params.rescheduleEndTime,
rescheduledBy: params.rescheduledBy,
};
await this.webhookNotifier.emitWebhook(dto, params.isDryRun);
}
async emitBookingPaid(params: BookingPaidParams): Promise<void> {
if (!params.eventType) {
throw new Error("eventType is required for booking webhook events");
}
const dto: BookingPaidDTO = {
triggerEvent: WebhookTriggerEvents.BOOKING_PAID,
createdAt: new Date().toISOString(),
bookingId: params.booking.id,
eventTypeId: params.eventType.id,
userId: params.booking.userId,
teamId: params.teamId,
orgId: params.orgId,
platformClientId: params.platformClientId,
evt: params.evt,
eventType: params.eventType,
booking: params.booking,
paymentId: params.paymentId,
paymentData: params.paymentData,
};
await this.webhookNotifier.emitWebhook(dto, params.isDryRun);
}
async emitBookingNoShow(params: BookingNoShowParams): Promise<void> {
const dto: BookingNoShowDTO = {
triggerEvent: WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED,
createdAt: new Date().toISOString(),
bookingId: params.bookingId,
eventTypeId: params.eventTypeId,
userId: params.userId,
teamId: params.teamId,
orgId: params.orgId,
platformClientId: params.platformClientId,
message: params.message,
bookingUid: params.bookingUid,
attendees: params.attendees,
};
await this.webhookNotifier.emitWebhook(dto, params.isDryRun);
}
async emitBookingRejected(params: BookingRejectedParams): Promise<void> {
if (!params.eventType) {
throw new Error("eventType is required for booking webhook events");
}
const dto: BookingRejectedDTO = {
triggerEvent: WebhookTriggerEvents.BOOKING_REJECTED,
createdAt: new Date().toISOString(),
bookingId: params.booking.id,
eventTypeId: params.eventType.id,
userId: params.booking.userId,
teamId: params.teamId,
orgId: params.orgId,
status: "REJECTED",
platformClientId: params.platformClientId,
evt: params.evt,
eventType: params.eventType,
booking: params.booking,
};
await this.webhookNotifier.emitWebhook(dto, params.isDryRun);
}
async scheduleMeetingWebhooks(params: ScheduleMeetingWebhooksParams): Promise<void> {
const webhookService = this.webhookService;
const subscribersMeetingStarted = await webhookService.getSubscribers({
userId: params.booking.userId,
eventTypeId: params.booking.eventTypeId,
triggerEvent: WebhookTriggerEvents.MEETING_STARTED,
teamId: params.teamId,
orgId: params.orgId,
oAuthClientId: params.oAuthClientId,
});
const subscribersMeetingEnded = await webhookService.getSubscribers({
userId: params.booking.userId,
eventTypeId: params.booking.eventTypeId,
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
teamId: params.teamId,
orgId: params.orgId,
oAuthClientId: params.oAuthClientId,
});
const schedulePromises: Promise<void>[] = [];
for (const subscriber of subscribersMeetingStarted) {
schedulePromises.push(
webhookService.scheduleTimeBasedWebhook(
WebhookTriggerEvents.MEETING_STARTED,
params.booking.startTime,
{
id: params.booking.id,
uid: params.booking.uid,
eventTypeId: params.booking.eventTypeId,
userId: params.booking.userId,
teamId: params.teamId,
responses: params.booking.responses,
},
subscriber,
params.evt as unknown as Record<string, unknown>,
params.isDryRun
)
);
}
for (const subscriber of subscribersMeetingEnded) {
schedulePromises.push(
webhookService.scheduleTimeBasedWebhook(
WebhookTriggerEvents.MEETING_ENDED,
params.booking.endTime,
{
id: params.booking.id,
uid: params.booking.uid,
eventTypeId: params.booking.eventTypeId,
userId: params.booking.userId,
teamId: params.teamId,
responses: params.booking.responses,
},
subscriber,
params.evt as unknown as Record<string, unknown>,
params.isDryRun
)
);
}
// Use Promise.allSettled to prevent single webhook scheduling failure from killing entire processor
const results = await Promise.allSettled(schedulePromises);
// Log summary for monitoring
const successCount = results.filter((result) => result.status === "fulfilled").length;
const failureCount = results.filter((result) => result.status === "rejected").length;
this.log.info(`Meeting webhook scheduling completed`, {
bookingId: params.booking.id,
totalWebhooks: schedulePromises.length,
successful: successCount,
failed: failureCount,
});
// Log individual failures for debugging
if (failureCount > 0) {
results.forEach((result, index) => {
if (result.status === "rejected") {
this.log.error(`Meeting webhook scheduling failed`, {
bookingId: params.booking.id,
index,
error: result.reason,
});
}
});
}
}
async cancelScheduledMeetingWebhooks(params: CancelScheduledMeetingWebhooksParams): Promise<void> {
const webhookService = this.webhookService;
await webhookService.cancelScheduledWebhooks(
params.bookingId,
[WebhookTriggerEvents.MEETING_STARTED, WebhookTriggerEvents.MEETING_ENDED],
params.isDryRun
);
}
async scheduleNoShowWebhooks(params: ScheduleNoShowWebhooksParams): Promise<void> {
const webhookService = this.webhookService;
try {
const tasker = await this.getTasker();
const noShowPromises: Promise<string | void>[] = [];
const subscribersHostsNoShow = await webhookService.getSubscribers({
userId: params.triggerForUser ? params.organizerUser.id : null,
eventTypeId: params.booking.eventTypeId,
triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
teamId: params.teamId,
orgId: params.orgId,
oAuthClientId: params.oAuthClientId,
});
noShowPromises.push(
...subscribersHostsNoShow.map((webhook: WebhookSubscriber) => {
if (params.booking.startTime && webhook.time && webhook.timeUnit) {
const scheduledAt = dayjs
.utc(params.booking.startTime)
.add(webhook.time, webhook.timeUnit.toLowerCase() as dayjs.ManipulateType)
.toDate();
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 }
);
}
return Promise.resolve();
})
);
const subscribersGuestsNoShow = await webhookService.getSubscribers({
userId: params.triggerForUser ? params.organizerUser.id : null,
eventTypeId: params.booking.eventTypeId,
triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
teamId: params.teamId,
orgId: params.orgId,
oAuthClientId: params.oAuthClientId,
});
noShowPromises.push(
...subscribersGuestsNoShow.map((webhook: WebhookSubscriber) => {
if (params.booking.startTime && webhook.time && webhook.timeUnit) {
const scheduledAt = dayjs
.utc(params.booking.startTime)
.add(webhook.time, webhook.timeUnit.toLowerCase() as dayjs.ManipulateType)
.toDate();
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 }
);
}
return Promise.resolve();
})
);
// Use Promise.allSettled to prevent single webhook scheduling failure from killing entire processor
const results = await Promise.allSettled(noShowPromises);
// Log summary for monitoring
const successCount = results.filter((result) => result.status === "fulfilled").length;
const failureCount = results.filter((result) => result.status === "rejected").length;
this.log.info(`No-show webhook scheduling completed`, {
bookingId: params.booking.id,
totalWebhooks: noShowPromises.length,
successful: successCount,
failed: failureCount,
});
// Log individual failures for debugging
if (failureCount > 0) {
results.forEach((result, index) => {
if (result.status === "rejected") {
this.log.error(`No-show webhook scheduling failed`, {
bookingId: params.booking.id,
index,
error: result.reason,
});
}
});
}
} catch (error) {
this.log.error("Failed to schedule no-show webhooks:", {
error: error instanceof Error ? error.message : String(error),
});
}
}
}
@@ -0,0 +1,99 @@
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/trpc/utils";
import dayjs from "@calcom/dayjs";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { FormSubmittedDTO, FormSubmittedNoEventDTO } from "../dto/types";
import type { FormSubmittedPayload } from "../factory/types";
import type { IWebhookService, IFormWebhookService } from "../interface/services";
import type { IWebhookNotifier } from "../interface/webhook";
export class FormWebhookService implements IFormWebhookService {
constructor(
private readonly webhookNotifier: IWebhookNotifier,
private readonly webhookService: IWebhookService
) {}
async emitFormSubmitted(params: {
form: { id: string; name: string };
response: { id: number; data: FORM_SUBMITTED_WEBHOOK_RESPONSES };
eventTypeId?: number | null;
userId?: number | null;
teamId?: number | null;
orgId?: number | null;
platformClientId?: string;
isDryRun?: boolean;
}): Promise<void> {
const dto: FormSubmittedDTO = {
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED,
createdAt: new Date().toISOString(),
eventTypeId: params.eventTypeId,
userId: params.userId,
teamId: params.teamId,
orgId: params.orgId,
platformClientId: params.platformClientId,
form: params.form,
response: params.response,
};
await this.webhookNotifier.emitWebhook(dto, params.isDryRun);
}
async emitFormSubmittedNoEvent(params: {
form: { id: string; name: string };
response: { id: number; data: FORM_SUBMITTED_WEBHOOK_RESPONSES };
userId?: number | null;
teamId?: number | null;
orgId?: number | null;
platformClientId?: string;
isDryRun?: boolean;
}): Promise<void> {
const dto: FormSubmittedNoEventDTO = {
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT,
createdAt: new Date().toISOString(),
userId: params.userId,
teamId: params.teamId,
orgId: params.orgId,
platformClientId: params.platformClientId,
form: params.form,
response: params.response,
};
await this.webhookNotifier.emitWebhook(dto, params.isDryRun);
}
async scheduleDelayedFormWebhooks(params: {
responseId: number;
form: {
id: string;
name: string;
teamId?: number | null;
};
responses: FORM_SUBMITTED_WEBHOOK_RESPONSES;
redirect?: Record<string, unknown>;
teamId?: number | null;
orgId?: number | null;
delayMinutes?: number;
}): Promise<void> {
const delayMinutes = params.delayMinutes || 15;
const scheduledAt = dayjs().add(delayMinutes, "minute").toDate();
const payload = {
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT,
createdAt: new Date().toISOString(),
payload: {
formId: params.form.id,
formName: params.form.name,
teamId: params.teamId ?? null,
redirect: params.redirect,
responses: params.responses,
} as FormSubmittedPayload,
};
await this.webhookService.scheduleDelayedWebhooks(
WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT,
payload,
scheduledAt,
{ teamId: params.teamId, orgId: params.orgId }
);
}
}
@@ -0,0 +1,66 @@
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 { IWebhookNotifier } from "../interface/webhook";
import { WebhookService } from "./WebhookService";
export class OOOWebhookService extends WebhookService {
constructor(
private readonly notifier: IWebhookNotifier,
repository: IWebhookRepository,
tasker: ITasker,
logger: ILogger
) {
super(repository, tasker, logger);
}
async 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> {
const dto: OOOCreatedDTO = {
triggerEvent: WebhookTriggerEvents.OOO_CREATED,
createdAt: new Date().toISOString(),
userId: params.userId,
teamId: params.teamId,
orgId: params.orgId,
platformClientId: params.platformClientId,
oooEntry: params.oooEntry,
};
await this.notifier.emitWebhook(dto, params.isDryRun);
}
}
@@ -0,0 +1,73 @@
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { RecordingReadyDTO, TranscriptionGeneratedDTO } from "../dto/types";
import type { IRecordingWebhookService } from "../interface/services";
import type { IWebhookNotifier } from "../interface/webhook";
export class RecordingWebhookService implements IRecordingWebhookService {
constructor(private readonly webhookNotifier: IWebhookNotifier) {}
async emitRecordingReady(params: {
evt: CalendarEvent;
downloadLink: string;
booking?: {
id: number;
eventTypeId?: number | null;
userId?: number | null;
};
teamId?: number | null;
orgId?: number | null;
platformClientId?: string;
isDryRun?: boolean;
}): Promise<void> {
const dto: RecordingReadyDTO = {
triggerEvent: WebhookTriggerEvents.RECORDING_READY,
createdAt: new Date().toISOString(),
bookingId: params.booking?.id,
eventTypeId: params.booking?.eventTypeId,
userId: params.booking?.userId,
teamId: params.teamId,
orgId: params.orgId,
platformClientId: params.platformClientId,
evt: params.evt,
downloadLink: params.downloadLink,
};
await this.webhookNotifier.emitWebhook(dto, params.isDryRun);
}
async emitTranscriptionGenerated(params: {
evt: CalendarEvent;
downloadLinks?: {
transcription?: Array<{
format: string;
link: string;
}>;
recording?: string;
};
booking?: {
id: number;
eventTypeId?: number | null;
userId?: number | null;
};
teamId?: number | null;
orgId?: number | null;
platformClientId?: string;
isDryRun?: boolean;
}): Promise<void> {
const dto: TranscriptionGeneratedDTO = {
triggerEvent: WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED,
createdAt: new Date().toISOString(),
bookingId: params.booking?.id,
eventTypeId: params.booking?.eventTypeId,
userId: params.booking?.userId,
teamId: params.teamId,
orgId: params.orgId,
platformClientId: params.platformClientId,
evt: params.evt,
downloadLinks: params.downloadLinks,
};
await this.webhookNotifier.emitWebhook(dto, params.isDryRun);
}
}
@@ -0,0 +1,135 @@
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { WebhookEventDTO } from "../dto/types";
import type { BookingPayloadBuilder } from "../factory/BookingPayloadBuilder";
import type { FormPayloadBuilder } from "../factory/FormPayloadBuilder";
import type { InstantMeetingBuilder } from "../factory/InstantMeetingBuilder";
import type { MeetingPayloadBuilder } from "../factory/MeetingPayloadBuilder";
import type { OOOPayloadBuilder } from "../factory/OOOPayloadBuilder";
import type { RecordingPayloadBuilder } from "../factory/RecordingPayloadBuilder";
import type { WebhookPayload } from "../factory/types";
import type { ILogger } from "../interface/infrastructure";
import type { IWebhookService } from "../interface/services";
import type { IWebhookNotificationHandler } from "../interface/webhook";
export class WebhookNotificationHandler implements IWebhookNotificationHandler {
private readonly log: ILogger;
constructor(
private readonly webhookService: IWebhookService,
private readonly bookingPayloadBuilder: BookingPayloadBuilder,
private readonly formPayloadBuilder: FormPayloadBuilder,
private readonly oooPayloadBuilder: OOOPayloadBuilder,
private readonly recordingPayloadBuilder: RecordingPayloadBuilder,
private readonly meetingPayloadBuilder: MeetingPayloadBuilder,
private readonly instantMeetingBuilder: InstantMeetingBuilder,
logger: ILogger
) {
this.log = logger.getSubLogger({ prefix: ["[WebhookNotificationHandler]"] });
}
async handleNotification(dto: WebhookEventDTO, isDryRun = false): Promise<void> {
const trigger = dto.triggerEvent;
try {
if (isDryRun) {
this.log.debug(`Dry run mode - skipping webhook notification for: ${trigger}`);
return;
}
const subscriptionParams = {
userId: dto.userId,
eventTypeId: dto.eventTypeId,
triggerEvent: trigger,
teamId: dto.teamId,
orgId: dto.orgId,
oAuthClientId: dto.platformClientId,
};
this.log.debug(`Querying for webhook subscribers with params:`, subscriptionParams);
const subscribers = await this.webhookService.getSubscribers(subscriptionParams);
if (subscribers.length === 0) {
this.log.debug(`No subscribers found for event: ${trigger}`, {
bookingId: dto.bookingId,
eventTypeId: dto.eventTypeId,
});
return;
}
const webhookPayload = this.createPayload(dto);
await this.webhookService.processWebhooks(trigger, webhookPayload, subscribers);
this.log.debug(`Successfully processed webhook notification: ${trigger}`, {
subscriberCount: subscribers.length,
bookingId: dto.bookingId,
});
} catch (error) {
this.log.error(`Error handling webhook notification: ${trigger}`, {
error: error instanceof Error ? error.message : String(error),
bookingId: dto.bookingId,
eventTypeId: dto.eventTypeId,
});
throw error;
}
}
private createPayload(dto: WebhookEventDTO): WebhookPayload {
switch (dto.triggerEvent) {
// Booking events
case WebhookTriggerEvents.BOOKING_CREATED:
case WebhookTriggerEvents.BOOKING_CANCELLED:
case WebhookTriggerEvents.BOOKING_REQUESTED:
case WebhookTriggerEvents.BOOKING_RESCHEDULED:
case WebhookTriggerEvents.BOOKING_REJECTED:
case WebhookTriggerEvents.BOOKING_PAID:
case WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED:
case WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED:
return this.bookingPayloadBuilder.build(dto);
// Form events
case WebhookTriggerEvents.FORM_SUBMITTED:
case WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT:
return this.formPayloadBuilder.build(dto);
// OOO events
case WebhookTriggerEvents.OOO_CREATED:
return this.oooPayloadBuilder.build(dto);
// Recording events
case WebhookTriggerEvents.RECORDING_READY:
case WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED:
return this.recordingPayloadBuilder.build(dto);
// Meeting events
case WebhookTriggerEvents.MEETING_STARTED:
case WebhookTriggerEvents.MEETING_ENDED:
return this.meetingPayloadBuilder.build(dto);
// Instant meeting events
case WebhookTriggerEvents.INSTANT_MEETING:
return this.instantMeetingBuilder.build(dto);
// No-show events (special handling)
case WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW:
case WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW: {
return {
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
payload: {
bookingId: dto.bookingId,
webhook: dto.webhook,
},
};
}
default: {
// TypeScript exhaustiveness check - this should never happen if all cases are covered
const _exhaustiveCheck: never = dto;
throw new Error(`Unsupported triggerEvent: ${JSON.stringify(_exhaustiveCheck)}`);
}
}
}
}
@@ -0,0 +1,36 @@
import type { WebhookEventDTO } from "../dto/types";
import type { ILogger } from "../interface/infrastructure";
import type { IWebhookNotifier, IWebhookNotificationHandler } from "../interface/webhook";
export class WebhookNotifier implements IWebhookNotifier {
private readonly log: ILogger;
constructor(private readonly handler: IWebhookNotificationHandler, logger: ILogger) {
this.log = logger.getSubLogger({ prefix: ["[WebhookNotifier]"] });
}
async emitWebhook(dto: WebhookEventDTO, isDryRun = false): Promise<void> {
const trigger = dto.triggerEvent;
try {
this.log.debug(`Emitting webhook event: ${trigger}`, {
bookingId: dto.bookingId,
eventTypeId: dto.eventTypeId,
isDryRun,
});
await this.handler.handleNotification(dto, isDryRun);
this.log.debug(`Successfully emitted webhook event: ${trigger}`, {
bookingId: dto.bookingId,
});
} catch (error) {
this.log.error(`Failed to emit webhook event: ${trigger}`, {
error: error instanceof Error ? error.message : String(error),
bookingId: dto.bookingId,
eventTypeId: dto.eventTypeId,
});
throw error;
}
}
}
@@ -0,0 +1,351 @@
import { createHmac } from "crypto";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { WebhookSubscriber, WebhookDeliveryResult } from "../dto/types";
import type { WebhookPayload } from "../factory/types";
import type { ITasker, ILogger } from "../interface/infrastructure";
import type { IWebhookRepository, IWebhookService } from "../interface/services";
export class WebhookService implements IWebhookService {
private readonly log: ILogger;
constructor(
private readonly repository: IWebhookRepository,
private readonly tasker: ITasker,
logger: ILogger
) {
this.log = logger.getSubLogger({ prefix: ["[WebhookService]"] });
}
protected async sendWebhook(
trigger: WebhookTriggerEvents,
payload: WebhookPayload,
subscriber: WebhookSubscriber
): Promise<WebhookDeliveryResult> {
try {
// TODO: Ideally we should inject this flag as well. Would be awesome when we would be able to unit test without mocking env variables too. Also with trigger.dev, this would be further worked on, so we can leave this as is for now
if (process.env.TASKER_ENABLE_WEBHOOKS === "1") {
return await this.scheduleWebhook(trigger, payload, subscriber);
} else {
return await this.sendWebhookDirectly(trigger, payload, subscriber);
}
} catch (error) {
return {
ok: false,
status: 0,
message: error instanceof Error ? error.message : String(error),
duration: 0,
subscriberUrl: subscriber.subscriberUrl,
webhookId: subscriber.id,
};
}
}
private async scheduleWebhook(
trigger: WebhookTriggerEvents,
payload: WebhookPayload,
subscriber: WebhookSubscriber
): Promise<WebhookDeliveryResult> {
if (!this.tasker) {
throw new Error("Tasker not injected - ensure proper DI configuration");
}
await this.tasker.create(
"sendWebhook",
JSON.stringify({
secretKey: subscriber.secret,
triggerEvent: trigger,
createdAt: new Date().toISOString(),
webhook: subscriber,
data: payload.payload,
})
);
return {
ok: true,
status: 200,
message: "Webhook scheduled successfully",
duration: 0,
subscriberUrl: subscriber.subscriberUrl,
webhookId: subscriber.id,
};
}
private async sendWebhookDirectly(
trigger: WebhookTriggerEvents,
payload: WebhookPayload,
subscriber: WebhookSubscriber
): Promise<WebhookDeliveryResult> {
const { subscriberUrl, payloadTemplate } = subscriber;
if (!subscriberUrl) throw new Error("Missing subscriber URL");
const contentType =
!payloadTemplate || this.isJsonTemplate(payloadTemplate)
? "application/json"
: "application/x-www-form-urlencoded";
const body = JSON.stringify({
triggerEvent: trigger,
createdAt: payload.createdAt,
payload: payload.payload,
});
const signature = subscriber.secret
? createHmac("sha256", subscriber.secret).update(body).digest("hex")
: "no-secret-provided";
const response = await fetch(subscriberUrl, {
method: "POST",
headers: {
"Content-Type": contentType,
"X-Cal-Signature-256": signature,
},
redirect: "manual",
body,
});
const responseText = await response.text();
return {
ok: response.ok,
status: response.status,
message: responseText || undefined,
duration: 0,
subscriberUrl: subscriberUrl,
webhookId: subscriber.id,
};
}
private isJsonTemplate(template: string | null): boolean {
if (!template) return true;
try {
JSON.parse(template);
return true;
} catch {
return false;
}
}
async getSubscribers(options: Parameters<IWebhookRepository["getSubscribers"]>[0]) {
return this.repository.getSubscribers(options);
}
async processWebhooks(
trigger: WebhookTriggerEvents,
payload: WebhookPayload,
subscribers: WebhookSubscriber[]
): Promise<void> {
if (subscribers.length === 0) {
this.log.debug("No subscribers to process for trigger:", { trigger });
return;
}
const promises = subscribers.map(async (subscriber) => {
try {
this.log.debug("Processing webhook", {
trigger,
webhookId: subscriber.id,
subscriberUrl: subscriber.subscriberUrl,
});
const result = await this.sendWebhook(trigger, payload, subscriber);
if (result.ok) {
this.log.debug(`Webhook sent successfully`, {
trigger,
webhookId: subscriber.id,
statusCode: result.status,
});
} else {
this.log.error(`Webhook failed`, {
error: result.message,
trigger,
webhookId: subscriber.id,
statusCode: result.status,
});
}
} catch (err) {
this.log.error("Error sending webhook", {
error: err instanceof Error ? err.message : String(err),
trigger,
webhookId: subscriber.id,
});
// Re-throw to ensure Promise.allSettled captures the failure
throw err;
}
});
// Use Promise.allSettled to prevent single webhook failure from killing entire processor
const results = await Promise.allSettled(promises);
// Log summary for monitoring
const successCount = results.filter((result) => result.status === "fulfilled").length;
const failureCount = results.filter((result) => result.status === "rejected").length;
this.log.info(`Webhook processing completed for ${trigger}`, {
totalSubscribers: subscribers.length,
successful: successCount,
failed: failureCount,
});
// Log individual failures for debugging
results.forEach((result, index) => {
if (result.status === "rejected") {
const subscriber = subscribers[index];
this.log.error(`Webhook processing failed for subscriber`, {
trigger,
webhookId: subscriber?.id,
subscriberUrl: subscriber?.subscriberUrl,
error: result.reason,
});
}
});
}
async scheduleTimeBasedWebhook(
trigger: WebhookTriggerEvents,
scheduledAt: Date,
bookingData: {
id: number;
uid: string;
eventTypeId: number | null;
userId: number | null;
teamId?: number | null;
responses?: Record<string, unknown>;
},
subscriber: WebhookSubscriber,
evt: Record<string, unknown>,
isDryRun = false
): Promise<void> {
if (isDryRun) return;
try {
if (!this.tasker) {
throw new Error("Tasker not injected - ensure proper DI configuration");
}
await this.tasker.create(
"sendWebhook",
JSON.stringify({
secretKey: subscriber.secret,
triggerEvent: trigger,
createdAt: new Date().toISOString(),
webhook: subscriber,
data: {
bookingId: bookingData.id,
eventTypeId: bookingData.eventTypeId,
userId: bookingData.userId,
teamId: bookingData.teamId,
responses: bookingData.responses,
evt,
},
}),
{ scheduledAt, referenceUid: `booking-${bookingData.id}-${trigger}` }
);
} catch (error) {
this.log.error("Failed to schedule time-based webhook", {
trigger,
bookingId: bookingData.id,
subscriberUrl: subscriber.subscriberUrl,
error: error instanceof Error ? error.message : String(error),
});
}
}
async cancelScheduledWebhooks(
bookingId: number,
triggers: WebhookTriggerEvents[] = [
WebhookTriggerEvents.MEETING_STARTED,
WebhookTriggerEvents.MEETING_ENDED,
],
isDryRun = false
): Promise<void> {
if (isDryRun) return;
try {
if (!this.tasker) {
throw new Error("Tasker not injected - ensure proper DI configuration");
}
const cancellationPromises = triggers.map(async (trigger) => {
const referenceUid = `booking-${bookingId}-${trigger}`;
try {
await this.tasker.cancelWithReference(referenceUid, "sendWebhook");
return { trigger, success: true };
} catch (error) {
this.log.warn(`Failed to cancel webhook for trigger ${trigger}:`, {
error: error instanceof Error ? error.message : String(error),
});
return { trigger, success: false, error };
}
});
const results = await Promise.allSettled(cancellationPromises);
// Log any failures for monitoring
const failures = results.filter((result) => result.status === "rejected");
if (failures.length > 0) {
this.log.warn(`Some webhook cancellations failed:`, { failureCount: failures.length });
}
} catch (error) {
this.log.error("Failed to cancel scheduled webhooks", {
bookingId,
triggers,
error: error instanceof Error ? error.message : String(error),
});
}
}
async scheduleDelayedWebhooks(
trigger: WebhookTriggerEvents,
payload: WebhookPayload,
scheduledAt: Date,
options?: { teamId?: number | number[] | null; orgId?: number | null },
subscribers?: WebhookSubscriber[],
isDryRun = false
): Promise<void> {
if (isDryRun) return;
const webhookSubscribers =
subscribers ||
(await this.repository.getSubscribers({
userId: null,
eventTypeId: null,
triggerEvent: trigger,
teamId: options?.teamId,
orgId: options?.orgId,
oAuthClientId: undefined,
}));
if (!webhookSubscribers.length) return;
try {
if (!this.tasker) {
throw new Error("Tasker not injected - ensure proper DI configuration");
}
const createdAt = new Date().toISOString();
await Promise.all(
webhookSubscribers.map((subscriber) =>
this.tasker.create(
"sendWebhook",
JSON.stringify({
secretKey: subscriber.secret,
triggerEvent: trigger,
createdAt,
webhook: subscriber,
data: payload.payload,
}),
{ scheduledAt }
)
)
);
} catch (error) {
this.log.error("Failed to schedule delayed webhooks", {
trigger,
scheduledAt,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
}
@@ -0,0 +1,246 @@
import type { CalendarEvent } from "@calcom/types/Calendar";
/**
* Parameter type definitions for webhook service methods
*/
export interface BookingCreatedParams {
evt: CalendarEvent;
booking: {
id: number;
eventTypeId: number | null;
userId: number | null;
startTime?: Date;
smsReminderNumber?: string | null;
};
eventType: {
id: number;
title: string;
description: string | null;
requiresConfirmation: boolean;
price: number;
currency: string;
length: number;
teamId?: number | null;
};
status?: "ACCEPTED" | "PENDING";
metadata?: Record<string, unknown>;
platformParams?: {
platformClientId?: string;
platformRescheduleUrl?: string;
platformCancelUrl?: string;
platformBookingUrl?: string;
};
platformClientId?: string;
teamId?: number | null;
orgId?: number | null;
isDryRun?: boolean;
}
export interface BookingCancelledParams {
evt: CalendarEvent;
booking: {
id: number;
eventTypeId: number | null;
userId: number | null;
smsReminderNumber?: string | null;
};
eventType: {
id: number;
title: string;
description: string | null;
requiresConfirmation: boolean;
price: number;
currency: string;
length: number;
teamId?: number | null;
};
cancelledBy?: string;
cancellationReason?: string;
teamId?: number | null;
orgId?: number | null;
platformClientId?: string;
isDryRun?: boolean;
}
export interface BookingRequestedParams {
evt: CalendarEvent;
booking: {
id: number;
eventTypeId: number | null;
userId: number | null;
};
eventType: {
id: number;
title: string;
description: string | null;
requiresConfirmation: boolean;
price: number;
currency: string;
length: number;
teamId?: number | null;
};
teamId?: number | null;
orgId?: number | null;
platformClientId?: string;
isDryRun?: boolean;
}
export interface BookingRescheduledParams {
evt: CalendarEvent;
booking: {
id: number;
eventTypeId: number | null;
userId: number | null;
smsReminderNumber?: string | null;
};
eventType: {
id: number;
title: string;
description: string | null;
requiresConfirmation: boolean;
price: number;
currency: string;
length: number;
teamId?: number | null;
};
rescheduleId?: number;
rescheduleUid?: string;
rescheduleStartTime?: string;
rescheduleEndTime?: string;
rescheduledBy?: string;
teamId?: number | null;
orgId?: number | null;
platformClientId?: string;
isDryRun?: boolean;
}
export interface BookingPaidParams {
evt: CalendarEvent;
booking: {
id: number;
eventTypeId: number | null;
userId: number | null;
};
eventType: {
id: number;
title: string;
description: string | null;
requiresConfirmation: boolean;
price: number;
currency: string;
length: number;
teamId?: number | null;
};
paymentId?: number;
paymentData?: Record<string, unknown>;
teamId?: number | null;
orgId?: number | null;
platformClientId?: string;
isDryRun?: boolean;
}
export interface BookingPaymentInitiatedParams {
evt: CalendarEvent;
booking: {
id: number;
eventTypeId: number | null;
userId: number | null;
};
eventType: {
id: number;
title: string;
description: string | null;
requiresConfirmation: boolean;
price: number;
currency: string;
length: number;
teamId?: number | null;
};
paymentId?: number;
paymentData?: Record<string, unknown>;
teamId?: number | null;
orgId?: number | null;
platformClientId?: string;
isDryRun?: boolean;
}
export interface BookingNoShowParams {
message: string;
bookingUid: string;
bookingId?: number;
attendees: { email: string; noShow: boolean }[];
userId?: number | null;
eventTypeId?: number | null;
teamId?: number | null;
orgId?: number | null;
platformClientId?: string;
isDryRun?: boolean;
}
export interface BookingRejectedParams {
evt: CalendarEvent;
booking: {
id: number;
eventTypeId: number | null;
userId: number | null;
smsReminderNumber?: string | null;
};
eventType: {
id: number;
title: string;
description: string | null;
requiresConfirmation: boolean;
price: number;
currency: string;
length: number;
teamId?: number | null;
};
teamId?: number | null;
orgId?: number | null;
platformClientId?: string;
isDryRun?: boolean;
}
export interface ScheduleMeetingWebhooksParams {
booking: {
id: number;
uid: string;
eventTypeId: number | null;
userId: number | null;
startTime: Date;
endTime: Date;
responses?: Record<string, unknown>;
};
evt: CalendarEvent;
teamId?: number | null;
orgId?: number | null;
oAuthClientId?: string;
isDryRun?: boolean;
}
export interface CancelScheduledMeetingWebhooksParams {
bookingId: number;
isDryRun?: boolean;
}
export interface ScheduleNoShowWebhooksParams {
booking: {
id: number;
startTime: Date;
eventTypeId: number | null;
userId: number | null;
uid: string;
};
organizerUser: {
id: number;
username: string | null;
};
eventType?: {
teamId?: number | null;
};
teamId?: number | null;
orgId?: number | null;
oAuthClientId?: string;
triggerForUser?: boolean;
}
@@ -0,0 +1,13 @@
import { createModule } from "@evyweb/ioctopus";
import type { ILogger } from "@calcom/features/webhooks/lib/interface/infrastructure";
import { SHARED_TOKENS } from "../shared.tokens";
export const loggerServiceModule = createModule();
// Bind logger with proper factory that respects IoC
loggerServiceModule.bind(SHARED_TOKENS.LOGGER).toFactory(async (): Promise<ILogger> => {
const loggerModule = await import("@calcom/lib/logger");
return loggerModule.default;
});
@@ -0,0 +1,13 @@
import { createModule } from "@evyweb/ioctopus";
import type { ITasker } from "@calcom/features/webhooks/lib/interface/infrastructure";
import { SHARED_TOKENS } from "../shared.tokens";
export const taskerServiceModule = createModule();
// Bind tasker with proper async factory that respects IoC
taskerServiceModule.bind(SHARED_TOKENS.TASKER).toFactory(async (): Promise<ITasker> => {
const taskerModule = await import("@calcom/features/tasker");
return taskerModule.default;
});
+5
View File
@@ -0,0 +1,5 @@
export const SHARED_TOKENS = {
// Infrastructure services
TASKER: Symbol("ITasker"),
LOGGER: Symbol("ILogger"),
} as const;
@@ -0,0 +1,21 @@
export const WEBHOOK_TOKENS = {
// Core interfaces
WEBHOOK_SERVICE: Symbol("IWebhookService"),
BOOKING_WEBHOOK_SERVICE: Symbol("IBookingWebhookService"),
FORM_WEBHOOK_SERVICE: Symbol("IFormWebhookService"),
RECORDING_WEBHOOK_SERVICE: Symbol("IRecordingWebhookService"),
WEBHOOK_NOTIFIER: Symbol("IWebhookNotifier"),
OOO_WEBHOOK_SERVICE: Symbol("OOO_WEBHOOK_SERVICE"),
WEBHOOK_NOTIFICATION_HANDLER: Symbol("WebhookNotificationHandler"),
// Payload builders
BOOKING_PAYLOAD_BUILDER: Symbol("BookingPayloadBuilder"),
FORM_PAYLOAD_BUILDER: Symbol("FormPayloadBuilder"),
OOO_PAYLOAD_BUILDER: Symbol("OOOPayloadBuilder"),
RECORDING_PAYLOAD_BUILDER: Symbol("RecordingPayloadBuilder"),
MEETING_PAYLOAD_BUILDER: Symbol("MeetingPayloadBuilder"),
INSTANT_MEETING_BUILDER: Symbol("InstantMeetingBuilder"),
// Repositories
WEBHOOK_REPOSITORY: Symbol("IWebhookRepository"),
} as const;
@@ -0,0 +1,46 @@
import { createContainer } from "@evyweb/ioctopus";
import { loggerServiceModule } 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 { webhookModule } from "../modules/Webhook.module";
export const webhookContainer = createContainer();
// Load shared infrastructure
webhookContainer.load(SHARED_TOKENS.LOGGER, loggerServiceModule);
webhookContainer.load(SHARED_TOKENS.TASKER, taskerServiceModule);
// Load webhook module
webhookContainer.load(WEBHOOK_TOKENS.WEBHOOK_REPOSITORY, webhookModule);
webhookContainer.load(WEBHOOK_TOKENS.WEBHOOK_SERVICE, webhookModule);
webhookContainer.load(WEBHOOK_TOKENS.BOOKING_WEBHOOK_SERVICE, webhookModule);
webhookContainer.load(WEBHOOK_TOKENS.FORM_WEBHOOK_SERVICE, webhookModule);
webhookContainer.load(WEBHOOK_TOKENS.RECORDING_WEBHOOK_SERVICE, webhookModule);
webhookContainer.load(WEBHOOK_TOKENS.OOO_WEBHOOK_SERVICE, webhookModule);
webhookContainer.load(WEBHOOK_TOKENS.BOOKING_PAYLOAD_BUILDER, webhookModule);
webhookContainer.load(WEBHOOK_TOKENS.FORM_PAYLOAD_BUILDER, webhookModule);
webhookContainer.load(WEBHOOK_TOKENS.OOO_PAYLOAD_BUILDER, webhookModule);
webhookContainer.load(WEBHOOK_TOKENS.RECORDING_PAYLOAD_BUILDER, webhookModule);
webhookContainer.load(WEBHOOK_TOKENS.MEETING_PAYLOAD_BUILDER, webhookModule);
webhookContainer.load(WEBHOOK_TOKENS.INSTANT_MEETING_BUILDER, 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);
}
export function getFormWebhookService() {
return webhookContainer.get(WEBHOOK_TOKENS.FORM_WEBHOOK_SERVICE);
}
export function getRecordingWebhookService() {
return webhookContainer.get(WEBHOOK_TOKENS.RECORDING_WEBHOOK_SERVICE);
}
export function getWebhookNotifier() {
return webhookContainer.get(WEBHOOK_TOKENS.WEBHOOK_NOTIFIER);
}
@@ -0,0 +1,82 @@
import { createModule } from "@evyweb/ioctopus";
import { BookingPayloadBuilder } from "@calcom/features/webhooks/lib/factory/BookingPayloadBuilder";
import { FormPayloadBuilder } from "@calcom/features/webhooks/lib/factory/FormPayloadBuilder";
import { InstantMeetingBuilder } from "@calcom/features/webhooks/lib/factory/InstantMeetingBuilder";
import { MeetingPayloadBuilder } from "@calcom/features/webhooks/lib/factory/MeetingPayloadBuilder";
import { OOOPayloadBuilder } from "@calcom/features/webhooks/lib/factory/OOOPayloadBuilder";
import { RecordingPayloadBuilder } from "@calcom/features/webhooks/lib/factory/RecordingPayloadBuilder";
import { WebhookRepository } from "@calcom/features/webhooks/lib/repository/WebhookRepository";
import { BookingWebhookService } from "@calcom/features/webhooks/lib/service/BookingWebhookService";
import { FormWebhookService } from "@calcom/features/webhooks/lib/service/FormWebhookService";
import { OOOWebhookService } from "@calcom/features/webhooks/lib/service/OOOWebhookService";
import { RecordingWebhookService } from "@calcom/features/webhooks/lib/service/RecordingWebhookService";
import { WebhookNotificationHandler } from "@calcom/features/webhooks/lib/service/WebhookNotificationHandler";
import { WebhookNotifier } from "@calcom/features/webhooks/lib/service/WebhookNotifier";
import { WebhookService } from "@calcom/features/webhooks/lib/service/WebhookService";
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 services
webhookModule
.bind(WEBHOOK_TOKENS.WEBHOOK_SERVICE)
.toClass(WebhookService, [WEBHOOK_TOKENS.WEBHOOK_REPOSITORY, SHARED_TOKENS.TASKER, SHARED_TOKENS.LOGGER]);
webhookModule
.bind(WEBHOOK_TOKENS.BOOKING_WEBHOOK_SERVICE)
.toClass(BookingWebhookService, [
WEBHOOK_TOKENS.WEBHOOK_NOTIFIER,
WEBHOOK_TOKENS.WEBHOOK_SERVICE,
SHARED_TOKENS.TASKER,
SHARED_TOKENS.LOGGER,
]);
webhookModule
.bind(WEBHOOK_TOKENS.FORM_WEBHOOK_SERVICE)
.toClass(FormWebhookService, [WEBHOOK_TOKENS.WEBHOOK_NOTIFIER, WEBHOOK_TOKENS.WEBHOOK_SERVICE]);
webhookModule
.bind(WEBHOOK_TOKENS.RECORDING_WEBHOOK_SERVICE)
.toClass(RecordingWebhookService, [WEBHOOK_TOKENS.WEBHOOK_NOTIFIER]);
webhookModule
.bind(WEBHOOK_TOKENS.OOO_WEBHOOK_SERVICE)
.toClass(OOOWebhookService, [
WEBHOOK_TOKENS.WEBHOOK_NOTIFIER,
WEBHOOK_TOKENS.WEBHOOK_REPOSITORY,
SHARED_TOKENS.TASKER,
SHARED_TOKENS.LOGGER,
]);
// Bind payload builders
webhookModule.bind(WEBHOOK_TOKENS.BOOKING_PAYLOAD_BUILDER).toClass(BookingPayloadBuilder);
webhookModule.bind(WEBHOOK_TOKENS.FORM_PAYLOAD_BUILDER).toClass(FormPayloadBuilder);
webhookModule.bind(WEBHOOK_TOKENS.OOO_PAYLOAD_BUILDER).toClass(OOOPayloadBuilder);
webhookModule.bind(WEBHOOK_TOKENS.RECORDING_PAYLOAD_BUILDER).toClass(RecordingPayloadBuilder);
webhookModule.bind(WEBHOOK_TOKENS.MEETING_PAYLOAD_BUILDER).toClass(MeetingPayloadBuilder);
webhookModule.bind(WEBHOOK_TOKENS.INSTANT_MEETING_BUILDER).toClass(InstantMeetingBuilder);
// Bind notification handler
webhookModule
.bind(WEBHOOK_TOKENS.WEBHOOK_NOTIFICATION_HANDLER)
.toClass(WebhookNotificationHandler, [
WEBHOOK_TOKENS.WEBHOOK_SERVICE,
WEBHOOK_TOKENS.BOOKING_PAYLOAD_BUILDER,
WEBHOOK_TOKENS.FORM_PAYLOAD_BUILDER,
WEBHOOK_TOKENS.OOO_PAYLOAD_BUILDER,
WEBHOOK_TOKENS.RECORDING_PAYLOAD_BUILDER,
WEBHOOK_TOKENS.MEETING_PAYLOAD_BUILDER,
WEBHOOK_TOKENS.INSTANT_MEETING_BUILDER,
SHARED_TOKENS.LOGGER,
]);
// Bind notifier
webhookModule
.bind(WEBHOOK_TOKENS.WEBHOOK_NOTIFIER)
.toClass(WebhookNotifier, [WEBHOOK_TOKENS.WEBHOOK_NOTIFICATION_HANDLER, SHARED_TOKENS.LOGGER]);
@@ -0,0 +1,9 @@
import { createModule } from "@evyweb/ioctopus";
import { WebhookRepository } from "@calcom/features/webhooks/lib/repository/WebhookRepository";
import { WEBHOOK_TOKENS } from "../webhooks.tokens";
export const webhookRepositoryModule = createModule();
webhookRepositoryModule.bind(WEBHOOK_TOKENS.WEBHOOK_REPOSITORY).toClass(WebhookRepository);
@@ -0,0 +1,42 @@
import { createModule } from "@evyweb/ioctopus";
import { BookingWebhookService } from "@calcom/features/webhooks/lib/service/BookingWebhookService";
import { FormWebhookService } from "@calcom/features/webhooks/lib/service/FormWebhookService";
import { OOOWebhookService } from "@calcom/features/webhooks/lib/service/OOOWebhookService";
import { RecordingWebhookService } from "@calcom/features/webhooks/lib/service/RecordingWebhookService";
import { WebhookService } from "@calcom/features/webhooks/lib/service/WebhookService";
import { SHARED_TOKENS } from "../../shared/shared.tokens";
import { WEBHOOK_TOKENS } from "../webhooks.tokens";
export const webhookServicesModule = createModule();
webhookServicesModule
.bind(WEBHOOK_TOKENS.WEBHOOK_SERVICE)
.toClass(WebhookService, [WEBHOOK_TOKENS.WEBHOOK_REPOSITORY, SHARED_TOKENS.TASKER, SHARED_TOKENS.LOGGER]);
webhookServicesModule
.bind(WEBHOOK_TOKENS.BOOKING_WEBHOOK_SERVICE)
.toClass(BookingWebhookService, [
WEBHOOK_TOKENS.WEBHOOK_NOTIFIER,
WEBHOOK_TOKENS.WEBHOOK_SERVICE,
SHARED_TOKENS.TASKER,
SHARED_TOKENS.LOGGER,
]);
webhookServicesModule
.bind(WEBHOOK_TOKENS.FORM_WEBHOOK_SERVICE)
.toClass(FormWebhookService, [WEBHOOK_TOKENS.WEBHOOK_NOTIFIER, WEBHOOK_TOKENS.WEBHOOK_SERVICE]);
webhookServicesModule
.bind(WEBHOOK_TOKENS.RECORDING_WEBHOOK_SERVICE)
.toClass(RecordingWebhookService, [WEBHOOK_TOKENS.WEBHOOK_NOTIFIER]);
webhookServicesModule
.bind(WEBHOOK_TOKENS.OOO_WEBHOOK_SERVICE)
.toClass(OOOWebhookService, [
WEBHOOK_TOKENS.WEBHOOK_NOTIFIER,
WEBHOOK_TOKENS.WEBHOOK_REPOSITORY,
SHARED_TOKENS.TASKER,
SHARED_TOKENS.LOGGER,
]);