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

+190
View File
@@ -0,0 +1,190 @@
// Package reports delivers scheduled business digests — a periodic email to
// each tenant owner summarizing bookings, revenue, and staff utilization over
// a trailing window. Dispatch is driven by an external cron hitting the
// internal job endpoint; cadence and deduplication live with the scheduler,
// not here.
package reports
import (
"context"
"fmt"
"strings"
"bookra/apps/backend/internal/bookings"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"bookra/apps/backend/internal/notifications"
)
const digestPageSize = 200
// SendEmailFunc matches notifications.Service.SendRawEmail — taken as a
// parameter so tests can capture messages instead of sending them.
type SendEmailFunc func(ctx context.Context, msg notifications.EmailMessage) error
type Service struct {
repo db.Repository
bookingStats *bookings.Service
frontendURL string
emailFrom string
}
func NewService(repo db.Repository, bookingStats *bookings.Service, frontendURL, emailFrom string) *Service {
return &Service{repo: repo, bookingStats: bookingStats, frontendURL: frontendURL, emailFrom: emailFrom}
}
// DispatchDigests emails each tenant owner a summary of the trailing `days`
// window. A tenantID query scopes the run to one tenant (per-tenant crons,
// manual retries). Tenants with zero bookings in the window are skipped —
// an empty digest is noise, not signal.
func (s *Service) DispatchDigests(ctx context.Context, days int, tenantID string, send SendEmailFunc) (domain.DispatchReportDigestsResponse, error) {
if days <= 0 || days > 365 {
days = 7
}
var response domain.DispatchReportDigestsResponse
if tenantID != "" {
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
if err != nil {
return response, err
}
return s.dispatchToTenant(ctx, tenant, days, send, response)
}
for offset := 0; ; offset += digestPageSize {
tenants, total, err := s.repo.ListAllTenants(ctx, digestPageSize, offset)
if err != nil {
return response, err
}
for _, tenant := range tenants {
response, err = s.dispatchToTenant(ctx, tenant, days, send, response)
if err != nil {
return response, err
}
}
if offset+len(tenants) >= total {
break
}
}
return response, nil
}
func (s *Service) dispatchToTenant(ctx context.Context, tenant db.TenantRecord, days int, send SendEmailFunc, response domain.DispatchReportDigestsResponse) (domain.DispatchReportDigestsResponse, error) {
response.TenantCount++
ownerEmail, err := s.repo.GetTenantOwnerEmail(ctx, tenant.ID)
if err != nil || ownerEmail == "" {
response.SkippedCount++
return response, nil
}
services, err := s.bookingStats.ServiceMetricsForTenant(ctx, tenant.ID, days)
if err != nil {
return response, err
}
staff, err := s.bookingStats.StaffMetricsForTenant(ctx, tenant.ID, days)
if err != nil {
return response, err
}
var total, completed, cancelled int
var revenueCents int64
for _, svc := range services.Services {
total += svc.Bookings
completed += svc.Completed
cancelled += svc.Cancelled
revenueCents += svc.RevenueCents
}
total += services.ClassBookings
if total == 0 {
response.SkippedCount++
return response, nil
}
msg := renderDigest(s.emailFrom, s.frontendURL, ownerEmail, tenant, services, staff, days, total, completed, cancelled, revenueCents)
if err := send(ctx, msg); err != nil {
response.FailedCount++
_ = s.repo.AppendActivityEvent(ctx, db.ActivityEventParams{
TenantID: tenant.ID,
ActorKind: "system",
Action: "report.digest_failed",
EntityType: "tenant",
EntityID: &tenant.ID,
Summary: err.Error(),
})
return response, nil
}
response.SentCount++
_ = s.repo.AppendActivityEvent(ctx, db.ActivityEventParams{
TenantID: tenant.ID,
ActorKind: "system",
Action: "report.digest_sent",
EntityType: "tenant",
EntityID: &tenant.ID,
Summary: fmt.Sprintf("%d bookings / %dd", total, days),
})
return response, nil
}
func renderDigest(from, frontendURL, to string, tenant db.TenantRecord, services domain.ServiceMetricsResponse, staff domain.StaffMetricsResponse, days, total, completed, cancelled int, revenueCents int64) notifications.EmailMessage {
cs := tenant.Locale == "cs"
dashboardURL := strings.TrimRight(frontendURL, "/") + "/dashboard"
money := formatCZK(revenueCents, cs)
var subject string
var text strings.Builder
if cs {
subject = fmt.Sprintf("%s: přehled za posledních %d dní", tenant.Name, days)
text.WriteString(fmt.Sprintf("Dobrý den,\n\nzde je souhrn provozu %s za posledních %d dní.\n\n", tenant.Name, days))
text.WriteString(fmt.Sprintf("Rezervace celkem: %d (dokončené: %d, zrušené: %d)\n", total, completed, cancelled))
text.WriteString(fmt.Sprintf("Tržby: %s\n", money))
if services.ClassBookings > 0 {
text.WriteString(fmt.Sprintf("Z toho rezervace na lekce: %d\n", services.ClassBookings))
}
text.WriteString("\nSlužby:\n")
for _, svc := range services.Services {
if svc.Bookings == 0 {
continue
}
text.WriteString(fmt.Sprintf("- %s: %d rezervací, %s\n", svc.Name, svc.Bookings, formatCZK(svc.RevenueCents, cs)))
}
text.WriteString("\nZaměstnanci:\n")
for _, member := range staff.Staff {
text.WriteString(fmt.Sprintf("- %s: %d rezervací, vytížení %d%%\n", member.DisplayName, member.Bookings, member.UtilizationPercent))
}
text.WriteString(fmt.Sprintf("\nDetaily najdete na nástěnce: %s\n", dashboardURL))
} else {
subject = fmt.Sprintf("%s: last %d days at a glance", tenant.Name, days)
text.WriteString(fmt.Sprintf("Hello,\n\nhere is the %s summary for the last %d days.\n\n", tenant.Name, days))
text.WriteString(fmt.Sprintf("Total bookings: %d (completed: %d, cancelled: %d)\n", total, completed, cancelled))
text.WriteString(fmt.Sprintf("Revenue: %s\n", money))
if services.ClassBookings > 0 {
text.WriteString(fmt.Sprintf("Of which class bookings: %d\n", services.ClassBookings))
}
text.WriteString("\nServices:\n")
for _, svc := range services.Services {
if svc.Bookings == 0 {
continue
}
text.WriteString(fmt.Sprintf("- %s: %d bookings, %s\n", svc.Name, svc.Bookings, formatCZK(svc.RevenueCents, cs)))
}
text.WriteString("\nStaff:\n")
for _, member := range staff.Staff {
text.WriteString(fmt.Sprintf("- %s: %d bookings, %d%% utilized\n", member.DisplayName, member.Bookings, member.UtilizationPercent))
}
text.WriteString(fmt.Sprintf("\nFull details on the dashboard: %s\n", dashboardURL))
}
return notifications.EmailMessage{From: from, To: to, Subject: subject, Text: text.String()}
}
// formatCZK renders cents as a whole-crown figure — digests show trends, not
// accounting, so haléře precision adds noise.
func formatCZK(cents int64, cs bool) string {
crowns := cents / 100
if cs {
return fmt.Sprintf("%d Kč", crowns)
}
return fmt.Sprintf("CZK %d", crowns)
}
@@ -0,0 +1,121 @@
package reports
import (
"context"
"errors"
"strings"
"testing"
"time"
"bookra/apps/backend/internal/bookings"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/notifications"
)
func newTestService(repo db.Repository) *Service {
return NewService(repo, bookings.NewService(repo, nil, "http://localhost:3000"), "http://localhost:3000", "[email protected]")
}
func TestDispatchDigestsSendsLocalizedSummary(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newTestService(repo)
ctx := context.Background()
tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
serviceID := "d5d76a61-3d49-467c-8dd4-bf61ee754e39"
_, err := repo.CreateBooking(ctx, db.CreateBookingParams{
TenantID: tenantID,
ServiceID: &serviceID,
BookingMode: "appointment",
CustomerName: "Klára",
CustomerEmail: "[email protected]",
StartsAt: time.Now().UTC().Add(-26 * time.Hour),
EndsAt: time.Now().UTC().Add(-25 * time.Hour),
Status: "confirmed",
Reference: "BK-DIG-1",
})
if err != nil {
t.Fatalf("seed booking: %v", err)
}
var sent []notifications.EmailMessage
capture := func(_ context.Context, msg notifications.EmailMessage) error {
sent = append(sent, msg)
return nil
}
response, err := svc.DispatchDigests(ctx, 7, tenantID, capture)
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if response.TenantCount != 1 || response.SentCount != 1 {
t.Fatalf("expected 1 tenant / 1 sent, got %+v", response)
}
if len(sent) != 1 {
t.Fatalf("expected 1 email, got %d", len(sent))
}
msg := sent[0]
if msg.To != "[email protected]" || msg.From != "[email protected]" {
t.Fatalf("bad addressing: %+v", msg)
}
// Demo tenant locale is cs — subject should be the Czech digest.
if !strings.Contains(msg.Subject, "přehled") {
t.Fatalf("expected Czech subject, got %q", msg.Subject)
}
if !strings.Contains(msg.Text, "Rezervace celkem") || !strings.Contains(msg.Text, "1200 Kč") {
t.Fatalf("digest body missing totals: %q", msg.Text)
}
if !strings.Contains(msg.Text, "Signature treatment") {
t.Fatalf("digest body missing service line: %q", msg.Text)
}
}
func TestDispatchDigestsSkipsZeroActivityTenant(t *testing.T) {
// NewMemoryRepository seeds the tenant, staff, and services but no
// bookings — the trailing window is empty, so the digest must skip.
repo := db.NewMemoryRepository()
svc := newTestService(repo)
calls := 0
response, err := svc.DispatchDigests(context.Background(), 7, "", func(context.Context, notifications.EmailMessage) error {
calls++
return nil
})
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if response.TenantCount != 1 || response.SkippedCount != 1 || calls != 0 {
t.Fatalf("expected 1 skipped tenant and no sends, got %+v (calls=%d)", response, calls)
}
}
func TestDispatchDigestsCountsSendFailures(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newTestService(repo)
ctx := context.Background()
tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
serviceID := "d5d76a61-3d49-467c-8dd4-bf61ee754e39"
if _, err := repo.CreateBooking(ctx, db.CreateBookingParams{
TenantID: tenantID,
ServiceID: &serviceID,
BookingMode: "appointment",
CustomerName: "Klára",
CustomerEmail: "[email protected]",
StartsAt: time.Now().UTC().Add(-2 * time.Hour),
EndsAt: time.Now().UTC().Add(-time.Hour),
Status: "confirmed",
Reference: "BK-DIG-2",
}); err != nil {
t.Fatalf("seed booking: %v", err)
}
response, err := svc.DispatchDigests(ctx, 7, tenantID, func(context.Context, notifications.EmailMessage) error {
return errors.New("smtp down")
})
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if response.FailedCount != 1 || response.SentCount != 0 {
t.Fatalf("expected 1 failed send, got %+v", response)
}
}