veza/veza-backend-api/internal/services/creator_analytics_service.go
senke 41a447224a feat(v0.11.0): F381-F385 creator analytics service
Implement CreatorAnalyticsService with:
- GetCreatorDashboard: aggregated plays, listeners, revenue (F381)
- GetPlayEvolution: temporal data by day/week/month (F382)
- GetSalesSummary: revenue and sales history (F383)
- GetDiscoverySources: how listeners find tracks (F381)
- GetGeographicBreakdown: anonymized geographic data (F381)
- GetAudienceProfile: aggregated audience demographics, min 10 users (F384)
- GetLiveStreamMetrics: real-time viewer count (F385)
- GetPerTrackStats: per-track analytics with pagination

All data is private to the creator, never exposed publicly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:25:30 +01:00

601 lines
21 KiB
Go

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
}