2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -0,0 +1,371 @@
/* Schedule any workflow reminder that falls within 72 hours for email */
import type { NextApiRequest, NextApiResponse } from "next";
import { v4 as uuidv4 } from "uuid";
import dayjs from "@calcom/dayjs";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import logger from "@calcom/lib/logger";
import { defaultHandler } from "@calcom/lib/server";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
import { WorkflowActions, WorkflowMethods, WorkflowTemplates } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import type { PartialWorkflowReminder } from "../lib/getWorkflowReminders";
import {
getAllRemindersToCancel,
getAllRemindersToDelete,
getAllUnscheduledReminders,
} from "../lib/getWorkflowReminders";
import { getiCalEventAsString } from "../lib/getiCalEventAsString";
import {
cancelScheduledEmail,
deleteScheduledSend,
getBatchId,
sendSendgridMail,
} from "../lib/reminders/providers/sendgridProvider";
import type { VariablesType } from "../lib/reminders/templates/customTemplate";
import customTemplate from "../lib/reminders/templates/customTemplate";
import emailRatingTemplate from "../lib/reminders/templates/emailRatingTemplate";
import emailReminderTemplate from "../lib/reminders/templates/emailReminderTemplate";
async function handler(req: NextApiRequest, res: NextApiResponse) {
const apiKey = req.headers.authorization || req.query.apiKey;
if (process.env.CRON_API_KEY !== apiKey) {
res.status(401).json({ message: "Not authenticated" });
return;
}
if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) {
res.status(405).json({ message: "No SendGrid API key or email" });
return;
}
// delete batch_ids with already past scheduled date from scheduled_sends
const remindersToDelete: { referenceId: string | null }[] = await getAllRemindersToDelete();
const deletePromises: Promise<any>[] = [];
for (const reminder of remindersToDelete) {
const deletePromise = deleteScheduledSend(reminder.referenceId);
deletePromises.push(deletePromise);
}
Promise.allSettled(deletePromises).then((results) => {
results.forEach((result) => {
if (result.status === "rejected") {
logger.error(`Error deleting batch id from scheduled_sends: ${result.reason}`);
}
});
});
//delete workflow reminders with past scheduled date
await prisma.workflowReminder.deleteMany({
where: {
method: WorkflowMethods.EMAIL,
scheduledDate: {
lte: dayjs().toISOString(),
},
},
});
//cancel reminders for cancelled/rescheduled bookings that are scheduled within the next hour
const remindersToCancel: { referenceId: string | null; id: number }[] = await getAllRemindersToCancel();
const cancelUpdatePromises: Promise<any>[] = [];
for (const reminder of remindersToCancel) {
const cancelPromise = cancelScheduledEmail(reminder.referenceId);
const updatePromise = prisma.workflowReminder.update({
where: {
id: reminder.id,
},
data: {
scheduled: false, // to know which reminder already got cancelled (to avoid error from cancelling the same reminders again)
},
});
cancelUpdatePromises.push(cancelPromise, updatePromise);
}
Promise.allSettled(cancelUpdatePromises).then((results) => {
results.forEach((result) => {
if (result.status === "rejected") {
logger.error(`Error cancelling scheduled_sends: ${result.reason}`);
}
});
});
// schedule all unscheduled reminders within the next 72 hours
const sendEmailPromises: Promise<any>[] = [];
const unscheduledReminders: PartialWorkflowReminder[] = await getAllUnscheduledReminders();
if (!unscheduledReminders.length) {
res.status(200).json({ message: "No Emails to schedule" });
return;
}
for (const reminder of unscheduledReminders) {
if (!reminder.booking) {
continue;
}
if (!reminder.isMandatoryReminder && reminder.workflowStep) {
try {
let sendTo;
switch (reminder.workflowStep.action) {
case WorkflowActions.EMAIL_HOST:
sendTo = reminder.booking?.userPrimaryEmail ?? reminder.booking.user?.email;
break;
case WorkflowActions.EMAIL_ATTENDEE:
sendTo = reminder.booking.attendees[0].email;
break;
case WorkflowActions.EMAIL_ADDRESS:
sendTo = reminder.workflowStep.sendTo;
}
const name =
reminder.workflowStep.action === WorkflowActions.EMAIL_ATTENDEE
? reminder.booking.attendees[0].name
: reminder.booking.user?.name;
const attendeeName =
reminder.workflowStep.action === WorkflowActions.EMAIL_ATTENDEE
? reminder.booking.user?.name
: reminder.booking.attendees[0].name;
const timeZone =
reminder.workflowStep.action === WorkflowActions.EMAIL_ATTENDEE
? reminder.booking.attendees[0].timeZone
: reminder.booking.user?.timeZone;
const locale =
reminder.workflowStep.action === WorkflowActions.EMAIL_ATTENDEE ||
reminder.workflowStep.action === WorkflowActions.SMS_ATTENDEE
? reminder.booking.attendees[0].locale
: reminder.booking.user?.locale;
let emailContent = {
emailSubject: reminder.workflowStep.emailSubject || "",
emailBody: `<body style="white-space: pre-wrap;">${
reminder.workflowStep.reminderBody || ""
}</body>`,
};
let emailBodyEmpty = false;
if (reminder.workflowStep.reminderBody) {
const { responses } = getCalEventResponses({
bookingFields: reminder.booking.eventType?.bookingFields ?? null,
booking: reminder.booking,
});
const organizerOrganizationProfile = await prisma.profile.findFirst({
where: {
userId: reminder.booking.user?.id,
},
});
const organizerOrganizationId = organizerOrganizationProfile?.organizationId;
const bookerUrl = await getBookerBaseUrl(
reminder.booking.eventType?.team?.parentId ?? organizerOrganizationId ?? null
);
const variables: VariablesType = {
eventName: reminder.booking.eventType?.title || "",
organizerName: reminder.booking.user?.name || "",
attendeeName: reminder.booking.attendees[0].name,
attendeeEmail: reminder.booking.attendees[0].email,
eventDate: dayjs(reminder.booking.startTime).tz(timeZone),
eventEndTime: dayjs(reminder.booking?.endTime).tz(timeZone),
timeZone: timeZone,
location: reminder.booking.location || "",
additionalNotes: reminder.booking.description,
responses: responses,
meetingUrl: bookingMetadataSchema.parse(reminder.booking.metadata || {})?.videoCallUrl,
cancelLink: `${bookerUrl}/booking/${reminder.booking.uid}?cancel=true`,
rescheduleLink: `${bookerUrl}/reschedule/${reminder.booking.uid}`,
ratingUrl: `${bookerUrl}/booking/${reminder.booking.uid}?rating`,
noShowUrl: `${bookerUrl}/booking/${reminder.booking.uid}?noShow=true`,
};
const emailLocale = locale || "en";
const emailSubject = customTemplate(
reminder.workflowStep.emailSubject || "",
variables,
emailLocale,
getTimeFormatStringFromUserTimeFormat(reminder.booking.user?.timeFormat),
!!reminder.booking.user?.hideBranding
).text;
emailContent.emailSubject = emailSubject;
emailContent.emailBody = customTemplate(
reminder.workflowStep.reminderBody || "",
variables,
emailLocale,
getTimeFormatStringFromUserTimeFormat(reminder.booking.user?.timeFormat),
!!reminder.booking.user?.hideBranding
).html;
emailBodyEmpty =
customTemplate(
reminder.workflowStep.reminderBody || "",
variables,
emailLocale,
getTimeFormatStringFromUserTimeFormat(reminder.booking.user?.timeFormat)
).text.length === 0;
} else if (reminder.workflowStep.template === WorkflowTemplates.REMINDER) {
emailContent = emailReminderTemplate(
false,
reminder.workflowStep.action,
getTimeFormatStringFromUserTimeFormat(reminder.booking.user?.timeFormat),
reminder.booking.startTime.toISOString() || "",
reminder.booking.endTime.toISOString() || "",
reminder.booking.eventType?.title || "",
timeZone || "",
attendeeName || "",
name || "",
!!reminder.booking.user?.hideBranding
);
} else if (reminder.workflowStep.template === WorkflowTemplates.RATING) {
const organizerOrganizationProfile = await prisma.profile.findFirst({
where: {
userId: reminder.booking.user?.id,
},
});
const organizerOrganizationId = organizerOrganizationProfile?.organizationId;
const bookerUrl = await getBookerBaseUrl(
reminder.booking.eventType?.team?.parentId ?? organizerOrganizationId ?? null
);
emailContent = emailRatingTemplate({
isEditingMode: true,
action: reminder.workflowStep.action || WorkflowActions.EMAIL_ADDRESS,
timeFormat: getTimeFormatStringFromUserTimeFormat(reminder.booking.user?.timeFormat),
startTime: reminder.booking.startTime.toISOString() || "",
endTime: reminder.booking.endTime.toISOString() || "",
eventName: reminder.booking.eventType?.title || "",
timeZone: timeZone || "",
organizer: reminder.booking.user?.name || "",
name: name || "",
ratingUrl: `${bookerUrl}/booking/${reminder.booking.uid}?rating` || "",
noShowUrl: `${bookerUrl}/booking/${reminder.booking.uid}?noShow=true` || "",
});
}
if (emailContent.emailSubject.length > 0 && !emailBodyEmpty && sendTo) {
const batchId = await getBatchId();
sendEmailPromises.push(
sendSendgridMail(
{
to: sendTo,
subject: emailContent.emailSubject,
html: emailContent.emailBody,
batchId: batchId,
sendAt: dayjs(reminder.scheduledDate).unix(),
replyTo: reminder.booking?.userPrimaryEmail ?? reminder.booking.user?.email,
attachments: reminder.workflowStep.includeCalendarEvent
? [
{
content: Buffer.from(getiCalEventAsString(reminder.booking) || "").toString("base64"),
filename: "event.ics",
type: "text/calendar; method=REQUEST",
disposition: "attachment",
contentId: uuidv4(),
},
]
: undefined,
},
{ sender: reminder.workflowStep.sender }
)
);
await prisma.workflowReminder.update({
where: {
id: reminder.id,
},
data: {
scheduled: true,
referenceId: batchId,
},
});
}
} catch (error) {
logger.error(`Error scheduling Email with error ${error}`);
}
} else if (reminder.isMandatoryReminder) {
try {
const sendTo = reminder.booking.attendees[0].email;
const name = reminder.booking.attendees[0].name;
const attendeeName = reminder.booking.user?.name;
const timeZone = reminder.booking.attendees[0].timeZone;
let emailContent = {
emailSubject: "",
emailBody: "",
};
const emailBodyEmpty = false;
emailContent = emailReminderTemplate(
false,
WorkflowActions.EMAIL_ATTENDEE,
getTimeFormatStringFromUserTimeFormat(reminder.booking.user?.timeFormat),
reminder.booking.startTime.toISOString() || "",
reminder.booking.endTime.toISOString() || "",
reminder.booking.eventType?.title || "",
timeZone || "",
attendeeName || "",
name || "",
!!reminder.booking.user?.hideBranding
);
if (emailContent.emailSubject.length > 0 && !emailBodyEmpty && sendTo) {
const batchId = await getBatchId();
sendEmailPromises.push(
sendSendgridMail(
{
to: sendTo,
subject: emailContent.emailSubject,
html: emailContent.emailBody,
batchId: batchId,
sendAt: dayjs(reminder.scheduledDate).unix(),
replyTo: reminder.booking?.userPrimaryEmail ?? reminder.booking.user?.email,
},
{ sender: reminder.workflowStep?.sender }
)
);
await prisma.workflowReminder.update({
where: {
id: reminder.id,
},
data: {
scheduled: true,
referenceId: batchId,
},
});
}
} catch (error) {
logger.error(`Error scheduling Email with error ${error}`);
}
}
}
Promise.allSettled(sendEmailPromises).then((results) => {
results.forEach((result) => {
if (result.status === "rejected") {
logger.error("Email sending failed", result.reason);
}
});
});
res.status(200).json({ message: `${unscheduledReminders.length} Emails to schedule` });
}
export default defaultHandler({
POST: Promise.resolve({ default: handler }),
});

View File

@@ -0,0 +1,206 @@
/* Schedule any workflow reminder that falls within 7 days for SMS */
import type { NextApiRequest, NextApiResponse } from "next";
import dayjs from "@calcom/dayjs";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import { defaultHandler } from "@calcom/lib/server";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
import { WorkflowActions, WorkflowMethods, WorkflowTemplates } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import { getSenderId } from "../lib/alphanumericSenderIdSupport";
import type { PartialWorkflowReminder } from "../lib/getWorkflowReminders";
import { select } from "../lib/getWorkflowReminders";
import * as twilio from "../lib/reminders/providers/twilioProvider";
import type { VariablesType } from "../lib/reminders/templates/customTemplate";
import customTemplate from "../lib/reminders/templates/customTemplate";
import smsReminderTemplate from "../lib/reminders/templates/smsReminderTemplate";
async function handler(req: NextApiRequest, res: NextApiResponse) {
const apiKey = req.headers.authorization || req.query.apiKey;
if (process.env.CRON_API_KEY !== apiKey) {
res.status(401).json({ message: "Not authenticated" });
return;
}
//delete all scheduled sms reminders where scheduled date is past current date
await prisma.workflowReminder.deleteMany({
where: {
OR: [
{
method: WorkflowMethods.SMS,
scheduledDate: {
lte: dayjs().toISOString(),
},
},
{
retryCount: {
gt: 1,
},
},
],
},
});
//find all unscheduled SMS reminders
const unscheduledReminders = (await prisma.workflowReminder.findMany({
where: {
method: WorkflowMethods.SMS,
scheduled: false,
scheduledDate: {
lte: dayjs().add(7, "day").toISOString(),
},
},
select: {
...select,
retryCount: true,
},
})) as (PartialWorkflowReminder & { retryCount: number })[];
if (!unscheduledReminders.length) {
res.json({ ok: true });
return;
}
for (const reminder of unscheduledReminders) {
if (!reminder.workflowStep || !reminder.booking) {
continue;
}
const userId = reminder.workflowStep.workflow.userId;
const teamId = reminder.workflowStep.workflow.teamId;
try {
const sendTo =
reminder.workflowStep.action === WorkflowActions.SMS_NUMBER
? reminder.workflowStep.sendTo
: reminder.booking?.smsReminderNumber;
const userName =
reminder.workflowStep.action === WorkflowActions.SMS_ATTENDEE
? reminder.booking?.attendees[0].name
: "";
const attendeeName =
reminder.workflowStep.action === WorkflowActions.SMS_ATTENDEE
? reminder.booking?.user?.name
: reminder.booking?.attendees[0].name;
const timeZone =
reminder.workflowStep.action === WorkflowActions.SMS_ATTENDEE
? reminder.booking?.attendees[0].timeZone
: reminder.booking?.user?.timeZone;
const senderID = getSenderId(sendTo, reminder.workflowStep.sender);
const locale =
reminder.workflowStep.action === WorkflowActions.EMAIL_ATTENDEE ||
reminder.workflowStep.action === WorkflowActions.SMS_ATTENDEE
? reminder.booking?.attendees[0].locale
: reminder.booking?.user?.locale;
let message: string | null = reminder.workflowStep.reminderBody || null;
if (reminder.workflowStep.reminderBody) {
const { responses } = getCalEventResponses({
bookingFields: reminder.booking.eventType?.bookingFields ?? null,
booking: reminder.booking,
});
const organizerOrganizationProfile = await prisma.profile.findFirst({
where: {
userId: reminder.booking.user?.id,
},
});
const organizerOrganizationId = organizerOrganizationProfile?.organizationId;
const bookerUrl = await getBookerBaseUrl(
reminder.booking.eventType?.team?.parentId ?? organizerOrganizationId ?? null
);
const variables: VariablesType = {
eventName: reminder.booking?.eventType?.title,
organizerName: reminder.booking?.user?.name || "",
attendeeName: reminder.booking?.attendees[0].name,
attendeeEmail: reminder.booking?.attendees[0].email,
eventDate: dayjs(reminder.booking?.startTime).tz(timeZone),
eventEndTime: dayjs(reminder.booking?.endTime).tz(timeZone),
timeZone: timeZone,
location: reminder.booking?.location || "",
additionalNotes: reminder.booking?.description,
responses: responses,
meetingUrl: bookingMetadataSchema.parse(reminder.booking?.metadata || {})?.videoCallUrl,
cancelLink: `${bookerUrl}/booking/${reminder.booking.uid}?cancel=true`,
rescheduleLink: `${bookerUrl}/reschedule/${reminder.booking.uid}`,
};
const customMessage = customTemplate(
reminder.workflowStep.reminderBody || "",
variables,
locale || "en",
getTimeFormatStringFromUserTimeFormat(reminder.booking.user?.timeFormat)
);
message = customMessage.text;
} else if (reminder.workflowStep.template === WorkflowTemplates.REMINDER) {
message = smsReminderTemplate(
false,
reminder.workflowStep.action,
getTimeFormatStringFromUserTimeFormat(reminder.booking.user?.timeFormat),
reminder.booking?.startTime.toISOString() || "",
reminder.booking?.eventType?.title || "",
timeZone || "",
attendeeName || "",
userName
);
}
if (message?.length && message?.length > 0 && sendTo) {
const scheduledSMS = await twilio.scheduleSMS(
sendTo,
message,
reminder.scheduledDate,
senderID,
userId,
teamId
);
if (scheduledSMS) {
await prisma.workflowReminder.update({
where: {
id: reminder.id,
},
data: {
scheduled: true,
referenceId: scheduledSMS.sid,
},
});
} else {
await prisma.workflowReminder.update({
where: {
id: reminder.id,
},
data: {
retryCount: reminder.retryCount + 1,
},
});
}
}
} catch (error) {
await prisma.workflowReminder.update({
where: {
id: reminder.id,
},
data: {
retryCount: reminder.retryCount + 1,
},
});
console.log(`Error scheduling SMS with error ${error}`);
}
}
res.status(200).json({ message: "SMS scheduled" });
}
export default defaultHandler({
POST: Promise.resolve({ default: handler }),
});

View File

@@ -0,0 +1,122 @@
/* Schedule any workflow reminder that falls within 7 days for WHATSAPP */
import type { NextApiRequest, NextApiResponse } from "next";
import dayjs from "@calcom/dayjs";
import { defaultHandler } from "@calcom/lib/server";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
import { WorkflowActions, WorkflowMethods } from "@calcom/prisma/enums";
import { getWhatsappTemplateFunction } from "../lib/actionHelperFunctions";
import type { PartialWorkflowReminder } from "../lib/getWorkflowReminders";
import { select } from "../lib/getWorkflowReminders";
import * as twilio from "../lib/reminders/providers/twilioProvider";
async function handler(req: NextApiRequest, res: NextApiResponse) {
const apiKey = req.headers.authorization || req.query.apiKey;
if (process.env.CRON_API_KEY !== apiKey) {
res.status(401).json({ message: "Not authenticated" });
return;
}
//delete all scheduled whatsapp reminders where scheduled date is past current date
await prisma.workflowReminder.deleteMany({
where: {
method: WorkflowMethods.WHATSAPP,
scheduledDate: {
lte: dayjs().toISOString(),
},
},
});
//find all unscheduled WHATSAPP reminders
const unscheduledReminders = (await prisma.workflowReminder.findMany({
where: {
method: WorkflowMethods.WHATSAPP,
scheduled: false,
scheduledDate: {
lte: dayjs().add(7, "day").toISOString(),
},
},
select,
})) as PartialWorkflowReminder[];
if (!unscheduledReminders.length) {
res.json({ ok: true });
return;
}
for (const reminder of unscheduledReminders) {
if (!reminder.workflowStep || !reminder.booking) {
continue;
}
const userId = reminder.workflowStep.workflow.userId;
const teamId = reminder.workflowStep.workflow.teamId;
try {
const sendTo =
reminder.workflowStep.action === WorkflowActions.WHATSAPP_NUMBER
? reminder.workflowStep.sendTo
: reminder.booking?.smsReminderNumber;
const userName =
reminder.workflowStep.action === WorkflowActions.WHATSAPP_ATTENDEE
? reminder.booking?.attendees[0].name
: "";
const attendeeName =
reminder.workflowStep.action === WorkflowActions.WHATSAPP_ATTENDEE
? reminder.booking?.user?.name
: reminder.booking?.attendees[0].name;
const timeZone =
reminder.workflowStep.action === WorkflowActions.WHATSAPP_ATTENDEE
? reminder.booking?.attendees[0].timeZone
: reminder.booking?.user?.timeZone;
const templateFunction = getWhatsappTemplateFunction(reminder.workflowStep.template);
const message = templateFunction(
false,
reminder.workflowStep.action,
getTimeFormatStringFromUserTimeFormat(reminder.booking.user?.timeFormat),
reminder.booking?.startTime.toISOString() || "",
reminder.booking?.eventType?.title || "",
timeZone || "",
attendeeName || "",
userName
);
if (message?.length && message?.length > 0 && sendTo) {
const scheduledSMS = await twilio.scheduleSMS(
sendTo,
message,
reminder.scheduledDate,
"",
userId,
teamId,
true
);
if (scheduledSMS) {
await prisma.workflowReminder.update({
where: {
id: reminder.id,
},
data: {
scheduled: true,
referenceId: scheduledSMS.sid,
},
});
}
}
} catch (error) {
console.log(`Error scheduling WHATSAPP with error ${error}`);
}
}
res.status(200).json({ message: "WHATSAPP scheduled" });
}
export default defaultHandler({
POST: Promise.resolve({ default: handler }),
});

View File

@@ -0,0 +1,275 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { isValidPhoneNumber } from "libphonenumber-js";
import type { Dispatch, SetStateAction } from "react";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { WorkflowActions } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import {
Button,
CheckboxField,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
EmailField,
Form,
Icon,
Input,
Label,
PhoneInput,
Select,
Tooltip,
} from "@calcom/ui";
import { WORKFLOW_ACTIONS } from "../lib/constants";
import { onlyLettersNumbersSpaces } from "../pages/workflow";
interface IAddActionDialog {
isOpenDialog: boolean;
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
addAction: (
action: WorkflowActions,
sendTo?: string,
numberRequired?: boolean,
senderId?: string,
senderName?: string
) => void;
}
interface ISelectActionOption {
label: string;
value: WorkflowActions;
}
type AddActionFormValues = {
action: WorkflowActions;
sendTo?: string;
numberRequired?: boolean;
senderId?: string;
senderName?: string;
};
export const AddActionDialog = (props: IAddActionDialog) => {
const { t } = useLocale();
const { isOpenDialog, setIsOpenDialog, addAction } = props;
const [isPhoneNumberNeeded, setIsPhoneNumberNeeded] = useState(false);
const [isSenderIdNeeded, setIsSenderIdNeeded] = useState(false);
const [isEmailAddressNeeded, setIsEmailAddressNeeded] = useState(false);
const { data: actionOptions } = trpc.viewer.workflows.getWorkflowActionOptions.useQuery();
const formSchema = z.object({
action: z.enum(WORKFLOW_ACTIONS),
sendTo: z
.string()
.refine((val) => isValidPhoneNumber(val) || val.includes("@"))
.optional(),
numberRequired: z.boolean().optional(),
senderId: z
.string()
.refine((val) => onlyLettersNumbersSpaces(val))
.nullable(),
senderName: z.string().nullable(),
});
const form = useForm<AddActionFormValues>({
mode: "onSubmit",
defaultValues: {
action: WorkflowActions.EMAIL_HOST,
senderId: SENDER_ID,
senderName: SENDER_NAME,
},
resolver: zodResolver(formSchema),
});
const handleSelectAction = (newValue: ISelectActionOption | null) => {
if (newValue) {
form.setValue("action", newValue.value);
if (newValue.value === WorkflowActions.SMS_NUMBER) {
setIsPhoneNumberNeeded(true);
setIsSenderIdNeeded(true);
setIsEmailAddressNeeded(false);
form.resetField("senderId", { defaultValue: SENDER_ID });
} else if (newValue.value === WorkflowActions.EMAIL_ADDRESS) {
setIsEmailAddressNeeded(true);
setIsSenderIdNeeded(false);
setIsPhoneNumberNeeded(false);
} else if (newValue.value === WorkflowActions.SMS_ATTENDEE) {
setIsSenderIdNeeded(true);
setIsEmailAddressNeeded(false);
setIsPhoneNumberNeeded(false);
form.resetField("senderId", { defaultValue: SENDER_ID });
} else if (newValue.value === WorkflowActions.WHATSAPP_NUMBER) {
setIsSenderIdNeeded(false);
setIsPhoneNumberNeeded(true);
setIsEmailAddressNeeded(false);
} else {
setIsSenderIdNeeded(false);
setIsEmailAddressNeeded(false);
setIsPhoneNumberNeeded(false);
}
form.unregister("sendTo");
form.unregister("numberRequired");
form.clearErrors("action");
form.clearErrors("sendTo");
}
};
if (!actionOptions) return null;
const canRequirePhoneNumber = (workflowStep: string) => {
return (
WorkflowActions.SMS_ATTENDEE === workflowStep || WorkflowActions.WHATSAPP_ATTENDEE === workflowStep
);
};
const showSender = (action: string) => {
return (
!isSenderIdNeeded &&
!(WorkflowActions.WHATSAPP_NUMBER === action || WorkflowActions.WHATSAPP_ATTENDEE === action)
);
};
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent enableOverflow type="creation" title={t("add_action")}>
<div className="-mt-3 space-x-3">
<Form
form={form}
handleSubmit={(values) => {
addAction(
values.action,
values.sendTo,
values.numberRequired,
values.senderId,
values.senderName
);
form.unregister("sendTo");
form.unregister("action");
form.unregister("numberRequired");
setIsOpenDialog(false);
setIsPhoneNumberNeeded(false);
setIsEmailAddressNeeded(false);
setIsSenderIdNeeded(false);
}}>
<div className="space-y-1">
<Label htmlFor="label">{t("action")}:</Label>
<Controller
name="action"
control={form.control}
render={() => {
return (
<Select
isSearchable={false}
className="text-sm"
menuPlacement="bottom"
defaultValue={actionOptions[0]}
onChange={handleSelectAction}
options={actionOptions.map((option) => ({
...option,
}))}
isOptionDisabled={(option: {
label: string;
value: WorkflowActions;
needsTeamsUpgrade: boolean;
}) => option.needsTeamsUpgrade}
/>
);
}}
/>
{form.formState.errors.action && (
<p className="mt-1 text-sm text-red-500">{form.formState.errors.action.message}</p>
)}
</div>
{isPhoneNumberNeeded && (
<div className="mt-5 space-y-1">
<Label htmlFor="sendTo">{t("phone_number")}</Label>
<div className="mb-5 mt-1">
<Controller
control={form.control}
name="sendTo"
render={({ field: { value, onChange } }) => (
<PhoneInput
className="rounded-md"
placeholder={t("enter_phone_number")}
id="sendTo"
required
value={value}
onChange={onChange}
/>
)}
/>
{form.formState.errors.sendTo && (
<p className="mt-1 text-sm text-red-500">{form.formState.errors.sendTo.message}</p>
)}
</div>
</div>
)}
{isEmailAddressNeeded && (
<div className="mt-5">
<EmailField required label={t("email_address")} {...form.register("sendTo")} />
</div>
)}
{isSenderIdNeeded && (
<>
<div className="mt-5">
<div className="flex items-center">
<Label>{t("sender_id")}</Label>
<Tooltip content={t("sender_id_info")}>
<span>
<Icon name="info" className="mb-2 ml-2 mr-1 mt-0.5 h-4 w-4 text-gray-500" />
</span>
</Tooltip>
</div>
<Input type="text" placeholder={SENDER_ID} maxLength={11} {...form.register(`senderId`)} />
</div>
{form.formState.errors && form.formState?.errors?.senderId && (
<p className="mt-1 text-xs text-red-500">{t("sender_id_error_message")}</p>
)}
</>
)}
{showSender(form.getValues("action")) && (
<div className="mt-5">
<Label>{t("sender_name")}</Label>
<Input type="text" placeholder={SENDER_NAME} {...form.register(`senderName`)} />
</div>
)}
{canRequirePhoneNumber(form.getValues("action")) && (
<div className="mt-5">
<Controller
name="numberRequired"
control={form.control}
render={() => (
<CheckboxField
defaultChecked={form.getValues("numberRequired") || false}
description={t("make_phone_number_required")}
onChange={(e) => form.setValue("numberRequired", e.target.checked)}
/>
)}
/>
</div>
)}
<DialogFooter showDivider className="mt-12">
<DialogClose
onClick={() => {
setIsOpenDialog(false);
form.unregister("sendTo");
form.unregister("action");
form.unregister("numberRequired");
setIsPhoneNumberNeeded(false);
setIsEmailAddressNeeded(false);
setIsSenderIdNeeded(false);
}}
/>
<Button type="submit">{t("add")}</Button>
</DialogFooter>
</Form>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,56 @@
import type { Dispatch, SetStateAction } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { trpc } from "@calcom/trpc/react";
import { ConfirmationDialogContent, Dialog, showToast } from "@calcom/ui";
interface IDeleteDialog {
isOpenDialog: boolean;
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
workflowId: number;
additionalFunction: () => Promise<boolean | void>;
}
export const DeleteDialog = (props: IDeleteDialog) => {
const { t } = useLocale();
const { isOpenDialog, setIsOpenDialog, workflowId, additionalFunction } = props;
const utils = trpc.useUtils();
const deleteMutation = trpc.viewer.workflows.delete.useMutation({
onSuccess: async () => {
await utils.viewer.workflows.filteredList.invalidate();
additionalFunction();
showToast(t("workflow_deleted_successfully"), "success");
setIsOpenDialog(false);
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
setIsOpenDialog(false);
}
if (err.data?.code === "UNAUTHORIZED") {
const message = `${err.data.code}: You are not authorized to delete this workflow`;
showToast(message, "error");
}
},
});
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<ConfirmationDialogContent
isPending={deleteMutation.isPending}
variety="danger"
title={t("delete_workflow")}
confirmBtnText={t("confirm_delete_workflow")}
loadingText={t("confirm_delete_workflow")}
onConfirm={(e) => {
e.preventDefault();
deleteMutation.mutate({ id: workflowId });
}}>
{t("delete_workflow_description")}
</ConfirmationDialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,99 @@
import { useRouter } from "next/navigation";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { trpc } from "@calcom/trpc/react";
import type { IconName } from "@calcom/ui";
import { CreateButtonWithTeamsList, EmptyScreen as ClassicEmptyScreen, Icon, showToast } from "@calcom/ui";
type WorkflowExampleType = {
Icon: IconName;
text: string;
};
function WorkflowExample(props: WorkflowExampleType) {
const { Icon: iconName, text } = props;
return (
<div className="border-subtle mx-2 my-2 max-h-24 max-w-[600px] rounded-md border border-solid p-6">
<div className="flex ">
<div className="flex items-center justify-center">
<div className="bg-emphasis dark:bg-default mr-4 flex h-10 w-10 items-center justify-center rounded-full">
<Icon name={iconName} className="text-default h-6 w-6 stroke-[2px]" />
</div>
</div>
<div className="m-auto w-full flex-grow items-center justify-center ">
<div className="text-semibold text-emphasis line-clamp-2 w-full text-sm font-medium">{text}</div>
</div>
</div>
</div>
);
}
export default function EmptyScreen(props: { isFilteredView: boolean }) {
const { t } = useLocale();
const router = useRouter();
const createMutation = trpc.viewer.workflows.create.useMutation({
onSuccess: async ({ workflow }) => {
await router.replace(`/workflows/${workflow.id}`);
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
if (err.data?.code === "UNAUTHORIZED") {
const message = `${err.data.code}: You are not authorized to create this workflow`;
showToast(message, "error");
}
},
});
const workflowsExamples = [
{ icon: "smartphone", text: t("workflow_example_1") },
{ icon: "smartphone", text: t("workflow_example_2") },
{ icon: "mail", text: t("workflow_example_3") },
{ icon: "mail", text: t("workflow_example_4") },
{ icon: "mail", text: t("workflow_example_5") },
{ icon: "smartphone", text: t("workflow_example_6") },
] as const;
// new workflow example when 'after meetings ends' trigger is implemented: Send custom thank you email to attendee after event (Smile icon),
if (props.isFilteredView) {
return <ClassicEmptyScreen Icon="zap" headline={t("no_workflows")} description={t("change_filter")} />;
}
return (
<>
<div className="min-h-80 flex w-full flex-col items-center justify-center rounded-md ">
<div className="bg-emphasis flex h-[72px] w-[72px] items-center justify-center rounded-full">
<Icon name="zap" className="dark:text-default inline-block h-10 w-10 stroke-[1.3px]" />
</div>
<div className="max-w-[420px] text-center">
<h2 className="text-semibold font-cal mt-6 text-xl dark:text-gray-300">{t("workflows")}</h2>
<p className="text-default mt-3 line-clamp-2 text-sm font-normal leading-6 dark:text-gray-300">
{t("no_workflows_description")}
</p>
<div className="mt-8 ">
<CreateButtonWithTeamsList
subtitle={t("new_workflow_subtitle").toUpperCase()}
createFunction={(teamId?: number) => createMutation.mutate({ teamId })}
buttonText={t("create_workflow")}
isPending={createMutation.isPending}
includeOrg={true}
/>
</div>
</div>
</div>
<div className="flex flex-row items-center justify-center">
<div className="grid-cols-none items-center lg:grid lg:grid-cols-3 xl:mx-20">
{workflowsExamples.map((example, index) => (
<WorkflowExample key={index} Icon={example.icon} text={example.text} />
))}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,313 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useFormContext } from "react-hook-form";
import { Trans } from "react-i18next";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { WorkflowActions } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Alert, Button, EmptyScreen, Icon, showToast, Switch, Tooltip } from "@calcom/ui";
import LicenseRequired from "../../common/components/LicenseRequired";
import { getActionIcon } from "../lib/getActionIcon";
import SkeletonLoader from "./SkeletonLoaderEventWorkflowsTab";
import type { WorkflowType } from "./WorkflowListPage";
type PartialWorkflowType = Pick<WorkflowType, "name" | "activeOn" | "isOrg" | "steps" | "id" | "readOnly">;
type ItemProps = {
workflow: PartialWorkflowType;
eventType: {
id: number;
title: string;
requiresConfirmation: boolean;
};
isChildrenManagedEventType: boolean;
isActive: boolean;
};
const WorkflowListItem = (props: ItemProps) => {
const { workflow, eventType, isActive } = props;
const { t } = useLocale();
const [activeEventTypeIds, setActiveEventTypeIds] = useState(
workflow.activeOn?.map((active) => {
if (active.eventType) {
return active.eventType.id;
}
}) ?? []
);
const utils = trpc.useUtils();
const activateEventTypeMutation = trpc.viewer.workflows.activateEventType.useMutation({
onSuccess: async () => {
const offOn = isActive ? "off" : "on";
await utils.viewer.workflows.getAllActiveWorkflows.invalidate();
await utils.viewer.eventTypes.get.invalidate({ id: eventType.id });
showToast(
t("workflow_turned_on_successfully", {
workflowName: workflow.name,
offOn,
}),
"success"
);
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
if (err.data?.code === "UNAUTHORIZED") {
showToast(
t("unauthorized_workflow_error_message", {
errorCode: err.data.code,
}),
"error"
);
}
},
});
const sendTo: Set<string> = new Set();
workflow.steps.forEach((step) => {
switch (step.action) {
case WorkflowActions.EMAIL_HOST:
sendTo.add(t("organizer"));
break;
case WorkflowActions.EMAIL_ATTENDEE:
case WorkflowActions.SMS_ATTENDEE:
case WorkflowActions.WHATSAPP_ATTENDEE:
sendTo.add(t("attendee_name_variable"));
break;
case WorkflowActions.SMS_NUMBER:
case WorkflowActions.WHATSAPP_NUMBER:
case WorkflowActions.EMAIL_ADDRESS:
sendTo.add(step.sendTo || "");
break;
}
});
return (
<div className="border-subtle w-full overflow-hidden rounded-md border p-6 px-3 md:p-6">
<div className="flex items-center ">
<div className="bg-subtle mr-4 flex h-10 w-10 items-center justify-center rounded-full text-xs font-medium">
{getActionIcon(
workflow.steps,
isActive ? "h-6 w-6 stroke-[1.5px] text-default" : "h-6 w-6 stroke-[1.5px] text-muted"
)}
</div>
<div className=" grow">
<div
className={classNames(
"text-emphasis mb-1 w-full truncate text-base font-medium leading-4 md:max-w-max",
workflow.name && isActive ? "text-emphasis" : "text-subtle"
)}>
{workflow.name
? workflow.name
: `Untitled (${`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`
.charAt(0)
.toUpperCase()}${`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.slice(1)})`}
</div>
<>
<div
className={classNames(
" flex w-fit items-center whitespace-nowrap rounded-sm text-sm leading-4",
isActive ? "text-default" : "text-muted"
)}>
<span className="mr-1">{t("to")}:</span>
{Array.from(sendTo).map((sendToPerson, index) => {
return <span key={index}>{`${index ? ", " : ""}${sendToPerson}`}</span>;
})}
</div>
</>
</div>
{!workflow.readOnly && (
<div className="flex-none">
<Link href={`/workflows/${workflow.id}`} passHref={true} target="_blank">
<Button type="button" color="minimal" className="mr-4">
<div className="hidden ltr:mr-2 rtl:ml-2 sm:block">{t("edit")}</div>
<Icon name="external-link" className="text-default -mt-[2px] h-4 w-4 stroke-2" />
</Button>
</Link>
</div>
)}
<Tooltip
content={
t(
workflow.readOnly && props.isChildrenManagedEventType
? "locked_by_team_admin"
: isActive
? "turn_off"
: "turn_on"
) as string
}>
<div className="flex items-center ltr:mr-2 rtl:ml-2">
{workflow.readOnly && props.isChildrenManagedEventType && (
<Icon name="lock" className="text-subtle h-4 w-4 ltr:mr-2 rtl:ml-2" />
)}
<Switch
checked={isActive}
disabled={workflow.readOnly}
onCheckedChange={() => {
activateEventTypeMutation.mutate({ workflowId: workflow.id, eventTypeId: eventType.id });
}}
/>
</div>
</Tooltip>
</div>
</div>
);
};
type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"];
type Props = {
eventType: EventTypeSetup;
workflows: PartialWorkflowType[];
};
function EventWorkflowsTab(props: Props) {
const { workflows, eventType } = props;
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
const { shouldLockDisableProps, isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager({
eventType,
translate: t,
formMethods,
});
const workflowsDisableProps = shouldLockDisableProps("workflows", { simple: true });
const lockedText = workflowsDisableProps.isLocked ? "locked" : "unlocked";
const { data, isPending } = trpc.viewer.workflows.list.useQuery({
teamId: eventType.team?.id,
userId: !isChildrenManagedEventType ? eventType.userId || undefined : undefined,
});
const router = useRouter();
const [sortedWorkflows, setSortedWorkflows] = useState<Array<WorkflowType>>([]);
useEffect(() => {
if (data?.workflows) {
const allActiveWorkflows = workflows.map((workflowOnEventType) => {
const dataWf = data.workflows.find((wf) => wf.id === workflowOnEventType.id);
return {
...workflowOnEventType,
readOnly: isChildrenManagedEventType && dataWf?.teamId ? true : dataWf?.readOnly ?? false,
} as WorkflowType;
});
const disabledWorkflows = data.workflows.filter(
(workflow) =>
(!workflow.teamId || eventType.teamId === workflow.teamId) &&
!workflows
.map((workflow) => {
return workflow.id;
})
.includes(workflow.id)
);
const allSortedWorkflows =
workflowsDisableProps.isLocked && !isManagedEventType
? allActiveWorkflows
: allActiveWorkflows.concat(disabledWorkflows);
setSortedWorkflows(allSortedWorkflows);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isPending]);
const createMutation = trpc.viewer.workflows.create.useMutation({
onSuccess: async ({ workflow }) => {
await router.replace(`/workflows/${workflow.id}?eventTypeId=${eventType.id}`);
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
if (err.data?.code === "UNAUTHORIZED") {
const message = `${err.data.code}: ${t("error_workflow_unauthorized_create")}`;
showToast(message, "error");
}
},
});
return (
<LicenseRequired>
{!isPending ? (
<>
{(isManagedEventType || isChildrenManagedEventType) && (
<Alert
severity={workflowsDisableProps.isLocked ? "neutral" : "green"}
className="mb-2"
title={
<Trans i18nKey={`${lockedText}_${isManagedEventType ? "for_members" : "by_team_admins"}`}>
{lockedText[0].toUpperCase()}
{lockedText.slice(1)} {isManagedEventType ? "for members" : "by team admins"}
</Trans>
}
actions={<div className="flex h-full items-center">{workflowsDisableProps.LockedIcon}</div>}
message={
<Trans
i18nKey={`workflows_${lockedText}_${
isManagedEventType ? "for_members" : "by_team_admins"
}_description`}>
{isManagedEventType ? "Members" : "You"}{" "}
{workflowsDisableProps.isLocked
? "will be able to see the active workflows but will not be able to edit any workflow settings"
: "will be able to see the active workflow and will be able to edit any workflow settings"}
</Trans>
}
/>
)}
{data?.workflows && sortedWorkflows.length > 0 ? (
<div>
<div className="space-y-4">
{sortedWorkflows.map((workflow) => {
return (
<WorkflowListItem
key={workflow.id}
workflow={workflow}
eventType={props.eventType}
isChildrenManagedEventType
isActive={!!workflows.find((activeWorkflow) => activeWorkflow.id === workflow.id)}
/>
);
})}
</div>
</div>
) : (
<div className="pt-2 before:border-0">
<EmptyScreen
Icon="zap"
headline={t("workflows")}
description={t("no_workflows_description")}
buttonRaw={
<Button
disabled={workflowsDisableProps.isLocked && !isManagedEventType}
target="_blank"
color="secondary"
onClick={() => createMutation.mutate({ teamId: eventType.team?.id })}
loading={createMutation.isPending}>
{t("create_workflow")}
</Button>
}
/>
</div>
)}
</>
) : (
<SkeletonLoader />
)}
</LicenseRequired>
);
}
export default EventWorkflowsTab;

View File

@@ -0,0 +1,23 @@
import { SkeletonContainer, SkeletonText } from "@calcom/ui";
function SkeletonLoader() {
return (
<SkeletonContainer>
<div className="ml-2 mt-10 md:flex">
<div className="mr-6 flex flex-col md:flex-none">
<SkeletonText className="h-4 w-28" />
<SkeletonText className="mb-6 mt-2 h-8 w-full md:w-64" />
<SkeletonText className="h-4 w-28" />
<SkeletonText className="mt-2 h-8 w-full md:w-64" />
<SkeletonText className="mt-8 hidden h-0.5 w-full md:block" />
<SkeletonText className="mb-6 mt-8 h-8 w-40" />
</div>
<div className="hidden flex-grow md:flex">
<SkeletonText className="h-64 w-full" />
</div>
</div>
</SkeletonContainer>
);
}
export default SkeletonLoader;

View File

@@ -0,0 +1,37 @@
import { SkeletonAvatar, SkeletonText } from "@calcom/ui";
function SkeletonLoader() {
return (
<ul className="bg-default divide-subtle animate-pulse sm:overflow-hidden">
<SkeletonItem />
<SkeletonItem />
</ul>
);
}
export default SkeletonLoader;
function SkeletonItem() {
return (
<li className="border-subtle group mb-4 flex h-[90px] w-full items-center justify-between rounded-md border px-4 py-4 sm:px-6">
<div className="flex-grow truncate text-sm">
<div className="flex">
<SkeletonAvatar className="h-10 w-10" />
<div className="ml-4 mt-1 flex flex-col space-y-1">
<SkeletonText className="h-5 w-20 sm:w-24" />
<div className="flex">
<SkeletonText className="h-4 w-16 ltr:mr-2 rtl:ml-2 sm:w-28" />
</div>
</div>
</div>
</div>
<div className="mt-0 flex flex-shrink-0 sm:ml-5">
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
<SkeletonText className="h-8 w-8 sm:w-16" />
<SkeletonText className="h-8 w-8 sm:w-16" />
</div>
</div>
</li>
);
}

View File

@@ -0,0 +1,38 @@
import { Icon, SkeletonText } from "@calcom/ui";
function SkeletonLoader() {
return (
<ul className="divide-subtle border-subtle bg-default animate-pulse divide-y rounded-md border sm:overflow-hidden">
<SkeletonItem />
<SkeletonItem />
<SkeletonItem />
</ul>
);
}
export default SkeletonLoader;
function SkeletonItem() {
return (
<li className="group flex w-full items-center justify-between px-4 py-4 sm:px-6">
<div className="flex-grow truncate text-sm">
<div className="flex">
<div className="flex flex-col space-y-2">
<SkeletonText className="h-4 w-16 sm:w-24" />
<div className="flex">
<Icon name="bell" className="mr-1.5 mt-0.5 inline h-4 w-4 text-gray-200" />
<SkeletonText className="h-4 w-16 ltr:mr-2 rtl:ml-2 sm:w-28" />
<Icon name="link" className="mr-1.5 mt-0.5 inline h-4 w-4 text-gray-200" />
<SkeletonText className="h-4 w-28 sm:w-36" />
</div>
</div>
</div>
</div>
<div className="mt-0 flex flex-shrink-0 sm:ml-5">
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
<SkeletonText className="h-8 w-8 sm:w-16" />
</div>
</div>
</li>
);
}

View File

@@ -0,0 +1,97 @@
import { useState } from "react";
import type { UseFormReturn } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { TimeUnit } from "@calcom/prisma/enums";
import {
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Icon,
TextField,
} from "@calcom/ui";
import type { FormValues } from "../pages/workflow";
const TIME_UNITS = [TimeUnit.DAY, TimeUnit.HOUR, TimeUnit.MINUTE] as const;
type Props = {
form: UseFormReturn<FormValues>;
disabled: boolean;
};
const TimeUnitAddonSuffix = ({
DropdownItems,
timeUnitOptions,
form,
}: {
form: UseFormReturn<FormValues>;
DropdownItems: JSX.Element;
timeUnitOptions: { [x: string]: string };
}) => {
// because isDropdownOpen already triggers a render cycle we can use getValues()
// instead of watch() function
const timeUnit = form.getValues("timeUnit");
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
return (
<Dropdown onOpenChange={setIsDropdownOpen}>
<DropdownMenuTrigger asChild>
<button className="flex items-center">
<div className="mr-1 w-3/5">{timeUnit ? timeUnitOptions[timeUnit] : "undefined"}</div>
<div className="w-1/4 pt-1">
{isDropdownOpen ? <Icon name="chevron-up" /> : <Icon name="chevron-down" />}
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>{DropdownItems}</DropdownMenuContent>
</Dropdown>
);
};
export const TimeTimeUnitInput = (props: Props) => {
const { form } = props;
const { t } = useLocale();
const timeUnitOptions = TIME_UNITS.reduce((acc, option) => {
acc[option] = t(`${option.toLowerCase()}_timeUnit`);
return acc;
}, {} as { [x: string]: string });
return (
<div className="flex">
<div className="grow">
<TextField
type="number"
min="1"
label=""
disabled={props.disabled}
defaultValue={form.getValues("time") || 24}
className="-mt-2 rounded-r-none text-sm focus:ring-0"
{...form.register("time", { valueAsNumber: true })}
addOnSuffix={
<TimeUnitAddonSuffix
form={form}
timeUnitOptions={timeUnitOptions}
DropdownItems={
<>
{TIME_UNITS.map((timeUnit, index) => (
<DropdownMenuItem key={index} className="outline-none">
<DropdownItem
key={index}
type="button"
onClick={() => {
form.setValue("timeUnit", timeUnit);
}}>
{timeUnitOptions[timeUnit]}
</DropdownItem>
</DropdownMenuItem>
))}
</>
}
/>
}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,233 @@
import { useRouter, useSearchParams } from "next/navigation";
import type { Dispatch, SetStateAction } from "react";
import { useState, useEffect } from "react";
import type { UseFormReturn } from "react-hook-form";
import { Controller } from "react-hook-form";
import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { WorkflowActions } from "@calcom/prisma/enums";
import { WorkflowTemplates } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui";
import { Button, Icon, Label, MultiSelectCheckboxes, TextField, CheckboxField, InfoBadge } from "@calcom/ui";
import { isSMSAction, isWhatsappAction } from "../lib/actionHelperFunctions";
import type { FormValues } from "../pages/workflow";
import { AddActionDialog } from "./AddActionDialog";
import { DeleteDialog } from "./DeleteDialog";
import WorkflowStepContainer from "./WorkflowStepContainer";
type User = RouterOutputs["viewer"]["me"];
interface Props {
form: UseFormReturn<FormValues>;
workflowId: number;
selectedOptions: Option[];
setSelectedOptions: Dispatch<SetStateAction<Option[]>>;
teamId?: number;
user: User;
readOnly: boolean;
isOrg: boolean;
allOptions: Option[];
}
export default function WorkflowDetailsPage(props: Props) {
const { form, workflowId, selectedOptions, setSelectedOptions, teamId, isOrg, allOptions } = props;
const { t } = useLocale();
const router = useRouter();
const [isAddActionDialogOpen, setIsAddActionDialogOpen] = useState(false);
const [reload, setReload] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const searchParams = useSearchParams();
const eventTypeId = searchParams?.get("eventTypeId");
useEffect(() => {
const matchingOption = allOptions.find((option) => option.value === eventTypeId);
if (matchingOption && !selectedOptions.find((option) => option.value === eventTypeId)) {
const newOptions = [...selectedOptions, matchingOption];
setSelectedOptions(newOptions);
form.setValue("activeOn", newOptions);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [eventTypeId]);
const addAction = (
action: WorkflowActions,
sendTo?: string,
numberRequired?: boolean,
sender?: string,
senderName?: string
) => {
const steps = form.getValues("steps");
const id =
steps?.length > 0
? steps.sort((a, b) => {
return a.id - b.id;
})[0].id - 1
: 0;
const step = {
id: id > 0 ? 0 : id, //id of new steps always <= 0
action,
stepNumber:
steps && steps.length > 0
? steps.sort((a, b) => {
return a.stepNumber - b.stepNumber;
})[steps.length - 1].stepNumber + 1
: 1,
sendTo: sendTo || null,
workflowId: workflowId,
reminderBody: null,
emailSubject: null,
template: isWhatsappAction(action) ? WorkflowTemplates.REMINDER : WorkflowTemplates.CUSTOM,
numberRequired: numberRequired || false,
sender: isSMSAction(action) ? sender || SENDER_ID : SENDER_ID,
senderName: !isSMSAction(action) ? senderName || SENDER_NAME : SENDER_NAME,
numberVerificationPending: false,
includeCalendarEvent: false,
};
steps?.push(step);
form.setValue("steps", steps);
};
return (
<>
<div className="z-1 my-8 sm:my-0 md:flex">
<div className="pl-2 pr-3 md:sticky md:top-6 md:h-0 md:pl-0">
<div className="mb-5">
<TextField
data-testid="workflow-name"
disabled={props.readOnly}
label={`${t("workflow_name")}:`}
type="text"
{...form.register("name")}
/>
</div>
{isOrg ? (
<div className="flex">
<Label>{t("which_team_apply")}</Label>
<div className="-mt-0.5">
<InfoBadge content={t("team_select_info")} />
</div>
</div>
) : (
<Label>{t("which_event_type_apply")}</Label>
)}
<Controller
name="activeOn"
control={form.control}
render={() => {
return (
<MultiSelectCheckboxes
options={allOptions}
isDisabled={props.readOnly || form.getValues("selectAll")}
className="w-full md:w-64"
setSelected={setSelectedOptions}
selected={form.getValues("selectAll") ? allOptions : selectedOptions}
setValue={(s: Option[]) => {
form.setValue("activeOn", s);
}}
countText={isOrg ? "count_team" : "nr_event_type"}
/>
);
}}
/>
<div className="mt-3">
<Controller
name="selectAll"
render={({ field: { value, onChange } }) => (
<CheckboxField
description={isOrg ? t("apply_to_all_teams") : t("apply_to_all_event_types")}
disabled={props.readOnly}
onChange={(e) => {
onChange(e);
if (e.target.value) {
setSelectedOptions(allOptions);
form.setValue("activeOn", allOptions);
}
}}
checked={value}
/>
)}
/>
</div>
<div className="md:border-subtle my-7 border-transparent md:border-t" />
{!props.readOnly && (
<Button
type="button"
StartIcon="trash-2"
color="destructive"
className="border"
onClick={() => setDeleteDialogOpen(true)}>
{t("delete_workflow")}
</Button>
)}
<div className="border-subtle my-7 border-t md:border-none" />
</div>
{/* Workflow Trigger Event & Steps */}
<div className="bg-muted border-subtle w-full rounded-md border p-3 py-5 md:ml-3 md:p-8">
{form.getValues("trigger") && (
<div>
<WorkflowStepContainer
form={form}
user={props.user}
teamId={teamId}
readOnly={props.readOnly}
/>
</div>
)}
{form.getValues("steps") && (
<>
{form.getValues("steps")?.map((step) => {
return (
<WorkflowStepContainer
key={step.id}
form={form}
user={props.user}
step={step}
reload={reload}
setReload={setReload}
teamId={teamId}
readOnly={props.readOnly}
/>
);
})}
</>
)}
{!props.readOnly && (
<>
<div className="my-3 flex justify-center">
<Icon name="arrow-down" className="text-subtle stroke-[1.5px] text-3xl" />
</div>
<div className="flex justify-center">
<Button
type="button"
onClick={() => setIsAddActionDialogOpen(true)}
color="secondary"
className="bg-default">
{t("add_action")}
</Button>
</div>
</>
)}
</div>
</div>
<AddActionDialog
isOpenDialog={isAddActionDialogOpen}
setIsOpenDialog={setIsAddActionDialogOpen}
addAction={addAction}
/>
<DeleteDialog
isOpenDialog={deleteDialogOpen}
setIsOpenDialog={setDeleteDialogOpen}
workflowId={workflowId}
additionalFunction={async () => router.push("/workflows")}
/>
</>
);
}

View File

@@ -0,0 +1,331 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import type { Membership, Workflow } from "@prisma/client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import classNames from "@calcom/lib/classNames";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
ArrowButton,
Avatar,
Badge,
Button,
ButtonGroup,
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Icon,
Tooltip,
} from "@calcom/ui";
import { getActionIcon } from "../lib/getActionIcon";
import type { WorkflowStep } from "../lib/types";
import { DeleteDialog } from "./DeleteDialog";
export type WorkflowType = Workflow & {
team: {
id: number;
name: string;
members: Membership[];
slug: string | null;
logo?: string | null;
} | null;
steps: WorkflowStep[];
activeOnTeams?: {
team: {
id: number;
name?: string | null;
};
}[];
activeOn?: {
eventType: {
id: number;
title: string;
parentId: number | null;
_count: {
children: number;
};
};
}[];
readOnly?: boolean;
isOrg?: boolean;
};
interface Props {
workflows: WorkflowType[] | undefined;
}
export default function WorkflowListPage({ workflows }: Props) {
const { t } = useLocale();
const utils = trpc.useUtils();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [workflowToDeleteId, setwWorkflowToDeleteId] = useState(0);
const [parent] = useAutoAnimate<HTMLUListElement>();
const router = useRouter();
const mutation = trpc.viewer.workflowOrder.useMutation({
onError: async (err) => {
console.error(err.message);
await utils.viewer.workflows.filteredList.cancel();
await utils.viewer.workflows.filteredList.invalidate();
},
onSettled: () => {
utils.viewer.workflows.filteredList.invalidate();
},
});
async function moveWorkflow(index: number, increment: 1 | -1) {
const types = workflows!;
const newList = [...types];
const type = types[index];
const tmp = types[index + increment];
if (tmp) {
newList[index] = tmp;
newList[index + increment] = type;
}
await utils.viewer.appRoutingForms.forms.cancel();
mutation.mutate({
ids: newList?.map((type) => type.id),
});
}
return (
<>
{workflows && workflows.length > 0 ? (
<div className="bg-default border-subtle overflow-hidden rounded-md border sm:mx-0">
<ul className="divide-subtle !static w-full divide-y" data-testid="workflow-list" ref={parent}>
{workflows.map((workflow, index) => {
const firstItem = workflows[0];
const lastItem = workflows[workflows.length - 1];
const dataTestId = `workflow-${workflow.name.toLowerCase().replaceAll(" ", "-")}`;
return (
<li
key={workflow.id}
data-testid={dataTestId}
className="group flex w-full max-w-full items-center justify-between overflow-hidden">
{!(firstItem && firstItem.id === workflow.id) && (
<ArrowButton onClick={() => moveWorkflow(index, -1)} arrowDirection="up" />
)}
{!(lastItem && lastItem.id === workflow.id) && (
<ArrowButton onClick={() => moveWorkflow(index, 1)} arrowDirection="down" />
)}
<div className="first-line:group hover:bg-muted flex w-full items-center justify-between p-4 sm:px-6">
<Link href={`/workflows/${workflow.id}`} className="flex-grow cursor-pointer">
<div className="rtl:space-x-reverse">
<div className="flex">
<div
className={classNames(
"max-w-56 text-emphasis truncate text-sm font-medium leading-6 md:max-w-max",
workflow.name ? "text-emphasis" : "text-subtle"
)}>
{workflow.name
? workflow.name
: workflow.steps[0]
? `Untitled (${`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`
.charAt(0)
.toUpperCase()}${`${t(
`${workflow.steps[0].action.toLowerCase()}_action`
)}`.slice(1)})`
: "Untitled"}
</div>
<div>
{workflow.readOnly && (
<Badge variant="gray" className="ml-2 ">
{t("readonly")}
</Badge>
)}
</div>
</div>
<ul className="mt-1 flex flex-wrap space-x-2 sm:flex-nowrap ">
<li>
<Badge variant="gray">
<div>
{getActionIcon(workflow.steps)}
<span className="mr-1">{t("triggers")}</span>
{workflow.timeUnit && workflow.time && (
<span className="mr-1">
{t(`${workflow.timeUnit.toLowerCase()}`, { count: workflow.time })}
</span>
)}
<span>{t(`${workflow.trigger.toLowerCase()}_trigger`)}</span>
</div>
</Badge>
</li>
<li>
<Badge variant="gray">
{/*active on all badge */}
{workflow.isActiveOnAll ? (
<div>
<Icon name="link" className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{workflow.isOrg ? t("active_on_all_teams") : t("active_on_all_event_types")}
</div>
) : workflow.activeOn && workflow.activeOn.length > 0 ? (
//active on event types badge
<Tooltip
content={workflow.activeOn
.filter((wf) => (workflow.teamId ? wf.eventType.parentId === null : true))
.map((activeOn, key) => (
<p key={key}>
{activeOn.eventType.title}
{activeOn.eventType._count.children > 0
? ` (+${activeOn.eventType._count.children})`
: ""}
</p>
))}>
<div>
<Icon name="link" className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{t("active_on_event_types", {
count: workflow.activeOn.filter((wf) =>
workflow.teamId ? wf.eventType.parentId === null : true
).length,
})}
</div>
</Tooltip>
) : workflow.activeOnTeams && workflow.activeOnTeams.length > 0 ? (
//active on teams badge
<Tooltip
content={workflow.activeOnTeams.map((activeOn, key) => (
<p key={key}>{activeOn.team.name}</p>
))}>
<div>
<Icon name="link" className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{t("active_on_teams", {
count: workflow.activeOnTeams?.length,
})}
</div>
</Tooltip>
) : (
// active on no teams or event types
<div>
<Icon name="link" className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{workflow.isOrg ? t("no_active_teams") : t("no_active_event_types")}
</div>
)}
</Badge>
</li>
<div className="block md:hidden">
{workflow.team?.name && (
<li>
<Badge variant="gray">
<>{workflow.team.name}</>
</Badge>
</li>
)}
</div>
</ul>
</div>
</Link>
<div>
<div className="hidden md:block">
{workflow.team?.name && (
<Badge className="mr-4 mt-1 p-[1px] px-2" variant="gray">
<Avatar
alt={workflow.team?.name || ""}
href={
workflow.team?.id
? `/settings/teams/${workflow.team?.id}/profile`
: "/settings/my-account/profile"
}
imageSrc={getPlaceholderAvatar(
workflow?.team.logo,
workflow.team?.name as string
)}
size="xxs"
className="mt-[3px] inline-flex justify-center"
/>
<div>{workflow.team.name}</div>
</Badge>
)}
</div>
</div>
<div className="flex flex-shrink-0">
<div className="hidden sm:block">
<ButtonGroup combined>
<Tooltip content={t("edit") as string}>
<Button
type="button"
color="secondary"
variant="icon"
StartIcon="pencil"
disabled={workflow.readOnly}
onClick={async () => await router.replace(`/workflows/${workflow.id}`)}
data-testid="edit-button"
/>
</Tooltip>
<Tooltip content={t("delete") as string}>
<Button
onClick={() => {
setDeleteDialogOpen(true);
setwWorkflowToDeleteId(workflow.id);
}}
color="secondary"
variant="icon"
disabled={workflow.readOnly}
StartIcon="trash-2"
data-testid="delete-button"
/>
</Tooltip>
</ButtonGroup>
</div>
{!workflow.readOnly && (
<div className="block sm:hidden">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button type="button" color="minimal" variant="icon" StartIcon="ellipsis" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon="pencil"
onClick={async () => await router.replace(`/workflows/${workflow.id}`)}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem
type="button"
color="destructive"
StartIcon="trash-2"
onClick={() => {
setDeleteDialogOpen(true);
setwWorkflowToDeleteId(workflow.id);
}}>
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
)}
</div>
</div>
</li>
);
})}
</ul>
<DeleteDialog
isOpenDialog={deleteDialogOpen}
setIsOpenDialog={setDeleteDialogOpen}
workflowId={workflowToDeleteId}
additionalFunction={async () => {
await utils.viewer.workflows.filteredList.invalidate();
}}
/>
</div>
) : (
<></>
)}
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,88 @@
import type { WorkflowTriggerEvents } from "@prisma/client";
import type { TimeFormat } from "@calcom/lib/timeFormat";
import { WorkflowActions, WorkflowTemplates } from "@calcom/prisma/enums";
import {
whatsappEventCancelledTemplate,
whatsappEventCompletedTemplate,
whatsappEventRescheduledTemplate,
whatsappReminderTemplate,
} from "../lib/reminders/templates/whatsapp";
export function shouldScheduleEmailReminder(action: WorkflowActions) {
return action === WorkflowActions.EMAIL_ATTENDEE || action === WorkflowActions.EMAIL_HOST;
}
export function shouldScheduleSMSReminder(action: WorkflowActions) {
return action === WorkflowActions.SMS_ATTENDEE || action === WorkflowActions.SMS_NUMBER;
}
export function isSMSAction(action: WorkflowActions) {
return action === WorkflowActions.SMS_ATTENDEE || action === WorkflowActions.SMS_NUMBER;
}
export function isWhatsappAction(action: WorkflowActions) {
return action === WorkflowActions.WHATSAPP_NUMBER || action === WorkflowActions.WHATSAPP_ATTENDEE;
}
export function isSMSOrWhatsappAction(action: WorkflowActions) {
return isSMSAction(action) || isWhatsappAction(action);
}
export function isAttendeeAction(action: WorkflowActions) {
return (
action === WorkflowActions.SMS_ATTENDEE ||
action === WorkflowActions.EMAIL_ATTENDEE ||
action === WorkflowActions.WHATSAPP_ATTENDEE
);
}
export function isEmailToAttendeeAction(action: WorkflowActions) {
return action === WorkflowActions.EMAIL_ATTENDEE;
}
export function isTextMessageToSpecificNumber(action?: WorkflowActions) {
return action === WorkflowActions.SMS_NUMBER || action === WorkflowActions.WHATSAPP_NUMBER;
}
export function getWhatsappTemplateForTrigger(trigger: WorkflowTriggerEvents): WorkflowTemplates {
switch (trigger) {
case "NEW_EVENT":
case "BEFORE_EVENT":
return WorkflowTemplates.REMINDER;
case "AFTER_EVENT":
return WorkflowTemplates.COMPLETED;
case "EVENT_CANCELLED":
return WorkflowTemplates.CANCELLED;
case "RESCHEDULE_EVENT":
return WorkflowTemplates.RESCHEDULED;
default:
return WorkflowTemplates.REMINDER;
}
}
export function getWhatsappTemplateFunction(template?: WorkflowTemplates): typeof whatsappReminderTemplate {
switch (template) {
case "CANCELLED":
return whatsappEventCancelledTemplate;
case "COMPLETED":
return whatsappEventCompletedTemplate;
case "RESCHEDULED":
return whatsappEventRescheduledTemplate;
case "CUSTOM":
case "REMINDER":
return whatsappReminderTemplate;
default:
return whatsappReminderTemplate;
}
}
export function getWhatsappTemplateForAction(
action: WorkflowActions,
template: WorkflowTemplates,
timeFormat: TimeFormat
): string | null {
const templateFunction = getWhatsappTemplateFunction(template);
return templateFunction(true, action, timeFormat);
}

View File

@@ -0,0 +1,23 @@
import { WorkflowTriggerEvents } from "@calcom/prisma/client";
import { WorkflowActions } from "@calcom/prisma/enums";
import type { Workflow } from "./types";
export function allowDisablingHostConfirmationEmails(workflows: Workflow[]) {
return !!workflows.find(
(workflow) =>
workflow.trigger === WorkflowTriggerEvents.NEW_EVENT &&
!!workflow.steps.find((step) => step.action === WorkflowActions.EMAIL_HOST)
);
}
export function allowDisablingAttendeeConfirmationEmails(workflows: Workflow[]) {
return !!workflows.find(
(workflow) =>
workflow.trigger === WorkflowTriggerEvents.NEW_EVENT &&
!!workflow.steps.find(
(step) =>
step.action === WorkflowActions.EMAIL_ATTENDEE || step.action === WorkflowActions.SMS_ATTENDEE
)
);
}

View File

@@ -0,0 +1,91 @@
import { SENDER_ID } from "@calcom/lib/constants";
export function getSenderId(phoneNumber?: string | null, sender?: string | null) {
const isAlphanumericSenderIdSupported = !noAlphanumericSenderIdSupport.find(
(code) => code === phoneNumber?.substring(0, code.length)
);
const senderID = isAlphanumericSenderIdSupported ? sender || SENDER_ID : "";
return senderID;
}
const noAlphanumericSenderIdSupport = [
"+93",
"+54",
"+374",
"+1",
"+375",
"+32",
"+229",
"+55",
"+237",
"+56",
"+86",
"+57",
"+243",
"+506",
"+53",
"+42",
"+593",
"+20",
"+503",
"+251",
"+594",
"+233",
"+224",
"+245",
"+852",
"+36",
"+91",
"+62",
"+98",
"+972",
"+225",
"+962",
"+7",
"+254",
"+965",
"+996",
"+231",
"+60",
"+52",
"+212",
"+95",
"+674",
"+977",
"+64",
"+505",
"+234",
"+968",
"+507",
"+595",
"+51",
"+63",
"+974",
"+7",
"+250",
"+966",
"+27",
"+82",
"+211",
"+94",
"+249",
"+268",
"+963",
"+886",
"+255",
"+66",
"+216",
"+90",
"+256",
"+598",
"+58",
"+84",
"+260",
"+61",
"+971",
"+420",
"+381",
"+65",
];

View File

@@ -0,0 +1,67 @@
import { WorkflowTriggerEvents, TimeUnit, WorkflowActions, WorkflowTemplates } from "@calcom/prisma/enums";
export const WORKFLOW_TRIGGER_EVENTS = [
WorkflowTriggerEvents.BEFORE_EVENT,
WorkflowTriggerEvents.EVENT_CANCELLED,
WorkflowTriggerEvents.NEW_EVENT,
WorkflowTriggerEvents.AFTER_EVENT,
WorkflowTriggerEvents.RESCHEDULE_EVENT,
] as const;
export const WORKFLOW_ACTIONS = [
WorkflowActions.EMAIL_HOST,
WorkflowActions.EMAIL_ATTENDEE,
WorkflowActions.EMAIL_ADDRESS,
WorkflowActions.SMS_ATTENDEE,
WorkflowActions.SMS_NUMBER,
WorkflowActions.WHATSAPP_ATTENDEE,
WorkflowActions.WHATSAPP_NUMBER,
] as const;
export const TIME_UNIT = [TimeUnit.DAY, TimeUnit.HOUR, TimeUnit.MINUTE] as const;
export const WORKFLOW_TEMPLATES = [
WorkflowTemplates.CUSTOM,
WorkflowTemplates.REMINDER,
WorkflowTemplates.RATING,
WorkflowTemplates.CANCELLED,
WorkflowTemplates.COMPLETED,
WorkflowTemplates.RESCHEDULED,
] as const;
export const BASIC_WORKFLOW_TEMPLATES = [WorkflowTemplates.CUSTOM, WorkflowTemplates.REMINDER] as const;
export const ATTENDEE_WORKFLOW_TEMPLATES = [
WorkflowTemplates.CUSTOM,
WorkflowTemplates.REMINDER,
WorkflowTemplates.RATING,
] as const;
export const WHATSAPP_WORKFLOW_TEMPLATES = [
WorkflowTemplates.REMINDER,
WorkflowTemplates.COMPLETED,
WorkflowTemplates.CANCELLED,
WorkflowTemplates.RESCHEDULED,
] as const;
export const DYNAMIC_TEXT_VARIABLES = [
"event_name",
"event_date",
"event_time",
"event_end_time",
"timezone",
"location",
"organizer_name",
"attendee_name",
"attendee_first_name",
"attendee_last_name",
"attendee_email",
"additional_notes",
"meeting_url",
"cancel_url",
"reschedule_url",
"rating_url",
"no_show_url",
];
export const FORMATTED_DYNAMIC_TEXT_VARIABLES = ["event_date_", "event_time_", "event_end_time_"];

View File

@@ -0,0 +1,88 @@
import { isSMSOrWhatsappAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import { classNames } from "@calcom/lib";
import { Icon } from "@calcom/ui";
import type { WorkflowStep } from "../lib/types";
export function getActionIcon(steps: WorkflowStep[], className?: string): JSX.Element {
if (steps.length === 0) {
return (
<Icon
name="zap"
className={classNames(className ? className : "mr-1.5 inline h-3 w-3")}
aria-hidden="true"
/>
);
}
if (steps.length === 1) {
if (isSMSOrWhatsappAction(steps[0].action)) {
return (
<Icon
name="smartphone"
className={classNames(className ? className : "mr-1.5 inline h-3 w-3")}
aria-hidden="true"
/>
);
} else {
return (
<Icon
name="mail"
className={classNames(className ? className : "mr-1.5 inline h-3 w-3")}
aria-hidden="true"
/>
);
}
}
if (steps.length > 1) {
let messageType = "";
for (const step of steps) {
if (!messageType) {
messageType = isSMSOrWhatsappAction(step.action) ? "SMS" : "EMAIL";
} else if (messageType !== "MIX") {
const newMessageType = isSMSOrWhatsappAction(step.action) ? "SMS" : "EMAIL";
if (newMessageType !== messageType) {
messageType = "MIX";
}
} else {
break;
}
}
switch (messageType) {
case "SMS":
return (
<Icon
name="smartphone"
className={classNames(className ? className : "mr-1.5 inline h-3 w-3")}
aria-hidden="true"
/>
);
case "EMAIL":
return (
<Icon
name="mail"
className={classNames(className ? className : "mr-1.5 inline h-3 w-3")}
aria-hidden="true"
/>
);
case "MIX":
return (
<Icon
name="bell"
className={classNames(className ? className : "mr-1.5 inline h-3 w-3")}
aria-hidden="true"
/>
);
default:
<Icon
name="zap"
className={classNames(className ? className : "mr-1.5 inline h-3 w-3")}
aria-hidden="true"
/>;
}
}
return <></>;
}

View File

@@ -0,0 +1,120 @@
import prisma from "@calcom/prisma";
import type { Workflow } from "./types";
export const workflowSelect = {
id: true,
trigger: true,
time: true,
timeUnit: true,
userId: true,
teamId: true,
name: true,
steps: {
select: {
id: true,
action: true,
sendTo: true,
reminderBody: true,
emailSubject: true,
template: true,
numberVerificationPending: true,
sender: true,
includeCalendarEvent: true,
numberRequired: true,
},
},
};
export const getAllWorkflows = async (
eventTypeWorkflows: Workflow[],
userId?: number | null,
teamId?: number | null,
orgId?: number | null,
workflowsLockedForUser = true
) => {
const allWorkflows = eventTypeWorkflows;
if (orgId) {
if (teamId) {
const orgTeamWorkflowsRel = await prisma.workflowsOnTeams.findMany({
where: {
teamId: teamId,
},
select: {
workflow: {
select: workflowSelect,
},
},
});
const orgTeamWorkflows = orgTeamWorkflowsRel?.map((workflowRel) => workflowRel.workflow) ?? [];
allWorkflows.push(...orgTeamWorkflows);
} else if (userId) {
const orgUserWorkflowsRel = await prisma.workflowsOnTeams.findMany({
where: {
team: {
members: {
some: {
userId: userId,
accepted: true,
},
},
},
},
select: {
workflow: {
select: workflowSelect,
},
team: true,
},
});
const orgUserWorkflows = orgUserWorkflowsRel.map((workflowRel) => workflowRel.workflow) ?? [];
allWorkflows.push(...orgUserWorkflows);
}
// get workflows that are active on all
const activeOnAllOrgWorkflows = await prisma.workflow.findMany({
where: {
teamId: orgId,
isActiveOnAll: true,
},
select: workflowSelect,
});
allWorkflows.push(...activeOnAllOrgWorkflows);
}
if (teamId) {
const activeOnAllTeamWorkflows = await prisma.workflow.findMany({
where: {
teamId,
isActiveOnAll: true,
},
select: workflowSelect,
});
allWorkflows.push(...activeOnAllTeamWorkflows);
}
if ((!teamId || !workflowsLockedForUser) && userId) {
const activeOnAllUserWorkflows = await prisma.workflow.findMany({
where: {
userId,
teamId: null,
isActiveOnAll: true,
},
select: workflowSelect,
});
allWorkflows.push(...activeOnAllUserWorkflows);
}
// remove all the duplicate workflows from allWorkflows
const seen = new Set();
const workflows = allWorkflows.filter((workflow) => {
const duplicate = seen.has(workflow.id);
seen.add(workflow.id);
return !duplicate;
});
return workflows;
};

View File

@@ -0,0 +1,44 @@
import type { TFunction } from "next-i18next";
import type { WorkflowActions } from "@calcom/prisma/enums";
import { isSMSOrWhatsappAction, isWhatsappAction, isEmailToAttendeeAction } from "./actionHelperFunctions";
import {
WHATSAPP_WORKFLOW_TEMPLATES,
WORKFLOW_ACTIONS,
BASIC_WORKFLOW_TEMPLATES,
WORKFLOW_TRIGGER_EVENTS,
ATTENDEE_WORKFLOW_TEMPLATES,
} from "./constants";
export function getWorkflowActionOptions(t: TFunction, isTeamsPlan?: boolean, isOrgsPlan?: boolean) {
return WORKFLOW_ACTIONS.map((action) => {
const actionString = t(`${action.toLowerCase()}_action`);
return {
label: actionString.charAt(0).toUpperCase() + actionString.slice(1),
value: action,
needsTeamsUpgrade: isSMSOrWhatsappAction(action) && !isTeamsPlan,
};
});
}
export function getWorkflowTriggerOptions(t: TFunction) {
return WORKFLOW_TRIGGER_EVENTS.map((triggerEvent) => {
const triggerString = t(`${triggerEvent.toLowerCase()}_trigger`);
return { label: triggerString.charAt(0).toUpperCase() + triggerString.slice(1), value: triggerEvent };
});
}
export function getWorkflowTemplateOptions(t: TFunction, action: WorkflowActions | undefined) {
const TEMPLATES =
action && isWhatsappAction(action)
? WHATSAPP_WORKFLOW_TEMPLATES
: action && isEmailToAttendeeAction(action)
? ATTENDEE_WORKFLOW_TEMPLATES
: BASIC_WORKFLOW_TEMPLATES;
return TEMPLATES.map((template) => {
return { label: t(`${template.toLowerCase()}`), value: template };
}) as { label: string; value: any }[];
}

View File

@@ -0,0 +1,187 @@
import dayjs from "@calcom/dayjs";
import prisma from "@calcom/prisma";
import type { EventType, Prisma, User, WorkflowReminder, WorkflowStep } from "@calcom/prisma/client";
import { WorkflowMethods } from "@calcom/prisma/enums";
type PartialWorkflowStep =
| (Partial<WorkflowStep> & { workflow: { userId?: number; teamId?: number } })
| null;
type Booking = Prisma.BookingGetPayload<{
include: {
attendees: true;
};
}>;
type PartialBooking =
| (Pick<
Booking,
| "startTime"
| "endTime"
| "location"
| "description"
| "metadata"
| "customInputs"
| "responses"
| "uid"
| "attendees"
| "userPrimaryEmail"
| "smsReminderNumber"
> & { eventType: (Partial<EventType> & { team: { parentId?: number } }) | null } & {
user: Partial<User> | null;
})
| null;
export type PartialWorkflowReminder = Pick<
WorkflowReminder,
"id" | "isMandatoryReminder" | "scheduledDate"
> & {
booking: PartialBooking | null;
} & { workflowStep: PartialWorkflowStep };
async function getWorkflowReminders<T extends Prisma.WorkflowReminderSelect>(
filter: Prisma.WorkflowReminderWhereInput,
select: T
): Promise<Array<Prisma.WorkflowReminderGetPayload<{ select: T }>>> {
const pageSize = 90;
let pageNumber = 0;
const filteredWorkflowReminders: Array<Prisma.WorkflowReminderGetPayload<{ select: T }>> = [];
while (true) {
const newFilteredWorkflowReminders = await prisma.workflowReminder.findMany({
where: filter,
select: select,
skip: pageNumber * pageSize,
take: pageSize,
});
if (newFilteredWorkflowReminders.length === 0) {
break;
}
filteredWorkflowReminders.push(
...(newFilteredWorkflowReminders as Array<Prisma.WorkflowReminderGetPayload<{ select: T }>>)
);
pageNumber++;
}
return filteredWorkflowReminders;
}
type RemindersToDeleteType = { referenceId: string | null };
export async function getAllRemindersToDelete(): Promise<RemindersToDeleteType[]> {
const whereFilter: Prisma.WorkflowReminderWhereInput = {
method: WorkflowMethods.EMAIL,
cancelled: true,
scheduledDate: {
lte: dayjs().toISOString(),
},
};
const select: Prisma.WorkflowReminderSelect = {
referenceId: true,
};
const remindersToDelete = await getWorkflowReminders(whereFilter, select);
return remindersToDelete;
}
type RemindersToCancelType = { referenceId: string | null; id: number };
export async function getAllRemindersToCancel(): Promise<RemindersToCancelType[]> {
const whereFilter: Prisma.WorkflowReminderWhereInput = {
cancelled: true,
scheduled: true, //if it is false then they are already cancelled
scheduledDate: {
lte: dayjs().add(1, "hour").toISOString(),
},
};
const select: Prisma.WorkflowReminderSelect = {
referenceId: true,
id: true,
};
const remindersToCancel = await getWorkflowReminders(whereFilter, select);
return remindersToCancel;
}
export const select: Prisma.WorkflowReminderSelect = {
id: true,
scheduledDate: true,
isMandatoryReminder: true,
workflowStep: {
select: {
action: true,
sendTo: true,
reminderBody: true,
emailSubject: true,
template: true,
sender: true,
includeCalendarEvent: true,
workflow: {
select: {
userId: true,
teamId: true,
},
},
},
},
booking: {
select: {
startTime: true,
endTime: true,
location: true,
description: true,
smsReminderNumber: true,
userPrimaryEmail: true,
user: {
select: {
email: true,
name: true,
timeZone: true,
locale: true,
username: true,
timeFormat: true,
hideBranding: true,
},
},
metadata: true,
uid: true,
customInputs: true,
responses: true,
attendees: true,
eventType: {
select: {
bookingFields: true,
title: true,
slug: true,
recurringEvent: true,
team: {
select: {
parentId: true,
},
},
},
},
},
},
};
export async function getAllUnscheduledReminders(): Promise<PartialWorkflowReminder[]> {
const whereFilter: Prisma.WorkflowReminderWhereInput = {
method: WorkflowMethods.EMAIL,
scheduled: false,
scheduledDate: {
lte: dayjs().add(72, "hour").toISOString(),
},
OR: [{ cancelled: false }, { cancelled: null }],
};
const unscheduledReminders = (await getWorkflowReminders(whereFilter, select)) as PartialWorkflowReminder[];
return unscheduledReminders;
}

View File

@@ -0,0 +1,71 @@
import { createEvent } from "ics";
import type { DateArray } from "ics";
import { RRule } from "rrule";
import { v4 as uuidv4 } from "uuid";
import dayjs from "@calcom/dayjs";
import { parseRecurringEvent } from "@calcom/lib";
import type { Prisma, User } from "@calcom/prisma/client";
type Booking = Prisma.BookingGetPayload<{
include: {
eventType: true;
attendees: true;
};
}>;
export function getiCalEventAsString(
booking: Pick<Booking, "startTime" | "endTime" | "description" | "location" | "attendees"> & {
eventType: { recurringEvent?: Prisma.JsonValue; title?: string } | null;
user: Partial<User> | null;
}
) {
let recurrenceRule: string | undefined = undefined;
const recurringEvent = parseRecurringEvent(booking.eventType?.recurringEvent);
if (recurringEvent?.count) {
recurrenceRule = new RRule(recurringEvent).toString().replace("RRULE:", "");
}
const uid = uuidv4();
const icsEvent = createEvent({
uid,
startInputType: "utc",
start: dayjs(booking.startTime.toISOString() || "")
.utc()
.toArray()
.slice(0, 6)
.map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
duration: {
minutes: dayjs(booking.endTime.toISOString() || "").diff(
dayjs(booking.startTime.toISOString() || ""),
"minute"
),
},
title: booking.eventType?.title || "",
description: booking.description || "",
location: booking.location || "",
organizer: {
email: booking.user?.email || "",
name: booking.user?.name || "",
},
attendees: [
{
name: booking.attendees[0].name,
email: booking.attendees[0].email,
partstat: "ACCEPTED",
role: "REQ-PARTICIPANT",
rsvp: true,
},
],
method: "REQUEST",
...{ recurrenceRule },
status: "CONFIRMED",
});
if (icsEvent.error) {
throw icsEvent.error;
}
return icsEvent.value;
}

View File

@@ -0,0 +1,406 @@
import type { MailData } from "@sendgrid/helpers/classes/mail";
import { createEvent } from "ics";
import type { ParticipationStatus } from "ics";
import type { DateArray } from "ics";
import { RRule } from "rrule";
import { v4 as uuidv4 } from "uuid";
import { guessEventLocationType } from "@calcom/app-store/locations";
import dayjs from "@calcom/dayjs";
import { preprocessNameFieldDataWithVariant } from "@calcom/features/form-builder/utils";
import logger from "@calcom/lib/logger";
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 { getBatchId, sendSendgridMail } from "./providers/sendgridProvider";
import type { AttendeeInBookingInfo, BookingInfo, timeUnitLowerCase } from "./smsReminderManager";
import type { VariablesType } from "./templates/customTemplate";
import customTemplate from "./templates/customTemplate";
import emailRatingTemplate from "./templates/emailRatingTemplate";
import emailReminderTemplate from "./templates/emailReminderTemplate";
const log = logger.getSubLogger({ prefix: ["[emailReminderManager]"] });
function getiCalEventAsString(evt: BookingInfo, status?: ParticipationStatus) {
const uid = uuidv4();
let recurrenceRule: string | undefined = undefined;
if (evt.eventType.recurringEvent?.count) {
recurrenceRule = new RRule(evt.eventType.recurringEvent).toString().replace("RRULE:", "");
}
let location = bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl;
if (!location) {
location = guessEventLocationType(location)?.label || evt.location || "";
}
const icsEvent = createEvent({
uid,
startInputType: "utc",
start: dayjs(evt.startTime)
.utc()
.toArray()
.slice(0, 6)
.map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
duration: { minutes: dayjs(evt.endTime).diff(dayjs(evt.startTime), "minute") },
title: evt.title,
description: evt.additionalNotes || "",
location,
organizer: { email: evt.organizer.email || "", name: evt.organizer.name },
attendees: [
{
name: preprocessNameFieldDataWithVariant("fullName", evt.attendees[0].name) as string,
email: evt.attendees[0].email,
partstat: status,
role: "REQ-PARTICIPANT",
rsvp: true,
},
],
method: "REQUEST",
...{ recurrenceRule },
status: "CONFIRMED",
});
if (icsEvent.error) {
throw icsEvent.error;
}
return icsEvent.value;
}
type ScheduleEmailReminderAction = Extract<
WorkflowActions,
"EMAIL_HOST" | "EMAIL_ATTENDEE" | "EMAIL_ADDRESS"
>;
export interface ScheduleReminderArgs {
evt: BookingInfo;
triggerEvent: WorkflowTriggerEvents;
timeSpan: {
time: number | null;
timeUnit: TimeUnit | null;
};
template?: WorkflowTemplates;
sender?: string | null;
workflowStepId?: number;
seatReferenceUid?: string;
}
interface scheduleEmailReminderArgs extends ScheduleReminderArgs {
sendTo: MailData["to"];
action: ScheduleEmailReminderAction;
emailSubject?: string;
emailBody?: string;
hideBranding?: boolean;
includeCalendarEvent?: boolean;
isMandatoryReminder?: boolean;
}
export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) => {
const {
evt,
triggerEvent,
timeSpan,
template,
sender,
workflowStepId,
seatReferenceUid,
sendTo,
emailSubject = "",
emailBody = "",
hideBranding,
includeCalendarEvent,
isMandatoryReminder,
action,
} = args;
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;
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 attendeeEmailToBeUsedInMail: string | null = 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:
//These type checks are required as sendTo is of type MailData["to"] which in turn is of string | {name?:string, email: string} | string | {name?:string, email: string}[0]
// and the email is being sent to the first attendee of event by default instead of the sendTo
// so check if first attendee can be extracted from sendTo -> attendeeEmailToBeUsedInMail
if (typeof sendTo === "string") {
attendeeEmailToBeUsedInMail = sendTo;
} else if (Array.isArray(sendTo)) {
// If it's an array, take the first entry (if it exists) and extract name and email (if object); otherwise, just put the email (if string)
const emailData = sendTo[0];
if (typeof emailData === "object" && emailData !== null) {
const { name, email } = emailData;
attendeeEmailToBeUsedInMail = email;
} else if (typeof emailData === "string") {
attendeeEmailToBeUsedInMail = emailData;
}
} else if (typeof sendTo === "object" && sendTo !== null) {
const { name, email } = sendTo;
attendeeEmailToBeUsedInMail = email;
}
// 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 === attendeeEmailToBeUsedInMail
);
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>`,
};
if (emailBody) {
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: evt.responses,
meetingUrl: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl,
cancelLink: `${evt.bookerUrl}/booking/${evt.uid}?cancel=true`,
rescheduleLink: `${evt.bookerUrl}/reschedule/${evt.uid}`,
ratingUrl: `${evt.bookerUrl}/booking/${evt.uid}?rating`,
noShowUrl: `${evt.bookerUrl}/booking/${evt.uid}?noShow=true`,
};
const locale =
action === WorkflowActions.EMAIL_ATTENDEE
? 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(
false,
action,
evt.organizer.timeFormat,
startTime,
endTime,
evt.title,
timeZone,
attendeeName,
name
);
} else if (template === WorkflowTemplates.RATING) {
emailContent = emailRatingTemplate({
isEditingMode: true,
action,
timeFormat: evt.organizer.timeFormat,
startTime,
endTime,
eventName: evt.title,
timeZone,
organizer: evt.organizer.name,
name,
ratingUrl: `${evt.bookerUrl}/booking/${evt.uid}?rating`,
noShowUrl: `${evt.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 batchId = await getBatchId();
function sendEmail(data: Partial<MailData>, triggerEvent?: WorkflowTriggerEvents) {
const status: ParticipationStatus =
triggerEvent === WorkflowTriggerEvents.AFTER_EVENT
? "COMPLETED"
: triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED
? "DECLINED"
: "ACCEPTED";
return sendSendgridMail(
{
to: data.to,
subject: emailContent.emailSubject,
html: emailContent.emailBody,
batchId,
replyTo: evt.organizer.email,
attachments: includeCalendarEvent
? [
{
content: Buffer.from(getiCalEventAsString(evt, status) || "").toString("base64"),
filename: "event.ics",
type: "text/calendar; method=REQUEST",
disposition: "attachment",
contentId: uuidv4(),
},
]
: undefined,
sendAt: data.sendAt,
},
{ sender }
);
}
if (
triggerEvent === WorkflowTriggerEvents.NEW_EVENT ||
triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED ||
triggerEvent === WorkflowTriggerEvents.RESCHEDULE_EVENT
) {
try {
if (!sendTo) throw new Error("No email addresses provided");
const addressees = Array.isArray(sendTo) ? sendTo : [sendTo];
const promises = addressees.map((email) => sendEmail({ to: email }, triggerEvent));
// TODO: Maybe don't await for this?
await Promise.all(promises);
} catch (error) {
log.error("Error sending Email");
}
} else if (
(triggerEvent === WorkflowTriggerEvents.BEFORE_EVENT ||
triggerEvent === WorkflowTriggerEvents.AFTER_EVENT) &&
scheduledDate
) {
// Sendgrid to schedule emails
// Can only schedule at least 60 minutes and at most 72 hours in advance
if (
currentDate.isBefore(scheduledDate.subtract(1, "hour")) &&
!scheduledDate.isAfter(currentDate.add(72, "hour"))
) {
try {
// If sendEmail failed then workflowReminer will not be created, failing E2E tests
await sendEmail(
{
to: sendTo,
sendAt: scheduledDate.unix(),
},
triggerEvent
);
if (!isMandatoryReminder) {
await prisma.workflowReminder.create({
data: {
bookingUid: uid,
workflowStepId: workflowStepId,
method: WorkflowMethods.EMAIL,
scheduledDate: scheduledDate.toDate(),
scheduled: true,
referenceId: batchId,
seatReferenceId: seatReferenceUid,
},
});
} else {
await prisma.workflowReminder.create({
data: {
bookingUid: uid,
method: WorkflowMethods.EMAIL,
scheduledDate: scheduledDate.toDate(),
scheduled: true,
referenceId: batchId,
seatReferenceId: seatReferenceUid,
isMandatoryReminder: true,
},
});
}
} catch (error) {
log.error(`Error scheduling email with error ${error}`);
}
} else if (scheduledDate.isAfter(currentDate.add(72, "hour"))) {
// Write to DB and send to CRON if scheduled reminder date is past 72 hours
if (!isMandatoryReminder) {
await prisma.workflowReminder.create({
data: {
bookingUid: uid,
workflowStepId: workflowStepId,
method: WorkflowMethods.EMAIL,
scheduledDate: scheduledDate.toDate(),
scheduled: false,
seatReferenceId: seatReferenceUid,
},
});
} else {
await prisma.workflowReminder.create({
data: {
bookingUid: uid,
method: WorkflowMethods.EMAIL,
scheduledDate: scheduledDate.toDate(),
scheduled: false,
seatReferenceId: seatReferenceUid,
isMandatoryReminder: true,
},
});
}
}
}
};
export const deleteScheduledEmailReminder = async (reminderId: number, referenceId: string | null) => {
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}`);
}
};

View File

@@ -0,0 +1,136 @@
import client from "@sendgrid/client";
import type { MailData } from "@sendgrid/helpers/classes/mail";
import sgMail from "@sendgrid/mail";
import { JSDOM } from "jsdom";
import { v4 as uuidv4 } from "uuid";
import { SENDER_NAME } from "@calcom/lib/constants";
import { setTestEmail } from "@calcom/lib/testEmails";
let sendgridAPIKey: string;
let senderEmail: string;
const testMode = process.env.NEXT_PUBLIC_IS_E2E || process.env.INTEGRATION_TEST_MODE;
function assertSendgrid() {
if (process.env.SENDGRID_API_KEY && process.env.SENDGRID_EMAIL) {
sendgridAPIKey = process.env.SENDGRID_API_KEY as string;
senderEmail = process.env.SENDGRID_EMAIL as string;
sgMail.setApiKey(sendgridAPIKey);
client.setApiKey(sendgridAPIKey);
} else {
console.error("Sendgrid credentials are missing from the .env file");
}
}
export async function getBatchId() {
if (testMode) {
return uuidv4();
}
assertSendgrid();
if (!process.env.SENDGRID_API_KEY) {
console.info("No sendgrid API key provided, returning DUMMY_BATCH_ID");
return "DUMMY_BATCH_ID";
}
const batchIdResponse = await client.request({
url: "/v3/mail/batch",
method: "POST",
});
return batchIdResponse[1].batch_id as string;
}
export function sendSendgridMail(
mailData: Partial<MailData>,
addData: { sender?: string | null; includeCalendarEvent?: boolean }
) {
assertSendgrid();
if (testMode) {
if (!mailData.sendAt) {
setTestEmail({
to: mailData.to?.toString() || "",
from: {
email: senderEmail,
name: addData.sender || SENDER_NAME,
},
subject: mailData.subject || "",
html: mailData.html || "",
});
}
console.log(
"Skipped Sending Email as process.env.NEXT_PUBLIC_IS_E2E or process.env.INTEGRATION_TEST_MODE is set. Emails are available in globalThis.testEmails"
);
return new Promise((r) => r("Skipped sendEmail for Unit Tests"));
}
if (!sendgridAPIKey) {
console.info("No sendgrid API key provided, skipping email");
return Promise.resolve();
}
return sgMail.send({
to: mailData.to,
from: {
email: senderEmail,
name: addData.sender || SENDER_NAME,
},
subject: mailData.subject,
html: addHTMLStyles(mailData.html),
batchId: mailData.batchId,
replyTo: mailData.replyTo || senderEmail,
attachments: mailData.attachments,
sendAt: mailData.sendAt,
});
}
export function cancelScheduledEmail(referenceId: string | null) {
if (!referenceId) {
console.info("No referenceId provided, skip canceling email");
return Promise.resolve();
}
assertSendgrid();
return client.request({
url: "/v3/user/scheduled_sends",
method: "POST",
body: {
batch_id: referenceId,
status: "cancel",
},
});
}
export function deleteScheduledSend(referenceId: string | null) {
if (!referenceId) {
console.info("No referenceId provided, skip deleting scheduledSend");
return Promise.resolve();
}
assertSendgrid();
return client.request({
url: `/v3/user/scheduled_sends/${referenceId}`,
method: "DELETE",
});
}
function addHTMLStyles(html?: string) {
if (!html) {
return "";
}
const dom = new JSDOM(html);
const document = dom.window.document;
// Select all <a> tags inside <h6> elements --> only used for emojis in rating template
const links = document.querySelectorAll("h6 a");
links.forEach((link) => {
const htmlLink = link as HTMLElement;
htmlLink.style.fontSize = "20px";
htmlLink.style.textDecoration = "none";
});
return dom.serialize();
}

View File

@@ -0,0 +1,196 @@
import TwilioClient from "twilio";
import { v4 as uuidv4 } from "uuid";
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
import logger from "@calcom/lib/logger";
import { setTestSMS } from "@calcom/lib/testSMS";
import prisma from "@calcom/prisma";
import { SMSLockState } from "@calcom/prisma/enums";
const log = logger.getSubLogger({ prefix: ["[twilioProvider]"] });
const testMode = process.env.NEXT_PUBLIC_IS_E2E || process.env.INTEGRATION_TEST_MODE;
function createTwilioClient() {
if (process.env.TWILIO_SID && process.env.TWILIO_TOKEN && process.env.TWILIO_MESSAGING_SID) {
return TwilioClient(process.env.TWILIO_SID, process.env.TWILIO_TOKEN);
}
throw new Error("Twilio credentials are missing from the .env file");
}
function getDefaultSender(whatsapp = false) {
let defaultSender = process.env.TWILIO_PHONE_NUMBER;
if (whatsapp) {
defaultSender = `whatsapp:+${process.env.TWILIO_WHATSAPP_PHONE_NUMBER}`;
}
return defaultSender || "";
}
function getSMSNumber(phone: string, whatsapp = false) {
return whatsapp ? `whatsapp:${phone}` : phone;
}
export const sendSMS = async (
phoneNumber: string,
body: string,
sender: string,
userId?: number | null,
teamId?: number | null,
whatsapp = false
) => {
const isSMSSendingLocked = await isLockedForSMSSending(userId, teamId);
if (isSMSSendingLocked) {
log.debug(`${teamId ? `Team id ${teamId} ` : `User id ${userId} `} is locked for SMS sending `);
return;
}
if (testMode) {
setTestSMS({
to: getSMSNumber(phoneNumber, whatsapp),
from: whatsapp ? getDefaultSender(whatsapp) : sender ? sender : getDefaultSender(),
message: body,
});
console.log(
"Skipped sending SMS because process.env.NEXT_PUBLIC_IS_E2E or process.env.INTEGRATION_TEST_MODE is set. SMS are available in globalThis.testSMS"
);
return;
}
const twilio = createTwilioClient();
if (!teamId && userId) {
await checkSMSRateLimit({
identifier: `sms:user:${userId}`,
rateLimitingType: "smsMonth",
});
}
const response = await twilio.messages.create({
body: body,
messagingServiceSid: process.env.TWILIO_MESSAGING_SID,
to: getSMSNumber(phoneNumber, whatsapp),
from: whatsapp ? getDefaultSender(whatsapp) : sender ? sender : getDefaultSender(),
});
return response;
};
export const scheduleSMS = async (
phoneNumber: string,
body: string,
scheduledDate: Date,
sender: string,
userId?: number | null,
teamId?: number | null,
whatsapp = false
) => {
const isSMSSendingLocked = await isLockedForSMSSending(userId, teamId);
if (isSMSSendingLocked) {
log.debug(`${teamId ? `Team id ${teamId} ` : `User id ${userId} `} is locked for SMS sending `);
return;
}
if (testMode) {
setTestSMS({
to: getSMSNumber(phoneNumber, whatsapp),
from: whatsapp ? getDefaultSender(whatsapp) : sender ? sender : getDefaultSender(),
message: body,
});
console.log(
"Skipped sending SMS because process.env.NEXT_PUBLIC_IS_E2E or process.env.INTEGRATION_TEST_MODE is set. SMS are available in globalThis.testSMS"
);
return { sid: uuidv4() };
}
const twilio = createTwilioClient();
if (!teamId && userId) {
await checkSMSRateLimit({
identifier: `sms:user:${userId}`,
rateLimitingType: "smsMonth",
});
}
const response = await twilio.messages.create({
body: body,
messagingServiceSid: process.env.TWILIO_MESSAGING_SID,
to: getSMSNumber(phoneNumber, whatsapp),
scheduleType: "fixed",
sendAt: scheduledDate,
from: whatsapp ? getDefaultSender(whatsapp) : sender ? sender : getDefaultSender(),
});
return response;
};
export const cancelSMS = async (referenceId: string) => {
const twilio = createTwilioClient();
await twilio.messages(referenceId).update({ status: "canceled" });
};
export const sendVerificationCode = async (phoneNumber: string) => {
const twilio = createTwilioClient();
if (process.env.TWILIO_VERIFY_SID) {
await twilio.verify
.services(process.env.TWILIO_VERIFY_SID)
.verifications.create({ to: phoneNumber, channel: "sms" });
}
};
export const verifyNumber = async (phoneNumber: string, code: string) => {
const twilio = createTwilioClient();
if (process.env.TWILIO_VERIFY_SID) {
try {
const verification_check = await twilio.verify.v2
.services(process.env.TWILIO_VERIFY_SID)
.verificationChecks.create({ to: phoneNumber, code: code });
return verification_check.status;
} catch (e) {
return "failed";
}
}
};
async function isLockedForSMSSending(userId?: number | null, teamId?: number | null) {
if (teamId) {
const team = await prisma.team.findFirst({
where: {
id: teamId,
},
});
return team?.smsLockState === SMSLockState.LOCKED;
}
if (userId) {
const memberships = await prisma.membership.findMany({
where: {
userId: userId,
},
select: {
team: {
select: {
smsLockState: true,
},
},
},
});
const memberOfLockedTeam = memberships.find(
(membership) => membership.team.smsLockState === SMSLockState.LOCKED
);
if (!!memberOfLockedTeam) {
return true;
}
const user = await prisma.user.findFirst({
where: {
id: userId,
},
});
return user?.smsLockState === SMSLockState.LOCKED;
}
}

View File

@@ -0,0 +1,208 @@
import {
isSMSAction,
isSMSOrWhatsappAction,
isWhatsappAction,
} from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import type { Workflow, WorkflowStep } from "@calcom/features/ee/workflows/lib/types";
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
import { SENDER_NAME } from "@calcom/lib/constants";
import { WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { scheduleEmailReminder } from "./emailReminderManager";
import type { ScheduleTextReminderAction } from "./smsReminderManager";
import { scheduleSMSReminder } from "./smsReminderManager";
import { scheduleWhatsappReminder } from "./whatsappReminderManager";
export type ExtendedCalendarEvent = CalendarEvent & {
metadata?: { videoCallUrl: string | undefined };
eventType: { slug?: string };
};
type ProcessWorkflowStepParams = {
smsReminderNumber: string | null;
calendarEvent: ExtendedCalendarEvent;
emailAttendeeSendToOverride?: string;
hideBranding?: boolean;
seatReferenceUid?: string;
};
export interface ScheduleWorkflowRemindersArgs extends ProcessWorkflowStepParams {
workflows: Workflow[];
isNotConfirmed?: boolean;
isRescheduleEvent?: boolean;
isFirstRecurringEvent?: boolean;
}
const processWorkflowStep = async (
workflow: Workflow,
step: WorkflowStep,
{
smsReminderNumber,
calendarEvent: evt,
emailAttendeeSendToOverride,
hideBranding,
seatReferenceUid,
}: ProcessWorkflowStepParams
) => {
if (isSMSOrWhatsappAction(step.action)) {
await checkSMSRateLimit({
identifier: `sms:${workflow.teamId ? "team:" : "user:"}${workflow.teamId || workflow.userId}`,
rateLimitingType: "sms",
});
}
if (isSMSAction(step.action)) {
const sendTo = step.action === WorkflowActions.SMS_ATTENDEE ? smsReminderNumber : step.sendTo;
await scheduleSMSReminder({
evt,
reminderPhone: sendTo,
triggerEvent: workflow.trigger,
action: step.action as ScheduleTextReminderAction,
timeSpan: {
time: workflow.time,
timeUnit: workflow.timeUnit,
},
message: step.reminderBody || "",
workflowStepId: step.id,
template: step.template,
sender: step.sender,
userId: workflow.userId,
teamId: workflow.teamId,
isVerificationPending: step.numberVerificationPending,
seatReferenceUid,
});
} else if (
step.action === WorkflowActions.EMAIL_ATTENDEE ||
step.action === WorkflowActions.EMAIL_HOST ||
step.action === WorkflowActions.EMAIL_ADDRESS
) {
let sendTo: string[] = [];
switch (step.action) {
case WorkflowActions.EMAIL_ADDRESS:
sendTo = [step.sendTo || ""];
break;
case WorkflowActions.EMAIL_HOST:
sendTo = [evt.organizer?.email || ""];
break;
case WorkflowActions.EMAIL_ATTENDEE:
const attendees = !!emailAttendeeSendToOverride
? [emailAttendeeSendToOverride]
: evt.attendees?.map((attendee) => attendee.email);
sendTo = attendees;
break;
}
await scheduleEmailReminder({
evt,
triggerEvent: workflow.trigger,
action: step.action,
timeSpan: {
time: workflow.time,
timeUnit: workflow.timeUnit,
},
sendTo,
emailSubject: step.emailSubject || "",
emailBody: step.reminderBody || "",
template: step.template,
sender: step.sender || SENDER_NAME,
workflowStepId: step.id,
hideBranding,
seatReferenceUid,
includeCalendarEvent: step.includeCalendarEvent,
});
} else if (isWhatsappAction(step.action)) {
const sendTo = step.action === WorkflowActions.WHATSAPP_ATTENDEE ? smsReminderNumber : step.sendTo;
await scheduleWhatsappReminder({
evt,
reminderPhone: sendTo,
triggerEvent: workflow.trigger,
action: step.action as ScheduleTextReminderAction,
timeSpan: {
time: workflow.time,
timeUnit: workflow.timeUnit,
},
message: step.reminderBody || "",
workflowStepId: step.id,
template: step.template,
userId: workflow.userId,
teamId: workflow.teamId,
isVerificationPending: step.numberVerificationPending,
seatReferenceUid,
});
}
};
export const scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersArgs) => {
const {
workflows,
smsReminderNumber,
calendarEvent: evt,
isNotConfirmed = false,
isRescheduleEvent = false,
isFirstRecurringEvent = true,
emailAttendeeSendToOverride = "",
hideBranding,
seatReferenceUid,
} = args;
if (isNotConfirmed || !workflows.length) return;
for (const workflow of workflows) {
if (workflow.steps.length === 0) continue;
const isNotBeforeOrAfterEvent =
workflow.trigger !== WorkflowTriggerEvents.BEFORE_EVENT &&
workflow.trigger !== WorkflowTriggerEvents.AFTER_EVENT;
if (
isNotBeforeOrAfterEvent &&
// Check if the trigger is not a new event without a reschedule and is the first recurring event.
!(
workflow.trigger === WorkflowTriggerEvents.NEW_EVENT &&
!isRescheduleEvent &&
isFirstRecurringEvent
) &&
// Check if the trigger is not a rescheduled event that is rescheduled.
!(workflow.trigger === WorkflowTriggerEvents.RESCHEDULE_EVENT && isRescheduleEvent)
) {
continue;
}
for (const step of workflow.steps) {
await processWorkflowStep(workflow, step, {
calendarEvent: evt,
emailAttendeeSendToOverride,
smsReminderNumber,
hideBranding,
seatReferenceUid,
});
}
}
};
export interface SendCancelledRemindersArgs {
workflows: Workflow[];
smsReminderNumber: string | null;
evt: ExtendedCalendarEvent;
hideBranding?: boolean;
}
export const sendCancelledReminders = async (args: SendCancelledRemindersArgs) => {
const { smsReminderNumber, evt, workflows, hideBranding } = args;
if (!workflows.length) return;
for (const workflow of workflows) {
if (workflow.trigger !== WorkflowTriggerEvents.EVENT_CANCELLED) continue;
for (const step of workflow.steps) {
processWorkflowStep(workflow, step, {
smsReminderNumber,
hideBranding,
calendarEvent: evt,
});
}
}
};

View File

@@ -0,0 +1,62 @@
import type { getEventTypeResponse } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB";
import { scheduleEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
import type { Workflow } from "@calcom/features/ee/workflows/lib/types";
import type { getDefaultEvent } from "@calcom/lib/defaultEvents";
import logger from "@calcom/lib/logger";
import { WorkflowTriggerEvents, TimeUnit, WorkflowActions, WorkflowTemplates } from "@calcom/prisma/enums";
import type { ExtendedCalendarEvent } from "./reminderScheduler";
const log = logger.getSubLogger({ prefix: ["[scheduleMandatoryReminder]"] });
export type NewBookingEventType = Awaited<ReturnType<typeof getDefaultEvent>> | getEventTypeResponse;
export async function scheduleMandatoryReminder(
evt: ExtendedCalendarEvent,
workflows: Workflow[],
requiresConfirmation: boolean,
hideBranding: boolean,
seatReferenceUid: string | undefined
) {
try {
const hasExistingWorkflow = workflows.some((workflow) => {
return (
workflow.trigger === WorkflowTriggerEvents.BEFORE_EVENT &&
((workflow.time !== null && workflow.time <= 12 && workflow.timeUnit === TimeUnit.HOUR) ||
(workflow.time !== null && workflow.time <= 720 && workflow.timeUnit === TimeUnit.MINUTE)) &&
workflow.steps.some((step) => step?.action === WorkflowActions.EMAIL_ATTENDEE)
);
});
if (
!hasExistingWorkflow &&
evt.attendees.some((attendee) => attendee.email.includes("@gmail.com")) &&
!requiresConfirmation
) {
try {
const filteredAttendees =
evt.attendees?.filter((attendee) => attendee.email.includes("@gmail.com")) || [];
await scheduleEmailReminder({
evt,
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
action: WorkflowActions.EMAIL_ATTENDEE,
timeSpan: {
time: 1,
timeUnit: TimeUnit.HOUR,
},
sendTo: filteredAttendees,
template: WorkflowTemplates.REMINDER,
hideBranding,
seatReferenceUid,
includeCalendarEvent: false,
isMandatoryReminder: true,
});
} catch (error) {
log.error("Error while scheduling mandatory reminders", JSON.stringify({ error }));
}
}
} catch (error) {
log.error("Error while scheduling mandatory reminders", JSON.stringify({ error }));
}
}

View File

@@ -0,0 +1,261 @@
import dayjs from "@calcom/dayjs";
import { SENDER_ID } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import type { TimeFormat } 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 { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import type { CalEventResponses, RecurringEvent } from "@calcom/types/Calendar";
import { getSenderId } from "../alphanumericSenderIdSupport";
import type { ScheduleReminderArgs } from "./emailReminderManager";
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 {
DAY = "day",
MINUTE = "minute",
YEAR = "year",
}
const log = logger.getSubLogger({ prefix: ["[smsReminderManager]"] });
export type AttendeeInBookingInfo = {
name: string;
firstName?: string;
lastName?: string;
email: string;
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;
};
startTime: string;
endTime: string;
title: string;
location?: string | null;
additionalNotes?: string | null;
responses?: CalEventResponses | null;
metadata?: Prisma.JsonValue;
};
export type ScheduleTextReminderAction = Extract<
WorkflowActions,
"SMS_ATTENDEE" | "SMS_NUMBER" | "WHATSAPP_ATTENDEE" | "WHATSAPP_NUMBER"
>;
export interface ScheduleTextReminderArgs extends ScheduleReminderArgs {
reminderPhone: string | null;
message: string;
action: ScheduleTextReminderAction;
userId?: number | null;
teamId?: number | null;
isVerificationPending?: boolean;
prisma?: PrismaClient;
}
export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => {
const {
evt,
reminderPhone,
triggerEvent,
action,
timeSpan,
message = "",
workflowStepId,
template,
sender,
userId,
teamId,
isVerificationPending = false,
seatReferenceUid,
} = args;
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;
const senderID = getSenderId(reminderPhone, sender || SENDER_ID);
//SMS_ATTENDEE action does not need to be verified
//isVerificationPending is from all already existing workflows (once they edit their workflow, they will also have to verify the number)
async function getIsNumberVerified() {
if (action === WorkflowActions.SMS_ATTENDEE) return true;
const verifiedNumber = await prisma.verifiedNumber.findFirst({
where: {
OR: [{ userId }, { teamId }],
phoneNumber: reminderPhone || "",
},
});
if (!!verifiedNumber) return true;
return isVerificationPending;
}
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;
const locale =
action === WorkflowActions.SMS_ATTENDEE
? attendeeToBeUsedInSMS.language?.locale
: evt.organizer.language.locale;
let smsMessage = message;
if (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: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl,
cancelLink: `${evt.bookerUrl}/booking/${evt.uid}?cancel=true`,
rescheduleLink: `${evt.bookerUrl}/reschedule/${evt.uid}`,
};
const customMessage = customTemplate(smsMessage, variables, locale, evt.organizer.timeFormat);
smsMessage = customMessage.text;
} else if (template === WorkflowTemplates.REMINDER) {
smsMessage =
smsReminderTemplate(
false,
action,
evt.organizer.timeFormat,
evt.startTime,
evt.title,
timeZone,
attendeeName,
name
) || message;
}
// Allows debugging generated email content without waiting for sendgrid to send emails
log.debug(`Sending sms for trigger ${triggerEvent}`, smsMessage);
if (smsMessage.length > 0 && reminderPhone && isNumberVerified) {
//send SMS when event is booked/cancelled/rescheduled
if (
triggerEvent === WorkflowTriggerEvents.NEW_EVENT ||
triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED ||
triggerEvent === WorkflowTriggerEvents.RESCHEDULE_EVENT
) {
try {
await twilio.sendSMS(reminderPhone, smsMessage, senderID, userId, teamId);
} catch (error) {
log.error(`Error sending SMS with error ${error}`);
}
} else if (
(triggerEvent === WorkflowTriggerEvents.BEFORE_EVENT ||
triggerEvent === WorkflowTriggerEvents.AFTER_EVENT) &&
scheduledDate
) {
// Can only schedule at least 60 minutes in advance and at most 7 days in advance
if (
currentDate.isBefore(scheduledDate.subtract(1, "hour")) &&
!scheduledDate.isAfter(currentDate.add(7, "day"))
) {
try {
const scheduledSMS = await twilio.scheduleSMS(
reminderPhone,
smsMessage,
scheduledDate.toDate(),
senderID,
userId,
teamId
);
if (scheduledSMS) {
await prisma.workflowReminder.create({
data: {
bookingUid: uid,
workflowStepId: workflowStepId,
method: WorkflowMethods.SMS,
scheduledDate: scheduledDate.toDate(),
scheduled: true,
referenceId: scheduledSMS.sid,
seatReferenceId: seatReferenceUid,
},
});
}
} catch (error) {
log.error(`Error scheduling SMS with error ${error}`);
}
} else if (scheduledDate.isAfter(currentDate.add(7, "day"))) {
// Write to DB and send to CRON if scheduled reminder date is past 7 days
await prisma.workflowReminder.create({
data: {
bookingUid: uid,
workflowStepId: workflowStepId,
method: WorkflowMethods.SMS,
scheduledDate: scheduledDate.toDate(),
scheduled: false,
seatReferenceId: seatReferenceUid,
},
});
}
}
}
};
export const deleteScheduledSMSReminder = async (reminderId: number, referenceId: string | null) => {
try {
if (referenceId) {
await twilio.cancelSMS(referenceId);
}
await prisma.workflowReminder.delete({
where: {
id: reminderId,
},
});
} catch (error) {
log.error(`Error canceling reminder with error ${error}`);
}
};

View File

@@ -0,0 +1,140 @@
import { guessEventLocationType } from "@calcom/app-store/locations";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { APP_NAME } from "@calcom/lib/constants";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalEventResponses } from "@calcom/types/Calendar";
export type VariablesType = {
eventName?: string;
organizerName?: string;
attendeeName?: string;
attendeeFirstName?: string;
attendeeLastName?: string;
attendeeEmail?: string;
eventDate?: Dayjs;
eventEndTime?: Dayjs;
timeZone?: string;
location?: string | null;
additionalNotes?: string | null;
responses?: CalEventResponses | null;
meetingUrl?: string;
cancelLink?: string;
rescheduleLink?: string;
ratingUrl?: string;
noShowUrl?: string;
};
const customTemplate = (
text: string,
variables: VariablesType,
locale: string,
timeFormat?: TimeFormat,
isBrandingDisabled?: boolean
) => {
const translatedDate = new Intl.DateTimeFormat(locale, {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
}).format(variables.eventDate?.add(dayjs().tz(variables.timeZone).utcOffset(), "minute").toDate());
let locationString = variables.location || "";
if (text.includes("{LOCATION}")) {
locationString = guessEventLocationType(locationString)?.label || locationString;
}
const cancelLink = variables.cancelLink ?? "";
const rescheduleLink = variables.rescheduleLink ?? "";
const currentTimeFormat = timeFormat || TimeFormat.TWELVE_HOUR;
const attendeeNameWords = variables.attendeeName?.trim().split(" ");
const attendeeNameWordCount = attendeeNameWords?.length ?? 0;
const attendeeFirstName = variables.attendeeFirstName
? variables.attendeeFirstName
: attendeeNameWords?.[0] ?? "";
const attendeeLastName = variables.attendeeLastName
? variables.attendeeLastName
: attendeeNameWordCount > 1
? attendeeNameWords![attendeeNameWordCount - 1]
: "";
let dynamicText = text
.replaceAll("{EVENT_NAME}", variables.eventName || "")
.replaceAll("{ORGANIZER}", variables.organizerName || "")
.replaceAll("{ATTENDEE}", variables.attendeeName || "")
.replaceAll("{ORGANIZER_NAME}", variables.organizerName || "") //old variable names
.replaceAll("{ATTENDEE_NAME}", variables.attendeeName || "") //old variable names
.replaceAll("{ATTENDEE_FIRST_NAME}", attendeeFirstName)
.replaceAll("{ATTENDEE_LAST_NAME}", attendeeLastName)
.replaceAll("{EVENT_DATE}", translatedDate)
.replaceAll("{EVENT_TIME}", variables.eventDate?.format(currentTimeFormat) || "")
.replaceAll("{START_TIME}", variables.eventDate?.format(currentTimeFormat) || "")
.replaceAll("{EVENT_END_TIME}", variables.eventEndTime?.format(currentTimeFormat) || "")
.replaceAll("{LOCATION}", locationString)
.replaceAll("{ADDITIONAL_NOTES}", variables.additionalNotes || "")
.replaceAll("{ATTENDEE_EMAIL}", variables.attendeeEmail || "")
.replaceAll("{TIMEZONE}", variables.timeZone || "")
.replaceAll("{CANCEL_URL}", cancelLink)
.replaceAll("{RESCHEDULE_URL}", rescheduleLink)
.replaceAll("{MEETING_URL}", variables.meetingUrl || "")
.replaceAll("{RATING_URL}", variables.ratingUrl || "")
.replaceAll("{NO_SHOW_URL}", variables.noShowUrl || "");
const customInputvariables = dynamicText.match(/\{(.+?)}/g)?.map((variable) => {
return variable.replace("{", "").replace("}", "");
});
// event date/time with formatting
customInputvariables?.forEach((variable) => {
if (
variable.startsWith("EVENT_DATE_") ||
variable.startsWith("EVENT_TIME_") ||
variable.startsWith("START_TIME_")
) {
const dateFormat = variable.substring(11, text.length);
const formattedDate = variables.eventDate?.format(dateFormat);
dynamicText = dynamicText.replace(`{${variable}}`, formattedDate || "");
return;
}
if (variable.startsWith("EVENT_END_TIME_")) {
const dateFormat = variable.substring(15, text.length);
const formattedDate = variables.eventEndTime?.format(dateFormat);
dynamicText = dynamicText.replace(`{${variable}}`, formattedDate || "");
return;
}
if (variables.responses) {
Object.keys(variables.responses).forEach((customInput) => {
const formatedToVariable = customInput
.replace(/[^a-zA-Z0-9 ]/g, "")
.trim()
.replaceAll(" ", "_")
.toUpperCase();
if (
variable === formatedToVariable &&
variables.responses &&
variables.responses[customInput as keyof typeof variables.responses].value
) {
dynamicText = dynamicText.replace(
`{${variable}}`,
variables.responses[customInput as keyof typeof variables.responses].value.toString()
);
}
});
}
});
const branding = !isBrandingDisabled ? `<br><br>_<br><br>Scheduling by ${APP_NAME}` : "";
const textHtml = `<body style="white-space: pre-wrap;">${dynamicText}${branding}</body>`;
return { text: dynamicText, html: textHtml };
};
export default customTemplate;

View File

@@ -0,0 +1,79 @@
import dayjs from "@calcom/dayjs";
import { APP_NAME } from "@calcom/lib/constants";
import { TimeFormat } from "@calcom/lib/timeFormat";
import { WorkflowActions } from "@calcom/prisma/enums";
const emailRatingTemplate = ({
isEditingMode,
action,
timeFormat,
startTime,
endTime,
eventName,
timeZone,
organizer,
name,
isBrandingDisabled,
ratingUrl,
noShowUrl,
}: {
isEditingMode: boolean;
action: WorkflowActions;
timeFormat?: TimeFormat;
startTime?: string;
endTime?: string;
eventName?: string;
timeZone?: string;
organizer?: string;
name?: string;
isBrandingDisabled?: boolean;
ratingUrl?: string;
noShowUrl?: string;
}) => {
const currentTimeFormat = timeFormat || TimeFormat.TWELVE_HOUR;
const dateTimeFormat = `ddd, MMM D, YYYY ${currentTimeFormat}`;
let eventDate = "";
if (isEditingMode) {
endTime = "{EVENT_END_TIME}";
eventName = "{EVENT_NAME}";
timeZone = "{TIMEZONE}";
organizer = "{ORGANIZER}";
name = action === WorkflowActions.EMAIL_ATTENDEE ? "{ATTENDEE}" : "{ORGANIZER}";
eventDate = `{EVENT_DATE_${dateTimeFormat}}`;
ratingUrl = "{RATING_URL}";
noShowUrl = "{NO_SHOW_URL}";
} else {
eventDate = dayjs(startTime).tz(timeZone).format(dateTimeFormat);
endTime = dayjs(endTime).tz(timeZone).format(currentTimeFormat);
}
const emailSubject = `How was your recent experience? ${eventName}`;
const introHtml = `<p>Hi${
name ? ` ${name}` : ""
},<br><br>We're always looking to improve our customer's experience. How satisfied were you with your recent meeting?<br></p>`;
const ratingHtml = `<h6><a href="${ratingUrl}=1">😠 </a> <a href="${ratingUrl}=2">🙁 </a> <a href="${ratingUrl}=3">😐 </a> <a href="${ratingUrl}=4">😄 </a> <a href="${ratingUrl}=5">😍</a></h6>`;
const noShowHtml = `${organizer} didn't join the meeting?<a href="${noShowUrl}"> Reschedule here</a><br><br>`;
const eventHtml = `<strong>Event: </strong>${eventName}<br><br>`;
const dateTimeHtml = `<strong>Date & Time: </strong>${eventDate} - ${endTime} (${timeZone})<br><br>`;
const attendeeHtml = `<strong>Attendees: </strong>You & ${organizer}<br><br>`;
const branding =
!isBrandingDisabled && !isEditingMode ? `<div>_<br><br>Scheduling by ${APP_NAME}</div>` : "";
const endingHtml = `This survey was triggered by a Workflow in Cal.${branding}`;
const emailBody = `<body>${introHtml}${ratingHtml}<p>${noShowHtml}${eventHtml}${dateTimeHtml}${attendeeHtml}${endingHtml}</p></body>`;
return { emailSubject, emailBody };
};
export default emailRatingTemplate;

View File

@@ -0,0 +1,57 @@
import dayjs from "@calcom/dayjs";
import { APP_NAME } from "@calcom/lib/constants";
import { TimeFormat } from "@calcom/lib/timeFormat";
import { WorkflowActions } from "@calcom/prisma/enums";
const emailReminderTemplate = (
isEditingMode: boolean,
action?: WorkflowActions,
timeFormat?: TimeFormat,
startTime?: string,
endTime?: string,
eventName?: string,
timeZone?: string,
otherPerson?: string,
name?: string,
isBrandingDisabled?: boolean
) => {
const currentTimeFormat = timeFormat || TimeFormat.TWELVE_HOUR;
const dateTimeFormat = `ddd, MMM D, YYYY ${currentTimeFormat}`;
let eventDate = "";
if (isEditingMode) {
endTime = "{EVENT_END_TIME}";
eventName = "{EVENT_NAME}";
timeZone = "{TIMEZONE}";
otherPerson = action === WorkflowActions.EMAIL_ATTENDEE ? "{ORGANIZER}" : "{ATTENDEE}";
name = action === WorkflowActions.EMAIL_ATTENDEE ? "{ATTENDEE}" : "{ORGANIZER}";
eventDate = `{EVENT_DATE_${dateTimeFormat}}`;
} else {
eventDate = dayjs(startTime).tz(timeZone).format(dateTimeFormat);
endTime = dayjs(endTime).tz(timeZone).format(currentTimeFormat);
}
const emailSubject = `Reminder: ${eventName} - ${eventDate}`;
const introHtml = `<body>Hi${
name ? ` ${name}` : ""
},<br><br>This is a reminder about your upcoming event.<br><br>`;
const eventHtml = `<div><strong class="editor-text-bold">Event: </strong></div>${eventName}<br><br>`;
const dateTimeHtml = `<div><strong class="editor-text-bold">Date & Time: </strong></div>${eventDate} - ${endTime} (${timeZone})<br><br>`;
const attendeeHtml = `<div><strong class="editor-text-bold">Attendees: </strong></div>You & ${otherPerson}<br><br>`;
const branding = !isBrandingDisabled && !isEditingMode ? `<br><br>_<br><br>Scheduling by ${APP_NAME}` : "";
const endingHtml = `This reminder was triggered by a Workflow in Cal.${branding}</body>`;
const emailBody = introHtml + eventHtml + dateTimeHtml + attendeeHtml + endingHtml;
return { emailSubject, emailBody };
};
export default emailReminderTemplate;

View File

@@ -0,0 +1,46 @@
import dayjs from "@calcom/dayjs";
import { TimeFormat } from "@calcom/lib/timeFormat";
import { WorkflowActions } from "@calcom/prisma/enums";
const smsReminderTemplate = (
isEditingMode: boolean,
action?: WorkflowActions,
timeFormat?: TimeFormat,
startTime?: string,
eventName?: string,
timeZone?: string,
attendee?: string,
name?: string
) => {
const currentTimeFormat = timeFormat || TimeFormat.TWELVE_HOUR;
let eventDate;
if (isEditingMode) {
eventName = "{EVENT_NAME}";
timeZone = "{TIMEZONE}";
startTime = `{EVENT_TIME_${currentTimeFormat}}`;
eventDate = "{EVENT_DATE_YYYY MMM D}";
attendee = action === WorkflowActions.SMS_ATTENDEE ? "{ORGANIZER}" : "{ATTENDEE}";
name = action === WorkflowActions.SMS_ATTENDEE ? "{ATTENDEE}" : "{ORGANIZER}";
} else {
eventDate = dayjs(startTime).tz(timeZone).format("YYYY MMM D");
startTime = dayjs(startTime).tz(timeZone).format(currentTimeFormat);
}
const templateOne = `Hi${
name ? ` ${name}` : ``
}, this is a reminder that your meeting (${eventName}) with ${attendee} is on ${eventDate} at ${startTime} ${timeZone}.`;
//Twilio recomments message to be no longer than 320 characters
if (templateOne.length <= 320) return templateOne;
const templateTwo = `Hi, this is a reminder that your meeting with ${attendee} is on ${eventDate} at ${startTime} ${timeZone}`;
//Twilio supports up to 1600 characters
if (templateTwo.length <= 1600) return templateTwo;
return null;
};
export default smsReminderTemplate;

View File

@@ -0,0 +1,4 @@
export * from "./whatsappEventCancelledTemplate";
export * from "./whatsappEventCompletedTemplate";
export * from "./whatsappEventReminderTemplate";
export * from "./whatsappEventRescheduledTemplate";

View File

@@ -0,0 +1,41 @@
import { WorkflowActions } from "@prisma/client";
import dayjs from "@calcom/dayjs";
import { TimeFormat } from "@calcom/lib/timeFormat";
export const whatsappEventCancelledTemplate = (
isEditingMode: boolean,
action?: WorkflowActions,
timeFormat?: TimeFormat,
startTime?: string,
eventName?: string,
timeZone?: string,
attendee?: string,
name?: string
) => {
const currentTimeFormat = timeFormat || TimeFormat.TWELVE_HOUR;
const dateTimeFormat = `ddd, MMM D, YYYY ${currentTimeFormat}`;
let eventDate;
if (isEditingMode) {
eventName = "{EVENT_NAME}";
timeZone = "{TIMEZONE}";
startTime = `{START_TIME_${currentTimeFormat}}`;
eventDate = `{EVENT_DATE_${dateTimeFormat}}`;
attendee = action === WorkflowActions.WHATSAPP_ATTENDEE ? "{ORGANIZER}" : "{ATTENDEE}";
name = action === WorkflowActions.WHATSAPP_ATTENDEE ? "{ATTENDEE}" : "{ORGANIZER}";
} else {
eventDate = dayjs(startTime).tz(timeZone).format("YYYY MMM D");
startTime = dayjs(startTime).tz(timeZone).format(currentTimeFormat);
}
const templateOne = `Hi${
name ? ` ${name}` : ``
}, your meeting (*${eventName}*) with ${attendee} on ${eventDate} at ${startTime} ${timeZone} has been canceled.`;
//Twilio supports up to 1024 characters for whatsapp template messages
if (templateOne.length <= 1024) return templateOne;
return null;
};

View File

@@ -0,0 +1,41 @@
import { WorkflowActions } from "@prisma/client";
import dayjs from "@calcom/dayjs";
import { TimeFormat } from "@calcom/lib/timeFormat";
export const whatsappEventCompletedTemplate = (
isEditingMode: boolean,
action?: WorkflowActions,
timeFormat?: TimeFormat,
startTime?: string,
eventName?: string,
timeZone?: string,
attendee?: string,
name?: string
) => {
const currentTimeFormat = timeFormat || TimeFormat.TWELVE_HOUR;
const dateTimeFormat = `ddd, MMM D, YYYY ${currentTimeFormat}`;
let eventDate;
if (isEditingMode) {
eventName = "{EVENT_NAME}";
timeZone = "{TIMEZONE}";
startTime = `{START_TIME_${currentTimeFormat}}`;
eventDate = `{EVENT_DATE_${dateTimeFormat}}`;
attendee = action === WorkflowActions.WHATSAPP_ATTENDEE ? "{ORGANIZER}" : "{ATTENDEE}";
name = action === WorkflowActions.WHATSAPP_ATTENDEE ? "{ATTENDEE}" : "{ORGANIZER}";
} else {
eventDate = dayjs(startTime).tz(timeZone).format("YYYY MMM D");
startTime = dayjs(startTime).tz(timeZone).format(currentTimeFormat);
}
const templateOne = `Hi${
name ? ` ${name}` : ``
}, thank you for attending the event (*${eventName}*) on ${eventDate} at ${startTime} ${timeZone}.`;
//Twilio supports up to 1024 characters for whatsapp template messages
if (templateOne.length <= 1024) return templateOne;
return null;
};

View File

@@ -0,0 +1,41 @@
import { WorkflowActions } from "@prisma/client";
import dayjs from "@calcom/dayjs";
import { TimeFormat } from "@calcom/lib/timeFormat";
export const whatsappReminderTemplate = (
isEditingMode: boolean,
action?: WorkflowActions,
timeFormat?: TimeFormat,
startTime?: string,
eventName?: string,
timeZone?: string,
attendee?: string,
name?: string
) => {
const currentTimeFormat = timeFormat || TimeFormat.TWELVE_HOUR;
const dateTimeFormat = `ddd, MMM D, YYYY ${currentTimeFormat}`;
let eventDate;
if (isEditingMode) {
eventName = "{EVENT_NAME}";
timeZone = "{TIMEZONE}";
startTime = `{START_TIME_${currentTimeFormat}}`;
eventDate = `{EVENT_DATE_${dateTimeFormat}}`;
attendee = action === WorkflowActions.WHATSAPP_ATTENDEE ? "{ORGANIZER}" : "{ATTENDEE}";
name = action === WorkflowActions.WHATSAPP_ATTENDEE ? "{ATTENDEE}" : "{ORGANIZER}";
} else {
eventDate = dayjs(startTime).tz(timeZone).format("YYYY MMM D");
startTime = dayjs(startTime).tz(timeZone).format(currentTimeFormat);
}
const templateOne = `Hi${
name ? ` ${name}` : ``
}, this is a reminder that your meeting (*${eventName}*) with ${attendee} is on ${eventDate} at ${startTime} ${timeZone}.`;
//Twilio supports up to 1024 characters for whatsapp template messages
if (templateOne.length <= 1024) return templateOne;
return null;
};

View File

@@ -0,0 +1,41 @@
import { WorkflowActions } from "@prisma/client";
import dayjs from "@calcom/dayjs";
import { TimeFormat } from "@calcom/lib/timeFormat";
export const whatsappEventRescheduledTemplate = (
isEditingMode: boolean,
action?: WorkflowActions,
timeFormat?: TimeFormat,
startTime?: string,
eventName?: string,
timeZone?: string,
attendee?: string,
name?: string
) => {
const currentTimeFormat = timeFormat || TimeFormat.TWELVE_HOUR;
const dateTimeFormat = `ddd, MMM D, YYYY ${currentTimeFormat}`;
let eventDate;
if (isEditingMode) {
eventName = "{EVENT_NAME}";
timeZone = "{TIMEZONE}";
startTime = `{START_TIME_${currentTimeFormat}}`;
eventDate = `{EVENT_DATE_${dateTimeFormat}}`;
attendee = action === WorkflowActions.WHATSAPP_ATTENDEE ? "{ORGANIZER}" : "{ATTENDEE}";
name = action === WorkflowActions.WHATSAPP_ATTENDEE ? "{ATTENDEE}" : "{ORGANIZER}";
} else {
eventDate = dayjs(startTime).tz(timeZone).format("YYYY MMM D");
startTime = dayjs(startTime).tz(timeZone).format(currentTimeFormat);
}
const templateOne = `Hi${
name ? ` ${name}` : ``
}, your meeting (*${eventName}*) with ${attendee} on ${eventDate} at ${startTime} ${timeZone} has been rescheduled.`;
//Twilio supports up to 1024 characters for whatsapp template messages
if (templateOne.length <= 1024) return templateOne;
return null;
};

View File

@@ -0,0 +1,30 @@
import prisma from "@calcom/prisma";
import * as twilio from "./providers/twilioProvider";
export const sendVerificationCode = async (phoneNumber: string) => {
return twilio.sendVerificationCode(phoneNumber);
};
export const verifyPhoneNumber = async (
phoneNumber: string,
code: string,
userId?: number,
teamId?: number
) => {
if (!userId && !teamId) return true;
const verificationStatus = await twilio.verifyNumber(phoneNumber, code);
if (verificationStatus === "approved") {
await prisma.verifiedNumber.create({
data: {
userId,
teamId,
phoneNumber,
},
});
return true;
}
return false;
};

View File

@@ -0,0 +1,209 @@
import dayjs from "@calcom/dayjs";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import {
WorkflowTriggerEvents,
WorkflowTemplates,
WorkflowActions,
WorkflowMethods,
} from "@calcom/prisma/enums";
import * as twilio from "./providers/twilioProvider";
import type { ScheduleTextReminderArgs, timeUnitLowerCase } from "./smsReminderManager";
import { deleteScheduledSMSReminder } from "./smsReminderManager";
import {
whatsappEventCancelledTemplate,
whatsappEventCompletedTemplate,
whatsappEventRescheduledTemplate,
whatsappReminderTemplate,
} from "./templates/whatsapp";
const log = logger.getSubLogger({ prefix: ["[whatsappReminderManager]"] });
export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs) => {
const {
evt,
reminderPhone,
triggerEvent,
action,
timeSpan,
message = "",
workflowStepId,
template,
userId,
teamId,
isVerificationPending = false,
seatReferenceUid,
} = args;
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;
//WHATSAPP_ATTENDEE action does not need to be verified
//isVerificationPending is from all already existing workflows (once they edit their workflow, they will also have to verify the number)
async function getIsNumberVerified() {
if (action === WorkflowActions.WHATSAPP_ATTENDEE) return true;
const verifiedNumber = await prisma.verifiedNumber.findFirst({
where: {
OR: [{ userId }, { teamId }],
phoneNumber: reminderPhone || "",
},
});
if (!!verifiedNumber) return true;
return isVerificationPending;
}
const isNumberVerified = await getIsNumberVerified();
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.WHATSAPP_ATTENDEE ? evt.attendees[0].name : evt.organizer.name;
const attendeeName =
action === WorkflowActions.WHATSAPP_ATTENDEE ? evt.organizer.name : evt.attendees[0].name;
const timeZone =
action === WorkflowActions.WHATSAPP_ATTENDEE ? evt.attendees[0].timeZone : evt.organizer.timeZone;
let textMessage = message;
switch (template) {
case WorkflowTemplates.REMINDER:
textMessage =
whatsappReminderTemplate(
false,
action,
evt.organizer.timeFormat,
evt.startTime,
evt.title,
timeZone,
attendeeName,
name
) || message;
break;
case WorkflowTemplates.CANCELLED:
textMessage =
whatsappEventCancelledTemplate(
false,
action,
evt.organizer.timeFormat,
evt.startTime,
evt.title,
timeZone,
attendeeName,
name
) || message;
break;
case WorkflowTemplates.RESCHEDULED:
textMessage =
whatsappEventRescheduledTemplate(
false,
action,
evt.organizer.timeFormat,
evt.startTime,
evt.title,
timeZone,
attendeeName,
name
) || message;
break;
case WorkflowTemplates.COMPLETED:
textMessage =
whatsappEventCompletedTemplate(
false,
action,
evt.organizer.timeFormat,
evt.startTime,
evt.title,
timeZone,
attendeeName,
name
) || message;
break;
default:
textMessage =
whatsappReminderTemplate(
false,
action,
evt.organizer.timeFormat,
evt.startTime,
evt.title,
timeZone,
attendeeName,
name
) || message;
}
// Allows debugging generated whatsapp content without waiting for twilio to send whatsapp messages
log.debug(`Sending Whatsapp for trigger ${triggerEvent}`, textMessage);
if (textMessage.length > 0 && reminderPhone && isNumberVerified) {
//send WHATSAPP when event is booked/cancelled/rescheduled
if (
triggerEvent === WorkflowTriggerEvents.NEW_EVENT ||
triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED ||
triggerEvent === WorkflowTriggerEvents.RESCHEDULE_EVENT
) {
try {
await twilio.sendSMS(reminderPhone, textMessage, "", userId, teamId, true);
} catch (error) {
console.log(`Error sending WHATSAPP with error ${error}`);
}
} else if (
(triggerEvent === WorkflowTriggerEvents.BEFORE_EVENT ||
triggerEvent === WorkflowTriggerEvents.AFTER_EVENT) &&
scheduledDate
) {
// Can only schedule at least 60 minutes in advance and at most 7 days in advance
if (
currentDate.isBefore(scheduledDate.subtract(1, "hour")) &&
!scheduledDate.isAfter(currentDate.add(7, "day"))
) {
try {
const scheduledWHATSAPP = await twilio.scheduleSMS(
reminderPhone,
textMessage,
scheduledDate.toDate(),
"",
userId,
teamId,
true
);
if (scheduledWHATSAPP) {
await prisma.workflowReminder.create({
data: {
bookingUid: uid,
workflowStepId: workflowStepId,
method: WorkflowMethods.WHATSAPP,
scheduledDate: scheduledDate.toDate(),
scheduled: true,
referenceId: scheduledWHATSAPP.sid,
seatReferenceId: seatReferenceUid,
},
});
}
} catch (error) {
console.log(`Error scheduling WHATSAPP with error ${error}`);
}
} else if (scheduledDate.isAfter(currentDate.add(7, "day"))) {
// Write to DB and send to CRON if scheduled reminder date is past 7 days
await prisma.workflowReminder.create({
data: {
bookingUid: uid,
workflowStepId: workflowStepId,
method: WorkflowMethods.WHATSAPP,
scheduledDate: scheduledDate.toDate(),
scheduled: false,
seatReferenceId: seatReferenceUid,
},
});
}
}
}
};
export const deleteScheduledWhatsappReminder = deleteScheduledSMSReminder;

View File

@@ -0,0 +1,857 @@
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
import {
getOrganizer,
getScenarioData,
TestData,
createBookingScenario,
createOrganization,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import {
expectSMSWorkflowToBeTriggered,
expectSMSWorkflowToBeNotTriggered,
} from "@calcom/web/test/utils/bookingScenario/expects";
import { describe, expect, beforeAll, vi } from "vitest";
import dayjs from "@calcom/dayjs";
import { BookingStatus, WorkflowMethods, TimeUnit } from "@calcom/prisma/enums";
import {
deleteRemindersOfActiveOnIds,
scheduleBookingReminders,
bookingSelect,
} from "@calcom/trpc/server/routers/viewer/workflows/util";
import { test } from "@calcom/web/test/fixtures/fixtures";
import { deleteWorkfowRemindersOfRemovedMember } from "../../../teams/lib/deleteWorkflowRemindersOfRemovedMember";
const workflowSelect = {
id: true,
userId: true,
isActiveOnAll: true,
trigger: true,
time: true,
timeUnit: true,
team: {
select: {
isOrganization: true,
},
},
teamId: true,
user: {
select: {
teams: true,
},
},
steps: true,
activeOn: true,
activeOnTeams: true,
};
beforeAll(() => {
vi.setSystemTime(new Date("2024-05-20T11:59:59Z"));
});
const mockEventTypes = [
{
id: 1,
slotInterval: 30,
length: 30,
useEventTypeDestinationCalendarEmail: true,
owner: 101,
users: [
{
id: 101,
},
],
},
{
id: 2,
slotInterval: 30,
length: 30,
useEventTypeDestinationCalendarEmail: true,
owner: 101,
users: [
{
id: 101,
},
],
},
];
const mockBookings = [
{
uid: "jK7Rf8iYsOpmQUw9hB1vZxP",
eventTypeId: 1,
userId: 101,
status: BookingStatus.ACCEPTED,
startTime: `2024-05-22T04:00:00.000Z`,
endTime: `2024-05-22T04:30:00.000Z`,
attendees: [{ email: "attendee@example.com" }],
},
{
uid: "mL4Dx9jTkQbnWEu3pR7yNcF",
eventTypeId: 1,
userId: 101,
status: BookingStatus.ACCEPTED,
startTime: `2024-05-23T04:00:00.000Z`,
endTime: `2024-05-23T04:30:00.000Z`,
attendees: [{ email: "attendee@example.com" }],
},
{
uid: "Fd9Rf8iYsOpmQUw9hB1vKd8",
eventTypeId: 2,
userId: 101,
status: BookingStatus.ACCEPTED,
startTime: `2024-06-01T04:30:00.000Z`,
endTime: `2024-06-01T05:00:00.000Z`,
attendees: [{ email: "attendee@example.com" }],
},
{
uid: "Kd8Dx9jTkQbnWEu3pR7yKdl",
eventTypeId: 2,
userId: 101,
status: BookingStatus.ACCEPTED,
startTime: `2024-06-02T04:30:00.000Z`,
endTime: `2024-06-02T05:00:00.000Z`,
attendees: [{ email: "attendee@example.com" }],
},
];
async function createWorkflowRemindersForWorkflow(workflowName: string) {
const workflow = await prismock.workflow.findFirst({
where: {
name: workflowName,
},
select: {
steps: {
select: {
id: true,
stepNumber: true,
action: true,
workflowId: true,
sendTo: true,
reminderBody: true,
emailSubject: true,
template: true,
numberRequired: true,
sender: true,
numberVerificationPending: true,
includeCalendarEvent: true,
},
},
},
});
const workflowRemindersData = [
{
booking: {
connect: {
bookingUid: "jK7Rf8iYsOpmQUw9hB1vZxP",
},
},
bookingUid: "jK7Rf8iYsOpmQUw9hB1vZxP",
workflowStepId: workflow?.steps[0]?.id,
method: WorkflowMethods.EMAIL,
scheduledDate: `2024-05-22T06:00:00.000Z`,
scheduled: false,
retryCount: 0,
},
{
booking: {
connect: {
bookingUid: "mL4Dx9jTkQbnWEu3pR7yNcF",
},
},
bookingUid: "mL4Dx9jTkQbnWEu3pR7yNcF",
workflowStepId: workflow?.steps[0]?.id,
method: WorkflowMethods.EMAIL,
scheduledDate: `2024-05-22T06:30:00.000Z`,
scheduled: false,
retryCount: 0,
},
{
booking: {
connect: {
bookingUid: "Fd9Rf8iYsOpmQUw9hB1vKd8",
},
},
bookingUid: "Fd9Rf8iYsOpmQUw9hB1vKd8",
workflowStepId: workflow?.steps[0]?.id,
method: WorkflowMethods.EMAIL,
scheduledDate: `2024-05-22T06:30:00.000Z`,
scheduled: false,
retryCount: 0,
},
{
booking: {
connect: {
bookingUid: "Kd8Dx9jTkQbnWEu3pR7yKdl",
},
},
bookingUid: "Kd8Dx9jTkQbnWEu3pR7yKdl",
workflowStepId: workflow?.steps[0]?.id,
method: WorkflowMethods.EMAIL,
scheduledDate: `2024-05-22T06:30:00.000Z`,
scheduled: false,
retryCount: 0,
},
];
for (const data of workflowRemindersData) {
await prismock.workflowReminder.create({
data,
});
}
return workflow;
}
describe("deleteRemindersOfActiveOnIds", () => {
test("should delete all reminders from removed event types", async ({}) => {
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
});
await createBookingScenario(
getScenarioData({
workflows: [
{
name: "User Workflow",
userId: organizer.id,
trigger: "BEFORE_EVENT",
time: 1,
timeUnit: TimeUnit.HOUR,
action: "EMAIL_HOST",
template: "REMINDER",
activeOn: [1],
},
],
eventTypes: mockEventTypes,
bookings: mockBookings,
organizer,
})
);
const workflow = await createWorkflowRemindersForWorkflow("User Workflow");
const removedActiveOnIds = [1];
const activeOnIds = [2];
await deleteRemindersOfActiveOnIds({
removedActiveOnIds,
workflowSteps: workflow?.steps || [],
isOrg: false,
activeOnIds,
});
const workflowReminders = await prismock.workflowReminder.findMany({
select: {
booking: {
select: {
eventTypeId: true,
},
},
},
});
expect(workflowReminders.filter((reminder) => reminder.booking?.eventTypeId === 1).length).toBe(0);
expect(workflowReminders.filter((reminder) => reminder.booking?.eventTypeId === 2).length).toBe(2);
});
test("should delete all reminders from removed event types (org workflow)", async ({}) => {
const org = await createOrganization({
name: "Test Org",
slug: "testorg",
withTeam: true,
});
// organizer is part of org and two teams
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
defaultScheduleId: null,
organizationId: org.id,
teams: [
{
membership: {
accepted: true,
},
team: {
id: 3,
name: "Team 1",
slug: "team-1",
parentId: org.id,
},
},
{
membership: {
accepted: true,
},
team: {
id: 4,
name: "Team 2",
slug: "team-2",
parentId: org.id,
},
},
],
schedules: [TestData.schedules.IstMorningShift],
});
await createBookingScenario(
getScenarioData({
workflows: [
{
name: "Org Workflow",
teamId: 1,
trigger: "BEFORE_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeOnTeams: [2, 3, 4],
},
],
eventTypes: mockEventTypes,
bookings: mockBookings,
organizer,
})
);
const workflow = await createWorkflowRemindersForWorkflow("Org Workflow");
let removedActiveOnIds = [1];
const activeOnIds = [2];
//workflow removed from team 2, but still acitve on team 3 --> so reminder should not be removed
await deleteRemindersOfActiveOnIds({
removedActiveOnIds,
workflowSteps: workflow?.steps || [],
isOrg: true,
activeOnIds,
});
// get all reminders from organizer's bookings
const workflowRemindersWithOneTeamActive = await prismock.workflowReminder.findMany({
where: {
booking: {
userId: organizer.id,
},
},
});
removedActiveOnIds = [3];
// should still be active on all 4 bookings
expect(workflowRemindersWithOneTeamActive.length).toBe(4);
await deleteRemindersOfActiveOnIds({
removedActiveOnIds,
workflowSteps: workflow?.steps || [],
isOrg: true,
activeOnIds,
});
const workflowRemindersWithNoTeamActive = await prismock.workflowReminder.findMany({
where: {
booking: {
userId: organizer.id,
},
},
});
expect(workflowRemindersWithNoTeamActive.length).toBe(0);
});
});
describe("scheduleBookingReminders", () => {
test("schedules workflow notifications with before event trigger and email to host action", async ({}) => {
// organizer is part of org and two teams
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
defaultScheduleId: null,
schedules: [TestData.schedules.IstMorningShift],
});
await createBookingScenario(
getScenarioData({
workflows: [
{
name: "Workflow",
userId: 101,
trigger: "BEFORE_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeOn: [],
time: 1,
timeUnit: TimeUnit.HOUR,
},
],
eventTypes: mockEventTypes,
bookings: mockBookings,
organizer,
})
);
const workflow = await prismock.workflow.findFirst({
select: workflowSelect,
});
const bookings = await prismock.booking.findMany({
where: {
userId: organizer.id,
},
select: bookingSelect,
});
expect(workflow).not.toBeNull();
if (!workflow) return;
await scheduleBookingReminders(
bookings,
workflow.steps,
workflow.time,
workflow.timeUnit,
workflow.trigger,
organizer.id,
null //teamId
);
const scheduledWorkflowReminders = await prismock.workflowReminder.findMany({
where: {
workflowStep: {
workflowId: workflow.id,
},
},
});
scheduledWorkflowReminders.sort((a, b) =>
dayjs(a.scheduledDate).isBefore(dayjs(b.scheduledDate)) ? -1 : 1
);
const expectedScheduledDates = [
new Date("2024-05-22T03:00:00.000"),
new Date("2024-05-23T03:00:00.000Z"),
new Date("2024-06-01T03:30:00.000Z"),
new Date("2024-06-02T03:30:00.000Z"),
];
scheduledWorkflowReminders.forEach((reminder, index) => {
expect(expectedScheduledDates[index]).toStrictEqual(reminder.scheduledDate);
expect(reminder.method).toBe(WorkflowMethods.EMAIL);
if (index < 2) {
expect(reminder.scheduled).toBe(true);
} else {
expect(reminder.scheduled).toBe(false);
}
});
});
test("schedules workflow notifications with after event trigger and email to host action", async ({}) => {
// organizer is part of org and two teams
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
defaultScheduleId: null,
schedules: [TestData.schedules.IstMorningShift],
});
await createBookingScenario(
getScenarioData({
workflows: [
{
name: "Workflow",
userId: 101,
trigger: "AFTER_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeOn: [],
time: 1,
timeUnit: TimeUnit.HOUR,
},
],
eventTypes: mockEventTypes,
bookings: mockBookings,
organizer,
})
);
const workflow = await prismock.workflow.findFirst({
select: workflowSelect,
});
const bookings = await prismock.booking.findMany({
where: {
userId: organizer.id,
},
select: bookingSelect,
});
expect(workflow).not.toBeNull();
if (!workflow) return;
await scheduleBookingReminders(
bookings,
workflow.steps,
workflow.time,
workflow.timeUnit,
workflow.trigger,
organizer.id,
null //teamId
);
const scheduledWorkflowReminders = await prismock.workflowReminder.findMany({
where: {
workflowStep: {
workflowId: workflow.id,
},
},
});
scheduledWorkflowReminders.sort((a, b) =>
dayjs(a.scheduledDate).isBefore(dayjs(b.scheduledDate)) ? -1 : 1
);
const expectedScheduledDates = [
new Date("2024-05-22T05:30:00.000"),
new Date("2024-05-23T05:30:00.000Z"),
new Date("2024-06-01T06:00:00.000Z"),
new Date("2024-06-02T06:00:00.000Z"),
];
scheduledWorkflowReminders.forEach((reminder, index) => {
expect(expectedScheduledDates[index]).toStrictEqual(reminder.scheduledDate);
expect(reminder.method).toBe(WorkflowMethods.EMAIL);
if (index < 2) {
expect(reminder.scheduled).toBe(true);
} else {
expect(reminder.scheduled).toBe(false);
}
});
});
test("send sms to specific number for bookings", async ({ sms }) => {
// organizer is part of org and two teams
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
defaultScheduleId: null,
schedules: [TestData.schedules.IstMorningShift],
});
await createBookingScenario(
getScenarioData({
workflows: [
{
name: "Workflow",
userId: 101,
trigger: "BEFORE_EVENT",
action: "SMS_NUMBER",
template: "REMINDER",
activeOn: [],
time: 3,
timeUnit: TimeUnit.HOUR,
sendTo: "000",
},
],
eventTypes: mockEventTypes,
bookings: mockBookings,
organizer,
})
);
const workflow = await prismock.workflow.findFirst({
select: workflowSelect,
});
const bookings = await prismock.booking.findMany({
where: {
userId: organizer.id,
},
select: bookingSelect,
});
expect(workflow).not.toBeNull();
if (!workflow) return;
await scheduleBookingReminders(
bookings,
workflow.steps,
workflow.time,
workflow.timeUnit,
workflow.trigger,
organizer.id,
null //teamId
);
// number is not verified, so sms should not send
expectSMSWorkflowToBeNotTriggered({
sms,
toNumber: "000",
});
await prismock.verifiedNumber.create({
data: {
userId: organizer.id,
phoneNumber: "000",
},
});
const allVerified = await prismock.verifiedNumber.findMany();
await scheduleBookingReminders(
bookings,
workflow.steps,
workflow.time,
workflow.timeUnit,
workflow.trigger,
organizer.id,
null //teamId
);
// two sms schould be scheduled
expectSMSWorkflowToBeTriggered({
sms,
toNumber: "000",
includedString: "2024 May 22 at 9:30am Asia/Kolkata",
});
expectSMSWorkflowToBeTriggered({
sms,
toNumber: "000",
includedString: "2024 May 23 at 9:30am Asia/Kolkata",
});
// sms are too far in future
expectSMSWorkflowToBeNotTriggered({
sms,
toNumber: "000",
includedString: "2024 June 1 at 10:00am Asia/Kolkata",
});
expectSMSWorkflowToBeNotTriggered({
sms,
toNumber: "000",
includedString: "2024 June 2 at 10:00am Asia/Kolkata",
});
const scheduledWorkflowReminders = await prismock.workflowReminder.findMany({
where: {
workflowStep: {
workflowId: workflow.id,
},
},
});
scheduledWorkflowReminders.sort((a, b) =>
dayjs(a.scheduledDate).isBefore(dayjs(b.scheduledDate)) ? -1 : 1
);
const expectedScheduledDates = [
new Date("2024-05-22T01:00:00.000"),
new Date("2024-05-23T01:00:00.000Z"),
new Date("2024-06-01T01:30:00.000Z"),
new Date("2024-06-02T01:30:00.000Z"),
];
scheduledWorkflowReminders.forEach((reminder, index) => {
expect(expectedScheduledDates[index]).toStrictEqual(reminder.scheduledDate);
expect(reminder.method).toBe(WorkflowMethods.SMS);
if (index < 2) {
expect(reminder.scheduled).toBe(true);
} else {
expect(reminder.scheduled).toBe(false);
}
});
});
});
describe("deleteWorkfowRemindersOfRemovedMember", () => {
test("deletes all workflow reminders when member is removed from org", async ({}) => {
const org = await createOrganization({
name: "Test Org",
slug: "testorg",
withTeam: true,
});
// organizer is part of org and two teams
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
defaultScheduleId: null,
organizationId: org.id,
teams: [
{
membership: {
accepted: true,
},
team: {
id: 3,
name: "Team 1",
slug: "team-1",
parentId: org.id,
},
},
{
membership: {
accepted: true,
},
team: {
id: 4,
name: "Team 2",
slug: "team-2",
parentId: org.id,
},
},
],
schedules: [TestData.schedules.IstMorningShift],
});
await createBookingScenario(
getScenarioData({
workflows: [
{
name: "Org Workflow",
teamId: 1,
trigger: "BEFORE_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeOnTeams: [2, 3, 4],
},
],
eventTypes: mockEventTypes,
bookings: mockBookings,
organizer,
})
);
await createWorkflowRemindersForWorkflow("Org Workflow");
await deleteWorkfowRemindersOfRemovedMember(org, 101, true);
const workflowReminders = await prismock.workflowReminder.findMany();
expect(workflowReminders.length).toBe(0);
});
test("deletes reminders if member is removed from an org team ", async ({}) => {
const org = await createOrganization({
name: "Test Org",
slug: "testorg",
withTeam: true,
});
// organizer is part of org and two teams
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
defaultScheduleId: null,
organizationId: org.id,
teams: [
{
membership: {
accepted: true,
},
team: {
id: 2,
name: "Team 1",
slug: "team-1",
parentId: org.id,
},
},
{
membership: {
accepted: true,
},
team: {
id: 3,
name: "Team 2",
slug: "team-2",
parentId: org.id,
},
},
{
membership: {
accepted: true,
},
team: {
id: 4,
name: "Team 3",
slug: "team-3",
parentId: org.id,
},
},
],
schedules: [TestData.schedules.IstMorningShift],
});
await createBookingScenario(
getScenarioData({
workflows: [
{
name: "Org Workflow 1",
teamId: 1,
trigger: "BEFORE_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeOnTeams: [2, 3, 4],
},
{
name: "Org Workflow 2",
teamId: 1,
trigger: "BEFORE_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeOnTeams: [2],
},
],
eventTypes: mockEventTypes,
bookings: mockBookings,
organizer,
})
);
await createWorkflowRemindersForWorkflow("Org Workflow 1");
await createWorkflowRemindersForWorkflow("Org Workflow 2");
const tes = await prismock.membership.findMany();
await prismock.membership.delete({
where: {
userId: 101,
teamId: 2,
},
});
await deleteWorkfowRemindersOfRemovedMember({ id: 2, parentId: org.id }, 101, false);
const workflowReminders = await prismock.workflowReminder.findMany({
select: {
workflowStep: {
select: {
workflow: {
select: {
name: true,
},
},
},
},
},
});
const workflow1Reminders = workflowReminders.filter(
(reminder) => reminder.workflowStep?.workflow.name === "Org Workflow 1"
);
const workflow2Reminders = workflowReminders.filter(
(reminder) => reminder.workflowStep?.workflow.name === "Org Workflow 2"
);
expect(workflow1Reminders.length).toBe(4);
expect(workflow2Reminders.length).toBe(0);
});
});

View File

@@ -0,0 +1,30 @@
import type {
TimeUnit,
WorkflowActions,
WorkflowTemplates,
WorkflowTriggerEvents,
} from "@calcom/prisma/enums";
export type Workflow = {
id: number;
name: string;
trigger: WorkflowTriggerEvents;
time: number | null;
timeUnit: TimeUnit | null;
userId: number | null;
teamId: number | null;
steps: WorkflowStep[];
};
export type WorkflowStep = {
action: WorkflowActions;
sendTo: string | null;
template: WorkflowTemplates;
reminderBody: string | null;
emailSubject: string | null;
id: number;
sender: string | null;
includeCalendarEvent: boolean;
numberVerificationPending: boolean;
numberRequired: boolean | null;
};

View File

@@ -0,0 +1,82 @@
import type { TFunction } from "next-i18next";
import { DYNAMIC_TEXT_VARIABLES, FORMATTED_DYNAMIC_TEXT_VARIABLES } from "./constants";
// variables are saved in the db always in english, so here we translate them to the user's language
export function getTranslatedText(text: string, language: { locale: string; t: TFunction }) {
let translatedText = text;
if (language.locale !== "en") {
const variables = text.match(/\{(.+?)}/g)?.map((variable) => {
return variable.replace("{", "").replace("}", "");
});
variables?.forEach((variable) => {
const regex = new RegExp(`{${variable}}`, "g"); // .replaceAll is not available here for some reason
let translatedVariable = DYNAMIC_TEXT_VARIABLES.includes(variable.toLowerCase())
? language.t(variable.toLowerCase().concat("_variable")).replace(/ /g, "_").toLocaleUpperCase()
: DYNAMIC_TEXT_VARIABLES.includes(variable.toLowerCase().concat("_name")) //for the old variables names (ORGANIZER_NAME, ATTENDEE_NAME)
? language.t(variable.toLowerCase().concat("_name_variable")).replace(/ /g, "_").toLocaleUpperCase()
: variable;
// this takes care of translating formatted variables (e.g. {EVENT_DATE_DD MM YYYY})
const formattedVarToTranslate = FORMATTED_DYNAMIC_TEXT_VARIABLES.map((formattedVar) => {
if (variable.toLowerCase().startsWith(formattedVar)) return variable;
})[0];
if (formattedVarToTranslate) {
// only translate the variable part not the formatting
const variableName = formattedVarToTranslate
.substring(0, formattedVarToTranslate?.lastIndexOf("_"))
.toLowerCase()
.concat("_variable");
translatedVariable = language
.t(variableName)
.replace(/ /g, "_")
.toLocaleUpperCase()
.concat(formattedVarToTranslate?.substring(formattedVarToTranslate?.lastIndexOf("_")));
}
translatedText = translatedText.replace(regex, `{${translatedVariable}}`);
});
}
return translatedText;
}
export function translateVariablesToEnglish(text: string, language: { locale: string; t: TFunction }) {
let newText = text;
if (language.locale !== "en") {
const variables = text.match(/\{(.+?)}/g)?.map((variable) => {
return variable.replace("{", "").replace("}", "");
});
variables?.forEach((variable) => {
DYNAMIC_TEXT_VARIABLES.forEach((originalVar) => {
const newVariableName = variable.replace("_NAME", "");
const originalVariable = `${originalVar}_variable`;
if (
language.t(originalVariable).replace(/ /g, "_").toUpperCase() === variable ||
language.t(originalVariable).replace(/ /g, "_").toUpperCase() === newVariableName
) {
newText = newText.replace(
variable,
language.t(originalVariable, { lng: "en" }).replace(/ /g, "_").toUpperCase()
);
return;
}
});
FORMATTED_DYNAMIC_TEXT_VARIABLES.forEach((formattedVar) => {
const translatedVariable = language.t(`${formattedVar}variable`).replace(/ /g, "_").toUpperCase();
if (variable.startsWith(translatedVariable)) {
newText = newText.replace(translatedVariable, formattedVar.slice(0, -1).toUpperCase());
}
});
});
}
return newText;
}

View File

@@ -0,0 +1,229 @@
"use client";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import type { Dispatch, SetStateAction } from "react";
import { useState } from "react";
import Shell, { ShellMain } from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { HttpError } from "@calcom/lib/http-error";
import { trpc } from "@calcom/trpc/react";
import { AnimatedPopover, Avatar, CreateButtonWithTeamsList, showToast } from "@calcom/ui";
import { FilterResults } from "../../../filters/components/FilterResults";
import { TeamsFilter } from "../../../filters/components/TeamsFilter";
import { getTeamsFiltersFromQuery } from "../../../filters/lib/getTeamsFiltersFromQuery";
import LicenseRequired from "../../common/components/LicenseRequired";
import EmptyScreen from "../components/EmptyScreen";
import SkeletonLoader from "../components/SkeletonLoaderList";
import WorkflowList from "../components/WorkflowListPage";
function WorkflowsPage() {
const { t } = useLocale();
const session = useSession();
const router = useRouter();
const routerQuery = useRouterQuery();
const filters = getTeamsFiltersFromQuery(routerQuery);
const queryRes = trpc.viewer.workflows.filteredList.useQuery({
filters,
});
const createMutation = trpc.viewer.workflows.create.useMutation({
onSuccess: async ({ workflow }) => {
await router.replace(`/workflows/${workflow.id}`);
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
if (err.data?.code === "UNAUTHORIZED") {
const message = `${err.data.code}: ${t("error_workflow_unauthorized_create")}`;
showToast(message, "error");
}
},
});
return (
<Shell withoutMain>
<LicenseRequired>
<ShellMain
heading={t("workflows")}
subtitle={t("workflows_to_automate_notifications")}
title="Workflows"
description="Create workflows to automate notifications and reminders."
hideHeadingOnMobile
CTA={
session.data?.hasValidLicense ? (
<CreateButtonWithTeamsList
subtitle={t("new_workflow_subtitle").toUpperCase()}
createFunction={(teamId?: number) => {
createMutation.mutate({ teamId });
}}
isPending={createMutation.isPending}
disableMobileButton={true}
onlyShowWithNoTeams={true}
includeOrg={true}
/>
) : null
}>
<>
{queryRes.data?.totalCount ? (
<div className="flex">
<TeamsFilter />
<div className="mb-4 ml-auto">
<CreateButtonWithTeamsList
subtitle={t("new_workflow_subtitle").toUpperCase()}
createFunction={(teamId?: number) => createMutation.mutate({ teamId })}
isPending={createMutation.isPending}
disableMobileButton={true}
onlyShowWithTeams={true}
includeOrg={true}
/>
</div>
</div>
) : null}
<FilterResults
queryRes={queryRes}
emptyScreen={<EmptyScreen isFilteredView={false} />}
noResultsScreen={<EmptyScreen isFilteredView={true} />}
SkeletonLoader={SkeletonLoader}>
<WorkflowList workflows={queryRes.data?.filtered} />
</FilterResults>
</>
</ShellMain>
</LicenseRequired>
</Shell>
);
}
const Filter = (props: {
profiles: {
readOnly?: boolean | undefined;
slug: string | null;
name: string | null;
teamId: number | null | undefined;
image?: string | undefined | null;
}[];
checked: {
userId: number | null;
teamIds: number[];
};
setChecked: Dispatch<
SetStateAction<{
userId: number | null;
teamIds: number[];
}>
>;
}) => {
const session = useSession();
const userId = session.data?.user.id || 0;
const user = session.data?.user.name || "";
const userName = session.data?.user.username;
const userAvatar = `${WEBAPP_URL}/${userName}/avatar.png`;
const teams = props.profiles.filter((profile) => !!profile.teamId);
const { checked, setChecked } = props;
const [noFilter, setNoFilter] = useState(true);
return (
<div className={classNames("-mb-2", noFilter ? "w-16" : "w-[100px]")}>
<AnimatedPopover text={noFilter ? "All" : "Filtered"}>
<div className="item-center focus-within:bg-subtle hover:bg-muted flex px-4 py-[6px] hover:cursor-pointer">
<Avatar
imageSrc={userAvatar || ""}
size="sm"
alt={`${user} Avatar`}
className="self-center"
asChild
/>
<label
htmlFor="yourWorkflows"
className="text-default ml-2 mr-auto self-center truncate text-sm font-medium">
{user}
</label>
<input
id="yourWorkflows"
type="checkbox"
className="text-emphasis focus:ring-emphasis dark:text-muted border-default inline-flex h-4 w-4 place-self-center justify-self-end rounded "
checked={!!checked.userId}
onChange={(e) => {
if (e.target.checked) {
setChecked({ userId: userId, teamIds: checked.teamIds });
if (checked.teamIds.length === teams.length) {
setNoFilter(true);
}
} else if (!e.target.checked) {
setChecked({ userId: null, teamIds: checked.teamIds });
setNoFilter(false);
}
}}
/>
</div>
{teams.map((profile) => (
<div
className="item-center focus-within:bg-subtle hover:bg-muted flex px-4 py-[6px] hover:cursor-pointer"
key={`${profile.teamId || 0}`}>
<Avatar
imageSrc={profile.image || ""}
size="sm"
alt={`${profile.slug} Avatar`}
className="self-center"
asChild
/>
<label
htmlFor={profile.slug || ""}
className="text-default ml-2 mr-auto select-none self-center truncate text-sm font-medium hover:cursor-pointer">
{profile.slug}
</label>
<input
id={profile.slug || ""}
name={profile.slug || ""}
type="checkbox"
checked={checked.teamIds?.includes(profile.teamId || 0)}
onChange={(e) => {
if (e.target.checked) {
const updatedChecked = checked;
updatedChecked.teamIds.push(profile.teamId || 0);
setChecked({ userId: checked.userId, teamIds: [...updatedChecked.teamIds] });
if (checked.userId && updatedChecked.teamIds.length === teams.length) {
setNoFilter(true);
} else {
setNoFilter(false);
}
} else if (!e.target.checked) {
const index = checked.teamIds.indexOf(profile.teamId || 0);
if (index !== -1) {
const updatedChecked = checked;
updatedChecked.teamIds.splice(index, 1);
setChecked({ userId: checked.userId, teamIds: [...updatedChecked.teamIds] });
if (checked.userId && updatedChecked.teamIds.length === teams.length) {
setNoFilter(true);
} else {
setNoFilter(false);
}
}
}
}}
className="text-emphasis focus:ring-emphasis dark:text-muted border-default inline-flex h-4 w-4 place-self-center justify-self-end rounded "
/>
</div>
))}
</AnimatedPopover>
</div>
);
};
export default WorkflowsPage;

View File

@@ -0,0 +1,452 @@
import { zodResolver } from "@hookform/resolvers/zod";
import type { WorkflowStep } from "@prisma/client";
import { isValidPhoneNumber } from "libphonenumber-js";
import { useSession } from "next-auth/react";
import { useEffect, useState, useMemo } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import Shell, { ShellMain } from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib";
import { SENDER_ID } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import { HttpError } from "@calcom/lib/http-error";
import {
MembershipRole,
TimeUnit,
WorkflowActions,
WorkflowTemplates,
WorkflowTriggerEvents,
} from "@calcom/prisma/enums";
import { stringOrNumber } from "@calcom/prisma/zod-utils";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui";
import { Alert, Badge, Button, Form, showToast } from "@calcom/ui";
import LicenseRequired from "../../common/components/LicenseRequired";
import SkeletonLoader from "../components/SkeletonLoaderEdit";
import WorkflowDetailsPage from "../components/WorkflowDetailsPage";
import { isSMSAction, isSMSOrWhatsappAction } from "../lib/actionHelperFunctions";
import { getTranslatedText, translateVariablesToEnglish } from "../lib/variableTranslations";
export type FormValues = {
name: string;
activeOn: Option[];
steps: (WorkflowStep & { senderName: string | null })[];
trigger: WorkflowTriggerEvents;
time?: number;
timeUnit?: TimeUnit;
selectAll: boolean;
};
export function onlyLettersNumbersSpaces(str: string) {
if (str.length <= 11 && /^[A-Za-z0-9\s]*$/.test(str)) {
return true;
}
return false;
}
const formSchema = z.object({
name: z.string(),
activeOn: z.object({ value: z.string(), label: z.string() }).array(),
trigger: z.nativeEnum(WorkflowTriggerEvents),
time: z.number().gte(0).optional(),
timeUnit: z.nativeEnum(TimeUnit).optional(),
steps: z
.object({
id: z.number(),
stepNumber: z.number(),
action: z.nativeEnum(WorkflowActions),
workflowId: z.number(),
reminderBody: z.string().nullable(),
emailSubject: z.string().nullable(),
template: z.nativeEnum(WorkflowTemplates),
numberRequired: z.boolean().nullable(),
includeCalendarEvent: z.boolean().nullable(),
sendTo: z
.string()
.refine((val) => isValidPhoneNumber(val) || val.includes("@"))
.optional()
.nullable(),
sender: z
.string()
.refine((val) => onlyLettersNumbersSpaces(val))
.optional()
.nullable(),
senderName: z.string().optional().nullable(),
})
.array(),
selectAll: z.boolean(),
});
const querySchema = z.object({
workflow: stringOrNumber,
});
function WorkflowPage() {
const { t, i18n } = useLocale();
const session = useSession();
const params = useParamsWithFallback();
const [selectedOptions, setSelectedOptions] = useState<Option[]>([]);
const [isAllDataLoaded, setIsAllDataLoaded] = useState(false);
const [isMixedEventType, setIsMixedEventType] = useState(false); //for old event types before team workflows existed
const form = useForm<FormValues>({
mode: "onBlur",
resolver: zodResolver(formSchema),
});
const { workflow: workflowId } = params ? querySchema.parse(params) : { workflow: -1 };
const utils = trpc.useUtils();
const userQuery = useMeQuery();
const user = userQuery.data;
const {
data: workflow,
isError,
error,
isPending: isPendingWorkflow,
} = trpc.viewer.workflows.get.useQuery(
{ id: +workflowId },
{
enabled: !!workflowId,
}
);
const { data: verifiedNumbers } = trpc.viewer.workflows.getVerifiedNumbers.useQuery(
{ teamId: workflow?.team?.id },
{
enabled: !!workflow?.id,
}
);
const { data: verifiedEmails } = trpc.viewer.workflows.getVerifiedEmails.useQuery({
teamId: workflow?.team?.id,
});
const { data: eventTypeGroups, isPending: isPendingEventTypes } =
trpc.viewer.eventTypes.getByViewer.useQuery();
const { data: otherTeams, isPending: isPendingTeams } = trpc.viewer.organizations.listOtherTeams.useQuery();
const isOrg = workflow?.team?.isOrganization ?? false;
const teamId = workflow?.teamId ?? undefined;
const profileTeamsOptions =
isOrg && eventTypeGroups
? eventTypeGroups?.profiles
.filter((profile) => !!profile.teamId)
.map((profile) => {
return {
value: String(profile.teamId) || "",
label: profile.name || profile.slug || "",
};
})
: [];
const otherTeamsOptions = otherTeams
? otherTeams.map((team) => {
return {
value: String(team.id) || "",
label: team.name || team.slug || "",
};
})
: [];
const teamOptions = profileTeamsOptions.concat(otherTeamsOptions);
const eventTypeOptions = useMemo(
() =>
eventTypeGroups?.eventTypeGroups.reduce((options, group) => {
/** don't show team event types for user workflow */
if (!teamId && group.teamId) return options;
/** only show correct team event types for team workflows */
if (teamId && teamId !== group.teamId) return options;
return [
...options,
...group.eventTypes
.filter(
(evType) =>
!evType.metadata?.managedEventConfig ||
!!evType.metadata?.managedEventConfig.unlockedFields?.workflows ||
!!teamId
)
.map((eventType) => ({
value: String(eventType.id),
label: `${eventType.title} ${
eventType.children && eventType.children.length ? `(+${eventType.children.length})` : ``
}`,
})),
];
}, [] as Option[]) || [],
[eventTypeGroups]
);
let allEventTypeOptions = eventTypeOptions;
const distinctEventTypes = new Set();
if (!teamId && isMixedEventType) {
allEventTypeOptions = [...eventTypeOptions, ...selectedOptions];
allEventTypeOptions = allEventTypeOptions.filter((option) => {
const duplicate = distinctEventTypes.has(option.value);
distinctEventTypes.add(option.value);
return !duplicate;
});
}
const readOnly =
workflow?.team?.members?.find((member) => member.userId === session.data?.user.id)?.role ===
MembershipRole.MEMBER;
const isPending = isPendingWorkflow || isPendingEventTypes || isPendingTeams;
useEffect(() => {
if (!isPending) {
setFormData(workflow);
}
}, [isPending]);
function setFormData(workflowData: RouterOutputs["viewer"]["workflows"]["get"] | undefined) {
if (workflowData) {
if (workflowData.userId && workflowData.activeOn.find((active) => !!active.eventType.teamId)) {
setIsMixedEventType(true);
}
let activeOn;
if (workflowData.isActiveOnAll) {
activeOn = isOrg ? teamOptions : allEventTypeOptions;
} else {
if (isOrg) {
activeOn = workflowData.activeOnTeams.flatMap((active) => {
return {
value: String(active.team.id) || "",
label: active.team.slug || "",
};
});
setSelectedOptions(activeOn || []);
} else {
setSelectedOptions(
workflowData.activeOn.flatMap((active) => {
if (workflowData.teamId && active.eventType.parentId) return [];
return {
value: String(active.eventType.id),
label: active.eventType.title,
};
}) || []
);
activeOn = workflowData.activeOn
? workflowData.activeOn.map((active) => ({
value: active.eventType.id.toString(),
label: active.eventType.slug,
}))
: undefined;
}
}
//translate dynamic variables into local language
const steps = workflowData.steps.map((step) => {
const updatedStep = {
...step,
senderName: step.sender,
sender: isSMSAction(step.action) ? step.sender : SENDER_ID,
};
if (step.reminderBody) {
updatedStep.reminderBody = getTranslatedText(step.reminderBody || "", {
locale: i18n.language,
t,
});
}
if (step.emailSubject) {
updatedStep.emailSubject = getTranslatedText(step.emailSubject || "", {
locale: i18n.language,
t,
});
}
return updatedStep;
});
form.setValue("name", workflowData.name);
form.setValue("steps", steps);
form.setValue("trigger", workflowData.trigger);
form.setValue("time", workflowData.time || undefined);
form.setValue("timeUnit", workflowData.timeUnit || undefined);
form.setValue("activeOn", activeOn || []);
form.setValue("selectAll", workflowData.isActiveOnAll ?? false);
setIsAllDataLoaded(true);
}
}
const updateMutation = trpc.viewer.workflows.update.useMutation({
onSuccess: async ({ workflow }) => {
if (workflow) {
utils.viewer.workflows.get.setData({ id: +workflow.id }, workflow);
setFormData(workflow);
showToast(
t("workflow_updated_successfully", {
workflowName: workflow.name,
}),
"success"
);
}
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
},
});
return session.data ? (
<Shell withoutMain backPath="/workflows">
<LicenseRequired>
<Form
form={form}
handleSubmit={async (values) => {
let activeOnIds: number[] = [];
let isEmpty = false;
let isVerified = true;
values.steps.forEach((step) => {
const strippedHtml = step.reminderBody?.replace(/<[^>]+>/g, "") || "";
const isBodyEmpty = !isSMSOrWhatsappAction(step.action) && strippedHtml.length <= 1;
if (isBodyEmpty) {
form.setError(`steps.${step.stepNumber - 1}.reminderBody`, {
type: "custom",
message: t("fill_this_field"),
});
}
if (step.reminderBody) {
step.reminderBody = translateVariablesToEnglish(step.reminderBody, {
locale: i18n.language,
t,
});
}
if (step.emailSubject) {
step.emailSubject = translateVariablesToEnglish(step.emailSubject, {
locale: i18n.language,
t,
});
}
isEmpty = !isEmpty ? isBodyEmpty : isEmpty;
//check if phone number is verified
if (
(step.action === WorkflowActions.SMS_NUMBER ||
step.action === WorkflowActions.WHATSAPP_NUMBER) &&
!verifiedNumbers?.find((verifiedNumber) => verifiedNumber.phoneNumber === step.sendTo)
) {
isVerified = false;
form.setError(`steps.${step.stepNumber - 1}.sendTo`, {
type: "custom",
message: t("not_verified"),
});
}
if (
step.action === WorkflowActions.EMAIL_ADDRESS &&
!verifiedEmails?.find((verifiedEmail) => verifiedEmail.email === step.sendTo)
) {
isVerified = false;
form.setError(`steps.${step.stepNumber - 1}.sendTo`, {
type: "custom",
message: t("not_verified"),
});
}
});
if (!isEmpty && isVerified) {
if (values.activeOn) {
activeOnIds = values.activeOn
.filter((option) => option.value !== "all")
.map((option) => {
return parseInt(option.value, 10);
});
}
updateMutation.mutate({
id: workflowId,
name: values.name,
activeOn: activeOnIds,
steps: values.steps,
trigger: values.trigger,
time: values.time || null,
timeUnit: values.timeUnit || null,
isActiveOnAll: values.selectAll || false,
});
utils.viewer.workflows.getVerifiedNumbers.invalidate();
}
}}>
<ShellMain
backPath="/workflows"
title={workflow && workflow.name ? workflow.name : "Untitled"}
CTA={
!readOnly && (
<div>
<Button data-testid="save-workflow" type="submit" loading={updateMutation.isPending}>
{t("save")}
</Button>
</div>
)
}
hideHeadingOnMobile
heading={
isAllDataLoaded && (
<div className="flex">
<div className={classNames(workflow && !workflow.name ? "text-muted" : "")}>
{workflow && workflow.name ? workflow.name : "untitled"}
</div>
{workflow && workflow.team && (
<Badge className="ml-4 mt-1" variant="gray">
{workflow.team.name}
</Badge>
)}
{readOnly && (
<Badge className="ml-4 mt-1" variant="gray">
{t("readonly")}
</Badge>
)}
</div>
)
}>
{!isError ? (
<>
{isAllDataLoaded && user ? (
<>
<WorkflowDetailsPage
form={form}
workflowId={+workflowId}
user={user}
selectedOptions={selectedOptions}
setSelectedOptions={setSelectedOptions}
teamId={workflow ? workflow.teamId || undefined : undefined}
readOnly={readOnly}
isOrg={isOrg}
allOptions={isOrg ? teamOptions : allEventTypeOptions}
/>
</>
) : (
<SkeletonLoader />
)}
</>
) : (
<Alert severity="error" title="Something went wrong" message={error.message} />
)}
</ShellMain>
</Form>
</LicenseRequired>
</Shell>
) : (
<></>
);
}
export default WorkflowPage;