fix(backend): remediation plan — tests, playback_analytics, job queue, gamification

Phase 1 - Backend tests:
- Add PlaybackAnalytics to AutoMigrate in setupTestTrackHandler
- Create migration 081_create_playback_analytics.sql for production
- PlaybackAnalyticsService: return ErrTrackNotFound for missing track
- RecordPlay handler: return 404 when track not found
- CreateShare: use RespondSuccess, fix services.ErrTrackNotFound/ErrForbidden
- GetTrackLikes, UnlikeTrack: use RespondSuccess for consistent response
- GetUserLikedTracks test: fix route /users/:id/likes and params
- GetSharedTrack_InvalidToken: set share service in test

Phase 4 - Job queue transcoding:
- Add EnqueueTranscodingJob to JobEnqueuer interface
- Add TypeTranscoding and processTranscodingJob (stub) in JobWorker
- MockJobEnqueuer: implement EnqueueTranscodingJob

Phase 5 - Gamification cleanup:
- Move api_manager.go to internal/api/archive/
- Add archive/README.md documenting archived modules
- Update TODOS_AUDIT.md and FEATURE_STATUS.md
This commit is contained in:
senke 2026-02-17 16:01:45 +01:00
parent b3ab89acd2
commit 7846bbab28
12 changed files with 99 additions and 16 deletions

View file

@ -34,7 +34,7 @@ Ce document décrit le statut réel des fonctionnalités par rapport au code.
|---------|-------------|--------| |---------|-------------|--------|
| **Studio** (Cloud File Browser) | Supprimé | Dossier supprimé | | **Studio** (Cloud File Browser) | Supprimé | Dossier supprimé |
| **Education** | Supprimé | Dossier supprimé | | **Education** | Supprimé | Dossier supprimé |
| **Gamification** (achievements, leaderboard) | MSW handlers | api_manager (build ignore), MSW uniquement | | **Gamification** (achievements, leaderboard) | MSW handlers | api_manager archivé (internal/api/archive/), MSW uniquement |
## Fonctionnalités désactivées (feature flags) ## Fonctionnalités désactivées (feature flags)

View file

@ -100,10 +100,10 @@ This document lists all TODO, FIXME, HACK, and XXX comments found in the codebas
- **Status**: Open - **Status**: Open
- **Category**: Migration incomplete - **Category**: Migration incomplete
### `internal/api/api_manager.go` ### `internal/api/archive/api_manager.go` (archived, build-ignored)
- **TODO**: Réactiver api_manager.go après stabilisation du noyau et alignement des services (graphql, grpc, websocket, features) - **TODO**: Réactiver api_manager.go après stabilisation du noyau et alignement des services (graphql, grpc, websocket, features)
- **Priority**: P3 - **Priority**: P3
- **Status**: Deferred - **Status**: Deferred (fichier archivé)
- **Category**: Future feature - **Category**: Future feature
- **TODO**: Implement feature start - **TODO**: Implement feature start
- **Priority**: P3 - **Priority**: P3

View file

@ -0,0 +1,10 @@
# Archived API modules
This directory contains deprecated or build-ignored API code.
## api_manager.go
- **Status**: Excluded from build (`//go:build ignore`)
- **Reason**: GraphQL, gRPC, WebSocket, and gamification (achievements, leaderboard) handlers were never fully integrated with the core REST router
- **Content**: handleGetAchievements, handleGetLeaderboard, multi-protocol setup
- **Reference**: docs/TODOS_AUDIT.md, docs/FEATURE_STATUS.md (Gamification: fantôme)

View file

@ -1287,7 +1287,7 @@ func (h *TrackHandler) UnlikeTrack(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{"message": "track unliked"}) handlers.RespondSuccess(c, http.StatusOK, gin.H{"message": "track unliked"})
} }
// GetTrackLikes gère la récupération du nombre de likes d'un track // GetTrackLikes gère la récupération du nombre de likes d'un track
@ -1323,7 +1323,7 @@ func (h *TrackHandler) GetTrackLikes(c *gin.Context) {
} }
} }
c.JSON(http.StatusOK, gin.H{ handlers.RespondSuccess(c, http.StatusOK, gin.H{
"count": count, "count": count,
"is_liked": isLiked, "is_liked": isLiked,
}) })
@ -1663,12 +1663,12 @@ func (h *TrackHandler) CreateShare(c *gin.Context) {
share, err := h.shareService.CreateShare(c.Request.Context(), trackID, userID, req.Permissions, req.ExpiresAt) share, err := h.shareService.CreateShare(c.Request.Context(), trackID, userID, req.Permissions, req.ExpiresAt)
if err != nil { if err != nil {
if errors.Is(err, ErrForbidden) { if errors.Is(err, services.ErrForbidden) {
// MOD-P2-003: Utiliser AppError au lieu de gin.H // MOD-P2-003: Utiliser AppError au lieu de gin.H
h.respondWithError(c, http.StatusForbidden, "forbidden") h.respondWithError(c, http.StatusForbidden, "forbidden")
return return
} }
if errors.Is(err, ErrTrackNotFound) { if errors.Is(err, services.ErrTrackNotFound) {
// MOD-P2-003: Utiliser AppError au lieu de gin.H // MOD-P2-003: Utiliser AppError au lieu de gin.H
h.respondWithError(c, http.StatusNotFound, "track not found") h.respondWithError(c, http.StatusNotFound, "track not found")
return return
@ -1678,7 +1678,7 @@ func (h *TrackHandler) CreateShare(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{"share": share}) handlers.RespondSuccess(c, http.StatusOK, gin.H{"share": share})
} }
// GetSharedTrack récupère un track via son token de partage // GetSharedTrack récupère un track via son token de partage
@ -1992,6 +1992,10 @@ func (h *TrackHandler) RecordPlay(c *gin.Context) {
// Enregistrer l'événement via le service // Enregistrer l'événement via le service
err = h.playbackAnalyticsService.RecordPlayback(c.Request.Context(), analytics) err = h.playbackAnalyticsService.RecordPlayback(c.Request.Context(), analytics)
if err != nil { if err != nil {
if errors.Is(err, services.ErrTrackNotFound) {
h.respondWithError(c, http.StatusNotFound, "track not found")
return
}
h.respondWithError(c, http.StatusInternalServerError, "failed to record play event") h.respondWithError(c, http.StatusInternalServerError, "failed to record play event")
return return
} }

View file

@ -368,9 +368,12 @@ func TestTrackHandler_GetSharedTrack_Success(t *testing.T) {
// TestTrackHandler_GetSharedTrack_InvalidToken tests GetSharedTrack with invalid token // TestTrackHandler_GetSharedTrack_InvalidToken tests GetSharedTrack with invalid token
func TestTrackHandler_GetSharedTrack_InvalidToken(t *testing.T) { func TestTrackHandler_GetSharedTrack_InvalidToken(t *testing.T) {
handler, _, router, cleanup := setupTestTrackHandler(t) handler, db, router, cleanup := setupTestTrackHandler(t)
defer cleanup() defer cleanup()
shareService := services.NewTrackShareService(db)
handler.SetShareService(shareService)
router.GET("/tracks/shared/:token", handler.GetSharedTrack) router.GET("/tracks/shared/:token", handler.GetSharedTrack)
invalidToken := "invalid-token" invalidToken := "invalid-token"
@ -473,9 +476,9 @@ func TestTrackHandler_GetUserLikedTracks_Success(t *testing.T) {
c.Set("user_id", userID) c.Set("user_id", userID)
c.Next() c.Next()
}) })
router.GET("/users/me/liked", handler.GetUserLikedTracks) router.GET("/users/:id/likes", handler.GetUserLikedTracks)
req := httptest.NewRequest(http.MethodGet, "/users/me/liked?page=1&limit=10", nil) req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/users/%s/likes?limit=10&offset=0", userID.String()), nil)
req.Header.Set("X-User-ID", userID.String()) req.Header.Set("X-User-ID", userID.String())
w := httptest.NewRecorder() w := httptest.NewRecorder()
router.ServeHTTP(w, req) router.ServeHTTP(w, req)

View file

@ -44,6 +44,7 @@ func setupTestTrackHandler(t *testing.T) (*TrackHandler, *gorm.DB, *gin.Engine,
&models.Permission{}, &models.Permission{},
&models.UserRole{}, &models.UserRole{},
&models.RolePermission{}, &models.RolePermission{},
&models.PlaybackAnalytics{},
) )
require.NoError(t, err) require.NoError(t, err)

View file

@ -14,6 +14,7 @@ type JobEnqueuer interface {
EnqueueEmailJobWithTemplate(to, subject, templateName string, templateData map[string]interface{}) EnqueueEmailJobWithTemplate(to, subject, templateName string, templateData map[string]interface{})
EnqueueThumbnailJob(inputPath, outputPath string, width, height int) EnqueueThumbnailJob(inputPath, outputPath string, width, height int)
EnqueueAnalyticsJob(eventName string, userID *uuid.UUID, payload map[string]interface{}) EnqueueAnalyticsJob(eventName string, userID *uuid.UUID, payload map[string]interface{})
EnqueueTranscodingJob(trackID uuid.UUID, inputPath, outputDir string)
} }
// JobService gère les jobs en arrière-plan // JobService gère les jobs en arrière-plan
@ -29,6 +30,7 @@ const (
TypeThumbnailGenerate = "thumbnail:generate" TypeThumbnailGenerate = "thumbnail:generate"
TypeAnalyticsProcess = "analytics:process" TypeAnalyticsProcess = "analytics:process"
TypeWebhookDelivery = "webhook:delivery" TypeWebhookDelivery = "webhook:delivery"
TypeTranscoding = "transcoding"
) )
// EmailPayload représente les données pour l'envoi d'email // EmailPayload représente les données pour l'envoi d'email

View file

@ -27,6 +27,8 @@ func (m *MockJobEnqueuer) EnqueueThumbnailJob(inputPath, outputPath string, widt
m.Called(inputPath, outputPath, width, height) m.Called(inputPath, outputPath, width, height)
} }
func (m *MockJobEnqueuer) EnqueueTranscodingJob(trackID uuid.UUID, inputPath, outputDir string) {}
func (m *MockJobEnqueuer) EnqueueAnalyticsJob(eventName string, userID *uuid.UUID, payload map[string]interface{}) { func (m *MockJobEnqueuer) EnqueueAnalyticsJob(eventName string, userID *uuid.UUID, payload map[string]interface{}) {
m.Called(eventName, userID, payload) m.Called(eventName, userID, payload)
} }

View file

@ -2,14 +2,15 @@ package services
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"github.com/google/uuid"
"time" "time"
"veza-backend-api/internal/models" "github.com/google/uuid"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
"veza-backend-api/internal/models"
) )
// PlaybackAnalyticsService gère les analytics de lecture de tracks // PlaybackAnalyticsService gère les analytics de lecture de tracks
@ -82,8 +83,8 @@ func (s *PlaybackAnalyticsService) RecordPlayback(ctx context.Context, analytics
// Vérifier que le track existe // Vérifier que le track existe
var track models.Track var track models.Track
if err := s.db.WithContext(ctx).First(&track, analytics.TrackID).Error; err != nil { if err := s.db.WithContext(ctx).First(&track, analytics.TrackID).Error; err != nil {
if err == gorm.ErrRecordNotFound { if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("track not found: %s", analytics.TrackID) return ErrTrackNotFound
} }
return fmt.Errorf("failed to get track: %w", err) return fmt.Errorf("failed to get track: %w", err)
} }

View file

@ -280,6 +280,8 @@ func (w *JobWorker) executeJob(ctx context.Context, job Job) error {
return w.processThumbnailJob(ctx, job) return w.processThumbnailJob(ctx, job)
case "analytics": case "analytics":
return w.processAnalyticsJob(ctx, job) return w.processAnalyticsJob(ctx, job)
case services.TypeTranscoding:
return w.processTranscodingJob(ctx, job)
default: default:
return fmt.Errorf("unknown job type: %s", job.Type) return fmt.Errorf("unknown job type: %s", job.Type)
} }
@ -368,6 +370,20 @@ func (w *JobWorker) EnqueueThumbnailJob(inputPath, outputPath string, width, hei
w.Enqueue(job) w.Enqueue(job)
} }
// EnqueueTranscodingJob enqueues a transcoding job (HLS/ffmpeg - stub implementation)
func (w *JobWorker) EnqueueTranscodingJob(trackID uuid.UUID, inputPath, outputDir string) {
job := Job{
Type: services.TypeTranscoding,
Priority: 2,
Payload: map[string]interface{}{
"track_id": trackID.String(),
"input_path": inputPath,
"output_dir": outputDir,
},
}
w.Enqueue(job)
}
// EnqueueAnalyticsJob helper // EnqueueAnalyticsJob helper
func (w *JobWorker) EnqueueAnalyticsJob(eventName string, userID *uuid.UUID, payload map[string]interface{}) { func (w *JobWorker) EnqueueAnalyticsJob(eventName string, userID *uuid.UUID, payload map[string]interface{}) {
jobPayload := map[string]interface{}{ jobPayload := map[string]interface{}{
@ -448,6 +464,25 @@ func (w *JobWorker) processAnalyticsJob(ctx context.Context, job Job) error {
return analyticsJob.Execute(ctx, w.db, w.logger) return analyticsJob.Execute(ctx, w.db, w.logger)
} }
// processTranscodingJob processes HLS/audio transcoding (stub - ffmpeg integration TODO)
func (w *JobWorker) processTranscodingJob(ctx context.Context, job Job) error {
p := job.Payload
trackIDStr, _ := p["track_id"].(string)
inputPath, _ := p["input_path"].(string)
outputDir, _ := p["output_dir"].(string)
if trackIDStr == "" || inputPath == "" {
return fmt.Errorf("transcoding job missing track_id or input_path")
}
// Stub: log and succeed. Actual ffmpeg/HLS transcoding to be implemented.
w.logger.Info("Transcoding job (stub)",
zap.String("track_id", trackIDStr),
zap.String("input_path", inputPath),
zap.String("output_dir", outputDir))
return nil
}
// GetStats retourne les stats DB si possible // GetStats retourne les stats DB si possible
func (w *JobWorker) GetStats() map[string]interface{} { func (w *JobWorker) GetStats() map[string]interface{} {
var pending, processing, failed int64 var pending, processing, failed int64

View file

@ -0,0 +1,25 @@
-- 081_create_playback_analytics.sql
-- Playback analytics for track listening events (BE-API-019)
CREATE TABLE IF NOT EXISTS public.playback_analytics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
track_id UUID NOT NULL REFERENCES public.tracks(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
play_time INT NOT NULL DEFAULT 0,
pause_count INT NOT NULL DEFAULT 0,
seek_count INT NOT NULL DEFAULT 0,
completion_rate DECIMAL(5,2) NOT NULL DEFAULT 0,
started_at TIMESTAMPTZ NOT NULL,
ended_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_playback_analytics_track_id ON public.playback_analytics(track_id);
CREATE INDEX IF NOT EXISTS idx_playback_analytics_user_id ON public.playback_analytics(user_id);
CREATE INDEX IF NOT EXISTS idx_playback_analytics_created_at ON public.playback_analytics(created_at);
COMMENT ON TABLE public.playback_analytics IS 'Playback analytics for track listening events';