veza/veza-backend-api/internal/handlers/playback_analytics_handler.go
2025-12-16 11:23:49 -05:00

827 lines
28 KiB
Go

package handlers
import (
"context"
"fmt"
"math"
"net/http"
"strconv"
"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"
)
// 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, 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 err.Error()[:13] == "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 {
errMsg := err.Error()
if len(errMsg) >= 13 && errMsg[:13] == "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, errMsg))
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 {
errMsg := err.Error()
if len(errMsg) >= 13 && errMsg[:13] == "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, errMsg))
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 {
errMsg := err.Error()
if len(errMsg) >= 13 && errMsg[:13] == "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, errMsg))
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
}