veza/veza-backend-api/internal/handlers/analytics_handler.go
senke b28d0e7eac [T0-006] test(backend): Ajout tests pour frontend_log_handler
- Tests complets pour frontend_log_handler.go (12 tests)
- Tests couvrent NewFrontendLogHandler et ReceiveLog
- Tests pour tous les niveaux de log (DEBUG, INFO, WARN, ERROR)
- Tests pour gestion des erreurs et validation JSON
- Couverture actuelle: 30.6% (objectif: 80%)

Files: veza-backend-api/internal/handlers/frontend_log_handler_test.go
       VEZA_ROADMAP.json
Hours: 16 estimated, 23 actual
2026-01-04 01:44:22 +01:00

400 lines
13 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
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
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
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
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
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,
})
}