package handlers import ( "context" "fmt" "math" "net/http" "strconv" "strings" "time" "veza-backend-api/internal/dto" apperrors "veza-backend-api/internal/errors" "veza-backend-api/internal/models" "veza-backend-api/internal/services" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" ) // PlaybackAnalyticsServiceInterfaceForHandler defines methods needed for playback analytics handler type PlaybackAnalyticsServiceInterfaceForHandler interface { RecordPlayback(ctx context.Context, analytics *models.PlaybackAnalytics) error GetTrackStats(ctx context.Context, trackID uuid.UUID) (*services.PlaybackStats, error) GetSessionsByDateRange(ctx context.Context, trackID uuid.UUID, startDate, endDate time.Time) ([]models.PlaybackAnalytics, error) } // PlaybackAnalyticsRateLimiterInterface defines methods needed for rate limiter type PlaybackAnalyticsRateLimiterInterface interface { CheckRateLimit(ctx context.Context, userID uuid.UUID) (*services.RateLimitResult, error) RecordRequest(ctx context.Context, userID uuid.UUID) error GetQuotaInfo(ctx context.Context, userID uuid.UUID) (map[string]interface{}, error) } // PlaybackHeatmapServiceInterface defines methods needed for heatmap service type PlaybackHeatmapServiceInterface interface { GenerateHeatmap(ctx context.Context, trackID uuid.UUID, segmentSize int) (*services.HeatmapData, error) } // PlaybackAnalyticsHandler gère les requêtes pour les analytics de lecture // T0358: Create Playback Analytics Endpoint type PlaybackAnalyticsHandler struct { analyticsService PlaybackAnalyticsServiceInterfaceForHandler heatmapService PlaybackHeatmapServiceInterface rateLimiter PlaybackAnalyticsRateLimiterInterface // 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 NewPlaybackAnalyticsHandlerWithInterface(analyticsService, logger) } // NewPlaybackAnalyticsHandlerWithInterface crée un nouveau handler avec interfaces (pour tests) func NewPlaybackAnalyticsHandlerWithInterface(analyticsService PlaybackAnalyticsServiceInterfaceForHandler, 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 // MOD-P1-001: Ajout tags validate pour validation systématique type RecordAnalyticsRequest struct { PlayTime int `json:"play_time" binding:"required,min=0" validate:"required,min=0"` // seconds PauseCount int `json:"pause_count" binding:"min=0" validate:"omitempty,min=0"` // optional, default 0 SeekCount int `json:"seek_count" binding:"min=0" validate:"omitempty,min=0"` // optional, default 0 CompletionRate *float64 `json:"completion_rate,omitempty" validate:"omitempty,gte=0,lte=1"` // optional, will be calculated if not provided StartedAt time.Time `json:"started_at" binding:"required" validate:"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, ok := GetUserIDUUID(c) if !ok { return // Erreur déjà envoyée par GetUserIDUUID } if userID == uuid.Nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.NewUnauthorizedError("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 { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "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 { // MOD-P2-003: Utiliser AppError au lieu de gin.H details := make([]apperrors.ErrorDetail, 0, len(validationResult.Errors)) for _, ve := range validationResult.Errors { details = append(details, apperrors.ErrorDetail{ Field: ve.Field, Message: ve.Message, }) } RespondWithAppError(c, apperrors.NewValidationError("Validation failed", details...)) 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 { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "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" { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, err.Error())) return } if strings.Contains(err.Error(), "track not found") { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.NewNotFoundError("track")) return } // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, 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 RespondSuccess(c, 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, ok := GetUserIDUUID(c) if !ok { return // Erreur déjà envoyée par GetUserIDUUID } if userID == uuid.Nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized")) return } if h.rateLimiter == nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H (503 -> ErrCodeInternal avec message approprié) RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "rate limiting not enabled")) return } quotaInfo, err := h.rateLimiter.GetQuotaInfo(c.Request.Context(), userID) if err != nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "failed to get quota info")) return } RespondSuccess(c, 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 { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "invalid track id")) return } if trackID == uuid.Nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "invalid track id")) return } // Récupérer les statistiques globales stats, err := h.analyticsService.GetTrackStats(c.Request.Context(), trackID) if err != nil { if strings.Contains(err.Error(), "track not found") { RespondWithAppError(c, apperrors.NewNotFoundError("track")) return } RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, err.Error())) return } // Calculer les tendances (comparaison 7 jours vs 14-7 jours) trends, err := h.calculateTrends(c.Request.Context(), trackID) if err != nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "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 { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "failed to calculate time series: "+err.Error())) return } // Construire la réponse dashboard := DashboardData{ Stats: stats, Trends: trends, TimeSeries: timeSeries, } RespondSuccess(c, 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 { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "invalid track id")) return } if trackID == uuid.Nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "invalid track id")) return } // Récupérer les statistiques via le service stats, err := h.analyticsService.GetTrackStats(c.Request.Context(), trackID) if err != nil { if strings.Contains(err.Error(), "track not found") { RespondWithAppError(c, apperrors.NewNotFoundError("track")) return } RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, err.Error())) return } // Construire le résumé summary := SummaryData{ TotalPlays: stats.TotalSessions, CompletionRate: stats.CompletionRate, AveragePlayTime: stats.AveragePlayTime, } RespondSuccess(c, 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 { // MOD-P2-003: Utiliser AppError au lieu de gin.H (503 -> ErrCodeInternal avec message approprié) RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "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 { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "invalid track id")) return } if trackID == uuid.Nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "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 { if strings.Contains(err.Error(), "track not found") { RespondWithAppError(c, apperrors.NewNotFoundError("track")) return } RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, err.Error())) return } RespondSuccess(c, 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, _ 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 }