diff --git a/apps/backend/internal/api/server.go b/apps/backend/internal/api/server.go index 1bceffd..6831571 100644 --- a/apps/backend/internal/api/server.go +++ b/apps/backend/internal/api/server.go @@ -904,6 +904,140 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { c.JSON(http.StatusOK, gin.H{"status": "updated"}) }) + // Staff-scoped working hours (per-person availability rules) + protected.GET("/catalog/staff/:staffID/working-hours", func(c *gin.Context) { + response, err := catalogService.ListStaffWorkingHours(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("staffID")) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrStaffNotFound): + status = http.StatusNotFound + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, response) + }) + protected.PUT("/catalog/staff/:staffID/working-hours/:dayOfWeek", func(c *gin.Context) { + dayOfWeek, err := strconv.Atoi(c.Param("dayOfWeek")) + if err != nil || dayOfWeek < 0 || dayOfWeek > 6 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_day_of_week"}) + return + } + var request domain.UpdateWorkingHoursRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) + return + } + err = catalogService.UpdateStaffWorkingHours(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("staffID"), dayOfWeek, request) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrStaffNotFound): + status = http.StatusNotFound + case errors.Is(err, catalog.ErrInvalidBooking): + status = http.StatusBadRequest + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "updated"}) + }) + + // ============================================ + // CATALOG API - Classes + // ============================================ + protected.GET("/catalog/class-templates", func(c *gin.Context) { + response, err := catalogService.ListClassTemplates(c.Request.Context(), auth.PrincipalFromContext(c)) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, catalog.ErrTenantMembership) { + status = http.StatusNotFound + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, response) + }) + protected.POST("/catalog/class-templates", func(c *gin.Context) { + var request domain.UpsertClassTemplateRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) + return + } + response, err := catalogService.CreateClassTemplate(c.Request.Context(), auth.PrincipalFromContext(c), request) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, catalog.ErrTenantMembership): + status = http.StatusNotFound + case errors.Is(err, catalog.ErrInvalidInput): + status = http.StatusBadRequest + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, response) + }) + protected.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) // ============================================ diff --git a/apps/backend/internal/catalog/service.go b/apps/backend/internal/catalog/service.go index a4624c9..9c1282b 100644 --- a/apps/backend/internal/catalog/service.go +++ b/apps/backend/internal/catalog/service.go @@ -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 +} diff --git a/apps/backend/internal/catalog/service_test.go b/apps/backend/internal/catalog/service_test.go index 2cc3986..b95a125 100644 --- a/apps/backend/internal/catalog/service_test.go +++ b/apps/backend/internal/catalog/service_test.go @@ -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: "demo@bookra.dev", 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: "kate@example.com", + 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") + } +} diff --git a/apps/backend/internal/db/classes.go b/apps/backend/internal/db/classes.go new file mode 100644 index 0000000..fbedebd --- /dev/null +++ b/apps/backend/internal/db/classes.go @@ -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 +} diff --git a/apps/backend/internal/db/locations.go b/apps/backend/internal/db/locations.go index 47e523a..0fa4c10 100644 --- a/apps/backend/internal/db/locations.go +++ b/apps/backend/internal/db/locations.go @@ -122,13 +122,24 @@ func (r *PGRepository) DeleteBlockedDay(ctx context.Context, blockedDayID string return err } -func (r *PGRepository) ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error) { - rows, err := r.pool.Query(ctx, ` +func (r *PGRepository) ListWorkingHoursByTenant(ctx context.Context, tenantID string, staffID *string) ([]WorkingHoursRecord, error) { + query := ` SELECT tenant_id, staff_id, day_of_week, starts_local::text, ends_local::text FROM availability_rules WHERE tenant_id = $1 AND staff_id IS NULL ORDER BY day_of_week - `, tenantID) + ` + args := []any{tenantID} + if staffID != nil { + query = ` + SELECT tenant_id, staff_id, day_of_week, starts_local::text, ends_local::text + FROM availability_rules + WHERE tenant_id = $1 AND staff_id = $2 + ORDER BY day_of_week + ` + args = append(args, *staffID) + } + rows, err := r.pool.Query(ctx, query, args...) if err != nil { return nil, err } @@ -145,12 +156,22 @@ func (r *PGRepository) ListWorkingHoursByTenant(ctx context.Context, tenantID st return records, rows.Err() } -func (r *PGRepository) UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error { - if params.IsOpen != nil && !*params.IsOpen { - _, err := r.pool.Exec(ctx, ` +func (r *PGRepository) UpdateWorkingHours(ctx context.Context, tenantID string, staffID *string, dayOfWeek int, params UpdateWorkingHoursParams) error { + deleteQuery := ` + DELETE FROM availability_rules + WHERE tenant_id = $1 AND day_of_week = $2 AND staff_id IS NULL + ` + args := []any{tenantID, dayOfWeek} + if staffID != nil { + deleteQuery = ` DELETE FROM availability_rules - WHERE tenant_id = $1 AND day_of_week = $2 AND staff_id IS NULL - `, tenantID, dayOfWeek) + WHERE tenant_id = $1 AND day_of_week = $2 AND staff_id = $3 + ` + args = append(args, *staffID) + } + + if params.IsOpen != nil && !*params.IsOpen { + _, err := r.pool.Exec(ctx, deleteQuery, args...) return err } @@ -159,17 +180,14 @@ func (r *PGRepository) UpdateWorkingHours(ctx context.Context, tenantID string, } // availability_rules has no unique constraint on (tenant, day): replace the - // tenant-level row so repeated updates stay idempotent. - if _, err := r.pool.Exec(ctx, ` - DELETE FROM availability_rules - WHERE tenant_id = $1 AND day_of_week = $2 AND staff_id IS NULL - `, tenantID, dayOfWeek); err != nil { + // scoped row so repeated updates stay idempotent. + if _, err := r.pool.Exec(ctx, deleteQuery, args...); err != nil { return err } _, err := r.pool.Exec(ctx, ` INSERT INTO availability_rules (id, tenant_id, staff_id, day_of_week, starts_local, ends_local) - VALUES (gen_random_uuid(), $1, NULL, $2, $3::time, $4::time) - `, tenantID, dayOfWeek, *params.StartsLocal, *params.EndsLocal) + VALUES (gen_random_uuid(), $1, $2, $3, $4::time, $5::time) + `, tenantID, staffID, dayOfWeek, *params.StartsLocal, *params.EndsLocal) return err } diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index fc164f8..b00cc5d 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -26,6 +26,13 @@ type Repository interface { ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) ListAvailabilityRulesByTenant(ctx context.Context, tenantID string) ([]AvailabilityRuleRecord, error) ListClassSessionsByTenant(ctx context.Context, tenantID string, from time.Time, limit int) ([]ClassSessionRecord, error) + GetClassSessionByID(ctx context.Context, sessionID string) (ClassSessionRecord, error) + CreateClassSession(ctx context.Context, params CreateClassSessionParams) (ClassSessionRecord, error) + DeleteClassSession(ctx context.Context, sessionID string) error + ListClassTemplatesByTenant(ctx context.Context, tenantID string) ([]ClassTemplateRecord, error) + GetClassTemplateByID(ctx context.Context, templateID string) (ClassTemplateRecord, error) + CreateClassTemplate(ctx context.Context, params CreateClassTemplateParams) (ClassTemplateRecord, error) + DeleteClassTemplate(ctx context.Context, templateID string) error ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error) AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error @@ -100,8 +107,8 @@ type Repository interface { RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error // Working Hours - ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error) - UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error + ListWorkingHoursByTenant(ctx context.Context, tenantID string, staffID *string) ([]WorkingHoursRecord, error) + UpdateWorkingHours(ctx context.Context, tenantID string, staffID *string, dayOfWeek int, params UpdateWorkingHoursParams) error // Email templates ListEmailTemplates(ctx context.Context, tenantID string) ([]EmailTemplateRecord, error) @@ -293,6 +300,22 @@ type AvailabilityRuleRecord struct { EndsLocal string } +type ClassTemplateRecord struct { + ID string + TenantID string + Title string + DurationMinutes int + Capacity int + CreatedAt time.Time +} + +type CreateClassTemplateParams struct { + TenantID string + Title string + DurationMinutes int + Capacity int +} + type ClassSessionRecord struct { ID string TenantID string @@ -304,6 +327,15 @@ type ClassSessionRecord struct { Capacity int32 } +type CreateClassSessionParams struct { + TenantID string + TemplateID string + LocationID *string + StartsAt time.Time + EndsAt time.Time + Capacity int32 +} + type BookingRecord struct { ID string TenantID string @@ -719,6 +751,7 @@ type MemoryRepository struct { brand BrandProfileRecord services []ServiceRecord rules []AvailabilityRuleRecord + classTemplates []ClassTemplateRecord classSessions []ClassSessionRecord bookings []BookingRecord 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: 5, StartsLocal: "09:00:00", EndsLocal: "17:00:00"}, }, + classTemplates: []ClassTemplateRecord{ + {ID: templateID, TenantID: tenantID, Title: "Small group mobility class", DurationMinutes: 60, Capacity: 4, CreatedAt: now}, + }, classSessions: []ClassSessionRecord{ { ID: sessionID, @@ -1179,6 +1215,97 @@ func (r *MemoryRepository) ListClassSessionsByTenant(_ context.Context, tenantID return out, nil } +func (r *MemoryRepository) GetClassSessionByID(_ context.Context, sessionID string) (ClassSessionRecord, error) { + for _, session := range r.classSessions { + if session.ID == sessionID { + return session, nil + } + } + return ClassSessionRecord{}, pgx.ErrNoRows +} + +func (r *MemoryRepository) CreateClassSession(_ context.Context, params CreateClassSessionParams) (ClassSessionRecord, error) { + record := ClassSessionRecord{ + ID: uuid.NewString(), + TenantID: params.TenantID, + TemplateID: params.TemplateID, + LocationID: params.LocationID, + StartsAt: params.StartsAt, + EndsAt: params.EndsAt, + Capacity: params.Capacity, + } + for _, template := range r.classTemplates { + if template.ID == params.TemplateID { + record.Title = template.Title + break + } + } + r.classSessions = append(r.classSessions, record) + return record, nil +} + +func (r *MemoryRepository) DeleteClassSession(_ context.Context, sessionID string) error { + for i, session := range r.classSessions { + if session.ID == sessionID { + r.classSessions = append(r.classSessions[:i], r.classSessions[i+1:]...) + for j := range r.bookings { + if r.bookings[j].ClassSessionID != nil && *r.bookings[j].ClassSessionID == sessionID { + r.bookings[j].ClassSessionID = nil + } + } + return nil + } + } + return pgx.ErrNoRows +} + +func (r *MemoryRepository) ListClassTemplatesByTenant(_ context.Context, tenantID string) ([]ClassTemplateRecord, error) { + if tenantID != r.tenant.ID { + return nil, nil + } + return append([]ClassTemplateRecord(nil), r.classTemplates...), nil +} + +func (r *MemoryRepository) GetClassTemplateByID(_ context.Context, templateID string) (ClassTemplateRecord, error) { + for _, template := range r.classTemplates { + if template.ID == templateID { + return template, nil + } + } + return ClassTemplateRecord{}, pgx.ErrNoRows +} + +func (r *MemoryRepository) CreateClassTemplate(_ context.Context, params CreateClassTemplateParams) (ClassTemplateRecord, error) { + record := ClassTemplateRecord{ + ID: uuid.NewString(), + TenantID: params.TenantID, + Title: params.Title, + DurationMinutes: params.DurationMinutes, + Capacity: params.Capacity, + CreatedAt: time.Now().UTC(), + } + r.classTemplates = append(r.classTemplates, record) + return record, nil +} + +func (r *MemoryRepository) DeleteClassTemplate(_ context.Context, templateID string) error { + for i, template := range r.classTemplates { + if template.ID == templateID { + r.classTemplates = append(r.classTemplates[:i], r.classTemplates[i+1:]...) + // class_sessions.template_id has ON DELETE CASCADE + var remaining []ClassSessionRecord + for _, session := range r.classSessions { + if session.TemplateID != templateID { + remaining = append(remaining, session) + } + } + r.classSessions = remaining + return nil + } + } + return pgx.ErrNoRows +} + func (r *MemoryRepository) ListBookingsByTenantBetween(_ context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) { var out []BookingRecord for _, booking := range r.bookings { @@ -1699,29 +1826,114 @@ func (r *MemoryRepository) UpsertEmailTemplate(_ context.Context, params EmailTe // WORKING HOURS METHODS - MEMORY REPOSITORY // ============================================ -func (r *MemoryRepository) ListWorkingHoursByTenant(_ context.Context, tenantID string) ([]WorkingHoursRecord, error) { +// ListWorkingHoursByTenant returns tenant-wide hours from the dedicated +// working-hours store, and staff-scoped hours from the availability rules that +// slot generation consumes — mirroring PostgreSQL where both live in +// availability_rules. +func (r *MemoryRepository) ListWorkingHoursByTenant(_ context.Context, tenantID string, staffID *string) ([]WorkingHoursRecord, error) { if tenantID != r.tenant.ID { return nil, pgx.ErrNoRows } - return r.workingHours, nil + var out []WorkingHoursRecord + if staffID == nil { + for _, wh := range r.workingHours { + if wh.TenantID == tenantID && wh.StaffID == nil { + out = append(out, wh) + } + } + return out, nil + } + for _, rule := range r.rules { + if rule.TenantID == tenantID && rule.StaffID != nil && *rule.StaffID == *staffID { + out = append(out, WorkingHoursRecord{ + TenantID: rule.TenantID, + StaffID: rule.StaffID, + DayOfWeek: rule.DayOfWeek, + StartsLocal: rule.StartsLocal, + EndsLocal: rule.EndsLocal, + }) + } + } + return out, nil } -func (r *MemoryRepository) UpdateWorkingHours(_ context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error { +func staffIDPtrEqual(a, b *string) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} + +func (r *MemoryRepository) UpdateWorkingHours(_ context.Context, tenantID string, staffID *string, dayOfWeek int, params UpdateWorkingHoursParams) error { if tenantID != r.tenant.ID { return pgx.ErrNoRows } + if staffID != nil { + return r.updateStaffWorkingHours(tenantID, staffID, dayOfWeek, params) + } + if params.IsOpen != nil && !*params.IsOpen { + filtered := r.workingHours[:0] + for _, wh := range r.workingHours { + if wh.TenantID == tenantID && wh.DayOfWeek == dayOfWeek && wh.StaffID == nil { + continue + } + filtered = append(filtered, wh) + } + r.workingHours = filtered + return nil + } + if params.StartsLocal == nil || params.EndsLocal == nil { + return nil + } for i, wh := range r.workingHours { - if wh.TenantID == tenantID && wh.DayOfWeek == dayOfWeek { - if params.StartsLocal != nil { - r.workingHours[i].StartsLocal = *params.StartsLocal - } - if params.EndsLocal != nil { - r.workingHours[i].EndsLocal = *params.EndsLocal - } + if wh.TenantID == tenantID && wh.DayOfWeek == dayOfWeek && wh.StaffID == nil { + r.workingHours[i].StartsLocal = *params.StartsLocal + r.workingHours[i].EndsLocal = *params.EndsLocal return nil } } - return pgx.ErrNoRows + r.workingHours = append(r.workingHours, WorkingHoursRecord{ + TenantID: tenantID, + DayOfWeek: dayOfWeek, + StartsLocal: *params.StartsLocal, + EndsLocal: *params.EndsLocal, + }) + return nil +} + +// updateStaffWorkingHours replaces the staff-scoped availability rule for a +// day so edits actually change generated slots, matching PostgreSQL semantics. +func (r *MemoryRepository) updateStaffWorkingHours(tenantID string, staffID *string, dayOfWeek int, params UpdateWorkingHoursParams) error { + filtered := r.rules[:0] + for _, rule := range r.rules { + if rule.TenantID == tenantID && rule.DayOfWeek == dayOfWeek && rule.StaffID != nil && *rule.StaffID == *staffID { + continue + } + filtered = append(filtered, rule) + } + r.rules = filtered + if params.IsOpen != nil && !*params.IsOpen { + return nil + } + if params.StartsLocal == nil || params.EndsLocal == nil { + return nil + } + r.rules = append(r.rules, AvailabilityRuleRecord{ + ID: uuid.NewString(), + TenantID: tenantID, + StaffID: staffID, + DayOfWeek: dayOfWeek, + StartsLocal: normalizeLocalTime(*params.StartsLocal), + EndsLocal: normalizeLocalTime(*params.EndsLocal), + }) + return nil +} + +func normalizeLocalTime(value string) string { + if len(value) == 5 { + return value + ":00" + } + return value } // Auth methods for MemoryRepository diff --git a/apps/backend/internal/domain/models.go b/apps/backend/internal/domain/models.go index de6ccaf..245a0b3 100644 --- a/apps/backend/internal/domain/models.go +++ b/apps/backend/internal/domain/models.go @@ -368,6 +368,45 @@ type UpdateBlockedDayRequest struct { Type string `json:"type,omitempty"` } +// ============================================ +// CLASS MODELS +// ============================================ + +type ClassTemplate struct { + ID string `json:"id"` + TenantID string `json:"tenantId"` + Title string `json:"title"` + DurationMinutes int `json:"durationMinutes"` + Capacity int `json:"capacity"` + CreatedAt time.Time `json:"createdAt"` +} + +type UpsertClassTemplateRequest struct { + Title string `json:"title" binding:"required"` + DurationMinutes int `json:"durationMinutes" binding:"required"` + Capacity int `json:"capacity" binding:"required"` +} + +type ClassSession struct { + ID string `json:"id"` + TenantID string `json:"tenantId"` + TemplateID string `json:"templateId"` + TemplateTitle string `json:"templateTitle"` + LocationID *string `json:"locationId,omitempty"` + LocationName string `json:"locationName,omitempty"` + StartsAt time.Time `json:"startsAt"` + EndsAt time.Time `json:"endsAt"` + Capacity int `json:"capacity"` + BookedCount int `json:"bookedCount"` +} + +type CreateClassSessionRequest struct { + TemplateID string `json:"templateId" binding:"required"` + LocationID *string `json:"locationId,omitempty"` + StartsAt string `json:"startsAt" binding:"required"` // RFC3339 + Capacity *int `json:"capacity,omitempty"` +} + // ============================================ // CUSTOMER MODELS // ============================================ diff --git a/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml index 5c00bfb..2fb2f5b 100644 --- a/apps/backend/openapi/bookra.openapi.yaml +++ b/apps/backend/openapi/bookra.openapi.yaml @@ -986,6 +986,171 @@ paths: description: Working hours updated "400": description: Invalid request + /v1/catalog/staff/{staffID}/working-hours: + get: + tags: [Catalog] + operationId: listStaffWorkingHours + security: + - bearerAuth: [] + parameters: + - in: path + name: staffID + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Staff member's weekly working hours + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/WorkingHours" + "404": + description: Staff member not found + /v1/catalog/staff/{staffID}/working-hours/{dayOfWeek}: + put: + tags: [Catalog] + operationId: updateStaffWorkingHours + security: + - bearerAuth: [] + parameters: + - in: path + name: staffID + required: true + schema: + type: string + format: uuid + - in: path + name: dayOfWeek + required: true + schema: + type: integer + minimum: 0 + maximum: 6 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateWorkingHoursRequest" + responses: + "200": + description: Staff working hours updated + "400": + description: Invalid request + "404": + description: Staff member not found + /v1/catalog/class-templates: + get: + tags: [Catalog] + operationId: listClassTemplates + security: + - bearerAuth: [] + responses: + "200": + description: Class templates + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ClassTemplate" + post: + tags: [Catalog] + operationId: createClassTemplate + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertClassTemplateRequest" + responses: + "201": + description: Class template created + content: + application/json: + schema: + $ref: "#/components/schemas/ClassTemplate" + "400": + description: Invalid request + /v1/catalog/class-templates/{templateID}: + delete: + tags: [Catalog] + operationId: deleteClassTemplate + security: + - bearerAuth: [] + parameters: + - in: path + name: templateID + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Class template deleted + "404": + description: Class template not found + /v1/catalog/class-sessions: + get: + tags: [Catalog] + operationId: listClassSessions + security: + - bearerAuth: [] + responses: + "200": + description: Upcoming class sessions + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ClassSession" + post: + tags: [Catalog] + operationId: createClassSession + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateClassSessionRequest" + responses: + "201": + description: Class session created + content: + application/json: + schema: + $ref: "#/components/schemas/ClassSession" + "400": + description: Invalid request + "404": + description: Template or location not found + /v1/catalog/class-sessions/{sessionID}: + delete: + tags: [Catalog] + operationId: deleteClassSession + security: + - bearerAuth: [] + parameters: + - in: path + name: sessionID + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Class session deleted + "404": + description: Class session not found # ---------------- Billing ---------------- /v1/billing/subscription: get: @@ -1880,6 +2045,80 @@ components: example: "18:00" isOpen: type: boolean + ClassTemplate: + type: object + required: [id, tenantId, title, durationMinutes, capacity, createdAt] + properties: + id: + type: string + format: uuid + tenantId: + type: string + format: uuid + title: + type: string + durationMinutes: + type: integer + capacity: + type: integer + createdAt: + type: string + format: date-time + UpsertClassTemplateRequest: + type: object + required: [title, durationMinutes, capacity] + properties: + title: + type: string + durationMinutes: + type: integer + capacity: + type: integer + ClassSession: + type: object + required: [id, tenantId, templateId, templateTitle, startsAt, endsAt, capacity, bookedCount] + properties: + id: + type: string + format: uuid + tenantId: + type: string + format: uuid + templateId: + type: string + format: uuid + templateTitle: + type: string + locationId: + type: string + format: uuid + locationName: + type: string + startsAt: + type: string + format: date-time + endsAt: + type: string + format: date-time + capacity: + type: integer + bookedCount: + type: integer + CreateClassSessionRequest: + type: object + required: [templateId, startsAt] + properties: + templateId: + type: string + format: uuid + locationId: + type: string + format: uuid + startsAt: + type: string + format: date-time + capacity: + type: integer PlanEntitlements: type: object required: [maxLocations, maxStaff, emailReminders, advancedReporting, widgetEmbedding, umamiTracking] diff --git a/apps/frontend/src/providers/i18n-provider.tsx b/apps/frontend/src/providers/i18n-provider.tsx index 0f325c0..906599f 100644 --- a/apps/frontend/src/providers/i18n-provider.tsx +++ b/apps/frontend/src/providers/i18n-provider.tsx @@ -400,6 +400,23 @@ const dictionaries = { "dashboard.zone.noZones": "Zatím nemáte žádné zóny. Přidejte první.", "dashboard.zone.staff": "Zaměstnanec", "dashboard.zone.allStaff": "Všichni zaměstnanci", + "dashboard.zone.businessHours": "Celá firma", + "dashboard.classes": "Lekce", + "dashboard.class.templates": "Šablony lekcí", + "dashboard.class.sessions": "Naplánované lekce", + "dashboard.class.addTemplate": "Nová šablona", + "dashboard.class.addSession": "Naplánovat lekci", + "dashboard.class.title": "Název", + "dashboard.class.titlePlaceholder": "např. Jóga pro začátečníky", + "dashboard.class.durationMin": "Délka (min)", + "dashboard.class.template": "Šablona", + "dashboard.class.location": "Místo", + "dashboard.class.noLocation": "Bez místa", + "dashboard.class.startsAt": "Začátek", + "dashboard.class.booked": "obsazeno", + "dashboard.class.noTemplates": "Zatím žádné šablony lekcí.", + "dashboard.class.noSessions": "Zatím žádné naplánované lekce.", + "dashboard.class.sessionHint": "Vytvořte šablonu a poté z ní naplánujte konkrétní lekce.", "dashboard.services": "Služby", "dashboard.services.subtitle": "služeb v nabídce", "dashboard.service.add": "Přidat službu", @@ -952,6 +969,23 @@ const dictionaries = { "dashboard.zone.noZones": "No zones yet. Add your first one.", "dashboard.zone.staff": "Staff member", "dashboard.zone.allStaff": "All staff", + "dashboard.zone.businessHours": "Whole business", + "dashboard.classes": "Classes", + "dashboard.class.templates": "Class templates", + "dashboard.class.sessions": "Scheduled sessions", + "dashboard.class.addTemplate": "New template", + "dashboard.class.addSession": "Schedule session", + "dashboard.class.title": "Title", + "dashboard.class.titlePlaceholder": "e.g. Beginner yoga", + "dashboard.class.durationMin": "Duration (min)", + "dashboard.class.template": "Template", + "dashboard.class.location": "Location", + "dashboard.class.noLocation": "No location", + "dashboard.class.startsAt": "Starts at", + "dashboard.class.booked": "booked", + "dashboard.class.noTemplates": "No class templates yet.", + "dashboard.class.noSessions": "No scheduled sessions yet.", + "dashboard.class.sessionHint": "Create a template, then schedule individual sessions from it.", "dashboard.services": "Services", "dashboard.services.subtitle": "services offered", "dashboard.service.add": "Add service", diff --git a/apps/frontend/src/routes/dashboard/zones-page.tsx b/apps/frontend/src/routes/dashboard/zones-page.tsx index 75d0dad..e5c6f41 100644 --- a/apps/frontend/src/routes/dashboard/zones-page.tsx +++ b/apps/frontend/src/routes/dashboard/zones-page.tsx @@ -2,13 +2,13 @@ import { For, Show, createResource, createSignal } from "solid-js"; import { apiClient } from "../../lib/api-client"; import { Input } from "../../components/ui/input"; import { Select } from "../../components/ui/select"; -import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon } from "../../components/dashboard/icons"; +import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon } from "../../components/dashboard/icons"; import { DashboardLayout, useDashboardData } from "./layout"; function ZonesPage() { const data = useDashboardData(); const cs = () => data.i18n.locale() === "cs"; - const [activeTab, setActiveTab] = createSignal<"zones" | "blocked" | "hours">("zones"); + const [activeTab, setActiveTab] = createSignal<"zones" | "blocked" | "hours" | "classes">("zones"); const [showAddZone, setShowAddZone] = createSignal(false); const [newZoneName, setNewZoneName] = createSignal(""); const [newZoneType, setNewZoneType] = createSignal("room"); @@ -19,6 +19,15 @@ function ZonesPage() { const [newBlockedStaffId, setNewBlockedStaffId] = createSignal(""); const [blockedSaving, setBlockedSaving] = createSignal(false); const [hoursSaving, setHoursSaving] = createSignal(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()}` } }); @@ -51,7 +60,7 @@ function ZonesPage() { const [staffMembers] = createResource( () => [data.token(), activeTab()] as const, async ([bearer, tab]) => { - if (!bearer || tab !== "blocked") return []; + if (!bearer || (tab !== "blocked" && tab !== "hours")) return []; if (bearer.startsWith("demo.")) { return [ { id: "st1", displayName: "Petra M.", active: true }, @@ -71,13 +80,17 @@ function ZonesPage() { (staffMembers() ?? []).find((m: any) => m.id === id)?.displayName ?? ""; const [workingHours, { refetch: refetchHours, mutate: mutateHours }] = createResource( - () => [data.token(), activeTab()] as const, - async ([bearer, tab]) => { + () => [data.token(), activeTab(), hoursScope()] as const, + async ([bearer, tab, scope]) => { if (!bearer || tab !== "hours") return []; if (bearer.startsWith("demo.")) { + if (scope) { + return [0, 1, 2, 3, 4, 5, 6].map((d) => ({ dayOfWeek: d, open: d >= 1 && d <= 5 ? "08:00" : "", close: d >= 1 && d <= 5 ? "14:00" : "", isOpen: d >= 1 && d <= 5 })); + } return [0, 1, 2, 3, 4, 5, 6].map((d) => ({ dayOfWeek: d, open: d === 0 ? "" : "09:00", close: d === 0 ? "" : "18:00", isOpen: d !== 0 })); } - const response = await (apiClient as any).GET("/v1/catalog/working-hours", authHeaders()); + const url = scope ? `/v1/catalog/staff/${scope}/working-hours` : "/v1/catalog/working-hours"; + const response = await (apiClient as any).GET(url, authHeaders()); const rows = (response.data as any[]) ?? []; return [0, 1, 2, 3, 4, 5, 6].map((d) => { const row = rows.find((r) => r.dayOfWeek === d); @@ -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 locationLimit = () => data.resolvedBilling()?.entitlements?.maxLocations ?? 3; 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; } setHoursSaving(day.dayOfWeek); try { - await (apiClient as any).PUT(`/v1/catalog/working-hours/${day.dayOfWeek}`, { + const scope = hoursScope(); + const url = scope ? `/v1/catalog/staff/${scope}/working-hours/${day.dayOfWeek}` : `/v1/catalog/working-hours/${day.dayOfWeek}`; + await (apiClient as any).PUT(url, { ...authHeaders(), body: { open: day.open, close: day.close, isOpen: day.isOpen }, }); @@ -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 map: Record = { 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; @@ -192,11 +296,12 @@ function ZonesPage() { -
+
{[ { id: "zones", label: data.i18n.t("dashboard.zones"), icon: MapPinIcon }, { id: "blocked", label: data.i18n.t("dashboard.zone.blockedDays"), icon: BanIcon }, { id: "hours", label: data.i18n.t("dashboard.zone.workingHours"), icon: ClockIcon }, + { id: "classes", label: data.i18n.t("dashboard.classes"), icon: UsersIcon }, ].map((tab) => ( +
+
+ + 0} fallback={ +
+

{data.i18n.t("dashboard.class.noTemplates")}

+
+ }> +
+ + {(tpl: any) => ( +
+
+
+
+

{tpl.title}

+

{tpl.durationMinutes} {data.i18n.t("dashboard.service.min")} • {data.i18n.t("dashboard.zone.capacity")}: {tpl.capacity}

+
+
+ +
+ )} +
+
+
+ + +
+
+

{data.i18n.t("dashboard.class.sessions")}

+
+
+
+ setNewSessionLocationId(v)} + options={[ + { value: "", label: data.i18n.t("dashboard.class.noLocation") }, + ...resolvedLocations().map((l: any) => ({ value: l.id, label: l.name })), + ]} + /> + setNewSessionStartsAt(e.currentTarget.value)} /> +
+ +
+
+
+ 0} fallback={ +
+

{data.i18n.t("dashboard.class.noSessions")}

+
+ }> +
+ + {(session: any) => ( +
+
+

{session.templateTitle}

+

+ {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")}`} +

+
+ +
+ )} +
+
+
+
+ + )} +
setShowUpgradePrompt(false)} /> diff --git a/packages/api-client/src/generated/types.ts b/packages/api-client/src/generated/types.ts index 6c45b3b..1f51cd6 100644 --- a/packages/api-client/src/generated/types.ts +++ b/packages/api-client/src/generated/types.ts @@ -287,6 +287,70 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/catalog/class-sessions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listClassSessions"]; + put?: never; + post: operations["createClassSession"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/catalog/class-sessions/{sessionID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["deleteClassSession"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/catalog/class-templates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listClassTemplates"]; + put?: never; + post: operations["createClassTemplate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/catalog/class-templates/{templateID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["deleteClassTemplate"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/catalog/customers": { parameters: { query?: never; @@ -415,6 +479,38 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/catalog/staff/{staffID}/working-hours": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listStaffWorkingHours"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/catalog/staff/{staffID}/working-hours/{dayOfWeek}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateStaffWorkingHours"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/catalog/working-hours": { parameters: { query?: never; @@ -895,6 +991,35 @@ export interface components { */ planCode: "starter" | "pro" | "business"; }; + ClassSession: { + bookedCount: number; + capacity: number; + /** Format: date-time */ + endsAt: string; + /** Format: uuid */ + id: string; + /** Format: uuid */ + locationId?: string; + locationName?: string; + /** Format: date-time */ + startsAt: string; + /** Format: uuid */ + templateId: string; + templateTitle: string; + /** Format: uuid */ + tenantId: string; + }; + ClassTemplate: { + capacity: number; + /** Format: date-time */ + createdAt: string; + durationMinutes: number; + /** Format: uuid */ + id: string; + /** Format: uuid */ + tenantId: string; + title: string; + }; CreateBookingRequest: { /** @enum {string} */ bookingMode: "appointment" | "class"; @@ -925,6 +1050,15 @@ export interface components { /** @enum {string} */ status: "confirmed" | "waitlisted"; }; + CreateClassSessionRequest: { + capacity?: number; + /** Format: uuid */ + locationId?: string; + /** Format: date-time */ + startsAt: string; + /** Format: uuid */ + templateId: string; + }; Customer: { bookingsCount?: number; /** Format: email */ @@ -1222,6 +1356,11 @@ export interface components { /** @enum {string} */ type: "closure" | "holiday" | "maintenance"; }; + UpsertClassTemplateRequest: { + capacity: number; + durationMinutes: number; + title: string; + }; UpsertCustomerRequest: { /** Format: email */ 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: { parameters: { 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: { parameters: { query?: never; diff --git a/project.md b/project.md index adbcca6..458b2cf 100644 --- a/project.md +++ b/project.md @@ -51,7 +51,7 @@ - protected tenant onboarding endpoint added with backend validation for slug, preset, locale, and timezone - onboarding now creates the tenant, owner membership, and first default location - dashboard can transition from empty bootstrap shell to a provisioned tenant state - - 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 - 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 @@ -65,7 +65,16 @@ - availability endpoint accepts `serviceId`, `staffId`, and `date` filters; default response is unchanged for the embeddable widget - slots now carry `staffName`, respect tenant-wide and per-staff blocked days, and are generated per-day without the preview cap when `date` is given - booking creation hardened: appointment requests must fit inside a matching availability rule, not overlap a blocked day, and staff/location IDs must belong to the tenant with staff active - - remaining: 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 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.