Comprehensive workflow reminder management for booking lifecycle events
calcom/cal.com#7232Golden Comments (2/2 found)
| # | Comment | Severity | Status | FriendlyReviewer Detail |
|---|---|---|---|---|
| #1 | Asynchronous functions deleteScheduledEmailReminder and deleteScheduledSMSReminder are called without await inside forEach loops. This occurs during booking rescheduling/cancellation, and workflow/workflow step deletion/updates. Consequently, scheduled workflow reminders may not be reliably cancelled, potentially leaving them active. |
MEDIUM | ✓ Found | "handleCancelBooking fires deleteScheduledEmailReminder/deleteScheduledSMSReminder inside a forEach without awaiting, so the cancelled: true write may not persist before the handler returns, letting the stale email fire" (packages/features/bookings/lib/handleCancelBooking.ts L484) |
| #2 | When immediateDelete is true, the deleteScheduledEmailReminder function cancels the SendGrid email but fails to delete the corresponding WorkflowReminder record from the database. This creates orphaned database entries and is inconsistent with the immediateDelete: false path, which marks the record as cancelled. The SendGrid DELETE API call is also omitted in this path. |
HIGH | ✓ Found | "In the immediateDelete branch, the function POSTs cancel to SendGrid and returns without deleting the workflowReminder row or setting cancelled: true. This leaves orphaned rows, diverges from the deferred path, and reintroduces the pending-cancellation accumulation that broke #6991" (packages/features/ee/workflows/lib/reminders/emailReminderManager.ts L213) |
Supplementary Findings (12 findings)
| Finding | File | Severity | Legitimate? |
|---|---|---|---|
The cron cancellation loop awaits each SendGrid request inside a single try/catch — one failure aborts all remaining cancellations and the queued deletes, and may re-fail on retry |
scheduleEmailReminders.ts:53 | HIGH | ✓ Real — a single SendGrid failure silently starves every remaining cancellation |
Reschedule path calls deleteScheduledEmailReminder(reminder.id, reminder.referenceId, true) (immediateDelete) — immediate SendGrid cancel, row left un-flagged/un-deleted, 100-pending-cancellation risk reintroduced |
handleNewBooking.ts:968 | HIGH | ✓ Real — reintroduces the exact limit the PR exists to avoid |
The bookings.tsx reschedule handler calls the cancellation functions without awaiting — the cancelled flag is not durably written |
bookings.tsx:487 | HIGH | ✓ Real — same fire-and-forget class as golden #1 |
Workflow delete mutation cancels reminders fire-and-forget then cascade-deletes the workflowReminder rows — a failed cancellation has no retry path and the email/SMS can still be sent |
workflows.tsx:218 | HIGH | ✓ Real — email can still be sent with no recovery path |
The try/catch in handleNewBooking wraps a forEach that starts async functions without awaiting — it cannot catch async rejections; the cancellation is fire-and-forget with no error handling |
handleNewBooking.ts:961 | MEDIUM | ✓ Real — the guard is structurally useless |
update uses two cancellation strategies: removed event types defer to the cron (cancelled:true), while deleted workflow steps cancel immediately |
workflows.tsx:378 | MEDIUM | ✓ Borderline: real inconsistency, but both strategies do cancel |
Step-deleted path fetches reminders without scheduled/referenceId filters and races the unawaited deletions with the cascade delete, possibly causing swallowed P2025 errors |
workflows.tsx:521 | MEDIUM | ✓ Real — unfiltered fetch plus unawaited delete race |
The cron's remindersToCancel query filters only on cancelled and scheduledDate — no method filter, no referenceId: { not: null } |
scheduleEmailReminders.ts:41 | LOW | ✓ Real — would act on non-email reminders if any were present |
The migration adds cancelled as nullable without a default; DEFAULT false would make the lifecycle explicit |
migration.sql:2 | LOW | ✓ Borderline: style choice, only risky if code ever forgets the value |
| Recurring/seat-only cancellations: the seat-removal path is never confirmed to handle reminders — stale emails may be sent for removed seats | handleCancelBooking.ts:487 | LOW | ✓ Borderline: a confirmation request, not a verified defect |
The cancelled flag is only ever read by the cron; repeated step edits can accumulate stale rows unless the cron deletes them |
workflows.tsx:576 | LOW | ✓ Real but minor — rows accumulate until the cron cleans them |
The PhoneInput required attribute is not backed by RHF rules; sendTo validation is only confirmed elsewhere |
WorkflowStepContainer.tsx:390 | LOW | ✓ Borderline: pre-existing and outside this PR's scope |
Honest Analysis
FriendlyReviewer's full review
As posted on the PR — the complete output from FriendlyReviewer.
Hi there 👋
| 🌥️ Tech | 🌥️ Feat |
|---|---|
| 6 high, 3 medium, 5 low | 2 issues, 1 mitigated |
The MR introduces a cancelled flag on WorkflowReminder and defers actual Email/SMS cancellation to the scheduleEmailReminders cron job to avoid exceeding SendGrid's 100-pending-cancellation limit. The core deferred mechanism is present, but the implementation has critical gaps: several cancellation calls are fire-and-forget (never awaited) in the booking cancel, reschedule, and workflow-delete paths, so the cancelled flag may not be persisted and emails can still be sent for cancelled/rescheduled bookings. Additionally, the reschedule path (handleNewBooking) and the workflow delete path still use the immediate SendGrid cancel, which reintroduces the exact 100-pending issue the PR aims to fix, and leaves orphaned reminder rows. The cron cancellation loop aborts on the first SendGrid error, starving remaining cancellations, and its query lacks a method filter. A last-hour race remains where cancellations within ~1h of send may still deliver. These issues require changes before merge.
These might need a close look
- 🔴 packages/features/ee/workflows/lib/reminders/emailReminderManager.ts (L213)
In theimmediateDeletebranch, the function POSTscancelto SendGrid and returns without deleting theworkflowReminderrow or settingcancelled: true. This leaves orphaned rows, diverges from the deferred path, and reintroduces the pending-cancellation accumulation that broke #6991. - 🔴 packages/features/ee/workflows/api/scheduleEmailReminders.ts (L53)
The cron cancellation loop awaits each SendGrid request inside a single try/catch; a single failure aborts all remaining cancellations and the queued deletes, and may re-fail on retry. - 🔴 packages/features/bookings/lib/handleNewBooking.ts (L968)
The reschedule path inhandleNewBookingcallsdeleteScheduledEmailReminder(reminder.id, reminder.referenceId, true)(immediateDelete), which performs an immediate SendGrid cancel and leaves the row un-flagged/un-deleted, inconsistent withhandleCancelBookingand the other reschedule path, and reintroduces the 100-pending-cancellation risk. - 🔴 packages/features/bookings/lib/handleCancelBooking.ts (L484)
handleCancelBookingfiresdeleteScheduledEmailReminder/deleteScheduledSMSReminderinside a forEach without awaiting, so thecancelled: truewrite may not persist before the handler returns, letting the stale email fire. - 🔴 packages/trpc/server/routers/viewer/bookings.tsx (L487)
Thebookings.tsxreschedule handler similarly calls cancellation functions without awaiting; collect and await the promises to ensure thecancelledflag is durably written. - 🔴 packages/trpc/server/routers/viewer/workflows.tsx (L218)
The workflowdeletemutation cancels reminders fire-and-forget and then cascade-deletes theworkflowReminderrows, so a failed cancellation has no retry path and the email/SMS can still be sent.
Worth checking
- 🟡 packages/features/bookings/lib/handleNewBooking.ts (L961)
Thetry/catchinhandleNewBookingwraps aforEachthat starts async functions without awaiting, so it cannot catch async rejection; the cancellation is fire-and-forget with no error handling. - 🟡 packages/trpc/server/routers/viewer/workflows.tsx (L378)
updateuses two cancellation strategies: removed event types defer to the cron (cancelled:true), while deleted workflow steps cancel immediately; reconcile for consistent 'never send' semantics. - 🟡 packages/trpc/server/routers/viewer/workflows.tsx (L521)
The step-deleted path fetches reminders withoutscheduled/referenceIdfilters and races the unawaited deletions with the cascade delete, possibly causing swallowed P2025 errors; filter and await.
Small things (take or leave)
- 🔵 packages/features/ee/workflows/api/scheduleEmailReminders.ts (L41)
The cron'sremindersToCancelquery filters only oncancelledandscheduledDate; addmethod: WorkflowMethods.EMAIL(andreferenceId: { not: null }) to avoid acting on non-email reminders. - 🔵 packages/prisma/migrations/20230217230604_add_cancelled_to_workflow_reminder/migration.sql (L2)
The migration addscancelledas nullable without a default; considerDEFAULT falsefor an explicit lifecycle. - 🔵 packages/features/bookings/lib/handleCancelBooking.ts (L487)
Recurring/seat-only cancellations: confirm seat-removal cancellations handle reminders; if not, stale emails may be sent for removed seats. - 🔵 packages/trpc/server/routers/viewer/workflows.tsx (L576)
Thecancelledflag is only ever read by the cron; repeated step edits can accumulate stale rows unless the cron deletes them. - 🔵 packages/features/ee/workflows/components/WorkflowStepContainer.tsx (L390)
The PhoneInputrequiredattribute is not backed by RHF rules; confirmsendTovalidation elsewhere (pre-existing).
✅ Feature-level checklist
Not there yet
- ❌ Cancelled and rescheduled bookings must not send workflow reminder emails.
Multiple paths (reschedule via handleNewBooking, workflow delete, and fire-and-forget cancellations in handleCancelBooking/bookings.tsx) can still send emails because the cancellation is not correctly persisted or executed. The cron's error handling can also skip cancellations, and a last-hour race exists. - ❌ The solution must not accumulate more than 100 pending SendGrid cancellations (the root cause of #7225).
TheimmediateDeletepaths inemailReminderManagerandhandleNewBookingstill perform immediate SendGrid cancels, which accumulate and can hit the 100 limit. The deferred cron mechanism is not used consistently.
Partially covered
- ⚠️ Add a
cancelledfield toworkflowReminderand set it when a scheduled email is cancelled; the cronscheduleEmailRemindersmust cancel those within the next hour.
The field and cron query exist, but the flag is not set in the immediateDelete paths, and the cron's loop aborts on error and lacks a method filter, so the mechanism is not fully reliable.
FriendlyReviewer found both golden comments of this PR — recall 100%. The unawaited
forEach(golden #1) is caught athandleCancelBooking.tsL484 with the exact mechanism: thecancelled: truewrite may not persist before the handler returns, letting the stale email fire. TheimmediateDeleteorphan (golden #2) is caught atemailReminderManager.tsL213, and the finding adds the context the reference comment only implies: the orphaned row and the omitted SendGrid DELETE diverge from the deferred path and reintroduce the pending-cancellation accumulation that broke the pre-existing reminder flow (#6991).The review then generalizes the defect class well beyond the two goldens: the same unawaited pattern appears in the
bookings.tsxreschedule handler (L487) and the workflow delete mutation (L218), the cron cancellation loop aborts on the first SendGrid error and starves every remaining cancellation (L53), and the reschedule path still callsimmediateDelete(L968) — arguably as severe as golden #2, since it reintroduces the exact 100-pending-cancellation limit the PR exists to avoid. On that basis the changes-required verdict is well supported: this is not a review that confirms 'the flag exists', it is one that establishes the flag is not reliably written in any path that matters.The 12 supplementary findings split 4 high, 3 medium, 5 low. The two most substantive are the cron abort and the reschedule immediateDelete; the lows are individually legitimate but mostly cosmetic (a migration default, a missing RHF rule, stale-row accumulation), and one is explicitly pre-existing (the PhoneInput at WorkflowStepContainer.tsx L390). No finding looks like a false positive, though several lows would not by themselves justify blocking a merge.