veza/veza-backend-api/internal/core/analytics/handler.go
senke 9cd0da0046 fix(v0.12.6): apply all pentest remediations — 36 findings across 36 files
CRITICAL fixes:
- Race condition (TOCTOU) in payout/refund with SELECT FOR UPDATE (CRITICAL-001/002)
- IDOR on analytics endpoint — ownership check enforced (CRITICAL-003)
- CSWSH on all WebSocket endpoints — origin whitelist (CRITICAL-004)
- Mass assignment on user self-update — strip privileged fields (CRITICAL-005)

HIGH fixes:
- Path traversal in marketplace upload — UUID filenames (HIGH-001)
- IP spoofing — use Gin trusted proxy c.ClientIP() (HIGH-002)
- Popularity metrics (followers, likes) set to json:"-" (HIGH-003)
- bcrypt cost hardened to 12 everywhere (HIGH-004)
- Refresh token lock made mandatory (HIGH-005)
- Stream token replay prevention with access_count (HIGH-006)
- Subscription trial race condition fixed (HIGH-007)
- License download expiration check (HIGH-008)
- Webhook amount validation (HIGH-009)
- pprof endpoint removed from production (HIGH-010)

MEDIUM fixes:
- WebSocket message size limit 64KB (MEDIUM-010)
- HSTS header in nginx production (MEDIUM-001)
- CORS origin restricted in nginx-rtmp (MEDIUM-002)
- Docker alpine pinned to 3.21 (MEDIUM-003/004)
- Redis authentication enforced (MEDIUM-005)
- GDPR account deletion expanded (MEDIUM-006)
- .gitignore hardened (MEDIUM-007)

LOW/INFO fixes:
- GitHub Actions SHA pinning on all workflows (LOW-001)
- .env.example security documentation (INFO-001)
- Production CORS set to HTTPS (LOW-002)

All tests pass. Go and Rust compile clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:44:46 +01:00

802 lines
24 KiB
Go

package analytics
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"veza-backend-api/internal/common"
apperrors "veza-backend-api/internal/errors"
"veza-backend-api/internal/handlers"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
"veza-backend-api/internal/services"
"veza-backend-api/internal/types"
)
// AnalyticsServiceInterface defines the interface for AnalyticsService
type AnalyticsServiceInterface interface {
GetTrackStats(ctx context.Context, trackID uuid.UUID) (*types.TrackStats, error)
GetPlaysOverTime(ctx context.Context, trackID uuid.UUID, startDate, endDate time.Time, interval string) ([]services.PlayTimePoint, error)
}
// AnalyticsJobWorkerInterface defines the interface for JobWorker (analytics related)
type AnalyticsJobWorkerInterface interface {
EnqueueAnalyticsJob(eventName string, userID *uuid.UUID, payload map[string]interface{})
}
// AnalyticsServiceWithDB extends AnalyticsServiceInterface with DB access for GetAnalytics
type AnalyticsServiceWithDB interface {
AnalyticsServiceInterface
GetDB() *gorm.DB
}
// Handler handles analytics HTTP requests (core domain, ADR-001)
type Handler struct {
analyticsService AnalyticsServiceInterface
jobWorker AnalyticsJobWorkerInterface
logger *zap.Logger
}
// NewHandler creates a new analytics handler
func NewHandler(analyticsService *services.AnalyticsService, logger *zap.Logger) *Handler {
if logger == nil {
logger = zap.NewNop()
}
return &Handler{
analyticsService: analyticsService,
logger: logger,
}
}
// NewHandlerWithInterface creates a handler with interfaces (for testing)
func NewHandlerWithInterface(analyticsService AnalyticsServiceInterface, logger *zap.Logger) *Handler {
if logger == nil {
logger = zap.NewNop()
}
return &Handler{
analyticsService: analyticsService,
logger: logger,
}
}
// SetJobWorker sets the JobWorker for recording analytics events
func (h *Handler) SetJobWorker(jobWorker AnalyticsJobWorkerInterface) {
h.jobWorker = jobWorker
}
// RecordEventRequest represents the request for recording a custom analytics event
type RecordEventRequest struct {
EventName string `json:"event_name" binding:"required,min=1,max=100" validate:"required,min=1,max=100"`
Payload map[string]interface{} `json:"payload,omitempty" validate:"omitempty"`
}
// RecordEvent handles POST /api/v1/analytics/events
func (h *Handler) RecordEvent(c *gin.Context) {
var req RecordEventRequest
if !common.BindAndValidateJSON(c, &req) {
return
}
var userID *uuid.UUID
if uid, ok := c.Get("user_id"); ok {
if uidUUID, ok := uid.(uuid.UUID); ok {
userID = &uidUUID
}
}
if h.jobWorker == nil {
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "analytics service not available"))
return
}
h.jobWorker.EnqueueAnalyticsJob(req.EventName, userID, req.Payload)
handlers.RespondSuccess(c, http.StatusOK, gin.H{
"message": "event recorded",
"event_name": req.EventName,
})
}
// GetTrafficSources handles GET /api/v1/analytics/traffic-sources
// Returns empty until traffic source tracking is implemented (track_plays has no source column)
func (h *Handler) GetTrafficSources(c *gin.Context) {
handlers.RespondSuccess(c, http.StatusOK, gin.H{"sources": []gin.H{}})
}
// GetDeviceBreakdown handles GET /api/v1/analytics/device-breakdown
// Returns mobile/desktop counts from track_plays for the user's tracks
func (h *Handler) GetDeviceBreakdown(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.RespondSuccess(c, http.StatusOK, gin.H{"mobile": 0, "desktop": 0})
return
}
var deviceCounts []struct {
Device string `gorm:"column:device"`
Count int64 `gorm:"column:cnt"`
}
if err := analyticsSvc.GetDB().WithContext(c.Request.Context()).
Table("track_plays tp").
Select("tp.device, COUNT(*) as cnt").
Joins("JOIN tracks t ON t.id = tp.track_id").
Where("t.creator_id = ?", userID).
Group("tp.device").
Find(&deviceCounts).Error; err != nil {
h.logger.Warn("Failed to fetch device breakdown", zap.Error(err))
handlers.RespondSuccess(c, http.StatusOK, gin.H{"mobile": 0, "desktop": 0})
return
}
var mobile, desktop int64
for _, d := range deviceCounts {
dev := strings.ToLower(strings.TrimSpace(d.Device))
if dev == "" || dev == "desktop" || dev == "web" {
desktop += d.Count
} else if dev == "mobile" || dev == "tablet" || strings.Contains(dev, "mobile") || strings.Contains(dev, "tablet") {
mobile += d.Count
} else {
desktop += d.Count
}
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{
"mobile": mobile,
"desktop": desktop,
})
}
// GetCreatorStats handles GET /api/v1/analytics/creator/stats
// Aggregates playback_analytics for the authenticated creator's tracks
func (h *Handler) GetCreatorStats(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.RespondSuccess(c, http.StatusOK, gin.H{
"total_plays": int64(0),
"unique_listeners": int64(0),
"average_completion_rate": float64(0),
"plays_by_day": []int64{},
"period": gin.H{"start_date": "", "end_date": "", "days": 0},
})
return
}
daysStr := c.DefaultQuery("days", "30")
days, err := strconv.Atoi(daysStr)
if err != nil || days < 1 {
days = 30
}
var startDate, endDate *time.Time
if startDateStr := c.Query("start_date"); startDateStr != "" {
if parsed, err := time.Parse(time.RFC3339, startDateStr); err == nil {
startDate = &parsed
}
}
if endDateStr := c.Query("end_date"); endDateStr != "" {
if parsed, err := time.Parse(time.RFC3339, endDateStr); err == nil {
endDate = &parsed
}
}
if startDate == nil || endDate == nil {
now := time.Now()
if endDate == nil {
endDate = &now
}
if startDate == nil {
calculatedStart := endDate.AddDate(0, 0, -days)
startDate = &calculatedStart
}
}
ctx := c.Request.Context()
db := analyticsSvc.GetDB()
var totalPlays int64
db.WithContext(ctx).Table("playback_analytics pa").
Joins("JOIN tracks t ON t.id = pa.track_id").
Where("t.creator_id = ? AND pa.started_at >= ? AND pa.started_at <= ?", userID, startDate, endDate).
Count(&totalPlays)
var uniqueListeners int64
db.WithContext(ctx).Table("playback_analytics pa").
Joins("JOIN tracks t ON t.id = pa.track_id").
Where("t.creator_id = ? AND pa.started_at >= ? AND pa.started_at <= ?", userID, startDate, endDate).
Select("COUNT(DISTINCT pa.user_id)").
Scan(&uniqueListeners)
var avgCompletion float64
db.WithContext(ctx).Table("playback_analytics pa").
Joins("JOIN tracks t ON t.id = pa.track_id").
Where("t.creator_id = ? AND pa.started_at >= ? AND pa.started_at <= ?", userID, startDate, endDate).
Select("COALESCE(AVG(pa.completion_rate), 0)").
Scan(&avgCompletion)
var dayCounts []struct {
Day string `gorm:"column:day"`
Count int64 `gorm:"column:cnt"`
}
db.WithContext(ctx).Table("playback_analytics pa").
Joins("JOIN tracks t ON t.id = pa.track_id").
Select("DATE(pa.started_at) as day, COUNT(*) as cnt").
Where("t.creator_id = ? AND pa.started_at >= ? AND pa.started_at <= ?", userID, startDate, endDate).
Group("DATE(pa.started_at)").
Order("day ASC").
Find(&dayCounts)
playsByDay := make([]int64, len(dayCounts))
for i, d := range dayCounts {
playsByDay[i] = d.Count
}
if len(playsByDay) == 0 {
playsByDay = []int64{0}
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{
"total_plays": totalPlays,
"unique_listeners": uniqueListeners,
"average_completion_rate": avgCompletion,
"plays_by_day": playsByDay,
"period": gin.H{
"start_date": startDate.Format(time.RFC3339),
"end_date": endDate.Format(time.RFC3339),
"days": days,
},
})
}
// 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")
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.RespondSuccess(c, http.StatusOK, gin.H{
"plays_per_day": []gin.H{},
"top_tracks": []gin.H{},
"period": gin.H{"start_date": "", "end_date": "", "days": 0},
})
return
}
daysStr := c.DefaultQuery("days", "30")
days, _ := strconv.Atoi(daysStr)
if days < 1 {
days = 30
}
endDate := time.Now()
startDate := endDate.AddDate(0, 0, -days)
ctx := c.Request.Context()
db := analyticsSvc.GetDB()
var dayRows []struct {
Day string `gorm:"column:day"`
Count int64 `gorm:"column:cnt"`
}
db.WithContext(ctx).Table("track_plays tp").
Joins("JOIN tracks t ON t.id = tp.track_id").
Select("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("DATE(tp.played_at)").
Order("day ASC").
Find(&dayRows)
playsPerDay := make([]gin.H, len(dayRows))
for i, r := range dayRows {
playsPerDay[i] = gin.H{"date": r.Day, "count": r.Count}
}
var topRows []struct {
TrackID uuid.UUID `gorm:"column:track_id"`
Title string `gorm:"column:title"`
PlayCount int64 `gorm:"column:play_count"`
}
db.WithContext(ctx).Table("track_plays tp").
Joins("JOIN tracks t ON t.id = tp.track_id").
Select("tp.track_id, t.title, COUNT(*) as play_count").
Where("t.creator_id = ? AND tp.played_at >= ? AND tp.played_at <= ?", userID, startDate, endDate).
Group("tp.track_id, t.title").
Order("play_count DESC").
Limit(10).
Find(&topRows)
topTracks := make([]gin.H, len(topRows))
for i, r := range topRows {
topTracks[i] = gin.H{"track_id": r.TrackID.String(), "title": r.Title, "plays": r.PlayCount}
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{
"plays_per_day": playsPerDay,
"top_tracks": topTracks,
"period": gin.H{
"start_date": startDate.Format(time.RFC3339),
"end_date": endDate.Format(time.RFC3339),
"days": days,
},
})
}
// GetAnalytics handles GET /api/v1/analytics
func (h *Handler) GetAnalytics(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
}
daysStr := c.DefaultQuery("days", "30")
days, err := strconv.Atoi(daysStr)
if err != nil || days < 1 {
days = 30
}
var startDate, endDate *time.Time
if startDateStr := c.Query("start_date"); startDateStr != "" {
if parsed, err := time.Parse(time.RFC3339, startDateStr); err == nil {
startDate = &parsed
}
}
if endDateStr := c.Query("end_date"); endDateStr != "" {
if parsed, err := time.Parse(time.RFC3339, endDateStr); err == nil {
endDate = &parsed
}
}
if startDate == nil || endDate == nil {
now := time.Now()
if endDate == nil {
endDate = &now
}
if startDate == nil {
calculatedStart := endDate.AddDate(0, 0, -days)
startDate = &calculatedStart
}
}
ctx := c.Request.Context()
analyticsSvc, ok := h.analyticsService.(AnalyticsServiceWithDB)
if !ok {
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "analytics service type error"))
return
}
var tracks []struct {
ID uuid.UUID `gorm:"column:id"`
Title string `gorm:"column:title"`
PlayCount int64 `gorm:"column:play_count"`
LikeCount int64 `gorm:"column:like_count"`
DownloadCount int64 `gorm:"column:download_count"`
}
if err := analyticsSvc.GetDB().WithContext(ctx).
Table("tracks").
Select("id, title, play_count, like_count, COALESCE(download_count, 0) as download_count").
Where("creator_id = ?", userID).
Find(&tracks).Error; err != nil {
h.logger.Error("Failed to fetch user tracks", zap.Error(err))
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "failed to fetch tracks"))
return
}
totalTracks := len(tracks)
var totalPlays, totalLikes, totalDownloads int64
for _, track := range tracks {
totalPlays += track.PlayCount
totalLikes += track.LikeCount
totalDownloads += track.DownloadCount
}
avgPlayCount := float64(0)
if totalTracks > 0 {
avgPlayCount = float64(totalPlays) / float64(totalTracks)
}
topTracks := make([]gin.H, 0, 5)
sortedTracks := make([]struct {
ID uuid.UUID
Title string
PlayCount int64
LikeCount int64
}, len(tracks))
for i, t := range tracks {
sortedTracks[i] = struct {
ID uuid.UUID
Title string
PlayCount int64
LikeCount int64
}{t.ID, t.Title, t.PlayCount, t.LikeCount}
}
for i := 0; i < len(sortedTracks)-1; i++ {
for j := i + 1; j < len(sortedTracks); j++ {
if sortedTracks[i].PlayCount < sortedTracks[j].PlayCount {
sortedTracks[i], sortedTracks[j] = sortedTracks[j], sortedTracks[i]
}
}
}
for i := 0; i < 5 && i < len(sortedTracks); i++ {
topTracks = append(topTracks, gin.H{
"id": sortedTracks[i].ID.String(),
"title": sortedTracks[i].Title,
"play_count": sortedTracks[i].PlayCount,
"like_count": sortedTracks[i].LikeCount,
"plays": sortedTracks[i].PlayCount,
"change": 0,
"revenue": 0.0,
})
}
var playlists []struct {
ID uuid.UUID `gorm:"column:id"`
Name string `gorm:"column:name"`
PlayCount int64 `gorm:"column:play_count"`
LikeCount int64 `gorm:"column:like_count"`
ShareCount int64 `gorm:"column:share_count"`
}
playlistsError := analyticsSvc.GetDB().WithContext(ctx).
Table("playlists").
Select("id, name, COALESCE(play_count, 0) as play_count, COALESCE(like_count, 0) as like_count, COALESCE(share_count, 0) as share_count").
Where("user_id = ?", userID).
Find(&playlists).Error
if playlistsError != nil {
h.logger.Warn("Failed to fetch user playlists, continuing with empty playlists data", zap.Error(playlistsError))
playlists = nil
}
totalPlaylists := len(playlists)
var playlistPlays, playlistLikes, playlistShares int64
for _, playlist := range playlists {
playlistPlays += playlist.PlayCount
playlistLikes += playlist.LikeCount
playlistShares += playlist.ShareCount
}
avgPlaylistPlayCount := float64(0)
if totalPlaylists > 0 {
avgPlaylistPlayCount = float64(playlistPlays) / float64(totalPlaylists)
}
topPlaylists := make([]gin.H, 0, 5)
sortedPlaylists := make([]struct {
ID uuid.UUID
Name string
PlayCount int64
LikeCount int64
}, len(playlists))
for i, p := range playlists {
sortedPlaylists[i] = struct {
ID uuid.UUID
Name string
PlayCount int64
LikeCount int64
}{p.ID, p.Name, p.PlayCount, p.LikeCount}
}
for i := 0; i < len(sortedPlaylists)-1; i++ {
for j := i + 1; j < len(sortedPlaylists); j++ {
if sortedPlaylists[i].PlayCount < sortedPlaylists[j].PlayCount {
sortedPlaylists[i], sortedPlaylists[j] = sortedPlaylists[j], sortedPlaylists[i]
}
}
}
for i := 0; i < 5 && i < len(sortedPlaylists); i++ {
topPlaylists = append(topPlaylists, gin.H{
"id": sortedPlaylists[i].ID.String(),
"name": sortedPlaylists[i].Name,
"play_count": sortedPlaylists[i].PlayCount,
"like_count": sortedPlaylists[i].LikeCount,
})
}
// P3.2: Additional fields for frontend GlobalStats contract
var followersCount int64
if err := analyticsSvc.GetDB().WithContext(ctx).
Table("follows").
Where("followed_id = ?", userID).
Count(&followersCount).Error; err != nil {
h.logger.Warn("Failed to fetch followers count", zap.Error(err))
}
var totalRevenue float64
if err := analyticsSvc.GetDB().WithContext(ctx).
Table("order_items").
Select("COALESCE(SUM(oi.price), 0)").
Joins("JOIN orders o ON o.id = order_items.order_id").
Joins("JOIN products p ON p.id = order_items.product_id").
Where("p.seller_id = ? AND o.status IN ('completed', 'paid')", userID).
Scan(&totalRevenue).Error; err != nil {
h.logger.Warn("Failed to fetch seller revenue", zap.Error(err))
}
// Sparklines: plays per day for user's tracks (last N days)
var sparklinePlays []int64
var dayCounts []struct {
Day string `gorm:"column:day"`
Count int64 `gorm:"column:cnt"`
}
if err := analyticsSvc.GetDB().WithContext(ctx).
Table("track_plays tp").
Select("DATE(tp.played_at) as day, COUNT(*) as cnt").
Joins("JOIN tracks t ON t.id = tp.track_id").
Where("t.creator_id = ? AND tp.played_at >= ? AND tp.played_at <= ?", userID, startDate, endDate).
Group("DATE(tp.played_at)").
Order("day ASC").
Find(&dayCounts).Error; err == nil && len(dayCounts) > 0 {
sparklinePlays = make([]int64, len(dayCounts))
for i, d := range dayCounts {
sparklinePlays[i] = d.Count
}
}
if len(sparklinePlays) == 0 {
sparklinePlays = []int64{0}
}
// Trends: % change vs previous period (simplified: use 0 if no prior data)
trends := gin.H{
"plays": float64(0),
"revenue": float64(0),
"followers": float64(0),
"views": float64(0),
}
analyticsData := gin.H{
"tracks": gin.H{
"total_tracks": totalTracks,
"total_plays": totalPlays,
"total_likes": totalLikes,
"total_downloads": totalDownloads,
"average_play_count": avgPlayCount,
"top_tracks": topTracks,
},
"playlists": gin.H{
"total_playlists": totalPlaylists,
"total_plays": playlistPlays,
"total_likes": playlistLikes,
"total_shares": playlistShares,
"average_play_count": avgPlaylistPlayCount,
"top_playlists": topPlaylists,
},
"period": gin.H{
"start_date": startDate.Format(time.RFC3339),
"end_date": endDate.Format(time.RFC3339),
"days": days,
},
// P3.2: Frontend GlobalStats contract
"total_tracks": totalTracks,
"total_plays": totalPlays,
"total_revenue": totalRevenue,
"followers": followersCount,
"profile_views": 0,
"trends": trends,
"sparklines": gin.H{
"plays": sparklinePlays,
"revenue": []float64{0},
"followers": []int64{0},
"views": []int64{0},
},
}
handlers.RespondSuccess(c, http.StatusOK, analyticsData)
}
// GetTrackAnalyticsDashboard handles GET /api/v1/analytics/tracks/:id
// SECURITY(REM-004): Verifies track ownership before returning analytics (prevents IDOR).
func (h *Handler) GetTrackAnalyticsDashboard(c *gin.Context) {
trackIDStr := c.Param("id")
if trackIDStr == "" {
handlers.RespondWithAppError(c, apperrors.NewValidationError("track id is required"))
return
}
trackID, err := uuid.Parse(trackIDStr)
if err != nil {
handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid track id"))
return
}
// Verify ownership: only the track creator can view analytics
userID, exists := common.GetUserIDFromContext(c)
if !exists {
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeUnauthorized, "unauthorized"))
return
}
analyticsSvc, ok := h.analyticsService.(AnalyticsServiceWithDB)
if !ok {
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "analytics service unavailable"))
return
}
var creatorIDStr string
if err := analyticsSvc.GetDB().WithContext(c.Request.Context()).
Table("tracks").Select("creator_id").
Where("id = ?", trackID).Scan(&creatorIDStr).Error; err != nil || creatorIDStr == "" {
handlers.RespondWithAppError(c, apperrors.NewNotFoundError("track"))
return
}
creatorID, _ := uuid.Parse(creatorIDStr)
if creatorID != userID {
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeForbidden, "you do not own this track"))
return
}
stats, err := h.analyticsService.GetTrackStats(c.Request.Context(), trackID)
if err != nil {
if err.Error() == "track not found" {
handlers.RespondWithAppError(c, apperrors.NewNotFoundError("track"))
return
}
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get track stats", err))
return
}
startDate := time.Now().AddDate(0, 0, -30)
endDate := time.Now()
playsOverTime, err := h.analyticsService.GetPlaysOverTime(c.Request.Context(), trackID, startDate, endDate, "day")
if err != nil {
playsOverTime = []services.PlayTimePoint{}
}
dashboard := gin.H{
"track_id": trackID.String(),
"stats": gin.H{
"total_plays": stats.TotalPlays,
"unique_listeners": stats.UniqueListeners,
"average_duration": stats.AverageDuration,
"completion_rate": stats.CompletionRate,
},
"plays_over_time": playsOverTime,
"period": gin.H{
"start_date": startDate.Format(time.RFC3339),
"end_date": endDate.Format(time.RFC3339),
"days": 30,
},
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{
"dashboard": dashboard,
})
}