2025-12-03 19:29:37 +00:00
package services
import (
"context"
"errors"
"fmt"
"time"
2026-03-05 22:03:43 +00:00
"github.com/google/uuid"
2025-12-03 19:29:37 +00:00
"veza-backend-api/internal/models"
"veza-backend-api/internal/types"
2026-03-05 22:03:43 +00:00
"go.uber.org/zap"
"gorm.io/gorm"
2025-12-03 19:29:37 +00:00
)
// AnalyticsService gère les analytics de lecture de tracks
type AnalyticsService struct {
db * gorm . DB
logger * zap . Logger
}
2026-01-07 18:39:21 +00:00
// GetDB retourne la connexion DB (pour accès direct dans les handlers si nécessaire)
func ( s * AnalyticsService ) GetDB ( ) * gorm . DB {
return s . db
}
2025-12-03 19:29:37 +00:00
// 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 {
2025-12-06 16:21:59 +00:00
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" `
2025-12-03 19:29:37 +00:00
}
// 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
}
2026-02-14 17:31:29 +00:00
// 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" `
2025-12-03 19:29:37 +00:00
}
2026-02-14 17:31:29 +00:00
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 )
2025-12-03 19:29:37 +00:00
}
2026-02-14 17:31:29 +00:00
if tx . RowsAffected == 0 {
return nil , errors . New ( "track not found" )
2025-12-03 19:29:37 +00:00
}
2026-02-14 17:31:29 +00:00
stats := & types . TrackStats {
2026-03-05 22:03:43 +00:00
TotalPlays : result . TotalPlays ,
UniqueListeners : result . UniqueListeners ,
AverageDuration : result . AvgDuration ,
CompletionRate : 0 ,
2025-12-03 19:29:37 +00:00
}
2026-02-14 17:31:29 +00:00
if result . TotalPlays > 0 {
stats . CompletionRate = float64 ( result . CompletedPlays ) / float64 ( result . TotalPlays ) * 100
2025-12-03 19:29:37 +00:00
}
2026-02-14 17:31:29 +00:00
return stats , nil
2025-12-03 19:29:37 +00:00
}
// 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
}