Files
calendar/packages/features/webhooks/lib/service/BookingWebhookService.ts
T
Syed Ali ShahbazandGitHub 110615b509 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
2025-09-12 20:02:50 +00:00

439 lines
15 KiB
TypeScript

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),
});
}
}
}