// 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) }