package services import ( "context" "errors" "fmt" "time" "github.com/google/uuid" "veza-backend-api/internal/models" "veza-backend-api/internal/types" "go.uber.org/zap" "gorm.io/gorm" ) // AnalyticsService gère les analytics de lecture de tracks type AnalyticsService struct { db *gorm.DB logger *zap.Logger } // GetDB retourne la connexion DB (pour accès direct dans les handlers si nécessaire) func (s *AnalyticsService) GetDB() *gorm.DB { return s.db } // NewAnalyticsService crée un nouveau service d'analytics func NewAnalyticsService(db *gorm.DB, logger *zap.Logger) *AnalyticsService { if logger == nil { logger = zap.NewNop() } return &AnalyticsService{ db: db, logger: logger, } } // TrackStats est maintenant défini dans internal/types/stats.go // Import: veza-backend-api/internal/types // PlayTimePoint représente un point de données temporel pour les graphiques type PlayTimePoint struct { Date time.Time `json:"date"` Count int64 `json:"count"` } // TopTrack représente un track dans le classement type TopTrack struct { TrackID uuid.UUID `json:"track_id"` // Changed to uuid.UUID Title string `json:"title"` Artist string `json:"artist"` TotalPlays int64 `json:"total_plays"` UniqueListeners int64 `json:"unique_listeners"` AverageDuration float64 `json:"average_duration"` } // UserStats est maintenant défini dans internal/types/stats.go // Import: veza-backend-api/internal/types // RecordPlay enregistre une lecture de track // MIGRATION UUID: userID migré vers *uuid.UUID (nullable) func (s *AnalyticsService) RecordPlay(ctx context.Context, trackID uuid.UUID, userID *uuid.UUID, duration int, device, ipAddress string) error { // Vérifier que le track existe var track models.Track if err := s.db.WithContext(ctx).First(&track, "id = ?", trackID).Error; err != nil { // Updated query to use "id = ?" for UUID if err == gorm.ErrRecordNotFound { return errors.New("track not found") } return fmt.Errorf("failed to check track: %w", err) } play := &models.TrackPlay{ TrackID: trackID, UserID: userID, Duration: duration, PlayedAt: time.Now(), Device: device, IPAddress: ipAddress, } if err := s.db.WithContext(ctx).Create(play).Error; err != nil { return fmt.Errorf("failed to record play: %w", err) } s.logger.Info("Track play recorded", zap.Any("track_id", trackID), // Changed to zap.Any for uuid.UUID zap.Any("user_id", userID), zap.Int("duration", duration), ) return nil } // GetTrackStats récupère les statistiques d'un track (optimisé: 1 requête au lieu de 5) func (s *AnalyticsService) GetTrackStats(ctx context.Context, trackID uuid.UUID) (*types.TrackStats, error) { var result struct { TrackDuration int `gorm:"column:track_duration"` TotalPlays int64 `gorm:"column:total_plays"` UniqueListeners int64 `gorm:"column:unique_listeners"` AvgDuration float64 `gorm:"column:avg_duration"` CompletedPlays int64 `gorm:"column:completed_plays"` } tx := s.db.WithContext(ctx).Raw(` SELECT t.duration AS track_duration, CAST(COUNT(tp.id) AS INTEGER) AS total_plays, CAST(COUNT(DISTINCT CASE WHEN tp.user_id IS NOT NULL THEN tp.user_id END) AS INTEGER) AS unique_listeners, COALESCE(AVG(tp.duration), 0) AS avg_duration, CAST(COUNT(CASE WHEN t.duration > 0 AND tp.duration >= t.duration * 0.9 THEN 1 END) AS INTEGER) AS completed_plays FROM tracks t LEFT JOIN track_plays tp ON tp.track_id = t.id WHERE t.id = ? GROUP BY t.id, t.duration `, trackID).Scan(&result) if tx.Error != nil { return nil, fmt.Errorf("failed to get track stats: %w", tx.Error) } if tx.RowsAffected == 0 { return nil, errors.New("track not found") } stats := &types.TrackStats{ TotalPlays: result.TotalPlays, UniqueListeners: result.UniqueListeners, AverageDuration: result.AvgDuration, CompletionRate: 0, } if result.TotalPlays > 0 { stats.CompletionRate = float64(result.CompletedPlays) / float64(result.TotalPlays) * 100 } return stats, nil } // GetPlaysOverTime récupère les lectures sur une période pour un graphique temporel func (s *AnalyticsService) GetPlaysOverTime(ctx context.Context, trackID uuid.UUID, startDate, endDate time.Time, interval string) ([]PlayTimePoint, error) { // Changed trackID to uuid.UUID // Vérifier que le track existe var track models.Track if err := s.db.WithContext(ctx).First(&track, "id = ?", trackID).Error; err != nil { // Updated query if err == gorm.ErrRecordNotFound { return nil, errors.New("track not found") } return nil, fmt.Errorf("failed to get track: %w", err) } // Requête SQL pour grouper par intervalle // Utiliser strftime pour SQLite (compatible avec la plupart des bases de données) var dateFormatSQLite string switch interval { case "hour": dateFormatSQLite = "%Y-%m-%d %H:00:00" case "day": dateFormatSQLite = "%Y-%m-%d" case "week": dateFormatSQLite = "%Y-W%W" case "month": dateFormatSQLite = "%Y-%m" default: dateFormatSQLite = "%Y-%m-%d" } var sqliteResults []struct { Date string `gorm:"column:date"` Count int64 `gorm:"column:count"` } if err := s.db.WithContext(ctx).Model(&models.TrackPlay{}). Select(fmt.Sprintf("strftime('%s', played_at) as date, COUNT(*) as count", dateFormatSQLite)). Where("track_id = ? AND played_at >= ? AND played_at <= ?", trackID, startDate, endDate). Group("date"). Order("date ASC"). Scan(&sqliteResults).Error; err != nil { return nil, fmt.Errorf("failed to get plays over time: %w", err) } // Convertir les résultats points := make([]PlayTimePoint, len(sqliteResults)) for i, r := range sqliteResults { // Essayer de parser avec différents formats parsedDate, err := time.Parse("2006-01-02 15:04:05", r.Date) if err != nil { parsedDate, err = time.Parse("2006-01-02", r.Date) if err != nil { parsedDate, err = time.Parse("2006-01", r.Date) if err != nil { parsedDate, _ = time.Parse("2006-W01", r.Date) } } } points[i] = PlayTimePoint{ Date: parsedDate, Count: r.Count, } } return points, nil } // GetTopTracks récupère les tracks les plus écoutés func (s *AnalyticsService) GetTopTracks(ctx context.Context, limit int, startDate, endDate *time.Time) ([]TopTrack, error) { if limit <= 0 { limit = 10 } if limit > 100 { limit = 100 } query := s.db.WithContext(ctx).Model(&models.TrackPlay{}). Select(` track_plays.track_id, tracks.title, tracks.artist, COUNT(*) as total_plays, COUNT(DISTINCT track_plays.user_id) as unique_listeners, COALESCE(AVG(track_plays.duration), 0) as average_duration `). Joins("JOIN tracks ON tracks.id = track_plays.track_id"). Group("track_plays.track_id, tracks.title, tracks.artist") // Filtrer par date si fourni if startDate != nil { query = query.Where("track_plays.played_at >= ?", *startDate) } if endDate != nil { query = query.Where("track_plays.played_at <= ?", *endDate) } query = query.Order("total_plays DESC").Limit(limit) var results []TopTrack if err := query.Scan(&results).Error; err != nil { return nil, fmt.Errorf("failed to get top tracks: %w", err) } return results, nil } // GetUserStats récupère les statistiques d'un utilisateur func (s *AnalyticsService) GetUserStats(ctx context.Context, userID uuid.UUID) (*types.UserStats, error) { // Vérifier que l'utilisateur existe var user models.User if err := s.db.WithContext(ctx).First(&user, userID).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New("user not found") } return nil, fmt.Errorf("failed to get user: %w", err) } var stats types.UserStats // Total plays if err := s.db.WithContext(ctx).Model(&models.TrackPlay{}). Where("user_id = ?", userID). Count(&stats.TotalPlays).Error; err != nil { return nil, fmt.Errorf("failed to count total plays: %w", err) } // Unique tracks if err := s.db.WithContext(ctx).Model(&models.TrackPlay{}). Where("user_id = ?", userID). Distinct("track_id"). Count(&stats.UniqueTracks).Error; err != nil { return nil, fmt.Errorf("failed to count unique tracks: %w", err) } // Total duration var totalDuration int64 if err := s.db.WithContext(ctx).Model(&models.TrackPlay{}). Where("user_id = ?", userID). Select("COALESCE(SUM(duration), 0)"). Scan(&totalDuration).Error; err != nil { return nil, fmt.Errorf("failed to calculate total duration: %w", err) } stats.TotalDuration = totalDuration // Average duration if stats.TotalPlays > 0 { stats.AverageDuration = float64(totalDuration) / float64(stats.TotalPlays) } return &stats, nil }