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

+70
View File
@@ -1595,6 +1595,76 @@ func (s *Service) DeleteClassSession(ctx context.Context, principal domain.Princ
return s.repo.DeleteClassSession(ctx, sessionID)
}
// ErrCapacityBelowBooked rejects shrinking a session's capacity under the
// number of confirmed seats unless the caller passes allowOverCapacity.
var ErrCapacityBelowBooked = errors.New("capacity below confirmed bookings")
// UpdateClassSession patches a single session — currently only the seat
// count. Shrinking below confirmed bookings needs an explicit override so a
// typo cannot silently overbook a session.
func (s *Service) UpdateClassSession(ctx context.Context, principal domain.Principal, sessionID string, req domain.UpdateClassSessionRequest) (domain.ClassSession, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.ClassSession{}, ErrTenantMembership
}
session, err := s.repo.GetClassSessionByID(ctx, sessionID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ClassSession{}, ErrClassSessionNotFound
}
return domain.ClassSession{}, err
}
if session.TenantID != membership.Tenant.ID {
return domain.ClassSession{}, ErrClassSessionNotFound
}
if req.Capacity == nil {
return domain.ClassSession{}, fmt.Errorf("%w: capacity is required", ErrInvalidInput)
}
if *req.Capacity < 1 || *req.Capacity > 500 {
return domain.ClassSession{}, fmt.Errorf("%w: capacity must be 1-500", ErrInvalidInput)
}
sessionBookings, err := s.repo.ListBookingsBySession(ctx, sessionID)
if err != nil {
return domain.ClassSession{}, err
}
confirmed := 0
for _, booking := range sessionBookings {
if booking.Status == "confirmed" {
confirmed++
}
}
if *req.Capacity < confirmed && !req.AllowOverCapacity {
return domain.ClassSession{}, fmt.Errorf("%w: %d confirmed seats", ErrCapacityBelowBooked, confirmed)
}
rec, err := s.repo.UpdateClassSessionCapacity(ctx, sessionID, *req.Capacity)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ClassSession{}, ErrClassSessionNotFound
}
return domain.ClassSession{}, err
}
out := 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),
BookedCount: confirmed,
}
if rec.LocationID != nil {
if location, err := s.repo.GetLocationByID(ctx, *rec.LocationID); err == nil {
out.LocationName = location.Name
}
}
return out, nil
}
func (s *Service) requireTenantClassTemplate(ctx context.Context, tenantID, templateID string) error {
template, err := s.repo.GetClassTemplateByID(ctx, templateID)
if err != nil {