feat: Generate email workflow payload at time of sending (#25446)

* 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

* Add `create` on `WorkflowReminderRepository`

* `sendWorkflowEmails` tasker to accept lazy payload

* Add `scheduleLazyEmailWorkflow` to `WorkflowService

* Process scheduled date in `scheduleLazyEmailWorkflow`

* Type fixes

* Use `WorkflowService` to schedule

* Refactor `scheduleEmailReminderForEvt` to use
`WorkflowService.processWorkflowScheduledDate`

* Pass seat reference to lazy scheduled workflow reminder

* Refactor `WorkflowReminderRepository` to accept prisma as constructor

* Abstract `FormSubmissionData` type

* Abstract select statement and add get by uid to `BookingRepository`

* Add `FormSubmissionData` type

* Add `findByIdIncludeStepAndWorkflow` to `WorkflowReminderRepository`

* Tasker payload to accept `workflowReminderId`

* Create `BookingSeatRepository`

* Write `workflowReminderId` to tasker payload

* Add `generateCommonScheduleFunctionParams` to `WorkflowService`

* Init

* Use services in tasker

* Abstract types

* In reminderScheduler use workflowService to generate common params

* Type fix

* Return params from emailWorkflowService.generateParametersToBuildEmailWorkflowContent

* Use emailWorkflowService to generate params

* Abstract types

* Generate email content and send in EmailWorkflowService

* Move check to caller

* Use EmailWorkflowService to generate email payload in
emailReminderManager

* Fix initalizing repository

* Use evt.videoCallData first before the booking metadata

* Only get non-deleted references to build calendar event

* Remove check for videoCallData.id

* Dynamic import credit service

* BookingRepository.getByUid to only return what we need from attendee

* Type fixes

* test: Add comprehensive tests for lazy email workflow generation and fix broken tests

- Fix prisma mocks in sms-manager.test.ts and outOfOfficeCreateOrUpdate.handler.test.ts
  to export both 'default' and named 'prisma' exports
- Add EmailWorkflowService.test.ts with 4 tests for error handling paths
- Add sendWorkflowEmails.test.ts with 7 tests for schema validation and email sending
- Add tests to WorkflowService.test.ts for scheduleLazyEmailWorkflow,
  processWorkflowScheduledDate, and generateCommonScheduleFunctionParams methods

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Write `seatReferenceUid`

* Return promise

* Fix log

* Change to `Promise.allSettled`

* Type fix

* Fix failing test

* fix: reorder workflow step checks to fix test failure

The test 'should throw error if workflow step not found on reminder' was failing because the code checked workflowStep.verifiedAt before checking if workflowStep exists. When workflowStep is null, this caused the error message to include 'undefined' instead of the expected workflow step id.

Fixed by reordering the checks:
1. First check if workflowStep exists
2. Then check if workflowStep.verifiedAt exists

Also updated the test expectation to match the correct error message.

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

---------

Co-authored-by: cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: hbjORbj <sldisek783@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
Joe Au-Yeung
2025-12-02 02:25:07 +00:00
committed by GitHub
co-authored by joe@cal.com <j.auyeung419@gmail.com> cal.com Morgan hbjORbj Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Alex van Andel
parent 5d8c4ad13d
commit f33edb2b99
21 changed files with 1843 additions and 536 deletions
+2 -2
View File
@@ -198,10 +198,10 @@ export class CalendarEventBuilder {
}
// Video
if (videoCallData && videoCallData.id && videoCallData.url) {
if (videoCallData && videoCallData.url) {
builder.withVideoCallData({
...videoCallData,
id: videoCallData.id,
id: videoCallData.id ?? "",
password: videoCallData.password ?? "",
url: videoCallData.url,
});
@@ -176,6 +176,150 @@ const buildWhereClauseForActiveBookings = ({
: {}),
});
const selectStatementToGetBookingForCalEventBuilder = {
uid: true,
title: true,
startTime: true,
endTime: true,
description: true,
customInputs: true,
responses: true,
metadata: true,
location: true,
iCalUID: true,
iCalSequence: true,
oneTimePassword: true,
attendees: {
select: {
name: true,
email: true,
timeZone: true,
locale: true,
phoneNumber: true,
bookingSeat: {
select: {
id: true,
referenceUid: true,
bookingId: true,
attendeeId: true,
data: true,
metadata: true,
},
},
},
},
user: {
// Organizer
select: {
id: true,
name: true,
email: true,
username: true,
timeZone: true,
locale: true,
timeFormat: true,
destinationCalendar: true,
profiles: { select: { organizationId: true } },
},
},
// destination calendar of the Organizer
destinationCalendar: true,
eventType: {
select: {
id: true,
title: true,
slug: true,
description: true,
hideCalendarNotes: true,
hideCalendarEventDetails: true,
hideOrganizerEmail: true,
schedulingType: true,
seatsPerTimeSlot: true,
seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
customReplyToEmail: true,
disableRescheduling: true,
disableCancelling: true,
requiresConfirmation: true,
recurringEvent: true,
bookingFields: true,
metadata: true,
eventName: true,
team: {
select: {
id: true,
name: true,
parentId: true,
members: {
select: {
user: {
select: {
id: true,
name: true,
email: true,
username: true,
timeZone: true,
locale: true,
timeFormat: true,
},
},
},
},
},
},
users: {
select: {
id: true,
name: true,
email: true,
username: true,
timeZone: true,
locale: true,
timeFormat: true,
destinationCalendar: true,
},
},
hosts: {
select: {
userId: true,
isFixed: true,
user: {
select: {
id: true,
name: true,
email: true,
username: true,
timeZone: true,
locale: true,
timeFormat: true,
destinationCalendar: true,
},
},
},
},
},
},
references: {
where: {
deleted: null,
},
select: {
type: true,
meetingId: true,
meetingPassword: true,
meetingUrl: true,
uid: true,
},
},
seatsReferences: {
select: {
id: true,
referenceUid: true,
attendee: { select: { id: true, email: true, phoneNumber: true } },
},
},
};
export class BookingRepository {
constructor(private prismaClient: PrismaClient) {}
@@ -1257,146 +1401,14 @@ export class BookingRepository {
async getBookingForCalEventBuilder(bookingId: number) {
return await this.prismaClient.booking.findUnique({
where: { id: bookingId },
select: {
uid: true,
title: true,
startTime: true,
endTime: true,
description: true,
customInputs: true,
responses: true,
metadata: true,
location: true,
iCalUID: true,
iCalSequence: true,
oneTimePassword: true,
attendees: {
select: {
name: true,
email: true,
timeZone: true,
locale: true,
phoneNumber: true,
bookingSeat: {
select: {
id: true,
referenceUid: true,
bookingId: true,
attendeeId: true,
data: true,
metadata: true,
},
},
},
},
user: {
// Organizer
select: {
id: true,
name: true,
email: true,
username: true,
timeZone: true,
locale: true,
timeFormat: true,
destinationCalendar: true,
profiles: { select: { organizationId: true } },
},
},
// destination calendar of the Organizer
destinationCalendar: true,
eventType: {
select: {
id: true,
title: true,
slug: true,
description: true,
hideCalendarNotes: true,
hideCalendarEventDetails: true,
hideOrganizerEmail: true,
schedulingType: true,
seatsPerTimeSlot: true,
seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
customReplyToEmail: true,
disableRescheduling: true,
disableCancelling: true,
requiresConfirmation: true,
recurringEvent: true,
bookingFields: true,
metadata: true,
eventName: true,
team: {
select: {
id: true,
name: true,
parentId: true,
members: {
select: {
user: {
select: {
id: true,
name: true,
email: true,
username: true,
timeZone: true,
locale: true,
timeFormat: true,
},
},
},
},
},
},
users: {
select: {
id: true,
name: true,
email: true,
username: true,
timeZone: true,
locale: true,
timeFormat: true,
destinationCalendar: true,
},
},
hosts: {
select: {
userId: true,
isFixed: true,
user: {
select: {
id: true,
name: true,
email: true,
username: true,
timeZone: true,
locale: true,
timeFormat: true,
destinationCalendar: true,
},
},
},
},
},
},
references: {
select: {
type: true,
meetingId: true,
meetingPassword: true,
meetingUrl: true,
uid: true,
},
},
seatsReferences: {
select: {
id: true,
referenceUid: true,
attendee: { select: { id: true, email: true, phoneNumber: true } },
},
},
},
select: selectStatementToGetBookingForCalEventBuilder,
});
}
async getBookingForCalEventBuilderFromUid(bookingUid: string) {
return this.prismaClient.booking.findUnique({
where: { uid: bookingUid },
select: selectStatementToGetBookingForCalEventBuilder,
});
}
async findByIdIncludeDestinationCalendar(bookingId: number) {
@@ -0,0 +1,20 @@
import type { PrismaClient } from "@calcom/prisma";
export class BookingSeatRepository {
constructor(private prismaClient: PrismaClient) {}
getByUidIncludeAttendee(uid: string) {
return this.prismaClient.bookingSeat.findUnique({
where: {
referenceUid: uid,
},
select: {
attendee: {
select: {
email: true,
},
},
},
});
}
}
@@ -39,7 +39,9 @@ export function isCalAIAction(action: WorkflowActions) {
return action === WorkflowActions.CAL_AI_PHONE_CALL;
}
export function isEmailAction(action: WorkflowActions) {
export function isEmailAction(
action: WorkflowActions
): action is Extract<WorkflowActions, "EMAIL_HOST" | "EMAIL_ATTENDEE" | "EMAIL_ADDRESS"> {
return (
action === WorkflowActions.EMAIL_ADDRESS ||
action === WorkflowActions.EMAIL_ATTENDEE ||
@@ -12,8 +12,8 @@ import { WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import type { TimeUnit } from "@calcom/prisma/enums";
import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums";
import type { FormSubmissionData, WorkflowContextData } from "./reminderScheduler";
import type { BookingInfo } from "./smsReminderManager";
import type { BookingInfo, FormSubmissionData } from "../types";
import type { WorkflowContextData } from "./reminderScheduler";
type timeUnitLowerCase = "day" | "hour" | "minute";
@@ -1,42 +1,22 @@
import type { EventStatus } from "ics";
import dayjs from "@calcom/dayjs";
import generateIcsString from "@calcom/emails/lib/generateIcsString";
import { preprocessNameFieldDataWithVariant } from "@calcom/features/form-builder/utils";
import { BookingSeatRepository } from "@calcom/features/bookings/repositories/BookingSeatRepository";
import { EmailWorkflowService } from "@calcom/features/ee/workflows/lib/service/EmailWorkflowService";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import { WorkflowReminderRepository } from "@calcom/features/ee/workflows/repositories/WorkflowReminderRepository";
import tasker from "@calcom/features/tasker";
import { WEBSITE_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
import type { TimeUnit } from "@calcom/prisma/enums";
import {
WorkflowActions,
WorkflowMethods,
WorkflowTemplates,
WorkflowTriggerEvents,
} from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import { WorkflowMethods, WorkflowTemplates, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import { getWorkflowRecipientEmail } from "../getWorkflowReminders";
import type { BookingInfo, ScheduleEmailReminderAction, FormSubmissionData } from "../types";
import { sendOrScheduleWorkflowEmails } from "./providers/emailProvider";
import type { FormSubmissionData, WorkflowContextData } from "./reminderScheduler";
import type { AttendeeInBookingInfo, BookingInfo, timeUnitLowerCase } from "./smsReminderManager";
import type { WorkflowContextData } from "./reminderScheduler";
import type { VariablesType } from "./templates/customTemplate";
import customTemplate, {
transformBookingResponsesToVariableFormat,
transformRoutingFormResponsesToVariableFormat,
} from "./templates/customTemplate";
import emailRatingTemplate from "./templates/emailRatingTemplate";
import emailReminderTemplate from "./templates/emailReminderTemplate";
import customTemplate, { transformRoutingFormResponsesToVariableFormat } from "./templates/customTemplate";
const log = logger.getSubLogger({ prefix: ["[emailReminderManager]"] });
type ScheduleEmailReminderAction = Extract<
WorkflowActions,
"EMAIL_HOST" | "EMAIL_ATTENDEE" | "EMAIL_ADDRESS"
>;
export type ScheduleReminderArgs = {
triggerEvent: WorkflowTriggerEvents;
timeSpan: {
@@ -74,7 +54,7 @@ type SendEmailReminderParams = {
};
sendTo: string[];
triggerEvent: WorkflowTriggerEvents;
scheduledDate?: dayjs.Dayjs | null;
scheduledDate?: Date | null;
uid?: string;
workflowStepId?: number;
seatReferenceUid?: string;
@@ -90,7 +70,7 @@ const sendOrScheduleWorkflowEmailWithReminder = async (params: SendEmailReminder
bookingUid: uid,
workflowStepId,
method: WorkflowMethods.EMAIL,
scheduledDate: scheduledDate.toDate(),
scheduledDate,
scheduled: true,
},
});
@@ -100,7 +80,7 @@ const sendOrScheduleWorkflowEmailWithReminder = async (params: SendEmailReminder
await sendOrScheduleWorkflowEmails({
...mailData,
to: sendTo,
sendAt: scheduledDate?.toDate(),
sendAt: scheduledDate,
referenceUid: reminderUid ?? undefined,
});
};
@@ -136,200 +116,30 @@ const scheduleEmailReminderForEvt = async (args: scheduleEmailReminderArgs & { e
action,
} = args;
const { startTime, endTime } = evt;
const uid = evt.uid as string;
const timeUnit: timeUnitLowerCase | undefined = timeSpan.timeUnit?.toLocaleLowerCase() as timeUnitLowerCase;
let scheduledDate = null;
if (triggerEvent === WorkflowTriggerEvents.BEFORE_EVENT) {
scheduledDate = timeSpan.time && timeUnit ? dayjs(startTime).subtract(timeSpan.time, timeUnit) : null;
} else if (triggerEvent === WorkflowTriggerEvents.AFTER_EVENT) {
scheduledDate = timeSpan.time && timeUnit ? dayjs(endTime).add(timeSpan.time, timeUnit) : null;
}
let attendeeToBeUsedInMail: AttendeeInBookingInfo | null = null;
let name = "";
let attendeeName = "";
let timeZone = "";
switch (action) {
case WorkflowActions.EMAIL_ADDRESS:
name = "";
attendeeToBeUsedInMail = evt.attendees[0];
attendeeName = evt.attendees[0].name;
timeZone = evt.organizer.timeZone;
break;
case WorkflowActions.EMAIL_HOST:
attendeeToBeUsedInMail = evt.attendees[0];
name = evt.organizer.name;
attendeeName = attendeeToBeUsedInMail.name;
timeZone = evt.organizer.timeZone;
break;
case WorkflowActions.EMAIL_ATTENDEE: {
// check if first attendee of sendTo is present in the attendees list, if not take the evt attendee
const attendeeEmailToBeUsedInMailFromEvt = evt.attendees.find(
(attendee) => attendee.email === sendTo[0]
);
attendeeToBeUsedInMail = attendeeEmailToBeUsedInMailFromEvt
? attendeeEmailToBeUsedInMailFromEvt
: evt.attendees[0];
name = attendeeToBeUsedInMail.name;
attendeeName = evt.organizer.name;
timeZone = attendeeToBeUsedInMail.timeZone;
break;
}
}
let emailContent = {
const scheduledDate = WorkflowService.processWorkflowScheduledDate({
workflowTriggerEvent: triggerEvent,
time: timeSpan.time,
timeUnit: timeSpan.timeUnit,
evt,
});
const workflowReminderRepository = new WorkflowReminderRepository(prisma);
const bookingSeatRepository = new BookingSeatRepository(prisma);
const emailWorkflowService = new EmailWorkflowService(workflowReminderRepository, bookingSeatRepository);
const mailData = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt,
sendTo,
seatReferenceUid,
hideBranding,
emailSubject,
emailBody: `<body style="white-space: pre-wrap;">${emailBody}</body>`,
};
const bookerUrl = evt.bookerUrl ?? WEBSITE_URL;
if (emailBody) {
const isEmailAttendeeAction = action === WorkflowActions.EMAIL_ATTENDEE;
const recipientEmail = getWorkflowRecipientEmail({
action,
attendeeEmail: attendeeToBeUsedInMail.email,
organizerEmail: evt.organizer.email,
sendToEmail: sendTo[0],
});
const variables: VariablesType = {
eventName: evt.title || "",
organizerName: evt.organizer.name,
attendeeName: attendeeToBeUsedInMail.name,
attendeeFirstName: attendeeToBeUsedInMail.firstName,
attendeeLastName: attendeeToBeUsedInMail.lastName,
attendeeEmail: attendeeToBeUsedInMail.email,
eventDate: dayjs(startTime).tz(timeZone),
eventEndTime: dayjs(endTime).tz(timeZone),
timeZone: timeZone,
location: evt.location,
additionalNotes: evt.additionalNotes,
responses: transformBookingResponsesToVariableFormat(evt.responses),
meetingUrl: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl,
cancelLink: `${bookerUrl}/booking/${evt.uid}?cancel=true${
recipientEmail ? `&cancelledBy=${encodeURIComponent(recipientEmail)}` : ""
}${isEmailAttendeeAction && seatReferenceUid ? `&seatReferenceUid=${seatReferenceUid}` : ""}`,
cancelReason: evt.cancellationReason,
rescheduleLink: `${bookerUrl}/reschedule/${evt.uid}${
recipientEmail
? `?rescheduledBy=${encodeURIComponent(recipientEmail)}${
isEmailAttendeeAction && seatReferenceUid
? `&seatReferenceUid=${encodeURIComponent(seatReferenceUid)}`
: ""
}`
: isEmailAttendeeAction && seatReferenceUid
? `?seatReferenceUid=${encodeURIComponent(seatReferenceUid)}`
: ""
}`,
rescheduleReason: evt.rescheduleReason,
ratingUrl: `${bookerUrl}/booking/${evt.uid}?rating`,
noShowUrl: `${bookerUrl}/booking/${evt.uid}?noShow=true`,
attendeeTimezone: evt.attendees[0].timeZone,
eventTimeInAttendeeTimezone: dayjs(startTime).tz(evt.attendees[0].timeZone),
eventEndTimeInAttendeeTimezone: dayjs(endTime).tz(evt.attendees[0].timeZone),
};
const locale = isEmailAttendeeAction
? attendeeToBeUsedInMail.language?.locale
: evt.organizer.language.locale;
const emailSubjectTemplate = customTemplate(emailSubject, variables, locale, evt.organizer.timeFormat);
emailContent.emailSubject = emailSubjectTemplate.text;
emailContent.emailBody = customTemplate(
emailBody,
variables,
locale,
evt.organizer.timeFormat,
hideBranding
).html;
} else if (template === WorkflowTemplates.REMINDER) {
emailContent = emailReminderTemplate({
isEditingMode: false,
locale: evt.organizer.language.locale,
t: await getTranslation(evt.organizer.language.locale || "en", "common"),
action,
timeFormat: evt.organizer.timeFormat,
startTime,
endTime,
eventName: evt.title,
timeZone,
location: evt.location || "",
meetingUrl: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl || "",
otherPerson: attendeeName,
name,
});
} else if (template === WorkflowTemplates.RATING) {
emailContent = emailRatingTemplate({
isEditingMode: true,
locale: evt.organizer.language.locale,
action,
t: await getTranslation(evt.organizer.language.locale || "en", "common"),
timeFormat: evt.organizer.timeFormat,
startTime,
endTime,
eventName: evt.title,
timeZone,
organizer: evt.organizer.name,
name,
ratingUrl: `${bookerUrl}/booking/${evt.uid}?rating`,
noShowUrl: `${bookerUrl}/booking/${evt.uid}?noShow=true`,
});
}
// Allows debugging generated email content without waiting for sendgrid to send emails
log.debug(`Sending Email for trigger ${triggerEvent}`, JSON.stringify(emailContent));
async function prepareEmailData() {
const status: EventStatus =
triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED ? "CANCELLED" : "CONFIRMED";
const organizerT = await getTranslation(evt.organizer.language.locale || "en", "common");
const attendeeT = await getTranslation(evt.attendees[0].language.locale || "en", "common");
const attendee = {
...evt.attendees[0],
name: preprocessNameFieldDataWithVariant("fullName", evt.attendees[0].name) as string,
language: { ...evt.attendees[0].language, translate: attendeeT },
};
const emailEvent = {
...evt,
type: evt.eventType?.slug || "",
organizer: { ...evt.organizer, language: { ...evt.organizer.language, translate: organizerT } },
attendees: [attendee],
location: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl || evt.location,
};
const attachments = includeCalendarEvent
? [
{
content:
generateIcsString({
event: emailEvent,
status,
}) || "",
filename: "event.ics",
contentType: "text/calendar; charset=UTF-8; method=REQUEST",
disposition: "attachment",
},
]
: undefined;
return {
subject: emailContent.emailSubject,
html: emailContent.emailBody,
...(!evt.hideOrganizerEmail && { replyTo: evt?.eventType?.customReplyToEmail || evt.organizer.email }),
attachments,
sender,
};
}
const mailData = await prepareEmailData();
emailBody,
sender: sender || "",
action,
template,
includeCalendarEvent,
triggerEvent,
});
await sendOrScheduleWorkflowEmailWithReminder({
mailData,
@@ -1,10 +1,10 @@
import { sendCustomWorkflowEmail } from "@calcom/emails/workflow-email-service";
import type { WorkflowEmailData } from "@calcom/emails/templates/workflow-email";
import { sendCustomWorkflowEmail } from "@calcom/emails/workflow-email-service";
import tasker from "@calcom/features/tasker";
type EmailData = Omit<WorkflowEmailData, "to"> & {
to: string[];
} & { sendAt?: Date; includeCalendarEvent?: boolean; referenceUid?: string };
} & { sendAt?: Date | null; includeCalendarEvent?: boolean; referenceUid?: string };
export async function sendOrScheduleWorkflowEmails(mailData: EmailData) {
if (mailData.sendAt) {
@@ -1,4 +1,4 @@
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
import { BookingSeatRepository } from "@calcom/features/bookings/repositories/BookingSeatRepository";
import type { CreditCheckFn } from "@calcom/features/ee/billing/credit-service";
import {
isAttendeeAction,
@@ -7,32 +7,24 @@ import {
isWhatsappAction,
isCalAIAction,
} from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import { isEmailAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import { EmailWorkflowService } from "@calcom/features/ee/workflows/lib/service/EmailWorkflowService";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import type { Workflow, WorkflowStep } from "@calcom/features/ee/workflows/lib/types";
import { getSubmitterEmail } from "@calcom/features/tasker/tasks/triggerFormSubmittedNoEvent/formSubmissionValidation";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { SENDER_NAME } from "@calcom/lib/constants";
import { WorkflowReminderRepository } from "@calcom/features/ee/workflows/repositories/WorkflowReminderRepository";
import { formatCalEventExtended } from "@calcom/lib/formatCalendarEvent";
import { withReporting } from "@calcom/lib/sentryWrapper";
import { getTranslation } from "@calcom/lib/server/i18n";
import { checkSMSRateLimit } from "@calcom/lib/smsLockState";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
import { WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { BookingInfo } from "./smsReminderManager";
import type { FormSubmissionData } from "../types";
import type { BookingInfo } from "../types";
import type { ScheduleTextReminderAction } from "./smsReminderManager";
export type FormSubmissionData = {
responses: FORM_SUBMITTED_WEBHOOK_RESPONSES;
routedEventTypeId: number | null;
user: {
email: string;
timeFormat: number | null;
locale: string;
};
};
export type WorkflowContextData =
| { evt: BookingInfo; formData?: never }
| {
@@ -100,20 +92,12 @@ const processWorkflowStep = async (
}
// Common parameters for all scheduling functions
const scheduleFunctionParams = {
triggerEvent: workflow.trigger,
timeSpan: {
time: workflow.time,
timeUnit: workflow.timeUnit,
},
workflowStepId: step.id,
template: step.template,
userId: workflow.userId,
teamId: workflow.teamId,
seatReferenceUid,
verifiedAt: step.verifiedAt,
const scheduleFunctionParams = WorkflowService.generateCommonScheduleFunctionParams({
workflow,
workflowStep: step,
seatReferenceUid: seatReferenceUid,
creditCheckFn,
};
});
if (isSMSAction(step.action)) {
const { scheduleSMSReminder } = await import("./smsReminderManager");
@@ -128,76 +112,25 @@ const processWorkflowStep = async (
isVerificationPending: step.numberVerificationPending,
...contextData,
});
} else if (
step.action === WorkflowActions.EMAIL_ATTENDEE ||
step.action === WorkflowActions.EMAIL_HOST ||
step.action === WorkflowActions.EMAIL_ADDRESS
) {
} else if (isEmailAction(step.action)) {
const { scheduleEmailReminder } = await import("./emailReminderManager");
let sendTo: string[] = [];
switch (step.action) {
case WorkflowActions.EMAIL_ADDRESS:
sendTo = [step.sendTo || ""];
break;
case WorkflowActions.EMAIL_HOST: {
if (!evt) {
// EMAIL_HOST is not supported for form triggers
return;
}
sendTo = [evt.organizer?.email || ""];
const schedulingType = evt.eventType.schedulingType;
const isTeamEvent =
schedulingType === SchedulingType.ROUND_ROBIN || schedulingType === SchedulingType.COLLECTIVE;
if (isTeamEvent && evt.team?.members) {
sendTo = sendTo.concat(evt.team.members.map((member) => member.email));
}
break;
}
case WorkflowActions.EMAIL_ATTENDEE:
if (evt) {
const attendees = emailAttendeeSendToOverride
? [emailAttendeeSendToOverride]
: evt.attendees?.map((attendee) => attendee.email);
const limitGuestsDate = new Date("2025-01-13");
if (workflow.userId) {
const userRepository = new UserRepository(prisma);
const user = await userRepository.findById({ id: workflow.userId });
if (user?.createdDate && user.createdDate > limitGuestsDate) {
sendTo = attendees.slice(0, 1);
} else {
sendTo = attendees;
}
} else {
sendTo = attendees;
}
}
if (formData) {
const submitterEmail = getSubmitterEmail(formData.responses);
if (submitterEmail) {
sendTo = [submitterEmail];
}
}
if (!evt && step.action === WorkflowActions.EMAIL_HOST) {
// EMAIL_HOST is not supported for form triggers
return;
}
const emailParams = {
...scheduleFunctionParams,
action: step.action,
sendTo,
emailSubject: step.emailSubject || "",
emailBody: step.reminderBody || "",
sender: step.sender || SENDER_NAME,
const workflowReminderRepository = new WorkflowReminderRepository(prisma);
const bookingSeatRepository = new BookingSeatRepository(prisma);
const emailWorkflowService = new EmailWorkflowService(workflowReminderRepository, bookingSeatRepository);
const emailParams = await emailWorkflowService.generateParametersToBuildEmailWorkflowContent({
evt,
workflowStep: step,
workflow,
emailAttendeeSendToOverride,
formData,
commonScheduleFunctionParams: scheduleFunctionParams,
hideBranding,
includeCalendarEvent: step.includeCalendarEvent,
...contextData,
verifiedAt: step.verifiedAt,
} as const;
});
await scheduleEmailReminder(emailParams);
} else if (isWhatsappAction(step.action)) {
if (!evt) {
@@ -210,6 +143,7 @@ const processWorkflowStep = async (
await scheduleWhatsappReminder({
...scheduleFunctionParams,
verifiedAt: step.verifiedAt,
reminderPhone: sendTo,
action: step.action as ScheduleTextReminderAction,
message: step.reminderBody || "",
@@ -254,6 +188,23 @@ const _scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersArgs) =
if (workflow.steps.length === 0) continue;
for (const step of workflow.steps) {
if (
// These tasks currently write the entire payload in the task
(workflow.trigger === WorkflowTriggerEvents.BEFORE_EVENT ||
workflow.trigger === WorkflowTriggerEvents.AFTER_EVENT) &&
isEmailAction(step.action) &&
evt
) {
await WorkflowService.scheduleLazyEmailWorkflow({
evt,
workflowStepId: step.id,
workflowTriggerEvent: workflow.trigger,
workflow,
seatReferenceId: args.seatReferenceUid,
});
continue;
}
await processWorkflowStep(
workflow,
step,
@@ -312,7 +263,8 @@ const _cancelScheduledMessagesAndScheduleEmails = async ({
"@calcom/features/ee/workflows/repositories/WorkflowReminderRepository"
);
const scheduledMessages = await WorkflowReminderRepository.findScheduledMessagesToCancel({
const workflowReminderRepository = new WorkflowReminderRepository(prisma);
const scheduledMessages = await workflowReminderRepository.findScheduledMessagesToCancel({
teamId,
userIdsWithNoCredits,
});
@@ -345,7 +297,7 @@ const _cancelScheduledMessagesAndScheduleEmails = async ({
})
);
await WorkflowReminderRepository.updateRemindersToEmail({
await workflowReminderRepository.updateRemindersToEmail({
reminderIds: scheduledMessages.map((msg) => msg.id),
});
};
@@ -10,24 +10,21 @@ import { SENDER_ID } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
import type { TimeFormat } from "@calcom/lib/timeFormat";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import type { PrismaClient } from "@calcom/prisma";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { WorkflowTemplates, WorkflowActions, WorkflowMethods } from "@calcom/prisma/enums";
import { WorkflowTriggerEvents } from "@calcom/prisma/enums";
import type { CalEventResponses, RecurringEvent } from "@calcom/types/Calendar";
import { isAttendeeAction } from "../actionHelperFunctions";
import { getSenderId } from "../alphanumericSenderIdSupport";
import { IMMEDIATE_WORKFLOW_TRIGGER_EVENTS } from "../constants";
import { WorkflowOptOutContactRepository } from "../repository/workflowOptOutContact";
import { WorkflowOptOutService } from "../service/workflowOptOutService";
import type { FormSubmissionData, BookingInfo } from "../types";
import type { ScheduleReminderArgs } from "./emailReminderManager";
import { scheduleSmsOrFallbackEmail, sendSmsOrFallbackEmail } from "./messageDispatcher";
import * as twilio from "./providers/twilioProvider";
import type { FormSubmissionData } from "./reminderScheduler";
import customTemplate, { transformRoutingFormResponsesToVariableFormat } from "./templates/customTemplate";
import smsReminderTemplate from "./templates/smsReminderTemplate";
@@ -38,45 +35,6 @@ export enum timeUnitLowerCase {
}
const log = logger.getSubLogger({ prefix: ["[smsReminderManager]"] });
export type AttendeeInBookingInfo = {
name: string;
firstName?: string;
lastName?: string;
email: string;
phoneNumber?: string | null;
timeZone: string;
language: { locale: string };
};
export type BookingInfo = {
uid?: string | null;
bookerUrl: string;
attendees: AttendeeInBookingInfo[];
organizer: {
language: { locale: string };
name: string;
email: string;
timeZone: string;
timeFormat?: TimeFormat;
username?: string;
};
eventType?: {
slug: string;
recurringEvent?: RecurringEvent | null;
customReplyToEmail?: string | null;
};
startTime: string;
endTime: string;
title: string;
location?: string | null;
additionalNotes?: string | null;
responses?: CalEventResponses | null;
metadata?: Prisma.JsonValue;
cancellationReason?: string | null;
rescheduleReason?: string | null;
hideOrganizerEmail?: boolean;
};
export type ScheduleTextReminderAction = Extract<
WorkflowActions,
"SMS_ATTENDEE" | "SMS_NUMBER" | "WHATSAPP_ATTENDEE" | "WHATSAPP_NUMBER"
@@ -6,7 +6,7 @@ import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import { IMMEDIATE_WORKFLOW_TRIGGER_EVENTS } from "../constants";
import { getWorkflowRecipientEmail } from "../getWorkflowReminders";
import type { AttendeeInBookingInfo, BookingInfo } from "./smsReminderManager";
import type { AttendeeInBookingInfo, BookingInfo } from "../types";
import type { VariablesType } from "./templates/customTemplate";
import customTemplate, { transformBookingResponsesToVariableFormat } from "./templates/customTemplate";
@@ -15,8 +15,9 @@ import {
getContentSidForTemplate,
getContentVariablesForTemplate,
} from "../reminders/templates/whatsapp/ContentSidMapping";
import type { BookingInfo } from "../types";
import { scheduleSmsOrFallbackEmail, sendSmsOrFallbackEmail } from "./messageDispatcher";
import type { BookingInfo, ScheduleTextReminderArgs, timeUnitLowerCase } from "./smsReminderManager";
import type { ScheduleTextReminderArgs, timeUnitLowerCase } from "./smsReminderManager";
import {
whatsappEventCancelledTemplate,
whatsappEventCompletedTemplate,
@@ -200,7 +201,7 @@ export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs &
} catch (error) {
console.log(`Error sending WHATSAPP with error ${error}`);
}
}else if (
} else if (
(triggerEvent === WorkflowTriggerEvents.BEFORE_EVENT ||
triggerEvent === WorkflowTriggerEvents.AFTER_EVENT) &&
scheduledDate
@@ -0,0 +1,159 @@
import { describe, expect, vi, beforeEach, test } from "vitest";
import type { BookingSeatRepository } from "@calcom/features/bookings/repositories/BookingSeatRepository";
import type { WorkflowReminderRepository } from "@calcom/features/ee/workflows/repositories/WorkflowReminderRepository";
import { WorkflowActions, WorkflowTemplates, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
vi.mock("@calcom/emails/workflow-email-service", () => ({
sendCustomWorkflowEmail: vi.fn(),
}));
vi.mock("@calcom/features/profile/lib/hideBranding", () => ({
getHideBranding: vi.fn().mockResolvedValue(false),
}));
vi.mock("@calcom/lib/server/i18n", () => ({
getTranslation: vi.fn().mockResolvedValue((key: string) => key),
}));
vi.mock("@calcom/prisma", () => ({
default: {},
prisma: {},
}));
import { EmailWorkflowService } from "./EmailWorkflowService";
const mockWorkflowReminderRepository: Pick<WorkflowReminderRepository, "findByIdIncludeStepAndWorkflow"> = {
findByIdIncludeStepAndWorkflow: vi.fn(),
};
const mockBookingSeatRepository: Pick<BookingSeatRepository, "getByUidIncludeAttendee"> = {
getByUidIncludeAttendee: vi.fn(),
};
describe("EmailWorkflowService", () => {
let emailWorkflowService: EmailWorkflowService;
beforeEach(() => {
vi.clearAllMocks();
emailWorkflowService = new EmailWorkflowService(
mockWorkflowReminderRepository as WorkflowReminderRepository,
mockBookingSeatRepository as BookingSeatRepository
);
});
describe("handleSendEmailWorkflowTask", () => {
const mockEvt: Partial<CalendarEvent> = {
uid: "booking-123",
bookerUrl: "https://cal.com",
title: "Test Meeting",
startTime: "2024-12-01T10:00:00Z",
endTime: "2024-12-01T11:00:00Z",
organizer: {
name: "Organizer Name",
email: "organizer@example.com",
timeZone: "UTC",
language: { locale: "en", translate: (key: string) => key },
timeFormat: 12,
},
attendees: [
{
name: "Attendee Name",
email: "attendee@example.com",
timeZone: "UTC",
language: { locale: "en", translate: (key: string) => key },
},
],
};
test("should throw error if workflow reminder not found", async () => {
vi.mocked(mockWorkflowReminderRepository.findByIdIncludeStepAndWorkflow).mockResolvedValue(null);
await expect(
emailWorkflowService.handleSendEmailWorkflowTask({
evt: mockEvt as CalendarEvent,
workflowReminderId: 1,
})
).rejects.toThrow("Workflow reminder not found with id 1");
});
test("should throw error if workflow step not verified", async () => {
vi.mocked(mockWorkflowReminderRepository.findByIdIncludeStepAndWorkflow).mockResolvedValue({
id: 1,
workflowStep: {
id: 1,
verifiedAt: null,
action: WorkflowActions.EMAIL_ATTENDEE,
workflow: { userId: 1, teamId: null },
},
});
await expect(
emailWorkflowService.handleSendEmailWorkflowTask({
evt: mockEvt as CalendarEvent,
workflowReminderId: 1,
})
).rejects.toThrow("Workflow step id 1 is not verified");
});
test("should throw error if workflow step not found on reminder", async () => {
vi.mocked(mockWorkflowReminderRepository.findByIdIncludeStepAndWorkflow).mockResolvedValue({
id: 1,
workflowStep: null,
});
await expect(
emailWorkflowService.handleSendEmailWorkflowTask({
evt: mockEvt as CalendarEvent,
workflowReminderId: 1,
})
).rejects.toThrow("Workflow step not found on reminder with id 1");
});
test("should fetch seat attendee email for seated events", async () => {
const mockWorkflowReminder = {
id: 1,
seatReferenceId: "seat-123",
workflowStep: {
id: 1,
action: WorkflowActions.EMAIL_ATTENDEE,
sendTo: null,
template: WorkflowTemplates.REMINDER,
reminderBody: null,
emailSubject: null,
sender: null,
includeCalendarEvent: false,
verifiedAt: new Date(),
workflow: {
userId: 1,
teamId: null,
trigger: WorkflowTriggerEvents.BEFORE_EVENT,
time: 24,
timeUnit: "HOUR",
},
},
};
vi.mocked(mockWorkflowReminderRepository.findByIdIncludeStepAndWorkflow).mockResolvedValue(
mockWorkflowReminder
);
vi.mocked(mockBookingSeatRepository.getByUidIncludeAttendee).mockResolvedValue({
attendee: {
email: "seat-attendee@example.com",
},
});
try {
await emailWorkflowService.handleSendEmailWorkflowTask({
evt: mockEvt as CalendarEvent,
workflowReminderId: 1,
});
} catch {
// Expected to throw due to incomplete mock setup - we only care about the seat lookup
}
expect(mockBookingSeatRepository.getByUidIncludeAttendee).toHaveBeenCalledWith("seat-123");
});
});
});
@@ -0,0 +1,450 @@
import type { EventStatus } from "ics";
import dayjs from "@calcom/dayjs";
import generateIcsString from "@calcom/emails/lib/generateIcsString";
import { sendCustomWorkflowEmail } from "@calcom/emails/workflow-email-service";
import type { BookingSeatRepository } from "@calcom/features/bookings/repositories/BookingSeatRepository";
import type { Workflow, WorkflowStep } from "@calcom/features/ee/workflows/lib/types";
import { preprocessNameFieldDataWithVariant } from "@calcom/features/form-builder/utils";
import { getHideBranding } from "@calcom/features/profile/lib/hideBranding";
import { getSubmitterEmail } from "@calcom/features/tasker/tasks/triggerFormSubmittedNoEvent/formSubmissionValidation";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { SENDER_NAME, WEBSITE_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma";
import { WorkflowActions, WorkflowTemplates } from "@calcom/prisma/enums";
import { SchedulingType, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import { CalendarEvent } from "@calcom/types/Calendar";
import type { WorkflowReminderRepository } from "../../repositories/WorkflowReminderRepository";
import { isEmailAction } from "../actionHelperFunctions";
import { getWorkflowRecipientEmail } from "../getWorkflowReminders";
import type { VariablesType } from "../reminders/templates/customTemplate";
import customTemplate, {
transformBookingResponsesToVariableFormat,
} from "../reminders/templates/customTemplate";
import emailRatingTemplate from "../reminders/templates/emailRatingTemplate";
import emailReminderTemplate from "../reminders/templates/emailReminderTemplate";
import type {
FormSubmissionData,
WorkflowContextData,
AttendeeInBookingInfo,
BookingInfo,
ScheduleEmailReminderAction,
} from "../types";
import { WorkflowService } from "./WorkflowService";
export class EmailWorkflowService {
constructor(
private workflowReminderRepository: WorkflowReminderRepository,
private bookingSeatRepository: BookingSeatRepository
) {}
async handleSendEmailWorkflowTask({
evt,
workflowReminderId,
}: {
evt: CalendarEvent;
workflowReminderId: number;
}) {
const workflowReminder = await this.workflowReminderRepository.findByIdIncludeStepAndWorkflow(
workflowReminderId
);
if (!workflowReminder) {
throw new Error(`Workflow reminder not found with id ${workflowReminderId}`);
}
if (!workflowReminder.workflowStep) {
throw new Error(`Workflow step not found on reminder with id ${workflowReminderId}`);
}
if (!workflowReminder.workflowStep.verifiedAt) {
throw new Error(`Workflow step id ${workflowReminder.workflowStep.id} is not verified`);
}
const workflow = workflowReminder.workflowStep.workflow;
let emailAttendeeSendToOverride: string | null = null;
if (workflowReminder.seatReferenceId) {
const seatAttendee = await this.bookingSeatRepository.getByUidIncludeAttendee(
workflowReminder.seatReferenceId
);
emailAttendeeSendToOverride = seatAttendee?.attendee.email || null;
}
const { CreditService } = await import("@calcom/features/ee/billing/credit-service");
const creditService = new CreditService();
const creditCheckFn = creditService.hasAvailableCredits.bind(creditService);
const commonScheduleFunctionParams = WorkflowService.generateCommonScheduleFunctionParams({
workflow: workflowReminder.workflowStep.workflow,
workflowStep: workflowReminder.workflowStep,
seatReferenceUid: workflowReminder.seatReferenceId || undefined,
creditCheckFn,
});
const hideBranding = await getHideBranding({
userId: workflow.userId ?? undefined,
teamId: workflow.teamId ?? undefined,
});
const emailWorkflowContentParams = await this.generateParametersToBuildEmailWorkflowContent({
evt,
workflowStep: workflowReminder.workflowStep as WorkflowStep & { action: ScheduleEmailReminderAction },
workflow: workflowReminder.workflowStep.workflow,
emailAttendeeSendToOverride,
commonScheduleFunctionParams,
hideBranding,
});
const emailWorkflowContent = await this.generateEmailPayloadForEvtWorkflow({
...emailWorkflowContentParams,
evt: evt as BookingInfo,
action: workflowReminder.workflowStep.action as ScheduleEmailReminderAction,
template: workflowReminder.workflowStep.template,
includeCalendarEvent: workflowReminder.workflowStep.includeCalendarEvent,
});
const results = await Promise.allSettled(
emailWorkflowContentParams.sendTo.map((email) => {
return sendCustomWorkflowEmail({
to: email,
...emailWorkflowContent,
});
})
);
const failedEmails = results
.map((result, index) => ({
result,
email: emailWorkflowContentParams.sendTo[index],
}))
.filter(({ result }) => result.status === "rejected");
if (failedEmails.length > 0) {
console.error(
"Failed to send workflow emails:",
failedEmails.map(({ email, result }) => ({
email,
reason: result.status === "rejected" ? result.reason : undefined,
}))
);
}
}
async generateParametersToBuildEmailWorkflowContent({
evt,
workflowStep,
workflow,
emailAttendeeSendToOverride,
formData,
commonScheduleFunctionParams,
hideBranding,
}: {
evt?: CalendarEvent;
workflowStep: WorkflowStep;
workflow: Pick<Workflow, "userId">;
emailAttendeeSendToOverride?: string | null;
formData?: FormSubmissionData;
commonScheduleFunctionParams: ReturnType<typeof WorkflowService.generateCommonScheduleFunctionParams>;
hideBranding?: boolean;
}) {
if (!workflowStep.verifiedAt) {
throw new Error(`Workflow step ${workflowStep.id} is not verified`);
}
if (!isEmailAction(workflowStep.action)) {
throw new Error(`Non-email workflow step passed for booking ${evt?.uid}`);
}
let sendTo: string[] = [];
switch (workflowStep.action) {
case WorkflowActions.EMAIL_ADDRESS:
sendTo = [workflowStep.sendTo || ""];
break;
case WorkflowActions.EMAIL_HOST: {
if (!evt) {
throw new Error("EMAIL_HOST is not supported for form triggers");
}
sendTo = [evt.organizer?.email || ""];
const schedulingType = evt.schedulingType;
const isTeamEvent =
schedulingType === SchedulingType.ROUND_ROBIN || schedulingType === SchedulingType.COLLECTIVE;
if (isTeamEvent && evt.team?.members) {
sendTo = sendTo.concat(evt.team.members.map((member) => member.email));
}
break;
}
case WorkflowActions.EMAIL_ATTENDEE:
if (evt) {
const attendees = emailAttendeeSendToOverride
? [emailAttendeeSendToOverride]
: evt.attendees?.map((attendee) => attendee.email);
const limitGuestsDate = new Date("2025-01-13");
if (workflow.userId) {
const userRepository = new UserRepository(prisma);
const user = await userRepository.findById({ id: workflow.userId });
if (user?.createdDate && user.createdDate > limitGuestsDate) {
sendTo = attendees.slice(0, 1);
} else {
sendTo = attendees;
}
} else {
sendTo = attendees;
}
}
if (formData) {
const submitterEmail = getSubmitterEmail(formData.responses);
if (submitterEmail) {
sendTo = [submitterEmail];
}
}
}
// The evt builder already validates the bookerUrl exists
if (!evt || typeof evt.bookerUrl !== "string") {
throw new Error("bookerUrl not a part of the evt");
}
const contextData: WorkflowContextData = evt
? { evt: evt as BookingInfo }
: { formData: formData as FormSubmissionData };
return {
...commonScheduleFunctionParams,
action: workflowStep.action,
sendTo,
emailSubject: workflowStep.emailSubject || "",
emailBody: workflowStep.reminderBody || "",
sender: workflowStep.sender || SENDER_NAME,
hideBranding,
includeCalendarEvent: workflowStep.includeCalendarEvent,
...contextData,
verifiedAt: workflowStep.verifiedAt,
} as const;
}
async generateEmailPayloadForEvtWorkflow({
evt,
sendTo,
seatReferenceUid,
hideBranding,
emailSubject,
emailBody,
sender,
action,
template,
includeCalendarEvent,
triggerEvent,
}: {
evt: BookingInfo;
sendTo: string[];
seatReferenceUid?: string;
hideBranding?: boolean;
emailSubject: string;
emailBody: string;
sender: string;
action: ScheduleEmailReminderAction;
template?: WorkflowTemplates;
includeCalendarEvent?: boolean;
triggerEvent: WorkflowTriggerEvents;
}) {
const log = logger.getSubLogger({
prefix: [`[generateEmailPayloadForEvtWorkflow]: bookingUid: ${evt?.uid}`],
});
const { startTime, endTime } = evt;
let attendeeToBeUsedInMail: AttendeeInBookingInfo | null = null;
let name = "";
let attendeeName = "";
let timeZone = "";
switch (action) {
case WorkflowActions.EMAIL_ADDRESS:
name = "";
attendeeToBeUsedInMail = evt.attendees[0];
attendeeName = evt.attendees[0].name;
timeZone = evt.organizer.timeZone;
break;
case WorkflowActions.EMAIL_HOST:
attendeeToBeUsedInMail = evt.attendees[0];
name = evt.organizer.name;
attendeeName = attendeeToBeUsedInMail.name;
timeZone = evt.organizer.timeZone;
break;
case WorkflowActions.EMAIL_ATTENDEE: {
// check if first attendee of sendTo is present in the attendees list, if not take the evt attendee
const attendeeEmailToBeUsedInMailFromEvt = evt.attendees.find(
(attendee) => attendee.email === sendTo[0]
);
attendeeToBeUsedInMail = attendeeEmailToBeUsedInMailFromEvt
? attendeeEmailToBeUsedInMailFromEvt
: evt.attendees[0];
name = attendeeToBeUsedInMail.name;
attendeeName = evt.organizer.name;
timeZone = attendeeToBeUsedInMail.timeZone;
break;
}
}
if (!attendeeToBeUsedInMail) {
throw new Error("Failed to determine attendee email");
}
let emailContent = {
emailSubject,
emailBody: `<body style="white-space: pre-wrap;">${emailBody}</body>`,
};
const bookerUrl = evt.bookerUrl ?? WEBSITE_URL;
if (emailBody) {
const isEmailAttendeeAction = action === WorkflowActions.EMAIL_ATTENDEE;
const recipientEmail = getWorkflowRecipientEmail({
action,
attendeeEmail: attendeeToBeUsedInMail.email,
organizerEmail: evt.organizer.email,
sendToEmail: sendTo[0],
});
const meetingUrl =
evt.videoCallData?.url || bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl;
const variables: VariablesType = {
eventName: evt.title || "",
organizerName: evt.organizer.name,
attendeeName: attendeeToBeUsedInMail.name,
attendeeFirstName: attendeeToBeUsedInMail.firstName,
attendeeLastName: attendeeToBeUsedInMail.lastName,
attendeeEmail: attendeeToBeUsedInMail.email,
eventDate: dayjs(startTime).tz(timeZone),
eventEndTime: dayjs(endTime).tz(timeZone),
timeZone: timeZone,
location: evt.location,
additionalNotes: evt.additionalNotes,
responses: transformBookingResponsesToVariableFormat(evt.responses),
meetingUrl,
cancelLink: `${bookerUrl}/booking/${evt.uid}?cancel=true${
recipientEmail ? `&cancelledBy=${encodeURIComponent(recipientEmail)}` : ""
}${isEmailAttendeeAction && seatReferenceUid ? `&seatReferenceUid=${seatReferenceUid}` : ""}`,
cancelReason: evt.cancellationReason,
rescheduleLink: `${bookerUrl}/reschedule/${evt.uid}${
recipientEmail
? `?rescheduledBy=${encodeURIComponent(recipientEmail)}${
isEmailAttendeeAction && seatReferenceUid
? `&seatReferenceUid=${encodeURIComponent(seatReferenceUid)}`
: ""
}`
: isEmailAttendeeAction && seatReferenceUid
? `?seatReferenceUid=${encodeURIComponent(seatReferenceUid)}`
: ""
}`,
rescheduleReason: evt.rescheduleReason,
ratingUrl: `${bookerUrl}/booking/${evt.uid}?rating`,
noShowUrl: `${bookerUrl}/booking/${evt.uid}?noShow=true`,
attendeeTimezone: evt.attendees[0].timeZone,
eventTimeInAttendeeTimezone: dayjs(startTime).tz(evt.attendees[0].timeZone),
eventEndTimeInAttendeeTimezone: dayjs(endTime).tz(evt.attendees[0].timeZone),
};
const locale = isEmailAttendeeAction
? attendeeToBeUsedInMail.language?.locale
: evt.organizer.language.locale;
const emailSubjectTemplate = customTemplate(emailSubject, variables, locale, evt.organizer.timeFormat);
emailContent.emailSubject = emailSubjectTemplate.text;
emailContent.emailBody = customTemplate(
emailBody,
variables,
locale,
evt.organizer.timeFormat,
hideBranding
).html;
} else if (template === WorkflowTemplates.REMINDER) {
emailContent = emailReminderTemplate({
isEditingMode: false,
locale: evt.organizer.language.locale,
t: await getTranslation(evt.organizer.language.locale || "en", "common"),
action,
timeFormat: evt.organizer.timeFormat,
startTime,
endTime,
eventName: evt.title,
timeZone,
location: evt.location || "",
meetingUrl:
evt.videoCallData?.url || bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl || "",
otherPerson: attendeeName,
name,
});
} else if (template === WorkflowTemplates.RATING) {
emailContent = emailRatingTemplate({
isEditingMode: true,
locale: evt.organizer.language.locale,
action,
t: await getTranslation(evt.organizer.language.locale || "en", "common"),
timeFormat: evt.organizer.timeFormat,
startTime,
endTime,
eventName: evt.title,
timeZone,
organizer: evt.organizer.name,
name,
ratingUrl: `${bookerUrl}/booking/${evt.uid}?rating`,
noShowUrl: `${bookerUrl}/booking/${evt.uid}?noShow=true`,
});
}
// Allows debugging generated email content without waiting for sendgrid to send emails
log.debug(`Sending Email for trigger ${triggerEvent}`, JSON.stringify(emailContent));
const status: EventStatus =
triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED ? "CANCELLED" : "CONFIRMED";
const organizerT = await getTranslation(evt.organizer.language.locale || "en", "common");
const attendeeT = await getTranslation(evt.attendees[0].language.locale || "en", "common");
const attendee = {
...evt.attendees[0],
name: preprocessNameFieldDataWithVariant("fullName", evt.attendees[0].name) as string,
language: { ...evt.attendees[0].language, translate: attendeeT },
};
const emailEvent = {
...evt,
type: evt.eventType?.slug || "",
organizer: { ...evt.organizer, language: { ...evt.organizer.language, translate: organizerT } },
attendees: [attendee],
location: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl || evt.location,
};
const attachments = includeCalendarEvent
? [
{
content:
generateIcsString({
event: emailEvent,
status,
}) || "",
filename: "event.ics",
contentType: "text/calendar; charset=UTF-8; method=REQUEST",
disposition: "attachment",
},
]
: undefined;
return {
subject: emailContent.emailSubject,
html: emailContent.emailBody,
...(!evt.hideOrganizerEmail && {
replyTo: evt?.eventType?.customReplyToEmail || evt.organizer.email,
}),
attachments,
sender,
};
}
}
@@ -13,11 +13,22 @@ vi.mock("@calcom/features/tasker");
const mockScheduleWorkflowReminders = vi.mocked(scheduleWorkflowReminders);
const mockTasker = vi.mocked(tasker);
// Mock the getHideBranding function to return false
vi.mock("@calcom/features/profile/lib/hideBranding", () => ({
getHideBranding: vi.fn().mockResolvedValue(false),
}));
const mockWorkflowReminderCreate = vi.fn();
vi.mock("@calcom/features/ee/workflows/repositories/WorkflowReminderRepository", () => ({
WorkflowReminderRepository: vi.fn().mockImplementation(() => ({
create: mockWorkflowReminderCreate,
})),
}));
vi.mock("@calcom/prisma", () => ({
prisma: {},
default: {},
}));
describe("WorkflowService.scheduleFormWorkflows", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -226,3 +237,438 @@ describe("WorkflowService.scheduleFormWorkflows", () => {
});
});
});
describe("WorkflowService.scheduleLazyEmailWorkflow", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("should schedule lazy email workflow for BEFORE_EVENT trigger", async () => {
const mockWorkflow = {
time: 24,
timeUnit: TimeUnit.HOUR,
};
const mockEvt = {
uid: "booking-123",
startTime: "2024-12-01T10:00:00Z",
endTime: "2024-12-01T11:00:00Z",
};
const mockWorkflowReminder = {
id: 1,
uuid: "reminder-uuid-123",
};
mockWorkflowReminderCreate.mockResolvedValue(mockWorkflowReminder);
mockTasker.create.mockResolvedValue({ id: "task-123" });
await WorkflowService.scheduleLazyEmailWorkflow({
workflowTriggerEvent: "BEFORE_EVENT",
workflowStepId: 1,
workflow: mockWorkflow,
evt: mockEvt,
});
expect(mockWorkflowReminderCreate).toHaveBeenCalledWith({
bookingUid: "booking-123",
workflowStepId: 1,
method: "EMAIL",
scheduledDate: expect.any(Date),
scheduled: true,
});
expect(mockTasker.create).toHaveBeenCalledWith(
"sendWorkflowEmails",
{
bookingUid: "booking-123",
workflowReminderId: 1,
},
{
scheduledAt: expect.any(Date),
referenceUid: "reminder-uuid-123",
}
);
});
test("should schedule lazy email workflow for AFTER_EVENT trigger", async () => {
const mockWorkflow = {
time: 1,
timeUnit: TimeUnit.HOUR,
};
const mockEvt = {
uid: "booking-456",
startTime: "2024-12-01T10:00:00Z",
endTime: "2024-12-01T11:00:00Z",
};
const mockWorkflowReminder = {
id: 2,
uuid: "reminder-uuid-456",
};
mockWorkflowReminderCreate.mockResolvedValue(mockWorkflowReminder);
mockTasker.create.mockResolvedValue({ id: "task-456" });
await WorkflowService.scheduleLazyEmailWorkflow({
workflowTriggerEvent: "AFTER_EVENT",
workflowStepId: 2,
workflow: mockWorkflow,
evt: mockEvt,
});
expect(mockTasker.create).toHaveBeenCalledWith(
"sendWorkflowEmails",
{
bookingUid: "booking-456",
workflowReminderId: 2,
},
{
scheduledAt: expect.any(Date),
referenceUid: "reminder-uuid-456",
}
);
});
test("should handle seated events with seatReferenceId", async () => {
const mockWorkflow = {
time: 24,
timeUnit: TimeUnit.HOUR,
};
const mockEvt = {
uid: "booking-789",
startTime: "2024-12-01T10:00:00Z",
endTime: "2024-12-01T11:00:00Z",
};
const mockWorkflowReminder = {
id: 3,
uuid: "reminder-uuid-789",
};
mockWorkflowReminderCreate.mockResolvedValue(mockWorkflowReminder);
mockTasker.create.mockResolvedValue({ id: "task-789" });
await WorkflowService.scheduleLazyEmailWorkflow({
workflowTriggerEvent: "BEFORE_EVENT",
workflowStepId: 3,
workflow: mockWorkflow,
evt: mockEvt,
seatReferenceId: "seat-123",
});
expect(mockTasker.create).toHaveBeenCalled();
});
test("should not schedule if bookingUid is missing", async () => {
const mockWorkflow = {
time: 24,
timeUnit: TimeUnit.HOUR,
};
const mockEvt = {
uid: "",
startTime: "2024-12-01T10:00:00Z",
endTime: "2024-12-01T11:00:00Z",
};
await WorkflowService.scheduleLazyEmailWorkflow({
workflowTriggerEvent: "BEFORE_EVENT",
workflowStepId: 1,
workflow: mockWorkflow,
evt: mockEvt,
});
expect(mockWorkflowReminderCreate).not.toHaveBeenCalled();
expect(mockTasker.create).not.toHaveBeenCalled();
});
test("should not schedule if time is null", async () => {
const mockWorkflow = {
time: null,
timeUnit: TimeUnit.HOUR,
};
const mockEvt = {
uid: "booking-123",
startTime: "2024-12-01T10:00:00Z",
endTime: "2024-12-01T11:00:00Z",
};
await WorkflowService.scheduleLazyEmailWorkflow({
workflowTriggerEvent: "BEFORE_EVENT",
workflowStepId: 1,
workflow: mockWorkflow,
evt: mockEvt,
});
expect(mockWorkflowReminderCreate).not.toHaveBeenCalled();
expect(mockTasker.create).not.toHaveBeenCalled();
});
});
describe("WorkflowService.processWorkflowScheduledDate", () => {
test("should calculate scheduled date for BEFORE_EVENT trigger", () => {
const evt = {
startTime: "2024-12-01T10:00:00Z",
endTime: "2024-12-01T11:00:00Z",
};
const result = WorkflowService.processWorkflowScheduledDate({
workflowTriggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
time: 24,
timeUnit: TimeUnit.HOUR,
evt,
});
expect(result).toBeDefined();
expect(result?.toISOString()).toBe("2024-11-30T10:00:00.000Z");
});
test("should calculate scheduled date for AFTER_EVENT trigger", () => {
const evt = {
startTime: "2024-12-01T10:00:00Z",
endTime: "2024-12-01T11:00:00Z",
};
const result = WorkflowService.processWorkflowScheduledDate({
workflowTriggerEvent: WorkflowTriggerEvents.AFTER_EVENT,
time: 1,
timeUnit: TimeUnit.HOUR,
evt,
});
expect(result).toBeDefined();
expect(result?.toISOString()).toBe("2024-12-01T12:00:00.000Z");
});
test("should return null if time is null", () => {
const evt = {
startTime: "2024-12-01T10:00:00Z",
endTime: "2024-12-01T11:00:00Z",
};
const result = WorkflowService.processWorkflowScheduledDate({
workflowTriggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
time: null,
timeUnit: TimeUnit.HOUR,
evt,
});
expect(result).toBeNull();
});
test("should return null if timeUnit is null", () => {
const evt = {
startTime: "2024-12-01T10:00:00Z",
endTime: "2024-12-01T11:00:00Z",
};
const result = WorkflowService.processWorkflowScheduledDate({
workflowTriggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
time: 24,
timeUnit: null,
evt,
});
expect(result).toBeNull();
});
test("should calculate scheduled date with MINUTE time unit", () => {
const evt = {
startTime: "2024-12-01T10:00:00Z",
endTime: "2024-12-01T11:00:00Z",
};
const result = WorkflowService.processWorkflowScheduledDate({
workflowTriggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
time: 30,
timeUnit: TimeUnit.MINUTE,
evt,
});
expect(result).toBeDefined();
expect(result?.toISOString()).toBe("2024-12-01T09:30:00.000Z");
});
});
describe("WorkflowService.generateCommonScheduleFunctionParams", () => {
test("should generate common parameters correctly", () => {
const verifiedAt = new Date("2024-01-01T00:00:00Z");
const mockWorkflow = {
id: 1,
name: "Test Workflow",
trigger: WorkflowTriggerEvents.BEFORE_EVENT,
time: 24,
timeUnit: TimeUnit.HOUR,
userId: 1,
teamId: null,
};
const mockWorkflowStep = {
id: 1,
action: WorkflowActions.EMAIL_ATTENDEE,
sendTo: null,
template: WorkflowTemplates.REMINDER,
reminderBody: null,
emailSubject: null,
sender: null,
includeCalendarEvent: false,
verifiedAt,
numberVerificationPending: false,
numberRequired: false,
};
const mockCreditCheckFn = vi.fn();
const result = WorkflowService.generateCommonScheduleFunctionParams({
workflow: mockWorkflow,
workflowStep: mockWorkflowStep,
seatReferenceUid: undefined,
creditCheckFn: mockCreditCheckFn,
});
expect(result).toEqual({
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
timeSpan: {
time: 24,
timeUnit: TimeUnit.HOUR,
},
workflowStepId: 1,
template: WorkflowTemplates.REMINDER,
userId: 1,
teamId: null,
seatReferenceUid: undefined,
verifiedAt,
creditCheckFn: mockCreditCheckFn,
});
});
test("should handle seated events with seatReferenceUid", () => {
const verifiedAt = new Date("2024-01-01T00:00:00Z");
const mockWorkflow = {
id: 1,
name: "Test Workflow",
trigger: WorkflowTriggerEvents.BEFORE_EVENT,
time: 24,
timeUnit: TimeUnit.HOUR,
userId: 1,
teamId: null,
};
const mockWorkflowStep = {
id: 1,
action: WorkflowActions.EMAIL_ATTENDEE,
sendTo: null,
template: WorkflowTemplates.REMINDER,
reminderBody: null,
emailSubject: null,
sender: null,
includeCalendarEvent: false,
verifiedAt,
numberVerificationPending: false,
numberRequired: false,
};
const mockCreditCheckFn = vi.fn();
const result = WorkflowService.generateCommonScheduleFunctionParams({
workflow: mockWorkflow,
workflowStep: mockWorkflowStep,
seatReferenceUid: "seat-123",
creditCheckFn: mockCreditCheckFn,
});
expect(result.seatReferenceUid).toBe("seat-123");
});
test("should handle null verifiedAt", () => {
const mockWorkflow = {
id: 1,
name: "Test Workflow",
trigger: WorkflowTriggerEvents.BEFORE_EVENT,
time: 24,
timeUnit: TimeUnit.HOUR,
userId: 1,
teamId: null,
};
const mockWorkflowStep = {
id: 1,
action: WorkflowActions.EMAIL_ATTENDEE,
sendTo: null,
template: WorkflowTemplates.REMINDER,
reminderBody: null,
emailSubject: null,
sender: null,
includeCalendarEvent: false,
verifiedAt: null,
numberVerificationPending: false,
numberRequired: false,
};
const mockCreditCheckFn = vi.fn();
const result = WorkflowService.generateCommonScheduleFunctionParams({
workflow: mockWorkflow,
workflowStep: mockWorkflowStep,
seatReferenceUid: undefined,
creditCheckFn: mockCreditCheckFn,
});
expect(result.verifiedAt).toBeNull();
});
test("should handle team workflows", () => {
const verifiedAt = new Date("2024-01-01T00:00:00Z");
const mockWorkflow = {
id: 1,
name: "Team Workflow",
trigger: WorkflowTriggerEvents.AFTER_EVENT,
time: 1,
timeUnit: TimeUnit.HOUR,
userId: null,
teamId: 123,
};
const mockWorkflowStep = {
id: 2,
action: WorkflowActions.EMAIL_HOST,
sendTo: null,
template: WorkflowTemplates.CUSTOM,
reminderBody: "Custom body",
emailSubject: "Custom subject",
sender: "Team",
includeCalendarEvent: true,
verifiedAt,
numberVerificationPending: false,
numberRequired: false,
};
const mockCreditCheckFn = vi.fn();
const result = WorkflowService.generateCommonScheduleFunctionParams({
workflow: mockWorkflow,
workflowStep: mockWorkflowStep,
seatReferenceUid: undefined,
creditCheckFn: mockCreditCheckFn,
});
expect(result).toEqual({
triggerEvent: WorkflowTriggerEvents.AFTER_EVENT,
timeSpan: {
time: 1,
timeUnit: TimeUnit.HOUR,
},
workflowStepId: 2,
template: WorkflowTemplates.CUSTOM,
userId: null,
teamId: 123,
seatReferenceUid: undefined,
verifiedAt,
creditCheckFn: mockCreditCheckFn,
});
});
});
@@ -3,16 +3,20 @@ import { getAllWorkflows } from "@calcom/ee/workflows/lib/getAllWorkflows";
import type { ScheduleWorkflowRemindersArgs } from "@calcom/ee/workflows/lib/reminders/reminderScheduler";
import { scheduleWorkflowReminders } from "@calcom/ee/workflows/lib/reminders/reminderScheduler";
import type { timeUnitLowerCase } from "@calcom/ee/workflows/lib/reminders/smsReminderManager";
import type { Workflow } from "@calcom/ee/workflows/lib/types";
import type { Workflow, WorkflowStep } from "@calcom/ee/workflows/lib/types";
import type { CreditCheckFn } from "@calcom/features/ee/billing/credit-service";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { WorkflowReminderRepository } from "@calcom/features/ee/workflows/repositories/WorkflowReminderRepository";
import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository";
import { getHideBranding } from "@calcom/features/profile/lib/hideBranding";
import { tasker } from "@calcom/features/tasker";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
import { WorkflowTriggerEvents, WorkflowType } from "@calcom/prisma/enums";
import { WorkflowTriggerEvents, WorkflowType, WorkflowMethods } from "@calcom/prisma/enums";
import type { TimeUnit } from "@calcom/prisma/enums";
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/routing-forms/lib/formSubmissionUtils";
import { CalendarEvent } from "@calcom/types/Calendar";
// TODO (Sean): Move most of the logic migrated in 16861 to this service
export class WorkflowService {
@@ -239,4 +243,126 @@ export class WorkflowService {
workflows: workflows.filter((workflow) => triggers.includes(workflow.trigger)),
});
}
static async scheduleLazyEmailWorkflow({
workflowTriggerEvent,
workflowStepId,
workflow,
evt,
seatReferenceId,
}: {
// TODO: Expand this method to other workflow triggers
workflowTriggerEvent: "BEFORE_EVENT" | "AFTER_EVENT";
workflowStepId: number;
workflow: Pick<Workflow, "time" | "timeUnit">;
evt: Pick<CalendarEvent, "uid" | "startTime" | "endTime">;
seatReferenceId?: string;
}) {
const { uid: bookingUid } = evt;
const log = logger.getSubLogger({
prefix: [`[WorkflowService.scheduleLazyEmailReminder]: bookingUid ${bookingUid}`],
});
if (!bookingUid) {
log.error(`Missing bookingUid`);
return;
}
const scheduledDate = WorkflowService.processWorkflowScheduledDate({
time: workflow.time,
timeUnit: workflow.timeUnit,
workflowTriggerEvent,
evt,
...(seatReferenceId && { seatReferenceId }),
});
if (!scheduledDate) {
log.error("No scheduled date processed");
return;
}
let workflowReminder: { id: number | null; uuid: string | null };
try {
const workflowReminderRepository = new WorkflowReminderRepository(prisma);
workflowReminder = await workflowReminderRepository.create({
bookingUid,
workflowStepId,
method: WorkflowMethods.EMAIL,
scheduledDate,
scheduled: true,
seatReferenceUid: seatReferenceId,
});
} catch (error) {
log.error(`Error creating workflowReminder: ${error}`);
return;
}
if (!workflowReminder.id || !workflowReminder.uuid) {
log.error(`WorkflowReminder does not contain uuid`);
return;
}
const taskerPayload = {
bookingUid,
workflowReminderId: workflowReminder.id,
};
await tasker.create("sendWorkflowEmails", taskerPayload, {
scheduledAt: scheduledDate,
referenceUid: workflowReminder.uuid,
});
}
static processWorkflowScheduledDate({
workflowTriggerEvent,
time,
timeUnit,
evt,
}: {
workflowTriggerEvent: WorkflowTriggerEvents;
time: number | null;
timeUnit: TimeUnit | null;
evt: Pick<CalendarEvent, "startTime" | "endTime">;
}) {
const { startTime, endTime } = evt;
const processedTimeUnit: timeUnitLowerCase | undefined =
timeUnit?.toLocaleLowerCase() as timeUnitLowerCase;
let scheduledDate = null;
if (workflowTriggerEvent === WorkflowTriggerEvents.BEFORE_EVENT) {
scheduledDate =
time && processedTimeUnit ? dayjs(startTime).subtract(time, processedTimeUnit).toDate() : null;
} else if (workflowTriggerEvent === WorkflowTriggerEvents.AFTER_EVENT) {
scheduledDate = time && processedTimeUnit ? dayjs(endTime).add(time, processedTimeUnit).toDate() : null;
}
return scheduledDate;
}
static generateCommonScheduleFunctionParams({
workflow,
workflowStep,
seatReferenceUid,
creditCheckFn,
}: {
workflow: Omit<Workflow, "steps">;
workflowStep: WorkflowStep;
seatReferenceUid: string | undefined;
creditCheckFn: CreditCheckFn;
}) {
return {
triggerEvent: workflow.trigger,
timeSpan: {
time: workflow.time,
timeUnit: workflow.timeUnit,
},
workflowStepId: workflowStep.id,
template: workflowStep.template,
userId: workflow.userId,
teamId: workflow.teamId,
seatReferenceUid,
verifiedAt: workflowStep.verifiedAt || null,
creditCheckFn,
};
}
}
+70 -6
View File
@@ -1,9 +1,9 @@
import type {
TimeUnit,
WorkflowActions,
WorkflowTemplates,
WorkflowTriggerEvents,
} from "@calcom/prisma/enums";
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
import type { TimeFormat } from "@calcom/lib/timeFormat";
import type { Prisma } from "@calcom/prisma/client";
import type { TimeUnit, WorkflowTemplates, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import { WorkflowActions } from "@calcom/prisma/enums";
import type { CalEventResponses, RecurringEvent } from "@calcom/types/Calendar";
export type Workflow = {
id: number;
@@ -29,3 +29,67 @@ export type WorkflowStep = {
numberRequired: boolean | null;
verifiedAt?: Date | null;
};
export type FormSubmissionData = {
responses: FORM_SUBMITTED_WEBHOOK_RESPONSES;
routedEventTypeId: number | null;
user: {
email: string;
timeFormat: number | null;
locale: string;
};
};
export type AttendeeInBookingInfo = {
name: string;
firstName?: string;
lastName?: string;
email: string;
phoneNumber?: string | null;
timeZone: string;
language: { locale: string };
};
export type BookingInfo = {
uid?: string | null;
bookerUrl: string;
attendees: AttendeeInBookingInfo[];
organizer: {
language: { locale: string };
name: string;
email: string;
timeZone: string;
timeFormat?: TimeFormat;
username?: string;
};
eventType?: {
slug: string;
recurringEvent?: RecurringEvent | null;
customReplyToEmail?: string | null;
};
startTime: string;
endTime: string;
title: string;
location?: string | null;
additionalNotes?: string | null;
responses?: CalEventResponses | null;
metadata?: Prisma.JsonValue;
cancellationReason?: string | null;
rescheduleReason?: string | null;
hideOrganizerEmail?: boolean;
videoCallData?: {
url?: string;
};
};
export type WorkflowContextData =
| { evt: BookingInfo; formData?: never }
| {
evt?: never;
formData: FormSubmissionData;
};
export type ScheduleEmailReminderAction = Extract<
WorkflowActions,
"EMAIL_HOST" | "EMAIL_ATTENDEE" | "EMAIL_ADDRESS"
>;
@@ -1,15 +1,18 @@
import { prisma } from "@calcom/prisma";
// import { prisma as this.prismaClient } from "@calcom/prisma";
import type { PrismaClient } from "@calcom/prisma";
import { WorkflowMethods } from "@calcom/prisma/enums";
export class WorkflowReminderRepository {
static async findScheduledMessagesToCancel({
constructor(private prismaClient: PrismaClient) {}
async findScheduledMessagesToCancel({
teamId,
userIdsWithNoCredits,
}: {
teamId?: number | null;
userIdsWithNoCredits: number[];
}) {
return await prisma.workflowReminder.findMany({
return this.prismaClient.workflowReminder.findMany({
where: {
workflowStep: {
workflow: {
@@ -61,8 +64,8 @@ export class WorkflowReminderRepository {
});
}
static async updateRemindersToEmail({ reminderIds }: { reminderIds: number[] }): Promise<void> {
await prisma.workflowReminder.updateMany({
async updateRemindersToEmail({ reminderIds }: { reminderIds: number[] }) {
return this.prismaClient.workflowReminder.updateMany({
where: {
id: {
in: reminderIds,
@@ -74,4 +77,46 @@ export class WorkflowReminderRepository {
},
});
}
async create({
bookingUid,
workflowStepId,
method,
scheduledDate,
scheduled,
seatReferenceUid,
}: {
bookingUid: string;
workflowStepId: number;
method: WorkflowMethods;
scheduledDate: Date;
scheduled: boolean;
seatReferenceUid?: string;
}) {
return this.prismaClient.workflowReminder.create({
data: {
bookingUid,
workflowStepId,
method,
scheduledDate,
scheduled,
...(seatReferenceUid && { seatReferenceUid }),
},
});
}
findByIdIncludeStepAndWorkflow(id: number) {
return this.prismaClient.workflowReminder.findUnique({
where: {
id,
},
include: {
workflowStep: {
include: {
workflow: true,
},
},
},
});
}
}
@@ -0,0 +1,213 @@
import { describe, expect, vi, beforeEach, test } from "vitest";
vi.mock("@calcom/emails/workflow-email-service", () => ({
sendCustomWorkflowEmail: vi.fn(),
}));
const mockGetBookingForCalEventBuilderFromUid = vi.fn();
vi.mock("@calcom/features/bookings/repositories/BookingRepository", () => ({
BookingRepository: vi.fn().mockImplementation(() => ({
getBookingForCalEventBuilderFromUid: mockGetBookingForCalEventBuilderFromUid,
})),
}));
vi.mock("@calcom/features/bookings/repositories/BookingSeatRepository", () => ({
BookingSeatRepository: vi.fn(),
}));
const mockHandleSendEmailWorkflowTask = vi.fn();
vi.mock("@calcom/features/ee/workflows/lib/service/EmailWorkflowService", () => ({
EmailWorkflowService: vi.fn().mockImplementation(() => ({
handleSendEmailWorkflowTask: mockHandleSendEmailWorkflowTask,
})),
}));
vi.mock("@calcom/features/ee/workflows/repositories/WorkflowReminderRepository", () => ({
WorkflowReminderRepository: vi.fn(),
}));
vi.mock("@calcom/prisma", () => ({
default: {},
prisma: {},
}));
vi.mock("@calcom/features/CalendarEventBuilder", () => ({
CalendarEventBuilder: {
fromBooking: vi.fn(),
},
}));
import { sendCustomWorkflowEmail } from "@calcom/emails/workflow-email-service";
import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder";
import { sendWorkflowEmails, ZSendWorkflowEmailsSchema } from "./sendWorkflowEmails";
const mockSendCustomWorkflowEmail = vi.mocked(sendCustomWorkflowEmail);
const mockCalendarEventBuilder = vi.mocked(CalendarEventBuilder);
describe("sendWorkflowEmails", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("Schema validation", () => {
test("should validate eager email payload", () => {
const eagerPayload = {
to: ["test@example.com"],
subject: "Test Subject",
html: "<p>Test Body</p>",
replyTo: "reply@example.com",
sender: "Sender Name",
attachments: [
{
content: "content",
filename: "file.txt",
},
],
};
const result = ZSendWorkflowEmailsSchema.parse(eagerPayload);
expect(result).toEqual(eagerPayload);
});
test("should validate lazy email payload", () => {
const lazyPayload = {
bookingUid: "booking-123",
workflowReminderId: 1,
};
const result = ZSendWorkflowEmailsSchema.parse(lazyPayload);
expect(result).toEqual(lazyPayload);
});
});
describe("Eager email sending", () => {
test("should send email with eager payload", async () => {
const payload = {
to: ["test@example.com", "test2@example.com"],
subject: "Test Subject",
html: "<p>Test Body</p>",
replyTo: "reply@example.com",
sender: "Sender Name",
};
await sendWorkflowEmails(JSON.stringify(payload));
expect(mockSendCustomWorkflowEmail).toHaveBeenCalledTimes(2);
expect(mockSendCustomWorkflowEmail).toHaveBeenCalledWith({
to: "test@example.com",
subject: "Test Subject",
html: "<p>Test Body</p>",
replyTo: "reply@example.com",
sender: "Sender Name",
});
expect(mockSendCustomWorkflowEmail).toHaveBeenCalledWith({
to: "test2@example.com",
subject: "Test Subject",
html: "<p>Test Body</p>",
replyTo: "reply@example.com",
sender: "Sender Name",
});
});
test("should send email with attachments", async () => {
const payload = {
to: ["test@example.com"],
subject: "Test Subject",
html: "<p>Test Body</p>",
attachments: [
{
content: "base64content",
filename: "event.ics",
},
],
};
await sendWorkflowEmails(JSON.stringify(payload));
expect(mockSendCustomWorkflowEmail).toHaveBeenCalledWith({
to: "test@example.com",
subject: "Test Subject",
html: "<p>Test Body</p>",
attachments: [
{
content: "base64content",
filename: "event.ics",
},
],
});
});
});
describe("Lazy email generation", () => {
test("should generate and send email with lazy payload", async () => {
const payload = {
bookingUid: "booking-123",
workflowReminderId: 1,
};
const mockBooking = {
uid: "booking-123",
user: { id: 1, name: "User", email: "user@example.com" },
eventType: { id: 1, title: "Meeting" },
};
const mockCalendarEvent = {
uid: "booking-123",
title: "Meeting",
organizer: { email: "user@example.com" },
attendees: [{ email: "attendee@example.com" }],
};
const mockBuilder = {
build: vi.fn().mockReturnValue(mockCalendarEvent),
} as unknown as CalendarEventBuilder;
mockGetBookingForCalEventBuilderFromUid.mockResolvedValue(mockBooking);
mockCalendarEventBuilder.fromBooking.mockResolvedValue(mockBuilder);
await sendWorkflowEmails(JSON.stringify(payload));
expect(mockGetBookingForCalEventBuilderFromUid).toHaveBeenCalledWith("booking-123");
expect(mockHandleSendEmailWorkflowTask).toHaveBeenCalledWith({
evt: mockCalendarEvent,
workflowReminderId: 1,
});
});
test("should throw error if booking not found", async () => {
const payload = {
bookingUid: "non-existent-booking",
workflowReminderId: 1,
};
mockGetBookingForCalEventBuilderFromUid.mockResolvedValue(null);
await expect(sendWorkflowEmails(JSON.stringify(payload))).rejects.toThrow("Booking not found");
});
test("should throw error if calendar event cannot be built", async () => {
const payload = {
bookingUid: "booking-123",
workflowReminderId: 1,
};
const mockBooking = {
uid: "booking-123",
user: { id: 1, name: "User", email: "user@example.com" },
eventType: { id: 1, title: "Meeting" },
};
const mockBuilder = {
build: vi.fn().mockReturnValue(null),
} as unknown as CalendarEventBuilder;
mockGetBookingForCalEventBuilderFromUid.mockResolvedValue(mockBooking);
mockCalendarEventBuilder.fromBooking.mockResolvedValue(mockBuilder);
await expect(sendWorkflowEmails(JSON.stringify(payload))).rejects.toThrow(
"Calendar event could not be built"
);
});
});
});
@@ -1,8 +1,14 @@
import { z } from "zod";
import { sendCustomWorkflowEmail } from "@calcom/emails/workflow-email-service";
import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { BookingSeatRepository } from "@calcom/features/bookings/repositories/BookingSeatRepository";
import { EmailWorkflowService } from "@calcom/features/ee/workflows/lib/service/EmailWorkflowService";
import { WorkflowReminderRepository } from "@calcom/features/ee/workflows/repositories/WorkflowReminderRepository";
import { prisma } from "@calcom/prisma";
export const ZSendWorkflowEmailsSchema = z.object({
export const ZSendWorkflowEmailsSchemaEager = z.object({
to: z.array(z.string()),
subject: z.string(),
html: z.string(),
@@ -20,9 +26,45 @@ export const ZSendWorkflowEmailsSchema = z.object({
.optional(),
});
const ZSendWorkflowEmailsSchemaLazy = z.object({
bookingUid: z.string(),
workflowReminderId: z.number(),
});
export const ZSendWorkflowEmailsSchema = z.union([
ZSendWorkflowEmailsSchemaEager,
ZSendWorkflowEmailsSchemaLazy,
]);
export async function sendWorkflowEmails(payload: string): Promise<void> {
const mailData = ZSendWorkflowEmailsSchema.parse(JSON.parse(payload));
// Generate workflow body to send
if ("bookingUid" in mailData && "workflowReminderId" in mailData) {
const bookingRepository = new BookingRepository(prisma);
const booking = await bookingRepository.getBookingForCalEventBuilderFromUid(mailData.bookingUid);
if (!booking) {
throw new Error("Booking not found");
}
const calendarEvent = (await CalendarEventBuilder.fromBooking(booking, {})).build();
if (!calendarEvent) {
throw new Error("Calendar event could not be built");
}
const workflowReminderRepository = new WorkflowReminderRepository(prisma);
const bookingSeatRepository = new BookingSeatRepository(prisma);
const emailWorkflowService = new EmailWorkflowService(workflowReminderRepository, bookingSeatRepository);
await emailWorkflowService.handleSendEmailWorkflowTask({
evt: calendarEvent,
workflowReminderId: mailData.workflowReminderId,
});
return;
}
await Promise.all(
mailData.to.map((to) =>
sendCustomWorkflowEmail({
+8 -4
View File
@@ -10,13 +10,17 @@ import SMSManager from "../sms-manager";
vi.mock("@calcom/lib/smsLockState");
vi.mock("@calcom/features/ee/workflows/lib/reminders/messageDispatcher");
vi.mock("@calcom/prisma", () => ({
default: {
vi.mock("@calcom/prisma", () => {
const mockObj = {
team: {
findUnique: vi.fn(),
},
},
}));
};
return {
default: mockObj,
prisma: mockObj,
};
});
interface TestAttendee extends Person {
name: string;
@@ -5,16 +5,19 @@ import prisma from "@calcom/prisma";
import { outOfOfficeCreateOrUpdate } from "./outOfOfficeCreateOrUpdate.handler";
// Mock the module first with a default export object (empty for now)
vi.mock("@calcom/prisma", () => ({
default: {
vi.mock("@calcom/prisma", () => {
const mockObj = {
outOfOfficeEntry: {
findFirst: vi.fn(),
findUnique: vi.fn(),
upsert: vi.fn(),
},
},
}));
};
return {
default: mockObj,
prisma: mockObj,
};
});
// Setup mocks to control the implementations
const prismaMock = {