Scheduled reminders cancelled when booking is modified or cancelled before fire time
Why it matters
A reminder that fires for a cancelled booking is the worst possible notification: the customer shows up for an appointment that no longer exists. A reminder that fires with the original time after a reschedule is functionally identical — the customer is told to show up at the wrong time. CWE-362 (race condition) applies to the in-flight case: a reminder job queued at 8:00 AM for a booking cancelled at 8:01 AM will still fire if the worker does not check current booking status before sending. ISO 25010 reliability.fault-tolerance requires that booking lifecycle changes propagate atomically to all derived state, including scheduled notifications.
Severity rationale
High because sending reminders for cancelled or rescheduled bookings causes customers to show up at wrong times or for non-existent appointments, generating immediate operational and trust failures.
Remediation
On cancellation, null out reminder_due_at and reset reminder_sent. On modification, recalculate reminder_due_at from the new start time. Inside the reminder worker, always check booking.status === 'confirmed' as the final guard:
// src/lib/bookings.ts — cancellation
async function cancelBooking(bookingId: string) {
await db.bookings.update({
where: { id: bookingId },
data: { status: 'cancelled', reminder_due_at: null, reminder_sent: false },
})
}
// src/lib/bookings.ts — modification
async function modifyBooking(bookingId: string, changes: Partial<Booking>) {
const booking = await db.bookings.findUnique({ where: { id: bookingId } })
const service = await db.services.findUnique({ where: { id: booking.serviceId } })
const newStart = changes.start_time ?? booking.start_time
const intervalMs = service.reminder_interval_ms ?? 86400000
await db.bookings.update({
where: { id: bookingId },
data: { ...changes, reminder_due_at: new Date(newStart.getTime() - intervalMs), reminder_sent: false },
})
}
// src/jobs/reminder-worker.ts — in-flight guard
const booking = await db.bookings.findUnique({ where: { id: job.data.bookingId } })
if (booking.status !== 'confirmed') return
Detection
-
ID:
reminder-cancellation -
Severity:
high -
What to look for: Find the booking modification AND cancellation handlers. In each, search for: (1) Cancellation: does the handler reset
reminder_sentand/orreminder_due_at? Or does it remove the pending job from the queue? Quote the relevant code. (2) Modification: when a booking is rescheduled, isreminder_due_atrecalculated based on the new time? Is the old reminder invalidated (flag reset, old job removed)? (3) Edge case: if a reminder is already "in flight" (queued but not yet processed) when the booking is cancelled, will it still fire? Check for a status check inside the reminder worker. -
Pass criteria: Enumerate all 3 conditions. ALL 3 of the following must be met: (1) Booking cancellation invalidates the pending reminder — either by removing the queue job, resetting
reminder_due_atto null, or the reminder worker checks booking status before sending. Quote the mechanism. (2) Booking modification recalculatesreminder_due_at— quote the recalculation. A modification that leaves the old reminder_due_at unchanged does NOT count as pass. (3) At least 1 status guard exists: the reminder worker checksbooking.status === 'confirmed'before sending (guards against in-flight reminders for cancelled bookings). -
Fail criteria: Cancellation does not touch reminder state — reminder still fires after cancellation. Modification does not recalculate reminder time — old reminder fires for wrong time. No status check in reminder worker — in-flight reminders for cancelled bookings still send.
-
Skip (N/A) when: Reminders are not implemented (same criteria as reminder-scheduled-job skip).
-
Detail on fail: For each of the 3 conditions: "MET" or "NOT MET". Quote the relevant handlers. Example:
"(1) Cancellation: NOT MET — cancelBooking() at src/lib/bookings.ts:55 sets status='cancelled' but does not touch reminder_due_at or reminder_sent. (2) Modification: NOT MET — modifyBooking() updates time but does not recalculate reminder_due_at. (3) Worker status check: MET — worker checks booking.status === 'confirmed' at line 10.". -
Remediation: Invalidate reminders on both cancellation and modification:
// src/lib/bookings.ts async function cancelBooking(bookingId: string) { await db.bookings.update({ where: { id: bookingId }, data: { status: 'cancelled', reminder_due_at: null, // Invalidate reminder reminder_sent: false, } }) } async function modifyBooking(bookingId: string, changes: Partial<Booking>) { const booking = await db.bookings.findUnique({ where: { id: bookingId } }) const service = await db.services.findUnique({ where: { id: booking.serviceId } }) const newStartTime = changes.start_time ?? booking.start_time const reminderMs = service.reminder_interval_ms ?? 24 * 60 * 60 * 1000 const newReminderDueAt = new Date(newStartTime.getTime() - reminderMs) await db.bookings.update({ where: { id: bookingId }, data: { ...changes, reminder_due_at: newReminderDueAt, reminder_sent: false, // Reset so new reminder fires } }) } // src/jobs/reminder-worker.ts — always check status before sending // (inside the send-reminder handler) const booking = await db.bookings.findUnique({ where: { id: job.data.bookingId } }) if (booking.status !== 'confirmed') { return // Booking was cancelled or modified — skip } -
Cross-reference: Booking Flow & Lifecycle audit checks modification and cancellation handlers. Booking Calendar Availability audit checks slot recalculation on modification. Database Audit checks cascade behavior.
External references
- cwe · CWE-362 — Race Condition — in-flight reminder fires after cancellation
- iso-25010:2011 · reliability.fault-tolerance
Taxons
History
- 2026-04-18·v1.0.0·Initial import from booking-notifications-reminders·automated