diff --git a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts index dba55cf860..a3dafee1ed 100644 --- a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts @@ -1,6 +1,10 @@ import dayjs from "@calcom/dayjs"; -import { bulkShortenLinks } from "@calcom/ee/workflows/lib/reminders/utils"; -import { SENDER_ID, WEBSITE_URL } from "@calcom/lib/constants"; +import { + getAttendeeToBeUsedInSMS, + getSMSMessageWithVariables, + shouldUseTwilio, +} from "@calcom/ee/workflows/lib/reminders/utils"; +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"; @@ -10,7 +14,6 @@ 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 { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; import type { CalEventResponses, RecurringEvent } from "@calcom/types/Calendar"; import { isAttendeeAction } from "../actionHelperFunctions"; @@ -20,8 +23,6 @@ import { WorkflowOptOutService } from "../service/workflowOptOutService"; import type { ScheduleReminderArgs } from "./emailReminderManager"; import { scheduleSmsOrFallbackEmail, sendSmsOrFallbackEmail } from "./messageDispatcher"; import * as twilio from "./providers/twilioProvider"; -import type { VariablesType } from "./templates/customTemplate"; -import customTemplate from "./templates/customTemplate"; import smsReminderTemplate from "./templates/smsReminderTemplate"; export enum timeUnitLowerCase { @@ -118,7 +119,6 @@ export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => { const { startTime, endTime } = evt; const uid = evt.uid as string; - const currentDate = dayjs(); const timeUnit: timeUnitLowerCase | undefined = timeSpan.timeUnit?.toLocaleLowerCase() as timeUnitLowerCase; let scheduledDate = null; @@ -139,180 +139,140 @@ export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => { } const isNumberVerified = await getIsNumberVerified(); - let attendeeToBeUsedInSMS: AttendeeInBookingInfo | null = null; - if (action === WorkflowActions.SMS_ATTENDEE) { - const attendeeWithReminderPhoneAsSMSReminderNumber = - reminderPhone && evt.attendees.find((attendee) => attendee.email === evt.responses?.email?.value); - attendeeToBeUsedInSMS = attendeeWithReminderPhoneAsSMSReminderNumber - ? attendeeWithReminderPhoneAsSMSReminderNumber - : evt.attendees[0]; - } else { - attendeeToBeUsedInSMS = evt.attendees[0]; - } - 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; } - const name = action === WorkflowActions.SMS_ATTENDEE ? attendeeToBeUsedInSMS.name : ""; - const attendeeName = - action === WorkflowActions.SMS_ATTENDEE ? evt.organizer.name : attendeeToBeUsedInSMS.name; - const timeZone = - action === WorkflowActions.SMS_ATTENDEE ? attendeeToBeUsedInSMS.timeZone : evt.organizer.timeZone; + if (reminderPhone && isNumberVerified) { + const useTwilio = shouldUseTwilio(triggerEvent, scheduledDate); + if (useTwilio) { + const attendeeToBeUsedInSMS = getAttendeeToBeUsedInSMS(action, evt, reminderPhone); - const locale = - action === WorkflowActions.SMS_ATTENDEE - ? attendeeToBeUsedInSMS.language?.locale - : evt.organizer.language.locale; + const name = action === WorkflowActions.SMS_ATTENDEE ? attendeeToBeUsedInSMS.name : ""; + const attendeeName = + action === WorkflowActions.SMS_ATTENDEE ? evt.organizer.name : attendeeToBeUsedInSMS.name; + const timeZone = + action === WorkflowActions.SMS_ATTENDEE ? attendeeToBeUsedInSMS.timeZone : evt.organizer.timeZone; - let smsMessage = message; + let smsMessage = message; - if (smsMessage) { - const urls = { - meetingUrl: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl || "", - cancelLink: `${evt.bookerUrl ?? WEBSITE_URL}/booking/${evt.uid}?cancel=true`, - rescheduleLink: `${evt.bookerUrl ?? WEBSITE_URL}/reschedule/${evt.uid}`, - }; + if (smsMessage) { + smsMessage = await getSMSMessageWithVariables(smsMessage, evt, attendeeToBeUsedInSMS, action); + } else if (template === WorkflowTemplates.REMINDER) { + smsMessage = + smsReminderTemplate( + false, + evt.organizer.language.locale, + action, + evt.organizer.timeFormat, + evt.startTime, + evt.title, + timeZone, + attendeeName, + name + ) || message; + } - const [{ shortLink: meetingUrl }, { shortLink: cancelLink }, { shortLink: rescheduleLink }] = - await bulkShortenLinks([urls.meetingUrl, urls.cancelLink, urls.rescheduleLink]); + if (smsMessage.length > 0) { + if (process.env.TWILIO_OPT_OUT_ENABLED === "true") { + smsMessage = await WorkflowOptOutService.addOptOutMessage( + smsMessage, + evt.organizer.language.locale + ); + } + // Allows debugging generated email content without waiting for sendgrid to send emails + log.debug(`Sending sms for trigger ${triggerEvent}`, smsMessage); - const variables: VariablesType = { - eventName: evt.title, - organizerName: evt.organizer.name, - attendeeName: attendeeToBeUsedInSMS.name, - attendeeFirstName: attendeeToBeUsedInSMS.firstName, - attendeeLastName: attendeeToBeUsedInSMS.lastName, - attendeeEmail: attendeeToBeUsedInSMS.email, - eventDate: dayjs(evt.startTime).tz(timeZone), - eventEndTime: dayjs(evt.endTime).tz(timeZone), - timeZone: timeZone, - location: evt.location, - additionalNotes: evt.additionalNotes, - responses: evt.responses, - meetingUrl, - cancelLink, - rescheduleLink, - cancelReason: evt.cancellationReason, - rescheduleReason: evt.rescheduleReason, - attendeeTimezone: evt.attendees[0].timeZone, - eventTimeInAttendeeTimezone: dayjs(evt.startTime).tz(evt.attendees[0].timeZone), - eventEndTimeInAttendeeTimezone: dayjs(evt.endTime).tz(evt.attendees[0].timeZone), - }; - const customMessage = customTemplate(smsMessage, variables, locale, evt.organizer.timeFormat); - smsMessage = customMessage.text; - } else if (template === WorkflowTemplates.REMINDER) { - smsMessage = - smsReminderTemplate( - false, - evt.organizer.language.locale, - action, - evt.organizer.timeFormat, - evt.startTime, - evt.title, - timeZone, - attendeeName, - name - ) || message; - } + if ( + triggerEvent === WorkflowTriggerEvents.NEW_EVENT || + triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED || + triggerEvent === WorkflowTriggerEvents.RESCHEDULE_EVENT + ) { + try { + await sendSmsOrFallbackEmail({ + twilioData: { + phoneNumber: reminderPhone, + body: smsMessage, + sender: senderID, + bookingUid: evt.uid, + userId, + teamId, + }, + fallbackData: isAttendeeAction(action) + ? { + email: evt.attendees[0].email, + t: await getTranslation(evt.attendees[0].language.locale, "common"), + replyTo: evt.organizer.email, + } + : undefined, + }); + } catch (error) { + log.error(`Error sending SMS with error ${error}`); + } + } - // Allows debugging generated email content without waiting for sendgrid to send emails - log.debug(`Sending sms for trigger ${triggerEvent}`, smsMessage); + if ( + (triggerEvent === WorkflowTriggerEvents.BEFORE_EVENT || + triggerEvent === WorkflowTriggerEvents.AFTER_EVENT) && + scheduledDate + ) { + try { + // schedule at least 15 minutes in advance and at most 2 hours in advance + const scheduledNotification = await scheduleSmsOrFallbackEmail({ + twilioData: { + phoneNumber: reminderPhone, + body: smsMessage, + scheduledDate: scheduledDate.toDate(), + sender: senderID, + bookingUid: evt.uid, + userId, + teamId, + }, + fallbackData: isAttendeeAction(action) + ? { + email: evt.attendees[0].email, + t: await getTranslation(evt.attendees[0].language.locale, "common"), + replyTo: evt.organizer.email, + workflowStepId, + } + : undefined, + }); - if (smsMessage.length > 0 && reminderPhone && isNumberVerified) { - if (process.env.TWILIO_OPT_OUT_ENABLED === "true") { - smsMessage = await WorkflowOptOutService.addOptOutMessage(smsMessage, evt.organizer.language.locale); + if (scheduledNotification?.sid) { + await prisma.workflowReminder.create({ + data: { + bookingUid: uid, + workflowStepId: workflowStepId, + method: WorkflowMethods.SMS, + scheduledDate: scheduledDate.toDate(), + scheduled: true, + referenceId: scheduledNotification.sid, + seatReferenceId: seatReferenceUid, + }, + }); + } + } catch (error) { + log.error(`Error scheduling SMS with error ${error}`); + } + } + } + return; } - //send SMS when event is booked/cancelled/rescheduled - if ( - triggerEvent === WorkflowTriggerEvents.NEW_EVENT || - triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED || - triggerEvent === WorkflowTriggerEvents.RESCHEDULE_EVENT - ) { - try { - await sendSmsOrFallbackEmail({ - twilioData: { - phoneNumber: reminderPhone, - body: smsMessage, - sender: senderID, - bookingUid: evt.uid, - userId, - teamId, - }, - fallbackData: isAttendeeAction(action) - ? { - email: evt.attendees[0].email, - t: await getTranslation(evt.attendees[0].language.locale, "common"), - replyTo: evt.organizer.email, - } - : undefined, - }); - } catch (error) { - log.error(`Error sending SMS with error ${error}`); - } - } else if ( - (triggerEvent === WorkflowTriggerEvents.BEFORE_EVENT || - triggerEvent === WorkflowTriggerEvents.AFTER_EVENT) && - scheduledDate - ) { - // schedule at least 15 minutes in advance and at most 2 hours in advance - if ( - currentDate.isBefore(scheduledDate.subtract(15, "minute")) && - !scheduledDate.isAfter(currentDate.add(2, "hour")) - ) { - try { - const scheduledNotification = await scheduleSmsOrFallbackEmail({ - twilioData: { - phoneNumber: reminderPhone, - body: smsMessage, - scheduledDate: scheduledDate.toDate(), - sender: senderID, - bookingUid: evt.uid, - userId, - teamId, - }, - fallbackData: isAttendeeAction(action) - ? { - email: evt.attendees[0].email, - t: await getTranslation(evt.attendees[0].language.locale, "common"), - replyTo: evt.organizer.email, - workflowStepId, - } - : undefined, - }); - - if (scheduledNotification?.sid) { - await prisma.workflowReminder.create({ - data: { - bookingUid: uid, - workflowStepId: workflowStepId, - method: WorkflowMethods.SMS, - scheduledDate: scheduledDate.toDate(), - scheduled: true, - referenceId: scheduledNotification.sid, - seatReferenceId: seatReferenceUid, - }, - }); - } - } catch (error) { - log.error(`Error scheduling SMS with error ${error}`); - } - } else if (scheduledDate.isAfter(currentDate.add(2, "hour"))) { - // Write to DB and send to CRON if scheduled reminder date is past 2 hours from now - await prisma.workflowReminder.create({ - data: { - bookingUid: uid, - workflowStepId: workflowStepId, - method: WorkflowMethods.SMS, - scheduledDate: scheduledDate.toDate(), - scheduled: false, - seatReferenceId: seatReferenceUid, - }, - }); - } + if (!useTwilio && scheduledDate) { + // Write to DB and send to CRON if scheduled reminder date is past 2 hours from now + await prisma.workflowReminder.create({ + data: { + bookingUid: uid, + workflowStepId: workflowStepId, + method: WorkflowMethods.SMS, + scheduledDate: scheduledDate.toDate(), + scheduled: false, + seatReferenceId: seatReferenceUid, + }, + }); } } }; diff --git a/packages/features/ee/workflows/lib/reminders/utils.ts b/packages/features/ee/workflows/lib/reminders/utils.ts index 78d1d2d9b4..2255ae5faf 100644 --- a/packages/features/ee/workflows/lib/reminders/utils.ts +++ b/packages/features/ee/workflows/lib/reminders/utils.ts @@ -1,4 +1,12 @@ +import dayjs from "@calcom/dayjs"; import { dub } from "@calcom/features/auth/lib/dub"; +import { WEBSITE_URL } from "@calcom/lib/constants"; +import { WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/enums"; +import { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; + +import type { AttendeeInBookingInfo, BookingInfo } from "./smsReminderManager"; +import type { VariablesType } from "./templates/customTemplate"; +import customTemplate from "./templates/customTemplate"; export const bulkShortenLinks = async (links: string[]) => { if (!process.env.DUB_API_KEY) { @@ -25,3 +33,96 @@ export const bulkShortenLinks = async (links: string[]) => { } }); }; + +export const getSMSMessageWithVariables = async ( + smsMessage: string, + evt: BookingInfo, + attendeeToBeUsedInSMS: AttendeeInBookingInfo, + action: WorkflowActions +) => { + const urls = { + meetingUrl: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl || "", + cancelLink: `${evt.bookerUrl ?? WEBSITE_URL}/booking/${evt.uid}?cancel=true`, + rescheduleLink: `${evt.bookerUrl ?? WEBSITE_URL}/reschedule/${evt.uid}`, + }; + + const [{ shortLink: meetingUrl }, { shortLink: cancelLink }, { shortLink: rescheduleLink }] = + await bulkShortenLinks([urls.meetingUrl, urls.cancelLink, urls.rescheduleLink]); + + const timeZone = + action === WorkflowActions.SMS_ATTENDEE ? attendeeToBeUsedInSMS.timeZone : evt.organizer.timeZone; + + const variables: VariablesType = { + eventName: evt.title, + organizerName: evt.organizer.name, + attendeeName: attendeeToBeUsedInSMS.name, + attendeeFirstName: attendeeToBeUsedInSMS.firstName, + attendeeLastName: attendeeToBeUsedInSMS.lastName, + attendeeEmail: attendeeToBeUsedInSMS.email, + eventDate: dayjs(evt.startTime).tz(timeZone), + eventEndTime: dayjs(evt.endTime).tz(timeZone), + timeZone: timeZone, + location: evt.location, + additionalNotes: evt.additionalNotes, + responses: evt.responses, + meetingUrl, + cancelLink, + rescheduleLink, + cancelReason: evt.cancellationReason, + rescheduleReason: evt.rescheduleReason, + attendeeTimezone: evt.attendees[0].timeZone, + eventTimeInAttendeeTimezone: dayjs(evt.startTime).tz(evt.attendees[0].timeZone), + eventEndTimeInAttendeeTimezone: dayjs(evt.endTime).tz(evt.attendees[0].timeZone), + }; + + const locale = + action === WorkflowActions.SMS_ATTENDEE + ? attendeeToBeUsedInSMS.language?.locale + : evt.organizer.language.locale; + + const customMessage = customTemplate(smsMessage, variables, locale, evt.organizer.timeFormat); + smsMessage = customMessage.text; + + return smsMessage; +}; + +export const getAttendeeToBeUsedInSMS = ( + action: WorkflowActions, + evt: BookingInfo, + reminderPhone: string | null +) => { + let attendeeToBeUsedInSMS: AttendeeInBookingInfo | null = null; + if (action === WorkflowActions.SMS_ATTENDEE) { + const attendeeWithReminderPhoneAsSMSReminderNumber = + reminderPhone && evt.attendees.find((attendee) => attendee.email === evt.responses?.email?.value); + attendeeToBeUsedInSMS = attendeeWithReminderPhoneAsSMSReminderNumber + ? attendeeWithReminderPhoneAsSMSReminderNumber + : evt.attendees[0]; + } else { + attendeeToBeUsedInSMS = evt.attendees[0]; + } + + return attendeeToBeUsedInSMS; +}; + +export const shouldUseTwilio = (trigger: WorkflowTriggerEvents, scheduledDate: dayjs.Dayjs | null) => { + if ( + trigger === WorkflowTriggerEvents.NEW_EVENT || + trigger === WorkflowTriggerEvents.EVENT_CANCELLED || + trigger === WorkflowTriggerEvents.RESCHEDULE_EVENT + ) { + return true; + } + + if (trigger === WorkflowTriggerEvents.BEFORE_EVENT || trigger === WorkflowTriggerEvents.AFTER_EVENT) { + const currentDate = dayjs(); + if ( + scheduledDate && + currentDate.isBefore(scheduledDate.subtract(15, "minute")) && + !scheduledDate.isAfter(currentDate.add(2, "hour")) + ) { + return true; + } + } + return false; +};