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 }