diff --git a/veza-backend-api/internal/api/routes_analytics.go b/veza-backend-api/internal/api/routes_analytics.go index d09374670..aa1e69bc1 100644 --- a/veza-backend-api/internal/api/routes_analytics.go +++ b/veza-backend-api/internal/api/routes_analytics.go @@ -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 } } diff --git a/veza-backend-api/internal/core/analytics/creator_handler.go b/veza-backend-api/internal/core/analytics/creator_handler.go new file mode 100644 index 000000000..13f3680da --- /dev/null +++ b/veza-backend-api/internal/core/analytics/creator_handler.go @@ -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() +} diff --git a/veza-backend-api/internal/core/analytics/creator_handler_test.go b/veza-backend-api/internal/core/analytics/creator_handler_test.go new file mode 100644 index 000000000..980d76d1d --- /dev/null +++ b/veza-backend-api/internal/core/analytics/creator_handler_test.go @@ -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") +}