Files
Bookra/apps/backend/internal/export/service.go
T
Tomas DvorakandDevin 5c860a1264 feat: data surfacing — intake labels, service metrics, honest capacity
- booking views gain intakeLabels (declared key→label join); dashboard
  modal + manage view render real question labels with slug fallback
- GET /v1/metrics/services aggregates bookings/completed/cancelled/
  revenue per service + classBookings; service-report.csv delegates to
  the same ServiceMetrics aggregation and gains a completed column
- services page gains a Service performance card (per-service stat grid,
  locale CZK revenue, class-bookings footer); demo rows match the
  fabricated service list
- staff capacity utilization subtracts blocked days — tenant-wide and
  staff-scoped blocks remove that day's rule minutes via blockedAt
- tests: blocked-day capacity (tenant + staff scope), service aggregation

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 20:25:32 +02:00

257 lines
6.8 KiB
Go

package export
import (
"context"
"encoding/csv"
"errors"
"io"
"strconv"
"time"
"bookra/apps/backend/internal/bookings"
"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
bookings *bookings.Service
}
func NewService(repo db.Repository, bookingsService *bookings.Service) *Service {
return &Service{repo: repo, bookings: bookingsService}
}
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()
}
// WriteStaffReportCSV exports the per-staff performance metrics over a
// trailing window — the same numbers the dashboard performance panel shows.
func (s *Service) WriteStaffReportCSV(ctx context.Context, principal domain.Principal, w io.Writer, days int) error {
metrics, err := s.bookings.StaffMetrics(ctx, principal, days)
if err != nil {
if errors.Is(err, bookings.ErrTenantMembership) {
return ErrTenantMembership
}
return err
}
if _, err := w.Write(utf8BOM); err != nil {
return err
}
out := csv.NewWriter(w)
defer out.Flush()
if err := out.Write([]string{
"staff", "bookings", "completed", "cancelled",
"booked_minutes", "capacity_minutes", "utilization_percent", "revenue_cents",
}); err != nil {
return err
}
for _, m := range metrics.Staff {
if err := out.Write([]string{
m.DisplayName,
strconv.Itoa(m.Bookings),
strconv.Itoa(m.Completed),
strconv.Itoa(m.Cancelled),
strconv.Itoa(m.BookedMinutes),
strconv.Itoa(m.CapacityMinutes),
strconv.Itoa(m.UtilizationPercent),
strconv.FormatInt(m.RevenueCents, 10),
}); err != nil {
return err
}
}
return out.Error()
}
// WriteServiceReportCSV exports the per-service performance metrics over a
// trailing window — the same aggregation the dashboard services panel shows.
func (s *Service) WriteServiceReportCSV(ctx context.Context, principal domain.Principal, w io.Writer, days int) error {
metrics, err := s.bookings.ServiceMetrics(ctx, principal, days)
if err != nil {
if errors.Is(err, bookings.ErrTenantMembership) {
return ErrTenantMembership
}
return err
}
if _, err := w.Write(utf8BOM); err != nil {
return err
}
out := csv.NewWriter(w)
defer out.Flush()
if err := out.Write([]string{"service", "bookings", "completed", "cancelled", "revenue_cents"}); err != nil {
return err
}
for _, m := range metrics.Services {
if err := out.Write([]string{
m.Name,
strconv.Itoa(m.Bookings),
strconv.Itoa(m.Completed),
strconv.Itoa(m.Cancelled),
strconv.FormatInt(m.RevenueCents, 10),
}); err != nil {
return err
}
}
if metrics.ClassBookings > 0 {
if err := out.Write([]string{"class sessions", strconv.Itoa(metrics.ClassBookings), "0", "0", "0"}); err != nil {
return err
}
}
return out.Error()
}