feat(analytics): add creator export CSV/JSON (H4)

This commit is contained in:
senke 2026-02-20 17:00:36 +01:00
parent d81695c27c
commit 363b092f3e
5 changed files with 136 additions and 6 deletions

View file

@ -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 {

View file

@ -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({

View file

@ -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();

View file

@ -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)

View file

@ -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")