Merge pull request #18 from Dvorinka/feat/m33-report-digests-session-capacity

feat: scheduled report digests + per-session capacity (M33)
This commit is contained in:
Tomáš Dvořák authored and GitHub committed 2026-09-22 21:34:29 +02:00
commit 334b17f4ff
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/export"
"bookra/apps/backend/internal/httpx" "bookra/apps/backend/internal/httpx"
"bookra/apps/backend/internal/notifications" "bookra/apps/backend/internal/notifications"
"bookra/apps/backend/internal/reports"
"bookra/apps/backend/internal/sms" "bookra/apps/backend/internal/sms"
"bookra/apps/backend/internal/tenancy" "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) authService := auth.NewService(repository, cfg.AuthJWTSecret)
adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey) adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey)
smsService := sms.NewService(cfg, repository) 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 { notificationService.SetSMSSender(func(ctx context.Context, tenantID, phone, body string) error {
_, err := smsService.SendMessage(ctx, tenantID, domain.SendSMSRequest{To: phone, Body: body}) _, err := smsService.SendMessage(ctx, tenantID, domain.SendSMSRequest{To: phone, Body: body})
return err return err
@@ -1103,6 +1105,28 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
} }
c.JSON(http.StatusOK, response) 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) { protected.DELETE("/catalog/class-sessions/:sessionID", func(c *gin.Context) {
err := catalogService.DeleteClassSession(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("sessionID")) err := catalogService.DeleteClassSession(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("sessionID"))
if err != nil { if err != nil {
@@ -1697,6 +1721,23 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusOK, response) 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 // Widget embeddable script endpoint - serves JavaScript for external sites
server.router.GET("/v1/public/widget.js", func(c *gin.Context) { server.router.GET("/v1/public/widget.js", func(c *gin.Context) {
c.Header("Content-Type", "application/javascript; charset=utf-8") 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 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 { if days <= 0 || days > 365 {
days = 30 days = 30
} }
to := time.Now().UTC() to := time.Now().UTC()
from := to.AddDate(0, 0, -days) 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 { if err != nil {
return domain.StaffMetricsResponse{}, err return domain.StaffMetricsResponse{}, err
} }
@@ -701,19 +712,19 @@ func (s *Service) StaffMetrics(ctx context.Context, principal domain.Principal,
byStaff[rec.StaffID] = rec byStaff[rec.StaffID] = rec
} }
staff, err := s.repo.ListStaffByTenant(ctx, membership.Tenant.ID) staff, err := s.repo.ListStaffByTenant(ctx, tenantID)
if err != nil { if err != nil {
return domain.StaffMetricsResponse{}, err return domain.StaffMetricsResponse{}, err
} }
rules, err := s.repo.ListAvailabilityRulesByTenant(ctx, membership.Tenant.ID) rules, err := s.repo.ListAvailabilityRulesByTenant(ctx, tenantID)
if err != nil { if err != nil {
return domain.StaffMetricsResponse{}, err 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 { if err != nil {
return domain.StaffMetricsResponse{}, err return domain.StaffMetricsResponse{}, err
} }
loc, err := time.LoadLocation(membership.Tenant.Timezone) loc, err := time.LoadLocation(tenant.Timezone)
if err != nil { if err != nil {
loc = time.UTC loc = time.UTC
} }
@@ -753,17 +764,23 @@ func (s *Service) ServiceMetrics(ctx context.Context, principal domain.Principal
} }
return domain.ServiceMetricsResponse{}, err 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 { if days <= 0 || days > 365 {
days = 30 days = 30
} }
to := time.Now().UTC() to := time.Now().UTC()
from := to.AddDate(0, 0, -days) 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 { if err != nil {
return domain.ServiceMetricsResponse{}, err return domain.ServiceMetricsResponse{}, err
} }
services, err := s.repo.ListServicesByTenant(ctx, membership.Tenant.ID) services, err := s.repo.ListServicesByTenant(ctx, tenantID)
if err != nil { if err != nil {
return domain.ServiceMetricsResponse{}, err 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) 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 { func (s *Service) requireTenantClassTemplate(ctx context.Context, tenantID, templateID string) error {
template, err := s.repo.GetClassTemplateByID(ctx, templateID) template, err := s.repo.GetClassTemplateByID(ctx, templateID)
if err != nil { 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 ( import (
"context" "context"
"github.com/jackc/pgx/v5"
) )
const classTemplateColumns = "id, tenant_id, title, duration_minutes, capacity, created_at" 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) _, err := r.pool.Exec(ctx, `DELETE FROM class_sessions WHERE id = $1`, sessionID)
return err 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) GetClassSessionByID(ctx context.Context, sessionID string) (ClassSessionRecord, error)
CreateClassSession(ctx context.Context, params CreateClassSessionParams) (ClassSessionRecord, error) CreateClassSession(ctx context.Context, params CreateClassSessionParams) (ClassSessionRecord, error)
DeleteClassSession(ctx context.Context, sessionID string) 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) ListClassTemplatesByTenant(ctx context.Context, tenantID string) ([]ClassTemplateRecord, error)
GetClassTemplateByID(ctx context.Context, templateID string) (ClassTemplateRecord, error) GetClassTemplateByID(ctx context.Context, templateID string) (ClassTemplateRecord, error)
CreateClassTemplate(ctx context.Context, params CreateClassTemplateParams) (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 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) { func (r *MemoryRepository) ListClassSchedulesByTenant(_ context.Context, tenantID string) ([]ClassScheduleRecord, error) {
if tenantID != r.tenant.ID { if tenantID != r.tenant.ID {
return nil, nil return nil, nil
+17
View File
@@ -263,6 +263,15 @@ type DispatchReminderJobsResponse struct {
SkippedCount int `json:"skippedCount"` 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 // LOCATION / ZONE MODELS
// ============================================ // ============================================
@@ -470,6 +479,14 @@ type CreateClassSessionRequest struct {
Capacity *int `json:"capacity,omitempty"` 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 // ClassSchedule is a weekly recurring rule for a class template. Concrete
// sessions are materialized for a rolling horizon; DayOfWeek follows Go's // sessions are materialized for a rolling horizon; DayOfWeek follows Go's
// time.Weekday (Sunday=0) and StartsLocal is an "HH:MM" tenant-local time. // 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": "404":
description: Template or location not found description: Template or location not found
/v1/catalog/class-sessions/{sessionID}: /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: delete:
tags: [Catalog] tags: [Catalog]
operationId: deleteClassSession operationId: deleteClassSession
@@ -1938,6 +1970,36 @@ paths:
description: Monthly SMS invoices generated description: Monthly SMS invoices generated
"401": "401":
description: Unauthorized 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: components:
securitySchemes: securitySchemes:
bearerAuth: bearerAuth:
@@ -3083,6 +3145,17 @@ components:
startsLocal: startsLocal:
type: string type: string
description: Tenant-local start time, HH:MM 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: UpdateClassScheduleRequest:
type: object type: object
description: >- description: >-
@@ -3264,3 +3337,15 @@ components:
type: integer type: integer
skippedCount: skippedCount:
type: integer type: integer
DispatchReportDigestsResponse:
type: object
required: [tenantCount, sentCount, skippedCount, failedCount]
properties:
tenantCount:
type: integer
sentCount:
type: integer
skippedCount:
type: integer
failedCount:
type: integer
@@ -457,6 +457,9 @@ const dictionaries = {
"dashboard.class.applyToFutureHint": "Přesune již vytvořené lekce na nový čas a místo, rezervace se přesunou s nimi.", "dashboard.class.applyToFutureHint": "Přesune již vytvořené lekce na nový čas a místo, rezervace se přesunou s nimi.",
"dashboard.class.ruleActive": "Pravidlo je aktivní", "dashboard.class.ruleActive": "Pravidlo je aktivní",
"dashboard.class.inactive": "neaktivní", "dashboard.class.inactive": "neaktivní",
"dashboard.class.editCapacity": "Upravit kapacitu",
"dashboard.class.capacityLabel": "Kapacita",
"dashboard.class.capacityOverbookHint": "Kapacita je nižší než počet potvrzených míst — uložením se lekce přebookuje.",
"dashboard.class.dow.0": "Neděle", "dashboard.class.dow.0": "Neděle",
"dashboard.class.dow.1": "Pondělí", "dashboard.class.dow.1": "Pondělí",
"dashboard.class.dow.2": "Úterý", "dashboard.class.dow.2": "Úterý",
@@ -1093,6 +1096,9 @@ const dictionaries = {
"dashboard.class.applyToFutureHint": "Moves already-created sessions to the new time and location; bookings move with them.", "dashboard.class.applyToFutureHint": "Moves already-created sessions to the new time and location; bookings move with them.",
"dashboard.class.ruleActive": "Rule is active", "dashboard.class.ruleActive": "Rule is active",
"dashboard.class.inactive": "inactive", "dashboard.class.inactive": "inactive",
"dashboard.class.editCapacity": "Edit capacity",
"dashboard.class.capacityLabel": "Capacity",
"dashboard.class.capacityOverbookHint": "Capacity is below the confirmed seat count — saving overbooks the session.",
"dashboard.class.dow.0": "Sunday", "dashboard.class.dow.0": "Sunday",
"dashboard.class.dow.1": "Monday", "dashboard.class.dow.1": "Monday",
"dashboard.class.dow.2": "Tuesday", "dashboard.class.dow.2": "Tuesday",
@@ -42,6 +42,12 @@ function ZonesPage() {
const [editSchedApplyFuture, setEditSchedApplyFuture] = createSignal(true); const [editSchedApplyFuture, setEditSchedApplyFuture] = createSignal(true);
const [editSchedActive, setEditSchedActive] = createSignal(true); const [editSchedActive, setEditSchedActive] = createSignal(true);
const [schedEditSaving, setSchedEditSaving] = createSignal(false); const [schedEditSaving, setSchedEditSaving] = createSignal(false);
const [editingCapSessionId, setEditingCapSessionId] = createSignal<string | null>(null);
const [editCapValue, setEditCapValue] = createSignal(0);
const [capSaving, setCapSaving] = createSignal(false);
// Session ID whose capacity shrink hit the confirmed-seat floor — the row
// switches to an explicit "set anyway" confirm before resubmitting.
const [capOverbookConfirm, setCapOverbookConfirm] = createSignal<string | null>(null);
const [expandedSessionId, setExpandedSessionId] = createSignal<string | null>(null); const [expandedSessionId, setExpandedSessionId] = createSignal<string | null>(null);
const [promoteBusy, setPromoteBusy] = createSignal<string | null>(null); const [promoteBusy, setPromoteBusy] = createSignal<string | null>(null);
// Booking IDs whose promote attempt hit a full session — awaiting the // Booking IDs whose promote attempt hit a full session — awaiting the
@@ -448,6 +454,35 @@ function ZonesPage() {
} }
}; };
const startEditCapacity = (session: any) => {
setEditingCapSessionId(session.id);
setEditCapValue(session.capacity);
setCapOverbookConfirm(null);
};
const handleSaveCapacity = async (session: any, allowOverCapacity: boolean) => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
setCapSaving(true);
try {
const response = await (apiClient as any).PATCH(`/v1/catalog/class-sessions/${session.id}`, {
...authHeaders(),
body: { capacity: editCapValue(), allowOverCapacity },
});
if (response.response?.status === 409 && !allowOverCapacity) {
setCapOverbookConfirm(session.id);
return;
}
setEditingCapSessionId(null);
setCapOverbookConfirm(null);
void refetchSessions();
} catch {
data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed.");
} finally {
setCapSaving(false);
}
};
const handleDeleteSchedule = async (id: string) => { const handleDeleteSchedule = async (id: string) => {
const bearer = data.token(); const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode."); return; } if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode."); return; }
@@ -891,9 +926,26 @@ function ZonesPage() {
{expanded() ? data.i18n.t("dashboard.class.hideRoster") : data.i18n.t("dashboard.class.showRoster")} {expanded() ? data.i18n.t("dashboard.class.hideRoster") : data.i18n.t("dashboard.class.showRoster")}
<span class={`inline-flex transition-transform duration-200 ${expanded() ? "rotate-180" : ""}`}><ChevronDownIcon /></span> <span class={`inline-flex transition-transform duration-200 ${expanded() ? "rotate-180" : ""}`}><ChevronDownIcon /></span>
</button> </button>
<button onClick={() => startEditCapacity(session)} class="p-2 text-ink-subtle hover:text-accent hover:bg-accent-subtle/40 rounded-lg transition-colors" aria-label={data.i18n.t("dashboard.class.editCapacity")}><PencilIcon /></button>
<button onClick={() => void handleDeleteSession(session.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" aria-label={cs() ? "Smazat" : "Delete"}><Trash2Icon /></button> <button onClick={() => void handleDeleteSession(session.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" aria-label={cs() ? "Smazat" : "Delete"}><Trash2Icon /></button>
</div> </div>
</div> </div>
<Show when={editingCapSessionId() === session.id}>
<div class="px-6 pb-4 animate-fade-in">
<div class="flex flex-wrap items-end gap-3 rounded-xl border border-border/60 bg-canvas-subtle/30 p-4">
<Input type="number" label={data.i18n.t("dashboard.class.capacityLabel")} value={String(editCapValue())} min={1} onInput={(e) => setEditCapValue(parseInt(e.currentTarget.value, 10) || 0)} />
<button onClick={() => void handleSaveCapacity(session, capOverbookConfirm() === session.id)} disabled={capSaving() || editCapValue() < 1} class="btn-primary text-sm disabled:opacity-60">
<CheckIcon /> {capSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.saveChanges")}
</button>
<button onClick={() => { setEditingCapSessionId(null); setCapOverbookConfirm(null); }} class="btn-secondary text-sm">
<XIcon /> {data.i18n.t("dashboard.cancel")}
</button>
<Show when={capOverbookConfirm() === session.id}>
<p class="w-full text-sm text-warning font-medium">{data.i18n.t("dashboard.class.capacityOverbookHint")}</p>
</Show>
</div>
</div>
</Show>
<Show when={expanded()}> <Show when={expanded()}>
<div class="px-6 pb-5 animate-fade-in"> <div class="px-6 pb-5 animate-fade-in">
<div class="rounded-xl border border-border/60 bg-canvas-subtle/30 overflow-hidden"> <div class="rounded-xl border border-border/60 bg-canvas-subtle/30 overflow-hidden">
+109 -1
View File
@@ -461,7 +461,8 @@ export interface paths {
delete: operations["deleteClassSession"]; delete: operations["deleteClassSession"];
options?: never; options?: never;
head?: never; head?: never;
patch?: never; /** Patch a class session (currently capacity only) */
patch: operations["updateClassSession"];
trace?: never; trace?: never;
}; };
"/v1/catalog/class-sessions/{sessionID}/roster": { "/v1/catalog/class-sessions/{sessionID}/roster": {
@@ -816,6 +817,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/v1/internal/jobs/reports/dispatch": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Email each tenant owner a trailing-window business digest */
post: operations["dispatchReportDigests"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/internal/jobs/sms/invoices": { "/v1/internal/jobs/sms/invoices": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1564,6 +1582,12 @@ export interface components {
sentCount: number; sentCount: number;
skippedCount: number; skippedCount: number;
}; };
DispatchReportDigestsResponse: {
failedCount: number;
sentCount: number;
skippedCount: number;
tenantCount: number;
};
EmailTemplate: { EmailTemplate: {
bodyText?: string; bodyText?: string;
isEnabled?: boolean; isEnabled?: boolean;
@@ -1922,6 +1946,11 @@ export interface components {
/** @description Tenant-local start time, HH:MM */ /** @description Tenant-local start time, HH:MM */
startsLocal?: string; startsLocal?: string;
}; };
UpdateClassSessionRequest: {
/** @description Permit shrinking below the confirmed seat count */
allowOverCapacity?: boolean;
capacity: number;
};
UpdateEmailTemplateRequest: { UpdateEmailTemplateRequest: {
body?: string; body?: string;
isEnabled?: boolean; isEnabled?: boolean;
@@ -3039,6 +3068,53 @@ export interface operations {
}; };
}; };
}; };
updateClassSession: {
parameters: {
query?: never;
header?: never;
path: {
sessionID: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpdateClassSessionRequest"];
};
};
responses: {
/** @description Updated class session */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ClassSession"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Class session not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Capacity below confirmed bookings (pass allowOverCapacity to force) */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
getSessionRoster: { getSessionRoster: {
parameters: { parameters: {
query?: never; query?: never;
@@ -3965,6 +4041,38 @@ export interface operations {
}; };
}; };
}; };
dispatchReportDigests: {
parameters: {
query?: {
/** @description Trailing window length in days */
days?: number;
/** @description Scope the run to a single tenant */
tenantId?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Digests dispatched */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DispatchReportDigestsResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
generateSmsInvoices: { generateSmsInvoices: {
parameters: { parameters: {
query?: never; query?: never;
+9 -2
View File
@@ -236,6 +236,13 @@
- `waitlist_entries` dropped (migration 00020): the ledger was written on join/promotion but never read — positions derive from bookings' `created_at` - `waitlist_entries` dropped (migration 00020): the ledger was written on join/promotion but never read — positions derive from bookings' `created_at`
- verified live: PATCH retime cascades to session and booking windows; without `applyToFuture` sessions stay; 400/404 paths correct; focused tests cover cascade and no-cascade paths - verified live: PATCH retime cascades to session and booking windows; without `applyToFuture` sessions stay; 400/404 paths correct; focused tests cover cascade and no-cascade paths
- remaining: live staging validation on Neon + pgx repository path - remaining: live staging validation on Neon + pgx repository path
- `[done]` Milestone 33: Scheduled report digests + per-session capacity
- `POST /v1/internal/jobs/reports/dispatch?days=&tenantId=` (job-runner key) emails each tenant owner a trailing-window digest — totals, per-service bookings/revenue, per-staff utilization — localized to the tenant locale, dashboard link included; tenants with zero activity are skipped (a silent week is signal, not noise), failed sends are counted and logged to the activity feed
- `bookings.Service` gains tenant-scoped `StaffMetricsForTenant`/`ServiceMetricsForTenant` cores so jobs need no principal; the principal-facing methods delegate
- `POST /v1/internal/jobs/reports/dispatch` accepts `tenantId` for single-tenant runs (manual retry, per-tenant crons); cadence lives with the external scheduler — the job itself is deliberately stateless
- `PATCH /v1/catalog/class-sessions/:id` sets a session's seat count; shrinking below confirmed bookings returns `409` unless `allowOverCapacity` — same explicit-override convention as waitlist promotion; session rows gain an inline capacity editor (CS/EN) with an overbook confirm state
- verified live: dispatch authorized by job key, sent 1 digest for the seeded tenant; capacity PATCH grew/shrank sessions and rejected below-floor values; tests cover localized render, zero-activity skip, send-failure counting, and the capacity floor
- remaining: live staging validation on Neon + pgx repository path
## Overview ## Overview
Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP. Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP.
@@ -487,10 +494,10 @@ Shortlist of worthwhile directions once v1 stabilizes — kept deliberately smal
- **Google Calendar sync**: M22 shipped read-only ICS feeds; two-way sync (push bookings into Google, pull busy blocks back as availability exceptions) needs OAuth and conflict rules — larger commitment, defer until a concrete request. - **Google Calendar sync**: M22 shipped read-only ICS feeds; two-way sync (push bookings into Google, pull busy blocks back as availability exceptions) needs OAuth and conflict rules — larger commitment, defer until a concrete request.
- **Browser push notifications**: M27 persists read state and the feed covers deliveries; Web Push (service worker + VAPID) would notify owners about new bookings without keeping the dashboard open — defer until a concrete request, email already covers off-site reach. - **Browser push notifications**: M27 persists read state and the feed covers deliveries; Web Push (service worker + VAPID) would notify owners about new bookings without keeping the dashboard open — defer until a concrete request, email already covers off-site reach.
- **Scheduled report delivery**: period-end exports emailed automatically (cron + existing mail transport) instead of manual downloads — pairs naturally with M28's report endpoints.
- **Intake reporting**: M31 shows labels, but answers aren't queryable — filtering or aggregating yes-no answers (e.g. "first visits this month") needs a stored projection, not JSONB scans; defer until a reporting request exists. - **Intake reporting**: M31 shows labels, but answers aren't queryable — filtering or aggregating yes-no answers (e.g. "first visits this month") needs a stored projection, not JSONB scans; defer until a reporting request exists.
- **Bulk session shift**: M32 retimes future sessions of one rule; a genuine weekday *move* (relocate existing sessions to the new weekday, not just steer future materialization) and multi-rule shifts (holiday weeks) are still manual — defer until requested. - **Bulk session shift**: M32 retimes future sessions of one rule; a genuine weekday *move* (relocate existing sessions to the new weekday, not just steer future materialization) and multi-rule shifts (holiday weeks) are still manual — defer until requested.
- **Per-session capacity override**: session capacity still comes only from the template; letting staff override capacity on one session (or one rule) is a small schema addition if a real need appears. - **Digest preferences**: M33's digest is on-or-nothing per tenant — a per-tenant opt-out/cadence setting (or per-member recipients) belongs in notification settings if owners ask for control.
- **Digest attachments**: the digest is text-only; attaching the CSV exports (M28) needs multipart/mixed in the SMTP provider — small lift, defer until asked.
## Product Principles ## Product Principles
- calm, premium, structured UX - calm, premium, structured UX