veza/veza-backend-api/internal/services/analytics_service.go
2026-03-05 23:03:43 +01:00

284 lines
8.5 KiB
Go

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
}