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

+41
View File
@@ -25,6 +25,7 @@ import (
"bookra/apps/backend/internal/export"
"bookra/apps/backend/internal/httpx"
"bookra/apps/backend/internal/notifications"
"bookra/apps/backend/internal/reports"
"bookra/apps/backend/internal/sms"
"bookra/apps/backend/internal/tenancy"
@@ -65,6 +66,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
authService := auth.NewService(repository, cfg.AuthJWTSecret)
adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey)
smsService := sms.NewService(cfg, repository)
reportService := reports.NewService(repository, bookingService, cfg.FrontendURL, cfg.EmailFrom)
notificationService.SetSMSSender(func(ctx context.Context, tenantID, phone, body string) error {
_, err := smsService.SendMessage(ctx, tenantID, domain.SendSMSRequest{To: phone, Body: body})
return err
@@ -1103,6 +1105,28 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
}
c.JSON(http.StatusOK, response)
})
protected.PATCH("/catalog/class-sessions/:sessionID", func(c *gin.Context) {
var request domain.UpdateClassSessionRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.UpdateClassSession(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("sessionID"), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrClassSessionNotFound):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidInput):
status = http.StatusBadRequest
case errors.Is(err, catalog.ErrCapacityBelowBooked):
status = http.StatusConflict
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.DELETE("/catalog/class-sessions/:sessionID", func(c *gin.Context) {
err := catalogService.DeleteClassSession(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("sessionID"))
if err != nil {
@@ -1697,6 +1721,23 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusOK, response)
})
server.router.POST("/v1/internal/jobs/reports/dispatch", func(c *gin.Context) {
if !authorizeJobRunner(c, cfg) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
days, _ := strconv.Atoi(c.DefaultQuery("days", "7"))
response, err := reportService.DispatchDigests(c.Request.Context(), days, c.Query("tenantId"), func(ctx context.Context, msg notifications.EmailMessage) error {
_, sendErr := notificationService.SendRawEmail(ctx, msg)
return sendErr
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
// Widget embeddable script endpoint - serves JavaScript for external sites
server.router.GET("/v1/public/widget.js", func(c *gin.Context) {
c.Header("Content-Type", "application/javascript; charset=utf-8")
+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
}
+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 {
@@ -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)
}
}
+13
View File
@@ -2,6 +2,8 @@ package db
import (
"context"
"github.com/jackc/pgx/v5"
)
const classTemplateColumns = "id, tenant_id, title, duration_minutes, capacity, created_at"
@@ -132,3 +134,14 @@ func (r *PGRepository) DeleteClassSession(ctx context.Context, sessionID string)
_, err := r.pool.Exec(ctx, `DELETE FROM class_sessions WHERE id = $1`, sessionID)
return err
}
func (r *PGRepository) UpdateClassSessionCapacity(ctx context.Context, sessionID string, capacity int) (ClassSessionRecord, error) {
tag, err := r.pool.Exec(ctx, `UPDATE class_sessions SET capacity = $2 WHERE id = $1`, sessionID, capacity)
if err != nil {
return ClassSessionRecord{}, err
}
if tag.RowsAffected() == 0 {
return ClassSessionRecord{}, pgx.ErrNoRows
}
return r.GetClassSessionByID(ctx, sessionID)
}
+13
View File
@@ -30,6 +30,9 @@ type Repository interface {
GetClassSessionByID(ctx context.Context, sessionID string) (ClassSessionRecord, error)
CreateClassSession(ctx context.Context, params CreateClassSessionParams) (ClassSessionRecord, error)
DeleteClassSession(ctx context.Context, sessionID string) error
// UpdateClassSessionCapacity sets the session's seat count; the caller
// checks tenancy and the booked-seats floor.
UpdateClassSessionCapacity(ctx context.Context, sessionID string, capacity int) (ClassSessionRecord, error)
ListClassTemplatesByTenant(ctx context.Context, tenantID string) ([]ClassTemplateRecord, error)
GetClassTemplateByID(ctx context.Context, templateID string) (ClassTemplateRecord, error)
CreateClassTemplate(ctx context.Context, params CreateClassTemplateParams) (ClassTemplateRecord, error)
@@ -1539,6 +1542,16 @@ func (r *MemoryRepository) DeleteClassSession(_ context.Context, sessionID strin
return pgx.ErrNoRows
}
func (r *MemoryRepository) UpdateClassSessionCapacity(_ context.Context, sessionID string, capacity int) (ClassSessionRecord, error) {
for i := range r.classSessions {
if r.classSessions[i].ID == sessionID {
r.classSessions[i].Capacity = int32(capacity)
return r.classSessions[i], nil
}
}
return ClassSessionRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) ListClassSchedulesByTenant(_ context.Context, tenantID string) ([]ClassScheduleRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
+17
View File
@@ -263,6 +263,15 @@ type DispatchReminderJobsResponse struct {
SkippedCount int `json:"skippedCount"`
}
// DispatchReportDigestsResponse summarizes one run of the scheduled digest
// job: how many tenants were considered and how the sends resolved.
type DispatchReportDigestsResponse struct {
TenantCount int `json:"tenantCount"`
SentCount int `json:"sentCount"`
SkippedCount int `json:"skippedCount"`
FailedCount int `json:"failedCount"`
}
// ============================================
// LOCATION / ZONE MODELS
// ============================================
@@ -470,6 +479,14 @@ type CreateClassSessionRequest struct {
Capacity *int `json:"capacity,omitempty"`
}
// UpdateClassSessionRequest patches a single session. Capacity is the only
// staff-editable field; shrinking below the confirmed seat count needs
// AllowOverCapacity (same convention as promoting onto a full session).
type UpdateClassSessionRequest struct {
Capacity *int `json:"capacity"`
AllowOverCapacity bool `json:"allowOverCapacity,omitempty"`
}
// ClassSchedule is a weekly recurring rule for a class template. Concrete
// sessions are materialized for a rolling horizon; DayOfWeek follows Go's
// time.Weekday (Sunday=0) and StartsLocal is an "HH:MM" tenant-local time.
+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)
}
}
+85
View File
@@ -1311,6 +1311,38 @@ paths:
"404":
description: Template or location not found
/v1/catalog/class-sessions/{sessionID}:
patch:
tags: [Catalog]
operationId: updateClassSession
summary: Patch a class session (currently capacity only)
security:
- bearerAuth: []
parameters:
- in: path
name: sessionID
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateClassSessionRequest"
responses:
"200":
description: Updated class session
content:
application/json:
schema:
$ref: "#/components/schemas/ClassSession"
"400":
description: Invalid request
"404":
description: Class session not found
"409":
description: Capacity below confirmed bookings (pass allowOverCapacity to force)
delete:
tags: [Catalog]
operationId: deleteClassSession
@@ -1938,6 +1970,36 @@ paths:
description: Monthly SMS invoices generated
"401":
description: Unauthorized
/v1/internal/jobs/reports/dispatch:
post:
tags: [Jobs]
operationId: dispatchReportDigests
summary: Email each tenant owner a trailing-window business digest
security:
- jobRunnerKey: []
parameters:
- in: query
name: days
schema:
type: integer
default: 7
maximum: 365
description: Trailing window length in days
- in: query
name: tenantId
schema:
type: string
format: uuid
description: Scope the run to a single tenant
responses:
"200":
description: Digests dispatched
content:
application/json:
schema:
$ref: "#/components/schemas/DispatchReportDigestsResponse"
"401":
description: Unauthorized
components:
securitySchemes:
bearerAuth:
@@ -3083,6 +3145,17 @@ components:
startsLocal:
type: string
description: Tenant-local start time, HH:MM
UpdateClassSessionRequest:
type: object
required: [capacity]
properties:
capacity:
type: integer
minimum: 1
maximum: 500
allowOverCapacity:
type: boolean
description: Permit shrinking below the confirmed seat count
UpdateClassScheduleRequest:
type: object
description: >-
@@ -3264,3 +3337,15 @@ components:
type: integer
skippedCount:
type: integer
DispatchReportDigestsResponse:
type: object
required: [tenantCount, sentCount, skippedCount, failedCount]
properties:
tenantCount:
type: integer
sentCount:
type: integer
skippedCount:
type: integer
failedCount:
type: integer