Merge pull request #8 from Dvorinka/feat/csv-export

feat(export): CSV export for bookings and customers
This commit is contained in:
Tomáš Dvořák authored and GitHub committed 2026-09-18 20:25:07 +02:00
commit 95ae93f06b
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]
@@ -0,0 +1,38 @@
import { createSignal, Accessor } from "solid-js";
import { apiBaseUrl } from "../../lib/api-client";
import { useI18n } from "../../providers/i18n-provider";
import { DownloadIcon } from "./icons";
// Downloads an authenticated CSV endpoint — fetch + blob because the
// generated api-client is typed for JSON, not file responses.
export function ExportCsvButton(props: { path: string; filename: string; token: Accessor<string | null | undefined> }) {
const i18n = useI18n();
const [busy, setBusy] = createSignal(false);
const handleClick = async () => {
const bearer = props.token?.();
if (!bearer || busy()) return;
setBusy(true);
try {
const response = await fetch(`${apiBaseUrl}${props.path}`, {
headers: { Authorization: `Bearer ${bearer}` },
});
if (!response.ok) return;
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = props.filename;
anchor.click();
URL.revokeObjectURL(url);
} finally {
setBusy(false);
}
};
return (
<button onClick={handleClick} disabled={busy()} class="btn-secondary text-sm shrink-0">
<DownloadIcon /> {i18n.t("dashboard.exportCsv")}
</button>
);
}
@@ -234,3 +234,7 @@ export const CheckIcon = () => (
export const GlobeIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>
);
export const DownloadIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
);
@@ -367,6 +367,7 @@ const dictionaries = {
"dashboard.activity.booking.rescheduled": "Rezervace přesunuta",
"dashboard.activity.booking.moved": "Přesun na jinou lekci",
"dashboard.activity.booking.promoted": "Povýšení z čekací listiny",
"dashboard.exportCsv": "Export CSV",
"dashboard.demoBanner": "Demo režim",
"dashboard.demoBannerDesc": "Prozkoumáte Bookra s ukázkovými daty. Pro plnou funkčnost se zaregistrujte.",
"dashboard.demoBannerCTA": "Vytvořit účet",
@@ -967,6 +968,7 @@ const dictionaries = {
"dashboard.activity.booking.rescheduled": "Booking rescheduled",
"dashboard.activity.booking.moved": "Moved to another session",
"dashboard.activity.booking.promoted": "Promoted from waitlist",
"dashboard.exportCsv": "Export CSV",
"dashboard.demoBanner": "Demo mode",
"dashboard.demoBannerDesc": "You're exploring Bookra with sample data. Register for full functionality.",
"dashboard.demoBannerCTA": "Create account",
@@ -5,6 +5,7 @@ import { Textarea } from "../../components/ui/textarea";
import { getInitials } from "../../components/dashboard/types";
import { CalendarDaysIcon, PlusIcon, XIcon, EyeIcon } from "../../components/dashboard/icons";
import { apiClient } from "../../lib/api-client";
import { ExportCsvButton } from "../../components/dashboard/export-csv-button";
import { DashboardLayout, useDashboardData } from "./layout";
function BookingsPage() {
@@ -123,9 +124,12 @@ function BookingsPage() {
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.bookingManagement")}</h1>
<p class="text-ink-muted mt-1">{bookingStats().total} {data.i18n.t("dashboard.totalBookings")}</p>
</div>
<button onClick={() => setShowNewBooking(true)} class="btn-primary text-sm shrink-0">
<PlusIcon /> {data.i18n.t("dashboard.newBooking")}
</button>
<div class="flex items-center gap-2 shrink-0">
<ExportCsvButton path="/v1/export/bookings.csv" filename="bookra-bookings.csv" token={data.token} />
<button onClick={() => setShowNewBooking(true)} class="btn-primary text-sm">
<PlusIcon /> {data.i18n.t("dashboard.newBooking")}
</button>
</div>
</div>
{/* Stats */}
@@ -1,5 +1,6 @@
import { Show, createSignal, createMemo, createResource } from "solid-js";
import { apiClient } from "../../lib/api-client";
import { ExportCsvButton } from "../../components/dashboard/export-csv-button";
import { Input } from "../../components/ui/input";
import { getInitials } from "../../components/dashboard/types";
import { UsersIcon, XIcon } from "../../components/dashboard/icons";
@@ -73,6 +74,7 @@ function CustomersPage() {
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.customers")}</h1>
<p class="text-ink-muted mt-1">{filteredCustomers().length} {data.i18n.locale() === "cs" ? "zákazníků" : "customers"}</p>
</div>
<ExportCsvButton path="/v1/export/customers.csv" filename="bookra-customers.csv" token={data.token} />
</div>
<div class="surface-card p-4">
<Input type="text" placeholder={data.i18n.t("dashboard.search.customers")} value={searchQuery()} onInput={(e) => setSearchQuery(e.currentTarget.value)} />
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/components/animated-list.tsx","./src/components/bookra-character.tsx","./src/components/calendar-feed.tsx","./src/components/dashboard-mockup.tsx","./src/components/floating-dock.tsx","./src/components/hover-feature-cards.tsx","./src/components/index.ts","./src/components/integration-modal.tsx","./src/components/location-map.tsx","./src/components/pinned-list.tsx","./src/components/shell.tsx","./src/components/sms-settings.tsx","./src/components/video-player.tsx","./src/components/widget-builder.tsx","./src/components/dashboard/activity-timeline.tsx","./src/components/dashboard/calendar-view.tsx","./src/components/dashboard/icons.tsx","./src/components/dashboard/kpi-card.tsx","./src/components/dashboard/notification-dropdown.tsx","./src/components/dashboard/revenue-chart.tsx","./src/components/dashboard/types.ts","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api-client.ts","./src/lib/map.ts","./src/lib/sentry.ts","./src/lib/types.ts","./src/providers/auth-provider.tsx","./src/providers/i18n-provider.tsx","./src/providers/theme-provider.tsx","./src/routes/about-route.tsx","./src/routes/auth-callback-route.tsx","./src/routes/booking-manage-route.tsx","./src/routes/contact-route.tsx","./src/routes/home-route.tsx","./src/routes/legal-route.tsx","./src/routes/not-found-route.tsx","./src/routes/pricing-route.tsx","./src/routes/public-booking-route.tsx","./src/routes/dashboard/billing-page.tsx","./src/routes/dashboard/bookings-page.tsx","./src/routes/dashboard/customers-page.tsx","./src/routes/dashboard/dashboard-data.ts","./src/routes/dashboard/layout.tsx","./src/routes/dashboard/overview-page.tsx","./src/routes/dashboard/services-page.tsx","./src/routes/dashboard/settings-page.tsx","./src/routes/dashboard/staff-page.tsx","./src/routes/dashboard/zones-page.tsx","./vite.config.ts"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/components/animated-list.tsx","./src/components/bookra-character.tsx","./src/components/calendar-feed.tsx","./src/components/dashboard-mockup.tsx","./src/components/floating-dock.tsx","./src/components/hover-feature-cards.tsx","./src/components/index.ts","./src/components/integration-modal.tsx","./src/components/location-map.tsx","./src/components/pinned-list.tsx","./src/components/shell.tsx","./src/components/sms-settings.tsx","./src/components/video-player.tsx","./src/components/widget-builder.tsx","./src/components/dashboard/activity-timeline.tsx","./src/components/dashboard/calendar-view.tsx","./src/components/dashboard/export-csv-button.tsx","./src/components/dashboard/icons.tsx","./src/components/dashboard/kpi-card.tsx","./src/components/dashboard/notification-dropdown.tsx","./src/components/dashboard/revenue-chart.tsx","./src/components/dashboard/types.ts","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api-client.ts","./src/lib/map.ts","./src/lib/sentry.ts","./src/lib/types.ts","./src/providers/auth-provider.tsx","./src/providers/i18n-provider.tsx","./src/providers/theme-provider.tsx","./src/routes/about-route.tsx","./src/routes/auth-callback-route.tsx","./src/routes/booking-manage-route.tsx","./src/routes/contact-route.tsx","./src/routes/home-route.tsx","./src/routes/legal-route.tsx","./src/routes/not-found-route.tsx","./src/routes/pricing-route.tsx","./src/routes/public-booking-route.tsx","./src/routes/dashboard/billing-page.tsx","./src/routes/dashboard/bookings-page.tsx","./src/routes/dashboard/customers-page.tsx","./src/routes/dashboard/dashboard-data.ts","./src/routes/dashboard/layout.tsx","./src/routes/dashboard/overview-page.tsx","./src/routes/dashboard/services-page.tsx","./src/routes/dashboard/settings-page.tsx","./src/routes/dashboard/staff-page.tsx","./src/routes/dashboard/zones-page.tsx","./vite.config.ts"],"version":"5.9.3"}
@@ -664,6 +664,46 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/export/bookings.csv": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* 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.
*/
get: operations["exportBookingsCsv"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/export/customers.csv": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Export tenant customers as CSV
* @description Full customer list export — UTF-8 with BOM for Excel.
*/
get: operations["exportCustomersCsv"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/internal/jobs/reminders/dispatch": {
parameters: {
query?: never;
@@ -3359,6 +3399,63 @@ export interface operations {
};
};
};
exportBookingsCsv: {
parameters: {
query?: {
from?: string;
to?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description CSV file download */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/csv": string;
};
};
/** @description Tenant membership not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
exportCustomersCsv: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description CSV file download */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/csv": string;
};
};
/** @description Tenant membership not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
dispatchReminderJobs: {
parameters: {
query?: never;
+9 -1
View File
@@ -171,6 +171,14 @@
- demo repo seeds three varied events so the dropdown has content on first open
- verified live: feed returns seeds newest-first, public booking create lands `booking.created` with entity id, customer cancel lands `booking.cancelled`; browser-checked both locales against the demo dashboard, console clean
- remaining: live staging validation on Neon + pgx repository path; settings/CRUD edits can join the feed when a concrete need appears
- `[done/partial]` Milestone 25: CSV export
- `GET /v1/export/bookings.csv` + `GET /v1/export/customers.csv` — authed, tenant-scoped, `Content-Disposition: attachment`; bookings accept optional `from`/`to` (YYYY-MM-DD, `to` inclusive)
- UTF-8 BOM prefix so Czech diacritics open correctly in Excel; stdlib `encoding/csv` handles all quoting/escaping — no dependency
- service labels resolve at export time (service name for appointments, session title for classes, staff and location names via lookup maps — no N+1); customers export pages through the repository in 500-row chunks
- responses buffer in memory before headers — membership failures return clean JSON errors instead of a truncated stream
- shared `ExportCsvButton` (fetch + blob + object-URL download; the generated client is JSON-typed so it can't carry file responses) on the bookings and customers page headers, CS/EN label
- verified live: real booking row with comma-in-name and quotes-in-notes escaped correctly, diacritics preserved, range bounds honored (inclusive `to`), three seeded customers exported; browser download produced the real file on the bookings page, customers button present, console clean
- remaining: live staging validation on Neon + pgx repository path
## 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.
@@ -425,7 +433,7 @@ Shortlist of worthwhile directions once v1 stabilizes — kept deliberately smal
- **Booking notes intake**: structured intake fields per service (e.g. "first visit?", "injuries?") instead of the single free-text notes field — still schema-light.
- **Persistent notification center**: M24 put real activity events in the dropdown, but read state is still in-memory and there's no delivery history; a `notifications` table (or read-cursor on `activity_events`) would persist both.
- **Staff performance view**: bookings/completion/utilization per staff member — the metrics primitives already exist; this is a filtered presentation layer.
- **CSV export**: bookings and customers export for accounting/backup; cheap to build, frequently requested by small businesses.
- **CSV export**: bookings and customers export for accounting/backup — landed in M25; a report-style export (per-staff utilization, revenue summaries) can follow once metrics stabilize.
## Product Principles
- calm, premium, structured UX