From d9aa29dbcb9d86ff02847a7b126e7c306af88f1a Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 10:18:03 +0200 Subject: [PATCH] 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> --- apps/backend/internal/api/server.go | 22 +- apps/backend/internal/bookings/service.go | 307 +++++++++++++-- .../backend/internal/bookings/service_test.go | 185 ++++++++- apps/backend/internal/domain/models.go | 19 + apps/backend/openapi/bookra.openapi.yaml | 77 ++++ apps/frontend/src/providers/i18n-provider.tsx | 16 + .../src/routes/public-booking-route.tsx | 357 ++++++++++++++---- packages/api-client/src/generated/types.ts | 65 +++- project.md | 10 +- 9 files changed, 940 insertions(+), 118 deletions(-) diff --git a/apps/backend/internal/api/server.go b/apps/backend/internal/api/server.go index c59bec0..1bceffd 100644 --- a/apps/backend/internal/api/server.go +++ b/apps/backend/internal/api/server.go @@ -323,7 +323,27 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { } server.router.GET("/v1/public/tenants/:tenantSlug/availability", func(c *gin.Context) { - response, err := bookingService.Availability(c.Request.Context(), c.Param("tenantSlug")) + response, err := bookingService.Availability(c.Request.Context(), c.Param("tenantSlug"), bookings.AvailabilityFilter{ + ServiceID: c.Query("serviceId"), + StaffID: c.Query("staffId"), + Date: c.Query("date"), + }) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, bookings.ErrTenantNotFound): + status = http.StatusNotFound + case errors.Is(err, bookings.ErrInvalidBooking): + status = http.StatusBadRequest + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, response) + }) + + server.router.GET("/v1/public/tenants/:tenantSlug/catalog", func(c *gin.Context) { + response, err := bookingService.Catalog(c.Request.Context(), c.Param("tenantSlug")) if err != nil { status := http.StatusInternalServerError if errors.Is(err, bookings.ErrTenantNotFound) { diff --git a/apps/backend/internal/bookings/service.go b/apps/backend/internal/bookings/service.go index d20b55c..f6e0edb 100644 --- a/apps/backend/internal/bookings/service.go +++ b/apps/backend/internal/bookings/service.go @@ -58,7 +58,13 @@ func (n *noopNotifier) SendBusinessNotification(ctx context.Context, businessEma return nil } -func (s *Service) Availability(ctx context.Context, tenantSlug string) (domain.PublicAvailability, error) { +type AvailabilityFilter struct { + ServiceID string + StaffID string + Date string +} + +func (s *Service) Availability(ctx context.Context, tenantSlug string, filter AvailabilityFilter) (domain.PublicAvailability, error) { tenant, err := s.repo.GetTenantBySlug(ctx, tenantSlug) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -67,27 +73,101 @@ func (s *Service) Availability(ctx context.Context, tenantSlug string) (domain.P return domain.PublicAvailability{}, err } + location, err := time.LoadLocation(tenant.Timezone) + if err != nil { + location = time.UTC + } + services, err := s.repo.ListServicesByTenant(ctx, tenant.ID) if err != nil { return domain.PublicAvailability{}, err } + if filter.ServiceID != "" { + found := false + for _, service := range services { + if service.ID == filter.ServiceID { + services = []db.ServiceRecord{service} + found = true + break + } + } + if !found { + return domain.PublicAvailability{}, fmt.Errorf("%w: serviceId is not available for tenant", ErrInvalidBooking) + } + } + + staffRecords, err := s.repo.ListStaffByTenant(ctx, tenant.ID) + if err != nil { + return domain.PublicAvailability{}, err + } + staffNames := make(map[string]string, len(staffRecords)) + for _, staff := range staffRecords { + staffNames[staff.ID] = staff.DisplayName + } + var staffFilter *string + if filter.StaffID != "" { + for _, staff := range staffRecords { + if staff.ID == filter.StaffID && staff.Active { + staffID := staff.ID + staffFilter = &staffID + break + } + } + if staffFilter == nil { + return domain.PublicAvailability{}, fmt.Errorf("%w: staffId is not available for tenant", ErrInvalidBooking) + } + } + rules, err := s.repo.ListAvailabilityRulesByTenant(ctx, tenant.ID) if err != nil { return domain.PublicAvailability{}, err } - windowStart := time.Now().UTC() + + now := time.Now() + windowStart := now.UTC() windowEnd := windowStart.AddDate(0, 0, 7) + var days []time.Time + if filter.Date != "" { + day, err := time.ParseInLocation("2006-01-02", filter.Date, location) + if err != nil { + return domain.PublicAvailability{}, fmt.Errorf("%w: date must be YYYY-MM-DD", ErrInvalidBooking) + } + days = []time.Time{day} + windowStart = day.UTC() + windowEnd = day.AddDate(0, 0, 1).UTC() + } + existingBookings, err := s.repo.ListBookingsByTenantBetween(ctx, tenant.ID, windowStart, windowEnd) if err != nil { return domain.PublicAvailability{}, err } + blockedDays, err := s.repo.ListBlockedDaysByTenant(ctx, tenant.ID, windowStart, windowEnd) + if err != nil { + return domain.PublicAvailability{}, err + } + + if days != nil { + slots := appointmentSlotsForDays(tenant, location, now, days, services, rules, blockedDays, staffNames, existingBookings, staffFilter, 0) + sort.Slice(slots, func(i, j int) bool { return slots[i].StartsAt < slots[j].StartsAt }) + return domain.PublicAvailability{ + TenantSlug: tenant.Slug, + Timezone: tenant.Timezone, + Locale: tenant.Locale, + Slots: slots, + }, nil + } + classSessions, err := s.repo.ListClassSessionsByTenant(ctx, tenant.ID, windowStart, 8) if err != nil { return domain.PublicAvailability{}, err } + previewDays := make([]time.Time, 0, 7) + for offset := 0; offset < 7; offset++ { + previewDays = append(previewDays, now.In(location).AddDate(0, 0, offset)) + } slots := make([]domain.TimeSlot, 0, 8) - slots = append(slots, generateAppointmentSlots(tenant, services, rules, existingBookings)...) + slots = append(slots, appointmentSlotsForDays(tenant, location, now, previewDays, services, rules, blockedDays, staffNames, existingBookings, staffFilter, 4)...) slots = append(slots, generateClassSlots(classSessions, existingBookings)...) sort.Slice(slots, func(i, j int) bool { return slots[i].StartsAt < slots[j].StartsAt }) if len(slots) > 8 { @@ -102,6 +182,51 @@ func (s *Service) Availability(ctx context.Context, tenantSlug string) (domain.P }, nil } +// Catalog returns the public-facing service and staff catalog for a tenant's +// booking page. Only active staff are listed. +func (s *Service) Catalog(ctx context.Context, tenantSlug string) (domain.PublicCatalog, error) { + tenant, err := s.repo.GetTenantBySlug(ctx, tenantSlug) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.PublicCatalog{}, ErrTenantNotFound + } + return domain.PublicCatalog{}, err + } + + services, err := s.repo.ListServicesByTenant(ctx, tenant.ID) + if err != nil { + return domain.PublicCatalog{}, err + } + staffRecords, err := s.repo.ListStaffByTenant(ctx, tenant.ID) + if err != nil { + return domain.PublicCatalog{}, err + } + + catalog := domain.PublicCatalog{ + TenantSlug: tenant.Slug, + Services: make([]domain.PublicService, 0, len(services)), + Staff: make([]domain.PublicStaff, 0, len(staffRecords)), + } + for _, service := range services { + catalog.Services = append(catalog.Services, domain.PublicService{ + ID: service.ID, + Name: service.Name, + DurationMinutes: service.DurationMinutes, + PriceCents: service.PriceCents, + }) + } + for _, staff := range staffRecords { + if !staff.Active { + continue + } + catalog.Staff = append(catalog.Staff, domain.PublicStaff{ + ID: staff.ID, + DisplayName: staff.DisplayName, + }) + } + return catalog, nil +} + func (s *Service) Create(ctx context.Context, request domain.CreateBookingRequest) (domain.CreateBookingResponse, error) { tenant, err := s.repo.GetTenantBySlug(ctx, request.TenantSlug) if err != nil { @@ -165,6 +290,9 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques if !startsAt.Add(expectedDuration).Equal(endsAt) { return domain.CreateBookingResponse{}, fmt.Errorf("%w: appointment duration must match service duration", ErrInvalidBooking) } + if err := s.validateAppointmentTarget(ctx, tenant, request.StaffID, request.LocationID, startsAt, endsAt); err != nil { + return domain.CreateBookingResponse{}, err + } for _, booking := range existing { if booking.Status == "cancelled" { continue @@ -251,6 +379,57 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques }, nil } +// validateAppointmentTarget ensures the requested appointment references +// resources owned by the tenant and lands inside bookable time: the staff and +// location must belong to the tenant, the window must fit an availability rule +// the booking could claim, and it must not overlap a blocked day. +func (s *Service) validateAppointmentTarget(ctx context.Context, tenant db.TenantRecord, staffID, locationID *string, startsAt, endsAt time.Time) error { + if staffID != nil { + staff, err := s.repo.GetStaffByID(ctx, *staffID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("%w: staffId is not available for tenant", ErrInvalidBooking) + } + return err + } + if staff.TenantID != tenant.ID || !staff.Active { + return fmt.Errorf("%w: staffId is not available for tenant", ErrInvalidBooking) + } + } + if locationID != nil { + location, err := s.repo.GetLocationByID(ctx, *locationID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("%w: locationId is not available for tenant", ErrInvalidBooking) + } + return err + } + if location.TenantID != tenant.ID { + return fmt.Errorf("%w: locationId is not available for tenant", ErrInvalidBooking) + } + } + + rules, err := s.repo.ListAvailabilityRulesByTenant(ctx, tenant.ID) + if err != nil { + return err + } + blockedDays, err := s.repo.ListBlockedDaysByTenant(ctx, tenant.ID, startsAt.UTC(), endsAt.UTC()) + if err != nil { + return err + } + location, err := time.LoadLocation(tenant.Timezone) + if err != nil { + location = time.UTC + } + if !fitsAvailabilityWindow(rules, location, staffID, startsAt, endsAt) { + return fmt.Errorf("%w: requested time is outside bookable hours", ErrInvalidBooking) + } + if blockedAt(blockedDays, staffID, startsAt.UTC(), endsAt.UTC()) { + return fmt.Errorf("%w: requested time falls on a blocked day", ErrInvalidBooking) + } + return nil +} + func (s *Service) manageURL(booking db.CreatedBooking) string { return fmt.Sprintf("%s/manage/%s?token=%s", s.frontendURL, booking.Reference, booking.ManageToken) } @@ -485,68 +664,86 @@ func trackingStatus(repo db.Repository, ctx context.Context, tenant db.TenantRec return domain.TrackingStatus{Provider: "umami", Connected: true, SiteID: brand.UmamiSiteID, Message: "Umami tracking is connected."} } -func generateAppointmentSlots( +// appointmentSlotsForDays generates appointment slots for the given tenant-local +// days. When staffFilter is set it acts as the customer's staff preference: +// tenant-wide rules (staffID nil) still apply and produce slots assigned to the +// preferred staff member, while rules scoped to another staff member are +// skipped. capPerService limits total slots per service across all days; a cap +// of 0 means unlimited. +func appointmentSlotsForDays( tenant db.TenantRecord, + location *time.Location, + now time.Time, + days []time.Time, services []db.ServiceRecord, rules []db.AvailabilityRuleRecord, + blockedDays []db.BlockedDayRecord, + staffNames map[string]string, existing []db.BookingRecord, + staffFilter *string, + capPerService int, ) []domain.TimeSlot { if len(services) == 0 || len(rules) == 0 { return nil } - location, err := time.LoadLocation(tenant.Timezone) - if err != nil { - location = time.UTC - } - - now := time.Now().In(location) var slots []domain.TimeSlot - for _, service := range services { serviceSlots := 0 - for dayOffset := 0; dayOffset < 7 && serviceSlots < 4; dayOffset++ { - day := now.AddDate(0, 0, dayOffset) + for _, day := range days { + if capPerService > 0 && serviceSlots >= capPerService { + break + } for _, rule := range rules { if int(day.Weekday()) != rule.DayOfWeek { continue } - - startsLocal, err := time.ParseInLocation("15:04:05", rule.StartsLocal, location) - if err != nil { - continue - } - endsLocal, err := time.ParseInLocation("15:04:05", rule.EndsLocal, location) - if err != nil { + if staffFilter != nil && rule.StaffID != nil && *rule.StaffID != *staffFilter { continue } - windowStart := time.Date(day.Year(), day.Month(), day.Day(), startsLocal.Hour(), startsLocal.Minute(), 0, 0, location) - windowEnd := time.Date(day.Year(), day.Month(), day.Day(), endsLocal.Hour(), endsLocal.Minute(), 0, 0, location) + windowStart, windowEnd, ok := ruleWindow(day, rule, location) + if !ok { + continue + } + + slotStaffID := rule.StaffID + if staffFilter != nil { + slotStaffID = staffFilter + } step := time.Duration(service.DurationMinutes+service.BufferAfterMinutes) * time.Minute duration := time.Duration(service.DurationMinutes) * time.Minute - for slotStart := windowStart; slotStart.Add(duration).Before(windowEnd) || slotStart.Add(duration).Equal(windowEnd); slotStart = slotStart.Add(step) { + for slotStart := windowStart; !slotStart.Add(duration).After(windowEnd); slotStart = slotStart.Add(step) { if slotStart.Before(now.Add(2 * time.Hour)) { continue } slotEnd := slotStart.Add(duration) - if collides(existing, rule.StaffID, nil, slotStart.UTC(), slotEnd.UTC()) { + if collides(existing, slotStaffID, nil, slotStart.UTC(), slotEnd.UTC()) { + continue + } + if blockedAt(blockedDays, slotStaffID, slotStart.UTC(), slotEnd.UTC()) { continue } serviceID := service.ID - slots = append(slots, domain.TimeSlot{ + slot := domain.TimeSlot{ ServiceID: &serviceID, - StaffID: rule.StaffID, + StaffID: slotStaffID, StartsAt: slotStart.UTC().Format(time.RFC3339), EndsAt: slotEnd.UTC().Format(time.RFC3339), Mode: "appointment", Label: service.Name, - }) + } + if slotStaffID != nil { + if name, ok := staffNames[*slotStaffID]; ok { + slot.StaffName = &name + } + } + slots = append(slots, slot) serviceSlots++ - if serviceSlots >= 4 { + if capPerService > 0 && serviceSlots >= capPerService { break } } @@ -557,6 +754,60 @@ func generateAppointmentSlots( return slots } +// ruleWindow resolves a weekly availability rule to concrete start/end times on +// the given day in the tenant's timezone. +func ruleWindow(day time.Time, rule db.AvailabilityRuleRecord, location *time.Location) (time.Time, time.Time, bool) { + startsLocal, err := time.ParseInLocation("15:04:05", rule.StartsLocal, location) + if err != nil { + return time.Time{}, time.Time{}, false + } + endsLocal, err := time.ParseInLocation("15:04:05", rule.EndsLocal, location) + if err != nil { + return time.Time{}, time.Time{}, false + } + windowStart := time.Date(day.Year(), day.Month(), day.Day(), startsLocal.Hour(), startsLocal.Minute(), 0, 0, location) + windowEnd := time.Date(day.Year(), day.Month(), day.Day(), endsLocal.Hour(), endsLocal.Minute(), 0, 0, location) + return windowStart, windowEnd, true +} + +// blockedAt reports whether a tenant-wide blocked day or one scoped to the +// given staff member overlaps the requested window. +func blockedAt(blockedDays []db.BlockedDayRecord, staffID *string, startsAt, endsAt time.Time) bool { + for _, blocked := range blockedDays { + if !(blocked.StartsAt.Before(endsAt) && blocked.EndsAt.After(startsAt)) { + continue + } + if blocked.StaffID == nil || (staffID != nil && *blocked.StaffID == *staffID) { + return true + } + } + return false +} + +// fitsAvailabilityWindow reports whether the requested window falls inside an +// availability rule the booking could claim: a tenant-wide rule, or one scoped +// to the requested staff member. +func fitsAvailabilityWindow(rules []db.AvailabilityRuleRecord, location *time.Location, staffID *string, startsAt, endsAt time.Time) bool { + day := startsAt.In(location) + weekday := int(day.Weekday()) + for _, rule := range rules { + if rule.DayOfWeek != weekday { + continue + } + if rule.StaffID != nil && (staffID == nil || *rule.StaffID != *staffID) { + continue + } + windowStart, windowEnd, ok := ruleWindow(day, rule, location) + if !ok { + continue + } + if !day.Before(windowStart) && !endsAt.In(location).After(windowEnd) { + return true + } + } + return false +} + func generateClassSlots(classSessions []db.ClassSessionRecord, existing []db.BookingRecord) []domain.TimeSlot { slots := make([]domain.TimeSlot, 0, len(classSessions)) for _, session := range classSessions { diff --git a/apps/backend/internal/bookings/service_test.go b/apps/backend/internal/bookings/service_test.go index 5e29390..ce15fb6 100644 --- a/apps/backend/internal/bookings/service_test.go +++ b/apps/backend/internal/bookings/service_test.go @@ -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: "reminder@example.com", - 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: "night@example.com", + 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: "fake@example.com", + StartsAt: appointment.StartsAt, + EndsAt: appointment.EndsAt, + }) + if !errors.Is(err, ErrInvalidBooking) { + t.Fatalf("expected ErrInvalidBooking, got %v", err) + } +} diff --git a/apps/backend/internal/domain/models.go b/apps/backend/internal/domain/models.go index b7287f9..de6ccaf 100644 --- a/apps/backend/internal/domain/models.go +++ b/apps/backend/internal/domain/models.go @@ -114,6 +114,7 @@ type TimeSlot struct { ServiceID *string `json:"serviceId,omitempty"` ClassSessionID *string `json:"classSessionId,omitempty"` StaffID *string `json:"staffId,omitempty"` + StaffName *string `json:"staffName,omitempty"` LocationID *string `json:"locationId,omitempty"` StartsAt string `json:"startsAt"` EndsAt string `json:"endsAt"` @@ -129,6 +130,24 @@ type PublicAvailability struct { Slots []TimeSlot `json:"slots"` } +type PublicService struct { + ID string `json:"id"` + Name string `json:"name"` + DurationMinutes int `json:"durationMinutes"` + PriceCents int `json:"priceCents"` +} + +type PublicStaff struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` +} + +type PublicCatalog struct { + TenantSlug string `json:"tenantSlug"` + Services []PublicService `json:"services"` + Staff []PublicStaff `json:"staff"` +} + type CreateBookingRequest struct { TenantSlug string `json:"tenantSlug"` BookingMode string `json:"bookingMode"` diff --git a/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml index d594edc..5c00bfb 100644 --- a/apps/backend/openapi/bookra.openapi.yaml +++ b/apps/backend/openapi/bookra.openapi.yaml @@ -263,6 +263,27 @@ paths: required: true schema: type: string + - in: query + name: serviceId + required: false + schema: + type: string + format: uuid + description: Restrict appointment slots to a single service + - in: query + name: staffId + required: false + schema: + type: string + format: uuid + description: Restrict appointment slots to a single staff member + - in: query + name: date + required: false + schema: + type: string + format: date + description: Return all slots for a single tenant-local day (YYYY-MM-DD) instead of the capped preview responses: "200": description: Availability for a tenant booking page @@ -270,6 +291,23 @@ paths: application/json: schema: $ref: "#/components/schemas/PublicAvailabilityResponse" + /v1/public/tenants/{tenantSlug}/catalog: + get: + tags: [Public Booking] + operationId: getPublicCatalog + parameters: + - in: path + name: tenantSlug + required: true + schema: + type: string + responses: + "200": + description: Bookable services and active staff for a tenant booking page + content: + application/json: + schema: + $ref: "#/components/schemas/PublicCatalogResponse" /v1/public/bookings: post: tags: [Public Booking] @@ -1270,6 +1308,9 @@ components: type: string format: uuid nullable: true + staffName: + type: string + nullable: true locationId: type: string format: uuid @@ -1303,6 +1344,42 @@ components: type: array items: $ref: "#/components/schemas/TimeSlot" + PublicCatalogResponse: + type: object + required: [tenantSlug, services, staff] + properties: + tenantSlug: + type: string + services: + type: array + items: + $ref: "#/components/schemas/PublicService" + staff: + type: array + items: + $ref: "#/components/schemas/PublicStaff" + PublicService: + type: object + required: [id, name, durationMinutes, priceCents] + properties: + id: + type: string + format: uuid + name: + type: string + durationMinutes: + type: integer + priceCents: + type: integer + PublicStaff: + type: object + required: [id, displayName] + properties: + id: + type: string + format: uuid + displayName: + type: string CreateBookingRequest: type: object required: [tenantSlug, bookingMode, customerName, customerEmail, startsAt, endsAt] diff --git a/apps/frontend/src/providers/i18n-provider.tsx b/apps/frontend/src/providers/i18n-provider.tsx index 91c8b88..0f325c0 100644 --- a/apps/frontend/src/providers/i18n-provider.tsx +++ b/apps/frontend/src/providers/i18n-provider.tsx @@ -475,6 +475,14 @@ const dictionaries = { "booking.selectTimeBody": "Vyberte si z dostupných termínů pro služby a lekce.", "booking.empty": "Momentálně nejsou k dispozici žádné termíny.", "booking.emptyHint": "Zkuste to prosím později.", + "booking.emptyDay": "V tento den není nic volného.", + "booking.step.service": "Vyberte službu", + "booking.step.staff": "S kým?", + "booking.step.date": "Vyberte den", + "booking.step.time": "Vyberte čas", + "booking.staff.any": "Bez preference", + "booking.service.free": "Zdarma", + "booking.classes": "Skupinové lekce", "booking.only": "Zbývají", "booking.left": "místa", "booking.spotsAvailable": "volná místa", @@ -1019,6 +1027,14 @@ const dictionaries = { "booking.selectTimeBody": "Choose from available appointment and class times.", "booking.empty": "No bookable times are available right now.", "booking.emptyHint": "Please check again later.", + "booking.emptyDay": "No times available on this day.", + "booking.step.service": "Choose a service", + "booking.step.staff": "With whom?", + "booking.step.date": "Pick a day", + "booking.step.time": "Pick a time", + "booking.staff.any": "No preference", + "booking.service.free": "Free", + "booking.classes": "Group classes", "booking.only": "Only", "booking.left": "left", "booking.spotsAvailable": "spots available", diff --git a/apps/frontend/src/routes/public-booking-route.tsx b/apps/frontend/src/routes/public-booking-route.tsx index 6a13871..ac09a96 100644 --- a/apps/frontend/src/routes/public-booking-route.tsx +++ b/apps/frontend/src/routes/public-booking-route.tsx @@ -1,10 +1,13 @@ import { A, useParams } from "@solidjs/router"; -import { For, Show, createResource, createSignal } from "solid-js"; +import { For, Show, createMemo, createResource, createSignal } from "solid-js"; import { apiClient } from "../lib/api-client"; import { useI18n } from "../providers/i18n-provider"; import type { components } from "@bookra/api-client/generated/types"; import { BookraCharacter } from "../components/bookra-character"; -import { Button, Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Input, SkeletonCard, Textarea, Tooltip } from "../components/ui"; +import { Button, Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Input, SkeletonCard, Textarea } from "../components/ui"; + +type PublicService = components["schemas"]["PublicService"]; +type PublicStaff = components["schemas"]["PublicStaff"]; export function PublicBookingRoute() { const params = useParams(); @@ -18,7 +21,19 @@ export function PublicBookingRoute() { const [customerPhone, setCustomerPhone] = createSignal(""); const [notes, setNotes] = createSignal(""); const [highlightContact, setHighlightContact] = createSignal(false); + const [selectedServiceId, setSelectedServiceId] = createSignal(null); + const [selectedStaffId, setSelectedStaffId] = createSignal(null); + const [selectedDate, setSelectedDate] = createSignal(null); let contactFormRef: HTMLDivElement | undefined; + + const [catalog] = createResource(() => { + const slug = tenantSlug(); + if (!slug) return null; + return apiClient.GET("/v1/public/tenants/{tenantSlug}/catalog", { + params: { path: { tenantSlug: slug } }, + }); + }); + const [availability, { refetch }] = createResource(() => { const slug = tenantSlug(); if (!slug) return null; @@ -31,6 +46,72 @@ export function PublicBookingRoute() { }); }); + const dayParams = createMemo(() => { + const slug = tenantSlug(); + const serviceId = selectedServiceId(); + const date = selectedDate(); + if (!slug || !serviceId || !date) return null; + return { slug, serviceId, date, staffId: selectedStaffId() }; + }); + const [dayAvailability, { refetch: refetchDay }] = createResource(dayParams, (source) => + apiClient.GET("/v1/public/tenants/{tenantSlug}/availability", { + params: { + path: { tenantSlug: source.slug }, + query: { + serviceId: source.serviceId, + date: source.date, + ...(source.staffId ? { staffId: source.staffId } : {}), + }, + }, + }), + ); + + const services = createMemo(() => catalog.latest?.data?.services ?? []); + const staff = createMemo(() => catalog.latest?.data?.staff ?? []); + const classSlots = createMemo(() => (availability.latest?.data?.slots ?? []).filter((slot) => slot.mode === "class")); + const daySlots = createMemo(() => dayAvailability.latest?.data?.slots ?? []); + const hasCatalog = createMemo(() => services().length > 0); + + const tenantTimezone = createMemo( + () => availability.latest?.data?.timezone ?? dayAvailability.latest?.data?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone, + ); + + const days = createMemo(() => { + const tz = tenantTimezone(); + const locale = i18n.locale(); + const items: { value: string; weekday: string; day: string; month: string }[] = []; + const now = Date.now(); + for (let offset = 0; offset < 14; offset++) { + const date = new Date(now + offset * 86400000); + items.push({ + value: new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit" }).format(date), + weekday: new Intl.DateTimeFormat(locale, { timeZone: tz, weekday: "short" }).format(date), + day: new Intl.DateTimeFormat(locale, { timeZone: tz, day: "numeric" }).format(date), + month: new Intl.DateTimeFormat(locale, { timeZone: tz, month: "short" }).format(date), + }); + } + return items; + }); + + const formatPrice = (priceCents: number) => { + if (!priceCents) return i18n.t("booking.service.free"); + return new Intl.NumberFormat(i18n.locale() === "cs" ? "cs-CZ" : "en-US", { + style: "currency", + currency: "CZK", + maximumFractionDigits: 0, + }).format(priceCents / 100); + }; + + const pickService = (serviceId: string) => { + setSelectedServiceId(serviceId); + setSelectedDate(null); + setBookingError(null); + }; + + const pickStaff = (staffId: string | null) => { + setSelectedStaffId(staffId); + }; + const bookSlot = async (slot: components["schemas"]["TimeSlot"]) => { if (!customerName().trim() || !customerEmail().trim()) { setBookingError(i18n.t("booking.customerRequired")); @@ -77,6 +158,7 @@ export function PublicBookingRoute() { setSubmittingSlot(null); void refetch(); + void refetchDay(); }; return ( @@ -273,7 +355,7 @@ export function PublicBookingRoute() { {/* Loading state */} - +
@@ -281,96 +363,209 @@ export function PublicBookingRoute() {
- {/* Slots list */} -
- -
- -

{i18n.t("booking.empty")}

-

{i18n.t("booking.emptyHint")}

+ + {/* Stepped appointment flow */} + +
+ {/* Service picker */} +
+

{i18n.t("booking.step.service")}

+
+ + {(service) => ( + + )} +
- - } - > - - {(slot, index) => ( - -
-
-
- + + + {/* Staff preference */} + 1}> +
+

{i18n.t("booking.step.staff")}

+
+ + + {(member) => ( + + )} + +
+
+
+ + {/* Day picker */} +
+

{i18n.t("booking.step.date")}

+
+ + {(item) => ( + + )} + +
+
+ + {/* Time grid */} + +
+

{i18n.t("booking.step.time")}

+ +
+
+
+
+
+
+ + +
+ +

{i18n.t("booking.emptyDay")}

+
+
+
+ + {(slot) => ( + + )} + +
+
+ + +
+ + + {/* Class sessions */} + 0}> +
+

{i18n.t("booking.classes")}

+ + {(slot) => ( + +
+
+
+ + {slot.mode} + + + {i18n.t("booking.only")} {slot.remainingCapacity} {i18n.t("booking.left")} + +
+

{slot.label ?? "Bookable slot"}

+
+ + + + + {new Date(slot.startsAt).toLocaleDateString(i18n.locale(), { weekday: 'short', month: 'short', day: 'numeric' })} + + + + + + {new Date(slot.startsAt).toLocaleTimeString(i18n.locale(), { hour: '2-digit', minute: '2-digit' })} - {new Date(slot.endsAt).toLocaleTimeString(i18n.locale(), { hour: '2-digit', minute: '2-digit' })} + +
+ +

+ + + + {slot.remainingCapacity} {i18n.t("booking.spotsAvailable")} +

-

{slot.label ?? "Bookable slot"}

-
- - - - - {new Date(slot.startsAt).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })} - - - - - - {new Date(slot.startsAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })} - {new Date(slot.endsAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })} - -
- -

- - - - {slot.remainingCapacity} {i18n.t("booking.spotsAvailable")} -

-
-
- - -
- - )} - +
+ + )} + +
-
+ + {/* Empty state */} + +
+ +

{i18n.t("booking.empty")}

+

{i18n.t("booking.emptyHint")}

+
+
+
diff --git a/packages/api-client/src/generated/types.ts b/packages/api-client/src/generated/types.ts index d4c1df6..6c45b3b 100644 --- a/packages/api-client/src/generated/types.ts +++ b/packages/api-client/src/generated/types.ts @@ -607,6 +607,22 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/public/tenants/{tenantSlug}/catalog": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getPublicCatalog"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/public/widget.js": { parameters: { query?: never; @@ -1033,6 +1049,11 @@ export interface components { tenantSlug: string; timezone: string; }; + PublicCatalogResponse: { + services: components["schemas"]["PublicService"][]; + staff: components["schemas"]["PublicStaff"][]; + tenantSlug: string; + }; PublicConfig: { /** Format: uri */ apiUrl: string; @@ -1040,6 +1061,18 @@ export interface components { environment: string; neonAuthEnabled: boolean; }; + PublicService: { + durationMinutes: number; + /** Format: uuid */ + id: string; + name: string; + priceCents: number; + }; + PublicStaff: { + displayName: string; + /** Format: uuid */ + id: string; + }; Service: { /** @description Blocked time after the appointment (0-480) */ bufferAfterMinutes: number; @@ -1136,6 +1169,7 @@ export interface components { serviceId?: string | null; /** Format: uuid */ staffId?: string | null; + staffName?: string | null; /** Format: date-time */ startsAt: string; }; @@ -2674,7 +2708,14 @@ export interface operations { }; getPublicAvailability: { parameters: { - query?: never; + query?: { + /** @description Return all slots for a single tenant-local day (YYYY-MM-DD) instead of the capped preview */ + date?: string; + /** @description Restrict appointment slots to a single service */ + serviceId?: string; + /** @description Restrict appointment slots to a single staff member */ + staffId?: string; + }; header?: never; path: { tenantSlug: string; @@ -2694,6 +2735,28 @@ export interface operations { }; }; }; + getPublicCatalog: { + parameters: { + query?: never; + header?: never; + path: { + tenantSlug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Bookable services and active staff for a tenant booking page */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublicCatalogResponse"]; + }; + }; + }; + }; getWidgetScript: { parameters: { query?: never; diff --git a/project.md b/project.md index 034034b..adbcca6 100644 --- a/project.md +++ b/project.md @@ -59,6 +59,13 @@ - booking modal service field is a select bound to the tenant's real services; blocked-day creation supports optional per-staff scoping - deleting a service or staff member preserves bookings (FK `SET NULL`) and removes the member's availability rules (`CASCADE`), mirrored in the in-memory repository - live staging validation, real Neon Auth/Stripe/SMS provider checks, and operational hardening are still open +- `[done/partial]` Milestone 9: Public booking flow with real service/staff selection + - public booking page is now a stepped flow: pick service → optional staff preference → pick day → pick time; class sessions still listed below + - `GET /v1/public/tenants/{slug}/catalog` exposes bookable services and active staff for the booking page + - availability endpoint accepts `serviceId`, `staffId`, and `date` filters; default response is unchanged for the embeddable widget + - slots now carry `staffName`, respect tenant-wide and per-staff blocked days, and are generated per-day without the preview cap when `date` is given + - booking creation hardened: appointment requests must fit inside a matching availability rule, not overlap a blocked day, and staff/location IDs must belong to the tenant with staff active + - remaining: per-staff availability UI, class/session template management, live staging validation ## Overview Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP. @@ -287,6 +294,8 @@ Key entities: - `/dashboard` now includes first-run onboarding instead of only a passive empty state for unassigned authenticated users. - Catalog management now covers services and staff end-to-end; staff creation enforces `MaxStaff` entitlements and staff location assignment validates tenant ownership. - Dashboard summary computes `setupCompletion` from real configured state (brand, location, service, staff, availability rules) rather than a static value. +- Public booking flow offers real service and staff selection via a stepped picker backed by `GET /v1/public/tenants/{slug}/catalog` and filtered availability (`serviceId`, `staffId`, `date`). +- Appointment creation validates that requested times fit inside availability rules, skip blocked days, and reference tenant-owned staff and locations. ## Current Gaps - Neon Auth token extraction is wired structurally, but still needs live browser validation against a real Neon Auth environment. @@ -302,7 +311,6 @@ Key entities: - validate Stripe checkout/webhooks/portal and reminder dispatch against live staging infrastructure - replace noop notification providers with the first production email transport and a gated SMS transport - add management UI for class/session templates and per-staff availability rules so service duration, capacity, and staff schedules are editable after onboarding -- surface service selection and staff assignment on the public booking flow (services currently auto-resolve to the first match) ## Product Principles - calm, premium, structured UX