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 {