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:
Tomas DvorakandDevin committed 2026-09-18 10:18:03 +02:00
1 parent 09f780e7a6
commit d9aa29dbcb
9 files changed
+940 -118

No files matched your search

+279 -28
View File
@@ -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 {