mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
feat(booking): stepped public booking flow with service/staff selection
The public booking page showed a capped flat preview of slots with no way to pick a service, day, or staff member. Replaces it with a stepped flow: pick service, optional staff preference, pick a day, pick a time. Backend: availability accepts serviceId/staffId/date filters (default response unchanged for the embed widget), a new public catalog endpoint exposes bookable services and active staff, and slots carry staffName. Slot generation and booking creation now honor tenant-wide and per-staff blocked days; appointment requests must fit inside a matching availability rule and reference tenant-owned, active staff and locations. 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
09f780e7a6
commit
d9aa29dbcb
9 files changed
+940
-118
No files matched your search
@@ -14,7 +14,7 @@ func TestCreateAppointmentRejectsConflict(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo, nil, "http://localhost:3000")
|
||||
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier")
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("availability: %v", err)
|
||||
}
|
||||
@@ -71,7 +71,7 @@ func TestCreateClassFallsBackToWaitlistWhenCapacityReached(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo, nil, "http://localhost:3000")
|
||||
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier")
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("availability: %v", err)
|
||||
}
|
||||
@@ -164,7 +164,7 @@ func TestAvailabilityGeneratesUpcomingSlots(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo, nil, "http://localhost:3000")
|
||||
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier")
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("availability: %v", err)
|
||||
}
|
||||
@@ -187,7 +187,7 @@ func TestCreateSchedulesReminderJobForUpcomingAppointment(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo, nil, "http://localhost:3000")
|
||||
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier")
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{Date: nextRuleDay(t, 2)})
|
||||
if err != nil {
|
||||
t.Fatalf("availability: %v", err)
|
||||
}
|
||||
@@ -211,8 +211,8 @@ func TestCreateSchedulesReminderJobForUpcomingAppointment(t *testing.T) {
|
||||
LocationID: appointment.LocationID,
|
||||
CustomerName: "Reminder",
|
||||
CustomerEmail: "[email protected]",
|
||||
StartsAt: time.Now().UTC().Add(30 * time.Hour).Format(time.RFC3339),
|
||||
EndsAt: time.Now().UTC().Add(31 * time.Hour).Format(time.RFC3339),
|
||||
StartsAt: appointment.StartsAt,
|
||||
EndsAt: appointment.EndsAt,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
@@ -226,3 +226,176 @@ func TestCreateSchedulesReminderJobForUpcomingAppointment(t *testing.T) {
|
||||
t.Fatal("expected reminder job to be scheduled")
|
||||
}
|
||||
}
|
||||
|
||||
// nextRuleDay returns the first day at least minOffset days out whose weekday
|
||||
// has a seeded availability rule (Mon–Fri), formatted YYYY-MM-DD.
|
||||
func nextRuleDay(t *testing.T, minOffset int) string {
|
||||
t.Helper()
|
||||
for offset := minOffset; offset < minOffset+7; offset++ {
|
||||
day := time.Now().AddDate(0, 0, offset)
|
||||
if day.Weekday() >= time.Monday && day.Weekday() <= time.Friday {
|
||||
return day.Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
t.Fatal("no rule day found")
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestAvailabilityDateFilterReturnsFullDaySlots(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo, nil, "http://localhost:3000")
|
||||
|
||||
day := nextRuleDay(t, 2)
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{Date: day})
|
||||
if err != nil {
|
||||
t.Fatalf("availability: %v", err)
|
||||
}
|
||||
if len(availability.Slots) == 0 {
|
||||
t.Fatal("expected day slots")
|
||||
}
|
||||
tenantLocation, err := time.LoadLocation(availability.Timezone)
|
||||
if err != nil {
|
||||
t.Fatalf("load timezone: %v", err)
|
||||
}
|
||||
for _, slot := range availability.Slots {
|
||||
if slot.Mode != "appointment" {
|
||||
t.Fatalf("expected only appointment slots, got %s", slot.Mode)
|
||||
}
|
||||
startsAt, err := time.Parse(time.RFC3339, slot.StartsAt)
|
||||
if err != nil {
|
||||
t.Fatalf("parse startsAt: %v", err)
|
||||
}
|
||||
if startsAt.In(tenantLocation).Format("2006-01-02") != day {
|
||||
t.Fatalf("slot %s outside requested day %s", slot.StartsAt, day)
|
||||
}
|
||||
if slot.StaffName == nil || *slot.StaffName == "" {
|
||||
t.Fatal("expected staffName on slot")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailabilityHonorsTenantWideBlockedDay(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo, nil, "http://localhost:3000")
|
||||
|
||||
day := nextRuleDay(t, 2)
|
||||
dayStart, err := time.Parse("2006-01-02", day)
|
||||
if err != nil {
|
||||
t.Fatalf("parse day: %v", err)
|
||||
}
|
||||
if _, err := repo.CreateBlockedDay(context.Background(), db.CreateBlockedDayParams{
|
||||
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
|
||||
StartsAt: dayStart.UTC(),
|
||||
EndsAt: dayStart.Add(26 * time.Hour).UTC(),
|
||||
Kind: "blocked",
|
||||
Reason: "holiday",
|
||||
}); err != nil {
|
||||
t.Fatalf("create blocked day: %v", err)
|
||||
}
|
||||
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{Date: day})
|
||||
if err != nil {
|
||||
t.Fatalf("availability: %v", err)
|
||||
}
|
||||
if len(availability.Slots) != 0 {
|
||||
t.Fatalf("expected no slots on blocked day, got %d", len(availability.Slots))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailabilityStaffFilterValidatesTenantStaff(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo, nil, "http://localhost:3000")
|
||||
foreignStaff := "22222222-2222-2222-2222-222222222222"
|
||||
|
||||
_, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{StaffID: foreignStaff})
|
||||
if !errors.Is(err, ErrInvalidBooking) {
|
||||
t.Fatalf("expected ErrInvalidBooking, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogReturnsServicesAndActiveStaff(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo, nil, "http://localhost:3000")
|
||||
|
||||
catalog, err := service.Catalog(context.Background(), "studio-atelier")
|
||||
if err != nil {
|
||||
t.Fatalf("catalog: %v", err)
|
||||
}
|
||||
if len(catalog.Services) == 0 {
|
||||
t.Fatal("expected services")
|
||||
}
|
||||
if len(catalog.Staff) == 0 {
|
||||
t.Fatal("expected staff")
|
||||
}
|
||||
for _, staff := range catalog.Staff {
|
||||
if staff.DisplayName == "" {
|
||||
t.Fatal("expected display name")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAppointmentRejectsTimeOutsideAvailabilityWindow(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo, nil, "http://localhost:3000")
|
||||
|
||||
catalog, err := service.Catalog(context.Background(), "studio-atelier")
|
||||
if err != nil {
|
||||
t.Fatalf("catalog: %v", err)
|
||||
}
|
||||
serviceID := catalog.Services[0].ID
|
||||
staffID := catalog.Staff[0].ID
|
||||
|
||||
// 03:00 on a rule day is outside the seeded 09:00–17:00 window.
|
||||
day := nextRuleDay(t, 2)
|
||||
startsAt, err := time.Parse(time.RFC3339, day+"T03:00:00Z")
|
||||
if err != nil {
|
||||
t.Fatalf("parse startsAt: %v", err)
|
||||
}
|
||||
_, err = service.Create(context.Background(), domain.CreateBookingRequest{
|
||||
TenantSlug: "studio-atelier",
|
||||
BookingMode: "appointment",
|
||||
ServiceID: &serviceID,
|
||||
StaffID: &staffID,
|
||||
CustomerName: "Night Owl",
|
||||
CustomerEmail: "[email protected]",
|
||||
StartsAt: startsAt.Format(time.RFC3339),
|
||||
EndsAt: startsAt.Add(time.Hour).Format(time.RFC3339),
|
||||
})
|
||||
if !errors.Is(err, ErrInvalidBooking) {
|
||||
t.Fatalf("expected ErrInvalidBooking, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAppointmentRejectsForeignStaff(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo, nil, "http://localhost:3000")
|
||||
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("availability: %v", err)
|
||||
}
|
||||
var appointment domain.TimeSlot
|
||||
for _, slot := range availability.Slots {
|
||||
if slot.Mode == "appointment" {
|
||||
appointment = slot
|
||||
break
|
||||
}
|
||||
}
|
||||
if appointment.StartsAt == "" {
|
||||
t.Fatal("expected appointment slot")
|
||||
}
|
||||
foreignStaff := "22222222-2222-2222-2222-222222222222"
|
||||
_, err = service.Create(context.Background(), domain.CreateBookingRequest{
|
||||
TenantSlug: "studio-atelier",
|
||||
BookingMode: "appointment",
|
||||
ServiceID: appointment.ServiceID,
|
||||
StaffID: &foreignStaff,
|
||||
CustomerName: "Impersonator",
|
||||
CustomerEmail: "[email protected]",
|
||||
StartsAt: appointment.StartsAt,
|
||||
EndsAt: appointment.EndsAt,
|
||||
})
|
||||
if !errors.Is(err, ErrInvalidBooking) {
|
||||
t.Fatalf("expected ErrInvalidBooking, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user