Files
cal.diy/packages/features/bookings/lib/BookingEmailSmsHandler.ts
T
a050ccb4ee feat: Booking EmailAndSms Notifications Tasker (#24944)
* wip

* wip

* feature: Booking Tasker without DI yet

* feature: Booking Tasker with DI

* fix type check 1

* fix type check 2

* fix

* comment booking tasker for now

* fix: DI regularBookingService api v2

* fix: convert trigger.dev SDK imports to dynamic imports to fix unit tests

The unit tests were failing because BookingEmailAndSmsTriggerTasker.ts had static imports of trigger files that depend on @trigger.dev/sdk. This caused Vitest to try to resolve the SDK at module load time, even though it should be optional.

Changed all imports in BookingEmailAndSmsTriggerTasker.ts from static to dynamic (using await import()) so the trigger files are only loaded when the tasker methods are actually called, not at module load time during tests.

This fixes the 'Failed to load url @trigger.dev/sdk' errors that were causing 28+ test failures.

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix unit tests

* keep inline smsAndEmailHandler.send calls

* chore: add team feature flag

* add satisfies ModuleLoader

* fix type check app flags

* move trigger in feature

* fix: add trigger.dev prisma  generator

* fix: email app statuses

* fix: CalEvtBuilder unit test

* chore: improvements, schema, config, retry

* fixup! chore: improvements, schema, config, retry

* chore: cleanup code

* chore: cleanup code

* chore: clean code and give full payload

* remove log

* add booking notifications queue

* add attendee phone number for sms

* bump trigger to 4.1.0

* add missing booking seat data in attendee

* update config

* fix logger regular booking service

* fix: prisma as external deps of trigger

* fix yarn.lock

* revert change to example app booking page

* fix: resolve circular dependencies and improve cold start performance in trigger tasks

- Convert BookingRepository import to type-only in CalendarEventBuilder.ts to eliminate circular dependency risk
- Convert EventNameObjectType, CalendarEvent, and JsonObject imports to type-only in BookingEmailAndSmsTaskService.ts
- Use dynamic imports in all trigger notification tasks (confirm, request, reschedule, rr-reschedule) to reduce cold start time
- Move heavy imports (BookingEmailSmsHandler, BookingRepository, prisma, TriggerDevLogger, BookingEmailAndSmsTaskService) inside run functions
- Eliminates module-level prisma import which violates repo guidelines and adds cold start overhead
- Reduces initial module dependency graph by deferring heavy imports (email templates, workflows, large repositories) until task execution

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: improve cold start performance in reminderScheduler with dynamic imports

- Remove module-level prisma import (violates 'No prisma outside repositories' guideline)
- Use dynamic imports for UserRepository (1,168 lines) - only loaded when needed in EMAIL_ATTENDEE action
- Use dynamic imports for twilio provider (386 lines) - only loaded in cancelScheduledMessagesAndScheduleEmails
- Use dynamic imports for all manager functions by action type:
  - scheduleSMSReminder (387 lines) - loaded only for SMS actions
  - scheduleEmailReminder (459 lines) - loaded only for Email actions
  - scheduleWhatsappReminder (266 lines) - loaded only for WhatsApp actions
  - scheduleAIPhoneCall (478 lines) - loaded only for AI phone call actions
- Use dynamic imports for sendOrScheduleWorkflowEmails in cancelScheduledMessagesAndScheduleEmails
- Significantly reduces cold start time by deferring heavy module loading until execution paths need them
- Eliminates module-level prisma import that violated repository pattern guidelines

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: improve cold start performance in BookingEmailSmsHandler with dynamic imports

- Remove module-level imports of all email-manager functions (653 LOC + 30+ email templates)
- Add dynamic imports in each method (_handleRescheduled, _handleRoundRobinRescheduled, _handleConfirmed, _handleRequested, handleAddGuests)
- Defer heavy email-manager loading until method execution
- Verified no circular dependencies between email-manager and bookings
- Significantly reduces cold start time for RegularBookingService and BookingEmailAndSmsTaskService

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: use dynamic imports

* update yarn lock

* code review

* trigger config project ref in env

* update yarn lock

* add .env.example trigger variables

* add .env.example trigger variables

* fix: cleanup error handling and loggin

* fix: trigger config from env

* fix: small typo fix

* fix: ai review comments

* fix: ai review comments

* ai review

* prettier

---------

Co-authored-by: hbjORbj <sldisek783@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-11-28 14:00:04 +00:00

366 lines
12 KiB
TypeScript

import { default as cloneDeep } from "lodash/cloneDeep";
import type { Logger } from "tslog";
import dayjs from "@calcom/dayjs";
import {
allowDisablingHostConfirmationEmails,
allowDisablingAttendeeConfirmationEmails,
} from "@calcom/ee/workflows/lib/allowDisablingStandardEmails";
import type { Workflow as WorkflowType } from "@calcom/ee/workflows/lib/types";
import type { BookingType } from "@calcom/features/bookings/lib/handleNewBooking/originalRescheduledBookingUtils";
import type { EventNameObjectType } from "@calcom/features/eventtypes/lib/eventNaming";
import { getPiiFreeCalendarEvent } from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import type { Prisma, User } from "@calcom/prisma/client";
import type { SchedulingType } from "@calcom/prisma/enums";
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
import type { AdditionalInformation, CalendarEvent, Person } from "@calcom/types/Calendar";
export const BookingActionMap = {
confirmed: "BOOKING_CONFIRMED",
rescheduled: "BOOKING_RESCHEDULED",
requested: "BOOKING_REQUESTED",
} as const;
export type BookingActionType =
| (typeof BookingActionMap)["confirmed"]
| (typeof BookingActionMap)["rescheduled"]
| (typeof BookingActionMap)["requested"];
type EmailAndSmsPayload = {
evt: CalendarEvent;
eventType: {
metadata?: EventTypeMetadata;
schedulingType: SchedulingType | null;
};
};
type RescheduleEmailAndSmsPayload = EmailAndSmsPayload & {
rescheduleReason?: string;
additionalInformation: AdditionalInformation;
additionalNotes: string | null | undefined;
iCalUID: string;
users: (Pick<User, "name" | "email"> & {
isFixed?: boolean;
})[];
changedOrganizer?: boolean;
isRescheduledByBooker: boolean;
originalRescheduledBooking: NonNullable<BookingType>;
};
type ConfirmedEmailAndSmsPayload = EmailAndSmsPayload & {
workflows: WorkflowType[];
eventNameObject: EventNameObjectType;
additionalInformation: AdditionalInformation;
additionalNotes: string | null | undefined;
customInputs: Prisma.JsonObject | null | undefined;
};
type RequestedEmailAndSmsPayload = EmailAndSmsPayload & {
attendees?: Person[];
additionalNotes?: string | null;
};
type AddGuestsEmailAndSmsPayload = EmailAndSmsPayload & {
newGuests: string[];
};
type RescheduledSideEffectsPayload = {
action: typeof BookingActionMap.rescheduled;
data: RescheduleEmailAndSmsPayload;
};
type ConfirmedSideEffectsPayload = {
action: typeof BookingActionMap.confirmed;
data: ConfirmedEmailAndSmsPayload;
};
type RequestedSideEffectsPayload = {
action: typeof BookingActionMap.requested;
data: RequestedEmailAndSmsPayload;
};
export type EmailsAndSmsSideEffectsPayload =
| RescheduledSideEffectsPayload
| RequestedSideEffectsPayload
| ConfirmedSideEffectsPayload;
export interface IBookingEmailSmsHandler {
logger: Pick<Logger<unknown>, "warn" | "debug" | "error" | "getSubLogger">;
}
export class BookingEmailSmsHandler {
private readonly log: Pick<Logger<unknown>, "warn" | "debug" | "error">;
constructor(dependencies: IBookingEmailSmsHandler) {
this.log = dependencies.logger.getSubLogger({ prefix: ["BookingEmailSmsHandler"] });
}
public async send(payload: EmailsAndSmsSideEffectsPayload) {
const { action, data } = payload;
if (action === BookingActionMap.rescheduled) {
if (data.eventType.schedulingType === "ROUND_ROBIN") return this._handleRoundRobinRescheduled(data);
return this._handleRescheduled(data);
}
if (action === BookingActionMap.confirmed) return this._handleConfirmed(data);
if (action === BookingActionMap.requested) return this._handleRequested(data);
this.log.warn("Unknown email/SMS action requested.", { action });
}
/**
* Handles notifications for a RESCHEDULED booking.
*/
private async _handleRescheduled(data: RescheduleEmailAndSmsPayload) {
const {
evt,
eventType: { metadata },
rescheduleReason,
additionalNotes,
additionalInformation,
} = data;
const { sendRescheduledEmailsAndSMS } = await import("@calcom/emails/email-manager");
await sendRescheduledEmailsAndSMS(
{
...evt,
additionalInformation,
additionalNotes,
cancellationReason: `$RCH$${rescheduleReason || ""}`,
},
metadata
);
}
/**
* Handles notifications for a RESCHEDULED RR booking.
*/
private async _handleRoundRobinRescheduled(data: RescheduleEmailAndSmsPayload) {
const {
evt,
eventType: { metadata },
originalRescheduledBooking,
rescheduleReason,
additionalNotes,
changedOrganizer,
additionalInformation,
users,
isRescheduledByBooker,
iCalUID,
} = data;
const copyEvent = cloneDeep(evt);
const copyEventAdditionalInfo = {
...copyEvent,
additionalInformation,
additionalNotes,
cancellationReason: `$RCH$${rescheduleReason || ""}`,
};
const cancelledRRHostEvt = cloneDeep(copyEventAdditionalInfo);
this.log.debug("Emails: Sending rescheduled emails for booking confirmation");
const originalBookingMemberEmails: Person[] = [];
for (const user of originalRescheduledBooking.attendees) {
const translate = await getTranslation(user.locale ?? "en", "common");
originalBookingMemberEmails.push({
name: user.name,
email: user.email,
timeZone: user.timeZone,
phoneNumber: user.phoneNumber,
language: { translate, locale: user.locale ?? "en" },
});
}
if (originalRescheduledBooking.user) {
const translate = await getTranslation(originalRescheduledBooking.user.locale ?? "en", "common");
const originalOrganizer = originalRescheduledBooking.user;
originalBookingMemberEmails.push({
...originalRescheduledBooking.user,
username: originalRescheduledBooking.user.username ?? undefined,
timeFormat: getTimeFormatStringFromUserTimeFormat(originalRescheduledBooking.user.timeFormat),
name: originalRescheduledBooking.user.name || "",
language: { translate, locale: originalRescheduledBooking.user.locale ?? "en" },
});
if (changedOrganizer) {
cancelledRRHostEvt.title = originalRescheduledBooking.title;
cancelledRRHostEvt.startTime =
dayjs(originalRescheduledBooking?.startTime).utc().format() || copyEventAdditionalInfo.startTime;
cancelledRRHostEvt.endTime =
dayjs(originalRescheduledBooking?.endTime).utc().format() || copyEventAdditionalInfo.endTime;
cancelledRRHostEvt.organizer = {
email: originalOrganizer.email,
name: originalOrganizer.name || "",
timeZone: originalOrganizer.timeZone,
language: { translate, locale: originalOrganizer.locale || "en" },
};
}
}
const newBookingMemberEmails: Person[] = [
...(copyEvent.team?.members || []),
copyEvent.organizer,
...copyEvent.attendees,
];
const matchOriginalMemberWithNewMember = (originalMember: Person, newMember: Person) =>
originalMember.email === newMember.email;
const newBookedMembers = newBookingMemberEmails.filter(
(member) => !originalBookingMemberEmails.some((om) => matchOriginalMemberWithNewMember(om, member))
);
const cancelledMembers = originalBookingMemberEmails.filter(
(member) => !newBookingMemberEmails.some((nm) => matchOriginalMemberWithNewMember(member, nm))
);
const rescheduledMembers = newBookingMemberEmails.filter((member) =>
originalBookingMemberEmails.some((om) => matchOriginalMemberWithNewMember(om, member))
);
const reassignedTo = users.find(
(user) => !user.isFixed && newBookedMembers.some((member) => member.email === user.email)
);
const {
sendRoundRobinRescheduledEmailsAndSMS,
sendRoundRobinScheduledEmailsAndSMS,
sendRoundRobinCancelledEmailsAndSMS,
} = await import("@calcom/emails/email-manager");
try {
await Promise.all([
sendRoundRobinRescheduledEmailsAndSMS(
{ ...copyEventAdditionalInfo, iCalUID },
rescheduledMembers,
metadata
),
sendRoundRobinScheduledEmailsAndSMS({
calEvent: copyEventAdditionalInfo,
members: newBookedMembers,
eventTypeMetadata: metadata,
}),
sendRoundRobinCancelledEmailsAndSMS(
cancelledRRHostEvt,
cancelledMembers,
metadata,
reassignedTo
? {
name: reassignedTo.name,
email: reassignedTo.email,
...(isRescheduledByBooker && { reason: "Booker Rescheduled" }),
}
: undefined
),
]);
} catch (err) {
this.log.error("Failed to send rescheduled round robin event related emails", err);
}
}
/**
* Handles notifications for a newly CONFIRMED booking.
*/
private async _handleConfirmed(data: ConfirmedEmailAndSmsPayload) {
const {
evt,
eventType: { metadata },
workflows,
eventNameObject,
additionalInformation,
additionalNotes,
customInputs,
} = data;
let isHostConfirmationEmailsDisabled = metadata?.disableStandardEmails?.confirmation?.host || false;
if (isHostConfirmationEmailsDisabled) {
isHostConfirmationEmailsDisabled = allowDisablingHostConfirmationEmails(workflows);
}
let isAttendeeConfirmationEmailDisabled =
metadata?.disableStandardEmails?.confirmation?.attendee || false;
if (isAttendeeConfirmationEmailDisabled) {
isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows);
}
const { sendScheduledEmailsAndSMS } = await import("@calcom/emails/email-manager");
try {
await sendScheduledEmailsAndSMS(
{ ...evt, additionalInformation, additionalNotes, customInputs },
eventNameObject,
isHostConfirmationEmailsDisabled,
isAttendeeConfirmationEmailDisabled,
metadata
);
} catch (err) {
this.log.error("Failed to send scheduled event related emails", err);
}
}
/**
* Handles notifications when a booking REQUEST is made (requires confirmation).
*/
private async _handleRequested(data: RequestedEmailAndSmsPayload) {
const {
evt,
eventType: { metadata },
attendees,
additionalNotes,
} = data;
if (!attendees?.length) {
this.log.error("Requested action called without attendee details.");
return;
}
this.log.debug(
"Action: BOOKING_REQUESTED. Sending request emails.",
safeStringify({ calEvent: getPiiFreeCalendarEvent(evt) })
);
const eventWithNotes = { ...evt, additionalNotes };
const { sendOrganizerRequestEmail, sendAttendeeRequestEmailAndSMS } = await import(
"@calcom/emails/email-manager"
);
try {
await Promise.all([
sendOrganizerRequestEmail(eventWithNotes, metadata),
sendAttendeeRequestEmailAndSMS(eventWithNotes, attendees[0], metadata),
]);
} catch (err) {
this.log.error("Failed to send requested event related emails", err);
}
}
/**
* Handles notifications when guests are added to an existing booking.
*/
public async handleAddGuests(data: AddGuestsEmailAndSmsPayload) {
const {
evt,
eventType: { metadata },
newGuests,
} = data;
this.log.debug(
"Action: ADD_GUESTS. Sending add guests emails and SMS.",
safeStringify({ calEvent: getPiiFreeCalendarEvent(evt) })
);
const { sendAddGuestsEmailsAndSMS } = await import("@calcom/emails/email-manager");
try {
await sendAddGuestsEmailsAndSMS({
calEvent: evt,
newGuests,
eventTypeMetadata: metadata,
});
} catch (err) {
this.log.error("Failed to send add guests related emails and SMS", err);
}
}
}