fix(reminders): lifecycle-aware jobs and dispatch skip guard

The reminder pipeline fired regardless of what happened to the booking
after creation: cancelled customers got reminded about bookings that
weren't happening, reschedules kept the old reminder time, and
waitlisted bookings armed jobs too. Pending jobs are now cancelled on
both cancel paths, deleted and re-armed on reschedule, and dispatch
joins bookings.status to skip anything not confirmed. Reschedule also
gained the same availability-window and blocked-day validation as
booking creation, closing a 3am-reschedule hole via the manage URL.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-18 12:47:07 +02:00
1 parent 0dc26e0c16
commit cdad7875e1
12 files changed
+272 -9

No files matched your search

@@ -168,10 +168,35 @@ func (s *CustomerService) RescheduleBooking(ctx context.Context, reference strin
}
}
// Appointments must land inside bookable hours and off blocked days;
// class bookings keep their session-defined window.
if booking.ClassSessionID == nil {
tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID)
if err != nil {
return err
}
if err := validateAppointmentTarget(ctx, s.repo, tenant, booking.StaffID, booking.LocationID, newStartsAt, newEndsAt); err != nil {
return ErrInvalidReschedule
}
}
if err := s.repo.RescheduleBooking(ctx, booking.ID, newStartsAt, newEndsAt); err != nil {
return err
}
// Re-arm the reminder for the new time; a booking moved inside the
// no-reminder horizon simply gets none.
if err := s.repo.DeletePendingReminderJobsForBooking(ctx, booking.ID); err == nil {
if scheduledFor, ok := reminderSchedule(newStartsAt); ok {
_ = s.repo.CreateReminderJob(ctx, db.ReminderJobParams{
TenantID: booking.TenantID,
BookingID: booking.ID,
Channel: "email",
ScheduledFor: scheduledFor,
})
}
}
// Send reschedule email (async)
go s.sendRescheduleEmail(booking, newStartsAt, newEndsAt)
@@ -195,6 +220,7 @@ func (s *CustomerService) CancelBooking(ctx context.Context, reference string, t
if err := s.repo.UpdateBookingStatus(ctx, booking.ID, "cancelled"); err != nil {
return err
}
_ = s.repo.CancelPendingReminderJobsForBooking(ctx, booking.ID)
// Send cancellation email (async)
go s.sendCancellationEmail(booking)