From e680e0c2b86ef4f1d09872133f2cd7420184b194 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Tue, 22 Sep 2026 21:29:44 +0200 Subject: [PATCH] feat: scheduled report digests + per-session capacity (M33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- apps/backend/internal/api/server.go | 41 ++++ apps/backend/internal/bookings/service.go | 31 ++- apps/backend/internal/catalog/service.go | 70 +++++++ apps/backend/internal/catalog/service_test.go | 58 ++++++ apps/backend/internal/db/classes.go | 13 ++ apps/backend/internal/db/repository.go | 13 ++ apps/backend/internal/domain/models.go | 17 ++ apps/backend/internal/reports/service.go | 190 ++++++++++++++++++ apps/backend/internal/reports/service_test.go | 121 +++++++++++ apps/backend/openapi/bookra.openapi.yaml | 85 ++++++++ apps/frontend/src/providers/i18n-provider.tsx | 6 + .../src/routes/dashboard/zones-page.tsx | 52 +++++ packages/api-client/src/generated/types.ts | 110 +++++++++- project.md | 11 +- 14 files changed, 808 insertions(+), 10 deletions(-) create mode 100644 apps/backend/internal/reports/service.go create mode 100644 apps/backend/internal/reports/service_test.go diff --git a/apps/backend/internal/api/server.go b/apps/backend/internal/api/server.go index 5327b5b..a930ca7 100644 --- a/apps/backend/internal/api/server.go +++ b/apps/backend/internal/api/server.go @@ -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") diff --git a/apps/backend/internal/bookings/service.go b/apps/backend/internal/bookings/service.go index 7d943bd..6b0235b 100644 --- a/apps/backend/internal/bookings/service.go +++ b/apps/backend/internal/bookings/service.go @@ -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 } diff --git a/apps/backend/internal/catalog/service.go b/apps/backend/internal/catalog/service.go index bf7604f..412b033 100644 --- a/apps/backend/internal/catalog/service.go +++ b/apps/backend/internal/catalog/service.go @@ -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 { diff --git a/apps/backend/internal/catalog/service_test.go b/apps/backend/internal/catalog/service_test.go index ec6f636..9d96611 100644 --- a/apps/backend/internal/catalog/service_test.go +++ b/apps/backend/internal/catalog/service_test.go @@ -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) + } +} diff --git a/apps/backend/internal/db/classes.go b/apps/backend/internal/db/classes.go index cdb4c3c..1374b9e 100644 --- a/apps/backend/internal/db/classes.go +++ b/apps/backend/internal/db/classes.go @@ -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) +} diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index aacfc5c..2fada8d 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -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 diff --git a/apps/backend/internal/domain/models.go b/apps/backend/internal/domain/models.go index c3db1fd..980df43 100644 --- a/apps/backend/internal/domain/models.go +++ b/apps/backend/internal/domain/models.go @@ -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. diff --git a/apps/backend/internal/reports/service.go b/apps/backend/internal/reports/service.go new file mode 100644 index 0000000..e0961b6 --- /dev/null +++ b/apps/backend/internal/reports/service.go @@ -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) +} diff --git a/apps/backend/internal/reports/service_test.go b/apps/backend/internal/reports/service_test.go new file mode 100644 index 0000000..ff69bd7 --- /dev/null +++ b/apps/backend/internal/reports/service_test.go @@ -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", "noreply@bookra.dev") +} + +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: "klara@example.com", + 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 != "owner@bookra.dev" || msg.From != "noreply@bookra.dev" { + 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: "klara@example.com", + 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) + } +} diff --git a/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml index 5774266..fc4c48e 100644 --- a/apps/backend/openapi/bookra.openapi.yaml +++ b/apps/backend/openapi/bookra.openapi.yaml @@ -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 diff --git a/apps/frontend/src/providers/i18n-provider.tsx b/apps/frontend/src/providers/i18n-provider.tsx index 226c096..2d487d7 100644 --- a/apps/frontend/src/providers/i18n-provider.tsx +++ b/apps/frontend/src/providers/i18n-provider.tsx @@ -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", diff --git a/apps/frontend/src/routes/dashboard/zones-page.tsx b/apps/frontend/src/routes/dashboard/zones-page.tsx index bd60b1e..16adfc9 100644 --- a/apps/frontend/src/routes/dashboard/zones-page.tsx +++ b/apps/frontend/src/routes/dashboard/zones-page.tsx @@ -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(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(null); const [expandedSessionId, setExpandedSessionId] = createSignal(null); const [promoteBusy, setPromoteBusy] = createSignal(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")} + + +
+
+ setEditCapValue(parseInt(e.currentTarget.value, 10) || 0)} /> + + + +

{data.i18n.t("dashboard.class.capacityOverbookHint")}

+
+
+
+
diff --git a/packages/api-client/src/generated/types.ts b/packages/api-client/src/generated/types.ts index deb5459..951c13f 100644 --- a/packages/api-client/src/generated/types.ts +++ b/packages/api-client/src/generated/types.ts @@ -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; diff --git a/project.md b/project.md index e3f55f9..b5f27b2 100644 --- a/project.md +++ b/project.md @@ -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