mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
Merge pull request #2 from Dvorinka/feat/catalog-services-staff
Booking platform: public flow, classes, lifecycle, metrics, SMS reminders, race hardening
This commit is contained in:
34 files changed
+7126
-231
No files matched your search
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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">
|
||||
• +{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>
|
||||
);
|
||||
}
|
||||
@@ -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"> • {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")} • {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)} />
|
||||
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user