mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
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:
1 parent
d86cba61e3
commit
4674203502
12 files changed
+583
-5
No files matched your search
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user