mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
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:
1 parent
e6f1ec38a4
commit
e680e0c2b8
14 files changed
+808
-10
No files matched your search
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user