From a2f859b55a4e4a4d08ab769cba82c0e1938ecf9a Mon Sep 17 00:00:00 2001 From: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Date: Mon, 13 Nov 2023 23:57:16 -0500 Subject: [PATCH] fix: unhandled promise rejection in scheduleWorkflowReminder (#12301) Co-authored-by: CarinaWolli --- .../workflows/api/scheduleEmailReminders.ts | 473 +++++++----------- .../ee/workflows/lib/getWorkflowReminders.ts | 164 ++++++ 2 files changed, 347 insertions(+), 290 deletions(-) create mode 100644 packages/features/ee/workflows/lib/getWorkflowReminders.ts diff --git a/packages/features/ee/workflows/api/scheduleEmailReminders.ts b/packages/features/ee/workflows/api/scheduleEmailReminders.ts index d5ad586269..fa03762ad1 100644 --- a/packages/features/ee/workflows/api/scheduleEmailReminders.ts +++ b/packages/features/ee/workflows/api/scheduleEmailReminders.ts @@ -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[] = []; - 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[] = []; + const cancelUpdatePromises: Promise[] = []; - 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[] = []; - 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: `${reminder.workflowStep.reminderBody || ""}`, + }; - let emailContent = { - emailSubject: reminder.workflowStep.emailSubject || "", - emailBody: `${ - reminder.workflowStep.reminderBody || "" - }`, + 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({ diff --git a/packages/features/ee/workflows/lib/getWorkflowReminders.ts b/packages/features/ee/workflows/lib/getWorkflowReminders.ts new file mode 100644 index 0000000000..fe16bead7c --- /dev/null +++ b/packages/features/ee/workflows/lib/getWorkflowReminders.ts @@ -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 | null; + +type Booking = Prisma.BookingGetPayload<{ + include: { + attendees: true; + }; +}>; + +type PartialBooking = + | (Pick< + Booking, + | "startTime" + | "endTime" + | "location" + | "description" + | "metadata" + | "customInputs" + | "responses" + | "uid" + | "attendees" + > & { eventType: Partial | null } & { user: Partial | null }) + | null; + +export type PartialWorkflowReminder = Pick & { + booking: PartialBooking | null; +} & { workflowStep: PartialWorkflowStep }; + +async function getWorkflowReminders( + filter: Prisma.WorkflowReminderWhereInput, + select: T +): Promise>> { + const pageSize = 90; + let pageNumber = 0; + const filteredWorkflowReminders: Array> = []; + + 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>) + ); + pageNumber++; + } + + return filteredWorkflowReminders; +} + +type RemindersToDeleteType = { referenceId: string | null }; + +export async function getAllRemindersToDelete(): Promise { + 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 { + 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 { + 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; +}