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.ListBookingsBySession(ctx, sessionID) if err != nil { return nil, err } confirmed := 0 var waitlisted []db.BookingRecord for _, booking := range bookings { 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 := confirmWaitlistedBooking(ctx, repo, tenantID, booking); err != nil { return promoted, err } confirmed++ promoted = append(promoted, booking) _ = repo.AppendActivityEvent(ctx, db.ActivityEventParams{ TenantID: tenantID, ActorKind: "system", Action: "booking.promoted", EntityType: "booking", EntityID: &booking.ID, Summary: booking.CustomerName + " — " + session.Title, }) } return promoted, nil } // PromoteBooking manually confirms one waitlisted class booking. Unlike the // automatic FIFO promotion it targets a specific booking; when the session is // full it fails with ErrSessionFull unless allowOverCapacity is set — a // deliberate business override, not a silent overbooking. func PromoteBooking(ctx context.Context, repo db.Repository, tenantID, bookingID string, allowOverCapacity bool) (db.BookingRecord, error) { booking, err := repo.GetBookingByID(ctx, bookingID) if err != nil { return db.BookingRecord{}, err } if booking.TenantID != tenantID { return db.BookingRecord{}, pgx.ErrNoRows } if booking.Status != "waitlisted" || booking.ClassSessionID == nil { return db.BookingRecord{}, ErrNotWaitlisted } sessionID := *booking.ClassSessionID if !allowOverCapacity { session, err := repo.GetClassSessionByID(ctx, sessionID) if err != nil { return db.BookingRecord{}, err } sessionBookings, err := repo.ListBookingsBySession(ctx, sessionID) if err != nil { return db.BookingRecord{}, err } confirmed := 0 for _, other := range sessionBookings { if other.Status == "confirmed" { confirmed++ } } if session.Capacity > 0 && confirmed >= int(session.Capacity) { return db.BookingRecord{}, ErrSessionFull } } if err := confirmWaitlistedBooking(ctx, repo, tenantID, booking); err != nil { return db.BookingRecord{}, err } booking.Status = "confirmed" return booking, nil } // confirmWaitlistedBooking flips a waitlisted booking to confirmed and // re-arms reminder jobs — a booking promoted after its reminder already // dispatched-as-skipped would otherwise go unreminded. func confirmWaitlistedBooking(ctx context.Context, repo db.Repository, tenantID string, booking db.BookingRecord) error { if err := repo.UpdateBookingStatus(ctx, booking.ID, "confirmed"); err != nil { return err } RearmReminderJobs(ctx, repo, tenantID, booking) return nil } // RearmReminderJobs clears pending reminders for a booking and re-arms them // for its (possibly new) start time. Used after promotion, session moves, and // schedule retimes so reminders always match the booking's live window. func RearmReminderJobs(ctx context.Context, repo db.Repository, tenantID string, booking db.BookingRecord) { _ = repo.DeletePendingReminderJobsForBooking(ctx, booking.ID) _ = armReminderJobs(ctx, repo, tenantID, booking.ID, booking.StartsAt, booking.CustomerPhone) } // 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 }