mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
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:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
Reference in new issue
Block a user