fix: unhandled promise rejection in scheduleWorkflowReminder (#12301)

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
This commit is contained in:
Carina Wollendorfer 2023-11-13 23:57:16 -05:00 committed by GitHub
parent b2f4eaee63
commit a2f859b55a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 347 additions and 290 deletions

View File

@ -6,12 +6,19 @@ import { v4 as uuidv4 } from "uuid";
import dayjs from "@calcom/dayjs";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
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 type { VariablesType } from "../lib/reminders/templates/customTemplate";
import customTemplate from "../lib/reminders/templates/customTemplate";
@ -38,45 +45,22 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const sandboxMode = process.env.NEXT_PUBLIC_IS_E2E ? true : false;
// delete batch_ids with already past scheduled date from scheduled_sends
const pageSize = 90;
let pageNumber = 0;
const remindersToDelete: { referenceId: string | null }[] = await getAllRemindersToDelete();
const deletePromises: Promise<any>[] = [];
while (true) {
const remindersToDelete = await prisma.workflowReminder.findMany({
where: {
method: WorkflowMethods.EMAIL,
cancelled: true,
scheduledDate: {
lte: dayjs().toISOString(),
},
},
skip: pageNumber * pageSize,
take: pageSize,
select: {
referenceId: true,
},
for (const reminder of remindersToDelete) {
const deletePromise = client.request({
url: `/v3/user/scheduled_sends/${reminder.referenceId}`,
method: "DELETE",
});
if (remindersToDelete.length === 0) {
break;
}
for (const reminder of remindersToDelete) {
const deletePromise = client.request({
url: `/v3/user/scheduled_sends/${reminder.referenceId}`,
method: "DELETE",
});
deletePromises.push(deletePromise);
}
pageNumber++;
deletePromises.push(deletePromise);
}
Promise.allSettled(deletePromises).then((results) => {
results.forEach((result) => {
if (result.status === "rejected") {
console.log(`Error deleting batch id from scheduled_sends: ${result.reason}`);
logger.error(`Error deleting batch id from scheduled_sends: ${result.reason}`);
}
});
});
@ -92,312 +76,221 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
});
//cancel reminders for cancelled/rescheduled bookings that are scheduled within the next hour
pageNumber = 0;
const remindersToCancel: { referenceId: string | null; id: number }[] = await getAllRemindersToCancel();
const allPromisesCancelReminders: Promise<any>[] = [];
const cancelUpdatePromises: Promise<any>[] = [];
while (true) {
const remindersToCancel = await prisma.workflowReminder.findMany({
where: {
cancelled: true,
scheduled: true, //if it is false then they are already cancelled
scheduledDate: {
lte: dayjs().add(1, "hour").toISOString(),
},
},
skip: pageNumber * pageSize,
take: pageSize,
select: {
referenceId: true,
id: true,
for (const reminder of remindersToCancel) {
const cancelPromise = client.request({
url: "/v3/user/scheduled_sends",
method: "POST",
body: {
batch_id: reminder.referenceId,
status: "cancel",
},
});
if (remindersToCancel.length === 0) {
break;
}
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)
},
});
for (const reminder of remindersToCancel) {
const cancelPromise = client.request({
url: "/v3/user/scheduled_sends",
method: "POST",
body: {
batch_id: reminder.referenceId,
status: "cancel",
},
});
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)
},
});
allPromisesCancelReminders.push(cancelPromise, updatePromise);
}
pageNumber++;
cancelUpdatePromises.push(cancelPromise, updatePromise);
}
Promise.allSettled(allPromisesCancelReminders).then((results) => {
Promise.allSettled(cancelUpdatePromises).then((results) => {
results.forEach((result) => {
if (result.status === "rejected") {
console.log(`Error cancelling scheduled_sends: ${result.reason}`);
logger.error(`Error cancelling scheduled_sends: ${result.reason}`);
}
});
});
// schedule all unscheduled reminders within the next 72 hours
pageNumber = 0;
const sendEmailPromises: Promise<any>[] = [];
while (true) {
const unscheduledReminders = await prisma.workflowReminder.findMany({
where: {
method: WorkflowMethods.EMAIL,
scheduled: false,
scheduledDate: {
lte: dayjs().add(72, "hour").toISOString(),
},
OR: [{ cancelled: false }, { cancelled: null }],
},
skip: pageNumber * pageSize,
take: pageSize,
select: {
id: true,
scheduledDate: true,
workflowStep: {
select: {
action: true,
sendTo: true,
reminderBody: true,
emailSubject: true,
template: true,
sender: true,
includeCalendarEvent: true,
},
},
booking: {
select: {
startTime: true,
endTime: true,
location: true,
description: 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,
},
},
},
},
},
});
const unscheduledReminders: PartialWorkflowReminder[] = await getAllUnscheduledReminders();
if (!unscheduledReminders.length && pageNumber === 0) {
res.status(200).json({ message: "No Emails to schedule" });
return;
if (!unscheduledReminders.length) {
res.status(200).json({ message: "No Emails to schedule" });
return;
}
for (const reminder of unscheduledReminders) {
if (!reminder.workflowStep || !reminder.booking) {
continue;
}
try {
let sendTo;
if (unscheduledReminders.length === 0) {
break;
}
for (const reminder of unscheduledReminders) {
if (!reminder.workflowStep || !reminder.booking) {
continue;
switch (reminder.workflowStep.action) {
case WorkflowActions.EMAIL_HOST:
sendTo = reminder.booking.user?.email;
break;
case WorkflowActions.EMAIL_ATTENDEE:
sendTo = reminder.booking.attendees[0].email;
break;
case WorkflowActions.EMAIL_ADDRESS:
sendTo = reminder.workflowStep.sendTo;
}
try {
let sendTo;
switch (reminder.workflowStep.action) {
case WorkflowActions.EMAIL_HOST:
sendTo = 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 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 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 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;
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 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 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: `/booking/${reminder.booking.uid}?cancel=true`,
rescheduleLink: `/${reminder.booking.user?.username}/${reminder.booking.eventType?.slug}?rescheduleUid=${reminder.booking.uid}`,
};
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;
let emailBodyEmpty = false;
if (reminder.workflowStep.reminderBody) {
const { responses } = getCalEventResponses({
bookingFields: reminder.booking.eventType?.bookingFields ?? null,
booking: reminder.booking,
});
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: `/booking/${reminder.booking.uid}?cancel=true`,
rescheduleLink: `/${reminder.booking.user?.username}/${reminder.booking.eventType?.slug}?rescheduleUid=${reminder.booking.uid}`,
};
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(
emailBodyEmpty =
customTemplate(
reminder.workflowStep.reminderBody || "",
variables,
emailLocale,
getTimeFormatStringFromUserTimeFormat(reminder.booking.user?.timeFormat),
!!reminder.booking.user?.hideBranding
).html;
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
);
}
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
if (emailContent.emailSubject.length > 0 && !emailBodyEmpty && sendTo) {
const batchIdResponse = await client.request({
url: "/v3/mail/batch",
method: "POST",
});
const batchId = batchIdResponse[1].batch_id;
if (reminder.workflowStep.action !== WorkflowActions.EMAIL_ADDRESS) {
sendEmailPromises.push(
sgMail.send({
to: sendTo,
from: {
email: senderEmail,
name: reminder.workflowStep.sender || "Cal.com",
},
subject: emailContent.emailSubject,
html: emailContent.emailBody,
batchId: batchId,
sendAt: dayjs(reminder.scheduledDate).unix(),
replyTo: reminder.booking.user?.email || senderEmail,
mailSettings: {
sandboxMode: {
enable: sandboxMode,
},
},
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,
})
);
}
if (emailContent.emailSubject.length > 0 && !emailBodyEmpty && sendTo) {
const batchIdResponse = await client.request({
url: "/v3/mail/batch",
method: "POST",
});
const batchId = batchIdResponse[1].batch_id;
if (reminder.workflowStep.action !== WorkflowActions.EMAIL_ADDRESS) {
sendEmailPromises.push(
sgMail.send({
to: sendTo,
from: {
email: senderEmail,
name: reminder.workflowStep.sender || "Cal.com",
},
subject: emailContent.emailSubject,
html: emailContent.emailBody,
batchId: batchId,
sendAt: dayjs(reminder.scheduledDate).unix(),
replyTo: reminder.booking.user?.email || senderEmail,
mailSettings: {
sandboxMode: {
enable: sandboxMode,
},
},
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,
})
);
}
await prisma.workflowReminder.update({
where: {
id: reminder.id,
},
data: {
scheduled: true,
referenceId: batchId,
},
});
}
} catch (error) {
console.log(`Error scheduling Email with error ${error}`);
await prisma.workflowReminder.update({
where: {
id: reminder.id,
},
data: {
scheduled: true,
referenceId: batchId,
},
});
}
} catch (error) {
logger.error(`Error scheduling Email with error ${error}`);
}
pageNumber++;
}
Promise.allSettled(sendEmailPromises).then((results) => {
results.forEach((result) => {
if (result.status === "rejected") {
console.log("Email sending failed", result.reason);
logger.error("Email sending failed", result.reason);
}
});
});
res.status(200).json({ message: "Emails scheduled" });
res.status(200).json({ message: `${unscheduledReminders.length} Emails scheduled` });
}
export default defaultHandler({

View File

@ -0,0 +1,164 @@
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> | null;
type Booking = Prisma.BookingGetPayload<{
include: {
attendees: true;
};
}>;
type PartialBooking =
| (Pick<
Booking,
| "startTime"
| "endTime"
| "location"
| "description"
| "metadata"
| "customInputs"
| "responses"
| "uid"
| "attendees"
> & { eventType: Partial<EventType> | null } & { user: Partial<User> | null })
| null;
export type PartialWorkflowReminder = Pick<WorkflowReminder, "id" | "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 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 select: Prisma.WorkflowReminderSelect = {
id: true,
scheduledDate: true,
workflowStep: {
select: {
action: true,
sendTo: true,
reminderBody: true,
emailSubject: true,
template: true,
sender: true,
includeCalendarEvent: true,
},
},
booking: {
select: {
startTime: true,
endTime: true,
location: true,
description: 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,
},
},
},
},
};
const unscheduledReminders = (await getWorkflowReminders(whereFilter, select)) as PartialWorkflowReminder[];
return unscheduledReminders;
}