feat: scheduled report digests + per-session capacity (M33)

POST /v1/internal/jobs/reports/dispatch emails each tenant owner a
trailing-window digest (bookings, revenue, per-service and per-staff
breakdown) localized to the tenant locale; zero-activity tenants are
skipped. Metrics cores extracted to tenant-scoped variants so the job
needs no principal.

PATCH /v1/catalog/class-sessions/:id sets a session's seat count with
a confirmed-bookings floor — shrinking below it needs allowOverCapacity,
matching the waitlist-promotion convention. Session rows gain an inline
capacity editor with an overbook confirm state.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-22 21:29:44 +02:00
1 parent e6f1ec38a4
commit e680e0c2b8
14 files changed
+808 -10

No files matched your search

+24 -7
View File
@@ -686,13 +686,24 @@ func (s *Service) StaffMetrics(ctx context.Context, principal domain.Principal,
}
return domain.StaffMetricsResponse{}, err
}
return s.StaffMetricsForTenant(ctx, membership.Tenant.ID, days)
}
// StaffMetricsForTenant is the tenant-scoped core of StaffMetrics — used by
// scheduled jobs that have no principal.
func (s *Service) StaffMetricsForTenant(ctx context.Context, tenantID string, days int) (domain.StaffMetricsResponse, error) {
if days <= 0 || days > 365 {
days = 30
}
to := time.Now().UTC()
from := to.AddDate(0, 0, -days)
records, err := s.repo.GetStaffMetrics(ctx, membership.Tenant.ID, from, to)
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
if err != nil {
return domain.StaffMetricsResponse{}, err
}
records, err := s.repo.GetStaffMetrics(ctx, tenantID, from, to)
if err != nil {
return domain.StaffMetricsResponse{}, err
}
@@ -701,19 +712,19 @@ func (s *Service) StaffMetrics(ctx context.Context, principal domain.Principal,
byStaff[rec.StaffID] = rec
}
staff, err := s.repo.ListStaffByTenant(ctx, membership.Tenant.ID)
staff, err := s.repo.ListStaffByTenant(ctx, tenantID)
if err != nil {
return domain.StaffMetricsResponse{}, err
}
rules, err := s.repo.ListAvailabilityRulesByTenant(ctx, membership.Tenant.ID)
rules, err := s.repo.ListAvailabilityRulesByTenant(ctx, tenantID)
if err != nil {
return domain.StaffMetricsResponse{}, err
}
blockedDays, err := s.repo.ListBlockedDaysByTenant(ctx, membership.Tenant.ID, from, to)
blockedDays, err := s.repo.ListBlockedDaysByTenant(ctx, tenantID, from, to)
if err != nil {
return domain.StaffMetricsResponse{}, err
}
loc, err := time.LoadLocation(membership.Tenant.Timezone)
loc, err := time.LoadLocation(tenant.Timezone)
if err != nil {
loc = time.UTC
}
@@ -753,17 +764,23 @@ func (s *Service) ServiceMetrics(ctx context.Context, principal domain.Principal
}
return domain.ServiceMetricsResponse{}, err
}
return s.ServiceMetricsForTenant(ctx, membership.Tenant.ID, days)
}
// ServiceMetricsForTenant is the tenant-scoped core of ServiceMetrics — used
// by scheduled jobs that have no principal.
func (s *Service) ServiceMetricsForTenant(ctx context.Context, tenantID string, days int) (domain.ServiceMetricsResponse, error) {
if days <= 0 || days > 365 {
days = 30
}
to := time.Now().UTC()
from := to.AddDate(0, 0, -days)
bookingsList, err := s.repo.ListBookingsByTenantBetween(ctx, membership.Tenant.ID, from, to)
bookingsList, err := s.repo.ListBookingsByTenantBetween(ctx, tenantID, from, to)
if err != nil {
return domain.ServiceMetricsResponse{}, err
}
services, err := s.repo.ListServicesByTenant(ctx, membership.Tenant.ID)
services, err := s.repo.ListServicesByTenant(ctx, tenantID)
if err != nil {
return domain.ServiceMetricsResponse{}, err
}