feat(catalog): add service and staff management end-to-end

Services and staff existed as schema + booking FK targets but had no
management surface. Adds full CRUD: repository (pgx + in-memory with
matching FK semantics), catalog service validation with tenant isolation,
MaxStaff entitlement enforcement, protected API routes, OpenAPI schemas,
and dashboard screens for both entities.

Dashboard summary now resolves staff names on bookings and computes
setup completion from real configured state. Booking modal selects from
the tenant's actual services; blocked days can be scoped per staff member.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-18 08:52:25 +02:00
1 parent 7a6223c07b
commit 09f780e7a6
21 files changed
+2422 -15

No files matched your search

+138
View File
@@ -541,6 +541,144 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
})
// ============================================
// CATALOG API - Services
// ============================================
protected.GET("/catalog/services", func(c *gin.Context) {
response, err := catalogService.ListServices(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.POST("/catalog/services", func(c *gin.Context) {
var request domain.CreateServiceRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.CreateService(c.Request.Context(), auth.PrincipalFromContext(c), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidInput):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, response)
})
protected.PUT("/catalog/services/:serviceID", func(c *gin.Context) {
var request domain.UpdateServiceRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.UpdateService(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("serviceID"), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrServiceNotFound):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidInput):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.DELETE("/catalog/services/:serviceID", func(c *gin.Context) {
err := catalogService.DeleteService(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("serviceID"))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) || errors.Is(err, catalog.ErrServiceNotFound) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
})
// ============================================
// CATALOG API - Staff
// ============================================
protected.GET("/catalog/staff", func(c *gin.Context) {
response, err := catalogService.ListStaff(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.POST("/catalog/staff", func(c *gin.Context) {
var request domain.CreateStaffRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.CreateStaff(c.Request.Context(), auth.PrincipalFromContext(c), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidInput):
status = http.StatusBadRequest
case errors.Is(err, catalog.ErrPlanLimitReached):
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, response)
})
protected.PUT("/catalog/staff/:staffID", func(c *gin.Context) {
var request domain.UpdateStaffRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.UpdateStaff(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("staffID"), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrStaffNotFound):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidInput):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.DELETE("/catalog/staff/:staffID", func(c *gin.Context) {
err := catalogService.DeleteStaff(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("staffID"))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) || errors.Is(err, catalog.ErrStaffNotFound) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
})
protected.POST("/catalog/bookings", func(c *gin.Context) {
var request domain.DashboardBookingRequest
if err := c.ShouldBindJSON(&request); err != nil {
+41 -1
View File
@@ -355,6 +355,12 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
locationNames[location.ID] = location.Name
}
}
staffNames := map[string]string{}
if staff, err := s.repo.ListStaffByTenant(ctx, membership.Tenant.ID); err == nil {
for _, member := range staff {
staffNames[member.ID] = member.DisplayName
}
}
toDomain := func(booking db.BookingRecord) domain.UpcomingBooking {
out := domain.UpcomingBooking{
ID: booking.ID,
@@ -372,6 +378,9 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
if booking.LocationID != nil {
out.Location = locationNames[*booking.LocationID]
}
if booking.StaffID != nil {
out.StaffName = staffNames[*booking.StaffID]
}
return out
}
@@ -408,6 +417,15 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
allBookings = append(allBookings, toDomain(booking))
}
brandName := membership.Tenant.Name
if brand, err := s.repo.GetBrandProfile(ctx, membership.Tenant.ID); err == nil && strings.TrimSpace(brand.Name) != "" {
brandName = brand.Name
}
scheduleCount := 0
if rules, err := s.repo.ListAvailabilityRulesByTenant(ctx, membership.Tenant.ID); err == nil {
scheduleCount = len(rules)
}
return domain.DashboardSummary{
TenantName: membership.Tenant.Name,
TenantSlug: membership.Tenant.Slug,
@@ -415,7 +433,7 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
Timezone: membership.Tenant.Timezone,
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
PublicBookingURL: "/book/" + membership.Tenant.Slug,
SetupCompletion: 100,
SetupCompletion: setupCompletion(brandName, len(locationNames), len(serviceNames), len(staffNames), scheduleCount),
KPIs: []domain.DashboardKPI{
{Code: "bookings_this_week", Label: "Bookings this week", Value: fmt.Sprintf("%d", metrics.BookingsCount)},
{Code: "cancellations", Label: "Cancellations", Value: fmt.Sprintf("%d", metrics.CancellationsCount)},
@@ -428,6 +446,28 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
}, nil
}
// setupCompletion scores onboarding readiness across five equal-weight checks:
// brand profile, location, service catalog, staff roster, and schedule rules.
func setupCompletion(brandName string, locationCount, serviceCount, staffCount, scheduleCount int) int {
score := 0
if strings.TrimSpace(brandName) != "" {
score += 20
}
if locationCount > 0 {
score += 20
}
if serviceCount > 0 {
score += 20
}
if staffCount > 0 {
score += 20
}
if scheduleCount > 0 {
score += 20
}
return score
}
func widgetSnippets(tenant db.TenantRecord) []domain.WidgetSnippet {
path := "/book/" + tenant.Slug
return []domain.WidgetSnippet{
+307
View File
@@ -18,6 +18,9 @@ var (
ErrBlockedDayNotFound = errors.New("blocked day not found")
ErrCustomerNotFound = errors.New("customer not found")
ErrBookingNotFound = errors.New("booking not found")
ErrServiceNotFound = errors.New("service not found")
ErrStaffNotFound = errors.New("staff member not found")
ErrInvalidInput = errors.New("invalid request")
ErrInvalidBooking = errors.New("invalid booking request")
ErrBookingConflict = errors.New("booking conflicts with an existing reservation")
ErrTenantNotFound = errors.New("tenant not found")
@@ -183,6 +186,310 @@ func (s *Service) DeleteLocation(ctx context.Context, principal domain.Principal
return s.repo.DeleteLocation(ctx, locationID)
}
// ============================================
// SERVICE CATALOG MANAGEMENT
// ============================================
func serviceToDomain(rec db.ServiceRecord) domain.Service {
return domain.Service{
ID: rec.ID,
TenantID: rec.TenantID,
Name: rec.Name,
DurationMinutes: rec.DurationMinutes,
BufferBeforeMinutes: rec.BufferBeforeMinutes,
BufferAfterMinutes: rec.BufferAfterMinutes,
PriceCents: rec.PriceCents,
CreatedAt: rec.CreatedAt,
}
}
func validateServiceValues(name string, duration, bufferBefore, bufferAfter, priceCents int) error {
if trimmed := len(strings.TrimSpace(name)); trimmed < 1 || trimmed > 120 {
return fmt.Errorf("%w: name must be between 1 and 120 characters", ErrInvalidInput)
}
if duration <= 0 || duration > 24*60 {
return fmt.Errorf("%w: durationMinutes must be between 1 and 1440", ErrInvalidInput)
}
if bufferBefore < 0 || bufferBefore > 480 {
return fmt.Errorf("%w: bufferBeforeMinutes must be between 0 and 480", ErrInvalidInput)
}
if bufferAfter < 0 || bufferAfter > 480 {
return fmt.Errorf("%w: bufferAfterMinutes must be between 0 and 480", ErrInvalidInput)
}
if priceCents < 0 {
return fmt.Errorf("%w: priceCents cannot be negative", ErrInvalidInput)
}
return nil
}
func (s *Service) ListServices(ctx context.Context, principal domain.Principal) ([]domain.Service, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
records, err := s.repo.ListServicesByTenant(ctx, membership.Tenant.ID)
if err != nil {
return nil, err
}
services := make([]domain.Service, len(records))
for i, rec := range records {
services[i] = serviceToDomain(rec)
}
return services, nil
}
func (s *Service) CreateService(ctx context.Context, principal domain.Principal, req domain.CreateServiceRequest) (domain.Service, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.Service{}, ErrTenantMembership
}
name := strings.TrimSpace(req.Name)
if err := validateServiceValues(name, req.DurationMinutes, req.BufferBeforeMinutes, req.BufferAfterMinutes, req.PriceCents); err != nil {
return domain.Service{}, err
}
rec, err := s.repo.CreateService(ctx, db.CreateServiceParams{
TenantID: membership.Tenant.ID,
Name: name,
DurationMinutes: req.DurationMinutes,
BufferBeforeMinutes: req.BufferBeforeMinutes,
BufferAfterMinutes: req.BufferAfterMinutes,
PriceCents: req.PriceCents,
})
if err != nil {
return domain.Service{}, err
}
return serviceToDomain(rec), nil
}
func (s *Service) UpdateService(ctx context.Context, principal domain.Principal, serviceID string, req domain.UpdateServiceRequest) (domain.Service, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.Service{}, ErrTenantMembership
}
existing, err := s.repo.GetServiceByID(ctx, serviceID)
if err != nil || existing.TenantID != membership.Tenant.ID {
return domain.Service{}, ErrServiceNotFound
}
params := db.UpdateServiceParams{}
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
params.Name = &name
}
if req.DurationMinutes != nil {
params.DurationMinutes = req.DurationMinutes
}
if req.BufferBeforeMinutes != nil {
params.BufferBeforeMinutes = req.BufferBeforeMinutes
}
if req.BufferAfterMinutes != nil {
params.BufferAfterMinutes = req.BufferAfterMinutes
}
if req.PriceCents != nil {
params.PriceCents = req.PriceCents
}
// Validate against the merged result so partial updates cannot produce an
// invalid record (e.g. setting duration to 0 while keeping the name).
merged := existing
if params.Name != nil {
merged.Name = *params.Name
}
if params.DurationMinutes != nil {
merged.DurationMinutes = *params.DurationMinutes
}
if params.BufferBeforeMinutes != nil {
merged.BufferBeforeMinutes = *params.BufferBeforeMinutes
}
if params.BufferAfterMinutes != nil {
merged.BufferAfterMinutes = *params.BufferAfterMinutes
}
if params.PriceCents != nil {
merged.PriceCents = *params.PriceCents
}
if err := validateServiceValues(merged.Name, merged.DurationMinutes, merged.BufferBeforeMinutes, merged.BufferAfterMinutes, merged.PriceCents); err != nil {
return domain.Service{}, err
}
rec, err := s.repo.UpdateService(ctx, serviceID, params)
if err != nil {
return domain.Service{}, err
}
return serviceToDomain(rec), nil
}
func (s *Service) DeleteService(ctx context.Context, principal domain.Principal, serviceID string) error {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
existing, err := s.repo.GetServiceByID(ctx, serviceID)
if err != nil || existing.TenantID != membership.Tenant.ID {
return ErrServiceNotFound
}
return s.repo.DeleteService(ctx, serviceID)
}
// ============================================
// STAFF MANAGEMENT
// ============================================
func staffToDomain(rec db.StaffRecord) domain.StaffMember {
return domain.StaffMember{
ID: rec.ID,
TenantID: rec.TenantID,
LocationID: rec.LocationID,
DisplayName: rec.DisplayName,
Active: rec.Active,
CreatedAt: rec.CreatedAt,
}
}
func validateStaffName(name string) error {
if trimmed := len(strings.TrimSpace(name)); trimmed < 1 || trimmed > 120 {
return fmt.Errorf("%w: displayName must be between 1 and 120 characters", ErrInvalidInput)
}
return nil
}
// validateStaffLocation returns the normalized location reference: nil when the
// input is empty, the id when it belongs to the tenant, or an error otherwise.
func (s *Service) validateStaffLocation(ctx context.Context, tenantID string, locationID *string) (*string, error) {
if locationID == nil {
return nil, nil
}
trimmed := strings.TrimSpace(*locationID)
if trimmed == "" {
return nil, nil
}
loc, err := s.repo.GetLocationByID(ctx, trimmed)
if err != nil || loc.TenantID != tenantID {
return nil, fmt.Errorf("%w: locationId does not belong to this tenant", ErrInvalidInput)
}
return &trimmed, nil
}
func (s *Service) ListStaff(ctx context.Context, principal domain.Principal) ([]domain.StaffMember, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
records, err := s.repo.ListStaffByTenant(ctx, membership.Tenant.ID)
if err != nil {
return nil, err
}
staff := make([]domain.StaffMember, len(records))
for i, rec := range records {
staff[i] = staffToDomain(rec)
}
return staff, nil
}
func (s *Service) CreateStaff(ctx context.Context, principal domain.Principal, req domain.CreateStaffRequest) (domain.StaffMember, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.StaffMember{}, ErrTenantMembership
}
if s.billingService != nil {
entitlements, err := s.billingService.GetEntitlements(ctx, membership.Tenant.ID)
if err == nil && entitlements.MaxStaff >= 0 {
staff, err := s.repo.ListStaffByTenant(ctx, membership.Tenant.ID)
if err == nil && len(staff) >= entitlements.MaxStaff {
return domain.StaffMember{}, fmt.Errorf("%w: staff limit reached (%d/%d). Upgrade your plan to add more staff.", ErrPlanLimitReached, len(staff), entitlements.MaxStaff)
}
}
}
name := strings.TrimSpace(req.DisplayName)
if err := validateStaffName(name); err != nil {
return domain.StaffMember{}, err
}
locationID, err := s.validateStaffLocation(ctx, membership.Tenant.ID, req.LocationID)
if err != nil {
return domain.StaffMember{}, err
}
active := true
if req.Active != nil {
active = *req.Active
}
rec, err := s.repo.CreateStaff(ctx, db.CreateStaffParams{
TenantID: membership.Tenant.ID,
LocationID: locationID,
DisplayName: name,
Active: active,
})
if err != nil {
return domain.StaffMember{}, err
}
return staffToDomain(rec), nil
}
func (s *Service) UpdateStaff(ctx context.Context, principal domain.Principal, staffID string, req domain.UpdateStaffRequest) (domain.StaffMember, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.StaffMember{}, ErrTenantMembership
}
existing, err := s.repo.GetStaffByID(ctx, staffID)
if err != nil || existing.TenantID != membership.Tenant.ID {
return domain.StaffMember{}, ErrStaffNotFound
}
params := db.UpdateStaffParams{Active: req.Active}
if req.DisplayName != nil {
name := strings.TrimSpace(*req.DisplayName)
if err := validateStaffName(name); err != nil {
return domain.StaffMember{}, err
}
params.DisplayName = &name
}
if req.LocationID != nil {
locationID, err := s.validateStaffLocation(ctx, membership.Tenant.ID, req.LocationID)
if err != nil {
return domain.StaffMember{}, err
}
// Keep the tri-state: an explicit empty string must clear the value.
if locationID == nil {
empty := ""
params.LocationID = &empty
} else {
params.LocationID = locationID
}
}
rec, err := s.repo.UpdateStaff(ctx, staffID, params)
if err != nil {
return domain.StaffMember{}, err
}
return staffToDomain(rec), nil
}
func (s *Service) DeleteStaff(ctx context.Context, principal domain.Principal, staffID string) error {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
existing, err := s.repo.GetStaffByID(ctx, staffID)
if err != nil || existing.TenantID != membership.Tenant.ID {
return ErrStaffNotFound
}
return s.repo.DeleteStaff(ctx, staffID)
}
// ============================================
// BLOCKED DAYS MANAGEMENT
// ============================================
@@ -0,0 +1,261 @@
package catalog
import (
"context"
"errors"
"testing"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
var owner = domain.Principal{Subject: "demo-owner", Email: "[email protected]", Role: "owner"}
type stubBilling struct {
entitlements domain.PlanEntitlements
}
func (s stubBilling) GetEntitlements(_ context.Context, _ string) (domain.PlanEntitlements, error) {
return s.entitlements, nil
}
func newCatalogService(repo db.Repository) *Service {
return NewService(repo, stubBilling{entitlements: domain.PlanEntitlements{MaxStaff: -1}}, nil)
}
func TestListServicesReturnsSeededService(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
services, err := svc.ListServices(context.Background(), owner)
if err != nil {
t.Fatalf("list services: %v", err)
}
if len(services) != 1 {
t.Fatalf("expected 1 seeded service, got %d", len(services))
}
if services[0].Name != "Signature treatment" {
t.Fatalf("unexpected service name %q", services[0].Name)
}
}
func TestServiceCRUDLifecycle(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
created, err := svc.CreateService(ctx, owner, domain.CreateServiceRequest{
Name: " Haircut ",
DurationMinutes: 45,
BufferBeforeMinutes: 5,
BufferAfterMinutes: 10,
PriceCents: 90000,
})
if err != nil {
t.Fatalf("create service: %v", err)
}
if created.Name != "Haircut" {
t.Fatalf("expected trimmed name, got %q", created.Name)
}
if created.TenantID == "" {
t.Fatal("expected tenant id on created service")
}
services, err := svc.ListServices(ctx, owner)
if err != nil {
t.Fatalf("list services: %v", err)
}
if len(services) != 2 {
t.Fatalf("expected 2 services, got %d", len(services))
}
zero := 0
if _, err := svc.UpdateService(ctx, owner, created.ID, domain.UpdateServiceRequest{DurationMinutes: &zero}); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput for zero duration, got %v", err)
}
name := "Deluxe haircut"
price := 110000
updated, err := svc.UpdateService(ctx, owner, created.ID, domain.UpdateServiceRequest{Name: &name, PriceCents: &price})
if err != nil {
t.Fatalf("update service: %v", err)
}
if updated.Name != "Deluxe haircut" || updated.PriceCents != 110000 {
t.Fatalf("unexpected update result %+v", updated)
}
if updated.DurationMinutes != 45 || updated.BufferAfterMinutes != 10 {
t.Fatalf("partial update clobbered untouched fields %+v", updated)
}
if err := svc.DeleteService(ctx, owner, created.ID); err != nil {
t.Fatalf("delete service: %v", err)
}
services, err = svc.ListServices(ctx, owner)
if err != nil {
t.Fatalf("list services: %v", err)
}
if len(services) != 1 {
t.Fatalf("expected 1 service after delete, got %d", len(services))
}
}
func TestServiceValidation(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
cases := []struct {
name string
req domain.CreateServiceRequest
}{
{"empty name", domain.CreateServiceRequest{Name: " ", DurationMinutes: 30}},
{"zero duration", domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 0}},
{"huge duration", domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 2000}},
{"negative buffer", domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 30, BufferBeforeMinutes: -1}},
{"negative price", domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 30, PriceCents: -5}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, err := svc.CreateService(ctx, owner, tc.req); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput, got %v", err)
}
})
}
}
func TestServiceNotFoundAndMembership(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
missingID := "00000000-0000-0000-0000-000000000001"
if _, err := svc.UpdateService(ctx, owner, missingID, domain.UpdateServiceRequest{}); !errors.Is(err, ErrServiceNotFound) {
t.Fatalf("expected ErrServiceNotFound, got %v", err)
}
if err := svc.DeleteService(ctx, owner, missingID); !errors.Is(err, ErrServiceNotFound) {
t.Fatalf("expected ErrServiceNotFound, got %v", err)
}
stranger := domain.Principal{Subject: "someone-else", Role: "owner"}
if _, err := svc.ListServices(ctx, stranger); !errors.Is(err, ErrTenantMembership) {
t.Fatalf("expected ErrTenantMembership, got %v", err)
}
if _, err := svc.CreateService(ctx, stranger, domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 30}); !errors.Is(err, ErrTenantMembership) {
t.Fatalf("expected ErrTenantMembership, got %v", err)
}
}
func TestStaffCRUDLifecycle(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
locations, err := svc.ListLocations(ctx, owner)
if err != nil || len(locations) == 0 {
t.Fatalf("expected seeded location, got %v err=%v", locations, err)
}
locationID := locations[0].ID
created, err := svc.CreateStaff(ctx, owner, domain.CreateStaffRequest{
DisplayName: " Anna Novak ",
LocationID: &locationID,
})
if err != nil {
t.Fatalf("create staff: %v", err)
}
if created.DisplayName != "Anna Novak" {
t.Fatalf("expected trimmed name, got %q", created.DisplayName)
}
if !created.Active {
t.Fatal("new staff member should default to active")
}
if created.LocationID == nil || *created.LocationID != locationID {
t.Fatalf("expected location assignment %s, got %+v", locationID, created.LocationID)
}
staff, err := svc.ListStaff(ctx, owner)
if err != nil {
t.Fatalf("list staff: %v", err)
}
if len(staff) != 2 {
t.Fatalf("expected seeded member + new member, got %d", len(staff))
}
inactive := false
cleared := ""
updated, err := svc.UpdateStaff(ctx, owner, created.ID, domain.UpdateStaffRequest{
Active: &inactive,
LocationID: &cleared,
})
if err != nil {
t.Fatalf("update staff: %v", err)
}
if updated.Active {
t.Fatal("expected staff member to be deactivated")
}
if updated.LocationID != nil {
t.Fatalf("expected cleared location, got %+v", updated.LocationID)
}
if updated.DisplayName != "Anna Novak" {
t.Fatalf("name should be untouched, got %q", updated.DisplayName)
}
if err := svc.DeleteStaff(ctx, owner, created.ID); err != nil {
t.Fatalf("delete staff: %v", err)
}
staff, err = svc.ListStaff(ctx, owner)
if err != nil {
t.Fatalf("list staff: %v", err)
}
if len(staff) != 1 {
t.Fatalf("expected 1 staff member after delete, got %d", len(staff))
}
}
func TestCreateStaffEnforcesPlanLimit(t *testing.T) {
repo := db.NewMemoryRepository()
// Demo tenant seeds one member; a limit of 1 must reject a second.
svc := NewService(repo, stubBilling{entitlements: domain.PlanEntitlements{MaxStaff: 1}}, nil)
_, err := svc.CreateStaff(context.Background(), owner, domain.CreateStaffRequest{DisplayName: "Second"})
if !errors.Is(err, ErrPlanLimitReached) {
t.Fatalf("expected ErrPlanLimitReached, got %v", err)
}
}
func TestCreateStaffRejectsForeignLocation(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
foreign := "00000000-0000-0000-0000-000000000099"
_, err := svc.CreateStaff(context.Background(), owner, domain.CreateStaffRequest{
DisplayName: "Anna",
LocationID: &foreign,
})
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput, got %v", err)
}
}
func TestStaffNotFoundAndValidation(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
if _, err := svc.CreateStaff(ctx, owner, domain.CreateStaffRequest{DisplayName: " "}); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput for blank name, got %v", err)
}
missingID := "00000000-0000-0000-0000-000000000001"
if _, err := svc.UpdateStaff(ctx, owner, missingID, domain.UpdateStaffRequest{}); !errors.Is(err, ErrStaffNotFound) {
t.Fatalf("expected ErrStaffNotFound, got %v", err)
}
if err := svc.DeleteStaff(ctx, owner, missingID); !errors.Is(err, ErrStaffNotFound) {
t.Fatalf("expected ErrStaffNotFound, got %v", err)
}
stranger := domain.Principal{Subject: "someone-else", Role: "staff"}
if _, err := svc.ListStaff(ctx, stranger); !errors.Is(err, ErrTenantMembership) {
t.Fatalf("expected ErrTenantMembership, got %v", err)
}
}
+217
View File
@@ -65,6 +65,19 @@ type Repository interface {
UpdateLocation(ctx context.Context, locationID string, params UpdateLocationParams) (LocationRecord, error)
DeleteLocation(ctx context.Context, locationID string) error
// Service Management
GetServiceByID(ctx context.Context, serviceID string) (ServiceRecord, error)
CreateService(ctx context.Context, params CreateServiceParams) (ServiceRecord, error)
UpdateService(ctx context.Context, serviceID string, params UpdateServiceParams) (ServiceRecord, error)
DeleteService(ctx context.Context, serviceID string) error
// Staff Management
ListStaffByTenant(ctx context.Context, tenantID string) ([]StaffRecord, error)
GetStaffByID(ctx context.Context, staffID string) (StaffRecord, error)
CreateStaff(ctx context.Context, params CreateStaffParams) (StaffRecord, error)
UpdateStaff(ctx context.Context, staffID string, params UpdateStaffParams) (StaffRecord, error)
DeleteStaff(ctx context.Context, staffID string) error
// Blocked Days / Availability Exceptions
ListBlockedDaysByTenant(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BlockedDayRecord, error)
CreateBlockedDay(ctx context.Context, params CreateBlockedDayParams) (BlockedDayRecord, error)
@@ -225,6 +238,50 @@ type ServiceRecord struct {
BufferBeforeMinutes int
BufferAfterMinutes int
PriceCents int
CreatedAt time.Time
}
type CreateServiceParams struct {
TenantID string
Name string
DurationMinutes int
BufferBeforeMinutes int
BufferAfterMinutes int
PriceCents int
}
// Pointer fields distinguish "not sent" (keep current value) from an explicit
// zero value, which is meaningful for buffers and price.
type UpdateServiceParams struct {
Name *string
DurationMinutes *int
BufferBeforeMinutes *int
BufferAfterMinutes *int
PriceCents *int
}
type StaffRecord struct {
ID string
TenantID string
LocationID *string
DisplayName string
Active bool
CreatedAt time.Time
}
type CreateStaffParams struct {
TenantID string
LocationID *string
DisplayName string
Active bool
}
// LocationID tri-state: nil keeps the current assignment, a pointer to an
// empty string clears it, a pointer to an id reassigns it.
type UpdateStaffParams struct {
DisplayName *string
LocationID *string
Active *bool
}
type AvailabilityRuleRecord struct {
@@ -670,6 +727,7 @@ type MemoryRepository struct {
billingSnapshot BillingSnapshotRecord
recordedEvents map[string]struct{}
locations []LocationRecord
staff []StaffRecord
blockedDays []BlockedDayRecord
customers []CustomerRecord
workingHours []WorkingHoursRecord
@@ -732,6 +790,17 @@ func NewMemoryRepository() *MemoryRepository {
BufferBeforeMinutes: 0,
BufferAfterMinutes: 15,
PriceCents: 120000,
CreatedAt: now,
},
},
staff: []StaffRecord{
{
ID: staffID,
TenantID: tenantID,
LocationID: &locationID,
DisplayName: "Demo Stylist",
Active: true,
CreatedAt: now,
},
},
rules: []AvailabilityRuleRecord{
@@ -857,6 +926,7 @@ func (r *MemoryRepository) CreateTenantForUser(_ context.Context, params CreateT
BufferAfterMinutes: maxInt(params.BufferAfterMinutes, 0),
PriceCents: 0,
}}
r.staff = nil
r.rules = nil
blocks := params.AvailabilityBlocks
if len(blocks) == 0 {
@@ -937,6 +1007,153 @@ func (r *MemoryRepository) ListServicesByTenant(_ context.Context, tenantID stri
return append([]ServiceRecord(nil), r.services...), nil
}
func (r *MemoryRepository) GetServiceByID(_ context.Context, serviceID string) (ServiceRecord, error) {
for _, service := range r.services {
if service.ID == serviceID {
return service, nil
}
}
return ServiceRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) CreateService(_ context.Context, params CreateServiceParams) (ServiceRecord, error) {
service := ServiceRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
Name: params.Name,
DurationMinutes: params.DurationMinutes,
BufferBeforeMinutes: params.BufferBeforeMinutes,
BufferAfterMinutes: params.BufferAfterMinutes,
PriceCents: params.PriceCents,
CreatedAt: time.Now().UTC(),
}
r.services = append(r.services, service)
return service, nil
}
func (r *MemoryRepository) UpdateService(_ context.Context, serviceID string, params UpdateServiceParams) (ServiceRecord, error) {
for i, service := range r.services {
if service.ID != serviceID {
continue
}
if params.Name != nil {
service.Name = *params.Name
}
if params.DurationMinutes != nil {
service.DurationMinutes = *params.DurationMinutes
}
if params.BufferBeforeMinutes != nil {
service.BufferBeforeMinutes = *params.BufferBeforeMinutes
}
if params.BufferAfterMinutes != nil {
service.BufferAfterMinutes = *params.BufferAfterMinutes
}
if params.PriceCents != nil {
service.PriceCents = *params.PriceCents
}
r.services[i] = service
return service, nil
}
return ServiceRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) DeleteService(_ context.Context, serviceID string) error {
for i, service := range r.services {
if service.ID != serviceID {
continue
}
r.services = append(r.services[:i], r.services[i+1:]...)
// Mirror ON DELETE SET NULL for bookings referencing the service.
for j, booking := range r.bookings {
if booking.ServiceID != nil && *booking.ServiceID == serviceID {
booking.ServiceID = nil
r.bookings[j] = booking
}
}
return nil
}
return pgx.ErrNoRows
}
func (r *MemoryRepository) ListStaffByTenant(_ context.Context, tenantID string) ([]StaffRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
return append([]StaffRecord(nil), r.staff...), nil
}
func (r *MemoryRepository) GetStaffByID(_ context.Context, staffID string) (StaffRecord, error) {
for _, member := range r.staff {
if member.ID == staffID {
return member, nil
}
}
return StaffRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) CreateStaff(_ context.Context, params CreateStaffParams) (StaffRecord, error) {
member := StaffRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
LocationID: params.LocationID,
DisplayName: params.DisplayName,
Active: params.Active,
CreatedAt: time.Now().UTC(),
}
r.staff = append(r.staff, member)
return member, nil
}
func (r *MemoryRepository) UpdateStaff(_ context.Context, staffID string, params UpdateStaffParams) (StaffRecord, error) {
for i, member := range r.staff {
if member.ID != staffID {
continue
}
if params.DisplayName != nil {
member.DisplayName = *params.DisplayName
}
if params.LocationID != nil {
locationID := strings.TrimSpace(*params.LocationID)
if locationID == "" {
member.LocationID = nil
} else {
member.LocationID = &locationID
}
}
if params.Active != nil {
member.Active = *params.Active
}
r.staff[i] = member
return member, nil
}
return StaffRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) DeleteStaff(_ context.Context, staffID string) error {
for i, member := range r.staff {
if member.ID != staffID {
continue
}
r.staff = append(r.staff[:i], r.staff[i+1:]...)
// Mirror the FK behaviour: bookings SET NULL, availability rules CASCADE.
for j, booking := range r.bookings {
if booking.StaffID != nil && *booking.StaffID == staffID {
booking.StaffID = nil
r.bookings[j] = booking
}
}
var rules []AvailabilityRuleRecord
for _, rule := range r.rules {
if rule.StaffID == nil || *rule.StaffID != staffID {
rules = append(rules, rule)
}
}
r.rules = rules
return nil
}
return pgx.ErrNoRows
}
func (r *MemoryRepository) ListAvailabilityRulesByTenant(_ context.Context, tenantID string) ([]AvailabilityRuleRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
+71 -1
View File
@@ -5,9 +5,11 @@ import (
"time"
)
const serviceColumns = "id, tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents, created_at"
func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents
SELECT `+serviceColumns+`
FROM services
WHERE tenant_id = $1
ORDER BY created_at ASC
@@ -28,6 +30,7 @@ func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string
&record.BufferBeforeMinutes,
&record.BufferAfterMinutes,
&record.PriceCents,
&record.CreatedAt,
); err != nil {
return nil, err
}
@@ -36,6 +39,73 @@ func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string
return records, rows.Err()
}
func (r *PGRepository) GetServiceByID(ctx context.Context, serviceID string) (ServiceRecord, error) {
var record ServiceRecord
err := r.pool.QueryRow(ctx, `
SELECT `+serviceColumns+`
FROM services
WHERE id = $1
`, serviceID).Scan(
&record.ID,
&record.TenantID,
&record.Name,
&record.DurationMinutes,
&record.BufferBeforeMinutes,
&record.BufferAfterMinutes,
&record.PriceCents,
&record.CreatedAt,
)
return record, err
}
func (r *PGRepository) CreateService(ctx context.Context, params CreateServiceParams) (ServiceRecord, error) {
var record ServiceRecord
err := r.pool.QueryRow(ctx, `
INSERT INTO services (tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING `+serviceColumns+`
`, params.TenantID, params.Name, params.DurationMinutes, params.BufferBeforeMinutes, params.BufferAfterMinutes, params.PriceCents).Scan(
&record.ID,
&record.TenantID,
&record.Name,
&record.DurationMinutes,
&record.BufferBeforeMinutes,
&record.BufferAfterMinutes,
&record.PriceCents,
&record.CreatedAt,
)
return record, err
}
func (r *PGRepository) UpdateService(ctx context.Context, serviceID string, params UpdateServiceParams) (ServiceRecord, error) {
var record ServiceRecord
err := r.pool.QueryRow(ctx, `
UPDATE services
SET name = COALESCE($2, name),
duration_minutes = COALESCE($3, duration_minutes),
buffer_before_minutes = COALESCE($4, buffer_before_minutes),
buffer_after_minutes = COALESCE($5, buffer_after_minutes),
price_cents = COALESCE($6, price_cents)
WHERE id = $1
RETURNING `+serviceColumns+`
`, serviceID, params.Name, params.DurationMinutes, params.BufferBeforeMinutes, params.BufferAfterMinutes, params.PriceCents).Scan(
&record.ID,
&record.TenantID,
&record.Name,
&record.DurationMinutes,
&record.BufferBeforeMinutes,
&record.BufferAfterMinutes,
&record.PriceCents,
&record.CreatedAt,
)
return record, err
}
func (r *PGRepository) DeleteService(ctx context.Context, serviceID string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM services WHERE id = $1`, serviceID)
return err
}
func (r *PGRepository) ListAvailabilityRulesByTenant(ctx context.Context, tenantID string) ([]AvailabilityRuleRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, staff_id, day_of_week, starts_local, ends_local
+79
View File
@@ -0,0 +1,79 @@
package db
import (
"context"
"strings"
)
const staffColumns = "id, tenant_id, location_id, display_name, active, created_at"
func (r *PGRepository) ListStaffByTenant(ctx context.Context, tenantID string) ([]StaffRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT `+staffColumns+`
FROM staff
WHERE tenant_id = $1
ORDER BY created_at ASC
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []StaffRecord
for rows.Next() {
var rec StaffRecord
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.LocationID, &rec.DisplayName, &rec.Active, &rec.CreatedAt); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) GetStaffByID(ctx context.Context, staffID string) (StaffRecord, error) {
var rec StaffRecord
err := r.pool.QueryRow(ctx, `
SELECT `+staffColumns+`
FROM staff
WHERE id = $1
`, staffID).Scan(&rec.ID, &rec.TenantID, &rec.LocationID, &rec.DisplayName, &rec.Active, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) CreateStaff(ctx context.Context, params CreateStaffParams) (StaffRecord, error) {
var rec StaffRecord
err := r.pool.QueryRow(ctx, `
INSERT INTO staff (tenant_id, location_id, display_name, active)
VALUES ($1, $2, $3, $4)
RETURNING `+staffColumns+`
`, params.TenantID, params.LocationID, params.DisplayName, params.Active).Scan(
&rec.ID, &rec.TenantID, &rec.LocationID, &rec.DisplayName, &rec.Active, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) UpdateStaff(ctx context.Context, staffID string, params UpdateStaffParams) (StaffRecord, error) {
// Tri-state location update: untouched when the request omits the field,
// NULL when it is explicitly empty, otherwise reassigned.
locationSet := params.LocationID != nil
var locationValue any
if locationSet && strings.TrimSpace(*params.LocationID) != "" {
locationValue = strings.TrimSpace(*params.LocationID)
}
var rec StaffRecord
err := r.pool.QueryRow(ctx, `
UPDATE staff
SET display_name = COALESCE($2, display_name),
location_id = CASE WHEN $3 THEN $4::uuid ELSE location_id END,
active = COALESCE($5, active)
WHERE id = $1
RETURNING `+staffColumns+`
`, staffID, params.DisplayName, locationSet, locationValue, params.Active).Scan(
&rec.ID, &rec.TenantID, &rec.LocationID, &rec.DisplayName, &rec.Active, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) DeleteStaff(ctx context.Context, staffID string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM staff WHERE id = $1`, staffID)
return err
}
+56
View File
@@ -267,6 +267,62 @@ type UpdateLocationRequest struct {
Capacity int `json:"capacity,omitempty"`
}
// ============================================
// SERVICE MODELS
// ============================================
type Service struct {
ID string `json:"id"`
TenantID string `json:"tenantId"`
Name string `json:"name"`
DurationMinutes int `json:"durationMinutes"`
BufferBeforeMinutes int `json:"bufferBeforeMinutes"`
BufferAfterMinutes int `json:"bufferAfterMinutes"`
PriceCents int `json:"priceCents"`
CreatedAt time.Time `json:"createdAt"`
}
type CreateServiceRequest struct {
Name string `json:"name" binding:"required"`
DurationMinutes int `json:"durationMinutes" binding:"required"`
BufferBeforeMinutes int `json:"bufferBeforeMinutes,omitempty"`
BufferAfterMinutes int `json:"bufferAfterMinutes,omitempty"`
PriceCents int `json:"priceCents,omitempty"`
}
type UpdateServiceRequest struct {
Name *string `json:"name,omitempty"`
DurationMinutes *int `json:"durationMinutes,omitempty"`
BufferBeforeMinutes *int `json:"bufferBeforeMinutes,omitempty"`
BufferAfterMinutes *int `json:"bufferAfterMinutes,omitempty"`
PriceCents *int `json:"priceCents,omitempty"`
}
// ============================================
// STAFF MODELS
// ============================================
type StaffMember struct {
ID string `json:"id"`
TenantID string `json:"tenantId"`
LocationID *string `json:"locationId,omitempty"`
DisplayName string `json:"displayName"`
Active bool `json:"active"`
CreatedAt time.Time `json:"createdAt"`
}
type CreateStaffRequest struct {
DisplayName string `json:"displayName" binding:"required"`
LocationID *string `json:"locationId,omitempty"`
Active *bool `json:"active,omitempty"` // defaults to true
}
type UpdateStaffRequest struct {
DisplayName *string `json:"displayName,omitempty"`
LocationID *string `json:"locationId,omitempty"` // empty string clears the assignment
Active *bool `json:"active,omitempty"`
}
// ============================================
// BLOCKED DAYS / AVAILABILITY EXCEPTION MODELS
// ============================================