mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
feat(availability): per-staff working hours and class/session management
- Working-hours repo accepts nullable staff scope (pgx + memory); staff rules live in availability_rules and drive public slot generation - Protected routes for staff working hours, class templates, and class sessions with tenant-ownership checks and input validation - Sessions derive endsAt/capacity from templates; list resolves location names and live booked counts; template delete cascades sessions, session delete preserves bookings via SET NULL (mirrored in memory) - Zones page: staff-scope selector on working hours, new Classes tab with template/session CRUD, CS+EN strings, mobile tab wrapping - Normalize availability_rules times to HH:MM in the service layer Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
d9aa29dbcb
commit
0405106587
12 files changed
+2052
-41
No files matched your search
@@ -904,6 +904,140 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
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.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)
|
// CUSTOMER BOOKING MANAGEMENT API (Public)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
"bookra/apps/backend/internal/bookings"
|
"bookra/apps/backend/internal/bookings"
|
||||||
"bookra/apps/backend/internal/db"
|
"bookra/apps/backend/internal/db"
|
||||||
"bookra/apps/backend/internal/domain"
|
"bookra/apps/backend/internal/domain"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -26,6 +28,8 @@ var (
|
|||||||
ErrTenantNotFound = errors.New("tenant not found")
|
ErrTenantNotFound = errors.New("tenant not found")
|
||||||
ErrTenantMembership = errors.New("tenant membership not found")
|
ErrTenantMembership = errors.New("tenant membership not found")
|
||||||
ErrPlanLimitReached = errors.New("plan limit reached")
|
ErrPlanLimitReached = errors.New("plan limit reached")
|
||||||
|
ErrClassTemplateNotFound = errors.New("class template not found")
|
||||||
|
ErrClassSessionNotFound = errors.New("class session not found")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -786,18 +790,20 @@ func (s *Service) ListWorkingHours(ctx context.Context, principal domain.Princip
|
|||||||
return nil, ErrTenantMembership
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
hours := make([]domain.WorkingHours, len(records))
|
hours := make([]domain.WorkingHours, len(records))
|
||||||
for i, rec := range records {
|
for i, rec := range records {
|
||||||
|
open := hhmm(rec.StartsLocal)
|
||||||
|
close_ := hhmm(rec.EndsLocal)
|
||||||
hours[i] = domain.WorkingHours{
|
hours[i] = domain.WorkingHours{
|
||||||
DayOfWeek: rec.DayOfWeek,
|
DayOfWeek: rec.DayOfWeek,
|
||||||
Open: rec.StartsLocal,
|
Open: open,
|
||||||
Close: rec.EndsLocal,
|
Close: close_,
|
||||||
IsOpen: rec.StartsLocal != "" && rec.EndsLocal != "",
|
IsOpen: open != "" && close_ != "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return hours, nil
|
return hours, nil
|
||||||
@@ -823,7 +829,81 @@ func (s *Service) UpdateWorkingHours(ctx context.Context, principal domain.Princ
|
|||||||
params.EndsLocal = &req.Close
|
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
|
// CreateBooking creates a manual booking from the dashboard. The service name
|
||||||
@@ -930,3 +1010,256 @@ func (s *Service) CreateBooking(ctx context.Context, principal domain.Principal,
|
|||||||
Notes: strings.TrimSpace(req.Notes),
|
Notes: strings.TrimSpace(req.Notes),
|
||||||
}, nil
|
}, 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 := strings.TrimSpace(req.Title)
|
||||||
|
if len(title) < 1 || len(title) > 120 {
|
||||||
|
return domain.ClassTemplate{}, fmt.Errorf("%w: title must be between 1 and 120 characters", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
if req.DurationMinutes < 1 || req.DurationMinutes > 1440 {
|
||||||
|
return domain.ClassTemplate{}, fmt.Errorf("%w: durationMinutes must be between 1 and 1440", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
if req.Capacity < 1 || req.Capacity > 500 {
|
||||||
|
return domain.ClassTemplate{}, fmt.Errorf("%w: capacity must be between 1 and 500", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -4,11 +4,15 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookra/apps/backend/internal/bookings"
|
||||||
"bookra/apps/backend/internal/db"
|
"bookra/apps/backend/internal/db"
|
||||||
"bookra/apps/backend/internal/domain"
|
"bookra/apps/backend/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func boolPtr(v bool) *bool { return &v }
|
||||||
|
|
||||||
var owner = domain.Principal{Subject: "demo-owner", Email: "[email protected]", Role: "owner"}
|
var owner = domain.Principal{Subject: "demo-owner", Email: "[email protected]", Role: "owner"}
|
||||||
|
|
||||||
type stubBilling struct {
|
type stubBilling struct {
|
||||||
@@ -259,3 +263,287 @@ func TestStaffNotFoundAndValidation(t *testing.T) {
|
|||||||
t.Fatalf("expected ErrTenantMembership, got %v", err)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
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) 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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *PGRepository) ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error) {
|
func (r *PGRepository) ListWorkingHoursByTenant(ctx context.Context, tenantID string, staffID *string) ([]WorkingHoursRecord, error) {
|
||||||
rows, err := r.pool.Query(ctx, `
|
query := `
|
||||||
SELECT tenant_id, staff_id, day_of_week, starts_local::text, ends_local::text
|
SELECT tenant_id, staff_id, day_of_week, starts_local::text, ends_local::text
|
||||||
FROM availability_rules
|
FROM availability_rules
|
||||||
WHERE tenant_id = $1 AND staff_id IS NULL
|
WHERE tenant_id = $1 AND staff_id IS NULL
|
||||||
ORDER BY day_of_week
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -145,12 +156,22 @@ func (r *PGRepository) ListWorkingHoursByTenant(ctx context.Context, tenantID st
|
|||||||
return records, rows.Err()
|
return records, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *PGRepository) UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error {
|
func (r *PGRepository) UpdateWorkingHours(ctx context.Context, tenantID string, staffID *string, dayOfWeek int, params UpdateWorkingHoursParams) error {
|
||||||
if params.IsOpen != nil && !*params.IsOpen {
|
deleteQuery := `
|
||||||
_, err := r.pool.Exec(ctx, `
|
|
||||||
DELETE FROM availability_rules
|
DELETE FROM availability_rules
|
||||||
WHERE tenant_id = $1 AND day_of_week = $2 AND staff_id IS NULL
|
WHERE tenant_id = $1 AND day_of_week = $2 AND staff_id IS NULL
|
||||||
`, tenantID, dayOfWeek)
|
`
|
||||||
|
args := []any{tenantID, dayOfWeek}
|
||||||
|
if staffID != nil {
|
||||||
|
deleteQuery = `
|
||||||
|
DELETE FROM availability_rules
|
||||||
|
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
|
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
|
// availability_rules has no unique constraint on (tenant, day): replace the
|
||||||
// tenant-level row so repeated updates stay idempotent.
|
// scoped row so repeated updates stay idempotent.
|
||||||
if _, err := r.pool.Exec(ctx, `
|
if _, err := r.pool.Exec(ctx, deleteQuery, args...); err != nil {
|
||||||
DELETE FROM availability_rules
|
|
||||||
WHERE tenant_id = $1 AND day_of_week = $2 AND staff_id IS NULL
|
|
||||||
`, tenantID, dayOfWeek); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := r.pool.Exec(ctx, `
|
_, err := r.pool.Exec(ctx, `
|
||||||
INSERT INTO availability_rules (id, tenant_id, staff_id, day_of_week, starts_local, ends_local)
|
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)
|
VALUES (gen_random_uuid(), $1, $2, $3, $4::time, $5::time)
|
||||||
`, tenantID, dayOfWeek, *params.StartsLocal, *params.EndsLocal)
|
`, tenantID, staffID, dayOfWeek, *params.StartsLocal, *params.EndsLocal)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -26,6 +26,13 @@ type Repository interface {
|
|||||||
ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error)
|
ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error)
|
||||||
ListAvailabilityRulesByTenant(ctx context.Context, tenantID string) ([]AvailabilityRuleRecord, error)
|
ListAvailabilityRulesByTenant(ctx context.Context, tenantID string) ([]AvailabilityRuleRecord, error)
|
||||||
ListClassSessionsByTenant(ctx context.Context, tenantID string, from time.Time, limit int) ([]ClassSessionRecord, 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)
|
||||||
|
DeleteClassTemplate(ctx context.Context, templateID string) error
|
||||||
ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error)
|
ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error)
|
||||||
CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error)
|
CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error)
|
||||||
AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error
|
AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error
|
||||||
@@ -100,8 +107,8 @@ type Repository interface {
|
|||||||
RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error
|
RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error
|
||||||
|
|
||||||
// Working Hours
|
// Working Hours
|
||||||
ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error)
|
ListWorkingHoursByTenant(ctx context.Context, tenantID string, staffID *string) ([]WorkingHoursRecord, error)
|
||||||
UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error
|
UpdateWorkingHours(ctx context.Context, tenantID string, staffID *string, dayOfWeek int, params UpdateWorkingHoursParams) error
|
||||||
|
|
||||||
// Email templates
|
// Email templates
|
||||||
ListEmailTemplates(ctx context.Context, tenantID string) ([]EmailTemplateRecord, error)
|
ListEmailTemplates(ctx context.Context, tenantID string) ([]EmailTemplateRecord, error)
|
||||||
@@ -293,6 +300,22 @@ type AvailabilityRuleRecord struct {
|
|||||||
EndsLocal string
|
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 {
|
type ClassSessionRecord struct {
|
||||||
ID string
|
ID string
|
||||||
TenantID string
|
TenantID string
|
||||||
@@ -304,6 +327,15 @@ type ClassSessionRecord struct {
|
|||||||
Capacity int32
|
Capacity int32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CreateClassSessionParams struct {
|
||||||
|
TenantID string
|
||||||
|
TemplateID string
|
||||||
|
LocationID *string
|
||||||
|
StartsAt time.Time
|
||||||
|
EndsAt time.Time
|
||||||
|
Capacity int32
|
||||||
|
}
|
||||||
|
|
||||||
type BookingRecord struct {
|
type BookingRecord struct {
|
||||||
ID string
|
ID string
|
||||||
TenantID string
|
TenantID string
|
||||||
@@ -719,6 +751,7 @@ type MemoryRepository struct {
|
|||||||
brand BrandProfileRecord
|
brand BrandProfileRecord
|
||||||
services []ServiceRecord
|
services []ServiceRecord
|
||||||
rules []AvailabilityRuleRecord
|
rules []AvailabilityRuleRecord
|
||||||
|
classTemplates []ClassTemplateRecord
|
||||||
classSessions []ClassSessionRecord
|
classSessions []ClassSessionRecord
|
||||||
bookings []BookingRecord
|
bookings []BookingRecord
|
||||||
waitlistSize int
|
waitlistSize int
|
||||||
@@ -810,6 +843,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: 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"},
|
{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{
|
classSessions: []ClassSessionRecord{
|
||||||
{
|
{
|
||||||
ID: sessionID,
|
ID: sessionID,
|
||||||
@@ -1179,6 +1215,97 @@ func (r *MemoryRepository) ListClassSessionsByTenant(_ context.Context, tenantID
|
|||||||
return out, nil
|
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) 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) {
|
func (r *MemoryRepository) ListBookingsByTenantBetween(_ context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) {
|
||||||
var out []BookingRecord
|
var out []BookingRecord
|
||||||
for _, booking := range r.bookings {
|
for _, booking := range r.bookings {
|
||||||
@@ -1699,29 +1826,114 @@ func (r *MemoryRepository) UpsertEmailTemplate(_ context.Context, params EmailTe
|
|||||||
// WORKING HOURS METHODS - MEMORY REPOSITORY
|
// 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 {
|
if tenantID != r.tenant.ID {
|
||||||
return nil, pgx.ErrNoRows
|
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 {
|
if tenantID != r.tenant.ID {
|
||||||
return pgx.ErrNoRows
|
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 {
|
for i, wh := range r.workingHours {
|
||||||
if wh.TenantID == tenantID && wh.DayOfWeek == dayOfWeek {
|
if wh.TenantID == tenantID && wh.DayOfWeek == dayOfWeek && wh.StaffID == nil {
|
||||||
if params.StartsLocal != nil {
|
|
||||||
r.workingHours[i].StartsLocal = *params.StartsLocal
|
r.workingHours[i].StartsLocal = *params.StartsLocal
|
||||||
}
|
|
||||||
if params.EndsLocal != nil {
|
|
||||||
r.workingHours[i].EndsLocal = *params.EndsLocal
|
r.workingHours[i].EndsLocal = *params.EndsLocal
|
||||||
}
|
|
||||||
return nil
|
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
|
// Auth methods for MemoryRepository
|
||||||
|
|||||||
@@ -368,6 +368,45 @@ type UpdateBlockedDayRequest struct {
|
|||||||
Type string `json:"type,omitempty"`
|
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
|
// CUSTOMER MODELS
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -986,6 +986,171 @@ paths:
|
|||||||
description: Working hours updated
|
description: Working hours updated
|
||||||
"400":
|
"400":
|
||||||
description: Invalid request
|
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}:
|
||||||
|
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 ----------------
|
# ---------------- Billing ----------------
|
||||||
/v1/billing/subscription:
|
/v1/billing/subscription:
|
||||||
get:
|
get:
|
||||||
@@ -1880,6 +2045,80 @@ components:
|
|||||||
example: "18:00"
|
example: "18:00"
|
||||||
isOpen:
|
isOpen:
|
||||||
type: boolean
|
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:
|
PlanEntitlements:
|
||||||
type: object
|
type: object
|
||||||
required: [maxLocations, maxStaff, emailReminders, advancedReporting, widgetEmbedding, umamiTracking]
|
required: [maxLocations, maxStaff, emailReminders, advancedReporting, widgetEmbedding, umamiTracking]
|
||||||
|
|||||||
@@ -400,6 +400,23 @@ const dictionaries = {
|
|||||||
"dashboard.zone.noZones": "Zatím nemáte žádné zóny. Přidejte první.",
|
"dashboard.zone.noZones": "Zatím nemáte žádné zóny. Přidejte první.",
|
||||||
"dashboard.zone.staff": "Zaměstnanec",
|
"dashboard.zone.staff": "Zaměstnanec",
|
||||||
"dashboard.zone.allStaff": "Všichni zaměstnanci",
|
"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.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": "Služby",
|
||||||
"dashboard.services.subtitle": "služeb v nabídce",
|
"dashboard.services.subtitle": "služeb v nabídce",
|
||||||
"dashboard.service.add": "Přidat službu",
|
"dashboard.service.add": "Přidat službu",
|
||||||
@@ -952,6 +969,23 @@ const dictionaries = {
|
|||||||
"dashboard.zone.noZones": "No zones yet. Add your first one.",
|
"dashboard.zone.noZones": "No zones yet. Add your first one.",
|
||||||
"dashboard.zone.staff": "Staff member",
|
"dashboard.zone.staff": "Staff member",
|
||||||
"dashboard.zone.allStaff": "All staff",
|
"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.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": "Services",
|
||||||
"dashboard.services.subtitle": "services offered",
|
"dashboard.services.subtitle": "services offered",
|
||||||
"dashboard.service.add": "Add service",
|
"dashboard.service.add": "Add service",
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { For, Show, createResource, createSignal } from "solid-js";
|
|||||||
import { apiClient } from "../../lib/api-client";
|
import { apiClient } from "../../lib/api-client";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { Select } from "../../components/ui/select";
|
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 } from "../../components/dashboard/icons";
|
||||||
import { DashboardLayout, useDashboardData } from "./layout";
|
import { DashboardLayout, useDashboardData } from "./layout";
|
||||||
|
|
||||||
function ZonesPage() {
|
function ZonesPage() {
|
||||||
const data = useDashboardData();
|
const data = useDashboardData();
|
||||||
const cs = () => data.i18n.locale() === "cs";
|
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 [showAddZone, setShowAddZone] = createSignal(false);
|
||||||
const [newZoneName, setNewZoneName] = createSignal("");
|
const [newZoneName, setNewZoneName] = createSignal("");
|
||||||
const [newZoneType, setNewZoneType] = createSignal("room");
|
const [newZoneType, setNewZoneType] = createSignal("room");
|
||||||
@@ -19,6 +19,15 @@ function ZonesPage() {
|
|||||||
const [newBlockedStaffId, setNewBlockedStaffId] = createSignal("");
|
const [newBlockedStaffId, setNewBlockedStaffId] = createSignal("");
|
||||||
const [blockedSaving, setBlockedSaving] = createSignal(false);
|
const [blockedSaving, setBlockedSaving] = createSignal(false);
|
||||||
const [hoursSaving, setHoursSaving] = createSignal<number | null>(null);
|
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 [newSessionTemplateId, setNewSessionTemplateId] = createSignal("");
|
||||||
|
const [newSessionLocationId, setNewSessionLocationId] = createSignal("");
|
||||||
|
const [newSessionStartsAt, setNewSessionStartsAt] = createSignal("");
|
||||||
|
const [sessionSaving, setSessionSaving] = createSignal(false);
|
||||||
|
|
||||||
const authHeaders = () => ({ headers: { Authorization: `Bearer ${data.token()}` } });
|
const authHeaders = () => ({ headers: { Authorization: `Bearer ${data.token()}` } });
|
||||||
|
|
||||||
@@ -51,7 +60,7 @@ function ZonesPage() {
|
|||||||
const [staffMembers] = createResource(
|
const [staffMembers] = createResource(
|
||||||
() => [data.token(), activeTab()] as const,
|
() => [data.token(), activeTab()] as const,
|
||||||
async ([bearer, tab]) => {
|
async ([bearer, tab]) => {
|
||||||
if (!bearer || tab !== "blocked") return [];
|
if (!bearer || (tab !== "blocked" && tab !== "hours")) return [];
|
||||||
if (bearer.startsWith("demo.")) {
|
if (bearer.startsWith("demo.")) {
|
||||||
return [
|
return [
|
||||||
{ id: "st1", displayName: "Petra M.", active: true },
|
{ id: "st1", displayName: "Petra M.", active: true },
|
||||||
@@ -71,13 +80,17 @@ function ZonesPage() {
|
|||||||
(staffMembers() ?? []).find((m: any) => m.id === id)?.displayName ?? "";
|
(staffMembers() ?? []).find((m: any) => m.id === id)?.displayName ?? "";
|
||||||
|
|
||||||
const [workingHours, { refetch: refetchHours, mutate: mutateHours }] = createResource(
|
const [workingHours, { refetch: refetchHours, mutate: mutateHours }] = createResource(
|
||||||
() => [data.token(), activeTab()] as const,
|
() => [data.token(), activeTab(), hoursScope()] as const,
|
||||||
async ([bearer, tab]) => {
|
async ([bearer, tab, scope]) => {
|
||||||
if (!bearer || tab !== "hours") return [];
|
if (!bearer || tab !== "hours") return [];
|
||||||
if (bearer.startsWith("demo.")) {
|
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 }));
|
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[]) ?? [];
|
const rows = (response.data as any[]) ?? [];
|
||||||
return [0, 1, 2, 3, 4, 5, 6].map((d) => {
|
return [0, 1, 2, 3, 4, 5, 6].map((d) => {
|
||||||
const row = rows.find((r) => r.dayOfWeek === d);
|
const row = rows.find((r) => r.dayOfWeek === d);
|
||||||
@@ -86,6 +99,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 resolvedLocations = () => locations() ?? [];
|
||||||
const locationLimit = () => data.resolvedBilling()?.entitlements?.maxLocations ?? 3;
|
const locationLimit = () => data.resolvedBilling()?.entitlements?.maxLocations ?? 3;
|
||||||
const canAddLocation = () => locationLimit() < 0 || resolvedLocations().length < locationLimit();
|
const canAddLocation = () => locationLimit() < 0 || resolvedLocations().length < locationLimit();
|
||||||
@@ -159,7 +197,9 @@ function ZonesPage() {
|
|||||||
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
|
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
|
||||||
setHoursSaving(day.dayOfWeek);
|
setHoursSaving(day.dayOfWeek);
|
||||||
try {
|
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(),
|
...authHeaders(),
|
||||||
body: { open: day.open, close: day.close, isOpen: day.isOpen },
|
body: { open: day.open, close: day.close, isOpen: day.isOpen },
|
||||||
});
|
});
|
||||||
@@ -171,6 +211,70 @@ function ZonesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddTemplate = 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 {
|
||||||
|
await (apiClient as any).POST("/v1/catalog/class-templates", {
|
||||||
|
...authHeaders(),
|
||||||
|
body: { title: newTemplateTitle().trim(), durationMinutes: newTemplateDuration(), capacity: newTemplateCapacity() },
|
||||||
|
});
|
||||||
|
setNewTemplateTitle(""); setNewTemplateDuration(60); setNewTemplateCapacity(8);
|
||||||
|
void refetchTemplates();
|
||||||
|
} catch {
|
||||||
|
data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed.");
|
||||||
|
} finally {
|
||||||
|
setTemplateSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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") };
|
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;
|
return map[type] || type;
|
||||||
@@ -192,11 +296,12 @@ function ZonesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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: "zones", label: data.i18n.t("dashboard.zones"), icon: MapPinIcon },
|
||||||
{ id: "blocked", label: data.i18n.t("dashboard.zone.blockedDays"), icon: BanIcon },
|
{ id: "blocked", label: data.i18n.t("dashboard.zone.blockedDays"), icon: BanIcon },
|
||||||
{ id: "hours", label: data.i18n.t("dashboard.zone.workingHours"), icon: ClockIcon },
|
{ id: "hours", label: data.i18n.t("dashboard.zone.workingHours"), icon: ClockIcon },
|
||||||
|
{ id: "classes", label: data.i18n.t("dashboard.classes"), icon: UsersIcon },
|
||||||
].map((tab) => (
|
].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"}`}>
|
<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}
|
<tab.icon /> {tab.label}
|
||||||
@@ -314,10 +419,23 @@ function ZonesPage() {
|
|||||||
|
|
||||||
{activeTab() === "hours" && (
|
{activeTab() === "hours" && (
|
||||||
<div class="surface-card overflow-hidden">
|
<div class="surface-card overflow-hidden">
|
||||||
<div class="p-6 border-b border-border">
|
<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>
|
<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>
|
<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>
|
||||||
|
<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">
|
<div class="divide-y divide-border/60">
|
||||||
<For each={workingHours() ?? []}>
|
<For each={workingHours() ?? []}>
|
||||||
{(day) => {
|
{(day) => {
|
||||||
@@ -356,6 +474,108 @@ function ZonesPage() {
|
|||||||
</div>
|
</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 handleAddTemplate()} disabled={templateSaving() || !newTemplateTitle().trim()} class="btn-primary text-sm w-full sm:w-auto disabled:opacity-60">
|
||||||
|
{templateSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.class.addTemplate")}
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</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()}>
|
<Show when={showUpgradePrompt()}>
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<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="absolute inset-0 bg-ink/50 backdrop-blur-sm" onClick={() => setShowUpgradePrompt(false)} />
|
||||||
|
|||||||
@@ -287,6 +287,70 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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?: never;
|
||||||
|
post?: never;
|
||||||
|
delete: operations["deleteClassTemplate"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/v1/catalog/customers": {
|
"/v1/catalog/customers": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -415,6 +479,38 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/v1/catalog/working-hours": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -895,6 +991,35 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
planCode: "starter" | "pro" | "business";
|
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: {
|
CreateBookingRequest: {
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
bookingMode: "appointment" | "class";
|
bookingMode: "appointment" | "class";
|
||||||
@@ -925,6 +1050,15 @@ export interface components {
|
|||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
status: "confirmed" | "waitlisted";
|
status: "confirmed" | "waitlisted";
|
||||||
};
|
};
|
||||||
|
CreateClassSessionRequest: {
|
||||||
|
capacity?: number;
|
||||||
|
/** Format: uuid */
|
||||||
|
locationId?: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
startsAt: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
templateId: string;
|
||||||
|
};
|
||||||
Customer: {
|
Customer: {
|
||||||
bookingsCount?: number;
|
bookingsCount?: number;
|
||||||
/** Format: email */
|
/** Format: email */
|
||||||
@@ -1222,6 +1356,11 @@ export interface components {
|
|||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
type: "closure" | "holiday" | "maintenance";
|
type: "closure" | "holiday" | "maintenance";
|
||||||
};
|
};
|
||||||
|
UpsertClassTemplateRequest: {
|
||||||
|
capacity: number;
|
||||||
|
durationMinutes: number;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
UpsertCustomerRequest: {
|
UpsertCustomerRequest: {
|
||||||
/** Format: email */
|
/** Format: email */
|
||||||
email: string;
|
email: string;
|
||||||
@@ -1891,6 +2030,169 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
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: {
|
listCustomers: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
@@ -2380,6 +2682,74 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
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: {
|
listWorkingHours: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
+11
-2
@@ -51,7 +51,7 @@
|
|||||||
- protected tenant onboarding endpoint added with backend validation for slug, preset, locale, and timezone
|
- 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
|
- onboarding now creates the tenant, owner membership, and first default location
|
||||||
- dashboard can transition from empty bootstrap shell to a provisioned tenant state
|
- dashboard can transition from empty bootstrap shell to a provisioned tenant state
|
||||||
- post-onboarding entity screens for locations, services, staff, blocked days, and working hours now exist (Milestone 8); class/session template management is still missing
|
- 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
|
- `[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
|
- 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
|
- staff CRUD end-to-end with active toggle, optional location assignment, and `MaxStaff` entitlement enforcement; dashboard staff page with upgrade prompt
|
||||||
@@ -65,7 +65,16 @@
|
|||||||
- availability endpoint accepts `serviceId`, `staffId`, and `date` filters; default response is unchanged for the embeddable widget
|
- 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
|
- 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
|
- booking creation hardened: appointment requests must fit inside a matching availability rule, not overlap a blocked day, and staff/location IDs must belong to the tenant with staff active
|
||||||
- remaining: per-staff availability UI, class/session template management, live staging validation
|
- 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
|
||||||
|
|
||||||
## Overview
|
## 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.
|
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.
|
||||||
|
|||||||
Reference in new issue
Block a user