package handlers import ( "context" "net/http" "strconv" "time" apperrors "veza-backend-api/internal/errors" "veza-backend-api/internal/services" "veza-backend-api/internal/types" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" ) // AnalyticsServiceInterface defines the interface for AnalyticsService type AnalyticsServiceInterface interface { RecordPlay(ctx context.Context, trackID uuid.UUID, userID *uuid.UUID, duration int, device, ipAddress string) error GetTrackStats(ctx context.Context, trackID uuid.UUID) (*types.TrackStats, error) GetTopTracks(ctx context.Context, limit int, startDate, endDate *time.Time) ([]services.TopTrack, error) GetPlaysOverTime(ctx context.Context, trackID uuid.UUID, startDate, endDate time.Time, interval string) ([]services.PlayTimePoint, error) GetUserStats(ctx context.Context, userID uuid.UUID) (*types.UserStats, error) } // AnalyticsJobWorkerInterface defines the interface for JobWorker (analytics related) type AnalyticsJobWorkerInterface interface { EnqueueAnalyticsJob(eventName string, userID *uuid.UUID, payload map[string]interface{}) } // AnalyticsHandler gère les opérations d'analytics de lecture de tracks type AnalyticsHandler struct { analyticsService AnalyticsServiceInterface jobWorker AnalyticsJobWorkerInterface commonHandler *CommonHandler } // NewAnalyticsHandler crée un nouveau handler d'analytics func NewAnalyticsHandler(analyticsService *services.AnalyticsService, logger *zap.Logger) *AnalyticsHandler { return &AnalyticsHandler{ analyticsService: analyticsService, commonHandler: NewCommonHandler(logger), } } // NewAnalyticsHandlerWithInterface creates a new analytics handler with interfaces for testing func NewAnalyticsHandlerWithInterface(analyticsService AnalyticsServiceInterface, logger *zap.Logger) *AnalyticsHandler { return &AnalyticsHandler{ analyticsService: analyticsService, commonHandler: NewCommonHandler(logger), } } // SetJobWorker définit le JobWorker pour enregistrer des événements analytics func (h *AnalyticsHandler) SetJobWorker(jobWorker AnalyticsJobWorkerInterface) { h.jobWorker = jobWorker } // RecordPlayRequest représente la requête pour enregistrer une lecture // MOD-P1-001: Ajout tags validate pour validation systématique type RecordPlayRequest struct { Duration int `json:"duration" binding:"required,min=1" validate:"required,min=1"` Device string `json:"device,omitempty" validate:"omitempty,max=100"` } // RecordPlay gère l'enregistrement d'une lecture de track // @Summary Record play // @Description Record a play event for a track. Can be called anonymously or with authentication. // @Tags Analytics // @Accept json // @Produce json // @Param id path string true "Track ID (UUID)" // @Param request body handlers.RecordPlayRequest true "Play event data" // @Success 200 {object} handlers.APIResponse{data=object{message=string}} // @Failure 400 {object} handlers.APIResponse "Validation error" // @Failure 404 {object} handlers.APIResponse "Track not found" // @Failure 500 {object} handlers.APIResponse "Internal server error" // @Router /tracks/{id}/play [post] func (h *AnalyticsHandler) RecordPlay(c *gin.Context) { trackIDStr := c.Param("id") if trackIDStr == "" { RespondWithAppError(c, apperrors.NewValidationError("track id is required")) return } trackID, err := uuid.Parse(trackIDStr) // Changed to uuid.Parse if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid track id")) return } var req RecordPlayRequest if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil { RespondWithAppError(c, appErr) return } // Récupérer user_id si authentifié (optionnel pour analytics anonymes) var userID *uuid.UUID if uid, ok := c.Get("user_id"); ok { if uidUUID, ok := uid.(uuid.UUID); ok { userID = &uidUUID } } // Récupérer IP address et device ipAddress := c.ClientIP() device := req.Device if device == "" { device = c.GetHeader("User-Agent") } err = h.analyticsService.RecordPlay(c.Request.Context(), trackID, userID, req.Duration, device, ipAddress) if err != nil { if err.Error() == "track not found" { RespondWithAppError(c, apperrors.NewNotFoundError("track")) return } RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to record play", err)) return } RespondSuccess(c, http.StatusOK, gin.H{"message": "play recorded"}) } // GetTrackStats gère la récupération des statistiques d'un track // @Summary Get track statistics // @Description Get statistics for a track (plays, likes, etc.) // @Tags Analytics // @Accept json // @Produce json // @Param id path string true "Track ID (UUID)" // @Success 200 {object} handlers.APIResponse{data=object{stats=object}} // @Failure 400 {object} handlers.APIResponse "Validation error" // @Failure 404 {object} handlers.APIResponse "Track not found" // @Failure 500 {object} handlers.APIResponse "Internal server error" // @Router /tracks/{id}/stats [get] func (h *AnalyticsHandler) GetTrackStats(c *gin.Context) { trackIDStr := c.Param("id") if trackIDStr == "" { RespondWithAppError(c, apperrors.NewValidationError("track id is required")) return } trackID, err := uuid.Parse(trackIDStr) // Changed to uuid.Parse if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid track id")) return } stats, err := h.analyticsService.GetTrackStats(c.Request.Context(), trackID) if err != nil { if err.Error() == "track not found" { RespondWithAppError(c, apperrors.NewNotFoundError("track")) return } RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get track stats", err)) return } RespondSuccess(c, http.StatusOK, gin.H{"stats": stats}) } // GetTopTracks gère la récupération des tracks les plus écoutés // @Summary Get top tracks // @Description Get list of top tracks by play count, optionally filtered by date range // @Tags Analytics // @Accept json // @Produce json // @Param limit query int false "Number of tracks to return" default(10) minimum(1) maximum(100) // @Param start_date query string false "Start date filter (RFC3339 format)" // @Param end_date query string false "End date filter (RFC3339 format)" // @Success 200 {object} handlers.APIResponse{data=object{tracks=array}} // @Failure 400 {object} handlers.APIResponse "Validation error" // @Failure 500 {object} handlers.APIResponse "Internal server error" // @Router /analytics/tracks/top [get] func (h *AnalyticsHandler) GetTopTracks(c *gin.Context) { // Parse limit limit := 10 if limitStr := c.Query("limit"); limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { limit = l } else { RespondWithAppError(c, apperrors.NewValidationError("invalid limit (must be between 1 and 100)")) return } } // Parse start_date (optionnel) var startDate *time.Time if startDateStr := c.Query("start_date"); startDateStr != "" { parsed, err := time.Parse(time.RFC3339, startDateStr) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid start_date format (use RFC3339)")) return } startDate = &parsed } // Parse end_date (optionnel) var endDate *time.Time if endDateStr := c.Query("end_date"); endDateStr != "" { parsed, err := time.Parse(time.RFC3339, endDateStr) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid end_date format (use RFC3339)")) return } endDate = &parsed } topTracks, err := h.analyticsService.GetTopTracks(c.Request.Context(), limit, startDate, endDate) if err != nil { RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get top tracks", err)) return } RespondSuccess(c, http.StatusOK, gin.H{"tracks": topTracks}) } // GetPlaysOverTime gère la récupération des lectures sur une période // @Summary Get plays over time // @Description Get play statistics over time for a track, grouped by time period // @Tags Analytics // @Accept json // @Produce json // @Param id path string true "Track ID (UUID)" // @Param start_date query string false "Start date (RFC3339 format)" // @Param end_date query string false "End date (RFC3339 format)" // @Param interval query string false "Time period grouping (hour, day, week, month)" default(day) // @Success 200 {object} handlers.APIResponse{data=object{points=array}} // @Failure 400 {object} handlers.APIResponse "Validation error" // @Failure 404 {object} handlers.APIResponse "Track not found" // @Failure 500 {object} handlers.APIResponse "Internal server error" // @Router /tracks/{id}/analytics/plays [get] func (h *AnalyticsHandler) GetPlaysOverTime(c *gin.Context) { trackIDStr := c.Param("id") if trackIDStr == "" { RespondWithAppError(c, apperrors.NewValidationError("track id is required")) return } trackID, err := uuid.Parse(trackIDStr) // Changed to uuid.Parse if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid track id")) return } // Parse start_date (optionnel, défaut: 30 jours) startDate := time.Now().AddDate(0, 0, -30) if startDateStr := c.Query("start_date"); startDateStr != "" { parsed, err := time.Parse(time.RFC3339, startDateStr) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid start_date format (use RFC3339)")) return } startDate = parsed } // Parse end_date (optionnel, défaut: maintenant) endDate := time.Now() if endDateStr := c.Query("end_date"); endDateStr != "" { parsed, err := time.Parse(time.RFC3339, endDateStr) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid end_date format (use RFC3339)")) return } endDate = parsed } // Parse interval (optionnel, défaut: day) interval := c.DefaultQuery("interval", "day") validIntervals := map[string]bool{"hour": true, "day": true, "week": true, "month": true} if !validIntervals[interval] { RespondWithAppError(c, apperrors.NewValidationError("invalid interval (must be: hour, day, week, month)")) return } points, err := h.analyticsService.GetPlaysOverTime(c.Request.Context(), trackID, startDate, endDate, interval) if err != nil { if err.Error() == "track not found" { RespondWithAppError(c, apperrors.NewNotFoundError("track")) return } RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get plays over time", err)) return } RespondSuccess(c, http.StatusOK, gin.H{"points": points}) } // GetUserStats gère la récupération des statistiques d'un utilisateur // @Summary Get user statistics // @Description Get analytics statistics for a user (total plays, tracks, etc.) // @Tags Analytics // @Accept json // @Produce json // @Security BearerAuth // @Param id path string true "User ID (UUID)" // @Success 200 {object} handlers.APIResponse{data=object{stats=object}} // @Failure 400 {object} handlers.APIResponse "Validation error" // @Failure 401 {object} handlers.APIResponse "Unauthorized" // @Failure 403 {object} handlers.APIResponse "Forbidden - can only view own stats" // @Failure 404 {object} handlers.APIResponse "User not found" // @Failure 500 {object} handlers.APIResponse "Internal server error" // @Router /users/{id}/analytics/stats [get] func (h *AnalyticsHandler) GetUserStats(c *gin.Context) { userIDStr := c.Param("id") if userIDStr == "" { RespondWithAppError(c, apperrors.NewValidationError("user id is required")) return } userID, err := uuid.Parse(userIDStr) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid user id")) return } // Vérifier que l'utilisateur peut accéder à ses propres stats var authenticatedUserID *uuid.UUID if uid, ok := c.Get("user_id"); ok { if uidUUID, ok := uid.(uuid.UUID); ok { authenticatedUserID = &uidUUID } } if authenticatedUserID != nil && *authenticatedUserID != userID { RespondWithAppError(c, apperrors.NewForbiddenError("cannot access other user's stats")) return } stats, err := h.analyticsService.GetUserStats(c.Request.Context(), userID) if err != nil { if err.Error() == "user not found" { RespondWithAppError(c, apperrors.NewNotFoundError("user")) return } RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get user stats", err)) return } RespondSuccess(c, http.StatusOK, gin.H{"stats": stats}) } // GetTrackAnalyticsDashboard gère la récupération du dashboard d'analytics complet pour un track // BE-API-036: GET /api/v1/analytics/tracks/:id returns comprehensive track analytics // @Summary Get Track Analytics Dashboard // @Description Get comprehensive analytics dashboard for a track // @Tags Analytics // @Accept json // @Produce json // @Security BearerAuth // @Param id path string true "Track ID" // @Success 200 {object} APIResponse{data=object{dashboard=object}} // @Failure 400 {object} APIResponse "Validation Error" // @Failure 404 {object} APIResponse "Track not found" // @Failure 500 {object} APIResponse "Internal Error" // @Router /analytics/tracks/{id} [get] func (h *AnalyticsHandler) GetTrackAnalyticsDashboard(c *gin.Context) { trackIDStr := c.Param("id") if trackIDStr == "" { RespondWithAppError(c, apperrors.NewValidationError("track id is required")) return } trackID, err := uuid.Parse(trackIDStr) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid track id")) return } // Récupérer les statistiques de base stats, err := h.analyticsService.GetTrackStats(c.Request.Context(), trackID) if err != nil { if err.Error() == "track not found" { RespondWithAppError(c, apperrors.NewNotFoundError("track")) return } RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get track stats", err)) return } // Récupérer les lectures sur une période (30 derniers jours) startDate := time.Now().AddDate(0, 0, -30) endDate := time.Now() playsOverTime, err := h.analyticsService.GetPlaysOverTime(c.Request.Context(), trackID, startDate, endDate, "day") if err != nil { // Ne pas échouer si on ne peut pas récupérer les données temporelles playsOverTime = []services.PlayTimePoint{} } // Construire le dashboard complet dashboard := gin.H{ "track_id": trackID.String(), "stats": gin.H{ "total_plays": stats.TotalPlays, "unique_listeners": stats.UniqueListeners, "average_duration": stats.AverageDuration, "completion_rate": stats.CompletionRate, }, "plays_over_time": playsOverTime, "period": gin.H{ "start_date": startDate.Format(time.RFC3339), "end_date": endDate.Format(time.RFC3339), "days": 30, }, } RespondSuccess(c, http.StatusOK, gin.H{ "dashboard": dashboard, }) } // RecordEventRequest représente la requête pour enregistrer un événement analytics personnalisé // BE-API-035: POST /api/v1/analytics/events to record custom analytics events type RecordEventRequest struct { EventName string `json:"event_name" binding:"required,min=1,max=100" validate:"required,min=1,max=100"` Payload map[string]interface{} `json:"payload,omitempty" validate:"omitempty"` } // RecordEvent gère l'enregistrement d'un événement analytics personnalisé // BE-API-035: Implement analytics events endpoint // @Summary Record Analytics Event // @Description Record a custom analytics event // @Tags Analytics // @Accept json // @Produce json // @Security BearerAuth // @Param request body RecordEventRequest true "Event Data" // @Success 200 {object} APIResponse{data=object{message=string}} // @Failure 400 {object} APIResponse "Validation Error" // @Failure 401 {object} APIResponse "Unauthorized" // @Failure 500 {object} APIResponse "Internal Error" // @Router /analytics/events [post] func (h *AnalyticsHandler) RecordEvent(c *gin.Context) { var req RecordEventRequest if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil { RespondWithAppError(c, appErr) return } // Récupérer user_id si authentifié (optionnel pour analytics anonymes) var userID *uuid.UUID if uid, ok := c.Get("user_id"); ok { if uidUUID, ok := uid.(uuid.UUID); ok { userID = &uidUUID } } // Vérifier que le JobWorker est disponible if h.jobWorker == nil { RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "analytics service not available")) return } // Enqueue l'événement analytics via le JobWorker h.jobWorker.EnqueueAnalyticsJob(req.EventName, userID, req.Payload) RespondSuccess(c, http.StatusOK, gin.H{ "message": "event recorded", "event_name": req.EventName, }) } // GetAnalytics gère la récupération des analytics agrégées pour l'utilisateur // BE-API-037: GET /api/v1/analytics endpoint for aggregated analytics // @Summary Get Analytics Data // @Description Get aggregated analytics data for tracks and playlists // @Tags Analytics // @Accept json // @Produce json // @Security BearerAuth // @Param days query int false "Number of days (default: 30)" // @Param start_date query string false "Start date (ISO 8601)" // @Param end_date query string false "End date (ISO 8601)" // @Success 200 {object} APIResponse{data=object{tracks=object,playlists=object,period=object}} // @Failure 401 {object} APIResponse "Unauthorized" // @Failure 500 {object} APIResponse "Internal Error" // @Router /analytics [get] func (h *AnalyticsHandler) GetAnalytics(c *gin.Context) { // Récupérer l'utilisateur authentifié userIDInterface, exists := c.Get("user_id") if !exists { RespondWithAppError(c, apperrors.New(apperrors.ErrCodeUnauthorized, "authentication required")) return } userID, ok := userIDInterface.(uuid.UUID) if !ok { RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "invalid user id")) return } // Parser les paramètres de date daysStr := c.DefaultQuery("days", "30") days, err := strconv.Atoi(daysStr) if err != nil || days < 1 { days = 30 } var startDate, endDate *time.Time if startDateStr := c.Query("start_date"); startDateStr != "" { if parsed, err := time.Parse(time.RFC3339, startDateStr); err == nil { startDate = &parsed } } if endDateStr := c.Query("end_date"); endDateStr != "" { if parsed, err := time.Parse(time.RFC3339, endDateStr); err == nil { endDate = &parsed } } // Si les dates ne sont pas fournies, calculer depuis days if startDate == nil || endDate == nil { now := time.Now() if endDate == nil { endDate = &now } if startDate == nil { calculatedStart := endDate.AddDate(0, 0, -days) startDate = &calculatedStart } } ctx := c.Request.Context() // Accéder à la DB via le service (nécessite un cast) analyticsSvc, ok := h.analyticsService.(*services.AnalyticsService) if !ok { RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "analytics service type error")) return } // Récupérer les tracks de l'utilisateur avec leurs stats var tracks []struct { ID uuid.UUID `gorm:"column:id"` Title string `gorm:"column:title"` PlayCount int64 `gorm:"column:play_count"` LikeCount int64 `gorm:"column:like_count"` DownloadCount int64 `gorm:"column:download_count"` } if err := analyticsSvc.GetDB().WithContext(ctx). Table("tracks"). Select("id, title, play_count, like_count, COALESCE(download_count, 0) as download_count"). Where("creator_id = ?", userID). Find(&tracks).Error; err != nil { h.commonHandler.logger.Error("Failed to fetch user tracks", zap.Error(err)) RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "failed to fetch tracks")) return } // Calculer les stats des tracks totalTracks := len(tracks) var totalPlays, totalLikes, totalDownloads int64 for _, track := range tracks { totalPlays += track.PlayCount totalLikes += track.LikeCount totalDownloads += track.DownloadCount } avgPlayCount := float64(0) if totalTracks > 0 { avgPlayCount = float64(totalPlays) / float64(totalTracks) } // Top 5 tracks topTracks := make([]gin.H, 0, 5) sortedTracks := make([]struct { ID uuid.UUID Title string PlayCount int64 LikeCount int64 }, len(tracks)) for i, t := range tracks { sortedTracks[i] = struct { ID uuid.UUID Title string PlayCount int64 LikeCount int64 }{t.ID, t.Title, t.PlayCount, t.LikeCount} } // Trier par play_count for i := 0; i < len(sortedTracks)-1; i++ { for j := i + 1; j < len(sortedTracks); j++ { if sortedTracks[i].PlayCount < sortedTracks[j].PlayCount { sortedTracks[i], sortedTracks[j] = sortedTracks[j], sortedTracks[i] } } } for i := 0; i < 5 && i < len(sortedTracks); i++ { topTracks = append(topTracks, gin.H{ "id": sortedTracks[i].ID.String(), "title": sortedTracks[i].Title, "play_count": sortedTracks[i].PlayCount, "like_count": sortedTracks[i].LikeCount, }) } // Récupérer les playlists de l'utilisateur // CRITIQUE FIX #15: Gérer gracieusement l'erreur si la table playlists n'existe pas ou est vide var playlists []struct { ID uuid.UUID `gorm:"column:id"` Name string `gorm:"column:title"` PlayCount int64 `gorm:"column:play_count"` LikeCount int64 `gorm:"column:like_count"` ShareCount int64 `gorm:"column:share_count"` } playlistsError := analyticsSvc.GetDB().WithContext(ctx). Table("playlists"). Select("id, title as name, COALESCE(play_count, 0) as play_count, COALESCE(like_count, 0) as like_count, COALESCE(share_count, 0) as share_count"). Where("user_id = ?", userID). Find(&playlists).Error // Si erreur lors de la récupération des playlists, logger mais continuer avec des données vides // Cela permet de retourner les analytics des tracks même si les playlists ne sont pas disponibles if playlistsError != nil { h.commonHandler.logger.Warn("Failed to fetch user playlists, continuing with empty playlists data", zap.Error(playlistsError)) playlists = []struct { ID uuid.UUID `gorm:"column:id"` Name string `gorm:"column:title"` PlayCount int64 `gorm:"column:play_count"` LikeCount int64 `gorm:"column:like_count"` ShareCount int64 `gorm:"column:share_count"` }{} } // Calculer les stats des playlists totalPlaylists := len(playlists) var playlistPlays, playlistLikes, playlistShares int64 for _, playlist := range playlists { playlistPlays += playlist.PlayCount playlistLikes += playlist.LikeCount playlistShares += playlist.ShareCount } avgPlaylistPlayCount := float64(0) if totalPlaylists > 0 { avgPlaylistPlayCount = float64(playlistPlays) / float64(totalPlaylists) } // Top 5 playlists topPlaylists := make([]gin.H, 0, 5) sortedPlaylists := make([]struct { ID uuid.UUID Name string PlayCount int64 LikeCount int64 }, len(playlists)) for i, p := range playlists { sortedPlaylists[i] = struct { ID uuid.UUID Name string PlayCount int64 LikeCount int64 }{p.ID, p.Name, p.PlayCount, p.LikeCount} } // Trier par play_count for i := 0; i < len(sortedPlaylists)-1; i++ { for j := i + 1; j < len(sortedPlaylists); j++ { if sortedPlaylists[i].PlayCount < sortedPlaylists[j].PlayCount { sortedPlaylists[i], sortedPlaylists[j] = sortedPlaylists[j], sortedPlaylists[i] } } } for i := 0; i < 5 && i < len(sortedPlaylists); i++ { topPlaylists = append(topPlaylists, gin.H{ "id": sortedPlaylists[i].ID.String(), "name": sortedPlaylists[i].Name, "play_count": sortedPlaylists[i].PlayCount, "like_count": sortedPlaylists[i].LikeCount, }) } // Construire la réponse analyticsData := gin.H{ "tracks": gin.H{ "total_tracks": totalTracks, "total_plays": totalPlays, "total_likes": totalLikes, "total_downloads": totalDownloads, "average_play_count": avgPlayCount, "top_tracks": topTracks, }, "playlists": gin.H{ "total_playlists": totalPlaylists, "total_plays": playlistPlays, "total_likes": playlistLikes, "total_shares": playlistShares, "average_play_count": avgPlaylistPlayCount, "top_playlists": topPlaylists, }, "period": gin.H{ "start_date": startDate.Format(time.RFC3339), "end_date": endDate.Format(time.RFC3339), "days": days, }, } RespondSuccess(c, http.StatusOK, analyticsData) }