mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
Merge pull request #18 from Dvorinka/feat/m33-report-digests-session-capacity
feat: scheduled report digests + per-session capacity (M33)
This commit is contained in:
14 files changed
+808
-10
No files matched your search
@@ -25,6 +25,7 @@ import (
|
|||||||
"bookra/apps/backend/internal/export"
|
"bookra/apps/backend/internal/export"
|
||||||
"bookra/apps/backend/internal/httpx"
|
"bookra/apps/backend/internal/httpx"
|
||||||
"bookra/apps/backend/internal/notifications"
|
"bookra/apps/backend/internal/notifications"
|
||||||
|
"bookra/apps/backend/internal/reports"
|
||||||
"bookra/apps/backend/internal/sms"
|
"bookra/apps/backend/internal/sms"
|
||||||
"bookra/apps/backend/internal/tenancy"
|
"bookra/apps/backend/internal/tenancy"
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
|||||||
authService := auth.NewService(repository, cfg.AuthJWTSecret)
|
authService := auth.NewService(repository, cfg.AuthJWTSecret)
|
||||||
adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey)
|
adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey)
|
||||||
smsService := sms.NewService(cfg, repository)
|
smsService := sms.NewService(cfg, repository)
|
||||||
|
reportService := reports.NewService(repository, bookingService, cfg.FrontendURL, cfg.EmailFrom)
|
||||||
notificationService.SetSMSSender(func(ctx context.Context, tenantID, phone, body string) error {
|
notificationService.SetSMSSender(func(ctx context.Context, tenantID, phone, body string) error {
|
||||||
_, err := smsService.SendMessage(ctx, tenantID, domain.SendSMSRequest{To: phone, Body: body})
|
_, err := smsService.SendMessage(ctx, tenantID, domain.SendSMSRequest{To: phone, Body: body})
|
||||||
return err
|
return err
|
||||||
@@ -1103,6 +1105,28 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, response)
|
c.JSON(http.StatusOK, response)
|
||||||
})
|
})
|
||||||
|
protected.PATCH("/catalog/class-sessions/:sessionID", func(c *gin.Context) {
|
||||||
|
var request domain.UpdateClassSessionRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response, err := catalogService.UpdateClassSession(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("sessionID"), request)
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusInternalServerError
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrClassSessionNotFound):
|
||||||
|
status = http.StatusNotFound
|
||||||
|
case errors.Is(err, catalog.ErrInvalidInput):
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
case errors.Is(err, catalog.ErrCapacityBelowBooked):
|
||||||
|
status = http.StatusConflict
|
||||||
|
}
|
||||||
|
c.JSON(status, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
})
|
||||||
protected.DELETE("/catalog/class-sessions/:sessionID", func(c *gin.Context) {
|
protected.DELETE("/catalog/class-sessions/:sessionID", func(c *gin.Context) {
|
||||||
err := catalogService.DeleteClassSession(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("sessionID"))
|
err := catalogService.DeleteClassSession(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("sessionID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1697,6 +1721,23 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
|||||||
c.JSON(http.StatusOK, response)
|
c.JSON(http.StatusOK, response)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
server.router.POST("/v1/internal/jobs/reports/dispatch", func(c *gin.Context) {
|
||||||
|
if !authorizeJobRunner(c, cfg) {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
days, _ := strconv.Atoi(c.DefaultQuery("days", "7"))
|
||||||
|
response, err := reportService.DispatchDigests(c.Request.Context(), days, c.Query("tenantId"), func(ctx context.Context, msg notifications.EmailMessage) error {
|
||||||
|
_, sendErr := notificationService.SendRawEmail(ctx, msg)
|
||||||
|
return sendErr
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
})
|
||||||
|
|
||||||
// Widget embeddable script endpoint - serves JavaScript for external sites
|
// Widget embeddable script endpoint - serves JavaScript for external sites
|
||||||
server.router.GET("/v1/public/widget.js", func(c *gin.Context) {
|
server.router.GET("/v1/public/widget.js", func(c *gin.Context) {
|
||||||
c.Header("Content-Type", "application/javascript; charset=utf-8")
|
c.Header("Content-Type", "application/javascript; charset=utf-8")
|
||||||
|
|||||||
@@ -686,13 +686,24 @@ func (s *Service) StaffMetrics(ctx context.Context, principal domain.Principal,
|
|||||||
}
|
}
|
||||||
return domain.StaffMetricsResponse{}, err
|
return domain.StaffMetricsResponse{}, err
|
||||||
}
|
}
|
||||||
|
return s.StaffMetricsForTenant(ctx, membership.Tenant.ID, days)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StaffMetricsForTenant is the tenant-scoped core of StaffMetrics — used by
|
||||||
|
// scheduled jobs that have no principal.
|
||||||
|
func (s *Service) StaffMetricsForTenant(ctx context.Context, tenantID string, days int) (domain.StaffMetricsResponse, error) {
|
||||||
if days <= 0 || days > 365 {
|
if days <= 0 || days > 365 {
|
||||||
days = 30
|
days = 30
|
||||||
}
|
}
|
||||||
to := time.Now().UTC()
|
to := time.Now().UTC()
|
||||||
from := to.AddDate(0, 0, -days)
|
from := to.AddDate(0, 0, -days)
|
||||||
|
|
||||||
records, err := s.repo.GetStaffMetrics(ctx, membership.Tenant.ID, from, to)
|
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.StaffMetricsResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
records, err := s.repo.GetStaffMetrics(ctx, tenantID, from, to)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.StaffMetricsResponse{}, err
|
return domain.StaffMetricsResponse{}, err
|
||||||
}
|
}
|
||||||
@@ -701,19 +712,19 @@ func (s *Service) StaffMetrics(ctx context.Context, principal domain.Principal,
|
|||||||
byStaff[rec.StaffID] = rec
|
byStaff[rec.StaffID] = rec
|
||||||
}
|
}
|
||||||
|
|
||||||
staff, err := s.repo.ListStaffByTenant(ctx, membership.Tenant.ID)
|
staff, err := s.repo.ListStaffByTenant(ctx, tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.StaffMetricsResponse{}, err
|
return domain.StaffMetricsResponse{}, err
|
||||||
}
|
}
|
||||||
rules, err := s.repo.ListAvailabilityRulesByTenant(ctx, membership.Tenant.ID)
|
rules, err := s.repo.ListAvailabilityRulesByTenant(ctx, tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.StaffMetricsResponse{}, err
|
return domain.StaffMetricsResponse{}, err
|
||||||
}
|
}
|
||||||
blockedDays, err := s.repo.ListBlockedDaysByTenant(ctx, membership.Tenant.ID, from, to)
|
blockedDays, err := s.repo.ListBlockedDaysByTenant(ctx, tenantID, from, to)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.StaffMetricsResponse{}, err
|
return domain.StaffMetricsResponse{}, err
|
||||||
}
|
}
|
||||||
loc, err := time.LoadLocation(membership.Tenant.Timezone)
|
loc, err := time.LoadLocation(tenant.Timezone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
loc = time.UTC
|
loc = time.UTC
|
||||||
}
|
}
|
||||||
@@ -753,17 +764,23 @@ func (s *Service) ServiceMetrics(ctx context.Context, principal domain.Principal
|
|||||||
}
|
}
|
||||||
return domain.ServiceMetricsResponse{}, err
|
return domain.ServiceMetricsResponse{}, err
|
||||||
}
|
}
|
||||||
|
return s.ServiceMetricsForTenant(ctx, membership.Tenant.ID, days)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceMetricsForTenant is the tenant-scoped core of ServiceMetrics — used
|
||||||
|
// by scheduled jobs that have no principal.
|
||||||
|
func (s *Service) ServiceMetricsForTenant(ctx context.Context, tenantID string, days int) (domain.ServiceMetricsResponse, error) {
|
||||||
if days <= 0 || days > 365 {
|
if days <= 0 || days > 365 {
|
||||||
days = 30
|
days = 30
|
||||||
}
|
}
|
||||||
to := time.Now().UTC()
|
to := time.Now().UTC()
|
||||||
from := to.AddDate(0, 0, -days)
|
from := to.AddDate(0, 0, -days)
|
||||||
|
|
||||||
bookingsList, err := s.repo.ListBookingsByTenantBetween(ctx, membership.Tenant.ID, from, to)
|
bookingsList, err := s.repo.ListBookingsByTenantBetween(ctx, tenantID, from, to)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ServiceMetricsResponse{}, err
|
return domain.ServiceMetricsResponse{}, err
|
||||||
}
|
}
|
||||||
services, err := s.repo.ListServicesByTenant(ctx, membership.Tenant.ID)
|
services, err := s.repo.ListServicesByTenant(ctx, tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ServiceMetricsResponse{}, err
|
return domain.ServiceMetricsResponse{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1595,6 +1595,76 @@ func (s *Service) DeleteClassSession(ctx context.Context, principal domain.Princ
|
|||||||
return s.repo.DeleteClassSession(ctx, sessionID)
|
return s.repo.DeleteClassSession(ctx, sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrCapacityBelowBooked rejects shrinking a session's capacity under the
|
||||||
|
// number of confirmed seats unless the caller passes allowOverCapacity.
|
||||||
|
var ErrCapacityBelowBooked = errors.New("capacity below confirmed bookings")
|
||||||
|
|
||||||
|
// UpdateClassSession patches a single session — currently only the seat
|
||||||
|
// count. Shrinking below confirmed bookings needs an explicit override so a
|
||||||
|
// typo cannot silently overbook a session.
|
||||||
|
func (s *Service) UpdateClassSession(ctx context.Context, principal domain.Principal, sessionID string, req domain.UpdateClassSessionRequest) (domain.ClassSession, error) {
|
||||||
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ClassSession{}, ErrTenantMembership
|
||||||
|
}
|
||||||
|
session, err := s.repo.GetClassSessionByID(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.ClassSession{}, ErrClassSessionNotFound
|
||||||
|
}
|
||||||
|
return domain.ClassSession{}, err
|
||||||
|
}
|
||||||
|
if session.TenantID != membership.Tenant.ID {
|
||||||
|
return domain.ClassSession{}, ErrClassSessionNotFound
|
||||||
|
}
|
||||||
|
if req.Capacity == nil {
|
||||||
|
return domain.ClassSession{}, fmt.Errorf("%w: capacity is required", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
if *req.Capacity < 1 || *req.Capacity > 500 {
|
||||||
|
return domain.ClassSession{}, fmt.Errorf("%w: capacity must be 1-500", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionBookings, err := s.repo.ListBookingsBySession(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ClassSession{}, err
|
||||||
|
}
|
||||||
|
confirmed := 0
|
||||||
|
for _, booking := range sessionBookings {
|
||||||
|
if booking.Status == "confirmed" {
|
||||||
|
confirmed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *req.Capacity < confirmed && !req.AllowOverCapacity {
|
||||||
|
return domain.ClassSession{}, fmt.Errorf("%w: %d confirmed seats", ErrCapacityBelowBooked, confirmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, err := s.repo.UpdateClassSessionCapacity(ctx, sessionID, *req.Capacity)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.ClassSession{}, ErrClassSessionNotFound
|
||||||
|
}
|
||||||
|
return domain.ClassSession{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := domain.ClassSession{
|
||||||
|
ID: rec.ID,
|
||||||
|
TenantID: rec.TenantID,
|
||||||
|
TemplateID: rec.TemplateID,
|
||||||
|
TemplateTitle: rec.Title,
|
||||||
|
LocationID: rec.LocationID,
|
||||||
|
StartsAt: rec.StartsAt,
|
||||||
|
EndsAt: rec.EndsAt,
|
||||||
|
Capacity: int(rec.Capacity),
|
||||||
|
BookedCount: confirmed,
|
||||||
|
}
|
||||||
|
if rec.LocationID != nil {
|
||||||
|
if location, err := s.repo.GetLocationByID(ctx, *rec.LocationID); err == nil {
|
||||||
|
out.LocationName = location.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) requireTenantClassTemplate(ctx context.Context, tenantID, templateID string) error {
|
func (s *Service) requireTenantClassTemplate(ctx context.Context, tenantID, templateID string) error {
|
||||||
template, err := s.repo.GetClassTemplateByID(ctx, templateID)
|
template, err := s.repo.GetClassTemplateByID(ctx, templateID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -993,3 +993,61 @@ func TestUpdateClassScheduleWithoutCascade(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateClassSessionCapacityFloor(t *testing.T) {
|
||||||
|
repo := db.NewMemoryRepository()
|
||||||
|
svc := newCatalogService(repo)
|
||||||
|
ctx := context.Background()
|
||||||
|
tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
|
||||||
|
|
||||||
|
session, err := repo.CreateClassSession(ctx, db.CreateClassSessionParams{
|
||||||
|
TenantID: tenantID,
|
||||||
|
TemplateID: "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7",
|
||||||
|
StartsAt: time.Now().UTC().Add(48 * time.Hour),
|
||||||
|
EndsAt: time.Now().UTC().Add(49 * time.Hour),
|
||||||
|
Capacity: 4,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create session: %v", err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"A", "B", "C"} {
|
||||||
|
if _, err := repo.CreateBooking(ctx, db.CreateBookingParams{
|
||||||
|
TenantID: tenantID,
|
||||||
|
ClassSessionID: &session.ID,
|
||||||
|
BookingMode: "class",
|
||||||
|
CustomerName: name,
|
||||||
|
CustomerEmail: name + "@example.com",
|
||||||
|
StartsAt: session.StartsAt,
|
||||||
|
EndsAt: session.EndsAt,
|
||||||
|
Status: "confirmed",
|
||||||
|
Reference: "BK-CAP-" + name,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed booking %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shrinking to 2 with 3 confirmed seats → conflict without override.
|
||||||
|
cap2 := 2
|
||||||
|
if _, err := svc.UpdateClassSession(ctx, owner, session.ID, domain.UpdateClassSessionRequest{Capacity: &cap2}); !errors.Is(err, ErrCapacityBelowBooked) {
|
||||||
|
t.Fatalf("expected ErrCapacityBelowBooked, got %v", err)
|
||||||
|
}
|
||||||
|
// With override it applies.
|
||||||
|
updated, err := svc.UpdateClassSession(ctx, owner, session.ID, domain.UpdateClassSessionRequest{Capacity: &cap2, AllowOverCapacity: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("override update: %v", err)
|
||||||
|
}
|
||||||
|
if updated.Capacity != 2 || updated.BookedCount != 3 {
|
||||||
|
t.Fatalf("expected capacity=2 booked=3, got %d/%d", updated.Capacity, updated.BookedCount)
|
||||||
|
}
|
||||||
|
// Growing always works.
|
||||||
|
cap8 := 8
|
||||||
|
updated, err = svc.UpdateClassSession(ctx, owner, session.ID, domain.UpdateClassSessionRequest{Capacity: &cap8})
|
||||||
|
if err != nil || updated.Capacity != 8 {
|
||||||
|
t.Fatalf("grow update: %v cap=%d", err, updated.Capacity)
|
||||||
|
}
|
||||||
|
// Validation floor.
|
||||||
|
cap0 := 0
|
||||||
|
if _, err := svc.UpdateClassSession(ctx, owner, session.ID, domain.UpdateClassSessionRequest{Capacity: &cap0}); !errors.Is(err, ErrInvalidInput) {
|
||||||
|
t.Fatalf("expected ErrInvalidInput for capacity 0, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
const classTemplateColumns = "id, tenant_id, title, duration_minutes, capacity, created_at"
|
const classTemplateColumns = "id, tenant_id, title, duration_minutes, capacity, created_at"
|
||||||
@@ -132,3 +134,14 @@ func (r *PGRepository) DeleteClassSession(ctx context.Context, sessionID string)
|
|||||||
_, err := r.pool.Exec(ctx, `DELETE FROM class_sessions WHERE id = $1`, sessionID)
|
_, err := r.pool.Exec(ctx, `DELETE FROM class_sessions WHERE id = $1`, sessionID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) UpdateClassSessionCapacity(ctx context.Context, sessionID string, capacity int) (ClassSessionRecord, error) {
|
||||||
|
tag, err := r.pool.Exec(ctx, `UPDATE class_sessions SET capacity = $2 WHERE id = $1`, sessionID, capacity)
|
||||||
|
if err != nil {
|
||||||
|
return ClassSessionRecord{}, err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ClassSessionRecord{}, pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
return r.GetClassSessionByID(ctx, sessionID)
|
||||||
|
}
|
||||||
@@ -30,6 +30,9 @@ type Repository interface {
|
|||||||
GetClassSessionByID(ctx context.Context, sessionID string) (ClassSessionRecord, error)
|
GetClassSessionByID(ctx context.Context, sessionID string) (ClassSessionRecord, error)
|
||||||
CreateClassSession(ctx context.Context, params CreateClassSessionParams) (ClassSessionRecord, error)
|
CreateClassSession(ctx context.Context, params CreateClassSessionParams) (ClassSessionRecord, error)
|
||||||
DeleteClassSession(ctx context.Context, sessionID string) error
|
DeleteClassSession(ctx context.Context, sessionID string) error
|
||||||
|
// UpdateClassSessionCapacity sets the session's seat count; the caller
|
||||||
|
// checks tenancy and the booked-seats floor.
|
||||||
|
UpdateClassSessionCapacity(ctx context.Context, sessionID string, capacity int) (ClassSessionRecord, error)
|
||||||
ListClassTemplatesByTenant(ctx context.Context, tenantID string) ([]ClassTemplateRecord, error)
|
ListClassTemplatesByTenant(ctx context.Context, tenantID string) ([]ClassTemplateRecord, error)
|
||||||
GetClassTemplateByID(ctx context.Context, templateID string) (ClassTemplateRecord, error)
|
GetClassTemplateByID(ctx context.Context, templateID string) (ClassTemplateRecord, error)
|
||||||
CreateClassTemplate(ctx context.Context, params CreateClassTemplateParams) (ClassTemplateRecord, error)
|
CreateClassTemplate(ctx context.Context, params CreateClassTemplateParams) (ClassTemplateRecord, error)
|
||||||
@@ -1539,6 +1542,16 @@ func (r *MemoryRepository) DeleteClassSession(_ context.Context, sessionID strin
|
|||||||
return pgx.ErrNoRows
|
return pgx.ErrNoRows
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *MemoryRepository) UpdateClassSessionCapacity(_ context.Context, sessionID string, capacity int) (ClassSessionRecord, error) {
|
||||||
|
for i := range r.classSessions {
|
||||||
|
if r.classSessions[i].ID == sessionID {
|
||||||
|
r.classSessions[i].Capacity = int32(capacity)
|
||||||
|
return r.classSessions[i], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ClassSessionRecord{}, pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
|
||||||
func (r *MemoryRepository) ListClassSchedulesByTenant(_ context.Context, tenantID string) ([]ClassScheduleRecord, error) {
|
func (r *MemoryRepository) ListClassSchedulesByTenant(_ context.Context, tenantID string) ([]ClassScheduleRecord, error) {
|
||||||
if tenantID != r.tenant.ID {
|
if tenantID != r.tenant.ID {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@@ -263,6 +263,15 @@ type DispatchReminderJobsResponse struct {
|
|||||||
SkippedCount int `json:"skippedCount"`
|
SkippedCount int `json:"skippedCount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DispatchReportDigestsResponse summarizes one run of the scheduled digest
|
||||||
|
// job: how many tenants were considered and how the sends resolved.
|
||||||
|
type DispatchReportDigestsResponse struct {
|
||||||
|
TenantCount int `json:"tenantCount"`
|
||||||
|
SentCount int `json:"sentCount"`
|
||||||
|
SkippedCount int `json:"skippedCount"`
|
||||||
|
FailedCount int `json:"failedCount"`
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// LOCATION / ZONE MODELS
|
// LOCATION / ZONE MODELS
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -470,6 +479,14 @@ type CreateClassSessionRequest struct {
|
|||||||
Capacity *int `json:"capacity,omitempty"`
|
Capacity *int `json:"capacity,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateClassSessionRequest patches a single session. Capacity is the only
|
||||||
|
// staff-editable field; shrinking below the confirmed seat count needs
|
||||||
|
// AllowOverCapacity (same convention as promoting onto a full session).
|
||||||
|
type UpdateClassSessionRequest struct {
|
||||||
|
Capacity *int `json:"capacity"`
|
||||||
|
AllowOverCapacity bool `json:"allowOverCapacity,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// ClassSchedule is a weekly recurring rule for a class template. Concrete
|
// ClassSchedule is a weekly recurring rule for a class template. Concrete
|
||||||
// sessions are materialized for a rolling horizon; DayOfWeek follows Go's
|
// sessions are materialized for a rolling horizon; DayOfWeek follows Go's
|
||||||
// time.Weekday (Sunday=0) and StartsLocal is an "HH:MM" tenant-local time.
|
// time.Weekday (Sunday=0) and StartsLocal is an "HH:MM" tenant-local time.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1311,6 +1311,38 @@ paths:
|
|||||||
"404":
|
"404":
|
||||||
description: Template or location not found
|
description: Template or location not found
|
||||||
/v1/catalog/class-sessions/{sessionID}:
|
/v1/catalog/class-sessions/{sessionID}:
|
||||||
|
patch:
|
||||||
|
tags: [Catalog]
|
||||||
|
operationId: updateClassSession
|
||||||
|
summary: Patch a class session (currently capacity only)
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: sessionID
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UpdateClassSessionRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Updated class session
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ClassSession"
|
||||||
|
"400":
|
||||||
|
description: Invalid request
|
||||||
|
"404":
|
||||||
|
description: Class session not found
|
||||||
|
"409":
|
||||||
|
description: Capacity below confirmed bookings (pass allowOverCapacity to force)
|
||||||
delete:
|
delete:
|
||||||
tags: [Catalog]
|
tags: [Catalog]
|
||||||
operationId: deleteClassSession
|
operationId: deleteClassSession
|
||||||
@@ -1938,6 +1970,36 @@ paths:
|
|||||||
description: Monthly SMS invoices generated
|
description: Monthly SMS invoices generated
|
||||||
"401":
|
"401":
|
||||||
description: Unauthorized
|
description: Unauthorized
|
||||||
|
/v1/internal/jobs/reports/dispatch:
|
||||||
|
post:
|
||||||
|
tags: [Jobs]
|
||||||
|
operationId: dispatchReportDigests
|
||||||
|
summary: Email each tenant owner a trailing-window business digest
|
||||||
|
security:
|
||||||
|
- jobRunnerKey: []
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: days
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 7
|
||||||
|
maximum: 365
|
||||||
|
description: Trailing window length in days
|
||||||
|
- in: query
|
||||||
|
name: tenantId
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Scope the run to a single tenant
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Digests dispatched
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/DispatchReportDigestsResponse"
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
bearerAuth:
|
bearerAuth:
|
||||||
@@ -3083,6 +3145,17 @@ components:
|
|||||||
startsLocal:
|
startsLocal:
|
||||||
type: string
|
type: string
|
||||||
description: Tenant-local start time, HH:MM
|
description: Tenant-local start time, HH:MM
|
||||||
|
UpdateClassSessionRequest:
|
||||||
|
type: object
|
||||||
|
required: [capacity]
|
||||||
|
properties:
|
||||||
|
capacity:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 500
|
||||||
|
allowOverCapacity:
|
||||||
|
type: boolean
|
||||||
|
description: Permit shrinking below the confirmed seat count
|
||||||
UpdateClassScheduleRequest:
|
UpdateClassScheduleRequest:
|
||||||
type: object
|
type: object
|
||||||
description: >-
|
description: >-
|
||||||
@@ -3264,3 +3337,15 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
skippedCount:
|
skippedCount:
|
||||||
type: integer
|
type: integer
|
||||||
|
DispatchReportDigestsResponse:
|
||||||
|
type: object
|
||||||
|
required: [tenantCount, sentCount, skippedCount, failedCount]
|
||||||
|
properties:
|
||||||
|
tenantCount:
|
||||||
|
type: integer
|
||||||
|
sentCount:
|
||||||
|
type: integer
|
||||||
|
skippedCount:
|
||||||
|
type: integer
|
||||||
|
failedCount:
|
||||||
|
type: integer
|
||||||
@@ -457,6 +457,9 @@ const dictionaries = {
|
|||||||
"dashboard.class.applyToFutureHint": "Přesune již vytvořené lekce na nový čas a místo, rezervace se přesunou s nimi.",
|
"dashboard.class.applyToFutureHint": "Přesune již vytvořené lekce na nový čas a místo, rezervace se přesunou s nimi.",
|
||||||
"dashboard.class.ruleActive": "Pravidlo je aktivní",
|
"dashboard.class.ruleActive": "Pravidlo je aktivní",
|
||||||
"dashboard.class.inactive": "neaktivní",
|
"dashboard.class.inactive": "neaktivní",
|
||||||
|
"dashboard.class.editCapacity": "Upravit kapacitu",
|
||||||
|
"dashboard.class.capacityLabel": "Kapacita",
|
||||||
|
"dashboard.class.capacityOverbookHint": "Kapacita je nižší než počet potvrzených míst — uložením se lekce přebookuje.",
|
||||||
"dashboard.class.dow.0": "Neděle",
|
"dashboard.class.dow.0": "Neděle",
|
||||||
"dashboard.class.dow.1": "Pondělí",
|
"dashboard.class.dow.1": "Pondělí",
|
||||||
"dashboard.class.dow.2": "Úterý",
|
"dashboard.class.dow.2": "Úterý",
|
||||||
@@ -1093,6 +1096,9 @@ const dictionaries = {
|
|||||||
"dashboard.class.applyToFutureHint": "Moves already-created sessions to the new time and location; bookings move with them.",
|
"dashboard.class.applyToFutureHint": "Moves already-created sessions to the new time and location; bookings move with them.",
|
||||||
"dashboard.class.ruleActive": "Rule is active",
|
"dashboard.class.ruleActive": "Rule is active",
|
||||||
"dashboard.class.inactive": "inactive",
|
"dashboard.class.inactive": "inactive",
|
||||||
|
"dashboard.class.editCapacity": "Edit capacity",
|
||||||
|
"dashboard.class.capacityLabel": "Capacity",
|
||||||
|
"dashboard.class.capacityOverbookHint": "Capacity is below the confirmed seat count — saving overbooks the session.",
|
||||||
"dashboard.class.dow.0": "Sunday",
|
"dashboard.class.dow.0": "Sunday",
|
||||||
"dashboard.class.dow.1": "Monday",
|
"dashboard.class.dow.1": "Monday",
|
||||||
"dashboard.class.dow.2": "Tuesday",
|
"dashboard.class.dow.2": "Tuesday",
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ function ZonesPage() {
|
|||||||
const [editSchedApplyFuture, setEditSchedApplyFuture] = createSignal(true);
|
const [editSchedApplyFuture, setEditSchedApplyFuture] = createSignal(true);
|
||||||
const [editSchedActive, setEditSchedActive] = createSignal(true);
|
const [editSchedActive, setEditSchedActive] = createSignal(true);
|
||||||
const [schedEditSaving, setSchedEditSaving] = createSignal(false);
|
const [schedEditSaving, setSchedEditSaving] = createSignal(false);
|
||||||
|
const [editingCapSessionId, setEditingCapSessionId] = createSignal<string | null>(null);
|
||||||
|
const [editCapValue, setEditCapValue] = createSignal(0);
|
||||||
|
const [capSaving, setCapSaving] = createSignal(false);
|
||||||
|
// Session ID whose capacity shrink hit the confirmed-seat floor — the row
|
||||||
|
// switches to an explicit "set anyway" confirm before resubmitting.
|
||||||
|
const [capOverbookConfirm, setCapOverbookConfirm] = createSignal<string | null>(null);
|
||||||
const [expandedSessionId, setExpandedSessionId] = createSignal<string | null>(null);
|
const [expandedSessionId, setExpandedSessionId] = createSignal<string | null>(null);
|
||||||
const [promoteBusy, setPromoteBusy] = createSignal<string | null>(null);
|
const [promoteBusy, setPromoteBusy] = createSignal<string | null>(null);
|
||||||
// Booking IDs whose promote attempt hit a full session — awaiting the
|
// Booking IDs whose promote attempt hit a full session — awaiting the
|
||||||
@@ -448,6 +454,35 @@ function ZonesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startEditCapacity = (session: any) => {
|
||||||
|
setEditingCapSessionId(session.id);
|
||||||
|
setEditCapValue(session.capacity);
|
||||||
|
setCapOverbookConfirm(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveCapacity = async (session: any, allowOverCapacity: boolean) => {
|
||||||
|
const bearer = data.token();
|
||||||
|
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
|
||||||
|
setCapSaving(true);
|
||||||
|
try {
|
||||||
|
const response = await (apiClient as any).PATCH(`/v1/catalog/class-sessions/${session.id}`, {
|
||||||
|
...authHeaders(),
|
||||||
|
body: { capacity: editCapValue(), allowOverCapacity },
|
||||||
|
});
|
||||||
|
if (response.response?.status === 409 && !allowOverCapacity) {
|
||||||
|
setCapOverbookConfirm(session.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditingCapSessionId(null);
|
||||||
|
setCapOverbookConfirm(null);
|
||||||
|
void refetchSessions();
|
||||||
|
} catch {
|
||||||
|
data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed.");
|
||||||
|
} finally {
|
||||||
|
setCapSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteSchedule = async (id: string) => {
|
const handleDeleteSchedule = async (id: string) => {
|
||||||
const bearer = data.token();
|
const bearer = data.token();
|
||||||
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode."); return; }
|
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode."); return; }
|
||||||
@@ -891,9 +926,26 @@ function ZonesPage() {
|
|||||||
{expanded() ? data.i18n.t("dashboard.class.hideRoster") : data.i18n.t("dashboard.class.showRoster")}
|
{expanded() ? data.i18n.t("dashboard.class.hideRoster") : data.i18n.t("dashboard.class.showRoster")}
|
||||||
<span class={`inline-flex transition-transform duration-200 ${expanded() ? "rotate-180" : ""}`}><ChevronDownIcon /></span>
|
<span class={`inline-flex transition-transform duration-200 ${expanded() ? "rotate-180" : ""}`}><ChevronDownIcon /></span>
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={() => startEditCapacity(session)} class="p-2 text-ink-subtle hover:text-accent hover:bg-accent-subtle/40 rounded-lg transition-colors" aria-label={data.i18n.t("dashboard.class.editCapacity")}><PencilIcon /></button>
|
||||||
<button onClick={() => void handleDeleteSession(session.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" aria-label={cs() ? "Smazat" : "Delete"}><Trash2Icon /></button>
|
<button onClick={() => void handleDeleteSession(session.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" aria-label={cs() ? "Smazat" : "Delete"}><Trash2Icon /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Show when={editingCapSessionId() === session.id}>
|
||||||
|
<div class="px-6 pb-4 animate-fade-in">
|
||||||
|
<div class="flex flex-wrap items-end gap-3 rounded-xl border border-border/60 bg-canvas-subtle/30 p-4">
|
||||||
|
<Input type="number" label={data.i18n.t("dashboard.class.capacityLabel")} value={String(editCapValue())} min={1} onInput={(e) => setEditCapValue(parseInt(e.currentTarget.value, 10) || 0)} />
|
||||||
|
<button onClick={() => void handleSaveCapacity(session, capOverbookConfirm() === session.id)} disabled={capSaving() || editCapValue() < 1} class="btn-primary text-sm disabled:opacity-60">
|
||||||
|
<CheckIcon /> {capSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.saveChanges")}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setEditingCapSessionId(null); setCapOverbookConfirm(null); }} class="btn-secondary text-sm">
|
||||||
|
<XIcon /> {data.i18n.t("dashboard.cancel")}
|
||||||
|
</button>
|
||||||
|
<Show when={capOverbookConfirm() === session.id}>
|
||||||
|
<p class="w-full text-sm text-warning font-medium">{data.i18n.t("dashboard.class.capacityOverbookHint")}</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
<Show when={expanded()}>
|
<Show when={expanded()}>
|
||||||
<div class="px-6 pb-5 animate-fade-in">
|
<div class="px-6 pb-5 animate-fade-in">
|
||||||
<div class="rounded-xl border border-border/60 bg-canvas-subtle/30 overflow-hidden">
|
<div class="rounded-xl border border-border/60 bg-canvas-subtle/30 overflow-hidden">
|
||||||
|
|||||||
@@ -461,7 +461,8 @@ export interface paths {
|
|||||||
delete: operations["deleteClassSession"];
|
delete: operations["deleteClassSession"];
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
patch?: never;
|
/** Patch a class session (currently capacity only) */
|
||||||
|
patch: operations["updateClassSession"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/v1/catalog/class-sessions/{sessionID}/roster": {
|
"/v1/catalog/class-sessions/{sessionID}/roster": {
|
||||||
@@ -816,6 +817,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/v1/internal/jobs/reports/dispatch": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Email each tenant owner a trailing-window business digest */
|
||||||
|
post: operations["dispatchReportDigests"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/v1/internal/jobs/sms/invoices": {
|
"/v1/internal/jobs/sms/invoices": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1564,6 +1582,12 @@ export interface components {
|
|||||||
sentCount: number;
|
sentCount: number;
|
||||||
skippedCount: number;
|
skippedCount: number;
|
||||||
};
|
};
|
||||||
|
DispatchReportDigestsResponse: {
|
||||||
|
failedCount: number;
|
||||||
|
sentCount: number;
|
||||||
|
skippedCount: number;
|
||||||
|
tenantCount: number;
|
||||||
|
};
|
||||||
EmailTemplate: {
|
EmailTemplate: {
|
||||||
bodyText?: string;
|
bodyText?: string;
|
||||||
isEnabled?: boolean;
|
isEnabled?: boolean;
|
||||||
@@ -1922,6 +1946,11 @@ export interface components {
|
|||||||
/** @description Tenant-local start time, HH:MM */
|
/** @description Tenant-local start time, HH:MM */
|
||||||
startsLocal?: string;
|
startsLocal?: string;
|
||||||
};
|
};
|
||||||
|
UpdateClassSessionRequest: {
|
||||||
|
/** @description Permit shrinking below the confirmed seat count */
|
||||||
|
allowOverCapacity?: boolean;
|
||||||
|
capacity: number;
|
||||||
|
};
|
||||||
UpdateEmailTemplateRequest: {
|
UpdateEmailTemplateRequest: {
|
||||||
body?: string;
|
body?: string;
|
||||||
isEnabled?: boolean;
|
isEnabled?: boolean;
|
||||||
@@ -3039,6 +3068,53 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
updateClassSession: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
sessionID: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UpdateClassSessionRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Updated class session */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ClassSession"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Invalid request */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Class session not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Capacity below confirmed bookings (pass allowOverCapacity to force) */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
getSessionRoster: {
|
getSessionRoster: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3965,6 +4041,38 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
dispatchReportDigests: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
/** @description Trailing window length in days */
|
||||||
|
days?: number;
|
||||||
|
/** @description Scope the run to a single tenant */
|
||||||
|
tenantId?: string;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Digests dispatched */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["DispatchReportDigestsResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
generateSmsInvoices: {
|
generateSmsInvoices: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
+9
-2
@@ -236,6 +236,13 @@
|
|||||||
- `waitlist_entries` dropped (migration 00020): the ledger was written on join/promotion but never read — positions derive from bookings' `created_at`
|
- `waitlist_entries` dropped (migration 00020): the ledger was written on join/promotion but never read — positions derive from bookings' `created_at`
|
||||||
- verified live: PATCH retime cascades to session and booking windows; without `applyToFuture` sessions stay; 400/404 paths correct; focused tests cover cascade and no-cascade paths
|
- verified live: PATCH retime cascades to session and booking windows; without `applyToFuture` sessions stay; 400/404 paths correct; focused tests cover cascade and no-cascade paths
|
||||||
- remaining: live staging validation on Neon + pgx repository path
|
- remaining: live staging validation on Neon + pgx repository path
|
||||||
|
- `[done]` Milestone 33: Scheduled report digests + per-session capacity
|
||||||
|
- `POST /v1/internal/jobs/reports/dispatch?days=&tenantId=` (job-runner key) emails each tenant owner a trailing-window digest — totals, per-service bookings/revenue, per-staff utilization — localized to the tenant locale, dashboard link included; tenants with zero activity are skipped (a silent week is signal, not noise), failed sends are counted and logged to the activity feed
|
||||||
|
- `bookings.Service` gains tenant-scoped `StaffMetricsForTenant`/`ServiceMetricsForTenant` cores so jobs need no principal; the principal-facing methods delegate
|
||||||
|
- `POST /v1/internal/jobs/reports/dispatch` accepts `tenantId` for single-tenant runs (manual retry, per-tenant crons); cadence lives with the external scheduler — the job itself is deliberately stateless
|
||||||
|
- `PATCH /v1/catalog/class-sessions/:id` sets a session's seat count; shrinking below confirmed bookings returns `409` unless `allowOverCapacity` — same explicit-override convention as waitlist promotion; session rows gain an inline capacity editor (CS/EN) with an overbook confirm state
|
||||||
|
- verified live: dispatch authorized by job key, sent 1 digest for the seeded tenant; capacity PATCH grew/shrank sessions and rejected below-floor values; tests cover localized render, zero-activity skip, send-failure counting, and the capacity floor
|
||||||
|
- remaining: live staging validation on Neon + pgx repository path
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP.
|
Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP.
|
||||||
@@ -487,10 +494,10 @@ Shortlist of worthwhile directions once v1 stabilizes — kept deliberately smal
|
|||||||
|
|
||||||
- **Google Calendar sync**: M22 shipped read-only ICS feeds; two-way sync (push bookings into Google, pull busy blocks back as availability exceptions) needs OAuth and conflict rules — larger commitment, defer until a concrete request.
|
- **Google Calendar sync**: M22 shipped read-only ICS feeds; two-way sync (push bookings into Google, pull busy blocks back as availability exceptions) needs OAuth and conflict rules — larger commitment, defer until a concrete request.
|
||||||
- **Browser push notifications**: M27 persists read state and the feed covers deliveries; Web Push (service worker + VAPID) would notify owners about new bookings without keeping the dashboard open — defer until a concrete request, email already covers off-site reach.
|
- **Browser push notifications**: M27 persists read state and the feed covers deliveries; Web Push (service worker + VAPID) would notify owners about new bookings without keeping the dashboard open — defer until a concrete request, email already covers off-site reach.
|
||||||
- **Scheduled report delivery**: period-end exports emailed automatically (cron + existing mail transport) instead of manual downloads — pairs naturally with M28's report endpoints.
|
|
||||||
- **Intake reporting**: M31 shows labels, but answers aren't queryable — filtering or aggregating yes-no answers (e.g. "first visits this month") needs a stored projection, not JSONB scans; defer until a reporting request exists.
|
- **Intake reporting**: M31 shows labels, but answers aren't queryable — filtering or aggregating yes-no answers (e.g. "first visits this month") needs a stored projection, not JSONB scans; defer until a reporting request exists.
|
||||||
- **Bulk session shift**: M32 retimes future sessions of one rule; a genuine weekday *move* (relocate existing sessions to the new weekday, not just steer future materialization) and multi-rule shifts (holiday weeks) are still manual — defer until requested.
|
- **Bulk session shift**: M32 retimes future sessions of one rule; a genuine weekday *move* (relocate existing sessions to the new weekday, not just steer future materialization) and multi-rule shifts (holiday weeks) are still manual — defer until requested.
|
||||||
- **Per-session capacity override**: session capacity still comes only from the template; letting staff override capacity on one session (or one rule) is a small schema addition if a real need appears.
|
- **Digest preferences**: M33's digest is on-or-nothing per tenant — a per-tenant opt-out/cadence setting (or per-member recipients) belongs in notification settings if owners ask for control.
|
||||||
|
- **Digest attachments**: the digest is text-only; attaching the CSV exports (M28) needs multipart/mixed in the SMTP provider — small lift, defer until asked.
|
||||||
|
|
||||||
## Product Principles
|
## Product Principles
|
||||||
- calm, premium, structured UX
|
- calm, premium, structured UX
|
||||||
|
|||||||
Reference in new issue
Block a user