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

+15
View File
@@ -721,6 +721,21 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
} }
c.JSON(http.StatusCreated, response) c.JSON(http.StatusCreated, response)
}) })
protected.POST("/catalog/bookings/:bookingID/cancel", func(c *gin.Context) {
err := catalogService.CancelBooking(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("bookingID"))
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrBookingNotFound):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidInput):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
})
// ============================================ // ============================================
// CATALOG API - Blocked Days // CATALOG API - Blocked Days
@@ -26,6 +26,7 @@ type CustomerService struct {
} }
type CustomerNotifier interface { type CustomerNotifier interface {
SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error
SendBookingReschedule(ctx context.Context, data notifications.BookingEmailData) error SendBookingReschedule(ctx context.Context, data notifications.BookingEmailData) error
SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error
} }
@@ -42,6 +43,10 @@ func NewCustomerService(repo db.Repository, notifier CustomerNotifier, frontendU
type customerNoopNotifier struct{} type customerNoopNotifier struct{}
func (n *customerNoopNotifier) SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error {
return nil
}
func (n *customerNoopNotifier) SendBookingReschedule(ctx context.Context, data notifications.BookingEmailData) error { func (n *customerNoopNotifier) SendBookingReschedule(ctx context.Context, data notifications.BookingEmailData) error {
return nil return nil
} }
@@ -194,6 +199,15 @@ func (s *CustomerService) CancelBooking(ctx context.Context, reference string, t
// Send cancellation email (async) // Send cancellation email (async)
go s.sendCancellationEmail(booking) go s.sendCancellationEmail(booking)
// A freed class seat promotes the earliest waitlisted booking.
if booking.ClassSessionID != nil {
if promoted, err := PromoteWaitlist(ctx, s.repo, booking.TenantID, *booking.ClassSessionID); err == nil {
for _, promotedBooking := range promoted {
go s.sendPromotionEmail(promotedBooking)
}
}
}
return nil return nil
} }
@@ -270,3 +284,37 @@ func (s *CustomerService) sendCancellationEmail(booking db.BookingRecord) {
s.notifier.SendBookingCancellation(ctx, emailData) s.notifier.SendBookingCancellation(ctx, emailData)
} }
// sendPromotionEmail confirms a booking that moved off the class waitlist.
func (s *CustomerService) sendPromotionEmail(booking db.BookingRecord) {
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"
}
serviceName, locationName := s.descriptors(ctx, booking)
s.notifier.SendBookingConfirmation(ctx, 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,
ManagementURL: s.managementURL(booking),
})
}
@@ -0,0 +1,64 @@
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.ListBookingsByTenantBetween(ctx, tenantID, session.StartsAt.Add(-time.Second), session.EndsAt.Add(time.Second))
if err != nil {
return nil, err
}
confirmed := 0
var waitlisted []db.BookingRecord
for _, booking := range bookings {
if booking.ClassSessionID == nil || *booking.ClassSessionID != sessionID {
continue
}
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 := repo.UpdateBookingStatus(ctx, booking.ID, "confirmed"); err != nil {
return promoted, err
}
_ = repo.ResolveWaitlistEntry(ctx, tenantID, sessionID, booking.CustomerEmail)
confirmed++
promoted = append(promoted, booking)
}
return promoted, nil
}
@@ -3,6 +3,8 @@ package bookings
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"net/url"
"testing" "testing"
"time" "time"
@@ -399,3 +401,52 @@ func TestCreateAppointmentRejectsForeignStaff(t *testing.T) {
t.Fatalf("expected ErrInvalidBooking, got %v", err) t.Fatalf("expected ErrInvalidBooking, got %v", err)
} }
} }
func TestCustomerCancelPromotesWaitlist(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
customerService := NewCustomerService(repo, nil, "http://localhost:3000")
ctx := context.Background()
session, err := repo.GetClassSessionByID(ctx, "4bf74c12-44dd-45ca-86bb-b104f16f2435")
if err != nil {
t.Fatalf("get session: %v", err)
}
sessionID := session.ID
book := func(email string) domain.CreateBookingResponse {
resp, err := service.Create(ctx, domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "class",
ClassSessionID: &sessionID,
CustomerName: "Test User",
CustomerEmail: email,
StartsAt: session.StartsAt.UTC().Format(time.RFC3339),
EndsAt: session.EndsAt.UTC().Format(time.RFC3339),
})
if err != nil {
t.Fatalf("book %s: %v", email, err)
}
return resp
}
var confirmed []domain.CreateBookingResponse
for i := 0; i < 4; i++ {
confirmed = append(confirmed, book(fmt.Sprintf("seat%[email protected]", i)))
}
waiter := book("[email protected]")
if waiter.Status != "waitlisted" {
t.Fatalf("expected waitlisted, got %s", waiter.Status)
}
// Customer cancels a confirmed seat via the management token.
manageURL, _ := url.Parse(confirmed[0].ManageURL)
token := manageURL.Query().Get("token")
if err := customerService.CancelBooking(ctx, confirmed[0].Reference, token); err != nil {
t.Fatalf("customer cancel: %v", err)
}
booking, _ := repo.GetBookingByID(ctx, waiter.BookingID)
if booking.Status != "confirmed" {
t.Fatalf("expected waiter promoted to confirmed, got %q", booking.Status)
}
}
+108
View File
@@ -11,6 +11,7 @@ import (
"bookra/apps/backend/internal/bookings" "bookra/apps/backend/internal/bookings"
"bookra/apps/backend/internal/db" "bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain" "bookra/apps/backend/internal/domain"
"bookra/apps/backend/internal/notifications"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
) )
@@ -39,6 +40,8 @@ type Service struct {
} }
notificationService interface { notificationService interface {
SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error 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) GetEntitlements(ctx context.Context, tenantID string) (domain.PlanEntitlements, error)
}, notificationService interface { }, notificationService interface {
SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error 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 { }) *Service {
return &Service{repo: repo, billingService: billingService, notificationService: notificationService} return &Service{repo: repo, billingService: billingService, notificationService: notificationService}
} }
@@ -1263,3 +1268,106 @@ func hhmm(value string) string {
} }
return value 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 ( import (
"context" "context"
"errors" "errors"
"fmt"
"testing" "testing"
"time" "time"
@@ -547,3 +548,95 @@ func TestDeleteClassSessionPreservesBooking(t *testing.T) {
t.Fatal("expected booking class_session_id to be nulled") 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)
}
}
+13 -1
View File
@@ -9,7 +9,7 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id, SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference, customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
COALESCE(manage_token, ''), COALESCE(notes, '') COALESCE(manage_token, ''), COALESCE(notes, ''), created_at
FROM bookings FROM bookings
WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2 WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2
ORDER BY starts_at ASC ORDER BY starts_at ASC
@@ -38,6 +38,7 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID
&record.Reference, &record.Reference,
&record.ManageToken, &record.ManageToken,
&record.Notes, &record.Notes,
&record.CreatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -84,6 +85,17 @@ func (r *PGRepository) AppendWaitlistEntry(ctx context.Context, params WaitlistE
return err return err
} }
// ResolveWaitlistEntry marks the pending ledger row for a promoted booking.
// Entries carry no booking_id, so the session + customer email pair is the
// practical key.
func (r *PGRepository) ResolveWaitlistEntry(ctx context.Context, tenantID, sessionID, customerEmail string) error {
_, err := r.pool.Exec(ctx, `
UPDATE waitlist_entries SET status = 'resolved'
WHERE tenant_id = $1 AND class_session_id = $2 AND customer_email = $3 AND status = 'pending'
`, tenantID, sessionID, customerEmail)
return err
}
func (r *PGRepository) CreateReminderJob(ctx context.Context, params ReminderJobParams) error { func (r *PGRepository) CreateReminderJob(ctx context.Context, params ReminderJobParams) error {
_, err := r.pool.Exec(ctx, ` _, err := r.pool.Exec(ctx, `
INSERT INTO reminder_jobs (tenant_id, booking_id, channel, scheduled_for) INSERT INTO reminder_jobs (tenant_id, booking_id, channel, scheduled_for)
+35 -2
View File
@@ -36,6 +36,7 @@ type Repository interface {
ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error)
CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error) CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error)
AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error
ResolveWaitlistEntry(ctx context.Context, tenantID, sessionID, customerEmail string) error
CreateReminderJob(ctx context.Context, params ReminderJobParams) error CreateReminderJob(ctx context.Context, params ReminderJobParams) error
ListDueReminderJobs(ctx context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error) ListDueReminderJobs(ctx context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error)
MarkReminderJobDispatched(ctx context.Context, reminderJobID string, status string, dispatchedAt time.Time) error MarkReminderJobDispatched(ctx context.Context, reminderJobID string, status string, dispatchedAt time.Time) error
@@ -103,6 +104,7 @@ type Repository interface {
// Customer Booking Management // Customer Booking Management
GetBookingByReference(ctx context.Context, reference string) (BookingRecord, error) GetBookingByReference(ctx context.Context, reference string) (BookingRecord, error)
GetBookingByID(ctx context.Context, bookingID string) (BookingRecord, error)
UpdateBookingStatus(ctx context.Context, bookingID string, status string) error UpdateBookingStatus(ctx context.Context, bookingID string, status string) error
RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error
@@ -352,6 +354,7 @@ type BookingRecord struct {
Reference string Reference string
ManageToken string ManageToken string
Notes string Notes string
CreatedAt time.Time
} }
type CreateBookingParams struct { type CreateBookingParams struct {
@@ -719,11 +722,24 @@ func (r *PGRepository) GetBookingByReference(ctx context.Context, reference stri
err := r.pool.QueryRow(ctx, ` err := r.pool.QueryRow(ctx, `
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id, SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference, customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
COALESCE(manage_token, ''), COALESCE(notes, '') COALESCE(manage_token, ''), COALESCE(notes, ''), created_at
FROM bookings FROM bookings
WHERE reference = $1 WHERE reference = $1
`, reference).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID, `, reference).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes) &rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) GetBookingByID(ctx context.Context, bookingID string) (BookingRecord, error) {
var rec BookingRecord
err := r.pool.QueryRow(ctx, `
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
COALESCE(manage_token, ''), COALESCE(notes, ''), created_at
FROM bookings
WHERE id = $1
`, bookingID).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes, &rec.CreatedAt)
return rec, err return rec, err
} }
@@ -1343,6 +1359,7 @@ func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBooking
Reference: params.Reference, Reference: params.Reference,
ManageToken: params.ManageToken, ManageToken: params.ManageToken,
Notes: params.Notes, Notes: params.Notes,
CreatedAt: time.Now().UTC(),
}) })
return created, nil return created, nil
} }
@@ -1352,6 +1369,13 @@ func (r *MemoryRepository) AppendWaitlistEntry(_ context.Context, _ WaitlistEntr
return nil return nil
} }
func (r *MemoryRepository) ResolveWaitlistEntry(_ context.Context, _, _, _ string) error {
if r.waitlistSize > 0 {
r.waitlistSize--
}
return nil
}
func (r *MemoryRepository) CreateReminderJob(_ context.Context, params ReminderJobParams) error { func (r *MemoryRepository) CreateReminderJob(_ context.Context, params ReminderJobParams) error {
reference := params.BookingID reference := params.BookingID
startsAt := time.Now().UTC() startsAt := time.Now().UTC()
@@ -1756,6 +1780,15 @@ func (r *MemoryRepository) GetBookingByReference(_ context.Context, reference st
return BookingRecord{}, pgx.ErrNoRows return BookingRecord{}, pgx.ErrNoRows
} }
func (r *MemoryRepository) GetBookingByID(_ context.Context, bookingID string) (BookingRecord, error) {
for _, b := range r.bookings {
if b.ID == bookingID {
return b, nil
}
}
return BookingRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) UpdateBookingStatus(_ context.Context, bookingID string, status string) error { func (r *MemoryRepository) UpdateBookingStatus(_ context.Context, bookingID string, status string) error {
for i, b := range r.bookings { for i, b := range r.bookings {
if b.ID == bookingID { if b.ID == bookingID {
+20
View File
@@ -800,6 +800,26 @@ paths:
description: Invalid request description: Invalid request
"409": "409":
description: Conflict with existing booking description: Conflict with existing booking
/v1/catalog/bookings/{bookingID}/cancel:
post:
tags: [Catalog]
operationId: cancelDashboardBooking
security:
- bearerAuth: []
parameters:
- in: path
name: bookingID
required: true
schema:
type: string
format: uuid
responses:
"200":
description: Booking cancelled; freed class seats promote the waitlist
"400":
description: Booking already cancelled
"404":
description: Booking not found
/v1/catalog/blocked-days: /v1/catalog/blocked-days:
get: get:
tags: [Catalog] tags: [Catalog]
@@ -309,6 +309,8 @@ const dictionaries = {
"dashboard.totalBookings": "celkem rezervací", "dashboard.totalBookings": "celkem rezervací",
"dashboard.newBooking": "Nová rezervace", "dashboard.newBooking": "Nová rezervace",
"dashboard.bookingDetails": "Detail rezervace", "dashboard.bookingDetails": "Detail rezervace",
"dashboard.booking.cancelBooking": "Zrušit rezervaci",
"dashboard.booking.confirmCancel": "Potvrdit zrušení?",
"dashboard.customerDetails": "Detail zákazníka", "dashboard.customerDetails": "Detail zákazníka",
"dashboard.close": "Zavřít", "dashboard.close": "Zavřít",
"dashboard.edit": "Upravit", "dashboard.edit": "Upravit",
@@ -324,6 +326,7 @@ const dictionaries = {
"dashboard.nextMonth": "Další měsíc", "dashboard.nextMonth": "Další měsíc",
"dashboard.confirmed": "Potvrzeno", "dashboard.confirmed": "Potvrzeno",
"dashboard.pending": "Čeká", "dashboard.pending": "Čeká",
"dashboard.waitlisted": "Čekací listina",
"dashboard.cancelled": "Zrušeno", "dashboard.cancelled": "Zrušeno",
"dashboard.completed": "Dokončeno", "dashboard.completed": "Dokončeno",
"dashboard.welcome.title": "Vítejte v Bookra", "dashboard.welcome.title": "Vítejte v Bookra",
@@ -878,6 +881,8 @@ const dictionaries = {
"dashboard.totalBookings": "total bookings", "dashboard.totalBookings": "total bookings",
"dashboard.newBooking": "New Booking", "dashboard.newBooking": "New Booking",
"dashboard.bookingDetails": "Booking Details", "dashboard.bookingDetails": "Booking Details",
"dashboard.booking.cancelBooking": "Cancel booking",
"dashboard.booking.confirmCancel": "Confirm cancellation?",
"dashboard.customerDetails": "Customer Details", "dashboard.customerDetails": "Customer Details",
"dashboard.close": "Close", "dashboard.close": "Close",
"dashboard.edit": "Edit", "dashboard.edit": "Edit",
@@ -893,6 +898,7 @@ const dictionaries = {
"dashboard.nextMonth": "Next month", "dashboard.nextMonth": "Next month",
"dashboard.confirmed": "Confirmed", "dashboard.confirmed": "Confirmed",
"dashboard.pending": "Pending", "dashboard.pending": "Pending",
"dashboard.waitlisted": "Waitlisted",
"dashboard.cancelled": "Cancelled", "dashboard.cancelled": "Cancelled",
"dashboard.completed": "Completed", "dashboard.completed": "Completed",
"dashboard.welcome.title": "Welcome to Bookra", "dashboard.welcome.title": "Welcome to Bookra",
@@ -9,7 +9,7 @@ import { DashboardLayout, useDashboardData } from "./layout";
function BookingsPage() { function BookingsPage() {
const data = useDashboardData(); const data = useDashboardData();
const [filterStatus, setFilterStatus] = createSignal<"all" | "confirmed" | "pending" | "cancelled" | "completed">("all"); const [filterStatus, setFilterStatus] = createSignal<"all" | "confirmed" | "pending" | "waitlisted" | "cancelled" | "completed">("all");
const [filterDateRange, setFilterDateRange] = createSignal<"all" | "today" | "week" | "month">("all"); const [filterDateRange, setFilterDateRange] = createSignal<"all" | "today" | "week" | "month">("all");
const [searchQuery, setSearchQuery] = createSignal(""); const [searchQuery, setSearchQuery] = createSignal("");
const [showNewBooking, setShowNewBooking] = createSignal(false); const [showNewBooking, setShowNewBooking] = createSignal(false);
@@ -81,6 +81,7 @@ function BookingsPage() {
total: bookings.length, total: bookings.length,
confirmed: bookings.filter((b: any) => b.status === "confirmed").length, confirmed: bookings.filter((b: any) => b.status === "confirmed").length,
pending: bookings.filter((b: any) => b.status === "pending").length, pending: bookings.filter((b: any) => b.status === "pending").length,
waitlisted: bookings.filter((b: any) => b.status === "waitlisted").length,
cancelled: bookings.filter((b: any) => b.status === "cancelled").length, cancelled: bookings.filter((b: any) => b.status === "cancelled").length,
completed: bookings.filter((b: any) => b.status === "completed").length, completed: bookings.filter((b: any) => b.status === "completed").length,
}; };
@@ -111,6 +112,7 @@ function BookingsPage() {
const statusLabels: Record<string, string> = { const statusLabels: Record<string, string> = {
confirmed: data.i18n.t("dashboard.confirmed"), pending: data.i18n.t("dashboard.pending"), confirmed: data.i18n.t("dashboard.confirmed"), pending: data.i18n.t("dashboard.pending"),
waitlisted: data.i18n.t("dashboard.waitlisted"),
cancelled: data.i18n.t("dashboard.cancelled"), completed: data.i18n.t("dashboard.completed") cancelled: data.i18n.t("dashboard.cancelled"), completed: data.i18n.t("dashboard.completed")
}; };
@@ -130,7 +132,7 @@ function BookingsPage() {
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4"> <div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[ {[
{ key: "confirmed", label: data.i18n.t("dashboard.confirmed"), value: bookingStats().confirmed }, { key: "confirmed", label: data.i18n.t("dashboard.confirmed"), value: bookingStats().confirmed },
{ key: "pending", label: data.i18n.t("dashboard.pending"), value: bookingStats().pending }, { key: "waitlisted", label: data.i18n.t("dashboard.waitlisted"), value: bookingStats().waitlisted },
{ key: "cancelled", label: data.i18n.t("dashboard.cancelled"), value: bookingStats().cancelled }, { key: "cancelled", label: data.i18n.t("dashboard.cancelled"), value: bookingStats().cancelled },
{ key: "completed", label: data.i18n.t("dashboard.completed"), value: bookingStats().completed }, { key: "completed", label: data.i18n.t("dashboard.completed"), value: bookingStats().completed },
].map((s) => ( ].map((s) => (
@@ -151,6 +153,7 @@ function BookingsPage() {
{ value: "all", label: data.i18n.t("dashboard.filter.all") }, { value: "all", label: data.i18n.t("dashboard.filter.all") },
{ value: "confirmed", label: data.i18n.t("dashboard.confirmed") }, { value: "confirmed", label: data.i18n.t("dashboard.confirmed") },
{ value: "pending", label: data.i18n.t("dashboard.pending") }, { value: "pending", label: data.i18n.t("dashboard.pending") },
{ value: "waitlisted", label: data.i18n.t("dashboard.waitlisted") },
{ value: "cancelled", label: data.i18n.t("dashboard.cancelled") }, { value: "cancelled", label: data.i18n.t("dashboard.cancelled") },
{ value: "completed", label: data.i18n.t("dashboard.completed") }, { value: "completed", label: data.i18n.t("dashboard.completed") },
]} /> ]} />
@@ -218,6 +221,7 @@ function BookingsPage() {
</button> </button>
<span class={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${ <span class={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${
booking.status === "confirmed" ? "bg-accent/10 text-accent" booking.status === "confirmed" ? "bg-accent/10 text-accent"
: booking.status === "waitlisted" ? "bg-warning-subtle text-warning"
: booking.status === "pending" ? "bg-canvas-muted text-ink-muted" : booking.status === "pending" ? "bg-canvas-muted text-ink-muted"
: "bg-canvas-subtle text-ink-subtle" : "bg-canvas-subtle text-ink-subtle"
}`}> }`}>
@@ -48,6 +48,7 @@ function createDemoData(i18n: ReturnType<typeof useI18n>) {
{ id: "b6", customerName: customers[0].name, customerEmail: customers[0].email, service: services[1], status: "confirmed", startsAt: fmt(at(3, 10)), endsAt: fmt(at(3, 11)), reference: "BOK-006", location: salonA, staffName: staff1, notes: "" }, { id: "b6", customerName: customers[0].name, customerEmail: customers[0].email, service: services[1], status: "confirmed", startsAt: fmt(at(3, 10)), endsAt: fmt(at(3, 11)), reference: "BOK-006", location: salonA, staffName: staff1, notes: "" },
{ id: "b8", customerName: customers[2].name, customerEmail: customers[2].email, service: services[3], status: "pending", startsAt: fmt(at(4, 9)), endsAt: fmt(at(4, 10)), reference: "BOK-008", location: salonA, staffName: staff2, notes: "" }, { id: "b8", customerName: customers[2].name, customerEmail: customers[2].email, service: services[3], status: "pending", startsAt: fmt(at(4, 9)), endsAt: fmt(at(4, 10)), reference: "BOK-008", location: salonA, staffName: staff2, notes: "" },
{ id: "b9", customerName: customers[3].name, customerEmail: customers[3].email, service: services[0], status: "confirmed", startsAt: fmt(at(5, 11)), endsAt: fmt(at(5, 12)), reference: "BOK-009", location: salonA, staffName: staff1, notes: "" }, { id: "b9", customerName: customers[3].name, customerEmail: customers[3].email, service: services[0], status: "confirmed", startsAt: fmt(at(5, 11)), endsAt: fmt(at(5, 12)), reference: "BOK-009", location: salonA, staffName: staff1, notes: "" },
{ id: "b13", customerName: customers[0].name, customerEmail: customers[0].email, service: cs ? "Jóga pro začátečníky" : "Beginner yoga", status: "waitlisted", startsAt: fmt(at(3, 18)), endsAt: fmt(at(3, 19)), reference: "BOK-013", location: salonA, staffName: "", notes: "" },
]; ];
return { return {
@@ -11,6 +11,7 @@ import {
UserCircleIcon, MapPinIcon, ScissorsIcon, UsersIcon UserCircleIcon, MapPinIcon, ScissorsIcon, UsersIcon
} from "../../components/dashboard/icons"; } from "../../components/dashboard/icons";
import { DashboardContext, useDashboardData, DashboardDataProvider } from "./dashboard-data"; import { DashboardContext, useDashboardData, DashboardDataProvider } from "./dashboard-data";
import { apiClient } from "../../lib/api-client";
export { useDashboardData }; export { useDashboardData };
@@ -30,6 +31,31 @@ function DashboardShell(props: { children: JSX.Element }) {
const theme = useTheme(); const theme = useTheme();
const location = useLocation(); const location = useLocation();
const [isMobileMenuOpen, setIsMobileMenuOpen] = createSignal(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = createSignal(false);
const [cancelConfirm, setCancelConfirm] = createSignal<"idle" | "armed" | "busy">("idle");
const handleCancelBooking = async () => {
if (cancelConfirm() === "busy") return;
if (cancelConfirm() === "idle") { setCancelConfirm("armed"); return; }
const booking = data.selectedBooking();
const bearer = data.token();
if (!booking) return;
if (!bearer || bearer.startsWith("demo.")) {
data.setDemoNotice(data.i18n.locale() === "cs" ? "V demo režimu nelze rušit rezervace." : "Cannot cancel bookings in demo mode.");
setCancelConfirm("idle");
return;
}
setCancelConfirm("busy");
try {
const response = await (apiClient as any).POST(`/v1/catalog/bookings/${booking.id}/cancel`, { headers: { Authorization: `Bearer ${bearer}` } });
if (response.error) throw new Error("cancel failed");
data.setShowBookingDetail(false);
void data.refetchSummary();
} catch {
data.setDemoNotice(data.i18n.locale() === "cs" ? "Zrušení rezervace selhalo." : "Failed to cancel booking.");
} finally {
setCancelConfirm("idle");
}
};
const activeSection = () => { const activeSection = () => {
const p = location.pathname; const p = location.pathname;
@@ -242,6 +268,7 @@ function DashboardShell(props: { children: JSX.Element }) {
</div> </div>
<span class={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${ <span class={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
data.selectedBooking()?.status === "confirmed" ? "bg-accent/10 text-accent" data.selectedBooking()?.status === "confirmed" ? "bg-accent/10 text-accent"
: data.selectedBooking()?.status === "waitlisted" ? "bg-warning-subtle text-warning"
: data.selectedBooking()?.status === "pending" ? "bg-canvas-muted text-ink-muted" : data.selectedBooking()?.status === "pending" ? "bg-canvas-muted text-ink-muted"
: "bg-canvas-subtle text-ink-subtle" : "bg-canvas-subtle text-ink-subtle"
}`}> }`}>
@@ -255,6 +282,25 @@ function DashboardShell(props: { children: JSX.Element }) {
</div> </div>
</Show> </Show>
</div> </div>
<Show when={data.selectedBooking()?.status !== "cancelled"}>
<div class="p-6 border-t border-border">
<button
onClick={() => void handleCancelBooking()}
disabled={cancelConfirm() === "busy"}
class={`w-full px-4 py-2.5 rounded-xl text-sm font-medium transition-colors disabled:opacity-60 ${
cancelConfirm() === "armed"
? "bg-red-600 text-canvas hover:bg-red-700"
: "border border-border text-red-600 hover:bg-red-50"
}`}
>
{cancelConfirm() === "busy"
? "…"
: cancelConfirm() === "armed"
? data.i18n.t("dashboard.booking.confirmCancel")
: data.i18n.t("dashboard.booking.cancelBooking")}
</button>
</div>
</Show>
</div> </div>
</div> </div>
</Show> </Show>
@@ -287,6 +287,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/v1/catalog/bookings/{bookingID}/cancel": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["cancelDashboardBooking"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/catalog/class-sessions": { "/v1/catalog/class-sessions": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2030,6 +2046,40 @@ export interface operations {
}; };
}; };
}; };
cancelDashboardBooking: {
parameters: {
query?: never;
header?: never;
path: {
bookingID: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Booking cancelled; freed class seats promote the waitlist */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Booking already cancelled */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Booking not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
listClassSessions: { listClassSessions: {
parameters: { parameters: {
query?: never; query?: never;
+7
View File
@@ -75,6 +75,13 @@
- dashboard zones page gained a staff-scope selector on the working-hours tab (business-wide vs individual schedule) and a Classes tab with template create/delete and session scheduling - dashboard zones page gained a staff-scope selector on the working-hours tab (business-wide vs individual schedule) and a Classes tab with template create/delete and session scheduling
- normalized `availability_rules` time columns (`HH:MM:SS`) down to the `HH:MM` API contract in the service layer - normalized `availability_rules` time columns (`HH:MM:SS`) down to the `HH:MM` API contract in the service layer
- remaining: live staging validation - remaining: live staging validation
- `[done/partial]` Milestone 11: Booking lifecycle — dashboard cancellation and waitlist promotion
- businesses can cancel bookings from the dashboard (`POST /v1/catalog/bookings/{id}/cancel`), tenant-checked, with a two-step confirm in the booking detail modal
- cancelling a class booking (dashboard or customer self-service) now promotes the earliest waitlisted bookings while free capacity remains, ordered by booking `created_at`; promoted customers receive a confirmation email, cancelled customers a cancellation email
- shared `bookings.PromoteWaitlist` helper drives both cancel paths; waitlist ledger entries are marked resolved on promotion
- bookings surface the `waitlisted` status: stats card, filter option, warning-tone badge, demo seed entry; `dashboard.{status}` interpolation resolves the new i18n key automatically
- `BookingRecord` now carries `created_at` (needed for fair waitlist ordering); `GetBookingByID` added to the repository surface
- remaining: live staging validation; waitlist position display and manual promotion controls are not yet exposed
## Overview ## Overview
Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP. Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP.