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.
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.
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
ID: booking-notifications-reminders.reminders.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:
// 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.