diff --git a/veza-backend-api/internal/services/creator_analytics_service.go b/veza-backend-api/internal/services/creator_analytics_service.go new file mode 100644 index 000000000..f0152b7ea --- /dev/null +++ b/veza-backend-api/internal/services/creator_analytics_service.go @@ -0,0 +1,601 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// CreatorAnalyticsService provides analytics data for creators (F381-F395) +// All data is private to the creator — never exposed publicly. +type CreatorAnalyticsService struct { + db *gorm.DB + logger *zap.Logger +} + +// NewCreatorAnalyticsService creates a new creator analytics service +func NewCreatorAnalyticsService(db *gorm.DB, logger *zap.Logger) *CreatorAnalyticsService { + if logger == nil { + logger = zap.NewNop() + } + return &CreatorAnalyticsService{db: db, logger: logger} +} + +// CreatorDashboardStats is the aggregated creator dashboard response (F381) +type CreatorDashboardStats struct { + TotalPlays int64 `json:"total_plays"` + CompleteListens int64 `json:"complete_listens"` + UniqueListeners int64 `json:"unique_listeners"` + TotalPlayTime int64 `json:"total_play_time"` // seconds + AvgPlayDuration float64 `json:"avg_play_duration"` // seconds + AvgCompletionRate float64 `json:"avg_completion_rate"` // percentage + TotalTracks int64 `json:"total_tracks"` + TotalFollowers int64 `json:"total_followers"` + TotalRevenue float64 `json:"total_revenue"` +} + +// GetCreatorDashboard returns aggregated stats for a creator's dashboard (F381) +func (s *CreatorAnalyticsService) GetCreatorDashboard(ctx context.Context, creatorID uuid.UUID, startDate, endDate time.Time) (*CreatorDashboardStats, error) { + stats := &CreatorDashboardStats{} + + // Plays, unique listeners, complete listens, play time, completion from playback_analytics + var playbackAgg struct { + TotalPlays int64 `gorm:"column:total_plays"` + UniqueListeners int64 `gorm:"column:unique_listeners"` + CompleteListens int64 `gorm:"column:complete_listens"` + TotalPlayTime int64 `gorm:"column:total_play_time"` + AvgCompletion float64 `gorm:"column:avg_completion"` + } + if err := s.db.WithContext(ctx).Raw(` + SELECT + COUNT(pa.id) AS total_plays, + COUNT(DISTINCT pa.user_id) AS unique_listeners, + COUNT(CASE WHEN pa.completion_rate >= 90 THEN 1 END) AS complete_listens, + COALESCE(SUM(pa.play_time), 0) AS total_play_time, + COALESCE(AVG(pa.completion_rate), 0) AS avg_completion + FROM playback_analytics pa + JOIN tracks t ON t.id = pa.track_id + WHERE t.creator_id = ? AND pa.started_at >= ? AND pa.started_at <= ? + `, creatorID, startDate, endDate).Scan(&playbackAgg).Error; err != nil { + return nil, fmt.Errorf("failed to get playback stats: %w", err) + } + + stats.TotalPlays = playbackAgg.TotalPlays + stats.UniqueListeners = playbackAgg.UniqueListeners + stats.CompleteListens = playbackAgg.CompleteListens + stats.TotalPlayTime = playbackAgg.TotalPlayTime + stats.AvgCompletionRate = playbackAgg.AvgCompletion + if stats.TotalPlays > 0 { + stats.AvgPlayDuration = float64(stats.TotalPlayTime) / float64(stats.TotalPlays) + } + + // Also add track_plays data + var trackPlayAgg struct { + TrackPlays int64 `gorm:"column:track_plays"` + } + if err := s.db.WithContext(ctx).Raw(` + SELECT COUNT(tp.id) AS track_plays + FROM track_plays tp + JOIN tracks t ON t.id = tp.track_id + WHERE t.creator_id = ? AND tp.played_at >= ? AND tp.played_at <= ? + `, creatorID, startDate, endDate).Scan(&trackPlayAgg).Error; err != nil { + s.logger.Warn("Failed to get track_plays count", zap.Error(err)) + } + // Use the higher of the two counts + if trackPlayAgg.TrackPlays > stats.TotalPlays { + stats.TotalPlays = trackPlayAgg.TrackPlays + } + + // Total tracks + s.db.WithContext(ctx).Table("tracks").Where("creator_id = ? AND deleted_at IS NULL", creatorID).Count(&stats.TotalTracks) + + // Total followers + s.db.WithContext(ctx).Table("follows").Where("followed_id = ?", creatorID).Count(&stats.TotalFollowers) + + // Total revenue + if err := s.db.WithContext(ctx).Raw(` + SELECT COALESCE(SUM(oi.price), 0) + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + JOIN products p ON p.id = oi.product_id + WHERE p.seller_id = ? AND o.status IN ('completed', 'paid') + AND o.created_at >= ? AND o.created_at <= ? + `, creatorID, startDate, endDate).Scan(&stats.TotalRevenue).Error; err != nil { + s.logger.Warn("Failed to get revenue", zap.Error(err)) + } + + return stats, nil +} + +// PlayEvolutionPoint represents a single data point in the play evolution chart (F382) +type PlayEvolutionPoint struct { + Date string `json:"date"` + TotalPlays int64 `json:"total_plays"` + CompleteListens int64 `json:"complete_listens"` + UniqueListeners int64 `json:"unique_listeners"` + TotalPlayTime int64 `json:"total_play_time"` + AvgCompletion float64 `json:"avg_completion"` +} + +// GetPlayEvolution returns play data over time for the creator (F382) +// interval: "day", "week", "month" +func (s *CreatorAnalyticsService) GetPlayEvolution(ctx context.Context, creatorID uuid.UUID, startDate, endDate time.Time, interval string) ([]PlayEvolutionPoint, error) { + var dateExpr string + switch interval { + case "week": + dateExpr = "DATE_TRUNC('week', pa.started_at)::date" + case "month": + dateExpr = "DATE_TRUNC('month', pa.started_at)::date" + default: + dateExpr = "DATE(pa.started_at)" + } + + var rows []struct { + Date string `gorm:"column:date"` + TotalPlays int64 `gorm:"column:total_plays"` + CompleteListens int64 `gorm:"column:complete_listens"` + UniqueListeners int64 `gorm:"column:unique_listeners"` + TotalPlayTime int64 `gorm:"column:total_play_time"` + AvgCompletion float64 `gorm:"column:avg_completion"` + } + + query := fmt.Sprintf(` + SELECT + %s AS date, + COUNT(pa.id) AS total_plays, + COUNT(CASE WHEN pa.completion_rate >= 90 THEN 1 END) AS complete_listens, + COUNT(DISTINCT pa.user_id) AS unique_listeners, + COALESCE(SUM(pa.play_time), 0) AS total_play_time, + COALESCE(AVG(pa.completion_rate), 0) AS avg_completion + FROM playback_analytics pa + JOIN tracks t ON t.id = pa.track_id + WHERE t.creator_id = ? AND pa.started_at >= ? AND pa.started_at <= ? + GROUP BY date + ORDER BY date ASC + `, dateExpr) + + if err := s.db.WithContext(ctx).Raw(query, creatorID, startDate, endDate).Scan(&rows).Error; err != nil { + return nil, fmt.Errorf("failed to get play evolution: %w", err) + } + + points := make([]PlayEvolutionPoint, len(rows)) + for i, r := range rows { + points[i] = PlayEvolutionPoint{ + Date: r.Date, + TotalPlays: r.TotalPlays, + CompleteListens: r.CompleteListens, + UniqueListeners: r.UniqueListeners, + TotalPlayTime: r.TotalPlayTime, + AvgCompletion: r.AvgCompletion, + } + } + return points, nil +} + +// SalesRecord represents a sales entry for downloads and purchases (F383) +type SalesRecord struct { + Date string `json:"date"` + TrackID string `json:"track_id"` + TrackTitle string `json:"track_title"` + ProductType string `json:"product_type"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + BuyerCount int64 `json:"buyer_count"` +} + +// SalesSummary aggregates sales data (F383) +type SalesSummary struct { + TotalRevenue float64 `json:"total_revenue"` + TotalSales int64 `json:"total_sales"` + RevenueByPeriod []RevenuePeriod `json:"revenue_by_period"` + TopSellingTracks []TopSellingTrack `json:"top_selling_tracks"` +} + +// RevenuePeriod represents revenue for a time period +type RevenuePeriod struct { + Date string `json:"date"` + Revenue float64 `json:"revenue"` + Sales int64 `json:"sales"` +} + +// TopSellingTrack represents a top-selling track +type TopSellingTrack struct { + TrackID string `json:"track_id"` + Title string `json:"title"` + Revenue float64 `json:"revenue"` + Sales int64 `json:"sales"` +} + +// GetSalesSummary returns sales and download data for the creator (F383) +func (s *CreatorAnalyticsService) GetSalesSummary(ctx context.Context, creatorID uuid.UUID, startDate, endDate time.Time) (*SalesSummary, error) { + summary := &SalesSummary{ + RevenueByPeriod: []RevenuePeriod{}, + TopSellingTracks: []TopSellingTrack{}, + } + + // Total revenue and sales count + var totals struct { + TotalRevenue float64 `gorm:"column:total_revenue"` + TotalSales int64 `gorm:"column:total_sales"` + } + if err := s.db.WithContext(ctx).Raw(` + SELECT COALESCE(SUM(oi.price), 0) AS total_revenue, COUNT(oi.id) AS total_sales + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + JOIN products p ON p.id = oi.product_id + WHERE p.seller_id = ? AND o.status IN ('completed', 'paid') + AND o.created_at >= ? AND o.created_at <= ? + `, creatorID, startDate, endDate).Scan(&totals).Error; err != nil { + s.logger.Warn("Failed to get sales totals", zap.Error(err)) + } else { + summary.TotalRevenue = totals.TotalRevenue + summary.TotalSales = totals.TotalSales + } + + // Revenue by day + var periodRows []struct { + Date string `gorm:"column:date"` + Revenue float64 `gorm:"column:revenue"` + Sales int64 `gorm:"column:sales"` + } + if err := s.db.WithContext(ctx).Raw(` + SELECT DATE(o.created_at) AS date, COALESCE(SUM(oi.price), 0) AS revenue, COUNT(oi.id) AS sales + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + JOIN products p ON p.id = oi.product_id + WHERE p.seller_id = ? AND o.status IN ('completed', 'paid') + AND o.created_at >= ? AND o.created_at <= ? + GROUP BY DATE(o.created_at) + ORDER BY date ASC + `, creatorID, startDate, endDate).Scan(&periodRows).Error; err != nil { + s.logger.Warn("Failed to get revenue by period", zap.Error(err)) + } + for _, r := range periodRows { + summary.RevenueByPeriod = append(summary.RevenueByPeriod, RevenuePeriod{ + Date: r.Date, Revenue: r.Revenue, Sales: r.Sales, + }) + } + + // Top selling tracks + var topRows []struct { + TrackID string `gorm:"column:track_id"` + Title string `gorm:"column:title"` + Revenue float64 `gorm:"column:revenue"` + Sales int64 `gorm:"column:sales"` + } + if err := s.db.WithContext(ctx).Raw(` + SELECT CAST(p.id AS TEXT) AS track_id, COALESCE(t.title, p.name) AS title, + COALESCE(SUM(oi.price), 0) AS revenue, COUNT(oi.id) AS sales + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + JOIN products p ON p.id = oi.product_id + LEFT JOIN tracks t ON t.id = p.id + WHERE p.seller_id = ? AND o.status IN ('completed', 'paid') + AND o.created_at >= ? AND o.created_at <= ? + GROUP BY p.id, t.title, p.name + ORDER BY revenue DESC + LIMIT 10 + `, creatorID, startDate, endDate).Scan(&topRows).Error; err != nil { + s.logger.Warn("Failed to get top selling tracks", zap.Error(err)) + } + for _, r := range topRows { + summary.TopSellingTracks = append(summary.TopSellingTracks, TopSellingTrack{ + TrackID: r.TrackID, Title: r.Title, Revenue: r.Revenue, Sales: r.Sales, + }) + } + + return summary, nil +} + +// DiscoverySourceBreakdown represents how users discover the creator's tracks (F381) +type DiscoverySourceBreakdown struct { + Source string `json:"source"` + Count int64 `json:"count"` + Percentage float64 `json:"percentage"` +} + +// GetDiscoverySources returns how listeners find the creator's tracks (F381) +func (s *CreatorAnalyticsService) GetDiscoverySources(ctx context.Context, creatorID uuid.UUID, startDate, endDate time.Time) ([]DiscoverySourceBreakdown, error) { + var rows []struct { + Source string `gorm:"column:source"` + Count int64 `gorm:"column:cnt"` + } + + if err := s.db.WithContext(ctx).Raw(` + SELECT COALESCE(NULLIF(tp.source, ''), 'direct') AS source, COUNT(*) AS cnt + FROM track_plays tp + JOIN tracks t ON t.id = tp.track_id + WHERE t.creator_id = ? AND tp.played_at >= ? AND tp.played_at <= ? + GROUP BY source + ORDER BY cnt DESC + `, creatorID, startDate, endDate).Scan(&rows).Error; err != nil { + return nil, fmt.Errorf("failed to get discovery sources: %w", err) + } + + var total int64 + for _, r := range rows { + total += r.Count + } + + sources := make([]DiscoverySourceBreakdown, len(rows)) + for i, r := range rows { + pct := float64(0) + if total > 0 { + pct = float64(r.Count) / float64(total) * 100 + } + sources[i] = DiscoverySourceBreakdown{ + Source: r.Source, + Count: r.Count, + Percentage: pct, + } + } + return sources, nil +} + +// GeographicBreakdown represents geographic distribution of plays (F381) +type GeographicBreakdown struct { + CountryCode string `json:"country_code"` + Region string `json:"region,omitempty"` + PlayCount int64 `json:"play_count"` + UniqueListeners int64 `json:"unique_listeners"` + Percentage float64 `json:"percentage"` +} + +// GetGeographicBreakdown returns geographic distribution of plays (F381) +// Data is always aggregated — never exposes individual user locations +func (s *CreatorAnalyticsService) GetGeographicBreakdown(ctx context.Context, creatorID uuid.UUID, startDate, endDate time.Time) ([]GeographicBreakdown, error) { + var rows []struct { + CountryCode string `gorm:"column:country_code"` + PlayCount int64 `gorm:"column:play_count"` + UniqueListeners int64 `gorm:"column:unique_listeners"` + } + + if err := s.db.WithContext(ctx).Raw(` + SELECT COALESCE(NULLIF(tp.country_code, ''), 'XX') AS country_code, + COUNT(*) AS play_count, + COUNT(DISTINCT tp.user_id) AS unique_listeners + FROM track_plays tp + JOIN tracks t ON t.id = tp.track_id + WHERE t.creator_id = ? AND tp.played_at >= ? AND tp.played_at <= ? + GROUP BY country_code + HAVING COUNT(DISTINCT tp.user_id) >= 10 + ORDER BY play_count DESC + `, creatorID, startDate, endDate).Scan(&rows).Error; err != nil { + return nil, fmt.Errorf("failed to get geographic breakdown: %w", err) + } + + var totalPlays int64 + for _, r := range rows { + totalPlays += r.PlayCount + } + + result := make([]GeographicBreakdown, len(rows)) + for i, r := range rows { + pct := float64(0) + if totalPlays > 0 { + pct = float64(r.PlayCount) / float64(totalPlays) * 100 + } + result[i] = GeographicBreakdown{ + CountryCode: r.CountryCode, + PlayCount: r.PlayCount, + UniqueListeners: r.UniqueListeners, + Percentage: pct, + } + } + return result, nil +} + +// AudienceProfile represents aggregated audience demographics (F384) +// Only shown when >= 10 unique listeners for privacy +type AudienceProfile struct { + TotalListeners int64 `json:"total_listeners"` + ListenersByGenre []GenreBreakdown `json:"listeners_by_genre"` + TopListeningTimes []ListeningTimeSlot `json:"top_listening_times"` +} + +// GenreBreakdown shows what genres the audience listens to +type GenreBreakdown struct { + Genre string `json:"genre"` + Count int64 `json:"count"` + Percentage float64 `json:"percentage"` +} + +// ListeningTimeSlot shows when the audience listens most +type ListeningTimeSlot struct { + Hour int `json:"hour"` + PlayCount int64 `json:"play_count"` +} + +// GetAudienceProfile returns aggregated audience data (F384) +// Requires minimum 10 unique listeners for privacy protection +func (s *CreatorAnalyticsService) GetAudienceProfile(ctx context.Context, creatorID uuid.UUID, startDate, endDate time.Time) (*AudienceProfile, error) { + profile := &AudienceProfile{ + ListenersByGenre: []GenreBreakdown{}, + TopListeningTimes: []ListeningTimeSlot{}, + } + + // Count unique listeners + if err := s.db.WithContext(ctx).Raw(` + SELECT COUNT(DISTINCT pa.user_id) + FROM playback_analytics pa + JOIN tracks t ON t.id = pa.track_id + WHERE t.creator_id = ? AND pa.started_at >= ? AND pa.started_at <= ? + `, creatorID, startDate, endDate).Scan(&profile.TotalListeners).Error; err != nil { + return nil, fmt.Errorf("failed to count listeners: %w", err) + } + + // Privacy check: minimum 10 unique listeners + if profile.TotalListeners < 10 { + return profile, nil + } + + // What genres does the audience also listen to + var genreRows []struct { + Genre string `gorm:"column:genre"` + Count int64 `gorm:"column:cnt"` + } + if err := s.db.WithContext(ctx).Raw(` + SELECT t2.genre, COUNT(DISTINCT pa2.id) AS cnt + FROM playback_analytics pa + JOIN tracks t ON t.id = pa.track_id + JOIN playback_analytics pa2 ON pa2.user_id = pa.user_id AND pa2.track_id != pa.track_id + JOIN tracks t2 ON t2.id = pa2.track_id + WHERE t.creator_id = ? AND pa.started_at >= ? AND pa.started_at <= ? + AND t2.genre != '' AND t2.genre IS NOT NULL + GROUP BY t2.genre + ORDER BY cnt DESC + LIMIT 10 + `, creatorID, startDate, endDate).Scan(&genreRows).Error; err != nil { + s.logger.Warn("Failed to get audience genres", zap.Error(err)) + } + + var totalGenre int64 + for _, r := range genreRows { + totalGenre += r.Count + } + for _, r := range genreRows { + pct := float64(0) + if totalGenre > 0 { + pct = float64(r.Count) / float64(totalGenre) * 100 + } + profile.ListenersByGenre = append(profile.ListenersByGenre, GenreBreakdown{ + Genre: r.Genre, Count: r.Count, Percentage: pct, + }) + } + + // Peak listening times (hour of day) + var timeRows []struct { + Hour int `gorm:"column:hour"` + PlayCount int64 `gorm:"column:play_count"` + } + if err := s.db.WithContext(ctx).Raw(` + SELECT EXTRACT(HOUR FROM pa.started_at)::int AS hour, COUNT(*) AS play_count + FROM playback_analytics pa + JOIN tracks t ON t.id = pa.track_id + WHERE t.creator_id = ? AND pa.started_at >= ? AND pa.started_at <= ? + GROUP BY hour + ORDER BY hour ASC + `, creatorID, startDate, endDate).Scan(&timeRows).Error; err != nil { + s.logger.Warn("Failed to get listening times", zap.Error(err)) + } + for _, r := range timeRows { + profile.TopListeningTimes = append(profile.TopListeningTimes, ListeningTimeSlot{ + Hour: r.Hour, PlayCount: r.PlayCount, + }) + } + + return profile, nil +} + +// LiveStreamMetrics represents real-time metrics for a live stream (F385) +type LiveStreamMetrics struct { + StreamID string `json:"stream_id"` + CurrentViewers int `json:"current_viewers"` + PeakViewers int `json:"peak_viewers"` + IsLive bool `json:"is_live"` + DurationSeconds int64 `json:"duration_seconds"` +} + +// GetLiveStreamMetrics returns current metrics for a live stream (F385) +func (s *CreatorAnalyticsService) GetLiveStreamMetrics(ctx context.Context, creatorID uuid.UUID, streamID uuid.UUID) (*LiveStreamMetrics, error) { + var stream struct { + ID uuid.UUID `gorm:"column:id"` + UserID uuid.UUID `gorm:"column:user_id"` + IsLive bool `gorm:"column:is_live"` + ViewerCount int `gorm:"column:viewer_count"` + StartedAt *time.Time `gorm:"column:started_at"` + } + + if err := s.db.WithContext(ctx).Table("live_streams"). + Where("id = ? AND user_id = ?", streamID, creatorID). + First(&stream).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("live stream not found") + } + return nil, fmt.Errorf("failed to get live stream: %w", err) + } + + metrics := &LiveStreamMetrics{ + StreamID: streamID.String(), + CurrentViewers: stream.ViewerCount, + PeakViewers: stream.ViewerCount, // Could track separately in future + IsLive: stream.IsLive, + } + + if stream.StartedAt != nil && stream.IsLive { + metrics.DurationSeconds = int64(time.Since(*stream.StartedAt).Seconds()) + } + + return metrics, nil +} + +// GetTrackDetailedStats returns per-track stats for a creator (used in F381 dashboard) +type TrackDetailedStats struct { + TrackID string `json:"track_id"` + Title string `json:"title"` + TotalPlays int64 `json:"total_plays"` + CompleteListens int64 `json:"complete_listens"` + UniqueListeners int64 `json:"unique_listeners"` + AvgPlayDuration float64 `json:"avg_play_duration"` + AvgCompletion float64 `json:"avg_completion"` +} + +// GetPerTrackStats returns detailed stats for each of the creator's tracks +func (s *CreatorAnalyticsService) GetPerTrackStats(ctx context.Context, creatorID uuid.UUID, startDate, endDate time.Time, limit, offset int) ([]TrackDetailedStats, int64, error) { + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + + var total int64 + s.db.WithContext(ctx).Table("tracks").Where("creator_id = ? AND deleted_at IS NULL", creatorID).Count(&total) + + var rows []struct { + TrackID string `gorm:"column:track_id"` + Title string `gorm:"column:title"` + TotalPlays int64 `gorm:"column:total_plays"` + CompleteListens int64 `gorm:"column:complete_listens"` + UniqueListeners int64 `gorm:"column:unique_listeners"` + AvgPlayDuration float64 `gorm:"column:avg_play_duration"` + AvgCompletion float64 `gorm:"column:avg_completion"` + } + + if err := s.db.WithContext(ctx).Raw(` + SELECT CAST(t.id AS TEXT) AS track_id, t.title, + COUNT(pa.id) AS total_plays, + COUNT(CASE WHEN pa.completion_rate >= 90 THEN 1 END) AS complete_listens, + COUNT(DISTINCT pa.user_id) AS unique_listeners, + COALESCE(AVG(pa.play_time), 0) AS avg_play_duration, + COALESCE(AVG(pa.completion_rate), 0) AS avg_completion + FROM tracks t + LEFT JOIN playback_analytics pa ON pa.track_id = t.id + AND pa.started_at >= ? AND pa.started_at <= ? + WHERE t.creator_id = ? AND t.deleted_at IS NULL + GROUP BY t.id, t.title + ORDER BY total_plays DESC + LIMIT ? OFFSET ? + `, startDate, endDate, creatorID, limit, offset).Scan(&rows).Error; err != nil { + return nil, 0, fmt.Errorf("failed to get per-track stats: %w", err) + } + + result := make([]TrackDetailedStats, len(rows)) + for i, r := range rows { + result[i] = TrackDetailedStats{ + TrackID: r.TrackID, + Title: r.Title, + TotalPlays: r.TotalPlays, + CompleteListens: r.CompleteListens, + UniqueListeners: r.UniqueListeners, + AvgPlayDuration: r.AvgPlayDuration, + AvgCompletion: r.AvgCompletion, + } + } + + return result, total, nil +} diff --git a/veza-backend-api/internal/services/creator_analytics_service_test.go b/veza-backend-api/internal/services/creator_analytics_service_test.go new file mode 100644 index 000000000..97c78cf43 --- /dev/null +++ b/veza-backend-api/internal/services/creator_analytics_service_test.go @@ -0,0 +1,344 @@ +package services + +import ( + "context" + "testing" + "time" + + "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" + + "veza-backend-api/internal/models" +) + +func setupTestCreatorAnalyticsService(t *testing.T) (*CreatorAnalyticsService, *gorm.DB, uuid.UUID, 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) + + // Add source and country_code columns to track_plays (normally done by migration) + db.Exec("ALTER TABLE track_plays ADD COLUMN source TEXT DEFAULT ''") + db.Exec("ALTER TABLE track_plays ADD COLUMN country_code TEXT DEFAULT ''") + + // Create follows table (normally done by migration, no GORM model) + db.Exec(`CREATE TABLE IF NOT EXISTS follows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + follower_id TEXT NOT NULL, + followed_id TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`) + + // Create test user (creator) + creator := &models.User{ + Username: "creator1", Email: "creator@test.com", + PasswordHash: "hash", Slug: "creator1", IsActive: true, + } + require.NoError(t, db.Create(creator).Error) + + // Create test track + track := &models.Track{ + UserID: creator.ID, Title: "Test Track", FilePath: "/test.mp3", + FileSize: 5 * 1024 * 1024, Format: "MP3", Duration: 180, + IsPublic: true, Status: models.TrackStatusCompleted, + } + require.NoError(t, db.Create(track).Error) + + // Create listener users and playback data + for i := 0; i < 15; i++ { + listener := &models.User{ + Username: "listener" + string(rune('a'+i)), + Email: "listener" + string(rune('a'+i)) + "@test.com", + PasswordHash: "hash", + Slug: "listener" + string(rune('a'+i)), + IsActive: true, + } + require.NoError(t, db.Create(listener).Error) + + // Create playback analytics for each listener + pa := &models.PlaybackAnalytics{ + TrackID: track.ID, + UserID: listener.ID, + PlayTime: 120 + i*5, + PauseCount: i % 3, + SeekCount: i % 2, + CompletionRate: float64(60 + i*2), + StartedAt: time.Now().Add(-time.Duration(i) * time.Hour), + } + require.NoError(t, db.Create(pa).Error) + + // Create track_plays + tp := &models.TrackPlay{ + TrackID: track.ID, + UserID: &listener.ID, + Duration: 120 + i*5, + PlayedAt: time.Now().Add(-time.Duration(i) * time.Hour), + Device: "desktop", + } + require.NoError(t, db.Create(tp).Error) + + // Create a follow + db.Exec("INSERT INTO follows (follower_id, followed_id, created_at) VALUES (?, ?, ?)", + listener.ID, creator.ID, time.Now()) + } + + logger := zap.NewNop() + service := NewCreatorAnalyticsService(db, logger) + + return service, db, creator.ID, track.ID +} + +func TestCreatorAnalyticsService_GetCreatorDashboard(t *testing.T) { + service, _, creatorID, _ := setupTestCreatorAnalyticsService(t) + ctx := context.Background() + + startDate := time.Now().Add(-30 * 24 * time.Hour) + endDate := time.Now().Add(time.Hour) + + dashboard, err := service.GetCreatorDashboard(ctx, creatorID, startDate, endDate) + require.NoError(t, err) + require.NotNil(t, dashboard) + + assert.Greater(t, dashboard.TotalPlays, int64(0), "should have plays") + assert.Greater(t, dashboard.UniqueListeners, int64(0), "should have unique listeners") + assert.Greater(t, dashboard.TotalPlayTime, int64(0), "should have play time") + assert.Greater(t, dashboard.TotalTracks, int64(0), "should have tracks") + assert.Greater(t, dashboard.TotalFollowers, int64(0), "should have followers") + assert.Greater(t, dashboard.AvgCompletionRate, float64(0), "should have completion rate") + assert.Greater(t, dashboard.AvgPlayDuration, float64(0), "should have avg play duration") +} + +func TestCreatorAnalyticsService_GetCreatorDashboard_NoData(t *testing.T) { + service, _, _, _ := setupTestCreatorAnalyticsService(t) + ctx := context.Background() + + nonExistentID := uuid.New() + startDate := time.Now().Add(-30 * 24 * time.Hour) + endDate := time.Now() + + dashboard, err := service.GetCreatorDashboard(ctx, nonExistentID, startDate, endDate) + require.NoError(t, err) + require.NotNil(t, dashboard) + assert.Equal(t, int64(0), dashboard.TotalPlays) +} + +func TestCreatorAnalyticsService_GetPlayEvolution(t *testing.T) { + service, _, creatorID, _ := setupTestCreatorAnalyticsService(t) + ctx := context.Background() + + startDate := time.Now().Add(-30 * 24 * time.Hour) + endDate := time.Now().Add(time.Hour) + + // Test day interval + points, err := service.GetPlayEvolution(ctx, creatorID, startDate, endDate, "day") + require.NoError(t, err) + assert.NotEmpty(t, points, "should have data points") + + for _, p := range points { + assert.NotEmpty(t, p.Date, "date should not be empty") + assert.GreaterOrEqual(t, p.TotalPlays, int64(0)) + } +} + +func TestCreatorAnalyticsService_GetDiscoverySources(t *testing.T) { + service, _, creatorID, _ := setupTestCreatorAnalyticsService(t) + ctx := context.Background() + + startDate := time.Now().Add(-30 * 24 * time.Hour) + endDate := time.Now().Add(time.Hour) + + sources, err := service.GetDiscoverySources(ctx, creatorID, startDate, endDate) + require.NoError(t, err) + // Should have at least one source ("direct" since all track_plays have empty source) + assert.NotEmpty(t, sources) + + // Verify percentages sum to ~100 + var totalPct float64 + for _, s := range sources { + totalPct += s.Percentage + assert.NotEmpty(t, s.Source) + assert.Greater(t, s.Count, int64(0)) + } + assert.InDelta(t, 100.0, totalPct, 1.0) +} + +func TestCreatorAnalyticsService_GetAudienceProfile_MinimumListeners(t *testing.T) { + service, _, creatorID, _ := setupTestCreatorAnalyticsService(t) + ctx := context.Background() + + startDate := time.Now().Add(-30 * 24 * time.Hour) + endDate := time.Now().Add(time.Hour) + + profile, err := service.GetAudienceProfile(ctx, creatorID, startDate, endDate) + require.NoError(t, err) + require.NotNil(t, profile) + + // We have 15 listeners which is >= 10, so we should get data + assert.GreaterOrEqual(t, profile.TotalListeners, int64(10), "should have >= 10 listeners") +} + +func TestCreatorAnalyticsService_GetAudienceProfile_InsufficientListeners(t *testing.T) { + // Test with creator who has < 10 listeners + 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.PlaybackAnalytics{}) + require.NoError(t, err) + + creator := &models.User{ + Username: "lonely", Email: "lonely@test.com", + PasswordHash: "hash", Slug: "lonely", IsActive: true, + } + require.NoError(t, db.Create(creator).Error) + + track := &models.Track{ + UserID: creator.ID, Title: "Lonely Track", FilePath: "/lonely.mp3", + FileSize: 1024, Format: "MP3", Duration: 60, + IsPublic: true, Status: models.TrackStatusCompleted, + } + require.NoError(t, db.Create(track).Error) + + // Only create 3 listeners (< 10 minimum) + for i := 0; i < 3; i++ { + listener := &models.User{ + Username: "few" + string(rune('a'+i)), + Email: "few" + string(rune('a'+i)) + "@test.com", + PasswordHash: "hash", + Slug: "few" + string(rune('a'+i)), + IsActive: true, + } + require.NoError(t, db.Create(listener).Error) + pa := &models.PlaybackAnalytics{ + TrackID: track.ID, UserID: listener.ID, + PlayTime: 30, CompletionRate: 50, + StartedAt: time.Now().Add(-time.Hour), + } + require.NoError(t, db.Create(pa).Error) + } + + service := NewCreatorAnalyticsService(db, zap.NewNop()) + ctx := context.Background() + + profile, err := service.GetAudienceProfile(ctx, creator.ID, + time.Now().Add(-24*time.Hour), time.Now().Add(time.Hour)) + require.NoError(t, err) + require.NotNil(t, profile) + + // < 10 listeners: should NOT expose detailed data + assert.Less(t, profile.TotalListeners, int64(10)) + assert.Empty(t, profile.ListenersByGenre, "should not expose genre data with < 10 listeners") + assert.Empty(t, profile.TopListeningTimes, "should not expose listening times with < 10 listeners") +} + +func TestCreatorAnalyticsService_GetPerTrackStats(t *testing.T) { + service, _, creatorID, _ := setupTestCreatorAnalyticsService(t) + ctx := context.Background() + + startDate := time.Now().Add(-30 * 24 * time.Hour) + endDate := time.Now().Add(time.Hour) + + tracks, total, err := service.GetPerTrackStats(ctx, creatorID, startDate, endDate, 20, 0) + require.NoError(t, err) + assert.Greater(t, total, int64(0)) + assert.NotEmpty(t, tracks) + + for _, track := range tracks { + assert.NotEmpty(t, track.TrackID) + assert.NotEmpty(t, track.Title) + assert.GreaterOrEqual(t, track.TotalPlays, int64(0)) + } +} + +func TestCreatorAnalyticsService_GetPerTrackStats_PaginationLimit(t *testing.T) { + service, _, creatorID, _ := setupTestCreatorAnalyticsService(t) + ctx := context.Background() + + startDate := time.Now().Add(-30 * 24 * time.Hour) + endDate := time.Now().Add(time.Hour) + + // Test max limit enforcement + tracks, _, err := service.GetPerTrackStats(ctx, creatorID, startDate, endDate, 200, 0) + require.NoError(t, err) + assert.LessOrEqual(t, len(tracks), 100, "limit should be capped at 100") +} + +func TestCreatorAnalyticsService_GetLiveStreamMetrics(t *testing.T) { + service, db, creatorID, _ := setupTestCreatorAnalyticsService(t) + ctx := context.Background() + + // Create a live stream started 10 minutes ago + tenMinAgo := time.Now().Add(-10 * time.Minute) + stream := &models.LiveStream{ + UserID: creatorID, + Title: "Test Stream", + IsLive: true, + ViewerCount: 42, + StartedAt: &tenMinAgo, + } + require.NoError(t, db.Create(stream).Error) + + metrics, err := service.GetLiveStreamMetrics(ctx, creatorID, stream.ID) + require.NoError(t, err) + require.NotNil(t, metrics) + + assert.Equal(t, 42, metrics.CurrentViewers) + assert.True(t, metrics.IsLive) + assert.Greater(t, metrics.DurationSeconds, int64(500), "stream started 10 min ago") +} + +func TestCreatorAnalyticsService_GetLiveStreamMetrics_NotFound(t *testing.T) { + service, _, creatorID, _ := setupTestCreatorAnalyticsService(t) + ctx := context.Background() + + _, err := service.GetLiveStreamMetrics(ctx, creatorID, uuid.New()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestCreatorAnalyticsService_GetLiveStreamMetrics_WrongCreator(t *testing.T) { + service, db, creatorID, _ := setupTestCreatorAnalyticsService(t) + ctx := context.Background() + + now := time.Now() + stream := &models.LiveStream{ + UserID: creatorID, Title: "Stream", IsLive: true, StartedAt: &now, + } + require.NoError(t, db.Create(stream).Error) + + // Try to access with a different creator ID + _, err := service.GetLiveStreamMetrics(ctx, uuid.New(), stream.ID) + assert.Error(t, err) +} + +func TestCreatorAnalyticsService_MetricsNeverPublic(t *testing.T) { + // This test verifies the design principle: all creator analytics + // require a creator ID and only return data for that creator's tracks. + service, _, _, _ := setupTestCreatorAnalyticsService(t) + ctx := context.Background() + + startDate := time.Now().Add(-30 * 24 * time.Hour) + endDate := time.Now().Add(time.Hour) + + // All methods require a creator ID — there's no public endpoint + randomID := uuid.New() + + dashboard, err := service.GetCreatorDashboard(ctx, randomID, startDate, endDate) + require.NoError(t, err) + assert.Equal(t, int64(0), dashboard.TotalPlays, "random user should see no data") + + points, err := service.GetPlayEvolution(ctx, randomID, startDate, endDate, "day") + require.NoError(t, err) + assert.Empty(t, points, "random user should see no evolution data") +}