Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 6 additions & 19 deletions packages/features/bookings/lib/handleCancelBooking.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import {
BookingStatus,
MembershipRole,
Prisma,
PrismaPromise,
WebhookTriggerEvents,
WorkflowMethods,
WorkflowReminder,
Expand Down Expand Up @@ -483,29 +481,18 @@ async function handler(req: NextApiRequest & { userId?: number }) {
cancelScheduledJobs(booking);
});

//Workflows - delete all reminders for bookings
const remindersToDelete: PrismaPromise<Prisma.BatchPayload>[] = [];
//Workflows - cancel all reminders for cancelled bookings
updatedBookings.forEach((booking) => {
booking.workflowReminders.forEach((reminder) => {
if (reminder.scheduled && reminder.referenceId) {
if (reminder.method === WorkflowMethods.EMAIL) {
deleteScheduledEmailReminder(reminder.referenceId);
} else if (reminder.method === WorkflowMethods.SMS) {
deleteScheduledSMSReminder(reminder.referenceId);
}
if (reminder.method === WorkflowMethods.EMAIL) {
deleteScheduledEmailReminder(reminder.id, reminder.referenceId);
} else if (reminder.method === WorkflowMethods.SMS) {
deleteScheduledSMSReminder(reminder.id, reminder.referenceId);
}
const reminderToDelete = prisma.workflowReminder.deleteMany({
where: {
id: reminder.id,
},
});
remindersToDelete.push(reminderToDelete);
});
});
Comment on lines +484 to 493

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Async reminder cancellations during booking cancel are not awaited

The new reminder‑cancellation loop:

updatedBookings.forEach((booking) => {
  booking.workflowReminders.forEach((reminder) => {
    if (reminder.method === WorkflowMethods.EMAIL) {
      deleteScheduledEmailReminder(reminder.id, reminder.referenceId);
    } else if (reminder.method === WorkflowMethods.SMS) {
      deleteScheduledSMSReminder(reminder.id, reminder.referenceId);
    }
  });
});

calls async helpers without awaiting them, so:

  • Failures become unhandled rejections.
  • Promise.all(prismaPromises.concat(apiDeletes)) does not wait for reminder cancellations.

Refactor to collect and await the reminder promises:

-  //Workflows - cancel all reminders for cancelled bookings
-  updatedBookings.forEach((booking) => {
-    booking.workflowReminders.forEach((reminder) => {
-      if (reminder.method === WorkflowMethods.EMAIL) {
-        deleteScheduledEmailReminder(reminder.id, reminder.referenceId);
-      } else if (reminder.method === WorkflowMethods.SMS) {
-        deleteScheduledSMSReminder(reminder.id, reminder.referenceId);
-      }
-    });
-  });
-
-  const prismaPromises: Promise<unknown>[] = [attendeeDeletes, bookingReferenceDeletes];
-
-  await Promise.all(prismaPromises.concat(apiDeletes));
+  // Workflows - cancel all reminders for cancelled bookings
+  const reminderCancels = updatedBookings.flatMap((booking) =>
+    booking.workflowReminders.map((reminder) => {
+      if (reminder.method === WorkflowMethods.EMAIL) {
+        return deleteScheduledEmailReminder(reminder.id, reminder.referenceId);
+      }
+      if (reminder.method === WorkflowMethods.SMS) {
+        return deleteScheduledSMSReminder(reminder.id, reminder.referenceId);
+      }
+      return Promise.resolve();
+    })
+  );
+
+  const prismaPromises: Promise<unknown>[] = [attendeeDeletes, bookingReferenceDeletes];
+
+  await Promise.all([...prismaPromises, ...apiDeletes, ...reminderCancels]);

Also applies to: 495-497

🤖 Prompt for AI Agents
In packages/features/bookings/lib/handleCancelBooking.ts around lines 484-493
(and similarly at 495-497) the reminder cancellation loop calls async helpers
(deleteScheduledEmailReminder/deleteScheduledSMSReminder) without collecting or
awaiting their returned promises, causing unhandled rejections and making the
overall cancel flow not wait for deletions; fix by creating an array (e.g.,
reminderDeletes), push each call's promise into it instead of calling directly,
then await Promise.all(reminderDeletes) (or include reminderDeletes into the
existing Promise.all by concatenating) so all reminder cancellations are awaited
and errors propagate.


const prismaPromises: Promise<unknown>[] = [attendeeDeletes, bookingReferenceDeletes].concat(
remindersToDelete
);
const prismaPromises: Promise<unknown>[] = [attendeeDeletes, bookingReferenceDeletes];

await Promise.all(prismaPromises.concat(apiDeletes));

Expand Down
28 changes: 26 additions & 2 deletions packages/features/bookings/lib/handleNewBooking.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import type { App, Credential, EventTypeCustomInput, Prisma } from "@prisma/client";
import { BookingStatus, SchedulingType, WebhookTriggerEvents } from "@prisma/client";
import {
App,
BookingStatus,
Credential,
EventTypeCustomInput,
Prisma,
SchedulingType,
WebhookTriggerEvents,
WorkflowMethods,
} from "@prisma/client";
import async from "async";
import { isValidPhoneNumber } from "libphonenumber-js";
import { cloneDeep } from "lodash";
Expand Down Expand Up @@ -28,7 +36,9 @@ import {
sendScheduledEmails,
sendScheduledSeatsEmails,
} from "@calcom/emails";
import { deleteScheduledEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import { deleteScheduledSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { getVideoCallUrl } from "@calcom/lib/CalEventParser";
Expand Down Expand Up @@ -759,6 +769,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
},
},
payment: true,
workflowReminders: true,
},
});
}
Expand Down Expand Up @@ -950,6 +961,19 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
let videoCallUrl;

if (originalRescheduledBooking?.uid) {
try {
// cancel workflow reminders from previous rescheduled booking
originalRescheduledBooking.workflowReminders.forEach((reminder) => {
if (reminder.method === WorkflowMethods.EMAIL) {
deleteScheduledEmailReminder(reminder.id, reminder.referenceId, true);
} else if (reminder.method === WorkflowMethods.SMS) {
deleteScheduledSMSReminder(reminder.id, reminder.referenceId);
}
});
} catch (error) {
log.error("Error while canceling scheduled workflow reminders", error);
}
Comment on lines 963 to +975

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Async reminder deletions on reschedule are not awaited

In the reschedule path, async helpers are called inside forEach without awaiting:

originalRescheduledBooking.workflowReminders.forEach((reminder) => {
  if (reminder.method === WorkflowMethods.EMAIL) {
    deleteScheduledEmailReminder(reminder.id, reminder.referenceId, true);
  } else if (reminder.method === WorkflowMethods.SMS) {
    deleteScheduledSMSReminder(reminder.id, reminder.referenceId);
  }
});

Issues:

  • Promises are fire‑and‑forget; failures become unhandled rejections.
  • The handler proceeds without knowing whether cancellations actually completed.

Refactor to collect and await the promises:

-    try {
-      // cancel workflow reminders from previous rescheduled booking
-      originalRescheduledBooking.workflowReminders.forEach((reminder) => {
-        if (reminder.method === WorkflowMethods.EMAIL) {
-          deleteScheduledEmailReminder(reminder.id, reminder.referenceId, true);
-        } else if (reminder.method === WorkflowMethods.SMS) {
-          deleteScheduledSMSReminder(reminder.id, reminder.referenceId);
-        }
-      });
-    } catch (error) {
-      log.error("Error while canceling scheduled workflow reminders", error);
-    }
+    try {
+      // cancel workflow reminders from previous rescheduled booking
+      await Promise.all(
+        originalRescheduledBooking.workflowReminders.map((reminder) => {
+          if (reminder.method === WorkflowMethods.EMAIL) {
+            return deleteScheduledEmailReminder(reminder.id, reminder.referenceId, true);
+          }
+          if (reminder.method === WorkflowMethods.SMS) {
+            return deleteScheduledSMSReminder(reminder.id, reminder.referenceId);
+          }
+          return Promise.resolve();
+        })
+      );
+    } catch (error) {
+      log.error("Error while canceling scheduled workflow reminders", error);
+    }
🤖 Prompt for AI Agents
In packages/features/bookings/lib/handleNewBooking.ts around lines 963 to 975,
async deletion helpers for workflow reminders are invoked inside a forEach and
not awaited, causing fire-and-forget Promises and unhandled failures; change the
loop to map reminders to Promises (calling deleteScheduledEmailReminder or
deleteScheduledSMSReminder accordingly), collect them into an array, and await
Promise.all (or Promise.allSettled if you want to tolerate individual failures)
inside the try block, and preserve the existing error logging in the catch.


// Use EventManager to conditionally use all needed integrations.
const updateManager = await eventManager.reschedule(
evt,
Expand Down
37 changes: 37 additions & 0 deletions packages/features/ee/workflows/api/scheduleEmailReminders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import dayjs from "@calcom/dayjs";
import { defaultHandler } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { Prisma, WorkflowReminder } from "@calcom/prisma/client";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";

import customTemplate, { VariablesType } from "../lib/reminders/templates/customTemplate";
Expand Down Expand Up @@ -39,6 +40,42 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
},
});

//cancel reminders for cancelled/rescheduled bookings that are scheduled within the next hour
const remindersToCancel = await prisma.workflowReminder.findMany({
where: {
cancelled: true,
scheduledDate: {
lte: dayjs().add(1, "hour").toISOString(),
},
},
});

try {
const workflowRemindersToDelete: Prisma.Prisma__WorkflowReminderClient<WorkflowReminder, never>[] = [];

for (const reminder of remindersToCancel) {
await client.request({
url: "/v3/user/scheduled_sends",
method: "POST",
body: {
batch_id: reminder.referenceId,
status: "cancel",
},
});

const workflowReminderToDelete = prisma.workflowReminder.delete({
where: {
id: reminder.id,
},
});

workflowRemindersToDelete.push(workflowReminderToDelete);
}
await Promise.all(workflowRemindersToDelete);
} catch (error) {
console.log(`Error cancelling scheduled Emails: ${error}`);
}

//find all unscheduled Email reminders
const unscheduledReminders = await prisma.workflowReminder.findMany({
where: {
Expand Down
126 changes: 60 additions & 66 deletions packages/features/ee/workflows/components/WorkflowStepContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -387,81 +387,75 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
}}
/>
</div>
{(isPhoneNumberNeeded || isSenderIdNeeded) && (
{isPhoneNumberNeeded && (
<div className="mt-2 rounded-md bg-gray-50 p-4 pt-0">
{isPhoneNumberNeeded && (
<Label className="pt-4">{t("custom_phone_number")}</Label>
<div className="block sm:flex">
<PhoneInput<FormValues>
control={form.control}
name={`steps.${step.stepNumber - 1}.sendTo`}
placeholder={t("phone_number")}
id={`steps.${step.stepNumber - 1}.sendTo`}
className="min-w-fit sm:rounded-tl-md sm:rounded-bl-md sm:border-r-transparent"
required
onChange={() => {
const isAlreadyVerified = !!verifiedNumbers
?.concat([])
.find((number) => number === form.getValues(`steps.${step.stepNumber - 1}.sendTo`));
setNumberVerified(isAlreadyVerified);
}}
/>
<Button
color="secondary"
disabled={numberVerified || false}
className={classNames(
"-ml-[3px] h-[40px] min-w-fit sm:block sm:rounded-tl-none sm:rounded-bl-none ",
numberVerified ? "hidden" : "mt-3 sm:mt-0"
)}
onClick={() =>
sendVerificationCodeMutation.mutate({
phoneNumber: form.getValues(`steps.${step.stepNumber - 1}.sendTo`) || "",
})
}>
{t("send_code")}
</Button>
</div>

{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && (
<p className="mt-1 text-xs text-red-500">
{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}
</p>
)}
{numberVerified ? (
<div className="mt-1">
<Badge variant="green">{t("number_verified")}</Badge>
</div>
) : (
<>
<Label className="pt-4">{t("custom_phone_number")}</Label>
<div className="block sm:flex">
<PhoneInput<FormValues>
control={form.control}
name={`steps.${step.stepNumber - 1}.sendTo`}
placeholder={t("phone_number")}
id={`steps.${step.stepNumber - 1}.sendTo`}
className="min-w-fit sm:rounded-tl-md sm:rounded-bl-md sm:border-r-transparent"
required
onChange={() => {
const isAlreadyVerified = !!verifiedNumbers
?.concat([])
.find(
(number) => number === form.getValues(`steps.${step.stepNumber - 1}.sendTo`)
);
setNumberVerified(isAlreadyVerified);
<div className="mt-3 flex">
<TextField
className=" border-r-transparent"
placeholder="Verification code"
value={verificationCode}
onChange={(e) => {
setVerificationCode(e.target.value);
}}
required
/>
<Button
color="secondary"
disabled={numberVerified || false}
className={classNames(
"-ml-[3px] h-[40px] min-w-fit sm:block sm:rounded-tl-none sm:rounded-bl-none ",
numberVerified ? "hidden" : "mt-3 sm:mt-0"
)}
onClick={() =>
sendVerificationCodeMutation.mutate({
className="-ml-[3px] rounded-tl-none rounded-bl-none "
disabled={verifyPhoneNumberMutation.isLoading}
onClick={() => {
verifyPhoneNumberMutation.mutate({
phoneNumber: form.getValues(`steps.${step.stepNumber - 1}.sendTo`) || "",
})
}>
{t("send_code")}
code: verificationCode,
});
}}>
Verify
</Button>
Comment on lines 435 to 457

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use an error toast for wrong codes and add minimal client‑side validation

In verifyPhoneNumberMutation.onSuccess, a wrong code currently shows t("wrong_code") with a "success" toast variant. That’s confusing feedback. Also, the Verify button allows submitting with an empty verificationCode and without a phone number guard.

Recommend:

  • Switching the toast variant for the !isVerified branch to an error/severity style.
  • Adding simple guards before verifyPhoneNumberMutation.mutate, e.g. bail out (or set a form error) when !form.getValues(...sendTo) or !verificationCode.trim().
🤖 Prompt for AI Agents
In packages/features/ee/workflows/components/WorkflowStepContainer.tsx around
lines 435 to 457, the verification success handler currently shows
t("wrong_code") with a "success" toast and the Verify button allows submitting
with empty phone or code; change the toast variant in the !isVerified branch to
an error/severity style (e.g., "error" or "danger") so wrong codes show an
error, and add minimal client-side guards before calling
verifyPhoneNumberMutation.mutate: check that
form.getValues(`steps.${step.stepNumber - 1}.sendTo`) is non-empty and that
verificationCode.trim() is non-empty, and if missing either bail out and show a
user-facing error toast or set a form error via the form API; keep the mutation
call only when both checks pass.

</div>

{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && (
<p className="mt-1 text-xs text-red-500">
{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}
</p>
)}
{numberVerified ? (
<div className="mt-1">
<Badge variant="green">{t("number_verified")}</Badge>
</div>
) : (
<>
<div className="mt-3 flex">
<TextField
className=" border-r-transparent"
placeholder="Verification code"
value={verificationCode}
onChange={(e) => {
setVerificationCode(e.target.value);
}}
required
/>
<Button
color="secondary"
className="-ml-[3px] rounded-tl-none rounded-bl-none "
disabled={verifyPhoneNumberMutation.isLoading}
onClick={() => {
verifyPhoneNumberMutation.mutate({
phoneNumber: form.getValues(`steps.${step.stepNumber - 1}.sendTo`) || "",
code: verificationCode,
});
}}>
Verify
</Button>
</div>
</>
)}
</>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,20 +194,41 @@ export const scheduleEmailReminder = async (
}
};

export const deleteScheduledEmailReminder = async (referenceId: string) => {
export const deleteScheduledEmailReminder = async (
reminderId: number,
referenceId: string | null,
immediateDelete?: boolean
) => {
try {
await client.request({
url: "/v3/user/scheduled_sends",
method: "POST",
body: {
batch_id: referenceId,
status: "cancel",
},
});
if (!referenceId) {
await prisma.workflowReminder.delete({
where: {
id: reminderId,
},
});

return;
}

await client.request({
url: `/v3/user/scheduled_sends/${referenceId}`,
method: "DELETE",
if (immediateDelete) {
await client.request({
url: "/v3/user/scheduled_sends",
method: "POST",
body: {
batch_id: referenceId,
status: "cancel",
},
});
return;
}

await prisma.workflowReminder.update({
where: {
id: reminderId,
},
data: {
cancelled: true,
},
});
} catch (error) {
console.log(`Error canceling reminder with error ${error}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,16 @@ export const scheduleSMSReminder = async (
}
};

export const deleteScheduledSMSReminder = async (referenceId: string) => {
export const deleteScheduledSMSReminder = async (reminderId: number, referenceId: string | null) => {
try {
await twilio.cancelSMS(referenceId);
if (referenceId) {
await twilio.cancelSMS(referenceId);
}
await prisma.workflowReminder.delete({
where: {
id: reminderId,
},
});
} catch (error) {
console.log(`Error canceling reminder with error ${error}`);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "WorkflowReminder" ADD COLUMN "cancelled" BOOLEAN;
1 change: 1 addition & 0 deletions packages/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,7 @@ model WorkflowReminder {
scheduled Boolean
workflowStepId Int
workflowStep WorkflowStep @relation(fields: [workflowStepId], references: [id], onDelete: Cascade)
cancelled Boolean?
}

enum WorkflowTemplates {
Expand Down
Loading