feat: scheduled report digests + per-session capacity (M33)

POST /v1/internal/jobs/reports/dispatch emails each tenant owner a
trailing-window digest (bookings, revenue, per-service and per-staff
breakdown) localized to the tenant locale; zero-activity tenants are
skipped. Metrics cores extracted to tenant-scoped variants so the job
needs no principal.

PATCH /v1/catalog/class-sessions/:id sets a session's seat count with
a confirmed-bookings floor — shrinking below it needs allowOverCapacity,
matching the waitlist-promotion convention. Session rows gain an inline
capacity editor with an overbook confirm state.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-22 21:29:44 +02:00
1 parent e6f1ec38a4
commit e680e0c2b8
14 files changed
+808 -10

No files matched your search

+41
View File
@@ -25,6 +25,7 @@ import (
"bookra/apps/backend/internal/export"
"bookra/apps/backend/internal/httpx"
"bookra/apps/backend/internal/notifications"
"bookra/apps/backend/internal/reports"
"bookra/apps/backend/internal/sms"
"bookra/apps/backend/internal/tenancy"
@@ -65,6 +66,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
authService := auth.NewService(repository, cfg.AuthJWTSecret)
adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey)
smsService := sms.NewService(cfg, repository)
reportService := reports.NewService(repository, bookingService, cfg.FrontendURL, cfg.EmailFrom)
notificationService.SetSMSSender(func(ctx context.Context, tenantID, phone, body string) error {
_, err := smsService.SendMessage(ctx, tenantID, domain.SendSMSRequest{To: phone, Body: body})
return err
@@ -1103,6 +1105,28 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
}
c.JSON(http.StatusOK, response)
})
protected.PATCH("/catalog/class-sessions/:sessionID", func(c *gin.Context) {
var request domain.UpdateClassSessionRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.UpdateClassSession(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("sessionID"), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrClassSessionNotFound):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidInput):
status = http.StatusBadRequest
case errors.Is(err, catalog.ErrCapacityBelowBooked):
status = http.StatusConflict
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.DELETE("/catalog/class-sessions/:sessionID", func(c *gin.Context) {
err := catalogService.DeleteClassSession(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("sessionID"))
if err != nil {
@@ -1697,6 +1721,23 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusOK, response)
})
server.router.POST("/v1/internal/jobs/reports/dispatch", func(c *gin.Context) {
if !authorizeJobRunner(c, cfg) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
days, _ := strconv.Atoi(c.DefaultQuery("days", "7"))
response, err := reportService.DispatchDigests(c.Request.Context(), days, c.Query("tenantId"), func(ctx context.Context, msg notifications.EmailMessage) error {
_, sendErr := notificationService.SendRawEmail(ctx, msg)
return sendErr
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
// Widget embeddable script endpoint - serves JavaScript for external sites
server.router.GET("/v1/public/widget.js", func(c *gin.Context) {
c.Header("Content-Type", "application/javascript; charset=utf-8")