feat(export): CSV export for bookings and customers

GET /v1/export/bookings.csv (optional from/to bounds) and
/v1/export/customers.csv — UTF-8 with BOM for Excel, labels resolved
at export time, buffered responses so auth failures stay JSON.
Export buttons on the bookings and customers pages.

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-18 16:15:36 +02:00
1 parent d86cba61e3
commit 4674203502
12 files changed
+583 -5

No files matched your search

+52
View File
@@ -1,6 +1,7 @@
package api
import (
"bytes"
"context"
"errors"
"fmt"
@@ -21,6 +22,7 @@ import (
"bookra/apps/backend/internal/config"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"bookra/apps/backend/internal/export"
"bookra/apps/backend/internal/httpx"
"bookra/apps/backend/internal/notifications"
"bookra/apps/backend/internal/sms"
@@ -59,6 +61,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
catalogService := catalog.NewService(repository, billingService, notificationService)
calendarService := calendar.NewService(repository)
activityService := activity.NewService(repository)
exportService := export.NewService(repository)
authService := auth.NewService(repository, cfg.AuthJWTSecret)
adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey)
smsService := sms.NewService(cfg, repository)
@@ -1293,6 +1296,37 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusOK, response)
})
protected.GET("/export/bookings.csv", func(c *gin.Context) {
from, to := exportRange(c)
var buf bytes.Buffer
err := exportService.WriteBookingsCSV(c.Request.Context(), auth.PrincipalFromContext(c), &buf, from, to)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, export.ErrTenantMembership) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.Header("Content-Disposition", `attachment; filename="bookra-bookings.csv"`)
c.Data(http.StatusOK, "text/csv; charset=utf-8", buf.Bytes())
})
protected.GET("/export/customers.csv", func(c *gin.Context) {
var buf bytes.Buffer
err := exportService.WriteCustomersCSV(c.Request.Context(), auth.PrincipalFromContext(c), &buf)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, export.ErrTenantMembership) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.Header("Content-Disposition", `attachment; filename="bookra-customers.csv"`)
c.Data(http.StatusOK, "text/csv; charset=utf-8", buf.Bytes())
})
protected.GET("/billing/subscription", func(c *gin.Context) {
response, err := billingService.GetSubscription(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
@@ -1578,6 +1612,24 @@ func (s *Server) runTrialEndingCheck() {
}
}
// exportRange parses optional from/to=YYYY-MM-DD bounds for CSV export;
// the default is effectively all-time.
func exportRange(c *gin.Context) (time.Time, time.Time) {
from := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
to := time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC)
if value := c.Query("from"); value != "" {
if parsed, err := time.Parse("2006-01-02", value); err == nil {
from = parsed
}
}
if value := c.Query("to"); value != "" {
if parsed, err := time.Parse("2006-01-02", value); err == nil {
to = parsed.AddDate(0, 0, 1) // inclusive day bound
}
}
return from, to
}
func authorizeJobRunner(c *gin.Context, cfg config.Config) bool {
if cfg.JobRunnerKey == "" {
return false