feat(analytics): add creator export CSV/JSON (H4)
This commit is contained in:
parent
d81695c27c
commit
363b092f3e
5 changed files with 136 additions and 6 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue