feat(v0.11.0): F381-F385 creator analytics handler and routes

Add CreatorAnalyticsHandler with endpoints:
- GET /api/v1/creator/analytics/dashboard (F381)
- GET /api/v1/creator/analytics/plays (F382)
- GET /api/v1/creator/analytics/sales (F383)
- GET /api/v1/creator/analytics/discovery (F381)
- GET /api/v1/creator/analytics/geographic (F381)
- GET /api/v1/creator/analytics/audience (F384)
- GET /api/v1/creator/analytics/live/:streamId (F385)
- GET /api/v1/creator/analytics/tracks (F381)
- GET /api/v1/creator/analytics/export (F383)

All endpoints require authentication and only return data for the authenticated creator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
senke 2026-03-10 16:28:22 +01:00
parent 41a447224a
commit 8b6f0bb430
3 changed files with 650 additions and 11 deletions

View file

@ -20,19 +20,40 @@ func (r *APIRouter) setupAnalyticsRoutes(router *gin.RouterGroup) {
analyticsHandler.SetJobWorker(r.config.JobWorker)
}
analytics := router.Group("/analytics")
analyticsGroup := router.Group("/analytics")
if r.config != nil && r.config.AuthMiddleware != nil {
analytics.Use(r.config.AuthMiddleware.RequireAuth())
r.applyCSRFProtection(analytics)
analyticsGroup.Use(r.config.AuthMiddleware.RequireAuth())
r.applyCSRFProtection(analyticsGroup)
}
{
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)
analytics.GET("/traffic-sources", analyticsHandler.GetTrafficSources)
analytics.GET("/device-breakdown", analyticsHandler.GetDeviceBreakdown)
analyticsGroup.GET("/creator/stats", analyticsHandler.GetCreatorStats)
analyticsGroup.GET("/creator/charts", analyticsHandler.GetCreatorCharts)
analyticsGroup.GET("/creator/export", analyticsHandler.GetCreatorExport)
analyticsGroup.GET("", analyticsHandler.GetAnalytics)
analyticsGroup.POST("/events", analyticsHandler.RecordEvent)
analyticsGroup.GET("/tracks/:id", analyticsHandler.GetTrackAnalyticsDashboard)
analyticsGroup.GET("/traffic-sources", analyticsHandler.GetTrafficSources)
analyticsGroup.GET("/device-breakdown", analyticsHandler.GetDeviceBreakdown)
}
// v0.11.0: Creator Analytics (F381-F395)
creatorAnalyticsService := services.NewCreatorAnalyticsService(r.db.GormDB, r.logger)
creatorHandler := analytics.NewCreatorAnalyticsHandler(creatorAnalyticsService, r.logger)
creatorGroup := router.Group("/creator/analytics")
if r.config != nil && r.config.AuthMiddleware != nil {
creatorGroup.Use(r.config.AuthMiddleware.RequireAuth())
r.applyCSRFProtection(creatorGroup)
}
{
creatorGroup.GET("/dashboard", creatorHandler.GetDashboard) // F381
creatorGroup.GET("/plays", creatorHandler.GetPlayEvolution) // F382
creatorGroup.GET("/sales", creatorHandler.GetSales) // F383
creatorGroup.GET("/discovery", creatorHandler.GetDiscoverySources) // F381
creatorGroup.GET("/geographic", creatorHandler.GetGeographic) // F381
creatorGroup.GET("/audience", creatorHandler.GetAudience) // F384
creatorGroup.GET("/live/:streamId", creatorHandler.GetLiveMetrics) // F385
creatorGroup.GET("/tracks", creatorHandler.GetTracks) // F381
creatorGroup.GET("/export", creatorHandler.ExportAnalytics) // F383
}
}

View file

@ -0,0 +1,350 @@
package analytics
import (
"encoding/csv"
"fmt"
"net/http"
"strconv"
"strings"
"time"
apperrors "veza-backend-api/internal/errors"
"veza-backend-api/internal/handlers"
"veza-backend-api/internal/services"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// CreatorAnalyticsHandler handles creator analytics HTTP requests (F381-F395)
type CreatorAnalyticsHandler struct {
service *services.CreatorAnalyticsService
logger *zap.Logger
}
// NewCreatorAnalyticsHandler creates a new creator analytics handler
func NewCreatorAnalyticsHandler(service *services.CreatorAnalyticsService, logger *zap.Logger) *CreatorAnalyticsHandler {
if logger == nil {
logger = zap.NewNop()
}
return &CreatorAnalyticsHandler{service: service, logger: logger}
}
// getCreatorID extracts and validates the creator user ID from context
func (h *CreatorAnalyticsHandler) getCreatorID(c *gin.Context) (uuid.UUID, bool) {
userIDInterface, exists := c.Get("user_id")
if !exists {
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeUnauthorized, "authentication required"))
return uuid.Nil, false
}
userID, ok := userIDInterface.(uuid.UUID)
if !ok {
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "invalid user id"))
return uuid.Nil, false
}
return userID, true
}
// parseDateRange parses start/end dates from query params with fallback to days
func parseDateRange(c *gin.Context) (time.Time, time.Time) {
now := time.Now()
daysStr := c.DefaultQuery("days", "30")
days, err := strconv.Atoi(daysStr)
if err != nil || days < 1 {
days = 30
}
if days > 365 {
days = 365
}
endDate := now
startDate := now.AddDate(0, 0, -days)
if s := c.Query("start_date"); s != "" {
if parsed, err := time.Parse(time.RFC3339, s); err == nil {
startDate = parsed
}
}
if e := c.Query("end_date"); e != "" {
if parsed, err := time.Parse(time.RFC3339, e); err == nil {
endDate = parsed
}
}
return startDate, endDate
}
// GetDashboard handles GET /api/v1/creator/analytics/dashboard (F381)
func (h *CreatorAnalyticsHandler) GetDashboard(c *gin.Context) {
creatorID, ok := h.getCreatorID(c)
if !ok {
return
}
startDate, endDate := parseDateRange(c)
dashboard, err := h.service.GetCreatorDashboard(c.Request.Context(), creatorID, startDate, endDate)
if err != nil {
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get dashboard", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{
"dashboard": dashboard,
"period": gin.H{
"start_date": startDate.Format(time.RFC3339),
"end_date": endDate.Format(time.RFC3339),
},
})
}
// GetPlayEvolution handles GET /api/v1/creator/analytics/plays (F382)
func (h *CreatorAnalyticsHandler) GetPlayEvolution(c *gin.Context) {
creatorID, ok := h.getCreatorID(c)
if !ok {
return
}
startDate, endDate := parseDateRange(c)
interval := c.DefaultQuery("interval", "day")
if interval != "day" && interval != "week" && interval != "month" {
interval = "day"
}
points, err := h.service.GetPlayEvolution(c.Request.Context(), creatorID, startDate, endDate, interval)
if err != nil {
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get play evolution", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{
"data": points,
"interval": interval,
"period": gin.H{
"start_date": startDate.Format(time.RFC3339),
"end_date": endDate.Format(time.RFC3339),
},
})
}
// GetSales handles GET /api/v1/creator/analytics/sales (F383)
func (h *CreatorAnalyticsHandler) GetSales(c *gin.Context) {
creatorID, ok := h.getCreatorID(c)
if !ok {
return
}
startDate, endDate := parseDateRange(c)
summary, err := h.service.GetSalesSummary(c.Request.Context(), creatorID, startDate, endDate)
if err != nil {
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get sales", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{
"sales": summary,
"period": gin.H{
"start_date": startDate.Format(time.RFC3339),
"end_date": endDate.Format(time.RFC3339),
},
})
}
// GetDiscoverySources handles GET /api/v1/creator/analytics/discovery (F381)
func (h *CreatorAnalyticsHandler) GetDiscoverySources(c *gin.Context) {
creatorID, ok := h.getCreatorID(c)
if !ok {
return
}
startDate, endDate := parseDateRange(c)
sources, err := h.service.GetDiscoverySources(c.Request.Context(), creatorID, startDate, endDate)
if err != nil {
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get discovery sources", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{
"sources": sources,
})
}
// GetGeographic handles GET /api/v1/creator/analytics/geographic (F381)
func (h *CreatorAnalyticsHandler) GetGeographic(c *gin.Context) {
creatorID, ok := h.getCreatorID(c)
if !ok {
return
}
startDate, endDate := parseDateRange(c)
breakdown, err := h.service.GetGeographicBreakdown(c.Request.Context(), creatorID, startDate, endDate)
if err != nil {
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get geographic data", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{
"geographic": breakdown,
})
}
// GetAudience handles GET /api/v1/creator/analytics/audience (F384)
func (h *CreatorAnalyticsHandler) GetAudience(c *gin.Context) {
creatorID, ok := h.getCreatorID(c)
if !ok {
return
}
startDate, endDate := parseDateRange(c)
profile, err := h.service.GetAudienceProfile(c.Request.Context(), creatorID, startDate, endDate)
if err != nil {
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get audience profile", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{
"audience": profile,
})
}
// GetLiveMetrics handles GET /api/v1/creator/analytics/live/:streamId (F385)
func (h *CreatorAnalyticsHandler) GetLiveMetrics(c *gin.Context) {
creatorID, ok := h.getCreatorID(c)
if !ok {
return
}
streamIDStr := c.Param("streamId")
streamID, err := uuid.Parse(streamIDStr)
if err != nil {
handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid stream id"))
return
}
metrics, err := h.service.GetLiveStreamMetrics(c.Request.Context(), creatorID, streamID)
if err != nil {
if strings.Contains(err.Error(), "not found") {
handlers.RespondWithAppError(c, apperrors.NewNotFoundError("live stream"))
return
}
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get live metrics", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{
"metrics": metrics,
})
}
// GetTracks handles GET /api/v1/creator/analytics/tracks (F381 per-track breakdown)
func (h *CreatorAnalyticsHandler) GetTracks(c *gin.Context) {
creatorID, ok := h.getCreatorID(c)
if !ok {
return
}
startDate, endDate := parseDateRange(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
if limit < 1 {
limit = 20
}
if limit > 100 {
limit = 100
}
offset := (page - 1) * limit
tracks, total, err := h.service.GetPerTrackStats(c.Request.Context(), creatorID, startDate, endDate, limit, offset)
if err != nil {
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get track stats", err))
return
}
totalPages := int((total + int64(limit) - 1) / int64(limit))
handlers.RespondSuccess(c, http.StatusOK, gin.H{
"data": tracks,
"pagination": gin.H{
"page": page,
"limit": limit,
"total": total,
"total_pages": totalPages,
},
})
}
// ExportAnalytics handles GET /api/v1/creator/analytics/export (F383)
// Exports analytics data as CSV
func (h *CreatorAnalyticsHandler) ExportAnalytics(c *gin.Context) {
creatorID, ok := h.getCreatorID(c)
if !ok {
return
}
startDate, endDate := parseDateRange(c)
exportType := c.DefaultQuery("type", "plays") // plays, sales
switch exportType {
case "sales":
h.exportSalesCSV(c, creatorID, startDate, endDate)
default:
h.exportPlaysCSV(c, creatorID, startDate, endDate)
}
}
func (h *CreatorAnalyticsHandler) exportPlaysCSV(c *gin.Context, creatorID uuid.UUID, startDate, endDate time.Time) {
tracks, _, err := h.service.GetPerTrackStats(c.Request.Context(), creatorID, startDate, endDate, 1000, 0)
if err != nil {
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to export", err))
return
}
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=veza-analytics-%s.csv", time.Now().Format("2006-01-02")))
c.Header("Content-Type", "text/csv")
writer := csv.NewWriter(c.Writer)
writer.Write([]string{"track_id", "title", "total_plays", "complete_listens", "unique_listeners", "avg_play_duration", "avg_completion"})
for _, t := range tracks {
writer.Write([]string{
t.TrackID,
t.Title,
strconv.FormatInt(t.TotalPlays, 10),
strconv.FormatInt(t.CompleteListens, 10),
strconv.FormatInt(t.UniqueListeners, 10),
fmt.Sprintf("%.1f", t.AvgPlayDuration),
fmt.Sprintf("%.1f", t.AvgCompletion),
})
}
writer.Flush()
}
func (h *CreatorAnalyticsHandler) exportSalesCSV(c *gin.Context, creatorID uuid.UUID, startDate, endDate time.Time) {
summary, err := h.service.GetSalesSummary(c.Request.Context(), creatorID, startDate, endDate)
if err != nil {
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to export sales", err))
return
}
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=veza-sales-%s.csv", time.Now().Format("2006-01-02")))
c.Header("Content-Type", "text/csv")
writer := csv.NewWriter(c.Writer)
writer.Write([]string{"date", "revenue", "sales"})
for _, r := range summary.RevenueByPeriod {
writer.Write([]string{
r.Date,
fmt.Sprintf("%.2f", r.Revenue),
strconv.FormatInt(r.Sales, 10),
})
}
writer.Flush()
}

View file

@ -0,0 +1,268 @@
package analytics
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"veza-backend-api/internal/models"
"veza-backend-api/internal/services"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupCreatorHandlerTest(t *testing.T) (*CreatorAnalyticsHandler, *gorm.DB, uuid.UUID) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
db.Exec("PRAGMA foreign_keys = ON")
err = db.AutoMigrate(
&models.User{},
&models.Track{},
&models.TrackPlay{},
&models.PlaybackAnalytics{},
&models.LiveStream{},
)
require.NoError(t, err)
db.Exec("ALTER TABLE track_plays ADD COLUMN source TEXT DEFAULT ''")
db.Exec("ALTER TABLE track_plays ADD COLUMN country_code TEXT DEFAULT ''")
creator := &models.User{
Username: "creator", Email: "c@test.com",
PasswordHash: "hash", Slug: "creator", IsActive: true,
}
require.NoError(t, db.Create(creator).Error)
track := &models.Track{
UserID: creator.ID, Title: "Hit Song", FilePath: "/hit.mp3",
FileSize: 1024, Format: "MP3", Duration: 240,
IsPublic: true, Status: models.TrackStatusCompleted,
}
require.NoError(t, db.Create(track).Error)
for i := 0; i < 5; i++ {
listener := &models.User{
Username: "l" + string(rune('0'+i)), Email: "l" + string(rune('0'+i)) + "@test.com",
PasswordHash: "hash", Slug: "l" + string(rune('0'+i)), IsActive: true,
}
require.NoError(t, db.Create(listener).Error)
pa := &models.PlaybackAnalytics{
TrackID: track.ID, UserID: listener.ID,
PlayTime: 200, CompletionRate: 85,
StartedAt: time.Now().Add(-time.Duration(i) * time.Hour),
}
require.NoError(t, db.Create(pa).Error)
}
svc := services.NewCreatorAnalyticsService(db, zap.NewNop())
handler := NewCreatorAnalyticsHandler(svc, zap.NewNop())
return handler, db, creator.ID
}
func makeAuthContext(creatorID uuid.UUID, method, path string) (*gin.Context, *httptest.ResponseRecorder) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req := httptest.NewRequest(method, path, nil)
c.Request = req
c.Set("user_id", creatorID)
return c, w
}
func TestCreatorHandler_GetDashboard(t *testing.T) {
handler, _, creatorID := setupCreatorHandlerTest(t)
c, w := makeAuthContext(creatorID, http.MethodGet, "/creator/analytics/dashboard?days=30")
handler.GetDashboard(c)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Contains(t, resp, "data")
data := resp["data"].(map[string]interface{})
assert.Contains(t, data, "dashboard")
assert.Contains(t, data, "period")
}
func TestCreatorHandler_GetDashboard_Unauthorized(t *testing.T) {
handler, _, _ := setupCreatorHandlerTest(t)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/creator/analytics/dashboard", nil)
// No user_id in context
handler.GetDashboard(c)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestCreatorHandler_GetPlayEvolution(t *testing.T) {
handler, _, creatorID := setupCreatorHandlerTest(t)
c, w := makeAuthContext(creatorID, http.MethodGet, "/creator/analytics/plays?days=30&interval=day")
handler.GetPlayEvolution(c)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
data := resp["data"].(map[string]interface{})
assert.Contains(t, data, "data")
assert.Contains(t, data, "interval")
}
func TestCreatorHandler_GetPlayEvolution_InvalidInterval(t *testing.T) {
handler, _, creatorID := setupCreatorHandlerTest(t)
c, w := makeAuthContext(creatorID, http.MethodGet, "/creator/analytics/plays?interval=invalid")
handler.GetPlayEvolution(c)
assert.Equal(t, http.StatusOK, w.Code) // Should default to "day"
}
func TestCreatorHandler_GetSales(t *testing.T) {
handler, _, creatorID := setupCreatorHandlerTest(t)
c, w := makeAuthContext(creatorID, http.MethodGet, "/creator/analytics/sales?days=30")
handler.GetSales(c)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestCreatorHandler_GetDiscoverySources(t *testing.T) {
handler, _, creatorID := setupCreatorHandlerTest(t)
c, w := makeAuthContext(creatorID, http.MethodGet, "/creator/analytics/discovery?days=30")
handler.GetDiscoverySources(c)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestCreatorHandler_GetAudience(t *testing.T) {
handler, _, creatorID := setupCreatorHandlerTest(t)
c, w := makeAuthContext(creatorID, http.MethodGet, "/creator/analytics/audience?days=30")
handler.GetAudience(c)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestCreatorHandler_GetGeographic(t *testing.T) {
handler, _, creatorID := setupCreatorHandlerTest(t)
c, w := makeAuthContext(creatorID, http.MethodGet, "/creator/analytics/geographic?days=30")
handler.GetGeographic(c)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestCreatorHandler_GetTracks(t *testing.T) {
handler, _, creatorID := setupCreatorHandlerTest(t)
c, w := makeAuthContext(creatorID, http.MethodGet, "/creator/analytics/tracks?page=1&limit=10")
handler.GetTracks(c)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
data := resp["data"].(map[string]interface{})
assert.Contains(t, data, "data")
assert.Contains(t, data, "pagination")
}
func TestCreatorHandler_GetLiveMetrics(t *testing.T) {
handler, db, creatorID := setupCreatorHandlerTest(t)
now := time.Now()
stream := &models.LiveStream{
UserID: creatorID, Title: "Live Now",
IsLive: true, ViewerCount: 10, StartedAt: &now,
}
require.NoError(t, db.Create(stream).Error)
c, w := makeAuthContext(creatorID, http.MethodGet, "/creator/analytics/live/"+stream.ID.String())
c.Params = gin.Params{{Key: "streamId", Value: stream.ID.String()}}
handler.GetLiveMetrics(c)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestCreatorHandler_GetLiveMetrics_InvalidID(t *testing.T) {
handler, _, creatorID := setupCreatorHandlerTest(t)
c, w := makeAuthContext(creatorID, http.MethodGet, "/creator/analytics/live/invalid")
c.Params = gin.Params{{Key: "streamId", Value: "invalid"}}
handler.GetLiveMetrics(c)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestCreatorHandler_ExportAnalytics(t *testing.T) {
handler, _, creatorID := setupCreatorHandlerTest(t)
c, w := makeAuthContext(creatorID, http.MethodGet, "/creator/analytics/export?type=plays&days=30")
handler.ExportAnalytics(c)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Header().Get("Content-Type"), "text/csv")
assert.Contains(t, w.Header().Get("Content-Disposition"), "attachment")
}
func TestCreatorHandler_ExportAnalytics_Sales(t *testing.T) {
handler, _, creatorID := setupCreatorHandlerTest(t)
c, w := makeAuthContext(creatorID, http.MethodGet, "/creator/analytics/export?type=sales&days=30")
handler.ExportAnalytics(c)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Header().Get("Content-Type"), "text/csv")
}
func TestCreatorHandler_DataPrivacy(t *testing.T) {
// Ensure that a creator can only see their own data
handler, _, creatorID := setupCreatorHandlerTest(t)
// Request dashboard for the real creator
c1, w1 := makeAuthContext(creatorID, http.MethodGet, "/creator/analytics/dashboard?days=30")
handler.GetDashboard(c1)
assert.Equal(t, http.StatusOK, w1.Code)
var resp1 map[string]interface{}
json.Unmarshal(w1.Body.Bytes(), &resp1)
data1 := resp1["data"].(map[string]interface{})
dashboard1 := data1["dashboard"].(map[string]interface{})
plays1 := dashboard1["total_plays"].(float64)
// Request dashboard for a random user (should see 0 plays)
randomID := uuid.New()
c2, w2 := makeAuthContext(randomID, http.MethodGet, "/creator/analytics/dashboard?days=30")
handler.GetDashboard(c2)
assert.Equal(t, http.StatusOK, w2.Code)
var resp2 map[string]interface{}
json.Unmarshal(w2.Body.Bytes(), &resp2)
data2 := resp2["data"].(map[string]interface{})
dashboard2 := data2["dashboard"].(map[string]interface{})
plays2 := dashboard2["total_plays"].(float64)
assert.Greater(t, plays1, float64(0), "real creator should have plays")
assert.Equal(t, float64(0), plays2, "random user should see no data")
}