mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 23:51:17 +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"`
|
||||
|
||||
@@ -346,6 +346,10 @@ paths:
|
||||
responses:
|
||||
"200":
|
||||
description: Booking details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CustomerBookingView"
|
||||
"403":
|
||||
description: Invalid manage token
|
||||
"404":
|
||||
@@ -360,29 +364,37 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: token
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Manage token issued at booking time
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [token, startsAt]
|
||||
required: [newStartsAt, newEndsAt]
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
startsAt:
|
||||
newStartsAt:
|
||||
type: string
|
||||
format: date-time
|
||||
endsAt:
|
||||
newEndsAt:
|
||||
type: string
|
||||
format: date-time
|
||||
reason:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Booking rescheduled
|
||||
"400":
|
||||
description: Invalid new window, non-reschedulable status, or class booking
|
||||
"403":
|
||||
description: Invalid manage token
|
||||
"409":
|
||||
description: New slot conflicts
|
||||
description: New slot conflicts or booking already cancelled
|
||||
/v1/public/bookings/{reference}/cancel:
|
||||
post:
|
||||
tags: [Public Booking]
|
||||
@@ -1770,6 +1782,46 @@ components:
|
||||
waitlistPosition:
|
||||
type: integer
|
||||
description: 1-based queue spot for waitlisted class bookings; absent otherwise
|
||||
CustomerBookingView:
|
||||
type: object
|
||||
properties:
|
||||
reference:
|
||||
type: string
|
||||
customerName:
|
||||
type: string
|
||||
customerEmail:
|
||||
type: string
|
||||
service:
|
||||
type: string
|
||||
businessName:
|
||||
type: string
|
||||
tenantSlug:
|
||||
type: string
|
||||
serviceId:
|
||||
type: string
|
||||
format: uuid
|
||||
staffId:
|
||||
type: string
|
||||
format: uuid
|
||||
classSessionId:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Present for session-bound bookings, which cannot be rescheduled to arbitrary times
|
||||
startsAt:
|
||||
type: string
|
||||
format: date-time
|
||||
endsAt:
|
||||
type: string
|
||||
format: date-time
|
||||
location:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
notes:
|
||||
type: string
|
||||
waitlistPosition:
|
||||
type: integer
|
||||
description: 1-based queue spot for waitlisted class bookings; absent otherwise
|
||||
DashboardBookingRequest:
|
||||
type: object
|
||||
required: [customerName, customerEmail, startsAt]
|
||||
|
||||
Reference in new issue
Block a user