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é |
|
||||
| **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)
|
||||
|
||||
|
|
|
|||
|
|
@ -100,10 +100,10 @@ This document lists all TODO, FIXME, HACK, and XXX comments found in the codebas
|
|||
- **Status**: Open
|
||||
- **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)
|
||||
- **Priority**: P3
|
||||
- **Status**: Deferred
|
||||
- **Status**: Deferred (fichier archivé)
|
||||
- **Category**: Future feature
|
||||
- **TODO**: Implement feature start
|
||||
- **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
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -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,
|
||||
"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)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrForbidden) {
|
||||
if errors.Is(err, services.ErrForbidden) {
|
||||
// MOD-P2-003: Utiliser AppError au lieu de gin.H
|
||||
h.respondWithError(c, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrTrackNotFound) {
|
||||
if errors.Is(err, services.ErrTrackNotFound) {
|
||||
// MOD-P2-003: Utiliser AppError au lieu de gin.H
|
||||
h.respondWithError(c, http.StatusNotFound, "track not found")
|
||||
return
|
||||
|
|
@ -1678,7 +1678,7 @@ func (h *TrackHandler) CreateShare(c *gin.Context) {
|
|||
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
|
||||
|
|
@ -1992,6 +1992,10 @@ func (h *TrackHandler) RecordPlay(c *gin.Context) {
|
|||
// Enregistrer l'événement via le service
|
||||
err = h.playbackAnalyticsService.RecordPlayback(c.Request.Context(), analytics)
|
||||
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")
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -368,9 +368,12 @@ func TestTrackHandler_GetSharedTrack_Success(t *testing.T) {
|
|||
|
||||
// TestTrackHandler_GetSharedTrack_InvalidToken tests GetSharedTrack with invalid token
|
||||
func TestTrackHandler_GetSharedTrack_InvalidToken(t *testing.T) {
|
||||
handler, _, router, cleanup := setupTestTrackHandler(t)
|
||||
handler, db, router, cleanup := setupTestTrackHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
shareService := services.NewTrackShareService(db)
|
||||
handler.SetShareService(shareService)
|
||||
|
||||
router.GET("/tracks/shared/:token", handler.GetSharedTrack)
|
||||
|
||||
invalidToken := "invalid-token"
|
||||
|
|
@ -473,9 +476,9 @@ func TestTrackHandler_GetUserLikedTracks_Success(t *testing.T) {
|
|||
c.Set("user_id", userID)
|
||||
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())
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ func setupTestTrackHandler(t *testing.T) (*TrackHandler, *gorm.DB, *gin.Engine,
|
|||
&models.Permission{},
|
||||
&models.UserRole{},
|
||||
&models.RolePermission{},
|
||||
&models.PlaybackAnalytics{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ type JobEnqueuer interface {
|
|||
EnqueueEmailJobWithTemplate(to, subject, templateName string, templateData map[string]interface{})
|
||||
EnqueueThumbnailJob(inputPath, outputPath string, width, height int)
|
||||
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
|
||||
|
|
@ -29,6 +30,7 @@ const (
|
|||
TypeThumbnailGenerate = "thumbnail:generate"
|
||||
TypeAnalyticsProcess = "analytics:process"
|
||||
TypeWebhookDelivery = "webhook:delivery"
|
||||
TypeTranscoding = "transcoding"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
func (m *MockJobEnqueuer) EnqueueTranscodingJob(trackID uuid.UUID, inputPath, outputDir string) {}
|
||||
|
||||
func (m *MockJobEnqueuer) EnqueueAnalyticsJob(eventName string, userID *uuid.UUID, payload map[string]interface{}) {
|
||||
m.Called(eventName, userID, payload)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,15 @@ package services
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
|
||||
"veza-backend-api/internal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"veza-backend-api/internal/models"
|
||||
)
|
||||
|
||||
// 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
|
||||
var track models.Track
|
||||
if err := s.db.WithContext(ctx).First(&track, analytics.TrackID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return fmt.Errorf("track not found: %s", analytics.TrackID)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrTrackNotFound
|
||||
}
|
||||
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)
|
||||
case "analytics":
|
||||
return w.processAnalyticsJob(ctx, job)
|
||||
case services.TypeTranscoding:
|
||||
return w.processTranscodingJob(ctx, job)
|
||||
default:
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
func (w *JobWorker) EnqueueAnalyticsJob(eventName string, userID *uuid.UUID, payload 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
func (w *JobWorker) GetStats() map[string]interface{} {
|
||||
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