Files
Bookra/apps/backend/internal/bookings/lifecycle.go
T
Tomas DvorakandDevin 1cd5b1ba91 feat(classes): session roster + manual waitlist promotion
Adds the roster and manual promotion controls deferred from the booking
lifecycle milestones. Businesses can now inspect who holds a seat and who
is queueing on a class session, and promote waitlisted customers by hand
— including a deliberate over-capacity override when the session is full.

Backend:
- GET /v1/catalog/class-sessions/{id}/roster — session metadata plus
  confirmed and waitlist entry lists with queue positions
- POST /v1/catalog/bookings/{id}/promote — 409 on a full session unless
  allowOverCapacity is set; 400 for non-waitlisted, 404 for missing or
  foreign bookings; reuses the shared confirm path so waitlist ledger
  rows resolve and reminder jobs re-arm
- ListBookingsBySession repository method (pgx + memory); ClassSession
  carries waitlistCount; UpcomingBooking carries classSessionId and
  waitlistPosition
- OpenAPI paths + schemas, regenerated client, catalog service tests

Frontend:
- classes tab session rows expand into a two-column roster panel with
  per-entry promote buttons and an explicit confirm-anyway step on 409
- booking detail modal shows waitlist position and offers promote for
  waitlisted bookings; list badges show #N queue positions
- CS/EN i18n keys and seeded demo roster data; demo mutations surface
  the demo notice

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-18 14:37:40 +02:00

148 lines
4.6 KiB
Go

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, sessionID, booking); err != nil {
return promoted, err
}
confirmed++
promoted = append(promoted, booking)
}
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, sessionID, booking); err != nil {
return db.BookingRecord{}, err
}
booking.Status = "confirmed"
return booking, nil
}
// confirmWaitlistedBooking flips a waitlisted booking to confirmed, resolves
// the ledger entry, 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, sessionID string, booking db.BookingRecord) error {
if err := repo.UpdateBookingStatus(ctx, booking.ID, "confirmed"); err != nil {
return err
}
_ = repo.ResolveWaitlistEntry(ctx, tenantID, sessionID, booking.CustomerEmail)
_ = repo.DeletePendingReminderJobsForBooking(ctx, booking.ID)
_ = armReminderJobs(ctx, repo, tenantID, booking.ID, booking.StartsAt, booking.CustomerPhone)
return 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
}