package bookings import ( "context" "errors" "sort" "time" "github.com/jackc/pgx/v5" "bookra/apps/backend/internal/db" ) // PromoteWaitlist confirms the earliest waitlisted bookings on a class session // while free capacity remains. It returns the promoted records so callers can // notify customers. A missing session is treated as nothing to promote. func PromoteWaitlist(ctx context.Context, repo db.Repository, tenantID, sessionID string) ([]db.BookingRecord, error) { session, err := repo.GetClassSessionByID(ctx, sessionID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil } return nil, err } bookings, err := repo.ListBookingsByTenantBetween(ctx, tenantID, session.StartsAt.Add(-time.Second), session.EndsAt.Add(time.Second)) if err != nil { return nil, err } confirmed := 0 var waitlisted []db.BookingRecord for _, booking := range bookings { if booking.ClassSessionID == nil || *booking.ClassSessionID != sessionID { continue } switch booking.Status { case "confirmed": confirmed++ case "waitlisted": waitlisted = append(waitlisted, booking) } } if len(waitlisted) == 0 { return nil, nil } sort.SliceStable(waitlisted, func(i, j int) bool { return waitlisted[i].CreatedAt.Before(waitlisted[j].CreatedAt) }) var promoted []db.BookingRecord for _, booking := range waitlisted { if session.Capacity > 0 && confirmed >= int(session.Capacity) { break } if err := repo.UpdateBookingStatus(ctx, booking.ID, "confirmed"); err != nil { 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 }