From 363b092f3e7743c818e70c23598386eef85ab047 Mon Sep 17 00:00:00 2001 From: senke Date: Fri, 20 Feb 2026 17:00:36 +0100 Subject: [PATCH] feat(analytics): add creator export CSV/JSON (H4) --- .../pages/analytics-page/useAnalyticsView.ts | 17 ++-- apps/web/src/mocks/handlers-misc.ts | 21 ++++ apps/web/src/services/analyticsService.ts | 8 ++ .../internal/api/routes_analytics.go | 1 + .../internal/core/analytics/handler.go | 95 +++++++++++++++++++ 5 files changed, 136 insertions(+), 6 deletions(-) diff --git a/apps/web/src/features/analytics/pages/analytics-page/useAnalyticsView.ts b/apps/web/src/features/analytics/pages/analytics-page/useAnalyticsView.ts index 704b621b0..c9f9fcfed 100644 --- a/apps/web/src/features/analytics/pages/analytics-page/useAnalyticsView.ts +++ b/apps/web/src/features/analytics/pages/analytics-page/useAnalyticsView.ts @@ -54,19 +54,24 @@ export function useAnalyticsView(dateRangeInitial: DateRangeKey = '30d') { }, [fetchData]); const handleExport = useCallback( - (format: 'csv' | 'json') => { + async (format: 'csv' | 'json') => { toast(`Building ${format.toUpperCase()} archive...`, { icon: 'ℹ️' }); - setTimeout(() => { - const blob = new Blob([JSON.stringify(stats, null, 2)], { type: 'application/json' }); + try { + const days = parseInt(dateRange.replace('d', ''), 10) || 30; + const blob = await analyticsService.getCreatorExport(format, { days }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `veza-analytics-${dateRange}-${new Date().toISOString().split('T')[0]}.${format}`; + a.download = `veza-creator-export-${dateRange}-${new Date().toISOString().split('T')[0]}.${format}`; a.click(); + URL.revokeObjectURL(url); addToast('Data packet exported successfully', 'success'); - }, 1500); + } catch (e) { + logger.error('Export failed', { error: e }); + addToast('Export failed', 'error'); + } }, - [stats, dateRange] + [dateRange, addToast] ); return { diff --git a/apps/web/src/mocks/handlers-misc.ts b/apps/web/src/mocks/handlers-misc.ts index c646442f1..c9b2bbe5c 100644 --- a/apps/web/src/mocks/handlers-misc.ts +++ b/apps/web/src/mocks/handlers-misc.ts @@ -85,6 +85,27 @@ function createQueueHandlers() { } export const handlersMisc = [ +, http.get('*/api/v1/analytics/creator/export', ({ request }) => { + const url = new URL(request.url); + const format = url.searchParams.get('format') ?? 'json'; + if (format === 'csv') { + const body = 'track_id,title,date,plays\n' + 't1,Neon Nights,2024-01-15,42\n' + 't2,Cyber City,2024-01-14,31\n'; + return new HttpResponse(body, { + headers: { 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename=veza-creator-export.csv' }, + }); + } + const body = JSON.stringify({ + period: { start_date: '2024-01-01', end_date: '2024-01-31', days: 30 }, + rows: [ + { track_id: 't1', title: 'Neon Nights', date: '2024-01-15', plays: 42 }, + { track_id: 't2', title: 'Cyber City', date: '2024-01-14', plays: 31 }, + ], + }); + return new HttpResponse(body, { + headers: { 'Content-Type': 'application/json', 'Content-Disposition': 'attachment; filename=veza-creator-export.json' }, + }); + }), + http.get('*/api/v1/analytics/creator/charts', () => { const days = ['2024-01-01', '2024-01-02', '2024-01-03', '2024-01-04', '2024-01-05', '2024-01-06', '2024-01-07']; return HttpResponse.json({ diff --git a/apps/web/src/services/analyticsService.ts b/apps/web/src/services/analyticsService.ts index e97ef2bd6..d526e4703 100644 --- a/apps/web/src/services/analyticsService.ts +++ b/apps/web/src/services/analyticsService.ts @@ -119,6 +119,14 @@ export const analyticsService = { } }, + getCreatorExport: async (format: 'csv' | 'json', params?: { days?: number }) => { + const searchParams = new URLSearchParams({ format }); + if (params?.days != null) searchParams.set('days', String(params.days)); + const url = `/analytics/creator/export?${searchParams.toString()}`; + const response = await apiClient.get(url, { responseType: 'blob' }); + return response.data as Blob; + }, + getCreatorStats: async (params?: { start_date?: string; end_date?: string; days?: number }) => { try { const searchParams = new URLSearchParams(); diff --git a/veza-backend-api/internal/api/routes_analytics.go b/veza-backend-api/internal/api/routes_analytics.go index 8d5926bf3..d09374670 100644 --- a/veza-backend-api/internal/api/routes_analytics.go +++ b/veza-backend-api/internal/api/routes_analytics.go @@ -28,6 +28,7 @@ func (r *APIRouter) setupAnalyticsRoutes(router *gin.RouterGroup) { { analytics.GET("/creator/stats", analyticsHandler.GetCreatorStats) analytics.GET("/creator/charts", analyticsHandler.GetCreatorCharts) + analytics.GET("/creator/export", analyticsHandler.GetCreatorExport) analytics.GET("", analyticsHandler.GetAnalytics) analytics.POST("/events", analyticsHandler.RecordEvent) analytics.GET("/tracks/:id", analyticsHandler.GetTrackAnalyticsDashboard) diff --git a/veza-backend-api/internal/core/analytics/handler.go b/veza-backend-api/internal/core/analytics/handler.go index b07bbdb1f..5b15984cb 100644 --- a/veza-backend-api/internal/core/analytics/handler.go +++ b/veza-backend-api/internal/core/analytics/handler.go @@ -2,6 +2,7 @@ package analytics import ( "context" + "encoding/json" "net/http" "strconv" "strings" @@ -274,6 +275,100 @@ func (h *Handler) GetCreatorStats(c *gin.Context) { }) } +// GetCreatorExport handles GET /api/v1/analytics/creator/export?format=csv|json +func (h *Handler) GetCreatorExport(c *gin.Context) { + userIDInterface, exists := c.Get("user_id") + if !exists { + handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeUnauthorized, "authentication required")) + return + } + userID, ok := userIDInterface.(uuid.UUID) + if !ok { + handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "invalid user id")) + return + } + + analyticsSvc, ok := h.analyticsService.(AnalyticsServiceWithDB) + if !ok { + handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "analytics service unavailable")) + return + } + + format := strings.ToLower(c.DefaultQuery("format", "json")) + if format != "csv" && format != "json" { + handlers.RespondWithAppError(c, apperrors.NewValidationError("format must be csv or json")) + return + } + + days := 30 + if d := c.Query("days"); d != "" { + if n, err := strconv.Atoi(d); err == nil && n > 0 && n <= 365 { + days = n + } + } + endDate := time.Now() + startDate := endDate.AddDate(0, 0, -days) + ctx := c.Request.Context() + db := analyticsSvc.GetDB() + + var rows []struct { + TrackID uuid.UUID `gorm:"column:track_id"` + Title string `gorm:"column:title"` + Date string `gorm:"column:day"` + Plays int64 `gorm:"column:cnt"` + } + + db.WithContext(ctx).Table("track_plays tp"). + Joins("JOIN tracks t ON t.id = tp.track_id"). + Select("tp.track_id, t.title, DATE(tp.played_at) as day, COUNT(*) as cnt"). + Where("t.creator_id = ? AND tp.played_at >= ? AND tp.played_at <= ?", userID, startDate, endDate). + Group("tp.track_id, t.title, DATE(tp.played_at)"). + Order("day ASC, cnt DESC"). + Find(&rows) + + if format == "json" { + exportRows := make([]gin.H, len(rows)) + for i, r := range rows { + exportRows[i] = gin.H{"track_id": r.TrackID.String(), "title": r.Title, "date": r.Date, "plays": r.Plays} + } + exportData := gin.H{ + "period": gin.H{"start_date": startDate.Format(time.RFC3339), "end_date": endDate.Format(time.RFC3339), "days": days}, + "rows": exportRows, + } + c.Header("Content-Disposition", "attachment; filename=veza-creator-export.json") + c.Data(http.StatusOK, "application/json", []byte(mustJSON(exportData))) + return + } + + var b strings.Builder + b.WriteString("track_id,title,date,plays\n") + for _, r := range rows { + b.WriteString(r.TrackID.String()) + b.WriteString(",") + b.WriteString(escapeCSV(r.Title)) + b.WriteString(",") + b.WriteString(r.Date) + b.WriteString(",") + b.WriteString(strconv.FormatInt(r.Plays, 10)) + b.WriteString("\n") + } + c.Header("Content-Disposition", "attachment; filename=veza-creator-export.csv") + c.Data(http.StatusOK, "text/csv", []byte(b.String())) +} + +func escapeCSV(s string) string { + if strings.ContainsAny(s, ",\"\n") { + return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` + } + return s +} + +func mustJSON(v interface{}) []byte { + // Use simple JSON marshalling - gin has json package + data, _ := json.Marshal(v) + return data +} + // GetCreatorCharts handles GET /api/v1/analytics/creator/charts func (h *Handler) GetCreatorCharts(c *gin.Context) { userIDInterface, exists := c.Get("user_id")