From e4d95bf504f30ef99966cc4fbde836a74c524d53 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 13:18:39 +0200 Subject: [PATCH] fix(reminders): re-arm reminder jobs on waitlist promotion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A booking promoted off the waitlist after its reminder had already dispatched-as-skipped went unreminded. Promotion now clears pending jobs and re-arms via the shared armReminderJobs helper — extracted from booking creation so both paths arm email plus opt-in SMS. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/backend/internal/bookings/lifecycle.go | 34 +++++++++++++++++++++ apps/backend/internal/bookings/service.go | 22 ++----------- project.md | 1 + 3 files changed, 37 insertions(+), 20 deletions(-) diff --git a/apps/backend/internal/bookings/lifecycle.go b/apps/backend/internal/bookings/lifecycle.go index 5909d30..c3d65b5 100644 --- a/apps/backend/internal/bookings/lifecycle.go +++ b/apps/backend/internal/bookings/lifecycle.go @@ -57,8 +57,42 @@ func PromoteWaitlist(ctx context.Context, repo db.Repository, tenantID, sessionI return promoted, err } _ = repo.ResolveWaitlistEntry(ctx, tenantID, sessionID, booking.CustomerEmail) + // A booking promoted after its reminder already dispatched-as-skipped + // would otherwise go unreminded; re-arm whatever the horizon allows. + _ = repo.DeletePendingReminderJobsForBooking(ctx, booking.ID) + _ = armReminderJobs(ctx, repo, tenantID, booking.ID, booking.StartsAt, booking.CustomerPhone) confirmed++ promoted = append(promoted, booking) } return promoted, nil } + +// armReminderJobs schedules the email reminder — and the SMS reminder when the +// tenant opted into SMS and a phone number is on file — for a booking whose +// start still falls inside a reminder horizon. A start too close or too far +// away simply arms nothing. +func armReminderJobs(ctx context.Context, repo db.Repository, tenantID, bookingID string, startsAt time.Time, customerPhone string) error { + scheduledFor, ok := reminderSchedule(startsAt) + if !ok { + return nil + } + if err := repo.CreateReminderJob(ctx, db.ReminderJobParams{ + TenantID: tenantID, + BookingID: bookingID, + Channel: "email", + ScheduledFor: scheduledFor, + }); err != nil { + return err + } + if customerPhone != "" { + if smsSettings, err := repo.GetTenantSMSSettings(ctx, tenantID); err == nil && smsSettings.Enabled { + _ = repo.CreateReminderJob(ctx, db.ReminderJobParams{ + TenantID: tenantID, + BookingID: bookingID, + Channel: "sms", + ScheduledFor: scheduledFor, + }) + } + } + return nil +} diff --git a/apps/backend/internal/bookings/service.go b/apps/backend/internal/bookings/service.go index 9c817d4..6fc9196 100644 --- a/apps/backend/internal/bookings/service.go +++ b/apps/backend/internal/bookings/service.go @@ -357,26 +357,8 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques } } - if scheduledFor, ok := reminderSchedule(startsAt); ok { - if err := s.repo.CreateReminderJob(ctx, db.ReminderJobParams{ - TenantID: tenant.ID, - BookingID: created.ID, - Channel: "email", - ScheduledFor: scheduledFor, - }); err != nil { - return domain.CreateBookingResponse{}, err - } - // SMS reminders are a per-tenant opt-in and need a customer phone. - if request.CustomerPhone != "" { - if smsSettings, err := s.repo.GetTenantSMSSettings(ctx, tenant.ID); err == nil && smsSettings.Enabled { - _ = s.repo.CreateReminderJob(ctx, db.ReminderJobParams{ - TenantID: tenant.ID, - BookingID: created.ID, - Channel: "sms", - ScheduledFor: scheduledFor, - }) - } - } + if err := armReminderJobs(ctx, s.repo, tenant.ID, created.ID, startsAt, request.CustomerPhone); err != nil { + return domain.CreateBookingResponse{}, err } // Send confirmation emails (async - don't fail booking if email fails) diff --git a/project.md b/project.md index 29fb89a..756880a 100644 --- a/project.md +++ b/project.md @@ -119,6 +119,7 @@ - `[done/partial]` Milestone 18: Public class list polish - class sessions on the public booking page are now grouped by day (localized weekday + date headers) with compact timetable-style rows instead of one tall card per session — consistent with the stepped appointment picker above - capacity states read honestly: spots-left in success/warning tones, `Obsazeno`/`Full` when empty, and the action switches to `Na čekací listinu`/`Join waitlist` (previously a full session still said "Book now" while silently producing a waitlisted booking) + - reminder arming extracted into a shared `armReminderJobs` helper (email + opt-in SMS) used by booking creation and waitlist promotion — a booking promoted after its reminder already dispatched-as-skipped now gets re-armed instead of going unreminded - remaining: live staging validation ## Overview