first commit
This commit is contained in:
@@ -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 }),
|
||||
});
|
||||
@@ -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 }),
|
||||
});
|
||||
@@ -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 }),
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
];
|
||||
67
calcom/packages/features/ee/workflows/lib/constants.ts
Normal file
67
calcom/packages/features/ee/workflows/lib/constants.ts
Normal 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_"];
|
||||
88
calcom/packages/features/ee/workflows/lib/getActionIcon.tsx
Normal file
88
calcom/packages/features/ee/workflows/lib/getActionIcon.tsx
Normal 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 <></>;
|
||||
}
|
||||
120
calcom/packages/features/ee/workflows/lib/getAllWorkflows.ts
Normal file
120
calcom/packages/features/ee/workflows/lib/getAllWorkflows.ts
Normal 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;
|
||||
};
|
||||
44
calcom/packages/features/ee/workflows/lib/getOptions.ts
Normal file
44
calcom/packages/features/ee/workflows/lib/getOptions.ts
Normal 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 }[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./whatsappEventCancelledTemplate";
|
||||
export * from "./whatsappEventCompletedTemplate";
|
||||
export * from "./whatsappEventReminderTemplate";
|
||||
export * from "./whatsappEventRescheduledTemplate";
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
857
calcom/packages/features/ee/workflows/lib/test/workflows.test.ts
Normal file
857
calcom/packages/features/ee/workflows/lib/test/workflows.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
30
calcom/packages/features/ee/workflows/lib/types.ts
Normal file
30
calcom/packages/features/ee/workflows/lib/types.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
229
calcom/packages/features/ee/workflows/pages/index.tsx
Normal file
229
calcom/packages/features/ee/workflows/pages/index.tsx
Normal 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;
|
||||
452
calcom/packages/features/ee/workflows/pages/workflow.tsx
Normal file
452
calcom/packages/features/ee/workflows/pages/workflow.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user