2025-12-03 19:29:37 +00:00
package services
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"veza-backend-api/internal/models"
"veza-backend-api/internal/types"
)
// AnalyticsService gère les analytics de lecture de tracks
type AnalyticsService struct {
db * gorm . DB
logger * zap . Logger
}
// 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
}
// GetTrackStats récupère les statistiques d'un track
func ( s * AnalyticsService ) GetTrackStats ( ctx context . Context , trackID uuid . UUID ) ( * types . TrackStats , error ) { // Changed trackID to uuid.UUID
var stats types . TrackStats
// 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 )
}
// Total plays
if err := s . db . WithContext ( ctx ) . Model ( & models . TrackPlay { } ) .
Where ( "track_id = ?" , trackID ) .
Count ( & stats . TotalPlays ) . Error ; err != nil {
return nil , fmt . Errorf ( "failed to count total plays: %w" , err )
}
// Unique listeners (distinct user_id, en excluant NULL)
if err := s . db . WithContext ( ctx ) . Model ( & models . TrackPlay { } ) .
Where ( "track_id = ? AND user_id IS NOT NULL" , trackID ) .
Distinct ( "user_id" ) .
Count ( & stats . UniqueListeners ) . Error ; err != nil {
return nil , fmt . Errorf ( "failed to count unique listeners: %w" , err )
}
// Average duration
var avgDuration float64
if err := s . db . WithContext ( ctx ) . Model ( & models . TrackPlay { } ) .
Where ( "track_id = ?" , trackID ) .
Select ( "COALESCE(AVG(duration), 0)" ) .
Scan ( & avgDuration ) . Error ; err != nil {
return nil , fmt . Errorf ( "failed to calculate average duration: %w" , err )
}
stats . AverageDuration = avgDuration
// Completion rate (90% de la durée du track)
if track . Duration > 0 && stats . TotalPlays > 0 {
var completedPlays int64
completionThreshold := int ( float64 ( track . Duration ) * 0.9 )
if err := s . db . WithContext ( ctx ) . Model ( & models . TrackPlay { } ) .
Where ( "track_id = ? AND duration >= ?" , trackID , completionThreshold ) .
Count ( & completedPlays ) . Error ; err != nil {
return nil , fmt . Errorf ( "failed to count completed plays: %w" , err )
}
stats . CompletionRate = float64 ( completedPlays ) / float64 ( stats . 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
}