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>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-18 14:37:40 +02:00
1 parent a82a48e1c8
commit 1cd5b1ba91
18 files changed
+1022 -59

No files matched your search

+90 -2
View File
@@ -1128,9 +1128,16 @@ func (s *Service) ListClassSessions(ctx context.Context, principal domain.Princi
out := make([]domain.ClassSession, len(sessions))
for i, session := range sessions {
booked := 0
waitlisted := 0
for _, booking := range bookings {
if booking.ClassSessionID != nil && *booking.ClassSessionID == session.ID &&
booking.Status != "cancelled" && booking.Status != "waitlisted" {
if booking.ClassSessionID == nil || *booking.ClassSessionID != session.ID {
continue
}
switch booking.Status {
case "waitlisted":
waitlisted++
case "cancelled":
default:
booked++
}
}
@@ -1145,11 +1152,73 @@ func (s *Service) ListClassSessions(ctx context.Context, principal domain.Princi
EndsAt: session.EndsAt,
Capacity: int(session.Capacity),
BookedCount: booked,
WaitlistCount: waitlisted,
}
}
return out, nil
}
// ListSessionRoster returns the confirmed attendees and the FIFO waitlist of a
// tenant-owned class session for the dashboard roster view.
func (s *Service) ListSessionRoster(ctx context.Context, principal domain.Principal, sessionID string) (domain.SessionRoster, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.SessionRoster{}, ErrTenantMembership
}
session, err := s.repo.GetClassSessionByID(ctx, sessionID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.SessionRoster{}, ErrClassSessionNotFound
}
return domain.SessionRoster{}, err
}
if session.TenantID != membership.Tenant.ID {
return domain.SessionRoster{}, ErrClassSessionNotFound
}
sessionBookings, err := s.repo.ListBookingsBySession(ctx, sessionID)
if err != nil {
return domain.SessionRoster{}, err
}
toEntry := func(booking db.BookingRecord) domain.SessionRosterEntry {
return domain.SessionRosterEntry{
BookingID: booking.ID,
Reference: booking.Reference,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
CustomerPhone: booking.CustomerPhone,
Status: booking.Status,
CreatedAt: booking.CreatedAt,
}
}
roster := domain.SessionRoster{
SessionID: session.ID,
TemplateTitle: session.Title,
StartsAt: session.StartsAt,
EndsAt: session.EndsAt,
Capacity: int(session.Capacity),
Confirmed: []domain.SessionRosterEntry{},
Waitlist: []domain.SessionRosterEntry{},
}
if session.LocationID != nil {
if location, err := s.repo.GetLocationByID(ctx, *session.LocationID); err == nil {
roster.LocationName = location.Name
}
}
for _, booking := range sessionBookings {
switch booking.Status {
case "confirmed":
roster.Confirmed = append(roster.Confirmed, toEntry(booking))
case "waitlisted":
entry := toEntry(booking)
entry.WaitlistPosition = len(roster.Waitlist) + 1
roster.Waitlist = append(roster.Waitlist, entry)
}
}
return roster, nil
}
func (s *Service) CreateClassSession(ctx context.Context, principal domain.Principal, req domain.CreateClassSessionRequest) (domain.ClassSession, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
@@ -1311,6 +1380,25 @@ func (s *Service) CancelBooking(ctx context.Context, principal domain.Principal,
return nil
}
// PromoteBooking confirms one waitlisted class booking on behalf of the
// business. Sessions at capacity require the explicit allowOverCapacity flag;
// the promoted customer receives the standard confirmation email.
func (s *Service) PromoteBooking(ctx context.Context, principal domain.Principal, bookingID string, req domain.PromoteBookingRequest) error {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
promoted, err := bookings.PromoteBooking(ctx, s.repo, membership.Tenant.ID, bookingID, req.AllowOverCapacity)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrBookingNotFound
}
return err
}
go s.sendBookingEmail(promoted, true)
return nil
}
// sendBookingEmail builds the shared payload and fires a confirmation
// (promoted=true) or cancellation notification. Failures are logged-and-done:
// email delivery must not fail the cancellation itself.
@@ -641,6 +641,123 @@ func TestCancelBookingPromotesWaitlist(t *testing.T) {
}
}
func TestListSessionRoster(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
for i := 0; i < 4; i++ {
bookClass(t, repo, fmt.Sprintf("seat%[email protected]", i))
}
bookClass(t, repo, "[email protected]")
bookClass(t, repo, "[email protected]")
roster, err := svc.ListSessionRoster(ctx, owner, demoSessionID)
if err != nil {
t.Fatalf("roster: %v", err)
}
if roster.Capacity != 4 || len(roster.Confirmed) != 4 || len(roster.Waitlist) != 2 {
t.Fatalf("unexpected roster %+v", roster)
}
if roster.Waitlist[0].CustomerEmail != "[email protected]" || roster.Waitlist[0].WaitlistPosition != 1 {
t.Fatalf("expected first waiter at position 1, got %+v", roster.Waitlist[0])
}
if roster.Waitlist[1].WaitlistPosition != 2 {
t.Fatalf("expected second waiter at position 2, got %+v", roster.Waitlist[1])
}
if roster.TemplateTitle != "Small group mobility class" || roster.LocationName != "Main Studio" {
t.Fatalf("unexpected roster header %+v", roster)
}
sessions, _ := svc.ListClassSessions(ctx, owner)
if sessions[0].WaitlistCount != 2 {
t.Fatalf("expected waitlistCount 2, got %d", sessions[0].WaitlistCount)
}
stranger := domain.Principal{Subject: "someone-else", Role: "staff"}
if _, err := svc.ListSessionRoster(ctx, stranger, demoSessionID); !errors.Is(err, ErrTenantMembership) {
t.Fatalf("expected ErrTenantMembership, got %v", err)
}
if _, err := svc.ListSessionRoster(ctx, owner, "00000000-0000-0000-0000-000000000099"); !errors.Is(err, ErrClassSessionNotFound) {
t.Fatalf("expected ErrClassSessionNotFound, got %v", err)
}
}
func TestPromoteBooking(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
for i := 0; i < 4; i++ {
bookClass(t, repo, fmt.Sprintf("seat%[email protected]", i))
}
first := bookClass(t, repo, "[email protected]")
second := bookClass(t, repo, "[email protected]")
// Full session: strict promote is rejected with ErrSessionFull.
if err := svc.PromoteBooking(ctx, owner, second.BookingID, domain.PromoteBookingRequest{}); !errors.Is(err, bookings.ErrSessionFull) {
t.Fatalf("expected ErrSessionFull, got %v", err)
}
// The explicit override confirms anyway — deliberate out-of-order
// overbooking, skipping the first waiter.
if err := svc.PromoteBooking(ctx, owner, second.BookingID, domain.PromoteBookingRequest{AllowOverCapacity: true}); err != nil {
t.Fatalf("over-capacity promote: %v", err)
}
booking, _ := repo.GetBookingByID(ctx, second.BookingID)
if booking.Status != "confirmed" {
t.Fatalf("expected confirmed after override, got %q", booking.Status)
}
// Confirmed bookings, unknown IDs, and foreign principals are rejected.
if err := svc.PromoteBooking(ctx, owner, second.BookingID, domain.PromoteBookingRequest{}); !errors.Is(err, bookings.ErrNotWaitlisted) {
t.Fatalf("expected ErrNotWaitlisted for confirmed booking, got %v", err)
}
if err := svc.PromoteBooking(ctx, owner, "00000000-0000-0000-0000-000000000099", domain.PromoteBookingRequest{}); !errors.Is(err, ErrBookingNotFound) {
t.Fatalf("expected ErrBookingNotFound, got %v", err)
}
stranger := domain.Principal{Subject: "someone-else", Role: "staff"}
if err := svc.PromoteBooking(ctx, stranger, first.BookingID, domain.PromoteBookingRequest{}); !errors.Is(err, ErrTenantMembership) {
t.Fatalf("expected ErrTenantMembership, got %v", err)
}
}
func TestPromoteBookingWithFreeSeat(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
// A waitlisted booking on a session with free capacity — an edge state the
// auto-promoter cannot reach, e.g. a queue entry left after manual edits.
session, err := repo.GetClassSessionByID(ctx, demoSessionID)
if err != nil {
t.Fatalf("get session: %v", err)
}
created, err := repo.CreateBooking(ctx, db.CreateBookingParams{
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
ClassSessionID: &[]string{demoSessionID}[0],
BookingMode: "class",
CustomerName: "Queued Customer",
CustomerEmail: "[email protected]",
StartsAt: session.StartsAt,
EndsAt: session.EndsAt,
Status: "waitlisted",
Reference: "BOK-QUEUED",
ManageToken: "queued-token",
})
if err != nil {
t.Fatalf("seed waitlisted booking: %v", err)
}
if err := svc.PromoteBooking(ctx, owner, created.ID, domain.PromoteBookingRequest{}); err != nil {
t.Fatalf("promote with free seat: %v", err)
}
booking, _ := repo.GetBookingByID(ctx, created.ID)
if booking.Status != "confirmed" {
t.Fatalf("expected confirmed, got %q", booking.Status)
}
}
func TestUpdateClassTemplate(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)