From ffb36509ffe72e8b0e0303862142e6662948a14f Mon Sep 17 00:00:00 2001 From: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Date: Mon, 28 Apr 2025 17:56:13 +0200 Subject: [PATCH] fix: schedule unscheduled emails with tasker in cron job (#20748) Co-authored-by: CarinaWolli --- .../web/app/api/recorded-daily-video/route.ts | 4 - .../workflows/api/scheduleEmailReminders.ts | 221 +++++++++++------- .../ee/workflows/lib/getWorkflowReminders.ts | 3 +- .../lib/reminders/emailReminderManager.ts | 27 +-- packages/features/tasker/internal-tasker.ts | 6 + packages/features/tasker/repository.ts | 20 ++ packages/features/tasker/tasker.ts | 1 + 7 files changed, 173 insertions(+), 109 deletions(-) diff --git a/apps/web/app/api/recorded-daily-video/route.ts b/apps/web/app/api/recorded-daily-video/route.ts index 72e8c92c90..a95858a6d0 100644 --- a/apps/web/app/api/recorded-daily-video/route.ts +++ b/apps/web/app/api/recorded-daily-video/route.ts @@ -52,10 +52,6 @@ const getDownloadLinkOfCalVideo = async (recordingId: string) => { }; export async function postHandler(request: NextRequest) { - if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) { - return NextResponse.json({ message: "No SendGrid API key or email" }, { status: 405 }); - } - const body = await request.json(); if (testRequestSchema.safeParse(body).success) { diff --git a/packages/features/ee/workflows/api/scheduleEmailReminders.ts b/packages/features/ee/workflows/api/scheduleEmailReminders.ts index 72bf5b8e4d..455ff07261 100644 --- a/packages/features/ee/workflows/api/scheduleEmailReminders.ts +++ b/packages/features/ee/workflows/api/scheduleEmailReminders.ts @@ -21,7 +21,9 @@ import { getAllRemindersToCancel, getAllRemindersToDelete, getAllUnscheduledReminders, + select, } from "../lib/getWorkflowReminders"; +import { sendOrScheduleWorkflowEmails } from "../lib/reminders/providers/emailProvider"; import { cancelScheduledEmail, deleteScheduledSend, @@ -40,28 +42,27 @@ export async function handler(req: NextRequest) { return NextResponse.json({ message: "Not authenticated" }, { status: 401 }); } - if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) { - return NextResponse.json({ message: "No SendGrid API key or email" }, { status: 405 }); - } + const isSendgridEnabled = process.env.SENDGRID_API_KEY && process.env.SENDGRID_EMAIL; - // delete batch_ids with already past scheduled date from scheduled_sends - const remindersToDelete: { referenceId: string | null }[] = await getAllRemindersToDelete(); + if (isSendgridEnabled) { + // delete batch_ids with already past scheduled date from scheduled_sends + const remindersToDelete: { referenceId: string | null }[] = await getAllRemindersToDelete(); - const deletePromises: Promise[] = []; + const deletePromises: Promise[] = []; - for (const reminder of remindersToDelete) { - const deletePromise = deleteScheduledSend(reminder.referenceId); - deletePromises.push(deletePromise); - } + 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}`); - } + 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: { @@ -72,38 +73,53 @@ export async function handler(req: NextRequest) { }, }); - //cancel reminders for cancelled/rescheduled bookings that are scheduled within the next hour - const remindersToCancel: { referenceId: string | null; id: number }[] = await getAllRemindersToCancel(); + if (isSendgridEnabled) { + //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[] = []; + const cancelUpdatePromises: Promise[] = []; - for (const reminder of remindersToCancel) { - const cancelPromise = cancelScheduledEmail(reminder.referenceId); + 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) - }, + 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}`); + } + }); }); - - 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[] = []; - const unscheduledReminders: PartialWorkflowReminder[] = await getAllUnscheduledReminders(); + let unscheduledReminders: PartialWorkflowReminder[] = []; + + if (isSendgridEnabled) { + unscheduledReminders = await getAllUnscheduledReminders(); + } else { + unscheduledReminders = (await prisma.workflowReminder.findMany({ + where: { + method: WorkflowMethods.EMAIL, + scheduled: false, + OR: [{ cancelled: false }, { cancelled: null }], + }, + select, + })) as PartialWorkflowReminder[]; + } if (!unscheduledReminders.length) { return NextResponse.json({ message: "No Emails to schedule" }, { status: 200 }); @@ -113,6 +129,8 @@ export async function handler(req: NextRequest) { if (!reminder.booking) { continue; } + const referenceUid = reminder.uuid ?? uuidv4(); + if (!reminder.isMandatoryReminder && reminder.workflowStep) { try { let sendTo; @@ -290,7 +308,7 @@ export async function handler(req: NextRequest) { } if (emailContent.emailSubject.length > 0 && !emailBodyEmpty && sendTo) { - const batchId = await getBatchId(); + const batchId = isSendgridEnabled ? await getBatchId() : undefined; const booking = reminder.booking; @@ -325,35 +343,49 @@ export async function handler(req: NextRequest) { title: booking.title || booking.eventType?.title || "", }; - sendEmailPromises.push( - sendSendgridMail({ - to: sendTo, - subject: emailContent.emailSubject, - html: emailContent.emailBody, - batchId: batchId, - sendAt: dayjs(reminder.scheduledDate).unix(), - ...(!reminder.booking?.eventType?.hideOrganizerEmail && { - replyTo: - reminder.booking?.eventType?.customReplyToEmail ?? - reminder.booking?.userPrimaryEmail ?? - reminder.booking.user?.email, - }), - attachments: reminder.workflowStep.includeCalendarEvent - ? [ - { - content: Buffer.from(generateIcsString({ event, status: "CONFIRMED" }) || "").toString( - "base64" - ), - filename: "event.ics", - type: "text/calendar; method=REQUEST", - disposition: "attachment", - contentId: uuidv4(), - }, - ] - : undefined, - sender: reminder.workflowStep.sender, - }) - ); + const mailData = { + subject: emailContent.emailSubject, + to: Array.isArray(sendTo) ? sendTo : [sendTo], + html: emailContent.emailBody, + attachments: reminder.workflowStep.includeCalendarEvent + ? [ + { + content: Buffer.from(generateIcsString({ event, status: "CONFIRMED" }) || "").toString( + "base64" + ), + filename: "event.ics", + type: "text/calendar; method=REQUEST", + disposition: "attachment", + contentId: uuidv4(), + }, + ] + : undefined, + sender: reminder.workflowStep.sender, + ...(!reminder.booking?.eventType?.hideOrganizerEmail && { + replyTo: + reminder.booking?.eventType?.customReplyToEmail ?? + reminder.booking?.userPrimaryEmail ?? + reminder.booking.user?.email, + }), + }; + + if (isSendgridEnabled) { + sendEmailPromises.push( + sendSendgridMail({ + ...mailData, + batchId, + sendAt: dayjs(reminder.scheduledDate).unix(), + }) + ); + } else { + sendEmailPromises.push( + sendOrScheduleWorkflowEmails({ + ...mailData, + referenceUid, + sendAt: reminder.scheduledDate, + }) + ); + } await prisma.workflowReminder.update({ where: { @@ -362,6 +394,7 @@ export async function handler(req: NextRequest) { data: { scheduled: true, referenceId: batchId, + uuid: referenceUid, }, }); } @@ -403,24 +436,37 @@ export async function handler(req: NextRequest) { isBrandingDisabled: brandingDisabled, }); if (emailContent.emailSubject.length > 0 && !emailBodyEmpty && sendTo) { - const batchId = await getBatchId(); + const batchId = isSendgridEnabled ? await getBatchId() : undefined; - sendEmailPromises.push( - sendSendgridMail({ - to: sendTo, - subject: emailContent.emailSubject, - html: emailContent.emailBody, - batchId: batchId, - sendAt: dayjs(reminder.scheduledDate).unix(), - ...(!reminder.booking?.eventType?.hideOrganizerEmail && { - replyTo: - reminder.booking?.eventType?.customReplyToEmail || - reminder.booking?.userPrimaryEmail || - reminder.booking.user?.email, - }), - sender: reminder.workflowStep?.sender, - }) - ); + const mailData = { + subject: emailContent.emailSubject, + to: [sendTo], + html: emailContent.emailBody, + sender: reminder.workflowStep?.sender, + ...(!reminder.booking?.eventType?.hideOrganizerEmail && { + replyTo: + reminder.booking?.eventType?.customReplyToEmail || + reminder.booking?.userPrimaryEmail || + reminder.booking.user?.email, + }), + }; + if (isSendgridEnabled) { + sendEmailPromises.push( + sendSendgridMail({ + ...mailData, + batchId, + sendAt: dayjs(reminder.scheduledDate).unix(), + }) + ); + } else { + sendEmailPromises.push( + sendOrScheduleWorkflowEmails({ + ...mailData, + sendAt: reminder.scheduledDate, + referenceUid, + }) + ); + } await prisma.workflowReminder.update({ where: { @@ -429,6 +475,7 @@ export async function handler(req: NextRequest) { data: { scheduled: true, referenceId: batchId, + uuid: referenceUid, }, }); } diff --git a/packages/features/ee/workflows/lib/getWorkflowReminders.ts b/packages/features/ee/workflows/lib/getWorkflowReminders.ts index ded5b597d8..bd1da46bd5 100644 --- a/packages/features/ee/workflows/lib/getWorkflowReminders.ts +++ b/packages/features/ee/workflows/lib/getWorkflowReminders.ts @@ -44,7 +44,7 @@ type PartialBooking = export type PartialWorkflowReminder = Pick< WorkflowReminder, - "id" | "isMandatoryReminder" | "scheduledDate" + "id" | "isMandatoryReminder" | "scheduledDate" | "uuid" > & { booking: PartialBooking | null; } & { workflowStep: PartialWorkflowStep }; @@ -123,6 +123,7 @@ export const select = Prisma.validator()({ id: true, scheduledDate: true, isMandatoryReminder: true, + uuid: true, workflowStep: { select: { action: true, diff --git a/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts b/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts index 9062d4f6c0..b0a135ddc7 100644 --- a/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts @@ -389,24 +389,17 @@ export const deleteScheduledEmailReminder = async (reminderId: number) => { } const { uuid, referenceId } = workflowReminder; + if (uuid) { + const taskId = await tasker.cancelWithReference(uuid, "sendWorkflowEmails"); + if (taskId) { + await prisma.workflowReminder.delete({ + where: { + id: reminderId, + }, + }); - const task = await prisma.task.findFirst({ - where: { - type: "sendWorkflowEmails", - referenceUid: uuid, - }, - }); - - if (task) { - await tasker.cancel(task.id); - - await prisma.workflowReminder.delete({ - where: { - id: reminderId, - }, - }); - - return; + return; + } } /** diff --git a/packages/features/tasker/internal-tasker.ts b/packages/features/tasker/internal-tasker.ts index 63d54fe374..4d25ca4340 100644 --- a/packages/features/tasker/internal-tasker.ts +++ b/packages/features/tasker/internal-tasker.ts @@ -1,4 +1,5 @@ import { Task } from "./repository"; +import type { TaskTypes } from "./tasker"; import { type TaskerCreate, type Tasker } from "./tasker"; import tasksMap, { tasksConfig } from "./tasks"; @@ -52,4 +53,9 @@ export class InternalTasker implements Tasker { const task = await Task.cancel(id); return task.id; } + + async cancelWithReference(referenceUid: string, type: TaskTypes): Promise { + const task = await Task.cancelWithReference(referenceUid, type); + return task?.id ?? null; + } } diff --git a/packages/features/tasker/repository.ts b/packages/features/tasker/repository.ts index 6911776790..121847dfe9 100644 --- a/packages/features/tasker/repository.ts +++ b/packages/features/tasker/repository.ts @@ -154,6 +154,26 @@ export class Task { }); } + static async cancelWithReference(referenceUid: string, type: TaskTypes) { + const task = await db.task.findFirst({ + where: { + referenceUid, + type, + }, + select: { + id: true, + }, + }); + + if (!task) return null; + + return await db.task.delete({ + where: { + id: task.id, + }, + }); + } + static async cleanup() { // TODO: Uncomment this later // return db.task.deleteMany({ diff --git a/packages/features/tasker/tasker.ts b/packages/features/tasker/tasker.ts index 556773003c..b28030d180 100644 --- a/packages/features/tasker/tasker.ts +++ b/packages/features/tasker/tasker.ts @@ -34,4 +34,5 @@ export interface Tasker { processQueue(): Promise; cleanup(): Promise; cancel(id: string): Promise; + cancelWithReference(referenceUid: string, type: TaskTypes): Promise; }