veza/veza-backend-api/internal/handlers/playlist_handler.go
senke 72c5381c73 feat(openapi): annotate playlist handler gap — 12 endpoints (v1.0.8 B-annot)
Third batch. Fills the playlist_handler.go gap (was 8/24 annotated,
now 20/24). Covers the functionality consumed by the frontend
playlists service: import, favoris, share tokens, collaborators,
analytics, search, recommendations, duplication.

Handlers annotated:
- ImportPlaylist              — POST /playlists/import
- GetFavorisPlaylist          — GET  /playlists/favoris
- GetPlaylistByShareToken     — GET  /playlists/shared/{token}
- SearchPlaylists             — GET  /playlists/search
- GetRecommendations          — GET  /playlists/recommendations
- GetPlaylistStats            — GET  /playlists/{id}/analytics
- AddCollaborator             — POST /playlists/{id}/collaborators
- GetCollaborators            — GET  /playlists/{id}/collaborators
- UpdateCollaboratorPermission — PUT /playlists/{id}/collaborators/{userId}
- RemoveCollaborator          — DELETE /playlists/{id}/collaborators/{userId}
- CreateShareLink             — POST /playlists/{id}/share
- DuplicatePlaylist           — POST /playlists/{id}/duplicate

Not annotated (unrouted, survey false positives): FollowPlaylist,
UnfollowPlaylist — no route references in internal/api/routes_*.go.
Left unannotated to avoid polluting the spec with dead handlers.

Marketplace gap originally planned for this batch is deferred to
v1.0.9: the 13 remaining handlers (UploadProductPreview, reviews,
licenses, sell stats, refund, invoice) don't block the B-2 frontend
migration (auth/users/tracks/playlists only), so they will be done
after v1.0.8 ships. Task #48 updated to reflect.

Spec coverage:
  /playlists/* paths: 5 → 15
  make openapi:  valid
  go build ./...: 

Next: profile_handler.go + auth/handler.go to finish the B-2 spec
surface (users endpoints), then regen orval and migrate 4 services.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:04:15 +02:00

1397 lines
52 KiB
Go

package handlers
import (
"errors"
"net/http"
"strconv"
"time"
apperrors "veza-backend-api/internal/errors"
"veza-backend-api/internal/models"
"veza-backend-api/internal/monitoring"
"veza-backend-api/internal/services"
"veza-backend-api/internal/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
)
// PlaylistHandler gère les opérations sur les playlists
type PlaylistHandler struct {
playlistService services.PlaylistServiceInterface
playlistAnalyticsService *services.PlaylistAnalyticsService
playlistFollowService *services.PlaylistFollowService
db *gorm.DB
commonHandler *CommonHandler
}
// NewPlaylistHandler crée un nouveau handler de playlists
func NewPlaylistHandler(playlistService *services.PlaylistService, db *gorm.DB, logger *zap.Logger) *PlaylistHandler {
return &PlaylistHandler{
playlistService: playlistService,
db: db,
commonHandler: NewCommonHandler(logger),
}
}
// NewPlaylistHandlerWithInterface crée un nouveau handler avec l'interface service (pour les tests)
func NewPlaylistHandlerWithInterface(playlistService services.PlaylistServiceInterface, db *gorm.DB, logger *zap.Logger) *PlaylistHandler {
return &PlaylistHandler{
playlistService: playlistService,
db: db,
commonHandler: NewCommonHandler(logger),
}
}
// SetPlaylistAnalyticsService définit le service d'analytics de playlist
// T0491: Create Playlist Analytics Backend
func (h *PlaylistHandler) SetPlaylistAnalyticsService(analyticsService *services.PlaylistAnalyticsService) {
h.playlistAnalyticsService = analyticsService
}
// SetPlaylistFollowService définit le service de follow de playlist
// T0498: Create Playlist Recommendations
func (h *PlaylistHandler) SetPlaylistFollowService(followService *services.PlaylistFollowService) {
h.playlistFollowService = followService
}
// CreatePlaylistRequest représente la requête pour créer une playlist
// MOD-P1-001: Ajout tags validate pour validation systématique (Description manquait)
type CreatePlaylistRequest struct {
Title string `json:"title" binding:"required,min=1,max=200" validate:"required,min=1,max=200"`
Description string `json:"description,omitempty" validate:"omitempty,max=1000"`
IsPublic bool `json:"is_public"`
}
// UpdatePlaylistRequest représente la requête pour mettre à jour une playlist
// MOD-P1-001: Ajout tags validate pour validation systématique (Description manquait)
type UpdatePlaylistRequest struct {
Title *string `json:"title,omitempty" binding:"omitempty,min=1,max=200" validate:"omitempty,min=1,max=200"`
Description *string `json:"description,omitempty" validate:"omitempty,max=1000"`
IsPublic *bool `json:"is_public,omitempty"`
}
// ReorderTracksRequest représente la requête pour réorganiser les tracks
type ReorderTracksRequest struct {
TrackIDs []uuid.UUID `json:"track_ids" binding:"required,min=1" validate:"required,min=1"` // Changed to []uuid.UUID
}
// CreatePlaylist gère la création d'une playlist
// @Summary Create Playlist
// @Description Create a new playlist
// @Tags Playlist
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body CreatePlaylistRequest true "Playlist Metadata"
// @Success 201 {object} APIResponse{data=object{playlist=models.Playlist}}
// @Failure 400 {object} APIResponse "Validation Error"
// @Failure 401 {object} APIResponse "Unauthorized"
// @Failure 500 {object} APIResponse "Internal Error"
// @Router /playlists [post]
func (h *PlaylistHandler) CreatePlaylist(c *gin.Context) {
// MOD-P1-001: Utiliser GetUserIDUUID au lieu de c.Get manuel
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
var req CreatePlaylistRequest
if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
RespondWithAppError(c, appErr)
return
}
// BE-SEC-009: Sanitize user inputs to prevent XSS and injection attacks
req.Title = utils.SanitizeText(req.Title, 200)
if req.Description != "" {
req.Description = utils.SanitizeText(req.Description, 1000)
}
// MOD-P1-004: Ajouter timeout context pour opération DB critique
ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
playlist, err := h.playlistService.CreatePlaylist(ctx, userID, req.Title, req.Description, req.IsPublic)
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to create playlist", err))
return
}
// MOD-P2-003: Enregistrer la métrique business (depuis le handler pour éviter cycle d'import)
monitoring.RecordPlaylistCreated()
RespondSuccess(c, http.StatusCreated, gin.H{"playlist": playlist})
}
// ImportPlaylistRequest represents JSON import payload (v0.10.4 F145)
type ImportPlaylistRequest struct {
Playlist struct {
Title string `json:"title"`
Description string `json:"description"`
IsPublic bool `json:"is_public"`
} `json:"playlist"`
Tracks []struct {
ID string `json:"id"`
} `json:"tracks"`
}
// ImportPlaylist gère l'import d'une playlist depuis JSON (v0.10.4 F145)
// @Summary Import playlist
// @Description Create a playlist from a JSON payload (title, description, is_public, ordered track IDs). Useful for bulk seed / migration.
// @Tags Playlist
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body ImportPlaylistRequest true "Playlist + tracks"
// @Success 201 {object} handlers.APIResponse{data=object{playlist=models.Playlist}}
// @Failure 400 {object} handlers.APIResponse "Validation"
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
// @Failure 500 {object} handlers.APIResponse "Internal Error"
// @Router /playlists/import [post]
func (h *PlaylistHandler) ImportPlaylist(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
var req ImportPlaylistRequest
if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
RespondWithAppError(c, appErr)
return
}
title := req.Playlist.Title
if title != "" {
title = utils.SanitizeText(title, 200)
} else {
title = "Imported Playlist"
}
description := req.Playlist.Description
if description != "" {
description = utils.SanitizeText(description, 1000)
}
trackIDs := make([]uuid.UUID, 0, len(req.Tracks))
for _, t := range req.Tracks {
if t.ID == "" {
continue
}
id, err := uuid.Parse(t.ID)
if err != nil {
continue
}
trackIDs = append(trackIDs, id)
}
ctx, cancel := WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
playlist, err := h.playlistService.ImportPlaylistWithTracks(ctx, userID, title, description, req.Playlist.IsPublic, trackIDs)
if err != nil {
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to import playlist", err))
return
}
RespondSuccess(c, http.StatusCreated, gin.H{"playlist": playlist})
}
// GetFavorisPlaylist returns the current user's Favoris playlist, creating it if needed (v0.10.4 F136)
// @Summary Get Favoris playlist
// @Description Returns the authenticated user's "Favoris" playlist. Auto-created on first call. Used by the like-as-save pattern.
// @Tags Playlist
// @Produce json
// @Security BearerAuth
// @Success 200 {object} handlers.APIResponse{data=object{playlist=models.Playlist}}
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
// @Failure 500 {object} handlers.APIResponse "Internal Error"
// @Router /playlists/favoris [get]
func (h *PlaylistHandler) GetFavorisPlaylist(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
playlist, err := h.playlistService.GetOrCreateFavorisPlaylist(ctx, userID)
if err != nil {
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get Favoris playlist", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"playlist": playlist})
}
// GetPlaylists gère la récupération des playlists avec pagination
// @Summary Get Playlists
// @Description Get a paginated list of playlists
// @Tags Playlist
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param page query int false "Page number" default(1)
// @Param limit query int false "Items per page" default(20)
// @Param user_id query string false "Filter by User ID"
// @Success 200 {object} APIResponse{data=object{playlists=[]models.Playlist,pagination=object}}
// @Failure 500 {object} APIResponse "Internal Error"
// @Router /playlists [get]
func (h *PlaylistHandler) GetPlaylists(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
// Bounds checking: return 400 with clear message instead of silently normalizing
if page < 1 || limit < 1 || limit > 100 {
msg := "pagination: page must be >= 1 and limit must be between 1 and 100"
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, msg))
return
}
// Filtres optionnels
var filterUserID *uuid.UUID
if filterUserIDStr := c.Query("user_id"); filterUserIDStr != "" {
if uid, err := uuid.Parse(filterUserIDStr); err == nil {
filterUserID = &uid
}
}
// Get current user ID
var currentUserID *uuid.UUID
if uidInterface, exists := c.Get("user_id"); exists {
h.commonHandler.logger.Debug("GetPlaylists: user_id found in context", zap.Any("value", uidInterface))
if uid, ok := uidInterface.(uuid.UUID); ok {
currentUserID = &uid
} else {
h.commonHandler.logger.Debug("GetPlaylists: user_id type assertion failed")
}
} else {
h.commonHandler.logger.Debug("GetPlaylists: user_id not found in context")
}
// MOD-P1-004: Ajouter timeout context pour opération DB
ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
playlists, total, err := h.playlistService.GetPlaylists(ctx, currentUserID, filterUserID, page, limit)
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get playlists", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{
"playlists": playlists,
"total": total,
"page": page,
"limit": limit,
})
}
// GetPlaylist gère la récupération d'une playlist
// @Summary Get Playlist by ID
// @Description Get detailed information about a playlist
// @Tags Playlist
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Playlist ID"
// @Success 200 {object} APIResponse{data=object{playlist=models.Playlist}}
// @Failure 400 {object} APIResponse "Invalid ID"
// @Failure 404 {object} APIResponse "Playlist not found"
// @Router /playlists/{id} [get]
func (h *PlaylistHandler) GetPlaylist(c *gin.Context) {
// Playlist IDs are uuid.UUID
playlistID, err := uuid.Parse(c.Param("id")) // Changed to uuid.Parse
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.NewValidationError("invalid playlist id"))
return
}
var currentUserID *uuid.UUID
if uidInterface, exists := c.Get("user_id"); exists {
if uid, ok := uidInterface.(uuid.UUID); ok {
currentUserID = &uid
}
}
// MOD-P1-004: Ajouter timeout context pour opération DB
ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
playlist, err := h.playlistService.GetPlaylist(ctx, playlistID, currentUserID)
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
if errors.Is(err, services.ErrPlaylistNotFound) || errors.Is(err, services.ErrAccessDenied) {
RespondWithAppError(c, apperrors.NewNotFoundError("playlist"))
return
}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get playlist", err))
return
}
RespondSuccess(c, http.StatusOK, playlist)
}
// GetPlaylistByShareToken returns a playlist by its public share token (v0.10.4 F143).
// No authentication required.
// GetPlaylistByShareToken returns a playlist via its share token (no auth required).
// @Summary Get playlist by share token
// @Description Public endpoint resolving a share token. Allows unauthenticated access to the playlist snapshot + tracks.
// @Tags Playlist
// @Produce json
// @Param token path string true "Share token"
// @Success 200 {object} handlers.APIResponse{data=object{playlist=models.Playlist}}
// @Failure 400 {object} handlers.APIResponse "Missing token"
// @Failure 403 {object} handlers.APIResponse "Share expired"
// @Failure 404 {object} handlers.APIResponse "Share or playlist not found"
// @Failure 500 {object} handlers.APIResponse "Internal Error"
// @Router /playlists/shared/{token} [get]
func (h *PlaylistHandler) GetPlaylistByShareToken(c *gin.Context) {
token := c.Param("token")
if token == "" {
RespondWithAppError(c, apperrors.NewValidationError("share token is required"))
return
}
ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
playlist, err := h.playlistService.GetPlaylistByShareToken(ctx, token)
if err != nil {
if errors.Is(err, services.ErrPlaylistNotFound) {
RespondWithAppError(c, apperrors.NewNotFoundError("playlist"))
return
}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get playlist", err))
return
}
RespondSuccess(c, http.StatusOK, playlist)
}
// UpdatePlaylist gère la mise à jour d'une playlist
// @Summary Update Playlist
// @Description Update playlist metadata
// @Tags Playlist
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Playlist ID"
// @Param playlist body UpdatePlaylistRequest true "Playlist Metadata"
// @Success 200 {object} APIResponse{data=object{playlist=models.Playlist}}
// @Failure 400 {object} APIResponse "Validation Error"
// @Failure 401 {object} APIResponse "Unauthorized"
// @Failure 403 {object} APIResponse "Forbidden"
// @Failure 404 {object} APIResponse "Playlist not found"
// @Router /playlists/{id} [put]
func (h *PlaylistHandler) UpdatePlaylist(c *gin.Context) {
// MOD-P1-001: Utiliser GetUserIDUUID au lieu de c.Get manuel
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
// Playlist IDs are uuid.UUID
playlistID, err := uuid.Parse(c.Param("id")) // Changed to uuid.Parse
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.NewValidationError("invalid playlist id"))
return
}
var req UpdatePlaylistRequest
if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
RespondWithAppError(c, appErr)
return
}
// BE-SEC-009: Sanitize user inputs to prevent XSS and injection attacks
if req.Title != nil {
sanitized := utils.SanitizeText(*req.Title, 200)
req.Title = &sanitized
}
if req.Description != nil {
sanitized := utils.SanitizeText(*req.Description, 1000)
req.Description = &sanitized
}
// MOD-P1-004: Ajouter timeout context pour opération DB
ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
playlist, err := h.playlistService.UpdatePlaylist(ctx, playlistID, userID, req.Title, req.Description, req.IsPublic)
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
if errors.Is(err, services.ErrPlaylistNotFound) {
RespondWithAppError(c, apperrors.NewNotFoundError("playlist"))
return
}
if errors.Is(err, services.ErrAccessDenied) {
RespondWithAppError(c, apperrors.NewForbiddenError("forbidden"))
return
}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to update playlist", err))
return
}
RespondSuccess(c, http.StatusOK, playlist)
}
// DeletePlaylist gère la suppression d'une playlist
// @Summary Delete Playlist
// @Description Permanently delete a playlist
// @Tags Playlist
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Playlist ID"
// @Success 200 {object} APIResponse{data=object{message=string}}
// @Failure 401 {object} APIResponse "Unauthorized"
// @Failure 403 {object} APIResponse "Forbidden"
// @Failure 404 {object} APIResponse "Playlist not found"
// @Router /playlists/{id} [delete]
func (h *PlaylistHandler) DeletePlaylist(c *gin.Context) {
// MOD-P1-001: Utiliser GetUserIDUUID au lieu de c.Get manuel
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
// Playlist IDs are uuid.UUID
playlistID, err := uuid.Parse(c.Param("id")) // Changed to uuid.Parse
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.NewValidationError("invalid playlist id"))
return
}
// MOD-P1-004: Ajouter timeout context pour opération DB
ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
if err := h.playlistService.DeletePlaylist(ctx, playlistID, userID); err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
if errors.Is(err, services.ErrPlaylistNotFound) {
RespondWithAppError(c, apperrors.NewNotFoundError("playlist"))
return
}
if errors.Is(err, services.ErrAccessDenied) {
RespondWithAppError(c, apperrors.NewForbiddenError("forbidden"))
return
}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to delete playlist", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"message": "playlist deleted"})
}
// AddTrack gère l'ajout d'un track à une playlist
// @Summary Add Track to Playlist
// @Description Add a track to the playlist
// @Tags Playlist
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Playlist ID"
// @Param trackId body object{track_id=string} true "Track ID (in body)"
// @Success 200 {object} APIResponse{data=object{message=string}}
// @Failure 400 {object} APIResponse "Track already present or invalid ID"
// @Failure 404 {object} APIResponse "Playlist or Track not found"
// @Router /playlists/{id}/tracks [post]
func (h *PlaylistHandler) AddTrack(c *gin.Context) {
// MOD-P1-001: Utiliser GetUserIDUUID au lieu de c.Get manuel
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
// Playlist IDs are uuid.UUID
playlistID, err := uuid.Parse(c.Param("id")) // Changed to uuid.Parse
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.NewValidationError("invalid playlist id"))
return
}
// Track IDs are uuid.UUID
trackID, err := uuid.Parse(c.Param("trackId")) // Changed to uuid.Parse
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.NewValidationError("invalid track id"))
return
}
if err := h.playlistService.AddTrack(c.Request.Context(), playlistID, trackID, userID); err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
if errors.Is(err, services.ErrPlaylistNotFound) {
RespondWithAppError(c, apperrors.NewNotFoundError("playlist"))
return
}
if errors.Is(err, services.ErrTrackNotFound) {
RespondWithAppError(c, apperrors.NewNotFoundError("track"))
return
}
if errors.Is(err, services.ErrTrackAlreadyInPlaylist) {
RespondWithAppError(c, apperrors.NewValidationError("track already in playlist"))
return
}
if errors.Is(err, services.ErrAccessDenied) {
RespondWithAppError(c, apperrors.NewForbiddenError("forbidden"))
return
}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to add track to playlist", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"message": "track added to playlist"})
}
// RemoveTrack gère la suppression d'un track d'une playlist
// @Summary Remove Track from Playlist
// @Description Remove a track from the playlist
// @Tags Playlist
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Playlist ID"
// @Param trackId path string true "Track ID"
// @Success 200 {object} APIResponse{data=object{message=string}}
// @Failure 404 {object} APIResponse "Playlist or Track not found"
// @Router /playlists/{id}/tracks/{trackId} [delete]
func (h *PlaylistHandler) RemoveTrack(c *gin.Context) {
// MOD-P1-001: Utiliser GetUserIDUUID au lieu de c.Get manuel
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
// Playlist IDs are uuid.UUID
playlistID, err := uuid.Parse(c.Param("id")) // Changed to uuid.Parse
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.NewValidationError("invalid playlist id"))
return
}
// Track IDs are uuid.UUID
trackID, err := uuid.Parse(c.Param("trackId")) // Changed to uuid.Parse
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.NewValidationError("invalid track id"))
return
}
if err := h.playlistService.RemoveTrack(c.Request.Context(), playlistID, trackID, userID); err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
if err.Error() == "playlist not found" {
RespondWithAppError(c, apperrors.NewNotFoundError("playlist"))
return
}
if err.Error() == "track not in playlist" {
RespondWithAppError(c, apperrors.NewNotFoundError("track not in playlist"))
return
}
if err.Error() == "forbidden" {
RespondWithAppError(c, apperrors.NewForbiddenError("forbidden"))
return
}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to remove track from playlist", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"message": "track removed from playlist"})
}
// ReorderTracks gère la réorganisation des tracks d'une playlist
// @Summary Reorder Tracks
// @Description Reorder tracks in the playlist
// @Tags Playlist
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Playlist ID"
// @Param order body ReorderTracksRequest true "New Track Order"
// @Success 200 {object} APIResponse{data=object{message=string}}
// @Failure 400 {object} APIResponse "Validation Error"
// @Router /playlists/{id}/tracks/reorder [put]
func (h *PlaylistHandler) ReorderTracks(c *gin.Context) {
// MOD-P1-001: Utiliser GetUserIDUUID au lieu de c.Get manuel
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
// Playlist IDs are uuid.UUID
playlistID, err := uuid.Parse(c.Param("id")) // Changed to uuid.Parse
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.NewValidationError("invalid playlist id"))
return
}
var req ReorderTracksRequest
if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
RespondWithAppError(c, appErr)
return
}
if err := h.playlistService.ReorderTracks(c.Request.Context(), playlistID, userID, req.TrackIDs); err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
if err.Error() == "playlist not found" {
RespondWithAppError(c, apperrors.NewNotFoundError("playlist"))
return
}
if err.Error() == "some tracks are not in the playlist" {
RespondWithAppError(c, apperrors.NewValidationError("some tracks are not in the playlist"))
return
}
if err.Error() == "forbidden" {
RespondWithAppError(c, apperrors.NewForbiddenError("forbidden"))
return
}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to reorder tracks", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"message": "tracks reordered"})
}
// AddCollaboratorRequest représente la requête pour ajouter un collaborateur
type AddCollaboratorRequest struct {
UserID uuid.UUID `json:"user_id" binding:"required" validate:"required"`
Permission string `json:"permission" binding:"required,oneof=read write admin" validate:"required,oneof=read write admin"`
}
// UpdateCollaboratorPermissionRequest représente la requête pour mettre à jour la permission d'un collaborateur
type UpdateCollaboratorPermissionRequest struct {
Permission string `json:"permission" binding:"required,oneof=read write admin" validate:"required,oneof=read write admin"`
}
// AddCollaborator gère l'ajout d'un collaborateur à une playlist
// T0479: POST /api/v1/playlists/:id/collaborators
// AddCollaborator adds a collaborator with a permission level to the playlist.
// @Summary Add playlist collaborator
// @Description Invite a user as collaborator. Only the owner (or admin) can add.
// @Tags Playlist
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Playlist UUID"
// @Param request body AddCollaboratorRequest true "Collaborator + permission"
// @Success 200 {object} handlers.APIResponse{data=object{collaborator=object}}
// @Failure 400 {object} handlers.APIResponse "Validation"
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
// @Failure 403 {object} handlers.APIResponse "Not owner"
// @Failure 404 {object} handlers.APIResponse "Playlist not found"
// @Failure 500 {object} handlers.APIResponse "Internal Error"
// @Router /playlists/{id}/collaborators [post]
func (h *PlaylistHandler) AddCollaborator(c *gin.Context) {
// MOD-P1-001: Utiliser GetUserIDUUID au lieu de c.Get manuel
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
// Playlist IDs are uuid.UUID
playlistID, err := uuid.Parse(c.Param("id")) // Changed to uuid.Parse
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.NewValidationError("invalid playlist id"))
return
}
var req AddCollaboratorRequest
if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
RespondWithAppError(c, appErr)
return
}
// Convertir la permission string en PlaylistPermission
var permission models.PlaylistPermission
switch req.Permission {
case "read":
permission = models.PlaylistPermissionRead
case "write":
permission = models.PlaylistPermissionWrite
case "admin":
permission = models.PlaylistPermissionAdmin
default:
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.NewValidationError("invalid permission"))
return
}
collaborator, err := h.playlistService.AddCollaborator(c.Request.Context(), playlistID, userID, req.UserID, permission)
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
if err.Error() == "playlist not found" {
RespondWithAppError(c, apperrors.NewNotFoundError("playlist"))
return
}
if err.Error() == "user not found" {
RespondWithAppError(c, apperrors.NewNotFoundError("user"))
return
}
if err.Error() == "user is already a collaborator" {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeConflict, "user is already a collaborator"))
return
}
if err.Error() == "cannot add playlist owner as collaborator" {
RespondWithAppError(c, apperrors.NewValidationError("cannot add playlist owner as collaborator"))
return
}
if err.Error() == "forbidden: only playlist owner can add collaborators" {
RespondWithAppError(c, apperrors.NewForbiddenError("forbidden"))
return
}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to add collaborator", err))
return
}
RespondSuccess(c, http.StatusCreated, collaborator)
}
// RemoveCollaborator gère la suppression d'un collaborateur d'une playlist
// T0479: DELETE /api/v1/playlists/:id/collaborators/:userId
// RemoveCollaborator removes a collaborator from a playlist.
// @Summary Remove playlist collaborator
// @Description Revoke a collaborator's access. Only the owner (or admin) can remove.
// @Tags Playlist
// @Produce json
// @Security BearerAuth
// @Param id path string true "Playlist UUID"
// @Param userId path string true "Collaborator user UUID"
// @Success 200 {object} handlers.APIResponse{data=object{message=string}}
// @Failure 400 {object} handlers.APIResponse "Validation"
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
// @Failure 403 {object} handlers.APIResponse "Not owner"
// @Failure 404 {object} handlers.APIResponse "Playlist or collaborator not found"
// @Failure 500 {object} handlers.APIResponse "Internal Error"
// @Router /playlists/{id}/collaborators/{userId} [delete]
func (h *PlaylistHandler) RemoveCollaborator(c *gin.Context) {
// MOD-P1-001: Utiliser GetUserIDUUID au lieu de c.Get manuel
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
// Playlist IDs are uuid.UUID
playlistID, err := uuid.Parse(c.Param("id")) // Changed to uuid.Parse
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("invalid playlist id"))
return
}
// User IDs are UUID
collaboratorUserID, err := uuid.Parse(c.Param("userId"))
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.NewValidationError("invalid user id"))
return
}
if err := h.playlistService.RemoveCollaborator(c.Request.Context(), playlistID, userID, collaboratorUserID); err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
if err.Error() == "playlist not found" {
RespondWithAppError(c, apperrors.NewNotFoundError("playlist"))
return
}
if err.Error() == "collaborator not found" {
RespondWithAppError(c, apperrors.NewNotFoundError("collaborator"))
return
}
if err.Error() == "forbidden: only playlist owner can remove collaborators" {
RespondWithAppError(c, apperrors.NewForbiddenError("forbidden"))
return
}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to remove collaborator", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"message": "collaborator removed"})
}
// UpdateCollaboratorPermission gère la mise à jour de la permission d'un collaborateur
// T0479: PUT /api/v1/playlists/:id/collaborators/:userId
// UpdateCollaboratorPermission changes a collaborator's permission level.
// @Summary Update collaborator permission
// @Description Change a collaborator's permission level (read / write / admin). Only the owner can update.
// @Tags Playlist
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Playlist UUID"
// @Param userId path string true "Collaborator user UUID"
// @Param request body UpdateCollaboratorPermissionRequest true "New permission"
// @Success 200 {object} handlers.APIResponse{data=object{collaborator=object}}
// @Failure 400 {object} handlers.APIResponse "Validation"
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
// @Failure 403 {object} handlers.APIResponse "Not owner"
// @Failure 404 {object} handlers.APIResponse "Playlist or collaborator not found"
// @Failure 500 {object} handlers.APIResponse "Internal Error"
// @Router /playlists/{id}/collaborators/{userId} [put]
func (h *PlaylistHandler) UpdateCollaboratorPermission(c *gin.Context) {
// MOD-P1-001: Utiliser GetUserIDUUID au lieu de c.Get manuel
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
// Playlist IDs are uuid.UUID
playlistID, err := uuid.Parse(c.Param("id")) // Changed to uuid.Parse
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.NewValidationError("invalid playlist id"))
return
}
// User IDs are UUID
collaboratorUserID, err := uuid.Parse(c.Param("userId"))
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.NewValidationError("invalid user id"))
return
}
var req UpdateCollaboratorPermissionRequest
if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
RespondWithAppError(c, appErr)
return
}
// Convertir la permission string en PlaylistPermission
var permission models.PlaylistPermission
switch req.Permission {
case "read":
permission = models.PlaylistPermissionRead
case "write":
permission = models.PlaylistPermissionWrite
case "admin":
permission = models.PlaylistPermissionAdmin
default:
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.NewValidationError("invalid permission"))
return
}
if err := h.playlistService.UpdateCollaboratorPermission(c.Request.Context(), playlistID, userID, collaboratorUserID, permission); err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
if err.Error() == "playlist not found" {
RespondWithAppError(c, apperrors.NewNotFoundError("playlist"))
return
}
if err.Error() == "collaborator not found" {
RespondWithAppError(c, apperrors.NewNotFoundError("collaborator"))
return
}
if err.Error() == "invalid permission" {
RespondWithAppError(c, apperrors.NewValidationError("invalid permission"))
return
}
if err.Error() == "forbidden: only playlist owner can update collaborator permissions" {
RespondWithAppError(c, apperrors.NewForbiddenError("forbidden"))
return
}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to update collaborator permission", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"message": "collaborator permission updated"})
}
// GetCollaborators gère la récupération des collaborateurs d'une playlist
// T0479: GET /api/v1/playlists/:id/collaborators
// GetCollaborators lists a playlist's collaborators.
// @Summary List playlist collaborators
// @Description Returns the collaborators of a playlist with their permission level.
// @Tags Playlist
// @Produce json
// @Security BearerAuth
// @Param id path string true "Playlist UUID"
// @Success 200 {object} handlers.APIResponse{data=object{collaborators=[]object}}
// @Failure 400 {object} handlers.APIResponse "Invalid id"
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
// @Failure 404 {object} handlers.APIResponse "Playlist not found"
// @Failure 500 {object} handlers.APIResponse "Internal Error"
// @Router /playlists/{id}/collaborators [get]
func (h *PlaylistHandler) GetCollaborators(c *gin.Context) {
// MOD-P1-001: Utiliser GetUserIDUUID au lieu de c.Get manuel
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
// Playlist IDs are uuid.UUID
playlistID, err := uuid.Parse(c.Param("id")) // Changed to uuid.Parse
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.NewValidationError("invalid playlist id"))
return
}
collaborators, err := h.playlistService.GetCollaborators(c.Request.Context(), playlistID, userID)
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
if err.Error() == "playlist not found" {
RespondWithAppError(c, apperrors.NewNotFoundError("playlist"))
return
}
if err.Error() == "forbidden: access denied" {
RespondWithAppError(c, apperrors.NewForbiddenError("forbidden"))
return
}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get collaborators", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"collaborators": collaborators})
}
// CreateShareLink gère la création d'un lien de partage public pour une playlist
// T0488: Create Playlist Public Share Link
// CreateShareLink generates a tokenised share link for a playlist.
// @Summary Create playlist share link
// @Description Generate a tokenised link to share a playlist (read-only). Only owner / admin can issue. No body required.
// @Tags Playlist
// @Produce json
// @Security BearerAuth
// @Param id path string true "Playlist UUID"
// @Success 200 {object} handlers.APIResponse{data=object{share=object}}
// @Failure 400 {object} handlers.APIResponse "Validation"
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
// @Failure 403 {object} handlers.APIResponse "Not owner"
// @Failure 404 {object} handlers.APIResponse "Playlist not found"
// @Failure 500 {object} handlers.APIResponse "Internal Error"
// @Router /playlists/{id}/share [post]
func (h *PlaylistHandler) CreateShareLink(c *gin.Context) {
// MOD-P1-001: Utiliser GetUserIDUUID au lieu de c.Get manuel
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
// Playlist IDs are uuid.UUID
playlistID, err := uuid.Parse(c.Param("id")) // Changed to uuid.Parse
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.NewValidationError("invalid playlist id"))
return
}
// Créer le lien de partage via le service
// La vérification des permissions (owner ou admin) est faite dans PlaylistService.CreateShareLink
shareLink, err := h.playlistService.CreateShareLink(c.Request.Context(), playlistID, userID, nil)
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
if err.Error() == "playlist not found" {
RespondWithAppError(c, apperrors.NewNotFoundError("playlist"))
return
}
if err.Error() == "forbidden: only owner or admin can create share links" {
RespondWithAppError(c, apperrors.NewForbiddenError("forbidden"))
return
}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to create share link", err))
return
}
RespondSuccess(c, http.StatusOK, shareLink)
}
// FollowPlaylist gère le follow d'une playlist
// T0489: Create Playlist Follow Feature
func (h *PlaylistHandler) FollowPlaylist(c *gin.Context) {
// MOD-P1-001: Utiliser GetUserIDUUID au lieu de c.Get manuel
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
// Playlist IDs are uuid.UUID
playlistID, err := uuid.Parse(c.Param("id")) // Changed to uuid.Parse
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.NewValidationError("invalid playlist id"))
return
}
err = h.playlistService.FollowPlaylist(c.Request.Context(), playlistID, userID)
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
if err.Error() == "playlist not found" {
RespondWithAppError(c, apperrors.NewNotFoundError("playlist"))
return
}
if err.Error() == "cannot follow own playlist" {
RespondWithAppError(c, apperrors.NewValidationError("cannot follow own playlist"))
return
}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to follow playlist", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"message": "playlist followed"})
}
// UnfollowPlaylist gère l'unfollow d'une playlist
// T0489: Create Playlist Follow Feature
func (h *PlaylistHandler) UnfollowPlaylist(c *gin.Context) {
// MOD-P1-001: Utiliser GetUserIDUUID au lieu de c.Get manuel
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
// Playlist IDs are uuid.UUID
playlistID, err := uuid.Parse(c.Param("id")) // Changed to uuid.Parse
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.NewValidationError("invalid playlist id"))
return
}
err = h.playlistService.UnfollowPlaylist(c.Request.Context(), playlistID, userID)
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
if err.Error() == "playlist not found" {
RespondWithAppError(c, apperrors.NewNotFoundError("playlist"))
return
}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to unfollow playlist", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"message": "playlist unfollowed"})
}
// GetPlaylistStats gère la récupération des statistiques d'une playlist
// T0491: Create Playlist Analytics Backend
// GetPlaylistStats returns aggregated stats for a playlist.
// @Summary Get playlist statistics
// @Description Returns aggregated stats for a playlist (plays, follows, tracks count, etc.). Visible to the owner, collaborators and admins.
// @Tags Playlist
// @Produce json
// @Security BearerAuth
// @Param id path string true "Playlist UUID"
// @Success 200 {object} handlers.APIResponse{data=object{stats=object}}
// @Failure 400 {object} handlers.APIResponse "Invalid id"
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
// @Failure 403 {object} handlers.APIResponse "Forbidden"
// @Failure 404 {object} handlers.APIResponse "Playlist not found"
// @Failure 500 {object} handlers.APIResponse "Internal Error"
// @Router /playlists/{id}/analytics [get]
func (h *PlaylistHandler) GetPlaylistStats(c *gin.Context) {
// Playlist IDs are uuid.UUID
playlistID, err := uuid.Parse(c.Param("id")) // Changed to uuid.Parse
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.NewValidationError("invalid playlist id"))
return
}
// Vérifier que la playlist existe et que l'utilisateur a accès
var userID *uuid.UUID
if uidInterface, exists := c.Get("user_id"); exists {
if uid, ok := uidInterface.(uuid.UUID); ok {
userID = &uid
}
}
playlist, err := h.playlistService.GetPlaylist(c.Request.Context(), playlistID, userID)
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
if err.Error() == "playlist not found" {
RespondWithAppError(c, apperrors.NewNotFoundError("playlist"))
return
}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get playlist", err))
return
}
// Vérifier que l'utilisateur a accès (propriétaire, collaborateur ou playlist publique)
// Use uuid.Nil for comparison if userID is nil
currentUserID := uuid.Nil
if userID != nil {
currentUserID = *userID
}
if playlist.UserID != currentUserID && !playlist.IsPublic {
// Vérifier si l'utilisateur est collaborateur
if userID != nil {
hasAccess, err := h.playlistService.CheckPermission(c.Request.Context(), playlistID, *userID, models.PlaylistPermissionRead)
if err != nil || !hasAccess {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.NewForbiddenError("forbidden"))
return
}
} else {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.NewForbiddenError("forbidden"))
return
}
}
// Récupérer les statistiques via le service d'analytics
if h.playlistAnalyticsService == nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "analytics service not available"))
return
}
stats, err := h.playlistAnalyticsService.GetPlaylistStats(c.Request.Context(), playlistID)
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
if err.Error() == "playlist not found" {
RespondWithAppError(c, apperrors.NewNotFoundError("playlist"))
return
}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get playlist stats", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"stats": stats})
}
// DuplicatePlaylistRequest représente la requête pour dupliquer une playlist
type DuplicatePlaylistRequest struct {
NewTitle string `json:"new_title"`
NewDescription string `json:"new_description,omitempty"`
IsPublic *bool `json:"is_public,omitempty"`
}
// DuplicatePlaylist gère la duplication d'une playlist
// T0495: Create Playlist Duplicate Feature
// DuplicatePlaylist duplicates a playlist into a new one owned by the caller.
// @Summary Duplicate playlist
// @Description Copy a playlist's track list into a new playlist owned by the authenticated user. Cover/description copied; original unchanged.
// @Tags Playlist
// @Produce json
// @Security BearerAuth
// @Param id path string true "Source playlist UUID"
// @Success 201 {object} handlers.APIResponse{data=object{playlist=models.Playlist}}
// @Failure 400 {object} handlers.APIResponse "Invalid id"
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
// @Failure 403 {object} handlers.APIResponse "Source not visible"
// @Failure 404 {object} handlers.APIResponse "Source playlist not found"
// @Failure 500 {object} handlers.APIResponse "Internal Error"
// @Router /playlists/{id}/duplicate [post]
func (h *PlaylistHandler) DuplicatePlaylist(c *gin.Context) {
// Playlist IDs are uuid.UUID
playlistID, err := uuid.Parse(c.Param("id")) // Changed to uuid.Parse
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.NewValidationError("invalid playlist id"))
return
}
// MOD-P1-001: Utiliser GetUserIDUUID au lieu de c.Get manuel
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
var req DuplicatePlaylistRequest
if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
RespondWithAppError(c, appErr)
return
}
// Créer le service de duplication
duplicateService := services.NewPlaylistDuplicateService(h.playlistService, h.db, nil)
// Dupliquer la playlist
newPlaylist, err := duplicateService.DuplicatePlaylist(
c.Request.Context(),
playlistID,
userID,
services.DuplicatePlaylistRequest{
NewTitle: req.NewTitle,
NewDescription: req.NewDescription,
IsPublic: req.IsPublic,
},
)
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
if err.Error() == "playlist not found" {
RespondWithAppError(c, apperrors.NewNotFoundError("playlist"))
return
}
if err.Error() == "forbidden: you don't have access to this playlist" {
RespondWithAppError(c, apperrors.NewForbiddenError("forbidden"))
return
}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to duplicate playlist", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{
"message": "playlist duplicated successfully",
"playlist": newPlaylist,
})
}
// SearchPlaylists gère la recherche de playlists
// T0496: Create Playlist Search Backend
// SearchPlaylists searches public playlists by query, sort, filters.
// @Summary Search playlists
// @Description Full-text search on public playlists (title + description). Paginated.
// @Tags Playlist
// @Produce json
// @Param q query string false "Full-text query"
// @Param page query int false "Page number" default(1)
// @Param limit query int false "Items per page (max 100)" default(20)
// @Success 200 {object} handlers.APIResponse{data=object{playlists=[]models.Playlist,pagination=object}}
// @Failure 400 {object} handlers.APIResponse "Validation"
// @Failure 500 {object} handlers.APIResponse "Internal Error"
// @Router /playlists/search [get]
func (h *PlaylistHandler) SearchPlaylists(c *gin.Context) {
// Get current user ID
var currentUserID *uuid.UUID
if uidInterface, exists := c.Get("user_id"); exists {
if uid, ok := uidInterface.(uuid.UUID); ok {
currentUserID = &uid
}
}
// Récupérer les paramètres de recherche
query := c.Query("q")
userIDParam := c.Query("user_id")
isPublicParam := c.Query("is_public")
pageParam := c.DefaultQuery("page", "1")
limitParam := c.DefaultQuery("limit", "20")
// Parser les paramètres
var filterUserID *uuid.UUID
if userIDParam != "" {
if parsed, err := uuid.Parse(userIDParam); err == nil {
filterUserID = &parsed
}
}
var filterIsPublic *bool
if isPublicParam != "" {
if parsed, err := strconv.ParseBool(isPublicParam); err == nil {
filterIsPublic = &parsed
}
}
page, err := strconv.Atoi(pageParam)
if err != nil || page < 1 {
page = 1
}
limit, err := strconv.Atoi(limitParam)
if err != nil || limit < 1 {
limit = 20
}
// Rechercher les playlists
playlists, total, err := h.playlistService.SearchPlaylists(c.Request.Context(), services.SearchPlaylistsParams{
Query: query,
UserID: filterUserID,
IsPublic: filterIsPublic,
Page: page,
Limit: limit,
CurrentUserID: currentUserID,
})
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to search playlists", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{
"playlists": playlists,
"total": total,
"page": page,
"limit": limit,
})
}
// GetRecommendations gère la récupération des recommandations de playlists
// T0498: Create Playlist Recommendations
// GetRecommendations returns playlist recommendations for the caller.
// @Summary Get playlist recommendations
// @Description Suggested playlists for the authenticated user. Chronological / declarative discovery — no behavioural ranking (CLAUDE.md rule 7).
// @Tags Playlist
// @Produce json
// @Security BearerAuth
// @Param limit query int false "Max items (max 100)" default(20)
// @Success 200 {object} handlers.APIResponse{data=object{playlists=[]models.Playlist}}
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
// @Failure 500 {object} handlers.APIResponse "Internal Error"
// @Router /playlists/recommendations [get]
func (h *PlaylistHandler) GetRecommendations(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
if userID == uuid.Nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized"))
return
}
// Parser les paramètres de requête
limitParam := c.DefaultQuery("limit", "20")
limit, err := strconv.Atoi(limitParam)
if err != nil || limit < 1 {
limit = 20
}
if limit > 100 {
limit = 100
}
minScoreParam := c.DefaultQuery("min_score", "0.1")
minScore, err := strconv.ParseFloat(minScoreParam, 64)
if err != nil || minScore < 0 {
minScore = 0.1
}
includeOwnParam := c.DefaultQuery("include_own", "false")
includeOwn := includeOwnParam == "true"
// Vérifier que le service de follow est disponible
if h.playlistFollowService == nil {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "follow service not available"))
return
}
// Créer le service de recommandations
recommendationService := services.NewPlaylistRecommendationService(
h.db,
h.playlistService,
h.playlistFollowService,
h.commonHandler.logger,
)
// Obtenir les recommandations
recommendations, err := recommendationService.GetRecommendations(
c.Request.Context(),
services.GetRecommendationsParams{
UserID: userID,
Limit: limit,
MinScore: minScore,
IncludeOwn: includeOwn,
},
)
if err != nil {
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get recommendations", err))
return
}
// Formater la réponse
response := make([]gin.H, 0, len(recommendations))
for _, rec := range recommendations {
response = append(response, gin.H{
"playlist": rec.Playlist,
"score": rec.Score,
"reason": rec.Reason,
})
}
RespondSuccess(c, http.StatusOK, gin.H{
"recommendations": response,
"count": len(response),
})
}