mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
feat(bookings): dashboard cancellation and waitlist promotion
- POST /v1/catalog/bookings/{id}/cancel lets businesses cancel
tenant-owned bookings; two-step confirm in the detail modal
- Shared PromoteWaitlist helper promotes earliest waitlisted bookings
when a class seat frees, on both dashboard and customer cancel paths;
promoted customers get a confirmation email, waitlist ledger marked
resolved
- waitlisted status surfaced in bookings UI: stats card, filter, warning
badge, demo seed, CS+EN strings
- BookingRecord gains created_at for fair ordering; GetBookingByID and
ResolveWaitlistEntry added to the repository surface
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
0405106587
commit
668a54ae1d
15 files changed
+563
-5
No files matched your search
@@ -3,6 +3,7 @@ package catalog
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -547,3 +548,95 @@ func TestDeleteClassSessionPreservesBooking(t *testing.T) {
|
||||
t.Fatal("expected booking class_session_id to be nulled")
|
||||
}
|
||||
}
|
||||
|
||||
const demoSessionID = "4bf74c12-44dd-45ca-86bb-b104f16f2435"
|
||||
|
||||
func bookClass(t *testing.T, repo db.Repository, email string) domain.CreateBookingResponse {
|
||||
t.Helper()
|
||||
session, err := repo.GetClassSessionByID(context.Background(), demoSessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("get session: %v", err)
|
||||
}
|
||||
bookingSvc := bookings.NewService(repo, nil, "")
|
||||
resp, err := bookingSvc.Create(context.Background(), domain.CreateBookingRequest{
|
||||
TenantSlug: "studio-atelier",
|
||||
CustomerName: "Test User",
|
||||
CustomerEmail: email,
|
||||
BookingMode: "class",
|
||||
ClassSessionID: &[]string{demoSessionID}[0],
|
||||
StartsAt: session.StartsAt.UTC().Format(time.RFC3339),
|
||||
EndsAt: session.EndsAt.UTC().Format(time.RFC3339),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("book class for %s: %v", email, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestCancelBookingLifecycle(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
svc := newCatalogService(repo)
|
||||
ctx := context.Background()
|
||||
|
||||
resp := bookClass(t, repo, "[email protected]")
|
||||
|
||||
if err := svc.CancelBooking(ctx, owner, resp.BookingID); err != nil {
|
||||
t.Fatalf("cancel booking: %v", err)
|
||||
}
|
||||
booking, _ := repo.GetBookingByID(ctx, resp.BookingID)
|
||||
if booking.Status != "cancelled" {
|
||||
t.Fatalf("expected cancelled, got %q", booking.Status)
|
||||
}
|
||||
|
||||
if err := svc.CancelBooking(ctx, owner, resp.BookingID); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("expected ErrInvalidInput on second cancel, got %v", err)
|
||||
}
|
||||
if err := svc.CancelBooking(ctx, owner, "00000000-0000-0000-0000-000000000099"); !errors.Is(err, ErrBookingNotFound) {
|
||||
t.Fatalf("expected ErrBookingNotFound, got %v", err)
|
||||
}
|
||||
stranger := domain.Principal{Subject: "someone-else", Role: "staff"}
|
||||
if err := svc.CancelBooking(ctx, stranger, resp.BookingID); !errors.Is(err, ErrTenantMembership) {
|
||||
t.Fatalf("expected ErrTenantMembership, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelBookingPromotesWaitlist(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
svc := newCatalogService(repo)
|
||||
ctx := context.Background()
|
||||
|
||||
// Seeded session capacity is 4 — fill it, then overflow onto the waitlist.
|
||||
var confirmed []domain.CreateBookingResponse
|
||||
for i := 0; i < 4; i++ {
|
||||
confirmed = append(confirmed, bookClass(t, repo, fmt.Sprintf("seat%[email protected]", i)))
|
||||
}
|
||||
first := bookClass(t, repo, "[email protected]")
|
||||
second := bookClass(t, repo, "[email protected]")
|
||||
|
||||
booking, _ := repo.GetBookingByID(ctx, first.BookingID)
|
||||
if booking.Status != "waitlisted" {
|
||||
t.Fatalf("expected waitlisted, got %q", booking.Status)
|
||||
}
|
||||
|
||||
// Cancel a confirmed seat -> earliest waiter is promoted, second stays queued.
|
||||
if err := svc.CancelBooking(ctx, owner, confirmed[0].BookingID); err != nil {
|
||||
t.Fatalf("cancel: %v", err)
|
||||
}
|
||||
booking, _ = repo.GetBookingByID(ctx, first.BookingID)
|
||||
if booking.Status != "confirmed" {
|
||||
t.Fatalf("expected first waiter promoted, got %q", booking.Status)
|
||||
}
|
||||
booking, _ = repo.GetBookingByID(ctx, second.BookingID)
|
||||
if booking.Status != "waitlisted" {
|
||||
t.Fatalf("expected second waiter still queued, got %q", booking.Status)
|
||||
}
|
||||
|
||||
// Free another seat -> second waiter promoted.
|
||||
if err := svc.CancelBooking(ctx, owner, confirmed[1].BookingID); err != nil {
|
||||
t.Fatalf("cancel second: %v", err)
|
||||
}
|
||||
booking, _ = repo.GetBookingByID(ctx, second.BookingID)
|
||||
if booking.Status != "confirmed" {
|
||||
t.Fatalf("expected second waiter promoted, got %q", booking.Status)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user