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
+174
View File
@@ -0,0 +1,174 @@
package export
import (
"context"
"encoding/csv"
"errors"
"io"
"time"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
var ErrTenantMembership = errors.New("tenant membership not found")
// utf8BOM lets Excel detect the encoding for Czech diacritics.
var utf8BOM = []byte{0xEF, 0xBB, 0xBF}
const customerPageSize = 500
type Service struct {
repo db.Repository
}
func NewService(repo db.Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) tenantFor(ctx context.Context, principal domain.Principal) (db.TenantRecord, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return db.TenantRecord{}, ErrTenantMembership
}
return membership.Tenant, nil
}
// WriteBookingsCSV streams every tenant booking in [from, to) as CSV rows.
// Service labels resolve to service names for appointments and session titles
// for classes, matching how the rest of the app labels them.
func (s *Service) WriteBookingsCSV(ctx context.Context, principal domain.Principal, w io.Writer, from, to time.Time) error {
tenant, err := s.tenantFor(ctx, principal)
if err != nil {
return err
}
bookings, err := s.repo.ListBookingsByTenantBetween(ctx, tenant.ID, from, to)
if err != nil {
return err
}
services, err := s.repo.ListServicesByTenant(ctx, tenant.ID)
if err != nil {
return err
}
sessions, err := s.repo.ListClassSessionsByTenant(ctx, tenant.ID, from, 10000)
if err != nil {
return err
}
staff, err := s.repo.ListStaffByTenant(ctx, tenant.ID)
if err != nil {
return err
}
locations, err := s.repo.ListLocationsByTenant(ctx, tenant.ID)
if err != nil {
return err
}
serviceNames := make(map[string]string, len(services))
for _, service := range services {
serviceNames[service.ID] = service.Name
}
sessionTitles := make(map[string]string, len(sessions))
for _, session := range sessions {
sessionTitles[session.ID] = session.Title
}
staffNames := make(map[string]string, len(staff))
for _, member := range staff {
staffNames[member.ID] = member.DisplayName
}
locationNames := make(map[string]string, len(locations))
for _, location := range locations {
locationNames[location.ID] = location.Name
}
if _, err := w.Write(utf8BOM); err != nil {
return err
}
out := csv.NewWriter(w)
defer out.Flush()
if err := out.Write([]string{
"reference", "status", "customer_name", "customer_email", "customer_phone",
"service", "staff", "location", "starts_at", "ends_at", "created_at", "notes",
}); err != nil {
return err
}
for _, booking := range bookings {
label := ""
if booking.ServiceID != nil {
label = serviceNames[*booking.ServiceID]
}
if booking.ClassSessionID != nil {
label = sessionTitles[*booking.ClassSessionID]
}
staffName := ""
if booking.StaffID != nil {
staffName = staffNames[*booking.StaffID]
}
locationName := ""
if booking.LocationID != nil {
locationName = locationNames[*booking.LocationID]
}
if err := out.Write([]string{
booking.Reference,
booking.Status,
booking.CustomerName,
booking.CustomerEmail,
booking.CustomerPhone,
label,
staffName,
locationName,
booking.StartsAt.UTC().Format(time.RFC3339),
booking.EndsAt.UTC().Format(time.RFC3339),
booking.CreatedAt.UTC().Format(time.RFC3339),
booking.Notes,
}); err != nil {
return err
}
}
return out.Error()
}
// WriteCustomersCSV streams the tenant's full customer list, paging through
// the repository in bounded chunks.
func (s *Service) WriteCustomersCSV(ctx context.Context, principal domain.Principal, w io.Writer) error {
tenant, err := s.tenantFor(ctx, principal)
if err != nil {
return err
}
if _, err := w.Write(utf8BOM); err != nil {
return err
}
out := csv.NewWriter(w)
defer out.Flush()
if err := out.Write([]string{"name", "email", "phone", "status", "created_at"}); err != nil {
return err
}
for offset := 0; ; offset += customerPageSize {
page, err := s.repo.ListCustomersByTenant(ctx, tenant.ID, customerPageSize, offset)
if err != nil {
return err
}
for _, customer := range page {
phone := ""
if customer.Phone != nil {
phone = *customer.Phone
}
if err := out.Write([]string{
customer.Name,
customer.Email,
phone,
customer.Status,
customer.CreatedAt.UTC().Format(time.RFC3339),
}); err != nil {
return err
}
}
if len(page) < customerPageSize {
break
}
}
return out.Error()
}
@@ -0,0 +1,149 @@
package export
import (
"bytes"
"context"
"encoding/csv"
"strings"
"testing"
"time"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
var demoPrincipal = domain.Principal{Subject: "demo-owner", Role: "owner"}
const demoTenantID = "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
func readCSV(t *testing.T, buf *bytes.Buffer) [][]string {
t.Helper()
records, err := csv.NewReader(strings.NewReader(strings.TrimPrefix(buf.String(), "\ufeff"))).ReadAll()
if err != nil {
t.Fatalf("parse csv: %v\n%s", err, buf.String())
}
return records
}
func TestWriteBookingsCSVResolvesLabelsAndEscapes(t *testing.T) {
repo := db.NewMemoryRepository()
serviceID := "d5d76a61-3d49-467c-8dd4-bf61ee754e39" // "Signature treatment"
staffID := "6936c444-c7d0-4a7d-b596-a9b72d2f4fc0" // "Demo Stylist"
locationID := "659f1cc0-a850-46d6-b3b8-cb15d55d8daf"
_, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{
TenantID: demoTenantID,
ServiceID: &serviceID,
StaffID: &staffID,
LocationID: &locationID,
CustomerName: "Novák, Jiří",
CustomerEmail: "[email protected]",
CustomerPhone: "+420111222333",
StartsAt: time.Now().Add(24 * time.Hour),
EndsAt: time.Now().Add(25 * time.Hour),
Status: "confirmed",
Reference: "BK-CSV-1",
ManageToken: "tok",
Notes: "note with \"quotes\"",
})
if err != nil {
t.Fatalf("create booking: %v", err)
}
var buf bytes.Buffer
svc := NewService(repo)
from := time.Now().Add(-time.Hour)
to := time.Now().Add(72 * time.Hour)
if err := svc.WriteBookingsCSV(context.Background(), demoPrincipal, &buf, from, to); err != nil {
t.Fatalf("write csv: %v", err)
}
if !bytes.HasPrefix(buf.Bytes(), utf8BOM) {
t.Fatal("expected UTF-8 BOM for Excel compatibility")
}
records := readCSV(t, &buf)
if len(records) != 2 {
t.Fatalf("expected header + 1 row, got %d rows", len(records))
}
row := records[1]
if row[0] != "BK-CSV-1" || row[1] != "confirmed" {
t.Fatalf("unexpected reference/status: %v", row[:2])
}
if row[2] != "Novák, Jiří" {
t.Fatalf("expected escaped customer name, got %q", row[2])
}
if row[5] != "Signature treatment" || row[6] != "Demo Stylist" || row[7] != "Main Studio" {
t.Fatalf("expected resolved labels, got service=%q staff=%q location=%q", row[5], row[6], row[7])
}
if row[11] != `note with "quotes"` {
t.Fatalf("expected escaped notes, got %q", row[11])
}
}
func TestWriteBookingsCSVRangeExcludesOutOfWindow(t *testing.T) {
repo := db.NewMemoryRepository()
_, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{
TenantID: demoTenantID,
CustomerName: "Far Future",
CustomerEmail: "[email protected]",
StartsAt: time.Now().Add(200 * 24 * time.Hour),
EndsAt: time.Now().Add(200*24*time.Hour + time.Hour),
Status: "confirmed",
Reference: "BK-CSV-FAR",
ManageToken: "tok",
})
if err != nil {
t.Fatalf("create booking: %v", err)
}
var buf bytes.Buffer
svc := NewService(repo)
from := time.Now().Add(-time.Hour)
to := time.Now().Add(24 * time.Hour) // far-future booking outside
if err := svc.WriteBookingsCSV(context.Background(), demoPrincipal, &buf, from, to); err != nil {
t.Fatalf("write csv: %v", err)
}
for _, record := range readCSV(t, &buf) {
if record[0] == "BK-CSV-FAR" {
t.Fatal("expected range to exclude far-future booking")
}
}
}
func TestWriteCustomersCSVPagesAndStrips(t *testing.T) {
repo := db.NewMemoryRepository()
var buf bytes.Buffer
svc := NewService(repo)
if err := svc.WriteCustomersCSV(context.Background(), demoPrincipal, &buf); err != nil {
t.Fatalf("write csv: %v", err)
}
records := readCSV(t, &buf)
if len(records) < 2 {
t.Fatalf("expected header + seeded customers, got %d rows", len(records))
}
if records[0][0] != "name" || records[0][4] != "created_at" {
t.Fatalf("unexpected header: %v", records[0])
}
found := false
for _, record := range records[1:] {
if record[0] == "Alice Johnson" && record[1] == "[email protected]" {
found = true
}
}
if !found {
t.Fatal("expected seeded customer Alice Johnson in export")
}
}
func TestExportRequiresMembership(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewService(repo)
var buf bytes.Buffer
err := svc.WriteCustomersCSV(context.Background(), domain.Principal{Subject: "nobody"}, &buf)
if err != ErrTenantMembership {
t.Fatalf("expected ErrTenantMembership, got %v", err)
}
if buf.Len() != 0 {
t.Fatal("expected no output on membership failure")
}
}
+48
View File
@@ -1431,6 +1431,54 @@ paths:
$ref: "#/components/schemas/ActivityFeedResponse"
"404":
description: Tenant membership not found
/v1/export/bookings.csv:
get:
tags: [Export]
operationId: exportBookingsCsv
security:
- bearerAuth: []
summary: Export tenant bookings as CSV
description: >-
Full booking export for accounting and backup — UTF-8 with BOM so
Czech diacritics open correctly in Excel. Optional `from`/`to`
(YYYY-MM-DD) bounds the export window; `to` is inclusive.
parameters:
- name: from
in: query
schema:
type: string
format: date
- name: to
in: query
schema:
type: string
format: date
responses:
"200":
description: CSV file download
content:
text/csv:
schema:
type: string
"404":
description: Tenant membership not found
/v1/export/customers.csv:
get:
tags: [Export]
operationId: exportCustomersCsv
security:
- bearerAuth: []
summary: Export tenant customers as CSV
description: Full customer list export — UTF-8 with BOM for Excel.
responses:
"200":
description: CSV file download
content:
text/csv:
schema:
type: string
"404":
description: Tenant membership not found
/v1/public/calendar/{token}:
get:
tags: [Calendar]