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