diff --git a/apps/backend/internal/api/server.go b/apps/backend/internal/api/server.go index 6831571..2aab16b 100644 --- a/apps/backend/internal/api/server.go +++ b/apps/backend/internal/api/server.go @@ -721,6 +721,21 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { } 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 diff --git a/apps/backend/internal/bookings/customer_service.go b/apps/backend/internal/bookings/customer_service.go index a8c8ac2..b587709 100644 --- a/apps/backend/internal/bookings/customer_service.go +++ b/apps/backend/internal/bookings/customer_service.go @@ -26,6 +26,7 @@ type CustomerService struct { } type CustomerNotifier interface { + SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error SendBookingReschedule(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{} +func (n *customerNoopNotifier) SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error { + return nil +} + func (n *customerNoopNotifier) SendBookingReschedule(ctx context.Context, data notifications.BookingEmailData) error { return nil } @@ -194,6 +199,15 @@ func (s *CustomerService) CancelBooking(ctx context.Context, reference string, t // Send cancellation email (async) 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 } @@ -270,3 +284,37 @@ func (s *CustomerService) sendCancellationEmail(booking db.BookingRecord) { 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), + }) +} diff --git a/apps/backend/internal/bookings/lifecycle.go b/apps/backend/internal/bookings/lifecycle.go new file mode 100644 index 0000000..5909d30 --- /dev/null +++ b/apps/backend/internal/bookings/lifecycle.go @@ -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 +} diff --git a/apps/backend/internal/bookings/service_test.go b/apps/backend/internal/bookings/service_test.go index ce15fb6..e74ff19 100644 --- a/apps/backend/internal/bookings/service_test.go +++ b/apps/backend/internal/bookings/service_test.go @@ -3,6 +3,8 @@ package bookings import ( "context" "errors" + "fmt" + "net/url" "testing" "time" @@ -399,3 +401,52 @@ func TestCreateAppointmentRejectsForeignStaff(t *testing.T) { 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%d@example.com", i))) + } + waiter := book("waiter@example.com") + 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) + } +} diff --git a/apps/backend/internal/catalog/service.go b/apps/backend/internal/catalog/service.go index 9c1282b..eccdef8 100644 --- a/apps/backend/internal/catalog/service.go +++ b/apps/backend/internal/catalog/service.go @@ -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) + } +} diff --git a/apps/backend/internal/catalog/service_test.go b/apps/backend/internal/catalog/service_test.go index b95a125..1d5adba 100644 --- a/apps/backend/internal/catalog/service_test.go +++ b/apps/backend/internal/catalog/service_test.go @@ -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, "cancel-me@example.com") + + 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%d@example.com", i))) + } + first := bookClass(t, repo, "first-waiter@example.com") + second := bookClass(t, repo, "second-waiter@example.com") + + 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) + } +} diff --git a/apps/backend/internal/db/bookings.go b/apps/backend/internal/db/bookings.go index 378ead2..1e0d92f 100644 --- a/apps/backend/internal/db/bookings.go +++ b/apps/backend/internal/db/bookings.go @@ -9,7 +9,7 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID rows, err := r.pool.Query(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, '') + COALESCE(manage_token, ''), COALESCE(notes, ''), created_at FROM bookings WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2 ORDER BY starts_at ASC @@ -38,6 +38,7 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID &record.Reference, &record.ManageToken, &record.Notes, + &record.CreatedAt, ); err != nil { return nil, err } @@ -84,6 +85,17 @@ func (r *PGRepository) AppendWaitlistEntry(ctx context.Context, params WaitlistE 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 { _, err := r.pool.Exec(ctx, ` INSERT INTO reminder_jobs (tenant_id, booking_id, channel, scheduled_for) diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index b00cc5d..9cde1d2 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -36,6 +36,7 @@ type Repository interface { ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error) AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error + ResolveWaitlistEntry(ctx context.Context, tenantID, sessionID, customerEmail string) error CreateReminderJob(ctx context.Context, params ReminderJobParams) error ListDueReminderJobs(ctx context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error) MarkReminderJobDispatched(ctx context.Context, reminderJobID string, status string, dispatchedAt time.Time) error @@ -103,6 +104,7 @@ type Repository interface { // Customer Booking Management 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 RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error @@ -352,6 +354,7 @@ type BookingRecord struct { Reference string ManageToken string Notes string + CreatedAt time.Time } type CreateBookingParams struct { @@ -719,11 +722,24 @@ func (r *PGRepository) GetBookingByReference(ctx context.Context, reference stri 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, '') + COALESCE(manage_token, ''), COALESCE(notes, ''), created_at FROM bookings WHERE reference = $1 `, 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 } @@ -1343,6 +1359,7 @@ func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBooking Reference: params.Reference, ManageToken: params.ManageToken, Notes: params.Notes, + CreatedAt: time.Now().UTC(), }) return created, nil } @@ -1352,6 +1369,13 @@ func (r *MemoryRepository) AppendWaitlistEntry(_ context.Context, _ WaitlistEntr 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 { reference := params.BookingID startsAt := time.Now().UTC() @@ -1756,6 +1780,15 @@ func (r *MemoryRepository) GetBookingByReference(_ context.Context, reference st 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 { for i, b := range r.bookings { if b.ID == bookingID { diff --git a/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml index 2fb2f5b..3fb2345 100644 --- a/apps/backend/openapi/bookra.openapi.yaml +++ b/apps/backend/openapi/bookra.openapi.yaml @@ -800,6 +800,26 @@ paths: description: Invalid request "409": 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: get: tags: [Catalog] diff --git a/apps/frontend/src/providers/i18n-provider.tsx b/apps/frontend/src/providers/i18n-provider.tsx index 906599f..36d6d0f 100644 --- a/apps/frontend/src/providers/i18n-provider.tsx +++ b/apps/frontend/src/providers/i18n-provider.tsx @@ -309,6 +309,8 @@ const dictionaries = { "dashboard.totalBookings": "celkem rezervací", "dashboard.newBooking": "Nová rezervace", "dashboard.bookingDetails": "Detail rezervace", + "dashboard.booking.cancelBooking": "Zrušit rezervaci", + "dashboard.booking.confirmCancel": "Potvrdit zrušení?", "dashboard.customerDetails": "Detail zákazníka", "dashboard.close": "Zavřít", "dashboard.edit": "Upravit", @@ -324,6 +326,7 @@ const dictionaries = { "dashboard.nextMonth": "Další měsíc", "dashboard.confirmed": "Potvrzeno", "dashboard.pending": "Čeká", + "dashboard.waitlisted": "Čekací listina", "dashboard.cancelled": "Zrušeno", "dashboard.completed": "Dokončeno", "dashboard.welcome.title": "Vítejte v Bookra", @@ -878,6 +881,8 @@ const dictionaries = { "dashboard.totalBookings": "total bookings", "dashboard.newBooking": "New Booking", "dashboard.bookingDetails": "Booking Details", + "dashboard.booking.cancelBooking": "Cancel booking", + "dashboard.booking.confirmCancel": "Confirm cancellation?", "dashboard.customerDetails": "Customer Details", "dashboard.close": "Close", "dashboard.edit": "Edit", @@ -893,6 +898,7 @@ const dictionaries = { "dashboard.nextMonth": "Next month", "dashboard.confirmed": "Confirmed", "dashboard.pending": "Pending", + "dashboard.waitlisted": "Waitlisted", "dashboard.cancelled": "Cancelled", "dashboard.completed": "Completed", "dashboard.welcome.title": "Welcome to Bookra", diff --git a/apps/frontend/src/routes/dashboard/bookings-page.tsx b/apps/frontend/src/routes/dashboard/bookings-page.tsx index ff8d3e8..a59133d 100644 --- a/apps/frontend/src/routes/dashboard/bookings-page.tsx +++ b/apps/frontend/src/routes/dashboard/bookings-page.tsx @@ -9,7 +9,7 @@ import { DashboardLayout, useDashboardData } from "./layout"; function BookingsPage() { 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 [searchQuery, setSearchQuery] = createSignal(""); const [showNewBooking, setShowNewBooking] = createSignal(false); @@ -81,6 +81,7 @@ function BookingsPage() { total: bookings.length, confirmed: bookings.filter((b: any) => b.status === "confirmed").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, completed: bookings.filter((b: any) => b.status === "completed").length, }; @@ -111,6 +112,7 @@ function BookingsPage() { const statusLabels: Record = { 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") }; @@ -130,7 +132,7 @@ function BookingsPage() {
{[ { 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: "completed", label: data.i18n.t("dashboard.completed"), value: bookingStats().completed }, ].map((s) => ( @@ -151,6 +153,7 @@ function BookingsPage() { { value: "all", label: data.i18n.t("dashboard.filter.all") }, { value: "confirmed", label: data.i18n.t("dashboard.confirmed") }, { 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: "completed", label: data.i18n.t("dashboard.completed") }, ]} /> @@ -218,6 +221,7 @@ function BookingsPage() { diff --git a/apps/frontend/src/routes/dashboard/dashboard-data.ts b/apps/frontend/src/routes/dashboard/dashboard-data.ts index 3a1eca2..ba1a72d 100644 --- a/apps/frontend/src/routes/dashboard/dashboard-data.ts +++ b/apps/frontend/src/routes/dashboard/dashboard-data.ts @@ -48,6 +48,7 @@ function createDemoData(i18n: ReturnType) { { 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: "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 { diff --git a/apps/frontend/src/routes/dashboard/layout.tsx b/apps/frontend/src/routes/dashboard/layout.tsx index 0e8d27e..fcf0ca2 100644 --- a/apps/frontend/src/routes/dashboard/layout.tsx +++ b/apps/frontend/src/routes/dashboard/layout.tsx @@ -11,6 +11,7 @@ import { UserCircleIcon, MapPinIcon, ScissorsIcon, UsersIcon } from "../../components/dashboard/icons"; import { DashboardContext, useDashboardData, DashboardDataProvider } from "./dashboard-data"; +import { apiClient } from "../../lib/api-client"; export { useDashboardData }; @@ -30,6 +31,31 @@ function DashboardShell(props: { children: JSX.Element }) { const theme = useTheme(); const location = useLocation(); 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 p = location.pathname; @@ -242,6 +268,7 @@ function DashboardShell(props: { children: JSX.Element }) {
@@ -255,6 +282,25 @@ function DashboardShell(props: { children: JSX.Element }) { + +
+ +
+
diff --git a/packages/api-client/src/generated/types.ts b/packages/api-client/src/generated/types.ts index 1f51cd6..f03edce 100644 --- a/packages/api-client/src/generated/types.ts +++ b/packages/api-client/src/generated/types.ts @@ -287,6 +287,22 @@ export interface paths { patch?: 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": { parameters: { 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: { parameters: { query?: never; diff --git a/project.md b/project.md index 458b2cf..cf5ace0 100644 --- a/project.md +++ b/project.md @@ -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 - normalized `availability_rules` time columns (`HH:MM:SS`) down to the `HH:MM` API contract in the service layer - 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 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.