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
@@ -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.ruleActive": "Pravidlo je aktivní",
"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.1": "Pondělí",
"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.ruleActive": "Rule is active",
"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.1": "Monday",
"dashboard.class.dow.2": "Tuesday",
@@ -42,6 +42,12 @@ function ZonesPage() {
const [editSchedApplyFuture, setEditSchedApplyFuture] = createSignal(true);
const [editSchedActive, setEditSchedActive] = createSignal(true);
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 [promoteBusy, setPromoteBusy] = createSignal<string | null>(null);
// 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 bearer = data.token();
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")}
<span class={`inline-flex transition-transform duration-200 ${expanded() ? "rotate-180" : ""}`}><ChevronDownIcon /></span>
</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>
</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()}>
<div class="px-6 pb-5 animate-fade-in">
<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"];
options?: never;
head?: never;
patch?: never;
/** Patch a class session (currently capacity only) */
patch: operations["updateClassSession"];
trace?: never;
};
"/v1/catalog/class-sessions/{sessionID}/roster": {
@@ -816,6 +817,23 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@@ -1564,6 +1582,12 @@ export interface components {
sentCount: number;
skippedCount: number;
};
DispatchReportDigestsResponse: {
failedCount: number;
sentCount: number;
skippedCount: number;
tenantCount: number;
};
EmailTemplate: {
bodyText?: string;
isEnabled?: boolean;
@@ -1922,6 +1946,11 @@ export interface components {
/** @description Tenant-local start time, HH:MM */
startsLocal?: string;
};
UpdateClassSessionRequest: {
/** @description Permit shrinking below the confirmed seat count */
allowOverCapacity?: boolean;
capacity: number;
};
UpdateEmailTemplateRequest: {
body?: string;
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: {
parameters: {
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: {
parameters: {
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`
- 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
- `[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
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.
- **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.
- **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
- calm, premium, structured UX