Merge pull request #2 from Dvorinka/feat/catalog-services-staff
CI / Frontend (push) Successful in 10m35s
CI / Go - apps/backend (push) Successful in 10m34s
CI / Docker publish - backend (push) Failing after 10s

Booking platform: public flow, classes, lifecycle, metrics, SMS reminders, race hardening
This commit is contained in:
Tomáš Dvořák authored and GitHub committed 2026-09-18 13:46:57 +02:00
commit a82a48e1c8
34 files changed
+7126 -231

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
name: Reminder Dispatch
on:
schedule:
# Every 15 minutes. GitHub scheduled workflows may run a few minutes late;
# reminder jobs are timestamp-based so lateness only delays, never skips.
- cron: "*/15 * * * *"
workflow_dispatch:
permissions: {}
jobs:
dispatch:
name: Dispatch due reminders
runs-on: ubuntu-latest
steps:
- name: Call dispatch endpoint
env:
API_URL: ${{ secrets.BOOKRA_API_URL }}
JOB_KEY: ${{ secrets.BOOKRA_JOB_RUNNER_KEY }}
run: |
if [ -z "$API_URL" ] || [ -z "$JOB_KEY" ]; then
echo "BOOKRA_API_URL / BOOKRA_JOB_RUNNER_KEY secrets not configured — skipping."
exit 0
fi
status=$(curl -sS -o /tmp/dispatch.json -w "%{http_code}" \
-X POST "$API_URL/v1/internal/jobs/reminders/dispatch" \
-H "Content-Type: application/json" \
-H "X-Bookra-Job-Key: $JOB_KEY" \
-d '{"limit": 100}')
cat /tmp/dispatch.json
test "$status" = "200"
+332 -1
View File
@@ -57,6 +57,10 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
authService := auth.NewService(repository, cfg.AuthJWTSecret)
adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey)
smsService := sms.NewService(cfg, repository)
notificationService.SetSMSSender(func(ctx context.Context, tenantID, phone, body string) error {
_, err := smsService.SendMessage(ctx, tenantID, domain.SendSMSRequest{To: phone, Body: body})
return err
})
publicRateLimiter := httpx.NewRateLimiter(rate.Every(time.Second), 5)
server := &Server{
@@ -323,7 +327,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) {
@@ -541,6 +565,144 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
})
// ============================================
// CATALOG API - Services
// ============================================
protected.GET("/catalog/services", func(c *gin.Context) {
response, err := catalogService.ListServices(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.POST("/catalog/services", func(c *gin.Context) {
var request domain.CreateServiceRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.CreateService(c.Request.Context(), auth.PrincipalFromContext(c), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidInput):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, response)
})
protected.PUT("/catalog/services/:serviceID", func(c *gin.Context) {
var request domain.UpdateServiceRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.UpdateService(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("serviceID"), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrServiceNotFound):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidInput):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.DELETE("/catalog/services/:serviceID", func(c *gin.Context) {
err := catalogService.DeleteService(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("serviceID"))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) || errors.Is(err, catalog.ErrServiceNotFound) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
})
// ============================================
// CATALOG API - Staff
// ============================================
protected.GET("/catalog/staff", func(c *gin.Context) {
response, err := catalogService.ListStaff(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.POST("/catalog/staff", func(c *gin.Context) {
var request domain.CreateStaffRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.CreateStaff(c.Request.Context(), auth.PrincipalFromContext(c), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidInput):
status = http.StatusBadRequest
case errors.Is(err, catalog.ErrPlanLimitReached):
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, response)
})
protected.PUT("/catalog/staff/:staffID", func(c *gin.Context) {
var request domain.UpdateStaffRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.UpdateStaff(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("staffID"), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrStaffNotFound):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidInput):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.DELETE("/catalog/staff/:staffID", func(c *gin.Context) {
err := catalogService.DeleteStaff(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("staffID"))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) || errors.Is(err, catalog.ErrStaffNotFound) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
})
protected.POST("/catalog/bookings", func(c *gin.Context) {
var request domain.DashboardBookingRequest
if err := c.ShouldBindJSON(&request); err != nil {
@@ -563,6 +725,21 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
}
c.JSON(http.StatusCreated, response)
})
protected.POST("/catalog/bookings/:bookingID/cancel", func(c *gin.Context) {
err := catalogService.CancelBooking(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("bookingID"))
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrBookingNotFound):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidInput):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
})
// ============================================
// CATALOG API - Blocked Days
@@ -746,6 +923,160 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusOK, gin.H{"status": "updated"})
})
// Staff-scoped working hours (per-person availability rules)
protected.GET("/catalog/staff/:staffID/working-hours", func(c *gin.Context) {
response, err := catalogService.ListStaffWorkingHours(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("staffID"))
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrStaffNotFound):
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.PUT("/catalog/staff/:staffID/working-hours/:dayOfWeek", func(c *gin.Context) {
dayOfWeek, err := strconv.Atoi(c.Param("dayOfWeek"))
if err != nil || dayOfWeek < 0 || dayOfWeek > 6 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_day_of_week"})
return
}
var request domain.UpdateWorkingHoursRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
err = catalogService.UpdateStaffWorkingHours(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("staffID"), dayOfWeek, request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrStaffNotFound):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidBooking):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "updated"})
})
// ============================================
// CATALOG API - Classes
// ============================================
protected.GET("/catalog/class-templates", func(c *gin.Context) {
response, err := catalogService.ListClassTemplates(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.POST("/catalog/class-templates", func(c *gin.Context) {
var request domain.UpsertClassTemplateRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.CreateClassTemplate(c.Request.Context(), auth.PrincipalFromContext(c), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidInput):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, response)
})
protected.PUT("/catalog/class-templates/:templateID", func(c *gin.Context) {
var request domain.UpsertClassTemplateRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.UpdateClassTemplate(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("templateID"), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrClassTemplateNotFound):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidInput):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.DELETE("/catalog/class-templates/:templateID", func(c *gin.Context) {
err := catalogService.DeleteClassTemplate(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("templateID"))
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrClassTemplateNotFound):
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
})
protected.GET("/catalog/class-sessions", func(c *gin.Context) {
response, err := catalogService.ListClassSessions(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.POST("/catalog/class-sessions", func(c *gin.Context) {
var request domain.CreateClassSessionRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.CreateClassSession(c.Request.Context(), auth.PrincipalFromContext(c), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrClassTemplateNotFound), errors.Is(err, catalog.ErrLocationNotFound):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidInput):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, response)
})
protected.DELETE("/catalog/class-sessions/:sessionID", func(c *gin.Context) {
err := catalogService.DeleteClassSession(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("sessionID"))
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrClassSessionNotFound):
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
})
// ============================================
// CUSTOMER BOOKING MANAGEMENT API (Public)
// ============================================
@@ -26,6 +26,7 @@ type CustomerService struct {
}
type CustomerNotifier interface {
SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error
SendBookingReschedule(ctx context.Context, data notifications.BookingEmailData) error
SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error
}
@@ -42,6 +43,10 @@ func NewCustomerService(repo db.Repository, notifier CustomerNotifier, frontendU
type customerNoopNotifier struct{}
func (n *customerNoopNotifier) SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error {
return nil
}
func (n *customerNoopNotifier) SendBookingReschedule(ctx context.Context, data notifications.BookingEmailData) error {
return nil
}
@@ -103,17 +108,23 @@ func (s *CustomerService) GetBookingByReference(ctx context.Context, reference s
serviceName, locationName := s.descriptors(ctx, booking)
position := 0
if booking.Status == "waitlisted" && booking.ClassSessionID != nil {
position, _ = s.repo.GetWaitlistPosition(ctx, *booking.ClassSessionID, booking.ID)
}
return domain.CustomerBookingView{
Reference: booking.Reference,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
Service: serviceName,
BusinessName: tenant.Name,
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
Location: locationName,
Status: booking.Status,
Notes: booking.Notes,
Reference: booking.Reference,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
Service: serviceName,
BusinessName: tenant.Name,
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
Location: locationName,
Status: booking.Status,
Notes: booking.Notes,
WaitlistPosition: position,
}, nil
}
@@ -163,10 +174,38 @@ func (s *CustomerService) RescheduleBooking(ctx context.Context, reference strin
}
}
// Appointments must land inside bookable hours and off blocked days;
// class bookings keep their session-defined window.
if booking.ClassSessionID == nil {
tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID)
if err != nil {
return err
}
if err := validateAppointmentTarget(ctx, s.repo, tenant, booking.StaffID, booking.LocationID, newStartsAt, newEndsAt); err != nil {
return ErrInvalidReschedule
}
}
if err := s.repo.RescheduleBooking(ctx, booking.ID, newStartsAt, newEndsAt); err != nil {
if db.IsExclusionViolation(err) {
return ErrBookingConflict
}
return err
}
// Re-arm the reminder for the new time; a booking moved inside the
// no-reminder horizon simply gets none.
if err := s.repo.DeletePendingReminderJobsForBooking(ctx, booking.ID); err == nil {
if scheduledFor, ok := reminderSchedule(newStartsAt); ok {
_ = s.repo.CreateReminderJob(ctx, db.ReminderJobParams{
TenantID: booking.TenantID,
BookingID: booking.ID,
Channel: "email",
ScheduledFor: scheduledFor,
})
}
}
// Send reschedule email (async)
go s.sendRescheduleEmail(booking, newStartsAt, newEndsAt)
@@ -190,10 +229,20 @@ func (s *CustomerService) CancelBooking(ctx context.Context, reference string, t
if err := s.repo.UpdateBookingStatus(ctx, booking.ID, "cancelled"); err != nil {
return err
}
_ = s.repo.CancelPendingReminderJobsForBooking(ctx, booking.ID)
// Send cancellation email (async)
go s.sendCancellationEmail(booking)
// A freed class seat promotes the earliest waitlisted booking.
if booking.ClassSessionID != nil {
if promoted, err := PromoteWaitlist(ctx, s.repo, booking.TenantID, *booking.ClassSessionID); err == nil {
for _, promotedBooking := range promoted {
go s.sendPromotionEmail(promotedBooking)
}
}
}
return nil
}
@@ -270,3 +319,37 @@ func (s *CustomerService) sendCancellationEmail(booking db.BookingRecord) {
s.notifier.SendBookingCancellation(ctx, emailData)
}
// sendPromotionEmail confirms a booking that moved off the class waitlist.
func (s *CustomerService) sendPromotionEmail(booking db.BookingRecord) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID)
if err != nil {
return
}
brand, _ := s.repo.GetBrandProfile(ctx, tenant.ID)
brandColor := brand.PrimaryColor
if brandColor == "" {
brandColor = "#a65c3e"
}
serviceName, locationName := s.descriptors(ctx, booking)
s.notifier.SendBookingConfirmation(ctx, notifications.BookingEmailData{
TenantID: tenant.ID,
TenantName: tenant.Name,
TenantSlug: tenant.Slug,
BrandColor: brandColor,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
Service: serviceName,
Location: locationName,
Reference: booking.Reference,
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
Timezone: tenant.Timezone,
Locale: tenant.Locale,
ManagementURL: s.managementURL(booking),
})
}
@@ -0,0 +1,98 @@
package bookings
import (
"context"
"errors"
"sort"
"time"
"github.com/jackc/pgx/v5"
"bookra/apps/backend/internal/db"
)
// PromoteWaitlist confirms the earliest waitlisted bookings on a class session
// while free capacity remains. It returns the promoted records so callers can
// notify customers. A missing session is treated as nothing to promote.
func PromoteWaitlist(ctx context.Context, repo db.Repository, tenantID, sessionID string) ([]db.BookingRecord, error) {
session, err := repo.GetClassSessionByID(ctx, sessionID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
bookings, err := repo.ListBookingsByTenantBetween(ctx, tenantID, session.StartsAt.Add(-time.Second), session.EndsAt.Add(time.Second))
if err != nil {
return nil, err
}
confirmed := 0
var waitlisted []db.BookingRecord
for _, booking := range bookings {
if booking.ClassSessionID == nil || *booking.ClassSessionID != sessionID {
continue
}
switch booking.Status {
case "confirmed":
confirmed++
case "waitlisted":
waitlisted = append(waitlisted, booking)
}
}
if len(waitlisted) == 0 {
return nil, nil
}
sort.SliceStable(waitlisted, func(i, j int) bool {
return waitlisted[i].CreatedAt.Before(waitlisted[j].CreatedAt)
})
var promoted []db.BookingRecord
for _, booking := range waitlisted {
if session.Capacity > 0 && confirmed >= int(session.Capacity) {
break
}
if err := repo.UpdateBookingStatus(ctx, booking.ID, "confirmed"); err != nil {
return promoted, err
}
_ = repo.ResolveWaitlistEntry(ctx, tenantID, sessionID, booking.CustomerEmail)
// A booking promoted after its reminder already dispatched-as-skipped
// would otherwise go unreminded; re-arm whatever the horizon allows.
_ = repo.DeletePendingReminderJobsForBooking(ctx, booking.ID)
_ = armReminderJobs(ctx, repo, tenantID, booking.ID, booking.StartsAt, booking.CustomerPhone)
confirmed++
promoted = append(promoted, booking)
}
return promoted, nil
}
// armReminderJobs schedules the email reminder — and the SMS reminder when the
// tenant opted into SMS and a phone number is on file — for a booking whose
// start still falls inside a reminder horizon. A start too close or too far
// away simply arms nothing.
func armReminderJobs(ctx context.Context, repo db.Repository, tenantID, bookingID string, startsAt time.Time, customerPhone string) error {
scheduledFor, ok := reminderSchedule(startsAt)
if !ok {
return nil
}
if err := repo.CreateReminderJob(ctx, db.ReminderJobParams{
TenantID: tenantID,
BookingID: bookingID,
Channel: "email",
ScheduledFor: scheduledFor,
}); err != nil {
return err
}
if customerPhone != "" {
if smsSettings, err := repo.GetTenantSMSSettings(ctx, tenantID); err == nil && smsSettings.Enabled {
_ = repo.CreateReminderJob(ctx, db.ReminderJobParams{
TenantID: tenantID,
BookingID: bookingID,
Channel: "sms",
ScheduledFor: scheduledFor,
})
}
}
return nil
}
+355 -41
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 := validateAppointmentTarget(ctx, s.repo, tenant, request.StaffID, request.LocationID, startsAt, endsAt); err != nil {
return domain.CreateBookingResponse{}, err
}
for _, booking := range existing {
if booking.Status == "cancelled" {
continue
@@ -213,6 +341,9 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
Notes: notes,
})
if err != nil {
if db.IsExclusionViolation(err) {
return domain.CreateBookingResponse{}, ErrBookingConflict
}
return domain.CreateBookingResponse{}, err
}
@@ -229,15 +360,8 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
}
}
if scheduledFor, ok := reminderSchedule(startsAt); ok {
if err := s.repo.CreateReminderJob(ctx, db.ReminderJobParams{
TenantID: tenant.ID,
BookingID: created.ID,
Channel: "email",
ScheduledFor: scheduledFor,
}); err != nil {
return domain.CreateBookingResponse{}, err
}
if err := armReminderJobs(ctx, s.repo, tenant.ID, created.ID, startsAt, request.CustomerPhone); err != nil {
return domain.CreateBookingResponse{}, err
}
// Send confirmation emails (async - don't fail booking if email fails)
@@ -251,6 +375,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 validateAppointmentTarget(ctx context.Context, repo db.Repository, tenant db.TenantRecord, staffID, locationID *string, startsAt, endsAt time.Time) error {
if staffID != nil {
staff, err := 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 := 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 := repo.ListAvailabilityRulesByTenant(ctx, tenant.ID)
if err != nil {
return err
}
blockedDays, err := 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)
}
@@ -355,6 +530,12 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
locationNames[location.ID] = location.Name
}
}
staffNames := map[string]string{}
if staff, err := s.repo.ListStaffByTenant(ctx, membership.Tenant.ID); err == nil {
for _, member := range staff {
staffNames[member.ID] = member.DisplayName
}
}
toDomain := func(booking db.BookingRecord) domain.UpcomingBooking {
out := domain.UpcomingBooking{
ID: booking.ID,
@@ -372,6 +553,9 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
if booking.LocationID != nil {
out.Location = locationNames[*booking.LocationID]
}
if booking.StaffID != nil {
out.StaffName = staffNames[*booking.StaffID]
}
return out
}
@@ -381,6 +565,14 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
if err != nil {
return domain.DashboardSummary{}, err
}
previousMetrics, err := s.repo.GetDashboardMetrics(ctx, membership.Tenant.ID, now.AddDate(0, 0, -7), now)
if err != nil {
return domain.DashboardSummary{}, err
}
priorMetrics, err := s.repo.GetDashboardMetrics(ctx, membership.Tenant.ID, now.AddDate(0, 0, -14), now.AddDate(0, 0, -7))
if err != nil {
return domain.DashboardSummary{}, err
}
upcomingRecords, err := s.repo.ListBookingsByTenantBetween(ctx, membership.Tenant.ID, now, weekEnd)
if err != nil {
return domain.DashboardSummary{}, err
@@ -408,6 +600,15 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
allBookings = append(allBookings, toDomain(booking))
}
brandName := membership.Tenant.Name
if brand, err := s.repo.GetBrandProfile(ctx, membership.Tenant.ID); err == nil && strings.TrimSpace(brand.Name) != "" {
brandName = brand.Name
}
scheduleCount := 0
if rules, err := s.repo.ListAvailabilityRulesByTenant(ctx, membership.Tenant.ID); err == nil {
scheduleCount = len(rules)
}
return domain.DashboardSummary{
TenantName: membership.Tenant.Name,
TenantSlug: membership.Tenant.Slug,
@@ -415,11 +616,13 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
Timezone: membership.Tenant.Timezone,
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
PublicBookingURL: "/book/" + membership.Tenant.Slug,
SetupCompletion: 100,
SetupCompletion: setupCompletion(brandName, len(locationNames), len(serviceNames), len(staffNames), scheduleCount),
KPIs: []domain.DashboardKPI{
{Code: "bookings_this_week", Label: "Bookings this week", Value: fmt.Sprintf("%d", metrics.BookingsCount)},
{Code: "cancellations", Label: "Cancellations", Value: fmt.Sprintf("%d", metrics.CancellationsCount)},
{Code: "utilization", Label: "Utilization", Value: fmt.Sprintf("%d%%", metrics.UtilizationPercent)},
dashboardKPI("bookings_this_week", "Bookings this week", metrics.BookingsCount, previousMetrics.BookingsCount, ""),
dashboardKPI("completed", "Completed", previousMetrics.CompletedCount, priorMetrics.CompletedCount, ""),
dashboardKPI("cancellations", "Cancellations", metrics.CancellationsCount, previousMetrics.CancellationsCount, ""),
dashboardKPI("utilization", "Utilization", metrics.UtilizationPercent, previousMetrics.UtilizationPercent, "%"),
dashboardKPI("new_clients", "New clients", metrics.NewClientsCount, previousMetrics.NewClientsCount, ""),
},
UpcomingBookings: upcoming,
AllBookings: allBookings,
@@ -428,6 +631,28 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
}, nil
}
// setupCompletion scores onboarding readiness across five equal-weight checks:
// brand profile, location, service catalog, staff roster, and schedule rules.
func setupCompletion(brandName string, locationCount, serviceCount, staffCount, scheduleCount int) int {
score := 0
if strings.TrimSpace(brandName) != "" {
score += 20
}
if locationCount > 0 {
score += 20
}
if serviceCount > 0 {
score += 20
}
if staffCount > 0 {
score += 20
}
if scheduleCount > 0 {
score += 20
}
return score
}
func widgetSnippets(tenant db.TenantRecord) []domain.WidgetSnippet {
path := "/book/" + tenant.Slug
return []domain.WidgetSnippet{
@@ -445,68 +670,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
}
}
@@ -517,6 +760,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 {
@@ -618,3 +915,20 @@ func reminderSchedule(startsAt time.Time) (time.Time, bool) {
return time.Time{}, false
}
}
func dashboardKPI(code, label string, current int, previous int, suffix string) domain.DashboardKPI {
delta := current - previous
change := "0"
trend := "neutral"
if delta > 0 {
change = fmt.Sprintf("+%d", delta)
trend = "up"
} else if delta < 0 {
change = fmt.Sprintf("%d", delta)
trend = "down"
}
if suffix != "" && change != "0" {
change += suffix
}
return domain.DashboardKPI{Code: code, Label: label, Value: fmt.Sprintf("%d%s", current, suffix), Change: change, Trend: trend}
}
+546 -6
View File
@@ -3,6 +3,8 @@ package bookings
import (
"context"
"errors"
"fmt"
"net/url"
"testing"
"time"
@@ -14,7 +16,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 +73,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 +166,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 +189,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 +213,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 +228,541 @@ 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)
}
}
func TestCustomerCancelPromotesWaitlist(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
customerService := NewCustomerService(repo, nil, "http://localhost:3000")
ctx := context.Background()
session, err := repo.GetClassSessionByID(ctx, "4bf74c12-44dd-45ca-86bb-b104f16f2435")
if err != nil {
t.Fatalf("get session: %v", err)
}
sessionID := session.ID
book := func(email string) domain.CreateBookingResponse {
resp, err := service.Create(ctx, domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "class",
ClassSessionID: &sessionID,
CustomerName: "Test User",
CustomerEmail: email,
StartsAt: session.StartsAt.UTC().Format(time.RFC3339),
EndsAt: session.EndsAt.UTC().Format(time.RFC3339),
})
if err != nil {
t.Fatalf("book %s: %v", email, err)
}
return resp
}
var confirmed []domain.CreateBookingResponse
for i := 0; i < 4; i++ {
confirmed = append(confirmed, book(fmt.Sprintf("seat%[email protected]", i)))
}
waiter := book("[email protected]")
if waiter.Status != "waitlisted" {
t.Fatalf("expected waitlisted, got %s", waiter.Status)
}
// Customer cancels a confirmed seat via the management token.
manageURL, _ := url.Parse(confirmed[0].ManageURL)
token := manageURL.Query().Get("token")
if err := customerService.CancelBooking(ctx, confirmed[0].Reference, token); err != nil {
t.Fatalf("customer cancel: %v", err)
}
booking, _ := repo.GetBookingByID(ctx, waiter.BookingID)
if booking.Status != "confirmed" {
t.Fatalf("expected waiter promoted to confirmed, got %q", booking.Status)
}
}
func TestDashboardSummaryComputesKPIsWithTrends(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
ctx := context.Background()
membership, err := repo.GetTenantMembershipBySubject(ctx, "demo-owner")
if err != nil {
t.Fatalf("membership: %v", err)
}
tenantID := membership.Tenant.ID
now := time.Now().UTC()
seed := func(offsetDays int, hours int, status, email string) {
t.Helper()
starts := now.AddDate(0, 0, offsetDays).Add(time.Duration(hours) * time.Hour)
_, err := repo.CreateBooking(ctx, db.CreateBookingParams{
TenantID: tenantID,
BookingMode: "appointment",
CustomerEmail: email,
StartsAt: starts,
EndsAt: starts.Add(time.Hour),
Status: status,
Reference: "BK-T",
})
if err != nil {
t.Fatalf("seed booking: %v", err)
}
}
// Next 7 days: 3 bookings, one cancelled; two first-time clients
// (b@ returns, having booked 9 days ago).
seed(1, 0, "confirmed", "[email protected]")
seed(2, 0, "confirmed", "[email protected]")
seed(3, 0, "cancelled", "[email protected]")
// Last 7 days: 1 confirmed booking that already ended (returning client).
seed(-2, 0, "confirmed", "[email protected]")
// 7-14 days ago: 2 completed bookings; b@'s first visit plus a new client.
seed(-8, 0, "confirmed", "[email protected]")
seed(-9, 0, "confirmed", "[email protected]")
summary, err := service.DashboardSummary(ctx, domain.Principal{Subject: "demo-owner", Role: "owner"})
if err != nil {
t.Fatalf("dashboard summary: %v", err)
}
if len(summary.KPIs) != 5 {
t.Fatalf("expected 5 kpis, got %d", len(summary.KPIs))
}
byCode := map[string]domain.DashboardKPI{}
for _, kpi := range summary.KPIs {
byCode[kpi.Code] = kpi
}
bookings := byCode["bookings_this_week"]
if bookings.Value != "3" || bookings.Change != "+2" || bookings.Trend != "up" {
t.Fatalf("bookings kpi = %+v", bookings)
}
completed := byCode["completed"]
if completed.Value != "1" || completed.Change != "-1" || completed.Trend != "down" {
t.Fatalf("completed kpi = %+v", completed)
}
cancellations := byCode["cancellations"]
if cancellations.Value != "1" || cancellations.Change != "+1" || cancellations.Trend != "up" {
t.Fatalf("cancellations kpi = %+v", cancellations)
}
utilization := byCode["utilization"]
if utilization.Value != "66%" || utilization.Trend != "down" {
t.Fatalf("utilization kpi = %+v", utilization)
}
// a@ and c@ have their first-ever booking next week (2); d@ was the
// only first-timer last week (1) — b@ first booked 8 days ago.
newClients := byCode["new_clients"]
if newClients.Value != "2" || newClients.Change != "+1" || newClients.Trend != "up" {
t.Fatalf("new clients kpi = %+v", newClients)
}
}
func bookAppointmentSlot(t *testing.T, repo db.Repository, service *Service) domain.CreateBookingResponse {
t.Helper()
// Target a day ~72h out so the booking lands in the -24h reminder horizon.
target := time.Now().UTC().AddDate(0, 0, 3).Format("2006-01-02")
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{Date: target})
if err != nil {
t.Fatalf("availability: %v", err)
}
var slot domain.TimeSlot
for _, s := range availability.Slots {
if s.Mode == "appointment" {
slot = s
break
}
}
if slot.StartsAt == "" {
t.Fatal("expected appointment slot")
}
resp, err := service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "appointment",
ServiceID: slot.ServiceID,
StaffID: slot.StaffID,
LocationID: slot.LocationID,
CustomerName: "Reminder Test",
CustomerEmail: "[email protected]",
StartsAt: slot.StartsAt,
EndsAt: slot.EndsAt,
})
if err != nil {
t.Fatalf("create booking: %v", err)
}
return resp
}
func pendingJobsFor(t *testing.T, repo db.Repository, bookingID string) []db.ReminderJobRecord {
t.Helper()
jobs, err := repo.ListDueReminderJobs(context.Background(), time.Now().UTC().AddDate(1, 0, 0), 100)
if err != nil {
t.Fatalf("list due jobs: %v", err)
}
var out []db.ReminderJobRecord
for _, job := range jobs {
if job.BookingID == bookingID {
out = append(out, job)
}
}
return out
}
func TestCancelBookingClearsPendingReminder(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
customerService := NewCustomerService(repo, nil, "http://localhost:3000")
ctx := context.Background()
resp := bookAppointmentSlot(t, repo, service)
if len(pendingJobsFor(t, repo, resp.BookingID)) != 1 {
t.Fatal("expected one pending reminder job after booking")
}
manageURL, _ := url.Parse(resp.ManageURL)
token := manageURL.Query().Get("token")
if err := customerService.CancelBooking(ctx, resp.Reference, token); err != nil {
t.Fatalf("cancel: %v", err)
}
if got := pendingJobsFor(t, repo, resp.BookingID); len(got) != 0 {
t.Fatalf("expected no pending jobs after cancel, got %d", len(got))
}
}
func TestRescheduleRearmsReminder(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
customerService := NewCustomerService(repo, nil, "http://localhost:3000")
ctx := context.Background()
resp := bookAppointmentSlot(t, repo, service)
booking, _ := repo.GetBookingByID(ctx, resp.BookingID)
newStartsAt := booking.StartsAt.Add(48 * time.Hour)
newEndsAt := booking.EndsAt.Add(48 * time.Hour)
manageURL, _ := url.Parse(resp.ManageURL)
token := manageURL.Query().Get("token")
if err := customerService.RescheduleBooking(ctx, resp.Reference, domain.RescheduleBookingRequest{
NewStartsAt: newStartsAt.UTC().Format(time.RFC3339),
NewEndsAt: newEndsAt.UTC().Format(time.RFC3339),
}, token); err != nil {
t.Fatalf("reschedule: %v", err)
}
jobs := pendingJobsFor(t, repo, resp.BookingID)
if len(jobs) != 1 {
t.Fatalf("expected one pending job after reschedule, got %d", len(jobs))
}
expected := newStartsAt.Add(-24 * time.Hour)
if !jobs[0].ScheduledFor.Equal(expected) {
t.Fatalf("expected reminder at %v, got %v", expected, jobs[0].ScheduledFor)
}
}
func TestRescheduleRejectsTimeOutsideAvailabilityWindow(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
customerService := NewCustomerService(repo, nil, "http://localhost:3000")
ctx := context.Background()
resp := bookAppointmentSlot(t, repo, service)
manageURL, _ := url.Parse(resp.ManageURL)
token := manageURL.Query().Get("token")
// 03:00 UTC next week is outside every availability rule.
night := time.Now().UTC().AddDate(0, 0, 7).Truncate(24 * time.Hour).Add(3 * time.Hour)
err := customerService.RescheduleBooking(ctx, resp.Reference, domain.RescheduleBookingRequest{
NewStartsAt: night.UTC().Format(time.RFC3339),
NewEndsAt: night.Add(time.Hour).UTC().Format(time.RFC3339),
}, token)
if !errors.Is(err, ErrInvalidReschedule) {
t.Fatalf("expected ErrInvalidReschedule, got %v", err)
}
}
func TestCreateArmsSmsReminderWhenEnabled(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
ctx := context.Background()
membership, _ := repo.GetTenantMembershipBySubject(ctx, "demo-owner")
if err := repo.UpsertTenantSMSSettings(ctx, db.TenantSMSSettingsRecord{
TenantID: membership.Tenant.ID,
Enabled: true,
}); err != nil {
t.Fatalf("sms settings: %v", err)
}
// Booking with a phone: expect email + sms reminder jobs.
availability, err := service.Availability(ctx, "studio-atelier", AvailabilityFilter{
Date: time.Now().UTC().AddDate(0, 0, 3).Format("2006-01-02"),
})
if err != nil {
t.Fatalf("availability: %v", err)
}
var slot domain.TimeSlot
for _, s := range availability.Slots {
if s.Mode == "appointment" {
slot = s
break
}
}
resp, err := service.Create(ctx, domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "appointment",
ServiceID: slot.ServiceID,
StaffID: slot.StaffID,
LocationID: slot.LocationID,
CustomerName: "SMS Test",
CustomerEmail: "[email protected]",
CustomerPhone: "+420777123456",
StartsAt: slot.StartsAt,
EndsAt: slot.EndsAt,
})
if err != nil {
t.Fatalf("create: %v", err)
}
jobs := pendingJobsFor(t, repo, resp.BookingID)
if len(jobs) != 2 {
t.Fatalf("expected 2 pending jobs, got %d", len(jobs))
}
channels := map[string]bool{}
for _, job := range jobs {
channels[job.Channel] = true
}
if !channels["email"] || !channels["sms"] {
t.Fatalf("expected email+sms channels, got %v", channels)
}
}
func TestWaitlistPositionReflectsQueueOrder(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
customerService := NewCustomerService(repo, nil, "http://localhost:3000")
ctx := context.Background()
session, err := repo.GetClassSessionByID(ctx, "4bf74c12-44dd-45ca-86bb-b104f16f2435")
if err != nil {
t.Fatalf("get session: %v", err)
}
sessionID := session.ID
book := func(email string) domain.CreateBookingResponse {
resp, err := service.Create(ctx, domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "class",
ClassSessionID: &sessionID,
CustomerName: "Queue Test",
CustomerEmail: email,
StartsAt: session.StartsAt.UTC().Format(time.RFC3339),
EndsAt: session.EndsAt.UTC().Format(time.RFC3339),
})
if err != nil {
t.Fatalf("book %s: %v", email, err)
}
return resp
}
var seats []domain.CreateBookingResponse
for i := 0; i < 4; i++ {
seats = append(seats, book(fmt.Sprintf("qseat%[email protected]", i)))
}
first := book("[email protected]")
second := book("[email protected]")
positionOf := func(resp domain.CreateBookingResponse) int {
manageURL, _ := url.Parse(resp.ManageURL)
view, err := customerService.GetBookingByReference(ctx, resp.Reference, manageURL.Query().Get("token"))
if err != nil {
t.Fatalf("get booking view: %v", err)
}
return view.WaitlistPosition
}
if got := positionOf(first); got != 1 {
t.Fatalf("expected first waiter at position 1, got %d", got)
}
if got := positionOf(second); got != 2 {
t.Fatalf("expected second waiter at position 2, got %d", got)
}
// Free a seat: first waiter is promoted, second moves to the front.
manageURL, _ := url.Parse(seats[0].ManageURL)
if err := customerService.CancelBooking(ctx, seats[0].Reference, manageURL.Query().Get("token")); err != nil {
t.Fatalf("cancel: %v", err)
}
if got := positionOf(second); got != 1 {
t.Fatalf("expected second waiter at position 1 after promotion, got %d", got)
}
if got := positionOf(first); got != 0 {
t.Fatalf("expected promoted booking to report no position, got %d", got)
}
}
+805 -14
View File
@@ -11,18 +11,26 @@ import (
"bookra/apps/backend/internal/bookings"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"bookra/apps/backend/internal/notifications"
"github.com/jackc/pgx/v5"
)
var (
ErrLocationNotFound = errors.New("location not found")
ErrBlockedDayNotFound = errors.New("blocked day not found")
ErrCustomerNotFound = errors.New("customer not found")
ErrBookingNotFound = errors.New("booking not found")
ErrInvalidBooking = errors.New("invalid booking request")
ErrBookingConflict = errors.New("booking conflicts with an existing reservation")
ErrTenantNotFound = errors.New("tenant not found")
ErrTenantMembership = errors.New("tenant membership not found")
ErrPlanLimitReached = errors.New("plan limit reached")
ErrLocationNotFound = errors.New("location not found")
ErrBlockedDayNotFound = errors.New("blocked day not found")
ErrCustomerNotFound = errors.New("customer not found")
ErrBookingNotFound = errors.New("booking not found")
ErrServiceNotFound = errors.New("service not found")
ErrStaffNotFound = errors.New("staff member not found")
ErrInvalidInput = errors.New("invalid request")
ErrInvalidBooking = errors.New("invalid booking request")
ErrBookingConflict = errors.New("booking conflicts with an existing reservation")
ErrTenantNotFound = errors.New("tenant not found")
ErrTenantMembership = errors.New("tenant membership not found")
ErrPlanLimitReached = errors.New("plan limit reached")
ErrClassTemplateNotFound = errors.New("class template not found")
ErrClassSessionNotFound = errors.New("class session not found")
)
type Service struct {
@@ -32,6 +40,8 @@ type Service struct {
}
notificationService interface {
SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error
SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error
SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error
}
}
@@ -39,6 +49,8 @@ func NewService(repo db.Repository, billingService interface {
GetEntitlements(ctx context.Context, tenantID string) (domain.PlanEntitlements, error)
}, notificationService interface {
SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error
SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error
SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error
}) *Service {
return &Service{repo: repo, billingService: billingService, notificationService: notificationService}
}
@@ -183,6 +195,310 @@ func (s *Service) DeleteLocation(ctx context.Context, principal domain.Principal
return s.repo.DeleteLocation(ctx, locationID)
}
// ============================================
// SERVICE CATALOG MANAGEMENT
// ============================================
func serviceToDomain(rec db.ServiceRecord) domain.Service {
return domain.Service{
ID: rec.ID,
TenantID: rec.TenantID,
Name: rec.Name,
DurationMinutes: rec.DurationMinutes,
BufferBeforeMinutes: rec.BufferBeforeMinutes,
BufferAfterMinutes: rec.BufferAfterMinutes,
PriceCents: rec.PriceCents,
CreatedAt: rec.CreatedAt,
}
}
func validateServiceValues(name string, duration, bufferBefore, bufferAfter, priceCents int) error {
if trimmed := len(strings.TrimSpace(name)); trimmed < 1 || trimmed > 120 {
return fmt.Errorf("%w: name must be between 1 and 120 characters", ErrInvalidInput)
}
if duration <= 0 || duration > 24*60 {
return fmt.Errorf("%w: durationMinutes must be between 1 and 1440", ErrInvalidInput)
}
if bufferBefore < 0 || bufferBefore > 480 {
return fmt.Errorf("%w: bufferBeforeMinutes must be between 0 and 480", ErrInvalidInput)
}
if bufferAfter < 0 || bufferAfter > 480 {
return fmt.Errorf("%w: bufferAfterMinutes must be between 0 and 480", ErrInvalidInput)
}
if priceCents < 0 {
return fmt.Errorf("%w: priceCents cannot be negative", ErrInvalidInput)
}
return nil
}
func (s *Service) ListServices(ctx context.Context, principal domain.Principal) ([]domain.Service, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
records, err := s.repo.ListServicesByTenant(ctx, membership.Tenant.ID)
if err != nil {
return nil, err
}
services := make([]domain.Service, len(records))
for i, rec := range records {
services[i] = serviceToDomain(rec)
}
return services, nil
}
func (s *Service) CreateService(ctx context.Context, principal domain.Principal, req domain.CreateServiceRequest) (domain.Service, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.Service{}, ErrTenantMembership
}
name := strings.TrimSpace(req.Name)
if err := validateServiceValues(name, req.DurationMinutes, req.BufferBeforeMinutes, req.BufferAfterMinutes, req.PriceCents); err != nil {
return domain.Service{}, err
}
rec, err := s.repo.CreateService(ctx, db.CreateServiceParams{
TenantID: membership.Tenant.ID,
Name: name,
DurationMinutes: req.DurationMinutes,
BufferBeforeMinutes: req.BufferBeforeMinutes,
BufferAfterMinutes: req.BufferAfterMinutes,
PriceCents: req.PriceCents,
})
if err != nil {
return domain.Service{}, err
}
return serviceToDomain(rec), nil
}
func (s *Service) UpdateService(ctx context.Context, principal domain.Principal, serviceID string, req domain.UpdateServiceRequest) (domain.Service, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.Service{}, ErrTenantMembership
}
existing, err := s.repo.GetServiceByID(ctx, serviceID)
if err != nil || existing.TenantID != membership.Tenant.ID {
return domain.Service{}, ErrServiceNotFound
}
params := db.UpdateServiceParams{}
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
params.Name = &name
}
if req.DurationMinutes != nil {
params.DurationMinutes = req.DurationMinutes
}
if req.BufferBeforeMinutes != nil {
params.BufferBeforeMinutes = req.BufferBeforeMinutes
}
if req.BufferAfterMinutes != nil {
params.BufferAfterMinutes = req.BufferAfterMinutes
}
if req.PriceCents != nil {
params.PriceCents = req.PriceCents
}
// Validate against the merged result so partial updates cannot produce an
// invalid record (e.g. setting duration to 0 while keeping the name).
merged := existing
if params.Name != nil {
merged.Name = *params.Name
}
if params.DurationMinutes != nil {
merged.DurationMinutes = *params.DurationMinutes
}
if params.BufferBeforeMinutes != nil {
merged.BufferBeforeMinutes = *params.BufferBeforeMinutes
}
if params.BufferAfterMinutes != nil {
merged.BufferAfterMinutes = *params.BufferAfterMinutes
}
if params.PriceCents != nil {
merged.PriceCents = *params.PriceCents
}
if err := validateServiceValues(merged.Name, merged.DurationMinutes, merged.BufferBeforeMinutes, merged.BufferAfterMinutes, merged.PriceCents); err != nil {
return domain.Service{}, err
}
rec, err := s.repo.UpdateService(ctx, serviceID, params)
if err != nil {
return domain.Service{}, err
}
return serviceToDomain(rec), nil
}
func (s *Service) DeleteService(ctx context.Context, principal domain.Principal, serviceID string) error {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
existing, err := s.repo.GetServiceByID(ctx, serviceID)
if err != nil || existing.TenantID != membership.Tenant.ID {
return ErrServiceNotFound
}
return s.repo.DeleteService(ctx, serviceID)
}
// ============================================
// STAFF MANAGEMENT
// ============================================
func staffToDomain(rec db.StaffRecord) domain.StaffMember {
return domain.StaffMember{
ID: rec.ID,
TenantID: rec.TenantID,
LocationID: rec.LocationID,
DisplayName: rec.DisplayName,
Active: rec.Active,
CreatedAt: rec.CreatedAt,
}
}
func validateStaffName(name string) error {
if trimmed := len(strings.TrimSpace(name)); trimmed < 1 || trimmed > 120 {
return fmt.Errorf("%w: displayName must be between 1 and 120 characters", ErrInvalidInput)
}
return nil
}
// validateStaffLocation returns the normalized location reference: nil when the
// input is empty, the id when it belongs to the tenant, or an error otherwise.
func (s *Service) validateStaffLocation(ctx context.Context, tenantID string, locationID *string) (*string, error) {
if locationID == nil {
return nil, nil
}
trimmed := strings.TrimSpace(*locationID)
if trimmed == "" {
return nil, nil
}
loc, err := s.repo.GetLocationByID(ctx, trimmed)
if err != nil || loc.TenantID != tenantID {
return nil, fmt.Errorf("%w: locationId does not belong to this tenant", ErrInvalidInput)
}
return &trimmed, nil
}
func (s *Service) ListStaff(ctx context.Context, principal domain.Principal) ([]domain.StaffMember, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
records, err := s.repo.ListStaffByTenant(ctx, membership.Tenant.ID)
if err != nil {
return nil, err
}
staff := make([]domain.StaffMember, len(records))
for i, rec := range records {
staff[i] = staffToDomain(rec)
}
return staff, nil
}
func (s *Service) CreateStaff(ctx context.Context, principal domain.Principal, req domain.CreateStaffRequest) (domain.StaffMember, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.StaffMember{}, ErrTenantMembership
}
if s.billingService != nil {
entitlements, err := s.billingService.GetEntitlements(ctx, membership.Tenant.ID)
if err == nil && entitlements.MaxStaff >= 0 {
staff, err := s.repo.ListStaffByTenant(ctx, membership.Tenant.ID)
if err == nil && len(staff) >= entitlements.MaxStaff {
return domain.StaffMember{}, fmt.Errorf("%w: staff limit reached (%d/%d). Upgrade your plan to add more staff.", ErrPlanLimitReached, len(staff), entitlements.MaxStaff)
}
}
}
name := strings.TrimSpace(req.DisplayName)
if err := validateStaffName(name); err != nil {
return domain.StaffMember{}, err
}
locationID, err := s.validateStaffLocation(ctx, membership.Tenant.ID, req.LocationID)
if err != nil {
return domain.StaffMember{}, err
}
active := true
if req.Active != nil {
active = *req.Active
}
rec, err := s.repo.CreateStaff(ctx, db.CreateStaffParams{
TenantID: membership.Tenant.ID,
LocationID: locationID,
DisplayName: name,
Active: active,
})
if err != nil {
return domain.StaffMember{}, err
}
return staffToDomain(rec), nil
}
func (s *Service) UpdateStaff(ctx context.Context, principal domain.Principal, staffID string, req domain.UpdateStaffRequest) (domain.StaffMember, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.StaffMember{}, ErrTenantMembership
}
existing, err := s.repo.GetStaffByID(ctx, staffID)
if err != nil || existing.TenantID != membership.Tenant.ID {
return domain.StaffMember{}, ErrStaffNotFound
}
params := db.UpdateStaffParams{Active: req.Active}
if req.DisplayName != nil {
name := strings.TrimSpace(*req.DisplayName)
if err := validateStaffName(name); err != nil {
return domain.StaffMember{}, err
}
params.DisplayName = &name
}
if req.LocationID != nil {
locationID, err := s.validateStaffLocation(ctx, membership.Tenant.ID, req.LocationID)
if err != nil {
return domain.StaffMember{}, err
}
// Keep the tri-state: an explicit empty string must clear the value.
if locationID == nil {
empty := ""
params.LocationID = &empty
} else {
params.LocationID = locationID
}
}
rec, err := s.repo.UpdateStaff(ctx, staffID, params)
if err != nil {
return domain.StaffMember{}, err
}
return staffToDomain(rec), nil
}
func (s *Service) DeleteStaff(ctx context.Context, principal domain.Principal, staffID string) error {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
existing, err := s.repo.GetStaffByID(ctx, staffID)
if err != nil || existing.TenantID != membership.Tenant.ID {
return ErrStaffNotFound
}
return s.repo.DeleteStaff(ctx, staffID)
}
// ============================================
// BLOCKED DAYS MANAGEMENT
// ============================================
@@ -479,18 +795,20 @@ func (s *Service) ListWorkingHours(ctx context.Context, principal domain.Princip
return nil, ErrTenantMembership
}
records, err := s.repo.ListWorkingHoursByTenant(ctx, membership.Tenant.ID)
records, err := s.repo.ListWorkingHoursByTenant(ctx, membership.Tenant.ID, nil)
if err != nil {
return nil, err
}
hours := make([]domain.WorkingHours, len(records))
for i, rec := range records {
open := hhmm(rec.StartsLocal)
close_ := hhmm(rec.EndsLocal)
hours[i] = domain.WorkingHours{
DayOfWeek: rec.DayOfWeek,
Open: rec.StartsLocal,
Close: rec.EndsLocal,
IsOpen: rec.StartsLocal != "" && rec.EndsLocal != "",
Open: open,
Close: close_,
IsOpen: open != "" && close_ != "",
}
}
return hours, nil
@@ -516,7 +834,81 @@ func (s *Service) UpdateWorkingHours(ctx context.Context, principal domain.Princ
params.EndsLocal = &req.Close
}
return s.repo.UpdateWorkingHours(ctx, membership.Tenant.ID, dayOfWeek, params)
return s.repo.UpdateWorkingHours(ctx, membership.Tenant.ID, nil, dayOfWeek, params)
}
// ListStaffWorkingHours returns the per-person schedule for a tenant-owned
// staff member.
func (s *Service) ListStaffWorkingHours(ctx context.Context, principal domain.Principal, staffID string) ([]domain.WorkingHours, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
if err := s.requireTenantStaff(ctx, membership.Tenant.ID, staffID); err != nil {
return nil, err
}
records, err := s.repo.ListWorkingHoursByTenant(ctx, membership.Tenant.ID, &staffID)
if err != nil {
return nil, err
}
hours := make([]domain.WorkingHours, len(records))
for i, rec := range records {
open := hhmm(rec.StartsLocal)
close_ := hhmm(rec.EndsLocal)
hours[i] = domain.WorkingHours{
DayOfWeek: rec.DayOfWeek,
Open: open,
Close: close_,
IsOpen: open != "" && close_ != "",
}
}
return hours, nil
}
// UpdateStaffWorkingHours replaces a staff member's availability rule for one
// weekday. The rule directly drives public slot generation for that person.
func (s *Service) UpdateStaffWorkingHours(ctx context.Context, principal domain.Principal, staffID string, dayOfWeek int, req domain.UpdateWorkingHoursRequest) error {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
if err := s.requireTenantStaff(ctx, membership.Tenant.ID, staffID); err != nil {
return err
}
params := db.UpdateWorkingHoursParams{IsOpen: req.IsOpen}
if req.Open != "" {
if _, err := time.Parse("15:04", req.Open); err != nil {
return fmt.Errorf("%w: open must be HH:MM", ErrInvalidBooking)
}
params.StartsLocal = &req.Open
}
if req.Close != "" {
if _, err := time.Parse("15:04", req.Close); err != nil {
return fmt.Errorf("%w: close must be HH:MM", ErrInvalidBooking)
}
params.EndsLocal = &req.Close
}
return s.repo.UpdateWorkingHours(ctx, membership.Tenant.ID, &staffID, dayOfWeek, params)
}
// requireTenantStaff verifies the staff record exists and belongs to the
// tenant, returning a tenant-safe not-found error otherwise.
func (s *Service) requireTenantStaff(ctx context.Context, tenantID, staffID string) error {
staff, err := s.repo.GetStaffByID(ctx, staffID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrStaffNotFound
}
return err
}
if staff.TenantID != tenantID {
return ErrStaffNotFound
}
return nil
}
// CreateBooking creates a manual booking from the dashboard. The service name
@@ -608,6 +1000,9 @@ func (s *Service) CreateBooking(ctx context.Context, principal domain.Principal,
Notes: strings.TrimSpace(req.Notes),
})
if err != nil {
if db.IsExclusionViolation(err) {
return domain.UpcomingBooking{}, ErrBookingConflict
}
return domain.UpcomingBooking{}, err
}
@@ -623,3 +1018,399 @@ func (s *Service) CreateBooking(ctx context.Context, principal domain.Principal,
Notes: strings.TrimSpace(req.Notes),
}, nil
}
// ============================================
// CLASS TEMPLATE & SESSION MANAGEMENT
// ============================================
func (s *Service) ListClassTemplates(ctx context.Context, principal domain.Principal) ([]domain.ClassTemplate, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
records, err := s.repo.ListClassTemplatesByTenant(ctx, membership.Tenant.ID)
if err != nil {
return nil, err
}
templates := make([]domain.ClassTemplate, len(records))
for i, rec := range records {
templates[i] = domain.ClassTemplate{
ID: rec.ID,
TenantID: rec.TenantID,
Title: rec.Title,
DurationMinutes: rec.DurationMinutes,
Capacity: rec.Capacity,
CreatedAt: rec.CreatedAt,
}
}
return templates, nil
}
func (s *Service) CreateClassTemplate(ctx context.Context, principal domain.Principal, req domain.UpsertClassTemplateRequest) (domain.ClassTemplate, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.ClassTemplate{}, ErrTenantMembership
}
title, err := validateClassTemplateInput(req)
if err != nil {
return domain.ClassTemplate{}, err
}
rec, err := s.repo.CreateClassTemplate(ctx, db.CreateClassTemplateParams{
TenantID: membership.Tenant.ID,
Title: title,
DurationMinutes: req.DurationMinutes,
Capacity: req.Capacity,
})
if err != nil {
return domain.ClassTemplate{}, err
}
return domain.ClassTemplate{
ID: rec.ID,
TenantID: rec.TenantID,
Title: rec.Title,
DurationMinutes: rec.DurationMinutes,
Capacity: rec.Capacity,
CreatedAt: rec.CreatedAt,
}, nil
}
func (s *Service) DeleteClassTemplate(ctx context.Context, principal domain.Principal, templateID string) error {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
if err := s.requireTenantClassTemplate(ctx, membership.Tenant.ID, templateID); err != nil {
return err
}
return s.repo.DeleteClassTemplate(ctx, templateID)
}
// ListClassSessions returns upcoming sessions enriched with location names and
// live booked counts for the management UI.
func (s *Service) ListClassSessions(ctx context.Context, principal domain.Principal) ([]domain.ClassSession, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
tenantID := membership.Tenant.ID
now := time.Now().UTC()
sessions, err := s.repo.ListClassSessionsByTenant(ctx, tenantID, now, 100)
if err != nil {
return nil, err
}
if len(sessions) == 0 {
return []domain.ClassSession{}, nil
}
var windowEnd time.Time
for _, session := range sessions {
if session.EndsAt.After(windowEnd) {
windowEnd = session.EndsAt
}
}
bookings, err := s.repo.ListBookingsByTenantBetween(ctx, tenantID, now, windowEnd)
if err != nil {
return nil, err
}
locations, err := s.repo.ListLocationsByTenant(ctx, tenantID)
if err != nil {
return nil, err
}
locationNames := make(map[string]string, len(locations))
for _, loc := range locations {
locationNames[loc.ID] = loc.Name
}
out := make([]domain.ClassSession, len(sessions))
for i, session := range sessions {
booked := 0
for _, booking := range bookings {
if booking.ClassSessionID != nil && *booking.ClassSessionID == session.ID &&
booking.Status != "cancelled" && booking.Status != "waitlisted" {
booked++
}
}
out[i] = domain.ClassSession{
ID: session.ID,
TenantID: session.TenantID,
TemplateID: session.TemplateID,
TemplateTitle: session.Title,
LocationID: session.LocationID,
LocationName: locationNames[deref(session.LocationID)],
StartsAt: session.StartsAt,
EndsAt: session.EndsAt,
Capacity: int(session.Capacity),
BookedCount: booked,
}
}
return out, nil
}
func (s *Service) CreateClassSession(ctx context.Context, principal domain.Principal, req domain.CreateClassSessionRequest) (domain.ClassSession, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.ClassSession{}, ErrTenantMembership
}
tenantID := membership.Tenant.ID
if err := s.requireTenantClassTemplate(ctx, tenantID, req.TemplateID); err != nil {
return domain.ClassSession{}, err
}
template, err := s.repo.GetClassTemplateByID(ctx, req.TemplateID)
if err != nil {
return domain.ClassSession{}, err
}
startsAt, err := time.Parse(time.RFC3339, strings.TrimSpace(req.StartsAt))
if err != nil {
return domain.ClassSession{}, fmt.Errorf("%w: startsAt must be RFC3339", ErrInvalidInput)
}
if !startsAt.After(time.Now()) {
return domain.ClassSession{}, fmt.Errorf("%w: startsAt must be in the future", ErrInvalidInput)
}
if req.LocationID != nil {
location, err := s.repo.GetLocationByID(ctx, *req.LocationID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ClassSession{}, ErrLocationNotFound
}
return domain.ClassSession{}, err
}
if location.TenantID != tenantID {
return domain.ClassSession{}, ErrLocationNotFound
}
}
capacity := template.Capacity
if req.Capacity != nil {
if *req.Capacity < 1 || *req.Capacity > 500 {
return domain.ClassSession{}, fmt.Errorf("%w: capacity must be between 1 and 500", ErrInvalidInput)
}
capacity = *req.Capacity
}
endsAt := startsAt.Add(time.Duration(template.DurationMinutes) * time.Minute)
rec, err := s.repo.CreateClassSession(ctx, db.CreateClassSessionParams{
TenantID: tenantID,
TemplateID: req.TemplateID,
LocationID: req.LocationID,
StartsAt: startsAt.UTC(),
EndsAt: endsAt.UTC(),
Capacity: int32(capacity),
})
if err != nil {
return domain.ClassSession{}, err
}
return domain.ClassSession{
ID: rec.ID,
TenantID: rec.TenantID,
TemplateID: rec.TemplateID,
TemplateTitle: rec.Title,
LocationID: rec.LocationID,
StartsAt: rec.StartsAt,
EndsAt: rec.EndsAt,
Capacity: int(rec.Capacity),
}, nil
}
func (s *Service) DeleteClassSession(ctx context.Context, principal domain.Principal, sessionID string) error {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
session, err := s.repo.GetClassSessionByID(ctx, sessionID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrClassSessionNotFound
}
return err
}
if session.TenantID != membership.Tenant.ID {
return ErrClassSessionNotFound
}
return s.repo.DeleteClassSession(ctx, sessionID)
}
func (s *Service) requireTenantClassTemplate(ctx context.Context, tenantID, templateID string) error {
template, err := s.repo.GetClassTemplateByID(ctx, templateID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrClassTemplateNotFound
}
return err
}
if template.TenantID != tenantID {
return ErrClassTemplateNotFound
}
return nil
}
func deref(value *string) string {
if value == nil {
return ""
}
return *value
}
// hhmm trims a local time value to HH:MM; PostgreSQL returns time columns as
// HH:MM:SS while the API contract uses HH:MM.
func hhmm(value string) string {
if len(value) >= 5 {
return value[:5]
}
return value
}
// ============================================
// BOOKING LIFECYCLE
// ============================================
// CancelBooking cancels a tenant-owned booking from the dashboard. Cancelling a
// class booking frees the seat and promotes the earliest waitlisted bookings.
func (s *Service) CancelBooking(ctx context.Context, principal domain.Principal, bookingID string) error {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
booking, err := s.repo.GetBookingByID(ctx, bookingID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrBookingNotFound
}
return err
}
if booking.TenantID != membership.Tenant.ID {
return ErrBookingNotFound
}
if booking.Status == "cancelled" {
return fmt.Errorf("%w: booking already cancelled", ErrInvalidInput)
}
if err := s.repo.UpdateBookingStatus(ctx, booking.ID, "cancelled"); err != nil {
return err
}
_ = s.repo.CancelPendingReminderJobsForBooking(ctx, booking.ID)
go s.sendBookingEmail(booking, false)
if booking.ClassSessionID != nil {
promoted, err := bookings.PromoteWaitlist(ctx, s.repo, booking.TenantID, *booking.ClassSessionID)
if err != nil {
return nil
}
for _, promotedBooking := range promoted {
go s.sendBookingEmail(promotedBooking, true)
}
}
return nil
}
// sendBookingEmail builds the shared payload and fires a confirmation
// (promoted=true) or cancellation notification. Failures are logged-and-done:
// email delivery must not fail the cancellation itself.
func (s *Service) sendBookingEmail(booking db.BookingRecord, promoted bool) {
if s.notificationService == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID)
if err != nil {
return
}
brand, _ := s.repo.GetBrandProfile(ctx, tenant.ID)
brandColor := brand.PrimaryColor
if brandColor == "" {
brandColor = "#a65c3e"
}
var serviceName, locationName string
if booking.ServiceID != nil {
if service, err := s.repo.GetServiceByID(ctx, *booking.ServiceID); err == nil {
serviceName = service.Name
}
}
if booking.ClassSessionID != nil {
if session, err := s.repo.GetClassSessionByID(ctx, *booking.ClassSessionID); err == nil {
serviceName = session.Title
}
}
if booking.LocationID != nil {
if location, err := s.repo.GetLocationByID(ctx, *booking.LocationID); err == nil {
locationName = location.Name
}
}
data := notifications.BookingEmailData{
TenantID: tenant.ID,
TenantName: tenant.Name,
TenantSlug: tenant.Slug,
BrandColor: brandColor,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
Service: serviceName,
Location: locationName,
Reference: booking.Reference,
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
Timezone: tenant.Timezone,
Locale: tenant.Locale,
}
if promoted {
s.notificationService.SendBookingConfirmation(ctx, data)
} else {
s.notificationService.SendBookingCancellation(ctx, data)
}
}
func (s *Service) UpdateClassTemplate(ctx context.Context, principal domain.Principal, templateID string, req domain.UpsertClassTemplateRequest) (domain.ClassTemplate, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.ClassTemplate{}, ErrTenantMembership
}
if err := s.requireTenantClassTemplate(ctx, membership.Tenant.ID, templateID); err != nil {
return domain.ClassTemplate{}, err
}
title, err := validateClassTemplateInput(req)
if err != nil {
return domain.ClassTemplate{}, err
}
rec, err := s.repo.UpdateClassTemplate(ctx, templateID, db.CreateClassTemplateParams{
TenantID: membership.Tenant.ID,
Title: title,
DurationMinutes: req.DurationMinutes,
Capacity: req.Capacity,
})
if err != nil {
return domain.ClassTemplate{}, err
}
return domain.ClassTemplate{
ID: rec.ID,
TenantID: rec.TenantID,
Title: rec.Title,
DurationMinutes: rec.DurationMinutes,
Capacity: rec.Capacity,
CreatedAt: rec.CreatedAt,
}, nil
}
func validateClassTemplateInput(req domain.UpsertClassTemplateRequest) (string, error) {
title := strings.TrimSpace(req.Title)
if len(title) < 1 || len(title) > 120 {
return "", fmt.Errorf("%w: title must be between 1 and 120 characters", ErrInvalidInput)
}
if req.DurationMinutes < 1 || req.DurationMinutes > 1440 {
return "", fmt.Errorf("%w: durationMinutes must be between 1 and 1440", ErrInvalidInput)
}
if req.Capacity < 1 || req.Capacity > 500 {
return "", fmt.Errorf("%w: capacity must be between 1 and 500", ErrInvalidInput)
}
return title, nil
}
@@ -0,0 +1,682 @@
package catalog
import (
"context"
"errors"
"fmt"
"testing"
"time"
"bookra/apps/backend/internal/bookings"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
func boolPtr(v bool) *bool { return &v }
var owner = domain.Principal{Subject: "demo-owner", Email: "[email protected]", Role: "owner"}
type stubBilling struct {
entitlements domain.PlanEntitlements
}
func (s stubBilling) GetEntitlements(_ context.Context, _ string) (domain.PlanEntitlements, error) {
return s.entitlements, nil
}
func newCatalogService(repo db.Repository) *Service {
return NewService(repo, stubBilling{entitlements: domain.PlanEntitlements{MaxStaff: -1}}, nil)
}
func TestListServicesReturnsSeededService(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
services, err := svc.ListServices(context.Background(), owner)
if err != nil {
t.Fatalf("list services: %v", err)
}
if len(services) != 1 {
t.Fatalf("expected 1 seeded service, got %d", len(services))
}
if services[0].Name != "Signature treatment" {
t.Fatalf("unexpected service name %q", services[0].Name)
}
}
func TestServiceCRUDLifecycle(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
created, err := svc.CreateService(ctx, owner, domain.CreateServiceRequest{
Name: " Haircut ",
DurationMinutes: 45,
BufferBeforeMinutes: 5,
BufferAfterMinutes: 10,
PriceCents: 90000,
})
if err != nil {
t.Fatalf("create service: %v", err)
}
if created.Name != "Haircut" {
t.Fatalf("expected trimmed name, got %q", created.Name)
}
if created.TenantID == "" {
t.Fatal("expected tenant id on created service")
}
services, err := svc.ListServices(ctx, owner)
if err != nil {
t.Fatalf("list services: %v", err)
}
if len(services) != 2 {
t.Fatalf("expected 2 services, got %d", len(services))
}
zero := 0
if _, err := svc.UpdateService(ctx, owner, created.ID, domain.UpdateServiceRequest{DurationMinutes: &zero}); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput for zero duration, got %v", err)
}
name := "Deluxe haircut"
price := 110000
updated, err := svc.UpdateService(ctx, owner, created.ID, domain.UpdateServiceRequest{Name: &name, PriceCents: &price})
if err != nil {
t.Fatalf("update service: %v", err)
}
if updated.Name != "Deluxe haircut" || updated.PriceCents != 110000 {
t.Fatalf("unexpected update result %+v", updated)
}
if updated.DurationMinutes != 45 || updated.BufferAfterMinutes != 10 {
t.Fatalf("partial update clobbered untouched fields %+v", updated)
}
if err := svc.DeleteService(ctx, owner, created.ID); err != nil {
t.Fatalf("delete service: %v", err)
}
services, err = svc.ListServices(ctx, owner)
if err != nil {
t.Fatalf("list services: %v", err)
}
if len(services) != 1 {
t.Fatalf("expected 1 service after delete, got %d", len(services))
}
}
func TestServiceValidation(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
cases := []struct {
name string
req domain.CreateServiceRequest
}{
{"empty name", domain.CreateServiceRequest{Name: " ", DurationMinutes: 30}},
{"zero duration", domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 0}},
{"huge duration", domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 2000}},
{"negative buffer", domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 30, BufferBeforeMinutes: -1}},
{"negative price", domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 30, PriceCents: -5}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, err := svc.CreateService(ctx, owner, tc.req); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput, got %v", err)
}
})
}
}
func TestServiceNotFoundAndMembership(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
missingID := "00000000-0000-0000-0000-000000000001"
if _, err := svc.UpdateService(ctx, owner, missingID, domain.UpdateServiceRequest{}); !errors.Is(err, ErrServiceNotFound) {
t.Fatalf("expected ErrServiceNotFound, got %v", err)
}
if err := svc.DeleteService(ctx, owner, missingID); !errors.Is(err, ErrServiceNotFound) {
t.Fatalf("expected ErrServiceNotFound, got %v", err)
}
stranger := domain.Principal{Subject: "someone-else", Role: "owner"}
if _, err := svc.ListServices(ctx, stranger); !errors.Is(err, ErrTenantMembership) {
t.Fatalf("expected ErrTenantMembership, got %v", err)
}
if _, err := svc.CreateService(ctx, stranger, domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 30}); !errors.Is(err, ErrTenantMembership) {
t.Fatalf("expected ErrTenantMembership, got %v", err)
}
}
func TestStaffCRUDLifecycle(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
locations, err := svc.ListLocations(ctx, owner)
if err != nil || len(locations) == 0 {
t.Fatalf("expected seeded location, got %v err=%v", locations, err)
}
locationID := locations[0].ID
created, err := svc.CreateStaff(ctx, owner, domain.CreateStaffRequest{
DisplayName: " Anna Novak ",
LocationID: &locationID,
})
if err != nil {
t.Fatalf("create staff: %v", err)
}
if created.DisplayName != "Anna Novak" {
t.Fatalf("expected trimmed name, got %q", created.DisplayName)
}
if !created.Active {
t.Fatal("new staff member should default to active")
}
if created.LocationID == nil || *created.LocationID != locationID {
t.Fatalf("expected location assignment %s, got %+v", locationID, created.LocationID)
}
staff, err := svc.ListStaff(ctx, owner)
if err != nil {
t.Fatalf("list staff: %v", err)
}
if len(staff) != 2 {
t.Fatalf("expected seeded member + new member, got %d", len(staff))
}
inactive := false
cleared := ""
updated, err := svc.UpdateStaff(ctx, owner, created.ID, domain.UpdateStaffRequest{
Active: &inactive,
LocationID: &cleared,
})
if err != nil {
t.Fatalf("update staff: %v", err)
}
if updated.Active {
t.Fatal("expected staff member to be deactivated")
}
if updated.LocationID != nil {
t.Fatalf("expected cleared location, got %+v", updated.LocationID)
}
if updated.DisplayName != "Anna Novak" {
t.Fatalf("name should be untouched, got %q", updated.DisplayName)
}
if err := svc.DeleteStaff(ctx, owner, created.ID); err != nil {
t.Fatalf("delete staff: %v", err)
}
staff, err = svc.ListStaff(ctx, owner)
if err != nil {
t.Fatalf("list staff: %v", err)
}
if len(staff) != 1 {
t.Fatalf("expected 1 staff member after delete, got %d", len(staff))
}
}
func TestCreateStaffEnforcesPlanLimit(t *testing.T) {
repo := db.NewMemoryRepository()
// Demo tenant seeds one member; a limit of 1 must reject a second.
svc := NewService(repo, stubBilling{entitlements: domain.PlanEntitlements{MaxStaff: 1}}, nil)
_, err := svc.CreateStaff(context.Background(), owner, domain.CreateStaffRequest{DisplayName: "Second"})
if !errors.Is(err, ErrPlanLimitReached) {
t.Fatalf("expected ErrPlanLimitReached, got %v", err)
}
}
func TestCreateStaffRejectsForeignLocation(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
foreign := "00000000-0000-0000-0000-000000000099"
_, err := svc.CreateStaff(context.Background(), owner, domain.CreateStaffRequest{
DisplayName: "Anna",
LocationID: &foreign,
})
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput, got %v", err)
}
}
func TestStaffNotFoundAndValidation(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
if _, err := svc.CreateStaff(ctx, owner, domain.CreateStaffRequest{DisplayName: " "}); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput for blank name, got %v", err)
}
missingID := "00000000-0000-0000-0000-000000000001"
if _, err := svc.UpdateStaff(ctx, owner, missingID, domain.UpdateStaffRequest{}); !errors.Is(err, ErrStaffNotFound) {
t.Fatalf("expected ErrStaffNotFound, got %v", err)
}
if err := svc.DeleteStaff(ctx, owner, missingID); !errors.Is(err, ErrStaffNotFound) {
t.Fatalf("expected ErrStaffNotFound, got %v", err)
}
stranger := domain.Principal{Subject: "someone-else", Role: "staff"}
if _, err := svc.ListStaff(ctx, stranger); !errors.Is(err, ErrTenantMembership) {
t.Fatalf("expected ErrTenantMembership, got %v", err)
}
}
const demoStaffID = "6936c444-c7d0-4a7d-b596-a9b72d2f4fc0"
func TestListStaffWorkingHours(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
hours, err := svc.ListStaffWorkingHours(context.Background(), owner, demoStaffID)
if err != nil {
t.Fatalf("list staff working hours: %v", err)
}
if len(hours) != 5 {
t.Fatalf("expected 5 seeded weekday rules, got %d", len(hours))
}
for _, wh := range hours {
if wh.Open != "09:00" || wh.Close != "17:00" {
t.Fatalf("day %d: expected 09:00-17:00, got %s-%s", wh.DayOfWeek, wh.Open, wh.Close)
}
}
}
func TestUpdateStaffWorkingHours(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
err := svc.UpdateStaffWorkingHours(ctx, owner, demoStaffID, 1, domain.UpdateWorkingHoursRequest{
IsOpen: boolPtr(true),
Open: "08:00",
Close: "12:00",
})
if err != nil {
t.Fatalf("update staff hours: %v", err)
}
hours, err := svc.ListStaffWorkingHours(ctx, owner, demoStaffID)
if err != nil {
t.Fatalf("list staff hours: %v", err)
}
found := false
for _, wh := range hours {
if wh.DayOfWeek == 1 {
found = true
if wh.Open != "08:00" || wh.Close != "12:00" {
t.Fatalf("expected Monday 08:00-12:00, got %s-%s", wh.Open, wh.Close)
}
}
}
if !found {
t.Fatal("Monday rule missing after update")
}
// Closing a day removes the rule.
err = svc.UpdateStaffWorkingHours(ctx, owner, demoStaffID, 2, domain.UpdateWorkingHoursRequest{
IsOpen: boolPtr(false),
})
if err != nil {
t.Fatalf("close staff day: %v", err)
}
hours, _ = svc.ListStaffWorkingHours(ctx, owner, demoStaffID)
for _, wh := range hours {
if wh.DayOfWeek == 2 {
t.Fatal("Tuesday rule should have been removed")
}
}
}
func TestStaffWorkingHoursRejectsForeignStaff(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
missing := "00000000-0000-0000-0000-000000000099"
if _, err := svc.ListStaffWorkingHours(ctx, owner, missing); !errors.Is(err, ErrStaffNotFound) {
t.Fatalf("expected ErrStaffNotFound, got %v", err)
}
err := svc.UpdateStaffWorkingHours(ctx, owner, missing, 1, domain.UpdateWorkingHoursRequest{
IsOpen: boolPtr(true), Open: "09:00", Close: "17:00",
})
if !errors.Is(err, ErrStaffNotFound) {
t.Fatalf("expected ErrStaffNotFound, got %v", err)
}
err = svc.UpdateStaffWorkingHours(ctx, owner, demoStaffID, 1, domain.UpdateWorkingHoursRequest{
IsOpen: boolPtr(true), Open: "9am", Close: "17:00",
})
if !errors.Is(err, ErrInvalidBooking) {
t.Fatalf("expected ErrInvalidBooking for bad time, got %v", err)
}
}
func TestClassTemplateCRUD(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
templates, err := svc.ListClassTemplates(ctx, owner)
if err != nil {
t.Fatalf("list templates: %v", err)
}
if len(templates) != 1 || templates[0].Title != "Small group mobility class" {
t.Fatalf("expected seeded template, got %+v", templates)
}
created, err := svc.CreateClassTemplate(ctx, owner, domain.UpsertClassTemplateRequest{
Title: " Yoga Basics ",
DurationMinutes: 45,
Capacity: 8,
})
if err != nil {
t.Fatalf("create template: %v", err)
}
if created.Title != "Yoga Basics" {
t.Fatalf("expected trimmed title, got %q", created.Title)
}
if err := svc.DeleteClassTemplate(ctx, owner, created.ID); err != nil {
t.Fatalf("delete template: %v", err)
}
templates, _ = svc.ListClassTemplates(ctx, owner)
if len(templates) != 1 {
t.Fatalf("expected 1 template after delete, got %d", len(templates))
}
}
func TestClassTemplateValidation(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
cases := []domain.UpsertClassTemplateRequest{
{Title: " ", DurationMinutes: 60, Capacity: 4},
{Title: "X", DurationMinutes: 0, Capacity: 4},
{Title: "X", DurationMinutes: 60, Capacity: 0},
}
for i, req := range cases {
if _, err := svc.CreateClassTemplate(ctx, owner, req); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("case %d: expected ErrInvalidInput, got %v", i, err)
}
}
missing := "00000000-0000-0000-0000-000000000099"
if err := svc.DeleteClassTemplate(ctx, owner, missing); !errors.Is(err, ErrClassTemplateNotFound) {
t.Fatalf("expected ErrClassTemplateNotFound, got %v", err)
}
}
func TestDeleteClassTemplateCascadesSessions(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
if err := svc.DeleteClassTemplate(ctx, owner, "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7"); err != nil {
t.Fatalf("delete template: %v", err)
}
sessions, err := svc.ListClassSessions(ctx, owner)
if err != nil {
t.Fatalf("list sessions: %v", err)
}
if len(sessions) != 0 {
t.Fatalf("expected sessions to cascade-delete, got %d", len(sessions))
}
}
func TestListClassSessionsEnrichment(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
sessions, err := svc.ListClassSessions(context.Background(), owner)
if err != nil {
t.Fatalf("list sessions: %v", err)
}
if len(sessions) != 1 {
t.Fatalf("expected 1 seeded session, got %d", len(sessions))
}
s := sessions[0]
if s.TemplateTitle != "Small group mobility class" {
t.Fatalf("expected template title, got %q", s.TemplateTitle)
}
if s.LocationName != "Main Studio" {
t.Fatalf("expected location name, got %q", s.LocationName)
}
if s.Capacity != 4 || s.BookedCount != 0 {
t.Fatalf("expected capacity 4 booked 0, got %d/%d", s.Capacity, s.BookedCount)
}
}
func TestCreateClassSession(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
startsAt := time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339)
cap := 6
created, err := svc.CreateClassSession(ctx, owner, domain.CreateClassSessionRequest{
TemplateID: "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7",
StartsAt: startsAt,
Capacity: &cap,
})
if err != nil {
t.Fatalf("create session: %v", err)
}
if created.Capacity != 6 {
t.Fatalf("expected override capacity 6, got %d", created.Capacity)
}
if !created.EndsAt.Equal(created.StartsAt.Add(60 * time.Minute)) {
t.Fatalf("expected end = start + 60m, got %s -> %s", created.StartsAt, created.EndsAt)
}
}
func TestCreateClassSessionValidation(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
future := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
past := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
template := "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7"
badLocation := "00000000-0000-0000-0000-000000000099"
badCap := 0
cases := []struct {
name string
req domain.CreateClassSessionRequest
want error
}{
{"missing template", domain.CreateClassSessionRequest{TemplateID: "00000000-0000-0000-0000-000000000099", StartsAt: future}, ErrClassTemplateNotFound},
{"bad start format", domain.CreateClassSessionRequest{TemplateID: template, StartsAt: "tomorrow"}, ErrInvalidInput},
{"past start", domain.CreateClassSessionRequest{TemplateID: template, StartsAt: past}, ErrInvalidInput},
{"foreign location", domain.CreateClassSessionRequest{TemplateID: template, StartsAt: future, LocationID: &badLocation}, ErrLocationNotFound},
{"zero capacity", domain.CreateClassSessionRequest{TemplateID: template, StartsAt: future, Capacity: &badCap}, ErrInvalidInput},
}
for _, tc := range cases {
if _, err := svc.CreateClassSession(ctx, owner, tc.req); !errors.Is(err, tc.want) {
t.Fatalf("%s: expected %v, got %v", tc.name, tc.want, err)
}
}
}
func TestDeleteClassSessionPreservesBooking(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
// Book the seeded class session via the public booking service.
bookingSvc := bookings.NewService(repo, nil, "")
sessionID := "4bf74c12-44dd-45ca-86bb-b104f16f2435"
session, err := repo.GetClassSessionByID(ctx, sessionID)
if err != nil {
t.Fatalf("get seeded session: %v", err)
}
_, err = bookingSvc.Create(ctx, domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
CustomerName: "Kate",
CustomerEmail: "[email protected]",
BookingMode: "class",
ClassSessionID: &sessionID,
StartsAt: session.StartsAt.UTC().Format(time.RFC3339),
EndsAt: session.EndsAt.UTC().Format(time.RFC3339),
})
if err != nil {
t.Fatalf("book class session: %v", err)
}
sessions, _ := svc.ListClassSessions(ctx, owner)
if sessions[0].BookedCount != 1 {
t.Fatalf("expected bookedCount 1, got %d", sessions[0].BookedCount)
}
if err := svc.DeleteClassSession(ctx, owner, sessionID); err != nil {
t.Fatalf("delete session: %v", err)
}
// The booking survives but no longer references the session.
bookingsList, err := repo.ListBookingsByTenantBetween(ctx, sessions[0].TenantID, time.Now().Add(-time.Hour), time.Now().Add(200*time.Hour))
if err != nil {
t.Fatalf("list bookings: %v", err)
}
if len(bookingsList) != 1 {
t.Fatalf("expected booking to survive, got %d", len(bookingsList))
}
if bookingsList[0].ClassSessionID != nil {
t.Fatal("expected booking class_session_id to be nulled")
}
}
const demoSessionID = "4bf74c12-44dd-45ca-86bb-b104f16f2435"
func bookClass(t *testing.T, repo db.Repository, email string) domain.CreateBookingResponse {
t.Helper()
session, err := repo.GetClassSessionByID(context.Background(), demoSessionID)
if err != nil {
t.Fatalf("get session: %v", err)
}
bookingSvc := bookings.NewService(repo, nil, "")
resp, err := bookingSvc.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
CustomerName: "Test User",
CustomerEmail: email,
BookingMode: "class",
ClassSessionID: &[]string{demoSessionID}[0],
StartsAt: session.StartsAt.UTC().Format(time.RFC3339),
EndsAt: session.EndsAt.UTC().Format(time.RFC3339),
})
if err != nil {
t.Fatalf("book class for %s: %v", email, err)
}
return resp
}
func TestCancelBookingLifecycle(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
resp := bookClass(t, repo, "[email protected]")
if err := svc.CancelBooking(ctx, owner, resp.BookingID); err != nil {
t.Fatalf("cancel booking: %v", err)
}
booking, _ := repo.GetBookingByID(ctx, resp.BookingID)
if booking.Status != "cancelled" {
t.Fatalf("expected cancelled, got %q", booking.Status)
}
if err := svc.CancelBooking(ctx, owner, resp.BookingID); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput on second cancel, got %v", err)
}
if err := svc.CancelBooking(ctx, owner, "00000000-0000-0000-0000-000000000099"); !errors.Is(err, ErrBookingNotFound) {
t.Fatalf("expected ErrBookingNotFound, got %v", err)
}
stranger := domain.Principal{Subject: "someone-else", Role: "staff"}
if err := svc.CancelBooking(ctx, stranger, resp.BookingID); !errors.Is(err, ErrTenantMembership) {
t.Fatalf("expected ErrTenantMembership, got %v", err)
}
}
func TestCancelBookingPromotesWaitlist(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
// Seeded session capacity is 4 — fill it, then overflow onto the waitlist.
var confirmed []domain.CreateBookingResponse
for i := 0; i < 4; i++ {
confirmed = append(confirmed, bookClass(t, repo, fmt.Sprintf("seat%[email protected]", i)))
}
first := bookClass(t, repo, "[email protected]")
second := bookClass(t, repo, "[email protected]")
booking, _ := repo.GetBookingByID(ctx, first.BookingID)
if booking.Status != "waitlisted" {
t.Fatalf("expected waitlisted, got %q", booking.Status)
}
// Cancel a confirmed seat -> earliest waiter is promoted, second stays queued.
if err := svc.CancelBooking(ctx, owner, confirmed[0].BookingID); err != nil {
t.Fatalf("cancel: %v", err)
}
booking, _ = repo.GetBookingByID(ctx, first.BookingID)
if booking.Status != "confirmed" {
t.Fatalf("expected first waiter promoted, got %q", booking.Status)
}
booking, _ = repo.GetBookingByID(ctx, second.BookingID)
if booking.Status != "waitlisted" {
t.Fatalf("expected second waiter still queued, got %q", booking.Status)
}
// Free another seat -> second waiter promoted.
if err := svc.CancelBooking(ctx, owner, confirmed[1].BookingID); err != nil {
t.Fatalf("cancel second: %v", err)
}
booking, _ = repo.GetBookingByID(ctx, second.BookingID)
if booking.Status != "confirmed" {
t.Fatalf("expected second waiter promoted, got %q", booking.Status)
}
}
func TestUpdateClassTemplate(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
created, err := svc.CreateClassTemplate(ctx, owner, domain.UpsertClassTemplateRequest{
Title: "Yin Yoga", DurationMinutes: 75, Capacity: 10,
})
if err != nil {
t.Fatalf("create template: %v", err)
}
updated, err := svc.UpdateClassTemplate(ctx, owner, created.ID, domain.UpsertClassTemplateRequest{
Title: "Yin Yoga (večer)", DurationMinutes: 90, Capacity: 12,
})
if err != nil {
t.Fatalf("update template: %v", err)
}
if updated.Title != "Yin Yoga (večer)" || updated.DurationMinutes != 90 || updated.Capacity != 12 {
t.Fatalf("unexpected updated template: %+v", updated)
}
if _, err := svc.UpdateClassTemplate(ctx, owner, created.ID, domain.UpsertClassTemplateRequest{
Title: "", DurationMinutes: 90, Capacity: 12,
}); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput, got %v", err)
}
stranger := domain.Principal{Subject: "someone-else", Role: "staff"}
if _, err := svc.UpdateClassTemplate(ctx, stranger, created.ID, domain.UpsertClassTemplateRequest{
Title: "X", DurationMinutes: 60, Capacity: 5,
}); !errors.Is(err, ErrTenantMembership) {
t.Fatalf("expected ErrTenantMembership, got %v", err)
}
if _, err := svc.UpdateClassTemplate(ctx, owner, "00000000-0000-0000-0000-000000000099", domain.UpsertClassTemplateRequest{
Title: "X", DurationMinutes: 60, Capacity: 5,
}); !errors.Is(err, ErrClassTemplateNotFound) {
t.Fatalf("expected ErrClassTemplateNotFound, got %v", err)
}
}
+69 -3
View File
@@ -2,14 +2,17 @@ package db
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5/pgconn"
)
func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
COALESCE(manage_token, ''), COALESCE(notes, '')
COALESCE(manage_token, ''), COALESCE(notes, ''), created_at
FROM bookings
WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2
ORDER BY starts_at ASC
@@ -38,6 +41,7 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID
&record.Reference,
&record.ManageToken,
&record.Notes,
&record.CreatedAt,
); err != nil {
return nil, err
}
@@ -84,6 +88,17 @@ func (r *PGRepository) AppendWaitlistEntry(ctx context.Context, params WaitlistE
return err
}
// ResolveWaitlistEntry marks the pending ledger row for a promoted booking.
// Entries carry no booking_id, so the session + customer email pair is the
// practical key.
func (r *PGRepository) ResolveWaitlistEntry(ctx context.Context, tenantID, sessionID, customerEmail string) error {
_, err := r.pool.Exec(ctx, `
UPDATE waitlist_entries SET status = 'resolved'
WHERE tenant_id = $1 AND class_session_id = $2 AND customer_email = $3 AND status = 'pending'
`, tenantID, sessionID, customerEmail)
return err
}
func (r *PGRepository) CreateReminderJob(ctx context.Context, params ReminderJobParams) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO reminder_jobs (tenant_id, booking_id, channel, scheduled_for)
@@ -96,7 +111,7 @@ func (r *PGRepository) ListDueReminderJobs(ctx context.Context, dueBefore time.T
rows, err := r.pool.Query(ctx, `
SELECT rj.id, rj.tenant_id, t.name, t.locale, t.timezone,
rj.booking_id, rj.channel, rj.scheduled_for,
b.customer_name, b.customer_email, b.reference, b.starts_at, rj.status
b.customer_name, b.customer_email, b.customer_phone, b.reference, b.starts_at, rj.status, b.status
FROM reminder_jobs rj
INNER JOIN bookings b ON b.id = rj.booking_id
INNER JOIN tenants t ON t.id = rj.tenant_id
@@ -123,9 +138,11 @@ func (r *PGRepository) ListDueReminderJobs(ctx context.Context, dueBefore time.T
&record.ScheduledFor,
&record.CustomerName,
&record.CustomerEmail,
&record.CustomerPhone,
&record.Reference,
&record.StartsAt,
&record.Status,
&record.BookingStatus,
); err != nil {
return nil, err
}
@@ -167,13 +184,62 @@ func (r *PGRepository) GetDashboardMetrics(ctx context.Context, tenantID string,
/ NULLIF(COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3), 0)
),
0
)::integer AS utilization_percent
)::integer AS utilization_percent,
COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3 AND status = 'confirmed' AND ends_at < now()) AS completed_count,
COUNT(DISTINCT customer_email) FILTER (
WHERE starts_at >= $2 AND starts_at < $3
AND NOT EXISTS (
SELECT 1 FROM bookings earlier
WHERE earlier.tenant_id = bookings.tenant_id
AND earlier.customer_email = bookings.customer_email
AND earlier.starts_at < $2
)
) AS new_clients_count
FROM bookings
WHERE tenant_id = $1
`, tenantID, startsAt, endsAt).Scan(
&metrics.BookingsCount,
&metrics.CancellationsCount,
&metrics.UtilizationPercent,
&metrics.CompletedCount,
&metrics.NewClientsCount,
)
return metrics, err
}
func (r *PGRepository) GetWaitlistPosition(ctx context.Context, sessionID, bookingID string) (int, error) {
var position int
err := r.pool.QueryRow(ctx, `
SELECT COUNT(*) + 1
FROM bookings
WHERE class_session_id = $1
AND status = 'waitlisted'
AND created_at < (SELECT created_at FROM bookings WHERE id = $2)
`, sessionID, bookingID).Scan(&position)
return position, err
}
func (r *PGRepository) CancelPendingReminderJobsForBooking(ctx context.Context, bookingID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE reminder_jobs
SET status = 'cancelled'
WHERE booking_id = $1 AND status = 'pending'
`, bookingID)
return err
}
func (r *PGRepository) DeletePendingReminderJobsForBooking(ctx context.Context, bookingID string) error {
_, err := r.pool.Exec(ctx, `
DELETE FROM reminder_jobs
WHERE booking_id = $1 AND status = 'pending'
`, bookingID)
return err
}
// IsExclusionViolation reports whether err is a PostgreSQL exclusion
// constraint violation (SQLSTATE 23P01). The bookings_*_no_overlap
// constraints raise it when a concurrent request wins the race for a slot.
func IsExclusionViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23P01"
}
+133
View File
@@ -0,0 +1,133 @@
package db
import (
"context"
)
const classTemplateColumns = "id, tenant_id, title, duration_minutes, capacity, created_at"
const classSessionColumns = "cs.id, cs.tenant_id, cs.template_id, cs.location_id, ct.title, cs.starts_at, cs.ends_at, cs.capacity"
func (r *PGRepository) ListClassTemplatesByTenant(ctx context.Context, tenantID string) ([]ClassTemplateRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT `+classTemplateColumns+`
FROM class_templates
WHERE tenant_id = $1
ORDER BY created_at ASC
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []ClassTemplateRecord
for rows.Next() {
var record ClassTemplateRecord
if err := rows.Scan(
&record.ID,
&record.TenantID,
&record.Title,
&record.DurationMinutes,
&record.Capacity,
&record.CreatedAt,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (r *PGRepository) GetClassTemplateByID(ctx context.Context, templateID string) (ClassTemplateRecord, error) {
var record ClassTemplateRecord
err := r.pool.QueryRow(ctx, `
SELECT `+classTemplateColumns+`
FROM class_templates
WHERE id = $1
`, templateID).Scan(
&record.ID,
&record.TenantID,
&record.Title,
&record.DurationMinutes,
&record.Capacity,
&record.CreatedAt,
)
return record, err
}
func (r *PGRepository) CreateClassTemplate(ctx context.Context, params CreateClassTemplateParams) (ClassTemplateRecord, error) {
var record ClassTemplateRecord
err := r.pool.QueryRow(ctx, `
INSERT INTO class_templates (tenant_id, title, duration_minutes, capacity)
VALUES ($1, $2, $3, $4)
RETURNING `+classTemplateColumns+`
`, params.TenantID, params.Title, params.DurationMinutes, params.Capacity).Scan(
&record.ID,
&record.TenantID,
&record.Title,
&record.DurationMinutes,
&record.Capacity,
&record.CreatedAt,
)
return record, err
}
func (r *PGRepository) UpdateClassTemplate(ctx context.Context, templateID string, params CreateClassTemplateParams) (ClassTemplateRecord, error) {
var record ClassTemplateRecord
err := r.pool.QueryRow(ctx, `
UPDATE class_templates
SET title = $2, duration_minutes = $3, capacity = $4
WHERE id = $1
RETURNING `+classTemplateColumns+`
`, templateID, params.Title, params.DurationMinutes, params.Capacity).Scan(
&record.ID,
&record.TenantID,
&record.Title,
&record.DurationMinutes,
&record.Capacity,
&record.CreatedAt,
)
return record, err
}
func (r *PGRepository) DeleteClassTemplate(ctx context.Context, templateID string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM class_templates WHERE id = $1`, templateID)
return err
}
func (r *PGRepository) GetClassSessionByID(ctx context.Context, sessionID string) (ClassSessionRecord, error) {
var record ClassSessionRecord
err := r.pool.QueryRow(ctx, `
SELECT `+classSessionColumns+`
FROM class_sessions cs
INNER JOIN class_templates ct ON ct.id = cs.template_id
WHERE cs.id = $1
`, sessionID).Scan(
&record.ID,
&record.TenantID,
&record.TemplateID,
&record.LocationID,
&record.Title,
&record.StartsAt,
&record.EndsAt,
&record.Capacity,
)
return record, err
}
func (r *PGRepository) CreateClassSession(ctx context.Context, params CreateClassSessionParams) (ClassSessionRecord, error) {
var record ClassSessionRecord
err := r.pool.QueryRow(ctx, `
INSERT INTO class_sessions (tenant_id, template_id, location_id, starts_at, ends_at, capacity)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`, params.TenantID, params.TemplateID, params.LocationID, params.StartsAt, params.EndsAt, params.Capacity).Scan(&record.ID)
if err != nil {
return record, err
}
return r.GetClassSessionByID(ctx, record.ID)
}
func (r *PGRepository) DeleteClassSession(ctx context.Context, sessionID string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM class_sessions WHERE id = $1`, sessionID)
return err
}
+33 -15
View File
@@ -122,13 +122,24 @@ func (r *PGRepository) DeleteBlockedDay(ctx context.Context, blockedDayID string
return err
}
func (r *PGRepository) ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error) {
rows, err := r.pool.Query(ctx, `
func (r *PGRepository) ListWorkingHoursByTenant(ctx context.Context, tenantID string, staffID *string) ([]WorkingHoursRecord, error) {
query := `
SELECT tenant_id, staff_id, day_of_week, starts_local::text, ends_local::text
FROM availability_rules
WHERE tenant_id = $1 AND staff_id IS NULL
ORDER BY day_of_week
`, tenantID)
`
args := []any{tenantID}
if staffID != nil {
query = `
SELECT tenant_id, staff_id, day_of_week, starts_local::text, ends_local::text
FROM availability_rules
WHERE tenant_id = $1 AND staff_id = $2
ORDER BY day_of_week
`
args = append(args, *staffID)
}
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
@@ -145,12 +156,22 @@ func (r *PGRepository) ListWorkingHoursByTenant(ctx context.Context, tenantID st
return records, rows.Err()
}
func (r *PGRepository) UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error {
if params.IsOpen != nil && !*params.IsOpen {
_, err := r.pool.Exec(ctx, `
func (r *PGRepository) UpdateWorkingHours(ctx context.Context, tenantID string, staffID *string, dayOfWeek int, params UpdateWorkingHoursParams) error {
deleteQuery := `
DELETE FROM availability_rules
WHERE tenant_id = $1 AND day_of_week = $2 AND staff_id IS NULL
`
args := []any{tenantID, dayOfWeek}
if staffID != nil {
deleteQuery = `
DELETE FROM availability_rules
WHERE tenant_id = $1 AND day_of_week = $2 AND staff_id IS NULL
`, tenantID, dayOfWeek)
WHERE tenant_id = $1 AND day_of_week = $2 AND staff_id = $3
`
args = append(args, *staffID)
}
if params.IsOpen != nil && !*params.IsOpen {
_, err := r.pool.Exec(ctx, deleteQuery, args...)
return err
}
@@ -159,17 +180,14 @@ func (r *PGRepository) UpdateWorkingHours(ctx context.Context, tenantID string,
}
// availability_rules has no unique constraint on (tenant, day): replace the
// tenant-level row so repeated updates stay idempotent.
if _, err := r.pool.Exec(ctx, `
DELETE FROM availability_rules
WHERE tenant_id = $1 AND day_of_week = $2 AND staff_id IS NULL
`, tenantID, dayOfWeek); err != nil {
// scoped row so repeated updates stay idempotent.
if _, err := r.pool.Exec(ctx, deleteQuery, args...); err != nil {
return err
}
_, err := r.pool.Exec(ctx, `
INSERT INTO availability_rules (id, tenant_id, staff_id, day_of_week, starts_local, ends_local)
VALUES (gen_random_uuid(), $1, NULL, $2, $3::time, $4::time)
`, tenantID, dayOfWeek, *params.StartsLocal, *params.EndsLocal)
VALUES (gen_random_uuid(), $1, $2, $3, $4::time, $5::time)
`, tenantID, staffID, dayOfWeek, *params.StartsLocal, *params.EndsLocal)
return err
}
+567 -15
View File
@@ -26,12 +26,23 @@ type Repository interface {
ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error)
ListAvailabilityRulesByTenant(ctx context.Context, tenantID string) ([]AvailabilityRuleRecord, error)
ListClassSessionsByTenant(ctx context.Context, tenantID string, from time.Time, limit int) ([]ClassSessionRecord, error)
GetClassSessionByID(ctx context.Context, sessionID string) (ClassSessionRecord, error)
CreateClassSession(ctx context.Context, params CreateClassSessionParams) (ClassSessionRecord, error)
DeleteClassSession(ctx context.Context, sessionID string) error
ListClassTemplatesByTenant(ctx context.Context, tenantID string) ([]ClassTemplateRecord, error)
GetClassTemplateByID(ctx context.Context, templateID string) (ClassTemplateRecord, error)
CreateClassTemplate(ctx context.Context, params CreateClassTemplateParams) (ClassTemplateRecord, error)
UpdateClassTemplate(ctx context.Context, templateID string, params CreateClassTemplateParams) (ClassTemplateRecord, error)
DeleteClassTemplate(ctx context.Context, templateID string) error
ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error)
CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error)
AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error
ResolveWaitlistEntry(ctx context.Context, tenantID, sessionID, customerEmail string) error
CreateReminderJob(ctx context.Context, params ReminderJobParams) error
ListDueReminderJobs(ctx context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error)
MarkReminderJobDispatched(ctx context.Context, reminderJobID string, status string, dispatchedAt time.Time) error
CancelPendingReminderJobsForBooking(ctx context.Context, bookingID string) error
DeletePendingReminderJobsForBooking(ctx context.Context, bookingID string) error
CreateNotificationDeliveryLog(ctx context.Context, params NotificationDeliveryLogParams) error
GetDashboardMetrics(ctx context.Context, tenantID string, startsAt time.Time, endsAt time.Time) (DashboardMetrics, error)
GetSubscriptionSnapshot(ctx context.Context, tenantID string) (BillingSnapshotRecord, error)
@@ -65,6 +76,19 @@ type Repository interface {
UpdateLocation(ctx context.Context, locationID string, params UpdateLocationParams) (LocationRecord, error)
DeleteLocation(ctx context.Context, locationID string) error
// Service Management
GetServiceByID(ctx context.Context, serviceID string) (ServiceRecord, error)
CreateService(ctx context.Context, params CreateServiceParams) (ServiceRecord, error)
UpdateService(ctx context.Context, serviceID string, params UpdateServiceParams) (ServiceRecord, error)
DeleteService(ctx context.Context, serviceID string) error
// Staff Management
ListStaffByTenant(ctx context.Context, tenantID string) ([]StaffRecord, error)
GetStaffByID(ctx context.Context, staffID string) (StaffRecord, error)
CreateStaff(ctx context.Context, params CreateStaffParams) (StaffRecord, error)
UpdateStaff(ctx context.Context, staffID string, params UpdateStaffParams) (StaffRecord, error)
DeleteStaff(ctx context.Context, staffID string) error
// Blocked Days / Availability Exceptions
ListBlockedDaysByTenant(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BlockedDayRecord, error)
CreateBlockedDay(ctx context.Context, params CreateBlockedDayParams) (BlockedDayRecord, error)
@@ -83,12 +107,14 @@ type Repository interface {
// Customer Booking Management
GetBookingByReference(ctx context.Context, reference string) (BookingRecord, error)
GetBookingByID(ctx context.Context, bookingID string) (BookingRecord, error)
GetWaitlistPosition(ctx context.Context, sessionID, bookingID string) (int, error)
UpdateBookingStatus(ctx context.Context, bookingID string, status string) error
RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error
// Working Hours
ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error)
UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error
ListWorkingHoursByTenant(ctx context.Context, tenantID string, staffID *string) ([]WorkingHoursRecord, error)
UpdateWorkingHours(ctx context.Context, tenantID string, staffID *string, dayOfWeek int, params UpdateWorkingHoursParams) error
// Email templates
ListEmailTemplates(ctx context.Context, tenantID string) ([]EmailTemplateRecord, error)
@@ -225,6 +251,50 @@ type ServiceRecord struct {
BufferBeforeMinutes int
BufferAfterMinutes int
PriceCents int
CreatedAt time.Time
}
type CreateServiceParams struct {
TenantID string
Name string
DurationMinutes int
BufferBeforeMinutes int
BufferAfterMinutes int
PriceCents int
}
// Pointer fields distinguish "not sent" (keep current value) from an explicit
// zero value, which is meaningful for buffers and price.
type UpdateServiceParams struct {
Name *string
DurationMinutes *int
BufferBeforeMinutes *int
BufferAfterMinutes *int
PriceCents *int
}
type StaffRecord struct {
ID string
TenantID string
LocationID *string
DisplayName string
Active bool
CreatedAt time.Time
}
type CreateStaffParams struct {
TenantID string
LocationID *string
DisplayName string
Active bool
}
// LocationID tri-state: nil keeps the current assignment, a pointer to an
// empty string clears it, a pointer to an id reassigns it.
type UpdateStaffParams struct {
DisplayName *string
LocationID *string
Active *bool
}
type AvailabilityRuleRecord struct {
@@ -236,6 +306,22 @@ type AvailabilityRuleRecord struct {
EndsLocal string
}
type ClassTemplateRecord struct {
ID string
TenantID string
Title string
DurationMinutes int
Capacity int
CreatedAt time.Time
}
type CreateClassTemplateParams struct {
TenantID string
Title string
DurationMinutes int
Capacity int
}
type ClassSessionRecord struct {
ID string
TenantID string
@@ -247,6 +333,15 @@ type ClassSessionRecord struct {
Capacity int32
}
type CreateClassSessionParams struct {
TenantID string
TemplateID string
LocationID *string
StartsAt time.Time
EndsAt time.Time
Capacity int32
}
type BookingRecord struct {
ID string
TenantID string
@@ -263,6 +358,7 @@ type BookingRecord struct {
Reference string
ManageToken string
Notes string
CreatedAt time.Time
}
type CreateBookingParams struct {
@@ -302,6 +398,8 @@ type DashboardMetrics struct {
BookingsCount int
CancellationsCount int
UtilizationPercent int
CompletedCount int
NewClientsCount int
}
type ReminderJobParams struct {
@@ -322,9 +420,11 @@ type ReminderJobRecord struct {
ScheduledFor time.Time
CustomerName string
CustomerEmail string
CustomerPhone string
Reference string
StartsAt time.Time
Status string
BookingStatus string
}
type NotificationDeliveryLogParams struct {
@@ -630,11 +730,24 @@ func (r *PGRepository) GetBookingByReference(ctx context.Context, reference stri
err := r.pool.QueryRow(ctx, `
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
COALESCE(manage_token, ''), COALESCE(notes, '')
COALESCE(manage_token, ''), COALESCE(notes, ''), created_at
FROM bookings
WHERE reference = $1
`, reference).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes)
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) GetBookingByID(ctx context.Context, bookingID string) (BookingRecord, error) {
var rec BookingRecord
err := r.pool.QueryRow(ctx, `
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
COALESCE(manage_token, ''), COALESCE(notes, ''), created_at
FROM bookings
WHERE id = $1
`, bookingID).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes, &rec.CreatedAt)
return rec, err
}
@@ -662,6 +775,7 @@ type MemoryRepository struct {
brand BrandProfileRecord
services []ServiceRecord
rules []AvailabilityRuleRecord
classTemplates []ClassTemplateRecord
classSessions []ClassSessionRecord
bookings []BookingRecord
waitlistSize int
@@ -670,6 +784,7 @@ type MemoryRepository struct {
billingSnapshot BillingSnapshotRecord
recordedEvents map[string]struct{}
locations []LocationRecord
staff []StaffRecord
blockedDays []BlockedDayRecord
customers []CustomerRecord
workingHours []WorkingHoursRecord
@@ -732,6 +847,17 @@ func NewMemoryRepository() *MemoryRepository {
BufferBeforeMinutes: 0,
BufferAfterMinutes: 15,
PriceCents: 120000,
CreatedAt: now,
},
},
staff: []StaffRecord{
{
ID: staffID,
TenantID: tenantID,
LocationID: &locationID,
DisplayName: "Demo Stylist",
Active: true,
CreatedAt: now,
},
},
rules: []AvailabilityRuleRecord{
@@ -741,6 +867,9 @@ func NewMemoryRepository() *MemoryRepository {
{ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 4, StartsLocal: "09:00:00", EndsLocal: "17:00:00"},
{ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 5, StartsLocal: "09:00:00", EndsLocal: "17:00:00"},
},
classTemplates: []ClassTemplateRecord{
{ID: templateID, TenantID: tenantID, Title: "Small group mobility class", DurationMinutes: 60, Capacity: 4, CreatedAt: now},
},
classSessions: []ClassSessionRecord{
{
ID: sessionID,
@@ -857,6 +986,7 @@ func (r *MemoryRepository) CreateTenantForUser(_ context.Context, params CreateT
BufferAfterMinutes: maxInt(params.BufferAfterMinutes, 0),
PriceCents: 0,
}}
r.staff = nil
r.rules = nil
blocks := params.AvailabilityBlocks
if len(blocks) == 0 {
@@ -937,6 +1067,153 @@ func (r *MemoryRepository) ListServicesByTenant(_ context.Context, tenantID stri
return append([]ServiceRecord(nil), r.services...), nil
}
func (r *MemoryRepository) GetServiceByID(_ context.Context, serviceID string) (ServiceRecord, error) {
for _, service := range r.services {
if service.ID == serviceID {
return service, nil
}
}
return ServiceRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) CreateService(_ context.Context, params CreateServiceParams) (ServiceRecord, error) {
service := ServiceRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
Name: params.Name,
DurationMinutes: params.DurationMinutes,
BufferBeforeMinutes: params.BufferBeforeMinutes,
BufferAfterMinutes: params.BufferAfterMinutes,
PriceCents: params.PriceCents,
CreatedAt: time.Now().UTC(),
}
r.services = append(r.services, service)
return service, nil
}
func (r *MemoryRepository) UpdateService(_ context.Context, serviceID string, params UpdateServiceParams) (ServiceRecord, error) {
for i, service := range r.services {
if service.ID != serviceID {
continue
}
if params.Name != nil {
service.Name = *params.Name
}
if params.DurationMinutes != nil {
service.DurationMinutes = *params.DurationMinutes
}
if params.BufferBeforeMinutes != nil {
service.BufferBeforeMinutes = *params.BufferBeforeMinutes
}
if params.BufferAfterMinutes != nil {
service.BufferAfterMinutes = *params.BufferAfterMinutes
}
if params.PriceCents != nil {
service.PriceCents = *params.PriceCents
}
r.services[i] = service
return service, nil
}
return ServiceRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) DeleteService(_ context.Context, serviceID string) error {
for i, service := range r.services {
if service.ID != serviceID {
continue
}
r.services = append(r.services[:i], r.services[i+1:]...)
// Mirror ON DELETE SET NULL for bookings referencing the service.
for j, booking := range r.bookings {
if booking.ServiceID != nil && *booking.ServiceID == serviceID {
booking.ServiceID = nil
r.bookings[j] = booking
}
}
return nil
}
return pgx.ErrNoRows
}
func (r *MemoryRepository) ListStaffByTenant(_ context.Context, tenantID string) ([]StaffRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
return append([]StaffRecord(nil), r.staff...), nil
}
func (r *MemoryRepository) GetStaffByID(_ context.Context, staffID string) (StaffRecord, error) {
for _, member := range r.staff {
if member.ID == staffID {
return member, nil
}
}
return StaffRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) CreateStaff(_ context.Context, params CreateStaffParams) (StaffRecord, error) {
member := StaffRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
LocationID: params.LocationID,
DisplayName: params.DisplayName,
Active: params.Active,
CreatedAt: time.Now().UTC(),
}
r.staff = append(r.staff, member)
return member, nil
}
func (r *MemoryRepository) UpdateStaff(_ context.Context, staffID string, params UpdateStaffParams) (StaffRecord, error) {
for i, member := range r.staff {
if member.ID != staffID {
continue
}
if params.DisplayName != nil {
member.DisplayName = *params.DisplayName
}
if params.LocationID != nil {
locationID := strings.TrimSpace(*params.LocationID)
if locationID == "" {
member.LocationID = nil
} else {
member.LocationID = &locationID
}
}
if params.Active != nil {
member.Active = *params.Active
}
r.staff[i] = member
return member, nil
}
return StaffRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) DeleteStaff(_ context.Context, staffID string) error {
for i, member := range r.staff {
if member.ID != staffID {
continue
}
r.staff = append(r.staff[:i], r.staff[i+1:]...)
// Mirror the FK behaviour: bookings SET NULL, availability rules CASCADE.
for j, booking := range r.bookings {
if booking.StaffID != nil && *booking.StaffID == staffID {
booking.StaffID = nil
r.bookings[j] = booking
}
}
var rules []AvailabilityRuleRecord
for _, rule := range r.rules {
if rule.StaffID == nil || *rule.StaffID != staffID {
rules = append(rules, rule)
}
}
r.rules = rules
return nil
}
return pgx.ErrNoRows
}
func (r *MemoryRepository) ListAvailabilityRulesByTenant(_ context.Context, tenantID string) ([]AvailabilityRuleRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
@@ -962,6 +1239,109 @@ func (r *MemoryRepository) ListClassSessionsByTenant(_ context.Context, tenantID
return out, nil
}
func (r *MemoryRepository) GetClassSessionByID(_ context.Context, sessionID string) (ClassSessionRecord, error) {
for _, session := range r.classSessions {
if session.ID == sessionID {
return session, nil
}
}
return ClassSessionRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) CreateClassSession(_ context.Context, params CreateClassSessionParams) (ClassSessionRecord, error) {
record := ClassSessionRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
TemplateID: params.TemplateID,
LocationID: params.LocationID,
StartsAt: params.StartsAt,
EndsAt: params.EndsAt,
Capacity: params.Capacity,
}
for _, template := range r.classTemplates {
if template.ID == params.TemplateID {
record.Title = template.Title
break
}
}
r.classSessions = append(r.classSessions, record)
return record, nil
}
func (r *MemoryRepository) DeleteClassSession(_ context.Context, sessionID string) error {
for i, session := range r.classSessions {
if session.ID == sessionID {
r.classSessions = append(r.classSessions[:i], r.classSessions[i+1:]...)
for j := range r.bookings {
if r.bookings[j].ClassSessionID != nil && *r.bookings[j].ClassSessionID == sessionID {
r.bookings[j].ClassSessionID = nil
}
}
return nil
}
}
return pgx.ErrNoRows
}
func (r *MemoryRepository) ListClassTemplatesByTenant(_ context.Context, tenantID string) ([]ClassTemplateRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
return append([]ClassTemplateRecord(nil), r.classTemplates...), nil
}
func (r *MemoryRepository) GetClassTemplateByID(_ context.Context, templateID string) (ClassTemplateRecord, error) {
for _, template := range r.classTemplates {
if template.ID == templateID {
return template, nil
}
}
return ClassTemplateRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) CreateClassTemplate(_ context.Context, params CreateClassTemplateParams) (ClassTemplateRecord, error) {
record := ClassTemplateRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
Title: params.Title,
DurationMinutes: params.DurationMinutes,
Capacity: params.Capacity,
CreatedAt: time.Now().UTC(),
}
r.classTemplates = append(r.classTemplates, record)
return record, nil
}
func (r *MemoryRepository) UpdateClassTemplate(_ context.Context, templateID string, params CreateClassTemplateParams) (ClassTemplateRecord, error) {
for index := range r.classTemplates {
if r.classTemplates[index].ID == templateID {
r.classTemplates[index].Title = params.Title
r.classTemplates[index].DurationMinutes = params.DurationMinutes
r.classTemplates[index].Capacity = params.Capacity
return r.classTemplates[index], nil
}
}
return ClassTemplateRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) DeleteClassTemplate(_ context.Context, templateID string) error {
for i, template := range r.classTemplates {
if template.ID == templateID {
r.classTemplates = append(r.classTemplates[:i], r.classTemplates[i+1:]...)
// class_sessions.template_id has ON DELETE CASCADE
var remaining []ClassSessionRecord
for _, session := range r.classSessions {
if session.TemplateID != templateID {
remaining = append(remaining, session)
}
}
r.classSessions = remaining
return nil
}
}
return pgx.ErrNoRows
}
func (r *MemoryRepository) ListBookingsByTenantBetween(_ context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) {
var out []BookingRecord
for _, booking := range r.bookings {
@@ -999,6 +1379,7 @@ func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBooking
Reference: params.Reference,
ManageToken: params.ManageToken,
Notes: params.Notes,
CreatedAt: time.Now().UTC(),
})
return created, nil
}
@@ -1008,17 +1389,26 @@ func (r *MemoryRepository) AppendWaitlistEntry(_ context.Context, _ WaitlistEntr
return nil
}
func (r *MemoryRepository) ResolveWaitlistEntry(_ context.Context, _, _, _ string) error {
if r.waitlistSize > 0 {
r.waitlistSize--
}
return nil
}
func (r *MemoryRepository) CreateReminderJob(_ context.Context, params ReminderJobParams) error {
reference := params.BookingID
startsAt := time.Now().UTC()
customerName := "Booking Customer"
customerEmail := "[email protected]"
customerPhone := ""
for _, booking := range r.bookings {
if booking.ID == params.BookingID {
reference = booking.Reference
startsAt = booking.StartsAt
customerName = booking.CustomerName
customerEmail = booking.CustomerEmail
customerPhone = booking.CustomerPhone
break
}
}
@@ -1033,6 +1423,7 @@ func (r *MemoryRepository) CreateReminderJob(_ context.Context, params ReminderJ
ScheduledFor: params.ScheduledFor,
CustomerName: customerName,
CustomerEmail: customerEmail,
CustomerPhone: customerPhone,
Reference: reference,
StartsAt: startsAt,
Status: "pending",
@@ -1044,6 +1435,13 @@ func (r *MemoryRepository) ListDueReminderJobs(_ context.Context, dueBefore time
var out []ReminderJobRecord
for _, job := range r.reminderJobs {
if job.Status == "pending" && !job.ScheduledFor.After(dueBefore) {
for _, booking := range r.bookings {
if booking.ID == job.BookingID {
job.BookingStatus = booking.Status
job.CustomerPhone = booking.CustomerPhone
break
}
}
out = append(out, job)
}
}
@@ -1064,6 +1462,27 @@ func (r *MemoryRepository) MarkReminderJobDispatched(_ context.Context, reminder
return pgx.ErrNoRows
}
func (r *MemoryRepository) CancelPendingReminderJobsForBooking(_ context.Context, bookingID string) error {
for index := range r.reminderJobs {
if r.reminderJobs[index].BookingID == bookingID && r.reminderJobs[index].Status == "pending" {
r.reminderJobs[index].Status = "cancelled"
}
}
return nil
}
func (r *MemoryRepository) DeletePendingReminderJobsForBooking(_ context.Context, bookingID string) error {
kept := r.reminderJobs[:0]
for _, job := range r.reminderJobs {
if job.BookingID == bookingID && job.Status == "pending" {
continue
}
kept = append(kept, job)
}
r.reminderJobs = kept
return nil
}
func (r *MemoryRepository) CreateNotificationDeliveryLog(_ context.Context, params NotificationDeliveryLogParams) error {
r.deliveryLogs = append(r.deliveryLogs, params)
return nil
@@ -1084,6 +1503,24 @@ func (r *MemoryRepository) GetDashboardMetrics(_ context.Context, tenantID strin
}
if booking.Status == "confirmed" {
metrics.UtilizationPercent++
if booking.EndsAt.Before(time.Now().UTC()) {
metrics.CompletedCount++
}
}
}
firstSeen := map[string]time.Time{}
for _, booking := range r.bookings {
if booking.TenantID != tenantID {
continue
}
first, ok := firstSeen[booking.CustomerEmail]
if !ok || booking.StartsAt.Before(first) {
firstSeen[booking.CustomerEmail] = booking.StartsAt
}
}
for _, first := range firstSeen {
if !first.Before(startsAt) && first.Before(endsAt) {
metrics.NewClientsCount++
}
}
if metrics.BookingsCount == 0 {
@@ -1412,6 +1849,36 @@ func (r *MemoryRepository) GetBookingByReference(_ context.Context, reference st
return BookingRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) GetBookingByID(_ context.Context, bookingID string) (BookingRecord, error) {
for _, b := range r.bookings {
if b.ID == bookingID {
return b, nil
}
}
return BookingRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) GetWaitlistPosition(_ context.Context, sessionID, bookingID string) (int, error) {
var self *BookingRecord
for index := range r.bookings {
if r.bookings[index].ID == bookingID {
self = &r.bookings[index]
break
}
}
if self == nil {
return 0, pgx.ErrNoRows
}
position := 1
for _, booking := range r.bookings {
if booking.ClassSessionID != nil && *booking.ClassSessionID == sessionID &&
booking.Status == "waitlisted" && booking.CreatedAt.Before(self.CreatedAt) {
position++
}
}
return position, nil
}
func (r *MemoryRepository) UpdateBookingStatus(_ context.Context, bookingID string, status string) error {
for i, b := range r.bookings {
if b.ID == bookingID {
@@ -1482,29 +1949,114 @@ func (r *MemoryRepository) UpsertEmailTemplate(_ context.Context, params EmailTe
// WORKING HOURS METHODS - MEMORY REPOSITORY
// ============================================
func (r *MemoryRepository) ListWorkingHoursByTenant(_ context.Context, tenantID string) ([]WorkingHoursRecord, error) {
// ListWorkingHoursByTenant returns tenant-wide hours from the dedicated
// working-hours store, and staff-scoped hours from the availability rules that
// slot generation consumes — mirroring PostgreSQL where both live in
// availability_rules.
func (r *MemoryRepository) ListWorkingHoursByTenant(_ context.Context, tenantID string, staffID *string) ([]WorkingHoursRecord, error) {
if tenantID != r.tenant.ID {
return nil, pgx.ErrNoRows
}
return r.workingHours, nil
var out []WorkingHoursRecord
if staffID == nil {
for _, wh := range r.workingHours {
if wh.TenantID == tenantID && wh.StaffID == nil {
out = append(out, wh)
}
}
return out, nil
}
for _, rule := range r.rules {
if rule.TenantID == tenantID && rule.StaffID != nil && *rule.StaffID == *staffID {
out = append(out, WorkingHoursRecord{
TenantID: rule.TenantID,
StaffID: rule.StaffID,
DayOfWeek: rule.DayOfWeek,
StartsLocal: rule.StartsLocal,
EndsLocal: rule.EndsLocal,
})
}
}
return out, nil
}
func (r *MemoryRepository) UpdateWorkingHours(_ context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error {
func staffIDPtrEqual(a, b *string) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return *a == *b
}
func (r *MemoryRepository) UpdateWorkingHours(_ context.Context, tenantID string, staffID *string, dayOfWeek int, params UpdateWorkingHoursParams) error {
if tenantID != r.tenant.ID {
return pgx.ErrNoRows
}
if staffID != nil {
return r.updateStaffWorkingHours(tenantID, staffID, dayOfWeek, params)
}
if params.IsOpen != nil && !*params.IsOpen {
filtered := r.workingHours[:0]
for _, wh := range r.workingHours {
if wh.TenantID == tenantID && wh.DayOfWeek == dayOfWeek && wh.StaffID == nil {
continue
}
filtered = append(filtered, wh)
}
r.workingHours = filtered
return nil
}
if params.StartsLocal == nil || params.EndsLocal == nil {
return nil
}
for i, wh := range r.workingHours {
if wh.TenantID == tenantID && wh.DayOfWeek == dayOfWeek {
if params.StartsLocal != nil {
r.workingHours[i].StartsLocal = *params.StartsLocal
}
if params.EndsLocal != nil {
r.workingHours[i].EndsLocal = *params.EndsLocal
}
if wh.TenantID == tenantID && wh.DayOfWeek == dayOfWeek && wh.StaffID == nil {
r.workingHours[i].StartsLocal = *params.StartsLocal
r.workingHours[i].EndsLocal = *params.EndsLocal
return nil
}
}
return pgx.ErrNoRows
r.workingHours = append(r.workingHours, WorkingHoursRecord{
TenantID: tenantID,
DayOfWeek: dayOfWeek,
StartsLocal: *params.StartsLocal,
EndsLocal: *params.EndsLocal,
})
return nil
}
// updateStaffWorkingHours replaces the staff-scoped availability rule for a
// day so edits actually change generated slots, matching PostgreSQL semantics.
func (r *MemoryRepository) updateStaffWorkingHours(tenantID string, staffID *string, dayOfWeek int, params UpdateWorkingHoursParams) error {
filtered := r.rules[:0]
for _, rule := range r.rules {
if rule.TenantID == tenantID && rule.DayOfWeek == dayOfWeek && rule.StaffID != nil && *rule.StaffID == *staffID {
continue
}
filtered = append(filtered, rule)
}
r.rules = filtered
if params.IsOpen != nil && !*params.IsOpen {
return nil
}
if params.StartsLocal == nil || params.EndsLocal == nil {
return nil
}
r.rules = append(r.rules, AvailabilityRuleRecord{
ID: uuid.NewString(),
TenantID: tenantID,
StaffID: staffID,
DayOfWeek: dayOfWeek,
StartsLocal: normalizeLocalTime(*params.StartsLocal),
EndsLocal: normalizeLocalTime(*params.EndsLocal),
})
return nil
}
func normalizeLocalTime(value string) string {
if len(value) == 5 {
return value + ":00"
}
return value
}
// Auth methods for MemoryRepository
+71 -1
View File
@@ -5,9 +5,11 @@ import (
"time"
)
const serviceColumns = "id, tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents, created_at"
func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents
SELECT `+serviceColumns+`
FROM services
WHERE tenant_id = $1
ORDER BY created_at ASC
@@ -28,6 +30,7 @@ func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string
&record.BufferBeforeMinutes,
&record.BufferAfterMinutes,
&record.PriceCents,
&record.CreatedAt,
); err != nil {
return nil, err
}
@@ -36,6 +39,73 @@ func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string
return records, rows.Err()
}
func (r *PGRepository) GetServiceByID(ctx context.Context, serviceID string) (ServiceRecord, error) {
var record ServiceRecord
err := r.pool.QueryRow(ctx, `
SELECT `+serviceColumns+`
FROM services
WHERE id = $1
`, serviceID).Scan(
&record.ID,
&record.TenantID,
&record.Name,
&record.DurationMinutes,
&record.BufferBeforeMinutes,
&record.BufferAfterMinutes,
&record.PriceCents,
&record.CreatedAt,
)
return record, err
}
func (r *PGRepository) CreateService(ctx context.Context, params CreateServiceParams) (ServiceRecord, error) {
var record ServiceRecord
err := r.pool.QueryRow(ctx, `
INSERT INTO services (tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING `+serviceColumns+`
`, params.TenantID, params.Name, params.DurationMinutes, params.BufferBeforeMinutes, params.BufferAfterMinutes, params.PriceCents).Scan(
&record.ID,
&record.TenantID,
&record.Name,
&record.DurationMinutes,
&record.BufferBeforeMinutes,
&record.BufferAfterMinutes,
&record.PriceCents,
&record.CreatedAt,
)
return record, err
}
func (r *PGRepository) UpdateService(ctx context.Context, serviceID string, params UpdateServiceParams) (ServiceRecord, error) {
var record ServiceRecord
err := r.pool.QueryRow(ctx, `
UPDATE services
SET name = COALESCE($2, name),
duration_minutes = COALESCE($3, duration_minutes),
buffer_before_minutes = COALESCE($4, buffer_before_minutes),
buffer_after_minutes = COALESCE($5, buffer_after_minutes),
price_cents = COALESCE($6, price_cents)
WHERE id = $1
RETURNING `+serviceColumns+`
`, serviceID, params.Name, params.DurationMinutes, params.BufferBeforeMinutes, params.BufferAfterMinutes, params.PriceCents).Scan(
&record.ID,
&record.TenantID,
&record.Name,
&record.DurationMinutes,
&record.BufferBeforeMinutes,
&record.BufferAfterMinutes,
&record.PriceCents,
&record.CreatedAt,
)
return record, err
}
func (r *PGRepository) DeleteService(ctx context.Context, serviceID string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM services WHERE id = $1`, serviceID)
return err
}
func (r *PGRepository) ListAvailabilityRulesByTenant(ctx context.Context, tenantID string) ([]AvailabilityRuleRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, staff_id, day_of_week, starts_local, ends_local
+79
View File
@@ -0,0 +1,79 @@
package db
import (
"context"
"strings"
)
const staffColumns = "id, tenant_id, location_id, display_name, active, created_at"
func (r *PGRepository) ListStaffByTenant(ctx context.Context, tenantID string) ([]StaffRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT `+staffColumns+`
FROM staff
WHERE tenant_id = $1
ORDER BY created_at ASC
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []StaffRecord
for rows.Next() {
var rec StaffRecord
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.LocationID, &rec.DisplayName, &rec.Active, &rec.CreatedAt); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) GetStaffByID(ctx context.Context, staffID string) (StaffRecord, error) {
var rec StaffRecord
err := r.pool.QueryRow(ctx, `
SELECT `+staffColumns+`
FROM staff
WHERE id = $1
`, staffID).Scan(&rec.ID, &rec.TenantID, &rec.LocationID, &rec.DisplayName, &rec.Active, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) CreateStaff(ctx context.Context, params CreateStaffParams) (StaffRecord, error) {
var rec StaffRecord
err := r.pool.QueryRow(ctx, `
INSERT INTO staff (tenant_id, location_id, display_name, active)
VALUES ($1, $2, $3, $4)
RETURNING `+staffColumns+`
`, params.TenantID, params.LocationID, params.DisplayName, params.Active).Scan(
&rec.ID, &rec.TenantID, &rec.LocationID, &rec.DisplayName, &rec.Active, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) UpdateStaff(ctx context.Context, staffID string, params UpdateStaffParams) (StaffRecord, error) {
// Tri-state location update: untouched when the request omits the field,
// NULL when it is explicitly empty, otherwise reassigned.
locationSet := params.LocationID != nil
var locationValue any
if locationSet && strings.TrimSpace(*params.LocationID) != "" {
locationValue = strings.TrimSpace(*params.LocationID)
}
var rec StaffRecord
err := r.pool.QueryRow(ctx, `
UPDATE staff
SET display_name = COALESCE($2, display_name),
location_id = CASE WHEN $3 THEN $4::uuid ELSE location_id END,
active = COALESCE($5, active)
WHERE id = $1
RETURNING `+staffColumns+`
`, staffID, params.DisplayName, locationSet, locationValue, params.Active).Scan(
&rec.ID, &rec.TenantID, &rec.LocationID, &rec.DisplayName, &rec.Active, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) DeleteStaff(ctx context.Context, staffID string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM staff WHERE id = $1`, staffID)
return err
}
+123 -3
View File
@@ -10,9 +10,11 @@ type Principal struct {
}
type DashboardKPI struct {
Code string `json:"code"`
Label string `json:"label"`
Value string `json:"value"`
Code string `json:"code"`
Label string `json:"label"`
Value string `json:"value"`
Change string `json:"change,omitempty"`
Trend string `json:"trend,omitempty"`
}
type UpcomingBooking struct {
@@ -114,6 +116,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 +132,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"`
@@ -230,6 +251,7 @@ type DispatchReminderJobsResponse struct {
ProcessedCount int `json:"processedCount"`
SentCount int `json:"sentCount"`
FailedCount int `json:"failedCount"`
SkippedCount int `json:"skippedCount"`
}
// ============================================
@@ -267,6 +289,62 @@ type UpdateLocationRequest struct {
Capacity int `json:"capacity,omitempty"`
}
// ============================================
// SERVICE MODELS
// ============================================
type Service struct {
ID string `json:"id"`
TenantID string `json:"tenantId"`
Name string `json:"name"`
DurationMinutes int `json:"durationMinutes"`
BufferBeforeMinutes int `json:"bufferBeforeMinutes"`
BufferAfterMinutes int `json:"bufferAfterMinutes"`
PriceCents int `json:"priceCents"`
CreatedAt time.Time `json:"createdAt"`
}
type CreateServiceRequest struct {
Name string `json:"name" binding:"required"`
DurationMinutes int `json:"durationMinutes" binding:"required"`
BufferBeforeMinutes int `json:"bufferBeforeMinutes,omitempty"`
BufferAfterMinutes int `json:"bufferAfterMinutes,omitempty"`
PriceCents int `json:"priceCents,omitempty"`
}
type UpdateServiceRequest struct {
Name *string `json:"name,omitempty"`
DurationMinutes *int `json:"durationMinutes,omitempty"`
BufferBeforeMinutes *int `json:"bufferBeforeMinutes,omitempty"`
BufferAfterMinutes *int `json:"bufferAfterMinutes,omitempty"`
PriceCents *int `json:"priceCents,omitempty"`
}
// ============================================
// STAFF MODELS
// ============================================
type StaffMember struct {
ID string `json:"id"`
TenantID string `json:"tenantId"`
LocationID *string `json:"locationId,omitempty"`
DisplayName string `json:"displayName"`
Active bool `json:"active"`
CreatedAt time.Time `json:"createdAt"`
}
type CreateStaffRequest struct {
DisplayName string `json:"displayName" binding:"required"`
LocationID *string `json:"locationId,omitempty"`
Active *bool `json:"active,omitempty"` // defaults to true
}
type UpdateStaffRequest struct {
DisplayName *string `json:"displayName,omitempty"`
LocationID *string `json:"locationId,omitempty"` // empty string clears the assignment
Active *bool `json:"active,omitempty"`
}
// ============================================
// BLOCKED DAYS / AVAILABILITY EXCEPTION MODELS
// ============================================
@@ -293,6 +371,45 @@ type UpdateBlockedDayRequest struct {
Type string `json:"type,omitempty"`
}
// ============================================
// CLASS MODELS
// ============================================
type ClassTemplate struct {
ID string `json:"id"`
TenantID string `json:"tenantId"`
Title string `json:"title"`
DurationMinutes int `json:"durationMinutes"`
Capacity int `json:"capacity"`
CreatedAt time.Time `json:"createdAt"`
}
type UpsertClassTemplateRequest struct {
Title string `json:"title" binding:"required"`
DurationMinutes int `json:"durationMinutes" binding:"required"`
Capacity int `json:"capacity" binding:"required"`
}
type ClassSession struct {
ID string `json:"id"`
TenantID string `json:"tenantId"`
TemplateID string `json:"templateId"`
TemplateTitle string `json:"templateTitle"`
LocationID *string `json:"locationId,omitempty"`
LocationName string `json:"locationName,omitempty"`
StartsAt time.Time `json:"startsAt"`
EndsAt time.Time `json:"endsAt"`
Capacity int `json:"capacity"`
BookedCount int `json:"bookedCount"`
}
type CreateClassSessionRequest struct {
TemplateID string `json:"templateId" binding:"required"`
LocationID *string `json:"locationId,omitempty"`
StartsAt string `json:"startsAt" binding:"required"` // RFC3339
Capacity *int `json:"capacity,omitempty"`
}
// ============================================
// CUSTOMER MODELS
// ============================================
@@ -341,6 +458,9 @@ type CustomerBookingView struct {
Location string `json:"location"`
Status string `json:"status"`
Notes string `json:"notes,omitempty"`
// WaitlistPosition is the 1-based queue spot for waitlisted class
// bookings; zero for any other status.
WaitlistPosition int `json:"waitlistPosition,omitempty"`
}
type RescheduleBookingRequest struct {
@@ -37,6 +37,7 @@ type Service struct {
cfg config.Config
repo db.Repository
emailProvider EmailProvider
smsSender func(ctx context.Context, tenantID, phone, body string) error
now func() time.Time
}
@@ -54,6 +55,13 @@ func NewService(cfg config.Config, repo db.Repository) *Service {
}
}
// SetSMSSender plugs in the SMS delivery path used for sms-channel reminder
// jobs. Kept as a function adapter so this package stays free of the sms
// dependency; the concrete sms.Service is wired in the API server.
func (s *Service) SetSMSSender(sender func(ctx context.Context, tenantID, phone, body string) error) {
s.smsSender = sender
}
func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchReminderJobsResponse, error) {
if limit <= 0 {
limit = 25
@@ -68,6 +76,14 @@ func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchRe
for _, job := range jobs {
response.ProcessedCount++
if job.BookingStatus != "confirmed" {
if err := s.repo.MarkReminderJobDispatched(ctx, job.ID, "skipped", s.now()); err != nil {
return domain.DispatchReminderJobsResponse{}, err
}
response.SkippedCount++
continue
}
status := "sent"
provider := "unknown"
externalID := ""
@@ -83,6 +99,21 @@ func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchRe
provider = receipt.Provider
externalID = receipt.ExternalID
}
case "sms":
provider = "smsmanager"
switch {
case s.smsSender == nil:
status = "failed"
errorMessage = "sms delivery not configured"
case job.CustomerPhone == "":
status = "failed"
errorMessage = "missing customer phone"
default:
if sendErr := s.smsSender(ctx, job.TenantID, job.CustomerPhone, renderSmsBody(job)); sendErr != nil {
status = "failed"
errorMessage = sendErr.Error()
}
}
default:
status = "failed"
errorMessage = ErrUnsupportedChannel.Error()
@@ -267,7 +298,18 @@ func localizedStartsAt(job db.ReminderJobRecord) string {
return localStartsAt.Format("Jan 02, 2006 15:04")
}
func renderSmsBody(job db.ReminderJobRecord) string {
startLabel := localizedStartsAt(job)
if job.Locale == "cs" {
return fmt.Sprintf("%s: pripominka rezervace %s na %s.", job.TenantName, job.Reference, startLabel)
}
return fmt.Sprintf("%s: reminder for booking %s at %s.", job.TenantName, job.Reference, startLabel)
}
func reminderRecipient(job db.ReminderJobRecord) string {
if job.Channel == "sms" {
return job.CustomerPhone
}
return job.CustomerEmail
}
@@ -65,9 +65,21 @@ func TestDispatchDueProcessesPendingEmailReminders(t *testing.T) {
func TestDispatchDueFailsUnknownChannel(t *testing.T) {
repo := db.NewMemoryRepository()
startsAt := time.Now().UTC().Add(26 * time.Hour)
created, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
BookingMode: "appointment",
StartsAt: startsAt,
EndsAt: startsAt.Add(time.Hour),
Status: "confirmed",
Reference: "BK-TEST-PUSH",
})
if err != nil {
t.Fatalf("create booking: %v", err)
}
if err := repo.CreateReminderJob(context.Background(), db.ReminderJobParams{
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
BookingID: "booking-unknown-channel",
BookingID: created.ID,
Channel: "push",
ScheduledFor: time.Now().UTC().Add(-time.Minute),
}); err != nil {
@@ -90,3 +102,86 @@ func TestDispatchRequestContractShape(t *testing.T) {
t.Fatalf("expected limit 20, got %d", request.Limit)
}
}
func TestDispatchDueSkipsNonConfirmedBooking(t *testing.T) {
repo := db.NewMemoryRepository()
startsAt := time.Now().UTC().Add(26 * time.Hour)
created, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
BookingMode: "appointment",
StartsAt: startsAt,
EndsAt: startsAt.Add(time.Hour),
Status: "confirmed",
Reference: "BK-TEST-SKIP",
})
if err != nil {
t.Fatalf("create booking: %v", err)
}
if err := repo.CreateReminderJob(context.Background(), db.ReminderJobParams{
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
BookingID: created.ID,
Channel: "email",
ScheduledFor: time.Now().UTC().Add(-time.Minute),
}); err != nil {
t.Fatalf("create reminder job: %v", err)
}
if err := repo.UpdateBookingStatus(context.Background(), created.ID, "cancelled"); err != nil {
t.Fatalf("cancel booking: %v", err)
}
service := NewService(config.Config{Environment: "development"}, repo)
response, err := service.DispatchDue(context.Background(), 10)
if err != nil {
t.Fatalf("dispatch due: %v", err)
}
if response.ProcessedCount != 1 || response.SkippedCount != 1 || response.SentCount != 0 {
t.Fatalf("expected 1 skipped, got %+v", response)
}
}
func TestDispatchDueSendsSmsReminder(t *testing.T) {
repo := db.NewMemoryRepository()
startsAt := time.Now().UTC().Add(26 * time.Hour)
created, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
BookingMode: "appointment",
CustomerPhone: "+420777000111",
StartsAt: startsAt,
EndsAt: startsAt.Add(time.Hour),
Status: "confirmed",
Reference: "BK-TEST-SMS",
})
if err != nil {
t.Fatalf("create booking: %v", err)
}
if err := repo.CreateReminderJob(context.Background(), db.ReminderJobParams{
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
BookingID: created.ID,
Channel: "sms",
ScheduledFor: time.Now().UTC().Add(-time.Minute),
}); err != nil {
t.Fatalf("create reminder job: %v", err)
}
var sentPhone, sentBody string
service := NewService(config.Config{Environment: "development"}, repo)
service.SetSMSSender(func(_ context.Context, tenantID, phone, body string) error {
sentPhone = phone
sentBody = body
return nil
})
response, err := service.DispatchDue(context.Background(), 10)
if err != nil {
t.Fatalf("dispatch due: %v", err)
}
if response.SentCount != 1 {
t.Fatalf("expected 1 sent, got %+v", response)
}
if sentPhone != "+420777000111" {
t.Fatalf("expected phone recipient, got %q", sentPhone)
}
if sentBody == "" {
t.Fatal("expected rendered sms body")
}
}
@@ -0,0 +1,37 @@
-- +goose Up
-- Hard guarantee against double-booking. The service layer checks for
-- collisions before inserting, but check-then-insert is racy under concurrent
-- requests; these exclusion constraints enforce the same rules atomically.
CREATE EXTENSION IF NOT EXISTS btree_gist;
-- Two appointments may not overlap for the same staff member.
ALTER TABLE bookings
ADD CONSTRAINT bookings_staff_no_overlap
EXCLUDE USING gist (
tenant_id WITH =,
staff_id WITH =,
tstzrange(starts_at, ends_at) WITH &&
) WHERE (booking_mode = 'appointment' AND status <> 'cancelled' AND staff_id IS NOT NULL);
-- Two appointments may not overlap in the same location.
ALTER TABLE bookings
ADD CONSTRAINT bookings_location_no_overlap
EXCLUDE USING gist (
tenant_id WITH =,
location_id WITH =,
tstzrange(starts_at, ends_at) WITH &&
) WHERE (booking_mode = 'appointment' AND status <> 'cancelled' AND location_id IS NOT NULL);
-- Appointments with no staff or location assignment occupy the tenant's
-- single implicit resource (mirrors resourcesCollide in the service layer).
ALTER TABLE bookings
ADD CONSTRAINT bookings_unassigned_no_overlap
EXCLUDE USING gist (
tenant_id WITH =,
tstzrange(starts_at, ends_at) WITH &&
) WHERE (booking_mode = 'appointment' AND status <> 'cancelled' AND staff_id IS NULL AND location_id IS NULL);
-- +goose Down
ALTER TABLE bookings DROP CONSTRAINT IF EXISTS bookings_unassigned_no_overlap;
ALTER TABLE bookings DROP CONSTRAINT IF EXISTS bookings_location_no_overlap;
ALTER TABLE bookings DROP CONSTRAINT IF EXISTS bookings_staff_no_overlap;
+608 -1
View File
@@ -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]
@@ -589,6 +627,156 @@ paths:
description: Location deleted
"404":
description: Location not found
/v1/catalog/services:
get:
tags: [Catalog]
operationId: listServices
security:
- bearerAuth: []
responses:
"200":
description: Tenant services
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Service"
"401":
description: Unauthorized
post:
tags: [Catalog]
operationId: createService
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpsertServiceRequest"
responses:
"201":
description: Service created
content:
application/json:
schema:
$ref: "#/components/schemas/Service"
"400":
description: Invalid request
/v1/catalog/services/{serviceID}:
put:
tags: [Catalog]
operationId: updateService
security:
- bearerAuth: []
parameters:
- $ref: "#/components/parameters/serviceID"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpsertServiceRequest"
responses:
"200":
description: Service updated
content:
application/json:
schema:
$ref: "#/components/schemas/Service"
"400":
description: Invalid request
"404":
description: Service not found
delete:
tags: [Catalog]
operationId: deleteService
security:
- bearerAuth: []
parameters:
- $ref: "#/components/parameters/serviceID"
responses:
"200":
description: Service deleted
"404":
description: Service not found
/v1/catalog/staff:
get:
tags: [Catalog]
operationId: listStaff
security:
- bearerAuth: []
responses:
"200":
description: Tenant staff
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/StaffMember"
"401":
description: Unauthorized
post:
tags: [Catalog]
operationId: createStaff
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpsertStaffRequest"
responses:
"201":
description: Staff member created
content:
application/json:
schema:
$ref: "#/components/schemas/StaffMember"
"400":
description: Invalid request
"403":
description: Plan limit reached
/v1/catalog/staff/{staffID}:
put:
tags: [Catalog]
operationId: updateStaff
security:
- bearerAuth: []
parameters:
- $ref: "#/components/parameters/staffID"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpsertStaffRequest"
responses:
"200":
description: Staff member updated
content:
application/json:
schema:
$ref: "#/components/schemas/StaffMember"
"400":
description: Invalid request
"404":
description: Staff member not found
delete:
tags: [Catalog]
operationId: deleteStaff
security:
- bearerAuth: []
parameters:
- $ref: "#/components/parameters/staffID"
responses:
"200":
description: Staff member deleted
"404":
description: Staff member not found
/v1/catalog/bookings:
post:
tags: [Catalog]
@@ -612,6 +800,26 @@ paths:
description: Invalid request
"409":
description: Conflict with existing booking
/v1/catalog/bookings/{bookingID}/cancel:
post:
tags: [Catalog]
operationId: cancelDashboardBooking
security:
- bearerAuth: []
parameters:
- in: path
name: bookingID
required: true
schema:
type: string
format: uuid
responses:
"200":
description: Booking cancelled; freed class seats promote the waitlist
"400":
description: Booking already cancelled
"404":
description: Booking not found
/v1/catalog/blocked-days:
get:
tags: [Catalog]
@@ -798,6 +1006,196 @@ paths:
description: Working hours updated
"400":
description: Invalid request
/v1/catalog/staff/{staffID}/working-hours:
get:
tags: [Catalog]
operationId: listStaffWorkingHours
security:
- bearerAuth: []
parameters:
- in: path
name: staffID
required: true
schema:
type: string
format: uuid
responses:
"200":
description: Staff member's weekly working hours
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/WorkingHours"
"404":
description: Staff member not found
/v1/catalog/staff/{staffID}/working-hours/{dayOfWeek}:
put:
tags: [Catalog]
operationId: updateStaffWorkingHours
security:
- bearerAuth: []
parameters:
- in: path
name: staffID
required: true
schema:
type: string
format: uuid
- in: path
name: dayOfWeek
required: true
schema:
type: integer
minimum: 0
maximum: 6
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateWorkingHoursRequest"
responses:
"200":
description: Staff working hours updated
"400":
description: Invalid request
"404":
description: Staff member not found
/v1/catalog/class-templates:
get:
tags: [Catalog]
operationId: listClassTemplates
security:
- bearerAuth: []
responses:
"200":
description: Class templates
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/ClassTemplate"
post:
tags: [Catalog]
operationId: createClassTemplate
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpsertClassTemplateRequest"
responses:
"201":
description: Class template created
content:
application/json:
schema:
$ref: "#/components/schemas/ClassTemplate"
"400":
description: Invalid request
/v1/catalog/class-templates/{templateID}:
put:
tags: [Catalog]
operationId: updateClassTemplate
security:
- bearerAuth: []
parameters:
- in: path
name: templateID
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpsertClassTemplateRequest"
responses:
"200":
description: Updated class template
"400":
description: Invalid input
"404":
description: Template not found
delete:
tags: [Catalog]
operationId: deleteClassTemplate
security:
- bearerAuth: []
parameters:
- in: path
name: templateID
required: true
schema:
type: string
format: uuid
responses:
"200":
description: Class template deleted
"404":
description: Class template not found
/v1/catalog/class-sessions:
get:
tags: [Catalog]
operationId: listClassSessions
security:
- bearerAuth: []
responses:
"200":
description: Upcoming class sessions
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/ClassSession"
post:
tags: [Catalog]
operationId: createClassSession
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateClassSessionRequest"
responses:
"201":
description: Class session created
content:
application/json:
schema:
$ref: "#/components/schemas/ClassSession"
"400":
description: Invalid request
"404":
description: Template or location not found
/v1/catalog/class-sessions/{sessionID}:
delete:
tags: [Catalog]
operationId: deleteClassSession
security:
- bearerAuth: []
parameters:
- in: path
name: sessionID
required: true
schema:
type: string
format: uuid
responses:
"200":
description: Class session deleted
"404":
description: Class session not found
# ---------------- Billing ----------------
/v1/billing/subscription:
get:
@@ -1045,6 +1443,20 @@ components:
schema:
type: string
format: uuid
serviceID:
in: path
name: serviceID
required: true
schema:
type: string
format: uuid
staffID:
in: path
name: staffID
required: true
schema:
type: string
format: uuid
schemas:
HealthResponse:
type: object
@@ -1106,6 +1518,9 @@ components:
type: string
format: uuid
nullable: true
staffName:
type: string
nullable: true
locationId:
type: string
format: uuid
@@ -1139,6 +1554,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]
@@ -1198,6 +1649,11 @@ components:
type: string
value:
type: string
change:
type: string
trend:
type: string
enum: [up, down, neutral]
DashboardSummary:
type: object
required: [tenantName, tenantSlug, locale, timezone, planCode, publicBookingUrl, setupCompletion, kpis]
@@ -1478,6 +1934,81 @@ components:
type: integer
address:
type: string
Service:
type: object
required: [id, tenantId, name, durationMinutes, bufferBeforeMinutes, bufferAfterMinutes, priceCents]
properties:
id:
type: string
format: uuid
tenantId:
type: string
format: uuid
name:
type: string
durationMinutes:
type: integer
description: Appointment length in minutes (1-1440)
bufferBeforeMinutes:
type: integer
description: Blocked time before the appointment (0-480)
bufferAfterMinutes:
type: integer
description: Blocked time after the appointment (0-480)
priceCents:
type: integer
description: Price in the tenant's currency minor units
createdAt:
type: string
format: date-time
UpsertServiceRequest:
type: object
required: [name, durationMinutes]
properties:
name:
type: string
durationMinutes:
type: integer
bufferBeforeMinutes:
type: integer
bufferAfterMinutes:
type: integer
priceCents:
type: integer
StaffMember:
type: object
required: [id, tenantId, displayName, active]
properties:
id:
type: string
format: uuid
tenantId:
type: string
format: uuid
locationId:
type: string
format: uuid
nullable: true
displayName:
type: string
active:
type: boolean
createdAt:
type: string
format: date-time
UpsertStaffRequest:
type: object
required: [displayName]
properties:
displayName:
type: string
locationId:
type: string
format: uuid
nullable: true
description: Omit to keep the current assignment; empty string clears it
active:
type: boolean
BlockedDay:
type: object
required: [id, date, type]
@@ -1564,6 +2095,80 @@ components:
example: "18:00"
isOpen:
type: boolean
ClassTemplate:
type: object
required: [id, tenantId, title, durationMinutes, capacity, createdAt]
properties:
id:
type: string
format: uuid
tenantId:
type: string
format: uuid
title:
type: string
durationMinutes:
type: integer
capacity:
type: integer
createdAt:
type: string
format: date-time
UpsertClassTemplateRequest:
type: object
required: [title, durationMinutes, capacity]
properties:
title:
type: string
durationMinutes:
type: integer
capacity:
type: integer
ClassSession:
type: object
required: [id, tenantId, templateId, templateTitle, startsAt, endsAt, capacity, bookedCount]
properties:
id:
type: string
format: uuid
tenantId:
type: string
format: uuid
templateId:
type: string
format: uuid
templateTitle:
type: string
locationId:
type: string
format: uuid
locationName:
type: string
startsAt:
type: string
format: date-time
endsAt:
type: string
format: date-time
capacity:
type: integer
bookedCount:
type: integer
CreateClassSessionRequest:
type: object
required: [templateId, startsAt]
properties:
templateId:
type: string
format: uuid
locationId:
type: string
format: uuid
startsAt:
type: string
format: date-time
capacity:
type: integer
PlanEntitlements:
type: object
required: [maxLocations, maxStaff, emailReminders, advancedReporting, widgetEmbedding, umamiTracking]
@@ -1711,7 +2316,7 @@ components:
maximum: 200
DispatchReminderJobsResponse:
type: object
required: [processedCount, sentCount, failedCount]
required: [processedCount, sentCount, failedCount, skippedCount]
properties:
processedCount:
type: integer
@@ -1719,3 +2324,5 @@ components:
type: integer
failedCount:
type: integer
skippedCount:
type: integer
@@ -114,6 +114,18 @@ export const PlusIcon = () => (
</svg>
);
export const PencilIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5"/>
</svg>
);
export const ScissorsIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="6" cy="6" r="3"/><path d="M8.12 8.12 12 12"/><path d="M20 4 8.12 15.88"/><circle cx="6" cy="18" r="3"/><path d="M14.8 14.8 20 20"/>
</svg>
);
export const UsersIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
+4
View File
@@ -16,6 +16,8 @@ const DashboardRoute = lazy(() => import("./routes/dashboard/overview-page"));
const DashboardBookingsRoute = lazy(() => import("./routes/dashboard/bookings-page"));
const DashboardCustomersRoute = lazy(() => import("./routes/dashboard/customers-page"));
const DashboardZonesRoute = lazy(() => import("./routes/dashboard/zones-page"));
const DashboardServicesRoute = lazy(() => import("./routes/dashboard/services-page"));
const DashboardStaffRoute = lazy(() => import("./routes/dashboard/staff-page"));
const DashboardBillingRoute = lazy(() => import("./routes/dashboard/billing-page"));
const DashboardSettingsRoute = lazy(() => import("./routes/dashboard/settings-page"));
const PublicBookingRoute = lazy(() => import("./routes/public-booking-route").then((module) => ({ default: module.PublicBookingRoute })));
@@ -35,6 +37,8 @@ render(
<Route path="/dashboard/bookings" component={DashboardBookingsRoute} />
<Route path="/dashboard/customers" component={DashboardCustomersRoute} />
<Route path="/dashboard/zones" component={DashboardZonesRoute} />
<Route path="/dashboard/services" component={DashboardServicesRoute} />
<Route path="/dashboard/staff" component={DashboardStaffRoute} />
<Route path="/dashboard/billing" component={DashboardBillingRoute} />
<Route path="/dashboard/settings" component={DashboardSettingsRoute} />
<Route path="/book/:tenantSlug" component={PublicBookingRoute} />
@@ -295,6 +295,9 @@ const dictionaries = {
"dashboard.kpi.bookings": "Celkem rezervací",
"dashboard.kpi.cancelled": "Zrušených",
"dashboard.kpi.completed": "Dokončených",
"dashboard.kpi.bookingsThisWeek": "Rezervace tento týden",
"dashboard.kpi.cancellations": "Zrušení",
"dashboard.kpi.utilization": "Vytížení",
"dashboard.kpi.newClients": "Noví klienti",
"dashboard.recentActivity": "Nedávná aktivita",
"dashboard.upcomingBookings": "Nadcházející rezervace",
@@ -309,6 +312,8 @@ const dictionaries = {
"dashboard.totalBookings": "celkem rezervací",
"dashboard.newBooking": "Nová rezervace",
"dashboard.bookingDetails": "Detail rezervace",
"dashboard.booking.cancelBooking": "Zrušit rezervaci",
"dashboard.booking.confirmCancel": "Potvrdit zrušení?",
"dashboard.customerDetails": "Detail zákazníka",
"dashboard.close": "Zavřít",
"dashboard.edit": "Upravit",
@@ -324,6 +329,7 @@ const dictionaries = {
"dashboard.nextMonth": "Další měsíc",
"dashboard.confirmed": "Potvrzeno",
"dashboard.pending": "Čeká",
"dashboard.waitlisted": "Čekací listina",
"dashboard.cancelled": "Zrušeno",
"dashboard.completed": "Dokončeno",
"dashboard.welcome.title": "Vítejte v Bookra",
@@ -398,6 +404,59 @@ const dictionaries = {
"dashboard.zone.addBlocked": "Přidat blokovaný den",
"dashboard.zone.reason": "Důvod",
"dashboard.zone.noZones": "Zatím nemáte žádné zóny. Přidejte první.",
"dashboard.zone.staff": "Zaměstnanec",
"dashboard.zone.allStaff": "Všichni zaměstnanci",
"dashboard.zone.businessHours": "Celá firma",
"dashboard.classes": "Lekce",
"dashboard.class.templates": "Šablony lekcí",
"dashboard.class.sessions": "Naplánované lekce",
"dashboard.class.addTemplate": "Nová šablona",
"dashboard.class.saveChanges": "Uložit změny",
"dashboard.class.addSession": "Naplánovat lekci",
"dashboard.class.title": "Název",
"dashboard.class.titlePlaceholder": "např. Jóga pro začátečníky",
"dashboard.class.durationMin": "Délka (min)",
"dashboard.class.template": "Šablona",
"dashboard.class.location": "Místo",
"dashboard.class.noLocation": "Bez místa",
"dashboard.class.startsAt": "Začátek",
"dashboard.class.booked": "obsazeno",
"dashboard.class.noTemplates": "Zatím žádné šablony lekcí.",
"dashboard.class.noSessions": "Zatím žádné naplánované lekce.",
"dashboard.class.sessionHint": "Vytvořte šablonu a poté z ní naplánujte konkrétní lekce.",
"dashboard.services": "Služby",
"dashboard.services.subtitle": "služeb v nabídce",
"dashboard.service.add": "Přidat službu",
"dashboard.service.edit": "Upravit službu",
"dashboard.service.name": "Název služby",
"dashboard.service.namePlaceholder": "např. Dámský střih",
"dashboard.service.duration": "Délka (min)",
"dashboard.service.bufferBefore": "Přestávka před",
"dashboard.service.bufferAfter": "Přestávka po",
"dashboard.service.price": "Cena (Kč)",
"dashboard.service.free": "Zdarma",
"dashboard.service.min": "min",
"dashboard.service.noServices": "Zatím žádné služby.",
"dashboard.service.noServicesDesc": "Přidejte první službu, kterou si klienti mohou rezervovat.",
"dashboard.service.saveFailed": "Uložení služby selhalo.",
"dashboard.service.deleteFailed": "Smazání služby selhalo.",
"dashboard.staff": "Zaměstnanci",
"dashboard.staff.subtitle": "členů týmu",
"dashboard.staff.add": "Přidat zaměstnance",
"dashboard.staff.edit": "Upravit zaměstnance",
"dashboard.staff.name": "Jméno",
"dashboard.staff.namePlaceholder": "např. Anna Nováková",
"dashboard.staff.location": "Lokace",
"dashboard.staff.noLocation": "Bez lokace",
"dashboard.staff.active": "Aktivní",
"dashboard.staff.inactive": "Neaktivní",
"dashboard.staff.noStaff": "Zatím žádní zaměstnanci.",
"dashboard.staff.noStaffDesc": "Přidejte členy týmu, kteří poskytují služby.",
"dashboard.staff.limitReached": "Dosažen limit zaměstnanců",
"dashboard.staff.limitDesc": "Váš plán omezuje počet zaměstnanců. Pro více míst upgradujte.",
"dashboard.staff.saveFailed": "Uložení zaměstnance selhalo.",
"dashboard.staff.deleteFailed": "Smazání zaměstnance selhalo.",
"dashboard.bookingModal.selectService": "Vyberte službu",
"dashboard.billing.planUsage": "Využití plánu",
"dashboard.billing.locations": "Lokace",
"dashboard.billing.bookings": "Rezervace",
@@ -440,11 +499,21 @@ 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",
"booking.submitting": "Rezervuji...",
"booking.submit": "Rezervovat",
"booking.joinWaitlist": "Na čekací listinu",
"booking.full": "Obsazeno",
"booking.business": "Podnik",
"booking.sidebar.body": "Rezervace proběhne online. Potvrzení a připomenutí přijdou e-mailem.",
"booking.customer.title": "Kontaktní údaje",
@@ -804,6 +873,9 @@ const dictionaries = {
"dashboard.kpi.bookings": "Total Bookings",
"dashboard.kpi.cancelled": "Cancelled",
"dashboard.kpi.completed": "Completed",
"dashboard.kpi.bookingsThisWeek": "Bookings this week",
"dashboard.kpi.cancellations": "Cancellations",
"dashboard.kpi.utilization": "Utilization",
"dashboard.kpi.newClients": "New Clients",
"dashboard.recentActivity": "Recent Activity",
"dashboard.upcomingBookings": "Upcoming Bookings",
@@ -818,6 +890,8 @@ const dictionaries = {
"dashboard.totalBookings": "total bookings",
"dashboard.newBooking": "New Booking",
"dashboard.bookingDetails": "Booking Details",
"dashboard.booking.cancelBooking": "Cancel booking",
"dashboard.booking.confirmCancel": "Confirm cancellation?",
"dashboard.customerDetails": "Customer Details",
"dashboard.close": "Close",
"dashboard.edit": "Edit",
@@ -833,6 +907,7 @@ const dictionaries = {
"dashboard.nextMonth": "Next month",
"dashboard.confirmed": "Confirmed",
"dashboard.pending": "Pending",
"dashboard.waitlisted": "Waitlisted",
"dashboard.cancelled": "Cancelled",
"dashboard.completed": "Completed",
"dashboard.welcome.title": "Welcome to Bookra",
@@ -907,6 +982,59 @@ const dictionaries = {
"dashboard.zone.addBlocked": "Add blocked day",
"dashboard.zone.reason": "Reason",
"dashboard.zone.noZones": "No zones yet. Add your first one.",
"dashboard.zone.staff": "Staff member",
"dashboard.zone.allStaff": "All staff",
"dashboard.zone.businessHours": "Whole business",
"dashboard.classes": "Classes",
"dashboard.class.templates": "Class templates",
"dashboard.class.sessions": "Scheduled sessions",
"dashboard.class.addTemplate": "New template",
"dashboard.class.saveChanges": "Save changes",
"dashboard.class.addSession": "Schedule session",
"dashboard.class.title": "Title",
"dashboard.class.titlePlaceholder": "e.g. Beginner yoga",
"dashboard.class.durationMin": "Duration (min)",
"dashboard.class.template": "Template",
"dashboard.class.location": "Location",
"dashboard.class.noLocation": "No location",
"dashboard.class.startsAt": "Starts at",
"dashboard.class.booked": "booked",
"dashboard.class.noTemplates": "No class templates yet.",
"dashboard.class.noSessions": "No scheduled sessions yet.",
"dashboard.class.sessionHint": "Create a template, then schedule individual sessions from it.",
"dashboard.services": "Services",
"dashboard.services.subtitle": "services offered",
"dashboard.service.add": "Add service",
"dashboard.service.edit": "Edit service",
"dashboard.service.name": "Service name",
"dashboard.service.namePlaceholder": "e.g. Women's haircut",
"dashboard.service.duration": "Duration (min)",
"dashboard.service.bufferBefore": "Buffer before",
"dashboard.service.bufferAfter": "Buffer after",
"dashboard.service.price": "Price (CZK)",
"dashboard.service.free": "Free",
"dashboard.service.min": "min",
"dashboard.service.noServices": "No services yet.",
"dashboard.service.noServicesDesc": "Add the first service clients can book.",
"dashboard.service.saveFailed": "Failed to save service.",
"dashboard.service.deleteFailed": "Failed to delete service.",
"dashboard.staff": "Staff",
"dashboard.staff.subtitle": "team members",
"dashboard.staff.add": "Add staff member",
"dashboard.staff.edit": "Edit staff member",
"dashboard.staff.name": "Name",
"dashboard.staff.namePlaceholder": "e.g. Anna Novak",
"dashboard.staff.location": "Location",
"dashboard.staff.noLocation": "No location",
"dashboard.staff.active": "Active",
"dashboard.staff.inactive": "Inactive",
"dashboard.staff.noStaff": "No staff yet.",
"dashboard.staff.noStaffDesc": "Add team members who provide services.",
"dashboard.staff.limitReached": "Staff limit reached",
"dashboard.staff.limitDesc": "Your plan limits the number of staff. Upgrade for more seats.",
"dashboard.staff.saveFailed": "Failed to save staff member.",
"dashboard.staff.deleteFailed": "Failed to delete staff member.",
"dashboard.bookingModal.selectService": "Select service",
"dashboard.billing.planUsage": "Plan usage",
"dashboard.billing.locations": "Locations",
"dashboard.billing.bookings": "Bookings",
@@ -949,11 +1077,21 @@ 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",
"booking.submitting": "Booking...",
"booking.submit": "Book now",
"booking.joinWaitlist": "Join waitlist",
"booking.full": "Full",
"booking.business": "Business",
"booking.sidebar.body": "Book online. Confirmation and reminders are sent by email.",
"booking.customer.title": "Contact details",
@@ -195,18 +195,48 @@ export function BookingManageRoute() {
<CardDescription class="mt-1">{b().businessName}</CardDescription>
</div>
<span class={`inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold ${
b().status === 'confirmed'
? 'bg-[hsl(var(--success-soft))] text-[hsl(var(--success))]'
: 'bg-[hsl(var(--warning-soft))] text-[hsl(var(--warning))]'
b().status === 'confirmed'
? 'bg-[hsl(var(--success-soft))] text-[hsl(var(--success))]'
: b().status === 'cancelled'
? 'bg-[hsl(var(--error-subtle))] text-[hsl(var(--error))]'
: 'bg-[hsl(var(--warning-subtle))] text-[hsl(var(--warning))]'
}`}>
{b().status === 'confirmed'
{b().status === 'confirmed'
? (i18n.locale() === 'cs' ? 'Potvrzeno' : 'Confirmed')
: b().status === 'waitlisted'
? (i18n.locale() === 'cs' ? 'Čekací listina' : 'Waitlisted')
: b().status === 'cancelled'
? (i18n.locale() === 'cs' ? 'Zrušeno' : 'Cancelled')
: (i18n.locale() === 'cs' ? 'Čeká se' : 'Pending')
}
</span>
</div>
</CardHeader>
<CardContent class="space-y-6">
{/* Waitlist position */}
<Show when={b().status === 'waitlisted'}>
<div class="flex items-center gap-3 p-4 bg-[hsl(var(--warning-subtle))] rounded-xl border border-[hsl(var(--warning))]/20">
<div class="w-10 h-10 rounded-lg bg-[hsl(var(--warning))]/10 flex items-center justify-center text-[hsl(var(--warning))]">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</div>
<div>
<p class="font-medium text-ink">
{b().waitlistPosition > 0
? (i18n.locale() === 'cs' ? `Jste ${b().waitlistPosition}. v pořadí` : `You are #${b().waitlistPosition} in the queue`)
: (i18n.locale() === 'cs' ? 'Jste na čekací listině' : 'You are on the waitlist')}
</p>
<p class="text-sm text-ink-muted">
{i18n.locale() === 'cs'
? 'Po uvolnění místa vám rezervaci automaticky potvrdíme e-mailem.'
: 'When a seat frees up we will confirm your booking by email automatically.'}
</p>
</div>
</div>
</Show>
{/* Reference */}
<div class="flex items-center gap-3 p-3 bg-canvas-subtle rounded-xl">
<div class="w-10 h-10 rounded-lg bg-[hsl(var(--info-subtle))] flex items-center justify-center text-[hsl(var(--info))]">
@@ -1,4 +1,4 @@
import { Show, createSignal, createMemo } from "solid-js";
import { Show, createSignal, createMemo, createResource } from "solid-js";
import { Input } from "../../components/ui/input";
import { Select } from "../../components/ui/select";
import { Textarea } from "../../components/ui/textarea";
@@ -9,7 +9,7 @@ import { DashboardLayout, useDashboardData } from "./layout";
function BookingsPage() {
const data = useDashboardData();
const [filterStatus, setFilterStatus] = createSignal<"all" | "confirmed" | "pending" | "cancelled" | "completed">("all");
const [filterStatus, setFilterStatus] = createSignal<"all" | "confirmed" | "pending" | "waitlisted" | "cancelled" | "completed">("all");
const [filterDateRange, setFilterDateRange] = createSignal<"all" | "today" | "week" | "month">("all");
const [searchQuery, setSearchQuery] = createSignal("");
const [showNewBooking, setShowNewBooking] = createSignal(false);
@@ -25,6 +25,28 @@ function BookingsPage() {
const resolvedAllBookings = () => data.resolvedSummary()?.allBookings ?? data.normalizedAllBookings() ?? [];
const [serviceOptions] = createResource(
() => [data.token(), showNewBooking()] as const,
async ([bearer, open]) => {
if (!bearer || !open) return [];
if (bearer.startsWith("demo.")) {
const names = data.i18n.locale() === "cs"
? ["Masáž", "Kosmetika", "Fyzioterapie", "Manikúra"]
: ["Massage", "Cosmetics", "Physiotherapy", "Manicure"];
return names.map((name) => ({ value: name, label: name }));
}
try {
const response = await apiClient.GET("/v1/catalog/services", { headers: { Authorization: `Bearer ${bearer}` } });
return ((response.data as any[]) ?? []).map((s) => ({
value: s.name,
label: `${s.name} · ${s.durationMinutes} min`,
}));
} catch {
return [];
}
},
);
const filteredBookings = createMemo(() => {
let bookings = resolvedAllBookings();
if (filterStatus() !== "all") bookings = bookings.filter((b: any) => b.status === filterStatus());
@@ -59,6 +81,7 @@ function BookingsPage() {
total: bookings.length,
confirmed: bookings.filter((b: any) => b.status === "confirmed").length,
pending: bookings.filter((b: any) => b.status === "pending").length,
waitlisted: bookings.filter((b: any) => b.status === "waitlisted").length,
cancelled: bookings.filter((b: any) => b.status === "cancelled").length,
completed: bookings.filter((b: any) => b.status === "completed").length,
};
@@ -89,6 +112,7 @@ function BookingsPage() {
const statusLabels: Record<string, string> = {
confirmed: data.i18n.t("dashboard.confirmed"), pending: data.i18n.t("dashboard.pending"),
waitlisted: data.i18n.t("dashboard.waitlisted"),
cancelled: data.i18n.t("dashboard.cancelled"), completed: data.i18n.t("dashboard.completed")
};
@@ -108,7 +132,7 @@ function BookingsPage() {
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ key: "confirmed", label: data.i18n.t("dashboard.confirmed"), value: bookingStats().confirmed },
{ key: "pending", label: data.i18n.t("dashboard.pending"), value: bookingStats().pending },
{ key: "waitlisted", label: data.i18n.t("dashboard.waitlisted"), value: bookingStats().waitlisted },
{ key: "cancelled", label: data.i18n.t("dashboard.cancelled"), value: bookingStats().cancelled },
{ key: "completed", label: data.i18n.t("dashboard.completed"), value: bookingStats().completed },
].map((s) => (
@@ -129,6 +153,7 @@ function BookingsPage() {
{ value: "all", label: data.i18n.t("dashboard.filter.all") },
{ value: "confirmed", label: data.i18n.t("dashboard.confirmed") },
{ value: "pending", label: data.i18n.t("dashboard.pending") },
{ value: "waitlisted", label: data.i18n.t("dashboard.waitlisted") },
{ value: "cancelled", label: data.i18n.t("dashboard.cancelled") },
{ value: "completed", label: data.i18n.t("dashboard.completed") },
]} />
@@ -196,6 +221,7 @@ function BookingsPage() {
</button>
<span class={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${
booking.status === "confirmed" ? "bg-accent/10 text-accent"
: booking.status === "waitlisted" ? "bg-warning-subtle text-warning"
: booking.status === "pending" ? "bg-canvas-muted text-ink-muted"
: "bg-canvas-subtle text-ink-subtle"
}`}>
@@ -224,7 +250,16 @@ function BookingsPage() {
<div class="p-6 space-y-4">
<Input type="text" label={data.i18n.t("booking.customer.name")} value={newBookingCustomer()} onInput={(e) => setNewBookingCustomer(e.currentTarget.value)} />
<Input type="email" label={data.i18n.t("booking.customer.email")} value={newBookingEmail()} onInput={(e) => setNewBookingEmail(e.currentTarget.value)} />
<Input type="text" label={data.i18n.t("dashboard.bookingModal.service")} value={newBookingService()} onInput={(e) => setNewBookingService(e.currentTarget.value)} />
<Show when={(serviceOptions() ?? []).length > 0} fallback={
<Input type="text" label={data.i18n.t("dashboard.bookingModal.service")} value={newBookingService()} onInput={(e) => setNewBookingService(e.currentTarget.value)} />
}>
<Select
label={data.i18n.t("dashboard.bookingModal.service")}
value={newBookingService()}
onChange={(v) => setNewBookingService(v)}
options={[{ value: "", label: data.i18n.t("dashboard.bookingModal.selectService") }, ...(serviceOptions() ?? [])]}
/>
</Show>
<div class="grid grid-cols-2 gap-4">
<Input type="date" label={data.i18n.t("dashboard.bookingModal.date")} value={newBookingDate()} onInput={(e) => setNewBookingDate(e.currentTarget.value)} />
<Input type="time" label={data.i18n.t("dashboard.bookingModal.time")} value={newBookingTime()} onInput={(e) => setNewBookingTime(e.currentTarget.value)} />
@@ -4,7 +4,7 @@ import { useAuth } from "../../providers/auth-provider";
import { useI18n } from "../../providers/i18n-provider";
import { getInitials, getBookingDuration } from "../../components/dashboard/types";
export type Section = "overview" | "bookings" | "customers" | "zones" | "billing" | "settings";
export type Section = "overview" | "bookings" | "customers" | "services" | "staff" | "zones" | "billing" | "settings";
function createDemoData(i18n: ReturnType<typeof useI18n>) {
const cs = i18n.locale() === "cs";
@@ -48,6 +48,7 @@ function createDemoData(i18n: ReturnType<typeof useI18n>) {
{ id: "b6", customerName: customers[0].name, customerEmail: customers[0].email, service: services[1], status: "confirmed", startsAt: fmt(at(3, 10)), endsAt: fmt(at(3, 11)), reference: "BOK-006", location: salonA, staffName: staff1, notes: "" },
{ id: "b8", customerName: customers[2].name, customerEmail: customers[2].email, service: services[3], status: "pending", startsAt: fmt(at(4, 9)), endsAt: fmt(at(4, 10)), reference: "BOK-008", location: salonA, staffName: staff2, notes: "" },
{ id: "b9", customerName: customers[3].name, customerEmail: customers[3].email, service: services[0], status: "confirmed", startsAt: fmt(at(5, 11)), endsAt: fmt(at(5, 12)), reference: "BOK-009", location: salonA, staffName: staff1, notes: "" },
{ id: "b13", customerName: customers[0].name, customerEmail: customers[0].email, service: cs ? "Jóga pro začátečníky" : "Beginner yoga", status: "waitlisted", startsAt: fmt(at(3, 18)), endsAt: fmt(at(3, 19)), reference: "BOK-013", location: salonA, staffName: "", notes: "" },
];
return {
@@ -210,8 +211,18 @@ export function DashboardDataProvider(props: { children: any }) {
};
};
const kpiLabelKeys: Record<string, string> = {
bookings_this_week: "dashboard.kpi.bookingsThisWeek",
completed: "dashboard.kpi.completed",
cancellations: "dashboard.kpi.cancellations",
utilization: "dashboard.kpi.utilization",
new_clients: "dashboard.kpi.newClients",
};
const normalizedKpis = createMemo(() => (resolvedSummary()?.kpis ?? []).map((kpi: any) => ({
...kpi, trend: kpi.trend ?? "neutral", change: kpi.change ?? "—",
...kpi,
label: kpi.code && kpiLabelKeys[kpi.code] ? i18n.t(kpiLabelKeys[kpi.code]) : kpi.label,
trend: kpi.trend ?? "neutral",
change: kpi.change ?? "—",
})));
const normalizedUpcomingBookings = createMemo(() => (resolvedSummary()?.upcomingBookings ?? []).map((booking: any) => ({
+49 -1
View File
@@ -8,9 +8,10 @@ import { getInitials, getBookingDuration } from "../../components/dashboard/type
import {
LayoutDashboardIcon, CalendarDaysIcon, CreditCardIcon, Settings2Icon,
LogOutIcon, MenuIcon, XIcon, SparklesIcon, GlobeIcon, SunIcon, MoonIcon,
UserCircleIcon, MapPinIcon
UserCircleIcon, MapPinIcon, ScissorsIcon, UsersIcon
} from "../../components/dashboard/icons";
import { DashboardContext, useDashboardData, DashboardDataProvider } from "./dashboard-data";
import { apiClient } from "../../lib/api-client";
export { useDashboardData };
@@ -18,6 +19,8 @@ const navItems = [
{ id: "overview", label: "dashboard.overview", icon: LayoutDashboardIcon, path: "/dashboard" },
{ id: "bookings", label: "dashboard.bookings", icon: CalendarDaysIcon, path: "/dashboard/bookings" },
{ id: "customers", label: "dashboard.customers", icon: UserCircleIcon, path: "/dashboard/customers" },
{ id: "services", label: "dashboard.services", icon: ScissorsIcon, path: "/dashboard/services" },
{ id: "staff", label: "dashboard.staff", icon: UsersIcon, path: "/dashboard/staff" },
{ id: "zones", label: "dashboard.zones", icon: MapPinIcon, path: "/dashboard/zones" },
{ id: "billing", label: "dashboard.billing", icon: CreditCardIcon, path: "/dashboard/billing" },
{ id: "settings", label: "dashboard.settings", icon: Settings2Icon, path: "/dashboard/settings" },
@@ -28,6 +31,31 @@ function DashboardShell(props: { children: JSX.Element }) {
const theme = useTheme();
const location = useLocation();
const [isMobileMenuOpen, setIsMobileMenuOpen] = createSignal(false);
const [cancelConfirm, setCancelConfirm] = createSignal<"idle" | "armed" | "busy">("idle");
const handleCancelBooking = async () => {
if (cancelConfirm() === "busy") return;
if (cancelConfirm() === "idle") { setCancelConfirm("armed"); return; }
const booking = data.selectedBooking();
const bearer = data.token();
if (!booking) return;
if (!bearer || bearer.startsWith("demo.")) {
data.setDemoNotice(data.i18n.locale() === "cs" ? "V demo režimu nelze rušit rezervace." : "Cannot cancel bookings in demo mode.");
setCancelConfirm("idle");
return;
}
setCancelConfirm("busy");
try {
const response = await (apiClient as any).POST(`/v1/catalog/bookings/${booking.id}/cancel`, { headers: { Authorization: `Bearer ${bearer}` } });
if (response.error) throw new Error("cancel failed");
data.setShowBookingDetail(false);
void data.refetchSummary();
} catch {
data.setDemoNotice(data.i18n.locale() === "cs" ? "Zrušení rezervace selhalo." : "Failed to cancel booking.");
} finally {
setCancelConfirm("idle");
}
};
const activeSection = () => {
const p = location.pathname;
@@ -240,6 +268,7 @@ function DashboardShell(props: { children: JSX.Element }) {
</div>
<span class={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
data.selectedBooking()?.status === "confirmed" ? "bg-accent/10 text-accent"
: data.selectedBooking()?.status === "waitlisted" ? "bg-warning-subtle text-warning"
: data.selectedBooking()?.status === "pending" ? "bg-canvas-muted text-ink-muted"
: "bg-canvas-subtle text-ink-subtle"
}`}>
@@ -253,6 +282,25 @@ function DashboardShell(props: { children: JSX.Element }) {
</div>
</Show>
</div>
<Show when={data.selectedBooking()?.status !== "cancelled"}>
<div class="p-6 border-t border-border">
<button
onClick={() => void handleCancelBooking()}
disabled={cancelConfirm() === "busy"}
class={`w-full px-4 py-2.5 rounded-xl text-sm font-medium transition-colors disabled:opacity-60 ${
cancelConfirm() === "armed"
? "bg-red-600 text-canvas hover:bg-red-700"
: "border border-border text-red-600 hover:bg-red-50"
}`}
>
{cancelConfirm() === "busy"
? "…"
: cancelConfirm() === "armed"
? data.i18n.t("dashboard.booking.confirmCancel")
: data.i18n.t("dashboard.booking.cancelBooking")}
</button>
</div>
</Show>
</div>
</div>
</Show>
@@ -37,7 +37,7 @@ function OverviewPage() {
</div>
{/* KPI Cards */}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
{data.normalizedKpis().map((kpi: any, index: number) => (
<KpiCard kpi={kpi} index={index} />
))}
@@ -0,0 +1,204 @@
import { For, Show, createResource, createSignal } from "solid-js";
import { apiClient } from "../../lib/api-client";
import { Input } from "../../components/ui/input";
import { PlusIcon, ScissorsIcon, Trash2Icon, EditIcon, ClockIcon } from "../../components/dashboard/icons";
import { DashboardLayout, useDashboardData } from "./layout";
interface ServiceForm {
name: string;
durationMinutes: string;
bufferBeforeMinutes: string;
bufferAfterMinutes: string;
price: string;
}
const emptyForm: ServiceForm = { name: "", durationMinutes: "60", bufferBeforeMinutes: "0", bufferAfterMinutes: "0", price: "0" };
function ServicesPage() {
const data = useDashboardData();
const cs = () => data.i18n.locale() === "cs";
const [showForm, setShowForm] = createSignal(false);
const [editingId, setEditingId] = createSignal<string | null>(null);
const [form, setForm] = createSignal<ServiceForm>({ ...emptyForm });
const [saving, setSaving] = createSignal(false);
const authHeaders = () => ({ headers: { Authorization: `Bearer ${data.token()}` } });
const setField = (patch: Partial<ServiceForm>) => setForm({ ...form(), ...patch });
const [services, { refetch }] = createResource(
() => data.token(),
async (bearer) => {
if (!bearer) return [];
if (bearer.startsWith("demo.")) {
return [
{ id: "sv1", name: cs() ? "Dámský střih" : "Women's haircut", durationMinutes: 60, bufferBeforeMinutes: 0, bufferAfterMinutes: 15, priceCents: 89000 },
{ id: "sv2", name: cs() ? "Pánský střih" : "Men's haircut", durationMinutes: 45, bufferBeforeMinutes: 0, bufferAfterMinutes: 10, priceCents: 59000 },
{ id: "sv3", name: cs() ? "Barvení" : "Coloring", durationMinutes: 120, bufferBeforeMinutes: 10, bufferAfterMinutes: 15, priceCents: 199000 },
{ id: "sv4", name: cs() ? "Konzultace" : "Consultation", durationMinutes: 30, bufferBeforeMinutes: 0, bufferAfterMinutes: 0, priceCents: 0 },
];
}
const response = await apiClient.GET("/v1/catalog/services", authHeaders());
return (response.data as any[]) ?? [];
},
);
const resolvedServices = () => services() ?? [];
const formatPrice = (priceCents: number) => {
if (!priceCents) return data.i18n.t("dashboard.service.free");
return new Intl.NumberFormat(cs() ? "cs-CZ" : "en-US", { style: "currency", currency: "CZK", maximumFractionDigits: 0 }).format(priceCents / 100);
};
const openCreate = () => {
setEditingId(null);
setForm({ ...emptyForm });
setShowForm(true);
};
const openEdit = (service: any) => {
setEditingId(service.id);
setForm({
name: service.name,
durationMinutes: String(service.durationMinutes ?? 60),
bufferBeforeMinutes: String(service.bufferBeforeMinutes ?? 0),
bufferAfterMinutes: String(service.bufferAfterMinutes ?? 0),
price: String((service.priceCents ?? 0) / 100),
});
setShowForm(true);
};
const handleSave = async () => {
const bearer = data.token();
const name = form().name.trim();
const duration = parseInt(form().durationMinutes, 10);
if (!name || !duration || duration <= 0) return;
if (!bearer || bearer.startsWith("demo.")) {
data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode.");
return;
}
setSaving(true);
try {
const body = {
name,
durationMinutes: duration,
bufferBeforeMinutes: Math.max(0, parseInt(form().bufferBeforeMinutes, 10) || 0),
bufferAfterMinutes: Math.max(0, parseInt(form().bufferAfterMinutes, 10) || 0),
priceCents: Math.max(0, Math.round((parseFloat(form().price) || 0) * 100)),
};
const id = editingId();
const response = id
? await apiClient.PUT("/v1/catalog/services/{serviceID}", { ...authHeaders(), params: { path: { serviceID: id } }, body })
: await apiClient.POST("/v1/catalog/services", { ...authHeaders(), body });
if (response.error) throw new Error("save failed");
setShowForm(false);
setEditingId(null);
setForm({ ...emptyForm });
void refetch();
} catch {
data.setDemoNotice(data.i18n.t("dashboard.service.saveFailed"));
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) {
data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode.");
return;
}
try {
await apiClient.DELETE("/v1/catalog/services/{serviceID}", { ...authHeaders(), params: { path: { serviceID: id } } });
void refetch();
} catch {
data.setDemoNotice(data.i18n.t("dashboard.service.deleteFailed"));
}
};
return (
<div class="space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.services")}</h1>
<p class="text-ink-muted mt-1">{resolvedServices().length} {data.i18n.t("dashboard.services.subtitle")}</p>
</div>
<button onClick={openCreate} class="btn-primary text-sm shrink-0"><PlusIcon /> {data.i18n.t("dashboard.service.add")}</button>
</div>
<div class="surface-card overflow-hidden">
<Show when={showForm()}>
<div class="p-6 border-b border-border bg-canvas-subtle/50">
<h4 class="font-medium text-ink mb-4">{editingId() ? data.i18n.t("dashboard.service.edit") : data.i18n.t("dashboard.service.add")}</h4>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-6 gap-4">
<div class="sm:col-span-2">
<Input type="text" label={data.i18n.t("dashboard.service.name")} value={form().name} onInput={(e) => setField({ name: e.currentTarget.value })} placeholder={data.i18n.t("dashboard.service.namePlaceholder")} />
</div>
<Input type="number" min="1" label={data.i18n.t("dashboard.service.duration")} value={form().durationMinutes} onInput={(e) => setField({ durationMinutes: e.currentTarget.value })} />
<Input type="number" min="0" label={data.i18n.t("dashboard.service.price")} value={form().price} onInput={(e) => setField({ price: e.currentTarget.value })} />
<Input type="number" min="0" label={data.i18n.t("dashboard.service.bufferBefore")} value={form().bufferBeforeMinutes} onInput={(e) => setField({ bufferBeforeMinutes: e.currentTarget.value })} />
<Input type="number" min="0" label={data.i18n.t("dashboard.service.bufferAfter")} value={form().bufferAfterMinutes} onInput={(e) => setField({ bufferAfterMinutes: e.currentTarget.value })} />
</div>
<div class="flex gap-3 mt-4">
<button onClick={() => void handleSave()} disabled={saving() || !form().name.trim()} class="btn-primary text-sm disabled:opacity-60">
{saving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.settings.save")}
</button>
<button onClick={() => { setShowForm(false); setEditingId(null); }} class="btn-secondary text-sm">{data.i18n.t("common.cancel")}</button>
</div>
</div>
</Show>
<Show when={!services.loading} fallback={
<div class="p-12 text-center text-ink-muted text-sm animate-pulse">{cs() ? "Načítání…" : "Loading…"}</div>
}>
<Show when={resolvedServices().length > 0} fallback={
<div class="p-12 text-center">
<div class="w-16 h-16 rounded-full bg-canvas-subtle flex items-center justify-center mx-auto mb-4 text-ink-subtle"><ScissorsIcon /></div>
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.service.noServices")}</p>
<p class="text-sm text-ink-subtle mt-1">{data.i18n.t("dashboard.service.noServicesDesc")}</p>
</div>
}>
<div class="divide-y divide-border/60">
<For each={resolvedServices()}>
{(service: any) => (
<div class="flex items-center justify-between p-5 px-6 hover:bg-canvas-subtle/30 transition-colors">
<div class="flex items-center gap-4 min-w-0">
<div class="w-12 h-12 rounded-xl bg-accent-subtle/50 flex items-center justify-center text-accent shrink-0"><ScissorsIcon /></div>
<div class="min-w-0">
<h4 class="font-medium text-ink truncate">{service.name}</h4>
<p class="text-sm text-ink-muted flex items-center gap-1.5">
<ClockIcon /> {service.durationMinutes} {data.i18n.t("dashboard.service.min")}
<Show when={service.bufferBeforeMinutes > 0 || service.bufferAfterMinutes > 0}>
<span class="text-ink-subtle">
&bull; +{service.bufferBeforeMinutes ?? 0}/{service.bufferAfterMinutes ?? 0} {data.i18n.t("dashboard.service.min")} {cs() ? "rezerva" : "buffer"}
</span>
</Show>
</p>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="hidden sm:inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-accent/10 text-accent">{formatPrice(service.priceCents)}</span>
<button onClick={() => openEdit(service)} class="p-2 text-ink-subtle hover:text-accent hover:bg-accent-subtle rounded-lg transition-colors" aria-label={data.i18n.t("dashboard.edit")}>
<EditIcon />
</button>
<button onClick={() => void handleDelete(service.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" aria-label={cs() ? "Smazat" : "Delete"}>
<Trash2Icon />
</button>
</div>
</div>
)}
</For>
</div>
</Show>
</Show>
</div>
</div>
);
}
export default function ServicesRoute() {
return (
<DashboardLayout>
<ServicesPage />
</DashboardLayout>
);
}
@@ -0,0 +1,264 @@
import { For, Show, createResource, createSignal } from "solid-js";
import { apiClient } from "../../lib/api-client";
import { Input } from "../../components/ui/input";
import { Select } from "../../components/ui/select";
import { PlusIcon, UsersIcon, Trash2Icon, EditIcon, SparklesIcon, MapPinIcon } from "../../components/dashboard/icons";
import { DashboardLayout, useDashboardData } from "./layout";
interface StaffForm {
displayName: string;
locationId: string;
}
const emptyForm: StaffForm = { displayName: "", locationId: "" };
function StaffPage() {
const data = useDashboardData();
const cs = () => data.i18n.locale() === "cs";
const [showForm, setShowForm] = createSignal(false);
const [editingId, setEditingId] = createSignal<string | null>(null);
const [form, setForm] = createSignal<StaffForm>({ ...emptyForm });
const [saving, setSaving] = createSignal(false);
const [showUpgradePrompt, setShowUpgradePrompt] = createSignal(false);
const authHeaders = () => ({ headers: { Authorization: `Bearer ${data.token()}` } });
const setField = (patch: Partial<StaffForm>) => setForm({ ...form(), ...patch });
const [staff, { refetch }] = createResource(
() => data.token(),
async (bearer) => {
if (!bearer) return [];
if (bearer.startsWith("demo.")) {
return [
{ id: "st1", displayName: "Petra M.", active: true, locationId: "z1" },
{ id: "st2", displayName: "Lucie K.", active: true, locationId: "z2" },
{ id: "st3", displayName: cs() ? "Martin Hájek" : "Martin Hajek", active: false, locationId: null },
];
}
const response = await apiClient.GET("/v1/catalog/staff", authHeaders());
return (response.data as any[]) ?? [];
},
);
const [locations] = createResource(
() => data.token(),
async (bearer) => {
if (!bearer) return [];
if (bearer.startsWith("demo.")) {
return [
{ id: "z1", name: cs() ? "Hlavní sál" : "Main Hall" },
{ id: "z2", name: cs() ? "Soukromá místnost A" : "Private Room A" },
];
}
const response = await apiClient.GET("/v1/catalog/locations", authHeaders());
return (response.data as any[]) ?? [];
},
);
const resolvedStaff = () => staff() ?? [];
const resolvedLocations = () => locations() ?? [];
const staffLimit = () => data.resolvedBilling()?.entitlements?.maxStaff ?? -1;
const canAddStaff = () => staffLimit() < 0 || resolvedStaff().length < staffLimit();
const locationName = (id: string | null | undefined) =>
resolvedLocations().find((l: any) => l.id === id)?.name ?? data.i18n.t("dashboard.staff.noLocation");
const locationOptions = () => [
{ value: "", label: data.i18n.t("dashboard.staff.noLocation") },
...resolvedLocations().map((l: any) => ({ value: l.id, label: l.name })),
];
const openCreate = () => {
setEditingId(null);
setForm({ ...emptyForm });
setShowForm(true);
};
const openEdit = (member: any) => {
setEditingId(member.id);
setForm({ displayName: member.displayName, locationId: member.locationId ?? "" });
setShowForm(true);
};
const handleSave = async () => {
const bearer = data.token();
const name = form().displayName.trim();
if (!name) return;
if (!bearer || bearer.startsWith("demo.")) {
data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode.");
return;
}
setSaving(true);
try {
const id = editingId();
const locationId = form().locationId;
const response = id
? await apiClient.PUT("/v1/catalog/staff/{staffID}", {
...authHeaders(),
params: { path: { staffID: id } },
body: { displayName: name, locationId: locationId || "" },
})
: await apiClient.POST("/v1/catalog/staff", {
...authHeaders(),
body: { displayName: name, ...(locationId ? { locationId } : {}) },
});
if (response.error) throw new Error("save failed");
setShowForm(false);
setEditingId(null);
setForm({ ...emptyForm });
void refetch();
} catch {
data.setDemoNotice(data.i18n.t("dashboard.staff.saveFailed"));
} finally {
setSaving(false);
}
};
const handleToggleActive = async (member: any) => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) {
data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode.");
return;
}
try {
await apiClient.PUT("/v1/catalog/staff/{staffID}", {
...authHeaders(),
params: { path: { staffID: member.id } },
body: { displayName: member.displayName, active: !member.active },
});
void refetch();
} catch {
data.setDemoNotice(data.i18n.t("dashboard.staff.saveFailed"));
}
};
const handleDelete = async (id: string) => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) {
data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode.");
return;
}
try {
await apiClient.DELETE("/v1/catalog/staff/{staffID}", { ...authHeaders(), params: { path: { staffID: id } } });
void refetch();
} catch {
data.setDemoNotice(data.i18n.t("dashboard.staff.deleteFailed"));
}
};
return (
<div class="space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.staff")}</h1>
<p class="text-ink-muted mt-1">{resolvedStaff().length} {data.i18n.t("dashboard.staff.subtitle")}</p>
</div>
<Show when={canAddStaff()} fallback={
<button onClick={() => setShowUpgradePrompt(true)} class="flex items-center gap-2 px-4 py-2 bg-canvas-subtle text-ink-muted border border-border rounded-lg text-sm opacity-80">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
{data.i18n.t("dashboard.zone.limitReached")}
</button>
}>
<button onClick={openCreate} class="btn-primary text-sm shrink-0"><PlusIcon /> {data.i18n.t("dashboard.staff.add")}</button>
</Show>
</div>
<div class="surface-card overflow-hidden">
<Show when={showForm()}>
<div class="p-6 border-b border-border bg-canvas-subtle/50">
<h4 class="font-medium text-ink mb-4">{editingId() ? data.i18n.t("dashboard.staff.edit") : data.i18n.t("dashboard.staff.add")}</h4>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input type="text" label={data.i18n.t("dashboard.staff.name")} value={form().displayName} onInput={(e) => setField({ displayName: e.currentTarget.value })} placeholder={data.i18n.t("dashboard.staff.namePlaceholder")} />
<Select label={data.i18n.t("dashboard.staff.location")} value={form().locationId} onChange={(v) => setField({ locationId: v })} options={locationOptions()} />
</div>
<div class="flex gap-3 mt-4">
<button onClick={() => void handleSave()} disabled={saving() || !form().displayName.trim()} class="btn-primary text-sm disabled:opacity-60">
{saving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.settings.save")}
</button>
<button onClick={() => { setShowForm(false); setEditingId(null); }} class="btn-secondary text-sm">{data.i18n.t("common.cancel")}</button>
</div>
</div>
</Show>
<Show when={!staff.loading} fallback={
<div class="p-12 text-center text-ink-muted text-sm animate-pulse">{cs() ? "Načítání…" : "Loading…"}</div>
}>
<Show when={resolvedStaff().length > 0} fallback={
<div class="p-12 text-center">
<div class="w-16 h-16 rounded-full bg-canvas-subtle flex items-center justify-center mx-auto mb-4 text-ink-subtle"><UsersIcon /></div>
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.staff.noStaff")}</p>
<p class="text-sm text-ink-subtle mt-1">{data.i18n.t("dashboard.staff.noStaffDesc")}</p>
</div>
}>
<div class="divide-y divide-border/60">
<For each={resolvedStaff()}>
{(member: any) => (
<div class="flex items-center justify-between p-5 px-6 hover:bg-canvas-subtle/30 transition-colors">
<div class="flex items-center gap-4 min-w-0">
<div class={`w-12 h-12 rounded-full flex items-center justify-center text-sm font-bold shrink-0 ${member.active ? "bg-accent-subtle text-accent" : "bg-canvas-muted text-ink-subtle"}`}>
{member.displayName?.split(" ").map((n: string) => n[0]).join("").slice(0, 2).toUpperCase()}
</div>
<div class="min-w-0">
<h4 class="font-medium text-ink truncate">{member.displayName}</h4>
<p class="text-sm text-ink-muted flex items-center gap-1.5">
<MapPinIcon /> {locationName(member.locationId)}
</p>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
onClick={() => void handleToggleActive(member)}
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${member.active ? "bg-accent" : "bg-canvas-muted"}`}
role="switch"
aria-checked={member.active}
aria-label={member.active ? data.i18n.t("dashboard.staff.active") : data.i18n.t("dashboard.staff.inactive")}
>
<span class={`inline-block h-4 w-4 transform rounded-full bg-canvas transition-transform ${member.active ? "translate-x-6" : "translate-x-1"}`} />
</button>
<span class={`hidden sm:inline text-xs font-medium ${member.active ? "text-accent" : "text-ink-subtle"}`}>
{member.active ? data.i18n.t("dashboard.staff.active") : data.i18n.t("dashboard.staff.inactive")}
</span>
<button onClick={() => openEdit(member)} class="p-2 text-ink-subtle hover:text-accent hover:bg-accent-subtle rounded-lg transition-colors" aria-label={data.i18n.t("dashboard.edit")}>
<EditIcon />
</button>
<button onClick={() => void handleDelete(member.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" aria-label={cs() ? "Smazat" : "Delete"}>
<Trash2Icon />
</button>
</div>
</div>
)}
</For>
</div>
</Show>
</Show>
</div>
<Show when={showUpgradePrompt()}>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-ink/50 backdrop-blur-sm" onClick={() => setShowUpgradePrompt(false)} />
<div class="relative bg-canvas rounded-2xl shadow-2xl w-full max-w-md p-6 animate-scale-in">
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center shrink-0 text-accent"><SparklesIcon /></div>
<div class="flex-1">
<h4 class="font-medium text-ink mb-1">{data.i18n.t("dashboard.staff.limitReached")}</h4>
<p class="text-sm text-ink-muted">{data.i18n.t("dashboard.staff.limitDesc")}</p>
<div class="flex gap-3 mt-4">
<button onClick={() => { setShowUpgradePrompt(false); void data.openCheckout(); }} class="px-4 py-2 bg-accent text-canvas text-sm font-medium rounded-lg hover:bg-accent-hover transition-colors">{data.i18n.t("dashboard.upgrade")}</button>
<button onClick={() => setShowUpgradePrompt(false)} class="px-4 py-2 bg-canvas-subtle text-ink-muted text-sm font-medium rounded-lg hover:bg-canvas transition-colors">{data.i18n.t("dashboard.close")}</button>
</div>
</div>
</div>
</div>
</div>
</Show>
</div>
);
}
export default function StaffRoute() {
return (
<DashboardLayout>
<StaffPage />
</DashboardLayout>
);
}
+298 -13
View File
@@ -2,13 +2,13 @@ import { For, Show, createResource, createSignal } from "solid-js";
import { apiClient } from "../../lib/api-client";
import { Input } from "../../components/ui/input";
import { Select } from "../../components/ui/select";
import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon } from "../../components/dashboard/icons";
import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon, PencilIcon } from "../../components/dashboard/icons";
import { DashboardLayout, useDashboardData } from "./layout";
function ZonesPage() {
const data = useDashboardData();
const cs = () => data.i18n.locale() === "cs";
const [activeTab, setActiveTab] = createSignal<"zones" | "blocked" | "hours">("zones");
const [activeTab, setActiveTab] = createSignal<"zones" | "blocked" | "hours" | "classes">("zones");
const [showAddZone, setShowAddZone] = createSignal(false);
const [newZoneName, setNewZoneName] = createSignal("");
const [newZoneType, setNewZoneType] = createSignal("room");
@@ -16,8 +16,19 @@ function ZonesPage() {
const [showUpgradePrompt, setShowUpgradePrompt] = createSignal(false);
const [newBlockedDate, setNewBlockedDate] = createSignal("");
const [newBlockedReason, setNewBlockedReason] = createSignal("");
const [newBlockedStaffId, setNewBlockedStaffId] = createSignal("");
const [blockedSaving, setBlockedSaving] = createSignal(false);
const [hoursSaving, setHoursSaving] = createSignal<number | null>(null);
const [hoursScope, setHoursScope] = createSignal("");
const [newTemplateTitle, setNewTemplateTitle] = createSignal("");
const [newTemplateDuration, setNewTemplateDuration] = createSignal(60);
const [newTemplateCapacity, setNewTemplateCapacity] = createSignal(8);
const [templateSaving, setTemplateSaving] = createSignal(false);
const [editingTemplateId, setEditingTemplateId] = createSignal<string | null>(null);
const [newSessionTemplateId, setNewSessionTemplateId] = createSignal("");
const [newSessionLocationId, setNewSessionLocationId] = createSignal("");
const [newSessionStartsAt, setNewSessionStartsAt] = createSignal("");
const [sessionSaving, setSessionSaving] = createSignal(false);
const authHeaders = () => ({ headers: { Authorization: `Bearer ${data.token()}` } });
@@ -47,14 +58,40 @@ function ZonesPage() {
},
);
const [workingHours, { refetch: refetchHours, mutate: mutateHours }] = createResource(
const [staffMembers] = createResource(
() => [data.token(), activeTab()] as const,
async ([bearer, tab]) => {
if (!bearer || (tab !== "blocked" && tab !== "hours")) return [];
if (bearer.startsWith("demo.")) {
return [
{ id: "st1", displayName: "Petra M.", active: true },
{ id: "st2", displayName: "Lucie K.", active: true },
];
}
try {
const response = await apiClient.GET("/v1/catalog/staff", authHeaders());
return ((response.data as any[]) ?? []).filter((m) => m.active);
} catch {
return [];
}
},
);
const staffName = (id: string | null | undefined) =>
(staffMembers() ?? []).find((m: any) => m.id === id)?.displayName ?? "";
const [workingHours, { refetch: refetchHours, mutate: mutateHours }] = createResource(
() => [data.token(), activeTab(), hoursScope()] as const,
async ([bearer, tab, scope]) => {
if (!bearer || tab !== "hours") return [];
if (bearer.startsWith("demo.")) {
if (scope) {
return [0, 1, 2, 3, 4, 5, 6].map((d) => ({ dayOfWeek: d, open: d >= 1 && d <= 5 ? "08:00" : "", close: d >= 1 && d <= 5 ? "14:00" : "", isOpen: d >= 1 && d <= 5 }));
}
return [0, 1, 2, 3, 4, 5, 6].map((d) => ({ dayOfWeek: d, open: d === 0 ? "" : "09:00", close: d === 0 ? "" : "18:00", isOpen: d !== 0 }));
}
const response = await (apiClient as any).GET("/v1/catalog/working-hours", authHeaders());
const url = scope ? `/v1/catalog/staff/${scope}/working-hours` : "/v1/catalog/working-hours";
const response = await (apiClient as any).GET(url, authHeaders());
const rows = (response.data as any[]) ?? [];
return [0, 1, 2, 3, 4, 5, 6].map((d) => {
const row = rows.find((r) => r.dayOfWeek === d);
@@ -63,6 +100,31 @@ function ZonesPage() {
},
);
const [classTemplates, { refetch: refetchTemplates }] = createResource(
() => [data.token(), activeTab()] as const,
async ([bearer, tab]) => {
if (!bearer || tab !== "classes") return [];
if (bearer.startsWith("demo.")) {
return [{ id: "ct1", title: cs() ? "Jóga pro začátečníky" : "Beginner yoga", durationMinutes: 60, capacity: 8 }];
}
const response = await (apiClient as any).GET("/v1/catalog/class-templates", authHeaders());
return (response.data as any[]) ?? [];
},
);
const [classSessions, { refetch: refetchSessions }] = createResource(
() => [data.token(), activeTab()] as const,
async ([bearer, tab]) => {
if (!bearer || tab !== "classes") return [];
if (bearer.startsWith("demo.")) {
const start = new Date(Date.now() + 3 * 86400000);
return [{ id: "cs1", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationName: cs() ? "Hlavní sál" : "Main Hall", startsAt: start.toISOString(), endsAt: new Date(start.getTime() + 3600000).toISOString(), capacity: 8, bookedCount: 3 }];
}
const response = await (apiClient as any).GET("/v1/catalog/class-sessions", authHeaders());
return (response.data as any[]) ?? [];
},
);
const resolvedLocations = () => locations() ?? [];
const locationLimit = () => data.resolvedBilling()?.entitlements?.maxLocations ?? 3;
const canAddLocation = () => locationLimit() < 0 || resolvedLocations().length < locationLimit();
@@ -106,9 +168,14 @@ function ZonesPage() {
try {
await (apiClient as any).POST("/v1/catalog/blocked-days", {
...authHeaders(),
body: { date: new Date(`${newBlockedDate()}T00:00:00`).toISOString(), type: "closure", reason: newBlockedReason().trim() || (cs() ? "Zavřeno" : "Closed") },
body: {
date: new Date(`${newBlockedDate()}T00:00:00`).toISOString(),
type: "closure",
reason: newBlockedReason().trim() || (cs() ? "Zavřeno" : "Closed"),
...(newBlockedStaffId() ? { staffId: newBlockedStaffId() } : {}),
},
});
setNewBlockedDate(""); setNewBlockedReason("");
setNewBlockedDate(""); setNewBlockedReason(""); setNewBlockedStaffId("");
void refetchBlocked();
} catch {
data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed.");
@@ -131,7 +198,9 @@ function ZonesPage() {
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
setHoursSaving(day.dayOfWeek);
try {
await (apiClient as any).PUT(`/v1/catalog/working-hours/${day.dayOfWeek}`, {
const scope = hoursScope();
const url = scope ? `/v1/catalog/staff/${scope}/working-hours/${day.dayOfWeek}` : `/v1/catalog/working-hours/${day.dayOfWeek}`;
await (apiClient as any).PUT(url, {
...authHeaders(),
body: { open: day.open, close: day.close, isOpen: day.isOpen },
});
@@ -143,6 +212,84 @@ function ZonesPage() {
}
};
const resetTemplateForm = () => {
setNewTemplateTitle(""); setNewTemplateDuration(60); setNewTemplateCapacity(8); setEditingTemplateId(null);
};
const handleSaveTemplate = async () => {
const bearer = data.token();
if (!newTemplateTitle().trim()) return;
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
setTemplateSaving(true);
try {
const body = { title: newTemplateTitle().trim(), durationMinutes: newTemplateDuration(), capacity: newTemplateCapacity() };
const editing = editingTemplateId();
if (editing) {
await (apiClient as any).PUT(`/v1/catalog/class-templates/${editing}`, { ...authHeaders(), body });
} else {
await (apiClient as any).POST("/v1/catalog/class-templates", { ...authHeaders(), body });
}
resetTemplateForm();
void refetchTemplates(); void refetchSessions();
} catch {
data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed.");
} finally {
setTemplateSaving(false);
}
};
const handleEditTemplate = (tpl: any) => {
setNewTemplateTitle(tpl.title);
setNewTemplateDuration(tpl.durationMinutes);
setNewTemplateCapacity(tpl.capacity);
setEditingTemplateId(tpl.id);
};
const handleDeleteTemplate = async (id: string) => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode."); return; }
try {
await (apiClient as any).DELETE(`/v1/catalog/class-templates/${id}`, authHeaders());
void refetchTemplates(); void refetchSessions();
} catch {
data.setDemoNotice(cs() ? "Smazání selhalo." : "Delete failed.");
}
};
const handleAddSession = async () => {
const bearer = data.token();
if (!newSessionTemplateId() || !newSessionStartsAt()) return;
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
setSessionSaving(true);
try {
await (apiClient as any).POST("/v1/catalog/class-sessions", {
...authHeaders(),
body: {
templateId: newSessionTemplateId(),
startsAt: new Date(newSessionStartsAt()).toISOString(),
...(newSessionLocationId() ? { locationId: newSessionLocationId() } : {}),
},
});
setNewSessionTemplateId(""); setNewSessionLocationId(""); setNewSessionStartsAt("");
void refetchSessions();
} catch {
data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed.");
} finally {
setSessionSaving(false);
}
};
const handleDeleteSession = async (id: string) => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode."); return; }
try {
await (apiClient as any).DELETE(`/v1/catalog/class-sessions/${id}`, authHeaders());
void refetchSessions();
} catch {
data.setDemoNotice(cs() ? "Smazání selhalo." : "Delete failed.");
}
};
const typeLabel = (type: string) => {
const map: Record<string, string> = { room: data.i18n.t("dashboard.zone.rooms"), private: data.i18n.t("dashboard.zone.private"), hall: data.i18n.t("dashboard.zone.hall") };
return map[type] || type;
@@ -164,11 +311,12 @@ function ZonesPage() {
</div>
</div>
<div class="flex gap-2 p-1 bg-canvas-subtle rounded-xl w-fit">
<div class="flex flex-wrap gap-2 p-1 bg-canvas-subtle rounded-xl w-fit max-w-full">
{[
{ id: "zones", label: data.i18n.t("dashboard.zones"), icon: MapPinIcon },
{ id: "blocked", label: data.i18n.t("dashboard.zone.blockedDays"), icon: BanIcon },
{ id: "hours", label: data.i18n.t("dashboard.zone.workingHours"), icon: ClockIcon },
{ id: "classes", label: data.i18n.t("dashboard.classes"), icon: UsersIcon },
].map((tab) => (
<button onClick={() => setActiveTab(tab.id as any)} class={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${activeTab() === tab.id ? "bg-canvas text-accent shadow-sm" : "text-ink-muted hover:text-ink"}`}>
<tab.icon /> {tab.label}
@@ -236,9 +384,18 @@ function ZonesPage() {
<p class="text-sm text-ink-muted mt-1">{cs() ? "Dny, kdy nejsou dostupné rezervace." : "Days when bookings are unavailable."}</p>
</div>
<div class="p-6 border-b border-border bg-canvas-subtle/50">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Input type="date" label={cs() ? "Datum" : "Date"} value={newBlockedDate()} onInput={(e) => setNewBlockedDate(e.currentTarget.value)} />
<Input type="text" label={cs() ? "Důvod" : "Reason"} value={newBlockedReason()} onInput={(e) => setNewBlockedReason(e.currentTarget.value)} placeholder={cs() ? "např. Státní svátek" : "e.g. Public holiday"} />
<Select
label={data.i18n.t("dashboard.zone.staff")}
value={newBlockedStaffId()}
onChange={(v) => setNewBlockedStaffId(v)}
options={[
{ value: "", label: data.i18n.t("dashboard.zone.allStaff") },
...(staffMembers() ?? []).map((m: any) => ({ value: m.id, label: m.displayName })),
]}
/>
<div class="flex items-end">
<button onClick={() => void handleAddBlockedDay()} disabled={blockedSaving() || !newBlockedDate()} class="btn-primary text-sm w-full sm:w-auto disabled:opacity-60">
{blockedSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.settings.save")}
@@ -259,7 +416,12 @@ function ZonesPage() {
<div class="flex items-center justify-between p-4 px-6">
<div>
<p class="font-medium text-ink">{new Date(day.date).toLocaleDateString(cs() ? "cs-CZ" : "en-US", { weekday: "long", day: "numeric", month: "long", year: "numeric" })}</p>
<p class="text-sm text-ink-muted">{day.reason || "—"}</p>
<p class="text-sm text-ink-muted">
{day.reason || "—"}
<Show when={staffName(day.staffId)}>
<span class="text-ink-subtle"> &bull; {staffName(day.staffId)}</span>
</Show>
</p>
</div>
<button onClick={() => void handleDeleteBlockedDay(day.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2Icon /></button>
</div>
@@ -272,9 +434,22 @@ function ZonesPage() {
{activeTab() === "hours" && (
<div class="surface-card overflow-hidden">
<div class="p-6 border-b border-border">
<h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.zone.workingHours")}</h3>
<p class="text-sm text-ink-muted mt-1">{cs() ? "Nastavte dny a časy, kdy přijímáte rezervace." : "Set the days and times you accept bookings."}</p>
<div class="p-6 border-b border-border flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.zone.workingHours")}</h3>
<p class="text-sm text-ink-muted mt-1">{cs() ? "Nastavte dny a časy, kdy přijímáte rezervace." : "Set the days and times you accept bookings."}</p>
</div>
<div class="w-full sm:w-56 shrink-0">
<Select
label={data.i18n.t("dashboard.zone.staff")}
value={hoursScope()}
onChange={(v) => setHoursScope(v)}
options={[
{ value: "", label: data.i18n.t("dashboard.zone.businessHours") },
...(staffMembers() ?? []).map((m: any) => ({ value: m.id, label: m.displayName })),
]}
/>
</div>
</div>
<div class="divide-y divide-border/60">
<For each={workingHours() ?? []}>
@@ -314,6 +489,116 @@ function ZonesPage() {
</div>
)}
{activeTab() === "classes" && (
<div class="space-y-6">
<div class="surface-card overflow-hidden">
<div class="p-6 border-b border-border">
<h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.class.templates")}</h3>
<p class="text-sm text-ink-muted mt-1">{data.i18n.t("dashboard.class.sessionHint")}</p>
</div>
<div class="p-6 border-b border-border bg-canvas-subtle/50">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Input type="text" label={data.i18n.t("dashboard.class.title")} value={newTemplateTitle()} onInput={(e) => setNewTemplateTitle(e.currentTarget.value)} placeholder={data.i18n.t("dashboard.class.titlePlaceholder")} />
<Input type="number" label={data.i18n.t("dashboard.class.durationMin")} value={newTemplateDuration()} onInput={(e) => setNewTemplateDuration(parseInt(e.currentTarget.value) || 60)} />
<Input type="number" label={data.i18n.t("dashboard.zone.capacity")} value={newTemplateCapacity()} onInput={(e) => setNewTemplateCapacity(parseInt(e.currentTarget.value) || 8)} />
<div class="flex items-end">
<button onClick={() => void handleSaveTemplate()} disabled={templateSaving() || !newTemplateTitle().trim()} class="btn-primary text-sm w-full sm:w-auto disabled:opacity-60">
{templateSaving() ? data.i18n.t("dashboard.saving") : editingTemplateId() ? data.i18n.t("dashboard.class.saveChanges") : data.i18n.t("dashboard.class.addTemplate")}
</button>
<Show when={editingTemplateId()}>
<button onClick={resetTemplateForm} class="text-sm text-ink-muted hover:text-ink px-3 py-2">
{data.i18n.t("dashboard.cancel")}
</button>
</Show>
</div>
</div>
</div>
<Show when={(classTemplates() ?? []).length > 0} fallback={
<div class="p-10 text-center">
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.class.noTemplates")}</p>
</div>
}>
<div class="divide-y divide-border/60">
<For each={classTemplates() ?? []}>
{(tpl: any) => (
<div class="flex items-center justify-between p-4 px-6">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-xl bg-accent-subtle/50 flex items-center justify-center text-accent"><UsersIcon /></div>
<div>
<p class="font-medium text-ink">{tpl.title}</p>
<p class="text-sm text-ink-muted">{tpl.durationMinutes} {data.i18n.t("dashboard.service.min")} &bull; {data.i18n.t("dashboard.zone.capacity")}: {tpl.capacity}</p>
</div>
</div>
<div class="flex items-center gap-1">
<button onClick={() => handleEditTemplate(tpl)} class="p-2 text-ink-subtle hover:text-accent hover:bg-accent-subtle rounded-lg transition-colors"><PencilIcon /></button>
<button onClick={() => void handleDeleteTemplate(tpl.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2Icon /></button>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
<div class="surface-card overflow-hidden">
<div class="p-6 border-b border-border">
<h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.class.sessions")}</h3>
</div>
<div class="p-6 border-b border-border bg-canvas-subtle/50">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Select
label={data.i18n.t("dashboard.class.template")}
value={newSessionTemplateId()}
onChange={(v) => setNewSessionTemplateId(v)}
options={[
{ value: "", label: "—" },
...(classTemplates() ?? []).map((t: any) => ({ value: t.id, label: t.title })),
]}
/>
<Select
label={data.i18n.t("dashboard.class.location")}
value={newSessionLocationId()}
onChange={(v) => setNewSessionLocationId(v)}
options={[
{ value: "", label: data.i18n.t("dashboard.class.noLocation") },
...resolvedLocations().map((l: any) => ({ value: l.id, label: l.name })),
]}
/>
<Input type="datetime-local" label={data.i18n.t("dashboard.class.startsAt")} value={newSessionStartsAt()} onInput={(e) => setNewSessionStartsAt(e.currentTarget.value)} />
<div class="flex items-end">
<button onClick={() => void handleAddSession()} disabled={sessionSaving() || !newSessionTemplateId() || !newSessionStartsAt()} class="btn-primary text-sm w-full sm:w-auto disabled:opacity-60">
{sessionSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.class.addSession")}
</button>
</div>
</div>
</div>
<Show when={(classSessions() ?? []).length > 0} fallback={
<div class="p-10 text-center">
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.class.noSessions")}</p>
</div>
}>
<div class="divide-y divide-border/60">
<For each={classSessions() ?? []}>
{(session: any) => (
<div class="flex items-center justify-between p-4 px-6">
<div>
<p class="font-medium text-ink">{session.templateTitle}</p>
<p class="text-sm text-ink-muted">
{new Date(session.startsAt).toLocaleString(cs() ? "cs-CZ" : "en-US", { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })}
{session.locationName ? ` • ${session.locationName}` : ""}
{` • ${session.bookedCount}/${session.capacity} ${data.i18n.t("dashboard.class.booked")}`}
</p>
</div>
<button onClick={() => void handleDeleteSession(session.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2Icon /></button>
</div>
)}
</For>
</div>
</Show>
</div>
</div>
)}
<Show when={showUpgradePrompt()}>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-ink/50 backdrop-blur-sm" onClick={() => setShowUpgradePrompt(false)} />
+292 -89
View File
@@ -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<string | null>(null);
const [selectedStaffId, setSelectedStaffId] = createSignal<string | null>(null);
const [selectedDate, setSelectedDate] = createSignal<string | null>(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,84 @@ 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<PublicService[]>(() => catalog.latest?.data?.services ?? []);
const staff = createMemo<PublicStaff[]>(() => catalog.latest?.data?.staff ?? []);
const classSlots = createMemo(() => (availability.latest?.data?.slots ?? []).filter((slot) => slot.mode === "class"));
const classDays = createMemo(() => {
const groups = new Map<string, any[]>();
for (const slot of classSlots()) {
const d = new Date(slot.startsAt);
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
const day = groups.get(key);
if (day) day.push(slot); else groups.set(key, [slot]);
}
return [...groups.entries()]
.sort(([, a], [, b]) => a[0].startsAt.localeCompare(b[0].startsAt))
.map(([key, slots]) => ({ key, slots, date: new Date(slots[0].startsAt) }));
});
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 +170,7 @@ export function PublicBookingRoute() {
setSubmittingSlot(null);
void refetch();
void refetchDay();
};
return (
@@ -273,7 +367,7 @@ export function PublicBookingRoute() {
</Show>
{/* Loading state */}
<Show when={availability.loading}>
<Show when={availability.loading || catalog.loading}>
<div class="space-y-4">
<SkeletonCard />
<SkeletonCard />
@@ -281,96 +375,205 @@ export function PublicBookingRoute() {
</div>
</Show>
{/* Slots list */}
<div class="space-y-4">
<Show
when={!availability.loading && availability.latest?.data?.slots?.length}
fallback={
<Show when={!availability.loading}>
<div class="rounded-card border border-dashed border-border bg-canvas-subtle/50 p-8 text-center">
<BookraCharacter pose="sleep" size="md" animate={true} class="mx-auto mb-4" />
<p class="text-sm text-ink-muted font-medium">{i18n.t("booking.empty")}</p>
<p class="text-xs text-ink-subtle mt-2">{i18n.t("booking.emptyHint")}</p>
</div>
</Show>
}
>
<For each={availability.latest?.data?.slots ?? []}>
{(slot, index) => (
<Card
variant="default"
class="group surface-card hover:border-accent/30 transition-all duration-300"
style={{ "animation-delay": `${0.1 * (index() + 3)}s` }}
padding="lg"
>
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="space-y-2">
<div class="flex items-center gap-2">
<Badge
variant={slot.mode === "appointment" ? "secondary" : "default"}
class="font-display text-xs uppercase tracking-wider"
>
{slot.mode}
</Badge>
<Show when={typeof slot.remainingCapacity === "number" && slot.remainingCapacity <= 3}>
<Badge variant="warning" size="sm">{i18n.t("booking.only")} {slot.remainingCapacity} {i18n.t("booking.left")}</Badge>
</Show>
</div>
<h3 class="font-display text-lg font-semibold text-ink">{slot.label ?? "Bookable slot"}</h3>
<div class="flex items-center gap-4 text-sm text-ink-muted">
<span class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
{new Date(slot.startsAt).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })}
</span>
<span class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
{new Date(slot.startsAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })} - {new Date(slot.endsAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<Show when={typeof slot.remainingCapacity === "number" && slot.mode === "class"}>
<p class="text-sm text-success font-medium flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
{slot.remainingCapacity} {i18n.t("booking.spotsAvailable")}
</p>
</Show>
</div>
<Tooltip content="Book this slot now">
<Button
isLoading={submittingSlot() === slot.startsAt}
onClick={() => void bookSlot(slot)}
size="md"
class="shadow-md hover:shadow-lg transition-shadow whitespace-nowrap"
<Show when={!availability.loading && !catalog.loading}>
{/* Stepped appointment flow */}
<Show when={hasCatalog()}>
<div class="space-y-6">
{/* Service picker */}
<div class="space-y-3">
<p class="text-xs font-semibold uppercase tracking-wider text-ink-subtle">{i18n.t("booking.step.service")}</p>
<div class="grid gap-3 sm:grid-cols-2">
<For each={services()}>
{(service) => (
<button
type="button"
onClick={() => pickService(service.id)}
class={`rounded-card border p-4 text-left transition-all duration-200 ${
selectedServiceId() === service.id
? "border-accent bg-accent/5 ring-2 ring-accent/20 shadow-sm"
: "border-border bg-canvas-subtle/50 hover:border-accent/40 hover:bg-canvas-subtle"
}`}
>
{submittingSlot() === slot.startsAt ? (
<>
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
{i18n.t("booking.submitting")}
</>
) : (
<>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="mr-2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
{i18n.t("booking.submit")}
</>
<p class="font-display font-semibold text-ink">{service.name}</p>
<p class="mt-1 text-sm text-ink-muted">
{service.durationMinutes} min
<span class="mx-1.5 text-ink-subtle">·</span>
{formatPrice(service.priceCents)}
</p>
</button>
)}
</For>
</div>
</div>
<Show when={selectedServiceId()}>
{/* Staff preference */}
<Show when={staff().length > 1}>
<div class="space-y-3 animate-fade-in">
<p class="text-xs font-semibold uppercase tracking-wider text-ink-subtle">{i18n.t("booking.step.staff")}</p>
<div class="flex flex-wrap gap-2">
<button
type="button"
onClick={() => pickStaff(null)}
class={`rounded-full border px-4 py-2 text-sm font-medium transition-all duration-200 ${
selectedStaffId() === null
? "border-accent bg-accent text-canvas shadow-sm"
: "border-border bg-canvas-subtle/50 text-ink-muted hover:border-accent/40 hover:text-ink"
}`}
>
{i18n.t("booking.staff.any")}
</button>
<For each={staff()}>
{(member) => (
<button
type="button"
onClick={() => pickStaff(member.id)}
class={`rounded-full border px-4 py-2 text-sm font-medium transition-all duration-200 ${
selectedStaffId() === member.id
? "border-accent bg-accent text-canvas shadow-sm"
: "border-border bg-canvas-subtle/50 text-ink-muted hover:border-accent/40 hover:text-ink"
}`}
>
{member.displayName}
</button>
)}
</Button>
</Tooltip>
</For>
</div>
</div>
</Card>
)}
</For>
</Show>
{/* Day picker */}
<div class="space-y-3 animate-fade-in">
<p class="text-xs font-semibold uppercase tracking-wider text-ink-subtle">{i18n.t("booking.step.date")}</p>
<div class="flex gap-2 overflow-x-auto pb-1 -mx-1 px-1">
<For each={days()}>
{(item) => (
<button
type="button"
onClick={() => setSelectedDate(item.value)}
class={`flex min-w-[64px] shrink-0 flex-col items-center rounded-card border px-3 py-2.5 transition-all duration-200 ${
selectedDate() === item.value
? "border-accent bg-accent text-canvas shadow-sm"
: "border-border bg-canvas-subtle/50 text-ink-muted hover:border-accent/40 hover:text-ink"
}`}
>
<span class="text-[11px] font-medium uppercase tracking-wide opacity-80">{item.weekday}</span>
<span class="font-display text-lg font-semibold leading-tight">{item.day}</span>
<span class="text-[11px] opacity-70">{item.month}</span>
</button>
)}
</For>
</div>
</div>
{/* Time grid */}
<Show when={selectedDate()}>
<div class="space-y-3 animate-fade-in">
<p class="text-xs font-semibold uppercase tracking-wider text-ink-subtle">{i18n.t("booking.step.time")}</p>
<Show when={dayAvailability.loading}>
<div class="grid grid-cols-3 gap-2 sm:grid-cols-4">
<div class="h-11 animate-pulse rounded-card bg-canvas-subtle" />
<div class="h-11 animate-pulse rounded-card bg-canvas-subtle" />
<div class="h-11 animate-pulse rounded-card bg-canvas-subtle" />
<div class="h-11 animate-pulse rounded-card bg-canvas-subtle" />
</div>
</Show>
<Show when={!dayAvailability.loading && daySlots().length === 0}>
<div class="rounded-card border border-dashed border-border bg-canvas-subtle/50 p-6 text-center">
<BookraCharacter pose="sleep" size="sm" animate={false} class="mx-auto mb-3" />
<p class="text-sm text-ink-muted font-medium">{i18n.t("booking.emptyDay")}</p>
</div>
</Show>
<div class="grid grid-cols-3 gap-2 sm:grid-cols-4">
<For each={daySlots()}>
{(slot) => (
<button
type="button"
disabled={submittingSlot() === slot.startsAt}
onClick={() => void bookSlot(slot)}
class={`rounded-card border px-2 py-2.5 text-center transition-all duration-200 disabled:opacity-60 ${
submittingSlot() === slot.startsAt
? "border-accent bg-accent/10"
: "border-border bg-canvas-subtle/50 hover:border-accent hover:bg-accent/5 hover:shadow-sm"
}`}
>
<span class="block font-display text-sm font-semibold text-ink">
{new Date(slot.startsAt).toLocaleTimeString(i18n.locale(), { hour: "2-digit", minute: "2-digit", timeZone: tenantTimezone() })}
</span>
<Show when={slot.staffName}>
<span class="block truncate text-[11px] text-ink-subtle">{slot.staffName}</span>
</Show>
</button>
)}
</For>
</div>
</div>
</Show>
</Show>
</div>
</Show>
</div>
{/* Class sessions */}
<Show when={classSlots().length > 0}>
<div class="space-y-4" classList={{ "mt-8": hasCatalog() }}>
<p class="text-xs font-semibold uppercase tracking-wider text-ink-subtle">{i18n.t("booking.classes")}</p>
<div class="space-y-5">
<For each={classDays()}>
{(day) => (
<div>
<p class="text-sm font-semibold text-ink mb-2 capitalize">
{day.date.toLocaleDateString(i18n.locale(), { weekday: 'long', day: 'numeric', month: 'long' })}
</p>
<div class="divide-y divide-border/60 surface-card overflow-hidden">
<For each={day.slots}>
{(slot) => (
<div class="flex items-center justify-between gap-3 px-4 py-3.5 hover:bg-canvas-subtle/40 transition-colors">
<div class="flex items-center gap-4 min-w-0">
<span class="text-sm font-semibold text-ink whitespace-nowrap tabular-nums w-[104px] shrink-0">
{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' })}
</span>
<div class="min-w-0">
<p class="font-medium text-ink truncate">{slot.label ?? "Bookable slot"}</p>
<Show when={typeof slot.remainingCapacity === "number"}>
<p class={`text-xs font-medium ${slot.remainingCapacity === 0 ? "text-ink-subtle" : slot.remainingCapacity <= 3 ? "text-[hsl(var(--warning))]" : "text-success"}`}>
{slot.remainingCapacity === 0
? i18n.t("booking.full")
: `${slot.remainingCapacity} ${i18n.t("booking.spotsAvailable")}`}
</p>
</Show>
</div>
</div>
<Button
isLoading={submittingSlot() === slot.startsAt}
onClick={() => void bookSlot(slot)}
size="sm"
variant={slot.remainingCapacity === 0 ? "secondary" : "primary"}
class="whitespace-nowrap shrink-0"
>
{slot.remainingCapacity === 0 ? i18n.t("booking.joinWaitlist") : i18n.t("booking.submit")}
</Button>
</div>
)}
</For>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
{/* Empty state */}
<Show when={!hasCatalog() && classSlots().length === 0}>
<div class="rounded-card border border-dashed border-border bg-canvas-subtle/50 p-8 text-center">
<BookraCharacter pose="sleep" size="md" animate={true} class="mx-auto mb-4" />
<p class="text-sm text-ink-muted font-medium">{i18n.t("booking.empty")}</p>
<p class="text-xs text-ink-subtle mt-2">{i18n.t("booking.emptyHint")}</p>
</div>
</Show>
</Show>
</CardContent>
</Card>
</div>
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/components/animated-list.tsx","./src/components/bookra-character.tsx","./src/components/dashboard-mockup.tsx","./src/components/floating-dock.tsx","./src/components/hover-feature-cards.tsx","./src/components/index.ts","./src/components/integration-modal.tsx","./src/components/location-map.tsx","./src/components/pinned-list.tsx","./src/components/shell.tsx","./src/components/sms-settings.tsx","./src/components/video-player.tsx","./src/components/widget-builder.tsx","./src/components/dashboard/activity-timeline.tsx","./src/components/dashboard/calendar-view.tsx","./src/components/dashboard/icons.tsx","./src/components/dashboard/kpi-card.tsx","./src/components/dashboard/notification-dropdown.tsx","./src/components/dashboard/revenue-chart.tsx","./src/components/dashboard/types.ts","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api-client.ts","./src/lib/map.ts","./src/lib/sentry.ts","./src/lib/types.ts","./src/providers/auth-provider.tsx","./src/providers/i18n-provider.tsx","./src/providers/theme-provider.tsx","./src/routes/about-route.tsx","./src/routes/auth-callback-route.tsx","./src/routes/booking-manage-route.tsx","./src/routes/contact-route.tsx","./src/routes/home-route.tsx","./src/routes/legal-route.tsx","./src/routes/not-found-route.tsx","./src/routes/pricing-route.tsx","./src/routes/public-booking-route.tsx","./src/routes/dashboard/billing-page.tsx","./src/routes/dashboard/bookings-page.tsx","./src/routes/dashboard/customers-page.tsx","./src/routes/dashboard/dashboard-data.ts","./src/routes/dashboard/layout.tsx","./src/routes/dashboard/overview-page.tsx","./src/routes/dashboard/settings-page.tsx","./src/routes/dashboard/zones-page.tsx","./vite.config.ts"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/components/animated-list.tsx","./src/components/bookra-character.tsx","./src/components/dashboard-mockup.tsx","./src/components/floating-dock.tsx","./src/components/hover-feature-cards.tsx","./src/components/index.ts","./src/components/integration-modal.tsx","./src/components/location-map.tsx","./src/components/pinned-list.tsx","./src/components/shell.tsx","./src/components/sms-settings.tsx","./src/components/video-player.tsx","./src/components/widget-builder.tsx","./src/components/dashboard/activity-timeline.tsx","./src/components/dashboard/calendar-view.tsx","./src/components/dashboard/icons.tsx","./src/components/dashboard/kpi-card.tsx","./src/components/dashboard/notification-dropdown.tsx","./src/components/dashboard/revenue-chart.tsx","./src/components/dashboard/types.ts","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api-client.ts","./src/lib/map.ts","./src/lib/sentry.ts","./src/lib/types.ts","./src/providers/auth-provider.tsx","./src/providers/i18n-provider.tsx","./src/providers/theme-provider.tsx","./src/routes/about-route.tsx","./src/routes/auth-callback-route.tsx","./src/routes/booking-manage-route.tsx","./src/routes/contact-route.tsx","./src/routes/home-route.tsx","./src/routes/legal-route.tsx","./src/routes/not-found-route.tsx","./src/routes/pricing-route.tsx","./src/routes/public-booking-route.tsx","./src/routes/dashboard/billing-page.tsx","./src/routes/dashboard/bookings-page.tsx","./src/routes/dashboard/customers-page.tsx","./src/routes/dashboard/dashboard-data.ts","./src/routes/dashboard/layout.tsx","./src/routes/dashboard/overview-page.tsx","./src/routes/dashboard/services-page.tsx","./src/routes/dashboard/settings-page.tsx","./src/routes/dashboard/staff-page.tsx","./src/routes/dashboard/zones-page.tsx","./vite.config.ts"],"version":"5.9.3"}
+894 -1
View File
@@ -287,6 +287,86 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/catalog/bookings/{bookingID}/cancel": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["cancelDashboardBooking"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/catalog/class-sessions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listClassSessions"];
put?: never;
post: operations["createClassSession"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/catalog/class-sessions/{sessionID}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["deleteClassSession"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/catalog/class-templates": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listClassTemplates"];
put?: never;
post: operations["createClassTemplate"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/catalog/class-templates/{templateID}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["updateClassTemplate"];
post?: never;
delete: operations["deleteClassTemplate"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/catalog/customers": {
parameters: {
query?: never;
@@ -351,6 +431,102 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/catalog/services": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listServices"];
put?: never;
post: operations["createService"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/catalog/services/{serviceID}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["updateService"];
post?: never;
delete: operations["deleteService"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/catalog/staff": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listStaff"];
put?: never;
post: operations["createStaff"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/catalog/staff/{staffID}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["updateStaff"];
post?: never;
delete: operations["deleteStaff"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/catalog/staff/{staffID}/working-hours": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listStaffWorkingHours"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/catalog/staff/{staffID}/working-hours/{dayOfWeek}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["updateStaffWorkingHours"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/catalog/working-hours": {
parameters: {
query?: never;
@@ -543,6 +719,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;
@@ -815,6 +1007,35 @@ export interface components {
*/
planCode: "starter" | "pro" | "business";
};
ClassSession: {
bookedCount: number;
capacity: number;
/** Format: date-time */
endsAt: string;
/** Format: uuid */
id: string;
/** Format: uuid */
locationId?: string;
locationName?: string;
/** Format: date-time */
startsAt: string;
/** Format: uuid */
templateId: string;
templateTitle: string;
/** Format: uuid */
tenantId: string;
};
ClassTemplate: {
capacity: number;
/** Format: date-time */
createdAt: string;
durationMinutes: number;
/** Format: uuid */
id: string;
/** Format: uuid */
tenantId: string;
title: string;
};
CreateBookingRequest: {
/** @enum {string} */
bookingMode: "appointment" | "class";
@@ -845,6 +1066,15 @@ export interface components {
/** @enum {string} */
status: "confirmed" | "waitlisted";
};
CreateClassSessionRequest: {
capacity?: number;
/** Format: uuid */
locationId?: string;
/** Format: date-time */
startsAt: string;
/** Format: uuid */
templateId: string;
};
Customer: {
bookingsCount?: number;
/** Format: email */
@@ -871,8 +1101,11 @@ export interface components {
startsAt: string;
};
DashboardKPI: {
change?: string;
code: string;
label: string;
/** @enum {string} */
trend?: "up" | "down" | "neutral";
value: string;
};
DashboardSummary: {
@@ -895,6 +1128,7 @@ export interface components {
failedCount: number;
processedCount: number;
sentCount: number;
skippedCount: number;
};
EmailTemplate: {
bodyText?: string;
@@ -969,6 +1203,11 @@ export interface components {
tenantSlug: string;
timezone: string;
};
PublicCatalogResponse: {
services: components["schemas"]["PublicService"][];
staff: components["schemas"]["PublicStaff"][];
tenantSlug: string;
};
PublicConfig: {
/** Format: uri */
apiUrl: string;
@@ -976,6 +1215,47 @@ 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;
/** @description Blocked time before the appointment (0-480) */
bufferBeforeMinutes: number;
/** Format: date-time */
createdAt?: string;
/** @description Appointment length in minutes (1-1440) */
durationMinutes: number;
/** Format: uuid */
id: string;
name: string;
/** @description Price in the tenant's currency minor units */
priceCents: number;
/** Format: uuid */
tenantId: string;
};
StaffMember: {
active: boolean;
/** Format: date-time */
createdAt?: string;
displayName: string;
/** Format: uuid */
id: string;
/** Format: uuid */
locationId?: string | null;
/** Format: uuid */
tenantId: string;
};
SubscriptionSnapshot: {
cancelAtPeriodEnd: boolean;
checkoutUrlAvailable: boolean;
@@ -1043,6 +1323,7 @@ export interface components {
serviceId?: string | null;
/** Format: uuid */
staffId?: string | null;
staffName?: string | null;
/** Format: date-time */
startsAt: string;
};
@@ -1095,6 +1376,11 @@ export interface components {
/** @enum {string} */
type: "closure" | "holiday" | "maintenance";
};
UpsertClassTemplateRequest: {
capacity: number;
durationMinutes: number;
title: string;
};
UpsertCustomerRequest: {
/** Format: email */
email: string;
@@ -1109,6 +1395,22 @@ export interface components {
/** @enum {string} */
type?: "room" | "private" | "hall";
};
UpsertServiceRequest: {
bufferAfterMinutes?: number;
bufferBeforeMinutes?: number;
durationMinutes: number;
name: string;
priceCents?: number;
};
UpsertStaffRequest: {
active?: boolean;
displayName: string;
/**
* Format: uuid
* @description Omit to keep the current assignment; empty string clears it
*/
locationId?: string | null;
};
WidgetSnippet: {
code?: string;
kind?: string;
@@ -1127,6 +1429,8 @@ export interface components {
blockedDayID: string;
customerID: string;
locationID: string;
serviceID: string;
staffID: string;
};
requestBodies: never;
headers: never;
@@ -1746,6 +2050,241 @@ export interface operations {
};
};
};
cancelDashboardBooking: {
parameters: {
query?: never;
header?: never;
path: {
bookingID: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Booking cancelled; freed class seats promote the waitlist */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Booking already cancelled */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Booking not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
listClassSessions: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Upcoming class sessions */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ClassSession"][];
};
};
};
};
createClassSession: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateClassSessionRequest"];
};
};
responses: {
/** @description Class session created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ClassSession"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Template or location not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
deleteClassSession: {
parameters: {
query?: never;
header?: never;
path: {
sessionID: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Class session deleted */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Class session not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
listClassTemplates: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Class templates */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ClassTemplate"][];
};
};
};
};
createClassTemplate: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpsertClassTemplateRequest"];
};
};
responses: {
/** @description Class template created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ClassTemplate"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
updateClassTemplate: {
parameters: {
query?: never;
header?: never;
path: {
templateID: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpsertClassTemplateRequest"];
};
};
responses: {
/** @description Updated class template */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Template not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
deleteClassTemplate: {
parameters: {
query?: never;
header?: never;
path: {
templateID: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Class template deleted */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Class template not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
listCustomers: {
parameters: {
query?: {
@@ -1978,6 +2517,331 @@ export interface operations {
};
};
};
listServices: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Tenant services */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Service"][];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
createService: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpsertServiceRequest"];
};
};
responses: {
/** @description Service created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Service"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
updateService: {
parameters: {
query?: never;
header?: never;
path: {
serviceID: components["parameters"]["serviceID"];
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpsertServiceRequest"];
};
};
responses: {
/** @description Service updated */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Service"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Service not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
deleteService: {
parameters: {
query?: never;
header?: never;
path: {
serviceID: components["parameters"]["serviceID"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Service deleted */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Service not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
listStaff: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Tenant staff */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StaffMember"][];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
createStaff: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpsertStaffRequest"];
};
};
responses: {
/** @description Staff member created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StaffMember"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Plan limit reached */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
updateStaff: {
parameters: {
query?: never;
header?: never;
path: {
staffID: components["parameters"]["staffID"];
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpsertStaffRequest"];
};
};
responses: {
/** @description Staff member updated */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StaffMember"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Staff member not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
deleteStaff: {
parameters: {
query?: never;
header?: never;
path: {
staffID: components["parameters"]["staffID"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Staff member deleted */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Staff member not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
listStaffWorkingHours: {
parameters: {
query?: never;
header?: never;
path: {
staffID: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Staff member's weekly working hours */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["WorkingHours"][];
};
};
/** @description Staff member not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
updateStaffWorkingHours: {
parameters: {
query?: never;
header?: never;
path: {
dayOfWeek: number;
staffID: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpdateWorkingHoursRequest"];
};
};
responses: {
/** @description Staff working hours updated */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Staff member not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
listWorkingHours: {
parameters: {
query?: never;
@@ -2306,7 +3170,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;
@@ -2326,6 +3197,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;
+82 -4
View File
@@ -51,8 +51,82 @@
- protected tenant onboarding endpoint added with backend validation for slug, preset, locale, and timezone
- onboarding now creates the tenant, owner membership, and first default location
- dashboard can transition from empty bootstrap shell to a provisioned tenant state
- real post-onboarding entity management screens are still missing
- `[next]` Milestone 8: Catalog management, live staging validation, and operational hardening
- post-onboarding entity screens for locations, services, staff, blocked days, working hours, and class templates/sessions now exist (Milestones 8 and 10)
- `[done/partial]` Milestone 8: Catalog management shipped; live staging validation and operational hardening remain
- service CRUD end-to-end: repository (pgx + in-memory), catalog service with validation and tenant isolation, protected `/v1/catalog/services` routes, OpenAPI schemas, dashboard services page
- staff CRUD end-to-end with active toggle, optional location assignment, and `MaxStaff` entitlement enforcement; dashboard staff page with upgrade prompt
- dashboard summary now resolves `staffName` on bookings and computes `setupCompletion` from real state (brand, location, service, staff, schedule)
- 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: live staging validation
- `[done/partial]` Milestone 10: Per-staff availability and class/session management
- working-hours repository now accepts a nullable staff scope on list/update (pgx + in-memory); staff-scoped rules live in `availability_rules` and directly drive public slot generation
- protected routes added: `GET/PUT /v1/catalog/staff/{id}/working-hours[/{day}]` with tenant-ownership checks
- class template + session CRUD end-to-end: repository (`classes.go`), catalog service with validation, protected `/v1/catalog/class-templates` and `/v1/catalog/class-sessions` routes, OpenAPI + regenerated client
- sessions derive `endsAt` from template duration, default capacity from the template, validate future start, tenant-owned template and optional location; list resolves template title, location name, and live booked count
- deletion semantics mirror the schema: deleting a template cascades its sessions; deleting a session preserves bookings via `class_session_id SET NULL` (mirrored in the in-memory repo)
- dashboard zones page gained a staff-scope selector on the working-hours tab (business-wide vs individual schedule) and a Classes tab with template create/delete and session scheduling
- normalized `availability_rules` time columns (`HH:MM:SS`) down to the `HH:MM` API contract in the service layer
- remaining: live staging validation
- `[done/partial]` Milestone 11: Booking lifecycle — dashboard cancellation and waitlist promotion
- businesses can cancel bookings from the dashboard (`POST /v1/catalog/bookings/{id}/cancel`), tenant-checked, with a two-step confirm in the booking detail modal
- cancelling a class booking (dashboard or customer self-service) now promotes the earliest waitlisted bookings while free capacity remains, ordered by booking `created_at`; promoted customers receive a confirmation email, cancelled customers a cancellation email
- shared `bookings.PromoteWaitlist` helper drives both cancel paths; waitlist ledger entries are marked resolved on promotion
- bookings surface the `waitlisted` status: stats card, filter option, warning-tone badge, demo seed entry; `dashboard.{status}` interpolation resolves the new i18n key automatically
- `BookingRecord` now carries `created_at` (needed for fair waitlist ordering); `GetBookingByID` added to the repository surface
- remaining: live staging validation; waitlist position display and manual promotion controls are not yet exposed
- `[done/partial]` Milestone 12: Real dashboard business metrics
- `DashboardMetrics` gained `CompletedCount` (confirmed bookings already ended within the window) in both pgx and in-memory repositories
- `DashboardSummary` now queries three trailing windows (next 7d, last 7d, 7-14d ago) and emits four KPIs with real trend deltas: `bookings_this_week`, `completed`, `cancellations`, `utilization` (delta in percentage points)
- `DashboardKPI` carries optional `change`/`trend` (`up|down|neutral`); the KPI card already rendered these, so real data now drives the trend arrows instead of the previous `neutral`/`—` fallback
- KPI labels are localized in the frontend via a `code` → i18n-key map (`dashboard.kpi.bookingsThisWeek`, `cancellations`, `utilization`; `completed` reuses the existing key); backend labels stay English as a fallback
- the booking-trend chart was already bucketing real `allBookings` into the current week — confirmed, no change needed
- demo mode intentionally keeps curated static KPI values; real tenants get the computed set
- remaining: live staging validation; "new clients" KPI still has no backend metric (customer entity lacks first-booking tracking)
- `[done/partial]` Milestone 13: Reminder pipeline reliability
- reminder jobs are lifecycle-aware: customer and dashboard cancellation mark pending jobs `cancelled`; reschedule deletes pending jobs and re-arms one via `reminderSchedule` for the new time (no job when moved inside the no-reminder horizon)
- dispatch now joins `bookings.status` and skips non-confirmed bookings (`skipped` job status, `skippedCount` in the response contract) — cancelled/waitlisted customers no longer receive reminders for bookings that are not happening
- reschedule hardened with the same validation as booking creation: appointment reschedules must fit an availability rule and avoid blocked days (`validateAppointmentTarget` is now a shared package-level helper); class bookings keep session-defined times
- new repository methods `CancelPendingReminderJobsForBooking` / `DeletePendingReminderJobsForBooking` in pgx and in-memory implementations
- remaining: live staging validation; SMS reminder channel exists in schema (`channel` column) but only `email` is dispatched — SMS reminders are a separate opt-in feature
- `[done/partial]` Milestone 14: Metrics completion + reminder dispatch scheduling
- `DashboardMetrics` gained `NewClientsCount` — distinct customer emails whose earliest-ever booking (`starts_at`) lands in the window; a fifth KPI `new_clients` completes the set the demo data promised
- KPI grid rebalanced for five cards (`lg:grid-cols-3 xl:grid-cols-5`); `new_clients` maps to the existing `dashboard.kpi.newClients` i18n key
- reminder dispatch now has a real trigger: `.github/workflows/reminder-dispatch.yml` calls `POST /v1/internal/jobs/reminders/dispatch` every 15 minutes with `X-Bookra-Job-Key` from secrets (`BOOKRA_API_URL`, `BOOKRA_JOB_RUNNER_KEY`); jobs are timestamp-based so scheduler lateness delays but never skips
- remaining: live staging validation; the workflow no-ops gracefully until the two secrets are configured
- `[done/partial]` Milestone 15: SMS reminder channel
- booking creation arms a second reminder job on `channel: sms` when the tenant has SMS enabled (`tenant_sms_settings.enabled`) and the customer left a phone number; email jobs are unchanged
- `notifications.Service` gained an injected `smsSender` function adapter (wired to `sms.Service.SendMessage` in the API server, keeping the package dependency-free); dispatch routes `channel: sms` through it with localized body text and the customer phone as recipient
- `sms.Service.SendMessage` still enforces enabled/plan/monthly-limit and logs usage, so SMS reminders inherit metering and billing automatically; missing provider config or phone marks the job `failed` with a delivery-log reason
- reminder lifecycle work from milestone 13 applies per-booking, so cancel/reschedule clears and re-arms both channels
- remaining: live staging validation against a real SMS Manager key
- `[done/partial]` Milestone 16: Waitlist position display
- `GetWaitlistPosition` repository method (pgx + memory) returns the 1-based queue spot: count of earlier-created waitlisted bookings on the same session + 1
- `CustomerBookingView` carries `waitlistPosition` for waitlisted class bookings; the manage page now shows a proper status badge for `waitlisted`/`cancelled` (previously both rendered as "Pending") plus a notice with the queue position and the auto-promotion explanation, in CS and EN
- fixed invalid token names along the way (`--danger-soft`/`--warning-soft` do not exist; `--error-subtle`/`--warning-subtle` do)
- remaining: live staging validation; manual waitlist promotion controls are still not exposed
- `[done/partial]` Milestone 17: Class template editing
- `PUT /v1/catalog/class-templates/{id}` end-to-end: `UpdateClassTemplate` in both repositories, catalog service method sharing `validateClassTemplateInput` with create, route, OpenAPI path, regenerated client
- classes tab template rows gained a pencil edit action that loads the template into the form; the submit button switches to save-changes with a cancel affordance
- remaining: live staging validation
- `[done/partial]` Milestone 18: Public class list polish
- class sessions on the public booking page are now grouped by day (localized weekday + date headers) with compact timetable-style rows instead of one tall card per session — consistent with the stepped appointment picker above
- capacity states read honestly: spots-left in success/warning tones, `Obsazeno`/`Full` when empty, and the action switches to `Na čekací listinu`/`Join waitlist` (previously a full session still said "Book now" while silently producing a waitlisted booking)
- reminder arming extracted into a shared `armReminderJobs` helper (email + opt-in SMS) used by booking creation and waitlist promotion — a booking promoted after its reminder already dispatched-as-skipped now gets re-armed instead of going unreminded
- remaining: live staging validation
- `[done]` Milestone 19: Double-booking race hardening (validated on local Postgres)
- migration `00014_booking_no_overlap.sql`: `btree_gist` extension plus three partial exclusion constraints on `bookings` mirroring `resourcesCollide` semantics — same staff, same location, or both-unassigned appointments may not overlap (`tstzrange`, `[)` bounds so abutting slots are legal); `cancelled` rows and `booking_mode='class'` are excluded
- `db.IsExclusionViolation` maps SQLSTATE 23P01 to `ErrBookingConflict` at all three write paths (public create, customer reschedule, dashboard manual create) — a lost race now surfaces as a proper 409 instead of a silent double-booking
- validated end-to-end against a local `postgres:17` container: SQL-level matrix (staff/location/unassigned overlap blocked, abutting allowed, cancel frees the slot), repo-level 23P01 detection, and a 10-request concurrent burst on one slot producing exactly one 201 + 409s for the rest; down/up migration roundtrip clean
- local test container: `docker run -d --name bookra-pg-local -p 5439:5432 -e POSTGRES_PASSWORD=bookra-local postgres:17-alpine`, migrations via `npm run db:migrate:up` with `BOOKRA_DATABASE_DIRECT_URL` pointed at it
- remaining: live staging validation on Neon
## 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.
@@ -279,11 +353,15 @@ Key entities:
- goose migration execution is runnable through workspace scripts.
- Protected backend requests now perform automatic app-side identity sync before tenant membership resolution.
- `/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.
- Tenant membership bootstrap now syncs identities structurally, but staging still needs seeded membership records and onboarding flows for first-time tenants.
- First-time onboarding exists, but there are still no CRUD screens for locations, staff, services, or schedules after workspace creation.
- Post-onboarding CRUD now covers services, staff, locations, blocked days, working hours, and customers; class/session template management and per-staff availability rules still have no management UI.
- Stripe lifecycle sync is implemented; still needs live environment validation against real products, price IDs, and webhook signatures.
- Real email/SMS provider integrations are not implemented yet; current delivery providers are safe noops.
- The internal reminder dispatch route exists, but Railway scheduling and secret rotation still need production setup.
@@ -293,7 +371,7 @@ Key entities:
- sync Neon Auth identities into tenant membership records and onboarding bootstrap
- 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 real app management screens for services, staff, locations, and availability after onboarding
- add management UI for class/session templates and per-staff availability rules so service duration, capacity, and staff schedules are editable after onboarding
## Product Principles
- calm, premium, structured UX