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:
Tomas DvorakandDevin committed 2026-09-18 11:21:46 +02:00
1 parent 0405106587
commit 668a54ae1d
15 files changed
+563 -5

No files matched your search

+108
View File
@@ -11,6 +11,7 @@ import (
"bookra/apps/backend/internal/bookings"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"bookra/apps/backend/internal/notifications"
"github.com/jackc/pgx/v5"
)
@@ -39,6 +40,8 @@ type Service struct {
}
notificationService interface {
SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error
SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error
SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error
}
}
@@ -46,6 +49,8 @@ func NewService(repo db.Repository, billingService interface {
GetEntitlements(ctx context.Context, tenantID string) (domain.PlanEntitlements, error)
}, notificationService interface {
SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error
SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error
SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error
}) *Service {
return &Service{repo: repo, billingService: billingService, notificationService: notificationService}
}
@@ -1263,3 +1268,106 @@ func hhmm(value string) string {
}
return value
}
// ============================================
// BOOKING LIFECYCLE
// ============================================
// CancelBooking cancels a tenant-owned booking from the dashboard. Cancelling a
// class booking frees the seat and promotes the earliest waitlisted bookings.
func (s *Service) CancelBooking(ctx context.Context, principal domain.Principal, bookingID string) error {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
booking, err := s.repo.GetBookingByID(ctx, bookingID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrBookingNotFound
}
return err
}
if booking.TenantID != membership.Tenant.ID {
return ErrBookingNotFound
}
if booking.Status == "cancelled" {
return fmt.Errorf("%w: booking already cancelled", ErrInvalidInput)
}
if err := s.repo.UpdateBookingStatus(ctx, booking.ID, "cancelled"); err != nil {
return err
}
go s.sendBookingEmail(booking, false)
if booking.ClassSessionID != nil {
promoted, err := bookings.PromoteWaitlist(ctx, s.repo, booking.TenantID, *booking.ClassSessionID)
if err != nil {
return nil
}
for _, promotedBooking := range promoted {
go s.sendBookingEmail(promotedBooking, 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.
func (s *Service) sendBookingEmail(booking db.BookingRecord, promoted bool) {
if s.notificationService == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID)
if err != nil {
return
}
brand, _ := s.repo.GetBrandProfile(ctx, tenant.ID)
brandColor := brand.PrimaryColor
if brandColor == "" {
brandColor = "#a65c3e"
}
var serviceName, locationName string
if booking.ServiceID != nil {
if service, err := s.repo.GetServiceByID(ctx, *booking.ServiceID); err == nil {
serviceName = service.Name
}
}
if booking.ClassSessionID != nil {
if session, err := s.repo.GetClassSessionByID(ctx, *booking.ClassSessionID); err == nil {
serviceName = session.Title
}
}
if booking.LocationID != nil {
if location, err := s.repo.GetLocationByID(ctx, *booking.LocationID); err == nil {
locationName = location.Name
}
}
data := notifications.BookingEmailData{
TenantID: tenant.ID,
TenantName: tenant.Name,
TenantSlug: tenant.Slug,
BrandColor: brandColor,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
Service: serviceName,
Location: locationName,
Reference: booking.Reference,
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
Timezone: tenant.Timezone,
Locale: tenant.Locale,
}
if promoted {
s.notificationService.SendBookingConfirmation(ctx, data)
} else {
s.notificationService.SendBookingCancellation(ctx, data)
}
}
@@ -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)
}
}