mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
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:
1 parent
a82a48e1c8
commit
1cd5b1ba91
18 files changed
+1022
-59
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user