diff --git a/packages/features/CalendarEventBuilder.ts b/packages/features/CalendarEventBuilder.ts index eb4676fac2..018c17e75a 100644 --- a/packages/features/CalendarEventBuilder.ts +++ b/packages/features/CalendarEventBuilder.ts @@ -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, }); diff --git a/packages/features/bookings/repositories/BookingRepository.ts b/packages/features/bookings/repositories/BookingRepository.ts index d2277944fb..0cd30bafdf 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -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) { diff --git a/packages/features/bookings/repositories/BookingSeatRepository.ts b/packages/features/bookings/repositories/BookingSeatRepository.ts new file mode 100644 index 0000000000..ace67b38d8 --- /dev/null +++ b/packages/features/bookings/repositories/BookingSeatRepository.ts @@ -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, + }, + }, + }, + }); + } +} diff --git a/packages/features/ee/workflows/lib/actionHelperFunctions.ts b/packages/features/ee/workflows/lib/actionHelperFunctions.ts index 0728047644..6829c6c70e 100644 --- a/packages/features/ee/workflows/lib/actionHelperFunctions.ts +++ b/packages/features/ee/workflows/lib/actionHelperFunctions.ts @@ -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 { return ( action === WorkflowActions.EMAIL_ADDRESS || action === WorkflowActions.EMAIL_ATTENDEE || diff --git a/packages/features/ee/workflows/lib/reminders/aiPhoneCallManager.ts b/packages/features/ee/workflows/lib/reminders/aiPhoneCallManager.ts index 0d24aaa389..7ed283e581 100644 --- a/packages/features/ee/workflows/lib/reminders/aiPhoneCallManager.ts +++ b/packages/features/ee/workflows/lib/reminders/aiPhoneCallManager.ts @@ -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"; diff --git a/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts b/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts index 159df8a147..5bbe3728cf 100644 --- a/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts @@ -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: `${emailBody}`, - }; - 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, diff --git a/packages/features/ee/workflows/lib/reminders/providers/emailProvider.ts b/packages/features/ee/workflows/lib/reminders/providers/emailProvider.ts index d306ae236b..af12b9d9e5 100644 --- a/packages/features/ee/workflows/lib/reminders/providers/emailProvider.ts +++ b/packages/features/ee/workflows/lib/reminders/providers/emailProvider.ts @@ -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 & { to: string[]; -} & { sendAt?: Date; includeCalendarEvent?: boolean; referenceUid?: string }; +} & { sendAt?: Date | null; includeCalendarEvent?: boolean; referenceUid?: string }; export async function sendOrScheduleWorkflowEmails(mailData: EmailData) { if (mailData.sendAt) { diff --git a/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts b/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts index 75acb065b4..e38c8624ce 100644 --- a/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts +++ b/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts @@ -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), }); }; diff --git a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts index 4af48c5a74..0410f7da2f 100644 --- a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts @@ -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" diff --git a/packages/features/ee/workflows/lib/reminders/utils.ts b/packages/features/ee/workflows/lib/reminders/utils.ts index 5d363af61a..1cf6dbdc82 100644 --- a/packages/features/ee/workflows/lib/reminders/utils.ts +++ b/packages/features/ee/workflows/lib/reminders/utils.ts @@ -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"; diff --git a/packages/features/ee/workflows/lib/reminders/whatsappReminderManager.ts b/packages/features/ee/workflows/lib/reminders/whatsappReminderManager.ts index 9f09905227..231d8bd578 100644 --- a/packages/features/ee/workflows/lib/reminders/whatsappReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/whatsappReminderManager.ts @@ -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 diff --git a/packages/features/ee/workflows/lib/service/EmailWorkflowService.test.ts b/packages/features/ee/workflows/lib/service/EmailWorkflowService.test.ts new file mode 100644 index 0000000000..64fd338591 --- /dev/null +++ b/packages/features/ee/workflows/lib/service/EmailWorkflowService.test.ts @@ -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 = { + findByIdIncludeStepAndWorkflow: vi.fn(), +}; + +const mockBookingSeatRepository: Pick = { + 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 = { + 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"); + }); + }); +}); diff --git a/packages/features/ee/workflows/lib/service/EmailWorkflowService.ts b/packages/features/ee/workflows/lib/service/EmailWorkflowService.ts new file mode 100644 index 0000000000..020269a52c --- /dev/null +++ b/packages/features/ee/workflows/lib/service/EmailWorkflowService.ts @@ -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; + emailAttendeeSendToOverride?: string | null; + formData?: FormSubmissionData; + commonScheduleFunctionParams: ReturnType; + 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: `${emailBody}`, + }; + 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, + }; + } +} diff --git a/packages/features/ee/workflows/lib/service/WorkflowService.test.ts b/packages/features/ee/workflows/lib/service/WorkflowService.test.ts index 61f25dd25d..e09a2e57b2 100644 --- a/packages/features/ee/workflows/lib/service/WorkflowService.test.ts +++ b/packages/features/ee/workflows/lib/service/WorkflowService.test.ts @@ -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, + }); + }); +}); diff --git a/packages/features/ee/workflows/lib/service/WorkflowService.ts b/packages/features/ee/workflows/lib/service/WorkflowService.ts index 7093440613..7163ddbb14 100644 --- a/packages/features/ee/workflows/lib/service/WorkflowService.ts +++ b/packages/features/ee/workflows/lib/service/WorkflowService.ts @@ -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; + evt: Pick; + 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; + }) { + 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; + 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, + }; + } } diff --git a/packages/features/ee/workflows/lib/types.ts b/packages/features/ee/workflows/lib/types.ts index 8c5ac00f42..7e52945151 100644 --- a/packages/features/ee/workflows/lib/types.ts +++ b/packages/features/ee/workflows/lib/types.ts @@ -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" +>; diff --git a/packages/features/ee/workflows/repositories/WorkflowReminderRepository.ts b/packages/features/ee/workflows/repositories/WorkflowReminderRepository.ts index 60d6727d2c..c69a058c61 100644 --- a/packages/features/ee/workflows/repositories/WorkflowReminderRepository.ts +++ b/packages/features/ee/workflows/repositories/WorkflowReminderRepository.ts @@ -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 { - 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, + }, + }, + }, + }); + } } diff --git a/packages/features/tasker/tasks/sendWorkflowEmails.test.ts b/packages/features/tasker/tasks/sendWorkflowEmails.test.ts new file mode 100644 index 0000000000..93211d9fe5 --- /dev/null +++ b/packages/features/tasker/tasks/sendWorkflowEmails.test.ts @@ -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: "

Test Body

", + 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: "

Test Body

", + 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: "

Test Body

", + replyTo: "reply@example.com", + sender: "Sender Name", + }); + expect(mockSendCustomWorkflowEmail).toHaveBeenCalledWith({ + to: "test2@example.com", + subject: "Test Subject", + html: "

Test Body

", + replyTo: "reply@example.com", + sender: "Sender Name", + }); + }); + + test("should send email with attachments", async () => { + const payload = { + to: ["test@example.com"], + subject: "Test Subject", + html: "

Test Body

", + attachments: [ + { + content: "base64content", + filename: "event.ics", + }, + ], + }; + + await sendWorkflowEmails(JSON.stringify(payload)); + + expect(mockSendCustomWorkflowEmail).toHaveBeenCalledWith({ + to: "test@example.com", + subject: "Test Subject", + html: "

Test Body

", + 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" + ); + }); + }); +}); diff --git a/packages/features/tasker/tasks/sendWorkflowEmails.ts b/packages/features/tasker/tasks/sendWorkflowEmails.ts index 11c54889a0..dbd5ac6870 100644 --- a/packages/features/tasker/tasks/sendWorkflowEmails.ts +++ b/packages/features/tasker/tasks/sendWorkflowEmails.ts @@ -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 { 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({ diff --git a/packages/sms/test/sms-manager.test.ts b/packages/sms/test/sms-manager.test.ts index 06237ce89e..b787feaba8 100644 --- a/packages/sms/test/sms-manager.test.ts +++ b/packages/sms/test/sms-manager.test.ts @@ -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; diff --git a/packages/trpc/server/routers/viewer/ooo/outOfOfficeCreateOrUpdate.handler.test.ts b/packages/trpc/server/routers/viewer/ooo/outOfOfficeCreateOrUpdate.handler.test.ts index d44353f174..4ec49b2b84 100644 --- a/packages/trpc/server/routers/viewer/ooo/outOfOfficeCreateOrUpdate.handler.test.ts +++ b/packages/trpc/server/routers/viewer/ooo/outOfOfficeCreateOrUpdate.handler.test.ts @@ -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 = {