706 lines
25 KiB
Go
706 lines
25 KiB
Go
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)
|
|
}
|