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:
parent
b3ab89acd2
commit
7846bbab28
12 changed files with 99 additions and 16 deletions
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
10
veza-backend-api/internal/api/archive/README.md
Normal file
10
veza-backend-api/internal/api/archive/README.md
Normal 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)
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
Loading…
Reference in a new issue