package handlers import ( "context" "fmt" "github.com/google/uuid" "math" "net/http" "strconv" "time" "veza-backend-api/internal/dto" "veza-backend-api/internal/models" "veza-backend-api/internal/services" "github.com/gin-gonic/gin" "go.uber.org/zap" ) // PlaybackAnalyticsHandler gère les requêtes pour les analytics de lecture // T0358: Create Playback Analytics Endpoint type PlaybackAnalyticsHandler struct { analyticsService *services.PlaybackAnalyticsService heatmapService *services.PlaybackHeatmapService rateLimiter *services.PlaybackAnalyticsRateLimiter // T0389: Create Playback Analytics Rate Limiting commonHandler *CommonHandler } // NewPlaybackAnalyticsHandler crée un nouveau handler d'analytics de lecture func NewPlaybackAnalyticsHandler(analyticsService *services.PlaybackAnalyticsService, logger *zap.Logger) *PlaybackAnalyticsHandler { return &PlaybackAnalyticsHandler{ analyticsService: analyticsService, heatmapService: nil, rateLimiter: nil, // Rate limiter optionnel commonHandler: NewCommonHandler(logger), } } // NewPlaybackAnalyticsHandlerWithRateLimiter crée un nouveau handler avec rate limiter // T0389: Create Playback Analytics Rate Limiting func NewPlaybackAnalyticsHandlerWithRateLimiter(analyticsService *services.PlaybackAnalyticsService, rateLimiter *services.PlaybackAnalyticsRateLimiter, logger *zap.Logger) *PlaybackAnalyticsHandler { return &PlaybackAnalyticsHandler{ analyticsService: analyticsService, heatmapService: nil, rateLimiter: rateLimiter, commonHandler: NewCommonHandler(logger), } } // NewPlaybackAnalyticsHandlerWithHeatmap crée un nouveau handler avec service heatmap func NewPlaybackAnalyticsHandlerWithHeatmap(analyticsService *services.PlaybackAnalyticsService, heatmapService *services.PlaybackHeatmapService, logger *zap.Logger) *PlaybackAnalyticsHandler { return &PlaybackAnalyticsHandler{ analyticsService: analyticsService, heatmapService: heatmapService, rateLimiter: nil, commonHandler: NewCommonHandler(logger), } } // NewPlaybackAnalyticsHandlerFull crée un nouveau handler avec tous les services // T0389: Create Playback Analytics Rate Limiting func NewPlaybackAnalyticsHandlerFull(analyticsService *services.PlaybackAnalyticsService, heatmapService *services.PlaybackHeatmapService, rateLimiter *services.PlaybackAnalyticsRateLimiter, logger *zap.Logger) *PlaybackAnalyticsHandler { return &PlaybackAnalyticsHandler{ analyticsService: analyticsService, heatmapService: heatmapService, rateLimiter: rateLimiter, commonHandler: NewCommonHandler(logger), } } // RecordAnalyticsRequest représente la requête pour enregistrer des analytics de lecture // T0388: Create Playback Analytics Validation - Amélioré avec validation type RecordAnalyticsRequest struct { PlayTime int `json:"play_time" binding:"required,min=0"` // seconds PauseCount int `json:"pause_count" binding:"min=0"` // optional, default 0 SeekCount int `json:"seek_count" binding:"min=0"` // optional, default 0 CompletionRate *float64 `json:"completion_rate,omitempty"` // optional, will be calculated if not provided StartedAt time.Time `json:"started_at" binding:"required"` // ISO 8601 format EndedAt *time.Time `json:"ended_at,omitempty"` // optional } // ValidationResult représente le résultat d'une validation // T0388: Create Playback Analytics Validation // GO-013: Utilise dto.ValidationError pour éviter les cycles d'import type ValidationResult struct { Valid bool Errors []dto.ValidationError Sanitized *RecordAnalyticsRequest } // RecordAnalytics gère la requête POST /api/v1/tracks/:id/playback/analytics // Enregistre les analytics de lecture pour un track // T0358: Create Playback Analytics Endpoint func (h *PlaybackAnalyticsHandler) RecordAnalytics(c *gin.Context) { userID := c.MustGet("user_id").(uuid.UUID) if userID == uuid.Nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } // Récupérer l'ID du track depuis les paramètres de l'URL trackIDStr := c.Param("id") trackID, err := uuid.Parse(trackIDStr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid track id"}) return } // Valider et parser le body de la requête var req RecordAnalyticsRequest if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil { RespondWithAppError(c, appErr) return } // T0388: Create Playback Analytics Validation // Valider et sanitizer les données validationResult := h.validateAndSanitizeAnalyticsRequest(&req, trackID) if !validationResult.Valid { c.JSON(http.StatusBadRequest, gin.H{ "error": "Validation failed", "errors": validationResult.Errors, }) return } // Utiliser les données sanitizées req = *validationResult.Sanitized // T0389: Create Playback Analytics Rate Limiting // Vérifier le rate limiting si activé if h.rateLimiter != nil { rateLimitResult, err := h.rateLimiter.CheckRateLimit(c.Request.Context(), userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check rate limit"}) return } if !rateLimitResult.Allowed { // Ajouter les headers de rate limiting c.Header("X-RateLimit-Remaining", "0") c.Header("X-RateLimit-Retry-After", strconv.FormatInt(int64(rateLimitResult.RetryAfter.Seconds()), 10)) c.Header("X-RateLimit-Reason", rateLimitResult.Reason) c.JSON(http.StatusTooManyRequests, gin.H{ "error": "Rate limit exceeded", "reason": rateLimitResult.Reason, "retry_after": int(rateLimitResult.RetryAfter.Seconds()), "quota_used": rateLimitResult.QuotaUsed, "quota_limit": rateLimitResult.QuotaLimit, }) return } // Ajouter les headers de rate limiting c.Header("X-RateLimit-Remaining", strconv.Itoa(rateLimitResult.Remaining)) } // Créer le modèle PlaybackAnalytics analytics := &models.PlaybackAnalytics{ TrackID: trackID, UserID: userID, PlayTime: req.PlayTime, PauseCount: req.PauseCount, SeekCount: req.SeekCount, StartedAt: req.StartedAt, EndedAt: req.EndedAt, } // Définir le completion_rate si fourni if req.CompletionRate != nil { analytics.CompletionRate = *req.CompletionRate } // Enregistrer les analytics via le service err = h.analyticsService.RecordPlayback(c.Request.Context(), analytics) if err != nil { // Gérer les erreurs spécifiques if err.Error() == "invalid track ID: 0" || err.Error() == "invalid user ID: 0" || err.Error()[:14] == "invalid play time" || err.Error()[:14] == "invalid pause" || err.Error()[:14] == "invalid seek" || err.Error()[:14] == "invalid completion" || err.Error() == "started_at is required" { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err.Error()[:13] == "track not found" { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // T0389: Create Playback Analytics Rate Limiting // Enregistrer la requête dans le rate limiter si activé if h.rateLimiter != nil { if err := h.rateLimiter.RecordRequest(c.Request.Context(), userID); err != nil { // Logger l'erreur mais ne pas échouer la requête // Le rate limiting est une fonctionnalité de protection, pas critique } } // Retourner le succès c.JSON(http.StatusOK, gin.H{ "status": "recorded", "id": analytics.ID, }) } // GetQuotaInfo gère la requête GET /api/v1/playback/analytics/quota // Retourne les informations de quota pour l'utilisateur actuel // T0389: Create Playback Analytics Rate Limiting func (h *PlaybackAnalyticsHandler) GetQuotaInfo(c *gin.Context) { // Récupérer l'ID de l'utilisateur depuis le contexte userID := c.MustGet("user_id").(uuid.UUID) if userID == uuid.Nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } if h.rateLimiter == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "rate limiting not enabled"}) return } quotaInfo, err := h.rateLimiter.GetQuotaInfo(c.Request.Context(), userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get quota info"}) return } c.JSON(http.StatusOK, gin.H{ "quota": quotaInfo, }) } // DashboardData représente les données du dashboard d'analytics // T0363: Create Playback Analytics Dashboard Endpoint type DashboardData struct { Stats *services.PlaybackStats `json:"stats"` Trends *TrendsData `json:"trends"` TimeSeries []TimeSeriesPoint `json:"time_series"` } // TrendsData représente les tendances d'analytics type TrendsData struct { PlayTimeTrend float64 `json:"play_time_trend"` // % de changement sur 7 jours CompletionTrend float64 `json:"completion_trend"` // % de changement sur 7 jours SessionsTrend float64 `json:"sessions_trend"` // % de changement sur 7 jours AveragePlayTime float64 `json:"average_play_time"` // Moyenne sur 7 jours AverageCompletion float64 `json:"average_completion"` // Moyenne sur 7 jours TotalSessions7Days int64 `json:"total_sessions_7days"` // Total sur 7 jours TotalSessions30Days int64 `json:"total_sessions_30days"` // Total sur 30 jours } // TimeSeriesPoint représente un point dans une série temporelle type TimeSeriesPoint struct { Date string `json:"date"` // Format: YYYY-MM-DD Sessions int64 `json:"sessions"` TotalPlayTime int64 `json:"total_play_time"` // seconds AveragePlayTime float64 `json:"average_play_time"` // seconds AverageCompletion float64 `json:"average_completion"` // percentage } // GetDashboard gère la requête GET /api/v1/tracks/:id/playback/dashboard // Retourne les statistiques agrégées, graphiques et tendances pour un track // T0363: Create Playback Analytics Dashboard Endpoint func (h *PlaybackAnalyticsHandler) GetDashboard(c *gin.Context) { // Récupérer l'ID du track depuis les paramètres de l'URL trackIDStr := c.Param("id") trackID, err := uuid.Parse(trackIDStr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid track id"}) return } if trackID == uuid.Nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid track id"}) return } // Récupérer les statistiques globales stats, err := h.analyticsService.GetTrackStats(c.Request.Context(), trackID) if err != nil { errMsg := err.Error() if len(errMsg) >= 13 && errMsg[:13] == "track not found" { c.JSON(http.StatusNotFound, gin.H{"error": errMsg}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg}) return } // Calculer les tendances (comparaison 7 jours vs 14-7 jours) trends, err := h.calculateTrends(c.Request.Context(), trackID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to calculate trends: " + err.Error()}) return } // Calculer les séries temporelles (30 derniers jours) timeSeries, err := h.calculateTimeSeries(c.Request.Context(), trackID, 30) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to calculate time series: " + err.Error()}) return } // Construire la réponse dashboard := DashboardData{ Stats: stats, Trends: trends, TimeSeries: timeSeries, } c.JSON(http.StatusOK, gin.H{ "dashboard": dashboard, }) } // calculateTrends calcule les tendances d'analytics func (h *PlaybackAnalyticsHandler) calculateTrends(ctx context.Context, trackID uuid.UUID) (*TrendsData, error) { now := time.Now() sevenDaysAgo := now.AddDate(0, 0, -7) fourteenDaysAgo := now.AddDate(0, 0, -14) thirtyDaysAgo := now.AddDate(0, 0, -30) // Statistiques sur les 7 derniers jours stats7Days, err := h.getStatsForDateRange(ctx, trackID, sevenDaysAgo, now) if err != nil { return nil, err } // Statistiques sur les 7 jours précédents (14-7 jours) statsPrev7Days, err := h.getStatsForDateRange(ctx, trackID, fourteenDaysAgo, sevenDaysAgo) if err != nil { return nil, err } // Statistiques sur les 30 derniers jours stats30Days, err := h.getStatsForDateRange(ctx, trackID, thirtyDaysAgo, now) if err != nil { return nil, err } trends := &TrendsData{ TotalSessions7Days: stats7Days.TotalSessions, TotalSessions30Days: stats30Days.TotalSessions, AveragePlayTime: stats7Days.AveragePlayTime, AverageCompletion: stats7Days.AverageCompletion, } // Calculer les tendances en pourcentage if statsPrev7Days.TotalSessions > 0 { // Tendance des sessions trends.SessionsTrend = float64(stats7Days.TotalSessions-statsPrev7Days.TotalSessions) / float64(statsPrev7Days.TotalSessions) * 100.0 } else if stats7Days.TotalSessions > 0 { trends.SessionsTrend = 100.0 // Nouvelle donnée } if statsPrev7Days.AveragePlayTime > 0 { // Tendance du temps de lecture trends.PlayTimeTrend = (stats7Days.AveragePlayTime - statsPrev7Days.AveragePlayTime) / statsPrev7Days.AveragePlayTime * 100.0 } else if stats7Days.AveragePlayTime > 0 { trends.PlayTimeTrend = 100.0 // Nouvelle donnée } if statsPrev7Days.AverageCompletion > 0 { // Tendance du taux de complétion trends.CompletionTrend = (stats7Days.AverageCompletion - statsPrev7Days.AverageCompletion) / statsPrev7Days.AverageCompletion * 100.0 } else if stats7Days.AverageCompletion > 0 { trends.CompletionTrend = 100.0 // Nouvelle donnée } return trends, nil } // getStatsForDateRange récupère les statistiques pour une plage de dates func (h *PlaybackAnalyticsHandler) getStatsForDateRange(ctx context.Context, trackID uuid.UUID, startDate, endDate time.Time) (*services.PlaybackStats, error) { sessions, err := h.analyticsService.GetSessionsByDateRange(ctx, trackID, startDate, endDate) if err != nil { return nil, err } if len(sessions) == 0 { return &services.PlaybackStats{}, nil } var totalPlayTime int64 var totalPauses int64 var totalSeeks int64 var totalCompletion float64 for _, session := range sessions { totalPlayTime += int64(session.PlayTime) totalPauses += int64(session.PauseCount) totalSeeks += int64(session.SeekCount) totalCompletion += session.CompletionRate } totalSessions := int64(len(sessions)) avgPlayTime := float64(totalPlayTime) / float64(totalSessions) avgPauses := float64(totalPauses) / float64(totalSessions) avgSeeks := float64(totalSeeks) / float64(totalSessions) avgCompletion := totalCompletion / float64(totalSessions) // Compter les sessions complétées (>90%) var completedSessions int64 for _, session := range sessions { if session.CompletionRate >= 90 { completedSessions++ } } completionRate := float64(completedSessions) / float64(totalSessions) * 100.0 return &services.PlaybackStats{ TotalSessions: totalSessions, TotalPlayTime: totalPlayTime, AveragePlayTime: avgPlayTime, TotalPauses: totalPauses, AveragePauses: avgPauses, TotalSeeks: totalSeeks, AverageSeeks: avgSeeks, AverageCompletion: avgCompletion, CompletionRate: completionRate, }, nil } // calculateTimeSeries calcule les séries temporelles pour les N derniers jours func (h *PlaybackAnalyticsHandler) calculateTimeSeries(ctx context.Context, trackID uuid.UUID, days int) ([]TimeSeriesPoint, error) { now := time.Now() startDate := now.AddDate(0, 0, -days) // Récupérer toutes les sessions dans la plage sessions, err := h.analyticsService.GetSessionsByDateRange(ctx, trackID, startDate, now) if err != nil { return nil, err } // Grouper par jour dailyStats := make(map[string]*dailyStat) for _, session := range sessions { dateKey := session.CreatedAt.Format("2006-01-02") if dailyStats[dateKey] == nil { dailyStats[dateKey] = &dailyStat{} } stat := dailyStats[dateKey] stat.sessions++ stat.totalPlayTime += int64(session.PlayTime) stat.totalCompletion += session.CompletionRate } // Créer les points de série temporelle pour tous les jours var timeSeries []TimeSeriesPoint for i := days - 1; i >= 0; i-- { date := now.AddDate(0, 0, -i) dateKey := date.Format("2006-01-02") stat := dailyStats[dateKey] if stat == nil { stat = &dailyStat{} } var avgPlayTime float64 var avgCompletion float64 if stat.sessions > 0 { avgPlayTime = float64(stat.totalPlayTime) / float64(stat.sessions) avgCompletion = stat.totalCompletion / float64(stat.sessions) } timeSeries = append(timeSeries, TimeSeriesPoint{ Date: dateKey, Sessions: stat.sessions, TotalPlayTime: stat.totalPlayTime, AveragePlayTime: avgPlayTime, AverageCompletion: avgCompletion, }) } return timeSeries, nil } // dailyStat représente les statistiques d'un jour type dailyStat struct { sessions int64 totalPlayTime int64 totalCompletion float64 } // SummaryData représente le résumé des analytics de lecture // T0370: Create Playback Analytics Summary Endpoint type SummaryData struct { TotalPlays int64 `json:"total_plays"` // Nombre total de lectures CompletionRate float64 `json:"completion_rate"` // Taux de complétion moyen (%) AveragePlayTime float64 `json:"average_play_time"` // Temps de lecture moyen (secondes) } // GetSummary gère la requête GET /api/v1/tracks/:id/playback/summary // Retourne un résumé des analytics de lecture pour un track // T0370: Create Playback Analytics Summary Endpoint func (h *PlaybackAnalyticsHandler) GetSummary(c *gin.Context) { // Récupérer l'ID du track depuis les paramètres de l'URL trackIDStr := c.Param("id") trackID, err := uuid.Parse(trackIDStr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid track id"}) return } if trackID == uuid.Nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid track id"}) return } // Récupérer les statistiques via le service stats, err := h.analyticsService.GetTrackStats(c.Request.Context(), trackID) if err != nil { errMsg := err.Error() if len(errMsg) >= 13 && errMsg[:13] == "track not found" { c.JSON(http.StatusNotFound, gin.H{"error": errMsg}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg}) return } // Construire le résumé summary := SummaryData{ TotalPlays: stats.TotalSessions, CompletionRate: stats.CompletionRate, AveragePlayTime: stats.AveragePlayTime, } c.JSON(http.StatusOK, gin.H{ "summary": summary, }) } // GetHeatmap gère la requête GET /api/v1/tracks/:id/playback/heatmap // Retourne les données de heatmap pour un track // T0376: Create Playback Analytics Heatmap Generation func (h *PlaybackAnalyticsHandler) GetHeatmap(c *gin.Context) { if h.heatmapService == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "heatmap service not available"}) return } // Récupérer l'ID du track depuis les paramètres de l'URL trackIDStr := c.Param("id") trackID, err := uuid.Parse(trackIDStr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid track id"}) return } if trackID == uuid.Nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid track id"}) return } // Récupérer la taille de segment depuis les query params (optionnel, défaut: 5) segmentSize := 5 if segmentSizeStr := c.Query("segment_size"); segmentSizeStr != "" { if parsed, err := strconv.Atoi(segmentSizeStr); err == nil && parsed > 0 { segmentSize = parsed } } // Générer la heatmap via le service heatmap, err := h.heatmapService.GenerateHeatmap(c.Request.Context(), trackID, segmentSize) if err != nil { errMsg := err.Error() if len(errMsg) >= 13 && errMsg[:13] == "track not found" { c.JSON(http.StatusNotFound, gin.H{"error": errMsg}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg}) return } c.JSON(http.StatusOK, gin.H{ "heatmap": heatmap, }) } // validateAndSanitizeAnalyticsRequest valide et sanitize une requête d'analytics // T0388: Create Playback Analytics Validation func (h *PlaybackAnalyticsHandler) validateAndSanitizeAnalyticsRequest(req *RecordAnalyticsRequest, trackID uuid.UUID) ValidationResult { result := ValidationResult{ Valid: true, Errors: make([]dto.ValidationError, 0), Sanitized: &RecordAnalyticsRequest{}, } // Copier les données pour la sanitization sanitized := *req // 1. Validation du schéma - PlayTime if req.PlayTime < 0 { result.Valid = false result.Errors = append(result.Errors, dto.ValidationError{ Field: "play_time", Message: "play_time must be greater than or equal to 0", Value: fmt.Sprintf("%d", req.PlayTime), }) } else { // Limiter play_time à une valeur raisonnable (max 24 heures = 86400 secondes) if req.PlayTime > 86400 { result.Valid = false result.Errors = append(result.Errors, dto.ValidationError{ Field: "play_time", Message: "play_time cannot exceed 86400 seconds (24 hours)", Value: fmt.Sprintf("%d", req.PlayTime), }) } sanitized.PlayTime = req.PlayTime } // 2. Validation du schéma - PauseCount if req.PauseCount < 0 { result.Valid = false result.Errors = append(result.Errors, dto.ValidationError{ Field: "pause_count", Message: "pause_count must be greater than or equal to 0", Value: fmt.Sprintf("%d", req.PauseCount), }) } else { // Limiter pause_count à une valeur raisonnable (max 1000) if req.PauseCount > 1000 { sanitized.PauseCount = 1000 } else { sanitized.PauseCount = req.PauseCount } } // 3. Validation du schéma - SeekCount if req.SeekCount < 0 { result.Valid = false result.Errors = append(result.Errors, dto.ValidationError{ Field: "seek_count", Message: "seek_count must be greater than or equal to 0", Value: fmt.Sprintf("%d", req.SeekCount), }) } else { // Limiter seek_count à une valeur raisonnable (max 1000) if req.SeekCount > 1000 { sanitized.SeekCount = 1000 } else { sanitized.SeekCount = req.SeekCount } } // 4. Validation du schéma - CompletionRate if req.CompletionRate != nil { rate := *req.CompletionRate if math.IsNaN(rate) || math.IsInf(rate, 0) { result.Valid = false result.Errors = append(result.Errors, dto.ValidationError{ Field: "completion_rate", Message: "completion_rate must be a valid number", Value: fmt.Sprintf("%f", rate), }) } else if rate < 0 || rate > 100 { result.Valid = false result.Errors = append(result.Errors, dto.ValidationError{ Field: "completion_rate", Message: "completion_rate must be between 0 and 100", Value: fmt.Sprintf("%f", rate), }) } else { // Arrondir à 2 décimales roundedRate := math.Round(rate*100) / 100 sanitized.CompletionRate = &roundedRate } } // 5. Validation du schéma - StartedAt if req.StartedAt.IsZero() { result.Valid = false result.Errors = append(result.Errors, dto.ValidationError{ Field: "started_at", Message: "started_at is required", }) } else { now := time.Now() // Vérifier que started_at n'est pas dans le futur (avec une marge de 1 minute pour les décalages d'horloge) if req.StartedAt.After(now.Add(1 * time.Minute)) { result.Valid = false result.Errors = append(result.Errors, dto.ValidationError{ Field: "started_at", Message: "started_at cannot be in the future", Value: req.StartedAt.Format(time.RFC3339), }) } else { // Vérifier que started_at n'est pas trop ancien (max 30 jours) thirtyDaysAgo := now.AddDate(0, 0, -30) if req.StartedAt.Before(thirtyDaysAgo) { result.Valid = false result.Errors = append(result.Errors, dto.ValidationError{ Field: "started_at", Message: "started_at cannot be older than 30 days", Value: req.StartedAt.Format(time.RFC3339), }) } else { sanitized.StartedAt = req.StartedAt } } } // 6. Validation du schéma - EndedAt if req.EndedAt != nil { endedAt := *req.EndedAt if endedAt.IsZero() { // Si ended_at est fourni mais est zero, le traiter comme nil sanitized.EndedAt = nil } else { // Vérifier que ended_at n'est pas dans le futur now := time.Now() if endedAt.After(now.Add(1 * time.Minute)) { result.Valid = false result.Errors = append(result.Errors, dto.ValidationError{ Field: "ended_at", Message: "ended_at cannot be in the future", Value: endedAt.Format(time.RFC3339), }) } else { sanitized.EndedAt = &endedAt } } } // 7. Vérification de cohérence - EndedAt doit être après StartedAt if !req.StartedAt.IsZero() && req.EndedAt != nil && !req.EndedAt.IsZero() { if req.EndedAt.Before(req.StartedAt) { result.Valid = false result.Errors = append(result.Errors, dto.ValidationError{ Field: "ended_at", Message: "ended_at must be after started_at", Value: req.EndedAt.Format(time.RFC3339), }) } } // 8. Vérification de cohérence - PlayTime doit être cohérent avec les dates if !req.StartedAt.IsZero() && req.EndedAt != nil && !req.EndedAt.IsZero() { duration := req.EndedAt.Sub(req.StartedAt).Seconds() // Le play_time ne devrait pas être significativement supérieur à la durée entre started_at et ended_at // (avec une marge de 10% pour les pauses) maxExpectedPlayTime := duration * 1.1 if float64(req.PlayTime) > maxExpectedPlayTime && maxExpectedPlayTime > 0 { result.Valid = false result.Errors = append(result.Errors, dto.ValidationError{ Field: "play_time", Message: fmt.Sprintf("play_time (%.0f seconds) is inconsistent with session duration (%.0f seconds)", float64(req.PlayTime), duration), Value: fmt.Sprintf("%d", req.PlayTime), }) } } // 9. Vérification de cohérence - CompletionRate doit être cohérent avec PlayTime si fourni // Cette vérification nécessite la durée du track, donc elle sera faite après la récupération du track // Pour l'instant, on valide juste que le completion_rate est dans une plage raisonnable // 10. Vérification de cohérence - PauseCount et SeekCount doivent être raisonnables par rapport à PlayTime if req.PlayTime > 0 { // Si play_time est très court (< 10 secondes), pause_count et seek_count devraient être faibles if req.PlayTime < 10 { if req.PauseCount > 5 { result.Valid = false result.Errors = append(result.Errors, dto.ValidationError{ Field: "pause_count", Message: "pause_count is too high for such a short play_time", Value: fmt.Sprintf("%d", req.PauseCount), }) } if req.SeekCount > 10 { result.Valid = false result.Errors = append(result.Errors, dto.ValidationError{ Field: "seek_count", Message: "seek_count is too high for such a short play_time", Value: fmt.Sprintf("%d", req.SeekCount), }) } } } result.Sanitized = &sanitized return result } // validateAnalyticsConsistencyWithTrack valide la cohérence des analytics avec le track // T0388: Create Playback Analytics Validation func (h *PlaybackAnalyticsHandler) validateAnalyticsConsistencyWithTrack(ctx context.Context, req *RecordAnalyticsRequest, trackID uuid.UUID) []dto.ValidationError { errors := make([]dto.ValidationError, 0) // Récupérer le track pour valider la cohérence // Note: Cette validation nécessite un accès à la base de données // Pour l'instant, on retourne une liste vide car la validation du track // est déjà faite dans le service RecordPlayback // Cette fonction peut être étendue pour des validations plus spécifiques // Vérifier que completion_rate est cohérent avec play_time et track duration // Cette vérification sera faite dans le service car elle nécessite la durée du track return errors }