# Scheduled reminders cancelled when booking is modified or cancelled before fire time

- **Pattern:** `ab-000574` (`booking-notifications-reminders.reminders.reminder-cancellation`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000574
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000574)

## 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:

```ts
// 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_sent` and/or `reminder_due_at`? Or does it remove the pending job from the queue? Quote the relevant code. (2) Modification: when a booking is rescheduled, is `reminder_due_at` recalculated 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_at` to null, or the reminder worker checks booking status before sending. Quote the mechanism. (2) Booking modification recalculates `reminder_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 checks `booking.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:

  ```ts
  // 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
- iso-25010 reliability.fault-tolerance

Taxons: error-resilience, data-integrity

HTML version: https://auditbuffet.com/patterns/ab-000574
