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() }