- docs: SCOPE_CONTROL, CONTRIBUTING, README, .github templates - frontend: DeveloperDashboardView, Player components, MSW handlers, auth, reactQuerySync - backend: playback_analytics, playlist_service, testutils, integration README Excluded (artifacts): .auth, playwright-report, test-results, storybook_audit_detailed.json
844 lines
30 KiB
Go
844 lines
30 KiB
Go
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
|
|
}
|