fix: schedule unscheduled emails with tasker in cron job (#20748)

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
This commit is contained in:
Carina Wollendorfer
2025-04-28 08:56:13 -07:00
committed by GitHub
co-authored by CarinaWolli
parent dc361f7879
commit ffb36509ff
7 changed files with 173 additions and 109 deletions
@@ -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) {
@@ -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<any>[] = [];
const deletePromises: Promise<any>[] = [];
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<any>[] = [];
const cancelUpdatePromises: Promise<any>[] = [];
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<any>[] = [];
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,
},
});
}
@@ -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<Prisma.WorkflowReminderSelect>()({
id: true,
scheduledDate: true,
isMandatoryReminder: true,
uuid: true,
workflowStep: {
select: {
action: true,
@@ -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;
}
}
/**
@@ -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<string | null> {
const task = await Task.cancelWithReference(referenceUid, type);
return task?.id ?? null;
}
}
+20
View File
@@ -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({
+1
View File
@@ -34,4 +34,5 @@ export interface Tasker {
processQueue(): Promise<void>;
cleanup(): Promise<void>;
cancel(id: string): Promise<string>;
cancelWithReference(referenceUid: string, type: TaskTypes): Promise<string | null>;
}