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

@@ -993,3 +993,61 @@ func TestUpdateClassScheduleWithoutCascade(t *testing.T) {
}
}
}
func TestUpdateClassSessionCapacityFloor(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
session, err := repo.CreateClassSession(ctx, db.CreateClassSessionParams{
TenantID: tenantID,
TemplateID: "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7",
StartsAt: time.Now().UTC().Add(48 * time.Hour),
EndsAt: time.Now().UTC().Add(49 * time.Hour),
Capacity: 4,
})
if err != nil {
t.Fatalf("create session: %v", err)
}
for _, name := range []string{"A", "B", "C"} {
if _, err := repo.CreateBooking(ctx, db.CreateBookingParams{
TenantID: tenantID,
ClassSessionID: &session.ID,
BookingMode: "class",
CustomerName: name,
CustomerEmail: name + "@example.com",
StartsAt: session.StartsAt,
EndsAt: session.EndsAt,
Status: "confirmed",
Reference: "BK-CAP-" + name,
}); err != nil {
t.Fatalf("seed booking %s: %v", name, err)
}
}
// Shrinking to 2 with 3 confirmed seats → conflict without override.
cap2 := 2
if _, err := svc.UpdateClassSession(ctx, owner, session.ID, domain.UpdateClassSessionRequest{Capacity: &cap2}); !errors.Is(err, ErrCapacityBelowBooked) {
t.Fatalf("expected ErrCapacityBelowBooked, got %v", err)
}
// With override it applies.
updated, err := svc.UpdateClassSession(ctx, owner, session.ID, domain.UpdateClassSessionRequest{Capacity: &cap2, AllowOverCapacity: true})
if err != nil {
t.Fatalf("override update: %v", err)
}
if updated.Capacity != 2 || updated.BookedCount != 3 {
t.Fatalf("expected capacity=2 booked=3, got %d/%d", updated.Capacity, updated.BookedCount)
}
// Growing always works.
cap8 := 8
updated, err = svc.UpdateClassSession(ctx, owner, session.ID, domain.UpdateClassSessionRequest{Capacity: &cap8})
if err != nil || updated.Capacity != 8 {
t.Fatalf("grow update: %v cap=%d", err, updated.Capacity)
}
// Validation floor.
cap0 := 0
if _, err := svc.UpdateClassSession(ctx, owner, session.ID, domain.UpdateClassSessionRequest{Capacity: &cap0}); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput for capacity 0, got %v", err)
}
}