Files
calendar/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts
T
d7fc11df5e feat: use form responses as workflow variables (#24716)
* remove add variable dropdown

* feat: lang support

* fix: type errors

* feat: select voice agent

* refactor: address feedback

* refactor: address feedback

* refactor: missing import

* fix: types

* add getAllWorkflowsFromRoutingForm to WorkflowService

* fix error caused by undefined evt

* fix type error

* fix type error

* fix tests

* feat: add inbound calls

* chore: formatting

* chore

* feat: finish inbound call

* chore: formatting

* fix: update bug

* fix: types

* code clean up

* final fixes and clean up

* remove console.log

* remove template text form from triggers

* add routing form repoditory function

* refactor: Agent Configuration Sheet (#23930)

* refactor: agent configuration sheet

* chore: use default phone numbre

* refactor: improvements

* refactor: improvements

* fix: types

* fix: feedback

* fix bug with key

* chore:

* fix: feedback

* fix: prompt

* add comments

* fix: review

* fix: review

* refactor: class

* refactor: class

* fix test

* allow cal ai action on form triggers

* move any reusable code to scheduleAIPhoneCall

* add missing await

* use predefined FormSubmissionData type

* add .trim() to sms message

* pass contextData instead

* finish base setup

* add missing trigger in update-workflow.input.ts

* allow cal.ai action for form triggers in handler

* chore: add support for form workflows on api v2

* fixup! chore: add support for form workflows on api v2

* ai phone call on form submissions (WIP)

* use existing type for Option array

* pass chosen event type id

* refactor: rename

* Update apps/web/public/static/locales/en/common.json

* Update apps/web/public/static/locales/en/common.json

* add missing imports

* chore: update set value

* fix: remove index

* fix: type error

* fix: update tetss

* use only repository functions in update handler

* move all prisma queries from list.handler

* review suggestions

* fix: use logger

* chore: handle workflows api v2

* chore: handle workflows api v2, split in 2 endpoints

* fix workflow step creation

* remove connect agent and fixes types

* add type to workflow

* chore: use workflow type in apiv2 WorkflowsOutputService

* update worklfow type on update

* chore: use workflow type in apiv2 WorkflowsOutputService

* fix template body for torm trigger

* some UI fixes for email subject/body

* resetting email body when changing form triggers

* use type field to query workflows

* clean up all old active on values

* remove responseId from all funciton calls

* remove undefined from updateTemplate

* refactor: don't use static

* fix: type

* refactor: split routing form and event-type workflows code

* refactor: split routing form and event-type workflows code

* fix template  text when adding action

* chore: don't rename WorkflowActivationDto to avoid ci blocking

* refine update schedule to use only allowed actions

* fix type error

* don't allow whatsapp action with form trigger

* fix type error

* return early if activeOn array is empty

* fix: from step type in BaseFormWorkflowStepDto

* fixup! fix: from step type in BaseFormWorkflowStepDto

* api v2 updates

* move all prisma calls to repository (service/workflows.ts)

* use FORM_TRIGGER_WORKFLOW_EVENTS for form queries

* use userRepository

* use FORM_TRIGGER_WORKFLOW_EVENTS in isFormTrigger

* code clean up

* code clean up

* use repository functions in formSubmissionValidation.ts

* fix: schema

* refactor:

* remove action check in update handler

* add event type selection

* event type selector improvements

* adjust update.handler

* set outboundEventTypeId

* add back trpc  import

* fix agent repository functions

* clean up

* fix bugs caused by merge

* pass eventTypeId to updateToolsFromAgentId

* add migration for outboundEventTypeId

* add SMS actions to allowed form action constants

* add cal ai to allowed form actions

* pick correct event type for web call

* pass correct routed event type id

* remove unsued import

* fixes for offset api v2

* add missing responseId

* fix failing test

* fix failing test

* improve error message

* remove unused imports

* chore: handle sms step action for form worklfow in dtos

* fix typo

* missing missing newStep

* minor fixes

* remove changes

* add routedEventTypeId

* fix type error

* fix type error

* fix typ error in executAPIPhoneCall.tsx

* add back inboundEventTypeId

* remove console.log

* remove outdated code

* small fixes

* don't throw error for missing phone number

* add  back filtered triggerOptions

* fix eventTypeId in testCall handler

* fix type error

* update migration

* fix trigger is not defined

* convert eventTypeId to string

* only use outboundEventTypeId for FORM_SUBMITTED trigger

* show toast when no event type selected

* fix type errors

* add missing translation

* fix type error

* remove callType

* fix tests

* small fixes

* clean up AgentConfigurationSheet

* remove EventTypeSelector file

* code clean up

* clean up

* clean up

* use resusable function for TestPhoneCallDialog and WebCallDialog

* rename result

* fix types for event type id

* use repository runction in workflowReminder.ts

* fix type error

* pass eventTypeIds correctly

* fix typo

* custom variables from form responses

* remove comment

* Update apps/web/public/static/locales/en/common.json

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>

* use watch instead of getValues

* change to  z.record(z.unknown()) instead of any()

* fix type of eventTypeId

* check permissinon for outBoundEventTypeId

* add isNaN check

* improve function name

* add tests

* fixes for custom variables

* improve test

* update tools when outbound agent event type id changes

* handle undefined eventDate in customTemplate

* add info how to use form responses as variables

* rename responses to routingFormResponses

* remove cal ai from allowed steps api v2

* remove old migration file

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-11-13 15:34:44 +00:00

459 lines
14 KiB
TypeScript

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 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 { getWorkflowRecipientEmail } from "../getWorkflowReminders";
import { sendOrScheduleWorkflowEmails } from "./providers/emailProvider";
import type { FormSubmissionData, WorkflowContextData } from "./reminderScheduler";
import type { AttendeeInBookingInfo, BookingInfo, timeUnitLowerCase } from "./smsReminderManager";
import type { VariablesType } from "./templates/customTemplate";
import customTemplate, {
transformBookingResponsesToVariableFormat,
transformRoutingFormResponsesToVariableFormat,
} from "./templates/customTemplate";
import emailRatingTemplate from "./templates/emailRatingTemplate";
import emailReminderTemplate from "./templates/emailReminderTemplate";
const log = logger.getSubLogger({ prefix: ["[emailReminderManager]"] });
type ScheduleEmailReminderAction = Extract<
WorkflowActions,
"EMAIL_HOST" | "EMAIL_ATTENDEE" | "EMAIL_ADDRESS"
>;
export type ScheduleReminderArgs = {
triggerEvent: WorkflowTriggerEvents;
timeSpan: {
time: number | null;
timeUnit: TimeUnit | null;
};
template?: WorkflowTemplates;
sender?: string | null;
workflowStepId?: number;
seatReferenceUid?: string;
} & WorkflowContextData;
type scheduleEmailReminderArgs = ScheduleReminderArgs & {
sendTo: string[];
action: ScheduleEmailReminderAction;
emailSubject?: string;
emailBody?: string;
hideBranding?: boolean;
includeCalendarEvent?: boolean;
verifiedAt: Date | null;
};
type SendEmailReminderParams = {
mailData: {
subject: string;
html: string;
replyTo?: string;
attachments?: {
content: string;
filename: string;
contentType: string;
disposition: string;
}[];
sender?: string | null;
};
sendTo: string[];
triggerEvent: WorkflowTriggerEvents;
scheduledDate?: dayjs.Dayjs | null;
uid?: string;
workflowStepId?: number;
seatReferenceUid?: string;
};
const sendOrScheduleWorkflowEmailWithReminder = async (params: SendEmailReminderParams) => {
const { mailData, sendTo, scheduledDate, uid, workflowStepId } = params;
let reminderUid = undefined;
if (scheduledDate) {
const reminder = await prisma.workflowReminder.create({
data: {
bookingUid: uid,
workflowStepId,
method: WorkflowMethods.EMAIL,
scheduledDate: scheduledDate.toDate(),
scheduled: true,
},
});
reminderUid = reminder.uuid;
}
await sendOrScheduleWorkflowEmails({
...mailData,
to: sendTo,
sendAt: scheduledDate?.toDate(),
referenceUid: reminderUid ?? undefined,
});
};
export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) => {
const { verifiedAt, workflowStepId } = args;
if (!verifiedAt) {
log.warn(`Workflow step ${workflowStepId} not yet verified`);
return;
}
if (args.evt) {
await scheduleEmailReminderForEvt(args);
} else {
await scheduleEmailReminderForForm(args);
}
};
const scheduleEmailReminderForEvt = async (args: scheduleEmailReminderArgs & { evt: BookingInfo }) => {
const {
evt,
triggerEvent,
timeSpan,
template,
sender,
workflowStepId,
seatReferenceUid,
sendTo,
emailSubject = "",
emailBody = "",
hideBranding,
includeCalendarEvent,
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 = {
emailSubject,
emailBody: `<body style="white-space: pre-wrap;">${emailBody}</body>`,
};
const bookerUrl = evt.bookerUrl ?? WEBSITE_URL;
if (emailBody) {
const isEmailAttendeeAction = action === WorkflowActions.EMAIL_ATTENDEE;
const recipientEmail = getWorkflowRecipientEmail({
action,
attendeeEmail: attendeeToBeUsedInMail.email,
organizerEmail: evt.organizer.email,
sendToEmail: sendTo[0],
});
const variables: VariablesType = {
eventName: evt.title || "",
organizerName: evt.organizer.name,
attendeeName: attendeeToBeUsedInMail.name,
attendeeFirstName: attendeeToBeUsedInMail.firstName,
attendeeLastName: attendeeToBeUsedInMail.lastName,
attendeeEmail: attendeeToBeUsedInMail.email,
eventDate: dayjs(startTime).tz(timeZone),
eventEndTime: dayjs(endTime).tz(timeZone),
timeZone: timeZone,
location: evt.location,
additionalNotes: evt.additionalNotes,
responses: transformBookingResponsesToVariableFormat(evt.responses),
meetingUrl: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl,
cancelLink: `${bookerUrl}/booking/${evt.uid}?cancel=true${
recipientEmail ? `&cancelledBy=${encodeURIComponent(recipientEmail)}` : ""
}${isEmailAttendeeAction && seatReferenceUid ? `&seatReferenceUid=${seatReferenceUid}` : ""}`,
cancelReason: evt.cancellationReason,
rescheduleLink: `${bookerUrl}/reschedule/${evt.uid}${
recipientEmail
? `?rescheduledBy=${encodeURIComponent(recipientEmail)}${
isEmailAttendeeAction && seatReferenceUid
? `&seatReferenceUid=${encodeURIComponent(seatReferenceUid)}`
: ""
}`
: isEmailAttendeeAction && seatReferenceUid
? `?seatReferenceUid=${encodeURIComponent(seatReferenceUid)}`
: ""
}`,
rescheduleReason: evt.rescheduleReason,
ratingUrl: `${bookerUrl}/booking/${evt.uid}?rating`,
noShowUrl: `${bookerUrl}/booking/${evt.uid}?noShow=true`,
attendeeTimezone: evt.attendees[0].timeZone,
eventTimeInAttendeeTimezone: dayjs(startTime).tz(evt.attendees[0].timeZone),
eventEndTimeInAttendeeTimezone: dayjs(endTime).tz(evt.attendees[0].timeZone),
};
const locale = isEmailAttendeeAction
? attendeeToBeUsedInMail.language?.locale
: evt.organizer.language.locale;
const emailSubjectTemplate = customTemplate(emailSubject, variables, locale, evt.organizer.timeFormat);
emailContent.emailSubject = emailSubjectTemplate.text;
emailContent.emailBody = customTemplate(
emailBody,
variables,
locale,
evt.organizer.timeFormat,
hideBranding
).html;
} else if (template === WorkflowTemplates.REMINDER) {
emailContent = emailReminderTemplate({
isEditingMode: false,
locale: evt.organizer.language.locale,
t: await getTranslation(evt.organizer.language.locale || "en", "common"),
action,
timeFormat: evt.organizer.timeFormat,
startTime,
endTime,
eventName: evt.title,
timeZone,
location: evt.location || "",
meetingUrl: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl || "",
otherPerson: attendeeName,
name,
});
} else if (template === WorkflowTemplates.RATING) {
emailContent = emailRatingTemplate({
isEditingMode: true,
locale: evt.organizer.language.locale,
action,
t: await getTranslation(evt.organizer.language.locale || "en", "common"),
timeFormat: evt.organizer.timeFormat,
startTime,
endTime,
eventName: evt.title,
timeZone,
organizer: evt.organizer.name,
name,
ratingUrl: `${bookerUrl}/booking/${evt.uid}?rating`,
noShowUrl: `${bookerUrl}/booking/${evt.uid}?noShow=true`,
});
}
// Allows debugging generated email content without waiting for sendgrid to send emails
log.debug(`Sending Email for trigger ${triggerEvent}`, JSON.stringify(emailContent));
async function prepareEmailData() {
const status: EventStatus =
triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED ? "CANCELLED" : "CONFIRMED";
const organizerT = await getTranslation(evt.organizer.language.locale || "en", "common");
const attendeeT = await getTranslation(evt.attendees[0].language.locale || "en", "common");
const attendee = {
...evt.attendees[0],
name: preprocessNameFieldDataWithVariant("fullName", evt.attendees[0].name) as string,
language: { ...evt.attendees[0].language, translate: attendeeT },
};
const emailEvent = {
...evt,
type: evt.eventType?.slug || "",
organizer: { ...evt.organizer, language: { ...evt.organizer.language, translate: organizerT } },
attendees: [attendee],
location: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl || evt.location,
};
const attachments = includeCalendarEvent
? [
{
content:
generateIcsString({
event: emailEvent,
status,
}) || "",
filename: "event.ics",
contentType: "text/calendar; charset=UTF-8; method=REQUEST",
disposition: "attachment",
},
]
: undefined;
return {
subject: emailContent.emailSubject,
html: emailContent.emailBody,
...(!evt.hideOrganizerEmail && { replyTo: evt?.eventType?.customReplyToEmail || evt.organizer.email }),
attachments,
sender,
};
}
const mailData = await prepareEmailData();
await sendOrScheduleWorkflowEmailWithReminder({
mailData,
sendTo,
triggerEvent,
scheduledDate,
uid,
workflowStepId,
seatReferenceUid,
});
};
// sends all immediately, no scheduling needed
const scheduleEmailReminderForForm = async (
args: scheduleEmailReminderArgs & {
formData: FormSubmissionData;
}
) => {
const {
formData,
triggerEvent,
sender,
workflowStepId,
sendTo,
emailSubject = "",
emailBody = "",
hideBranding,
} = args;
const emailContent = {
emailSubject,
emailBody: `<body style="white-space: pre-wrap;">${emailBody}</body>`,
};
if (emailBody) {
const timeFormat = getTimeFormatStringFromUserTimeFormat(formData.user.timeFormat);
const variables: VariablesType = {
responses: transformRoutingFormResponsesToVariableFormat(formData.responses),
};
const emailSubjectTemplate = customTemplate(emailSubject, variables, formData.user.locale, timeFormat);
emailContent.emailSubject = emailSubjectTemplate.text;
emailContent.emailBody = customTemplate(
emailBody,
variables,
formData.user.locale,
timeFormat,
hideBranding
).html;
}
// Allows debugging generated email content without waiting for sendgrid to send emails
log.debug(`Sending Email for trigger ${triggerEvent}`, JSON.stringify(emailContent));
const mailData = {
subject: emailContent.emailSubject,
html: emailContent.emailBody,
sender,
};
await sendOrScheduleWorkflowEmailWithReminder({
mailData,
sendTo,
triggerEvent,
workflowStepId,
scheduledDate: null,
});
};
export const deleteScheduledEmailReminder = async (reminderId: number) => {
const workflowReminder = await prisma.workflowReminder.findUnique({
where: {
id: reminderId,
},
});
if (!workflowReminder) {
console.error("Workflow reminder not found");
return;
}
const { uuid, referenceId } = workflowReminder;
if (uuid) {
try {
const taskId = await tasker.cancelWithReference(uuid, "sendWorkflowEmails");
if (taskId) {
await prisma.workflowReminder.delete({
where: {
id: reminderId,
},
});
return;
}
} catch (error) {
log.error(`Error canceling/deleting reminder with tasker. Error: ${error}`);
}
}
/**
* @deprecated only needed for SendGrid, use SMTP with tasker instead
*/
try {
if (!referenceId) {
await prisma.workflowReminder.delete({
where: {
id: reminderId,
},
});
return;
}
await prisma.workflowReminder.update({
where: {
id: reminderId,
},
data: {
cancelled: true,
},
});
} catch (error) {
log.error(`Error canceling reminder with error ${error}`);
}
};