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
+2069
-58
No files matched your search
@@ -11,21 +11,25 @@ import (
|
||||
"bookra/apps/backend/internal/bookings"
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrLocationNotFound = errors.New("location not found")
|
||||
ErrBlockedDayNotFound = errors.New("blocked day not found")
|
||||
ErrCustomerNotFound = errors.New("customer not found")
|
||||
ErrBookingNotFound = errors.New("booking not found")
|
||||
ErrServiceNotFound = errors.New("service not found")
|
||||
ErrStaffNotFound = errors.New("staff member not found")
|
||||
ErrInvalidInput = errors.New("invalid request")
|
||||
ErrInvalidBooking = errors.New("invalid booking request")
|
||||
ErrBookingConflict = errors.New("booking conflicts with an existing reservation")
|
||||
ErrTenantNotFound = errors.New("tenant not found")
|
||||
ErrTenantMembership = errors.New("tenant membership not found")
|
||||
ErrPlanLimitReached = errors.New("plan limit reached")
|
||||
ErrLocationNotFound = errors.New("location not found")
|
||||
ErrBlockedDayNotFound = errors.New("blocked day not found")
|
||||
ErrCustomerNotFound = errors.New("customer not found")
|
||||
ErrBookingNotFound = errors.New("booking not found")
|
||||
ErrServiceNotFound = errors.New("service not found")
|
||||
ErrStaffNotFound = errors.New("staff member not found")
|
||||
ErrInvalidInput = errors.New("invalid request")
|
||||
ErrInvalidBooking = errors.New("invalid booking request")
|
||||
ErrBookingConflict = errors.New("booking conflicts with an existing reservation")
|
||||
ErrTenantNotFound = errors.New("tenant not found")
|
||||
ErrTenantMembership = errors.New("tenant membership not found")
|
||||
ErrPlanLimitReached = errors.New("plan limit reached")
|
||||
ErrClassTemplateNotFound = errors.New("class template not found")
|
||||
ErrClassSessionNotFound = errors.New("class session not found")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -786,18 +790,20 @@ func (s *Service) ListWorkingHours(ctx context.Context, principal domain.Princip
|
||||
return nil, ErrTenantMembership
|
||||
}
|
||||
|
||||
records, err := s.repo.ListWorkingHoursByTenant(ctx, membership.Tenant.ID)
|
||||
records, err := s.repo.ListWorkingHoursByTenant(ctx, membership.Tenant.ID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hours := make([]domain.WorkingHours, len(records))
|
||||
for i, rec := range records {
|
||||
open := hhmm(rec.StartsLocal)
|
||||
close_ := hhmm(rec.EndsLocal)
|
||||
hours[i] = domain.WorkingHours{
|
||||
DayOfWeek: rec.DayOfWeek,
|
||||
Open: rec.StartsLocal,
|
||||
Close: rec.EndsLocal,
|
||||
IsOpen: rec.StartsLocal != "" && rec.EndsLocal != "",
|
||||
Open: open,
|
||||
Close: close_,
|
||||
IsOpen: open != "" && close_ != "",
|
||||
}
|
||||
}
|
||||
return hours, nil
|
||||
@@ -823,7 +829,81 @@ func (s *Service) UpdateWorkingHours(ctx context.Context, principal domain.Princ
|
||||
params.EndsLocal = &req.Close
|
||||
}
|
||||
|
||||
return s.repo.UpdateWorkingHours(ctx, membership.Tenant.ID, dayOfWeek, params)
|
||||
return s.repo.UpdateWorkingHours(ctx, membership.Tenant.ID, nil, dayOfWeek, params)
|
||||
}
|
||||
|
||||
// ListStaffWorkingHours returns the per-person schedule for a tenant-owned
|
||||
// staff member.
|
||||
func (s *Service) ListStaffWorkingHours(ctx context.Context, principal domain.Principal, staffID string) ([]domain.WorkingHours, error) {
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return nil, ErrTenantMembership
|
||||
}
|
||||
if err := s.requireTenantStaff(ctx, membership.Tenant.ID, staffID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
records, err := s.repo.ListWorkingHoursByTenant(ctx, membership.Tenant.ID, &staffID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hours := make([]domain.WorkingHours, len(records))
|
||||
for i, rec := range records {
|
||||
open := hhmm(rec.StartsLocal)
|
||||
close_ := hhmm(rec.EndsLocal)
|
||||
hours[i] = domain.WorkingHours{
|
||||
DayOfWeek: rec.DayOfWeek,
|
||||
Open: open,
|
||||
Close: close_,
|
||||
IsOpen: open != "" && close_ != "",
|
||||
}
|
||||
}
|
||||
return hours, nil
|
||||
}
|
||||
|
||||
// UpdateStaffWorkingHours replaces a staff member's availability rule for one
|
||||
// weekday. The rule directly drives public slot generation for that person.
|
||||
func (s *Service) UpdateStaffWorkingHours(ctx context.Context, principal domain.Principal, staffID string, dayOfWeek int, req domain.UpdateWorkingHoursRequest) error {
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return ErrTenantMembership
|
||||
}
|
||||
if err := s.requireTenantStaff(ctx, membership.Tenant.ID, staffID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := db.UpdateWorkingHoursParams{IsOpen: req.IsOpen}
|
||||
if req.Open != "" {
|
||||
if _, err := time.Parse("15:04", req.Open); err != nil {
|
||||
return fmt.Errorf("%w: open must be HH:MM", ErrInvalidBooking)
|
||||
}
|
||||
params.StartsLocal = &req.Open
|
||||
}
|
||||
if req.Close != "" {
|
||||
if _, err := time.Parse("15:04", req.Close); err != nil {
|
||||
return fmt.Errorf("%w: close must be HH:MM", ErrInvalidBooking)
|
||||
}
|
||||
params.EndsLocal = &req.Close
|
||||
}
|
||||
|
||||
return s.repo.UpdateWorkingHours(ctx, membership.Tenant.ID, &staffID, dayOfWeek, params)
|
||||
}
|
||||
|
||||
// requireTenantStaff verifies the staff record exists and belongs to the
|
||||
// tenant, returning a tenant-safe not-found error otherwise.
|
||||
func (s *Service) requireTenantStaff(ctx context.Context, tenantID, staffID string) error {
|
||||
staff, err := s.repo.GetStaffByID(ctx, staffID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrStaffNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if staff.TenantID != tenantID {
|
||||
return ErrStaffNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateBooking creates a manual booking from the dashboard. The service name
|
||||
@@ -930,3 +1010,256 @@ func (s *Service) CreateBooking(ctx context.Context, principal domain.Principal,
|
||||
Notes: strings.TrimSpace(req.Notes),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CLASS TEMPLATE & SESSION MANAGEMENT
|
||||
// ============================================
|
||||
|
||||
func (s *Service) ListClassTemplates(ctx context.Context, principal domain.Principal) ([]domain.ClassTemplate, error) {
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return nil, ErrTenantMembership
|
||||
}
|
||||
|
||||
records, err := s.repo.ListClassTemplatesByTenant(ctx, membership.Tenant.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
templates := make([]domain.ClassTemplate, len(records))
|
||||
for i, rec := range records {
|
||||
templates[i] = domain.ClassTemplate{
|
||||
ID: rec.ID,
|
||||
TenantID: rec.TenantID,
|
||||
Title: rec.Title,
|
||||
DurationMinutes: rec.DurationMinutes,
|
||||
Capacity: rec.Capacity,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
}
|
||||
}
|
||||
return templates, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateClassTemplate(ctx context.Context, principal domain.Principal, req domain.UpsertClassTemplateRequest) (domain.ClassTemplate, error) {
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return domain.ClassTemplate{}, ErrTenantMembership
|
||||
}
|
||||
|
||||
title := 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"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/bookings"
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
)
|
||||
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
var owner = domain.Principal{Subject: "demo-owner", Email: "[email protected]", Role: "owner"}
|
||||
|
||||
type stubBilling struct {
|
||||
@@ -259,3 +263,287 @@ func TestStaffNotFoundAndValidation(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user