feat(bookings): customer self-service rebooking with real slot picker

CustomerBookingView now carries tenantSlug/serviceId/staffId/
classSessionId so the manage page can query live availability; class
bookings resolve their session title as the display label. Reschedule
rejects waitlisted/completed bookings and session-bound class bookings
(400), collapsing a dead branch in the appointment path.

The manage page reschedule dialog replaces free-form date/time inputs
with a date → availability-slot picker filtered to appointment mode,
so customers can only move to a genuinely free slot with the correct
duration; 409 surfaces "slot just taken". Actions are status-gated:
reschedule for confirmed/pending appointments, cancel for live
bookings, explanatory note for class bookings.

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 14:58:49 +02:00
1 parent 1cd5b1ba91
commit 796c06ae66
7 files changed
+311 -87

No files matched your search

@@ -73,6 +73,12 @@ func (s *CustomerService) managementURL(booking db.BookingRecord) string {
}
func (s *CustomerService) descriptors(ctx context.Context, booking db.BookingRecord) (serviceName string, locationName string) {
if booking.ServiceID == nil && booking.ClassSessionID != nil {
// Class bookings carry no service; the session title is the label.
if session, err := s.repo.GetClassSessionByID(ctx, *booking.ClassSessionID); err == nil {
serviceName = session.Title
}
}
if booking.ServiceID != nil {
if services, err := s.repo.ListServicesByTenant(ctx, booking.TenantID); err == nil {
for _, service := range services {
@@ -119,6 +125,10 @@ func (s *CustomerService) GetBookingByReference(ctx context.Context, reference s
CustomerEmail: booking.CustomerEmail,
Service: serviceName,
BusinessName: tenant.Name,
TenantSlug: tenant.Slug,
ServiceID: booking.ServiceID,
StaffID: booking.StaffID,
ClassSessionID: booking.ClassSessionID,
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
Location: locationName,
@@ -142,6 +152,18 @@ func (s *CustomerService) RescheduleBooking(ctx context.Context, reference strin
return ErrBookingCancelled
}
// Only live bookings may move: waitlisted entries sit in a queue tied to
// their session, and completed bookings are history.
if booking.Status == "waitlisted" || booking.Status == "completed" {
return ErrInvalidReschedule
}
// Class bookings are bound to their session window; moving to another
// session means cancelling and booking again.
if booking.ClassSessionID != nil {
return ErrInvalidReschedule
}
newStartsAt, err := time.Parse(time.RFC3339, req.NewStartsAt)
if err != nil {
return ErrInvalidReschedule
@@ -174,16 +196,14 @@ func (s *CustomerService) RescheduleBooking(ctx context.Context, reference strin
}
}
// Appointments must land inside bookable hours and off blocked days;
// class bookings keep their session-defined window.
if booking.ClassSessionID == nil {
tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID)
if err != nil {
return err
}
if err := validateAppointmentTarget(ctx, s.repo, tenant, booking.StaffID, booking.LocationID, newStartsAt, newEndsAt); err != nil {
return ErrInvalidReschedule
}
// Appointments must land inside bookable hours and off blocked days.
// Class bookings never reach this point — rejected above.
tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID)
if err != nil {
return err
}
if err := validateAppointmentTarget(ctx, s.repo, tenant, booking.StaffID, booking.LocationID, newStartsAt, newEndsAt); err != nil {
return ErrInvalidReschedule
}
if err := s.repo.RescheduleBooking(ctx, booking.ID, newStartsAt, newEndsAt); err != nil {
@@ -111,3 +111,41 @@ func TestRescheduleRejectsConflictingSlot(t *testing.T) {
t.Fatalf("expected ErrBookingConflict, got %v", err)
}
}
func TestRescheduleRejectsClassWaitlistedAndCompleted(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewCustomerService(repo, nil, "http://localhost:3000")
sessionID := "4bf74c12-44dd-45ca-86bb-b104f16f2435"
future := time.Now().Add(48 * time.Hour)
cases := []struct {
name string
status string
sessionID *string
}{
{"class booking", "confirmed", &sessionID},
{"waitlisted booking", "waitlisted", &sessionID},
{"completed booking", "completed", nil},
}
for i, tc := range cases {
_, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
ClassSessionID: tc.sessionID,
StartsAt: future,
EndsAt: future.Add(time.Hour),
Status: tc.status,
Reference: "BOK-RS-" + string(rune('A'+i)),
ManageToken: "tok",
})
if err != nil {
t.Fatalf("%s: create booking: %v", tc.name, err)
}
err = svc.RescheduleBooking(context.Background(), "BOK-RS-"+string(rune('A'+i)), domain.RescheduleBookingRequest{
NewStartsAt: future.Add(2 * time.Hour).Format(time.RFC3339),
NewEndsAt: future.Add(3 * time.Hour).Format(time.RFC3339),
}, "tok")
if err != ErrInvalidReschedule {
t.Fatalf("%s: expected ErrInvalidReschedule, got %v", tc.name, err)
}
}
}
+6
View File
@@ -490,6 +490,12 @@ type CustomerBookingView struct {
CustomerEmail string `json:"customerEmail"`
Service string `json:"service"`
BusinessName string `json:"businessName"`
TenantSlug string `json:"tenantSlug,omitempty"`
ServiceID *string `json:"serviceId,omitempty"`
StaffID *string `json:"staffId,omitempty"`
// ClassSessionID identifies session-bound bookings; they cannot be
// rescheduled to arbitrary times (see RescheduleBooking).
ClassSessionID *string `json:"classSessionId,omitempty"`
StartsAt time.Time `json:"startsAt"`
EndsAt time.Time `json:"endsAt"`
Location string `json:"location"`