package services import ( "context" "errors" "fmt" "time" "github.com/google/uuid" "veza-backend-api/internal/models" "veza-backend-api/internal/repositories" "go.uber.org/zap" "gorm.io/gorm" ) // UserRepositoryForPlaylist définit l'interface minimale nécessaire pour PlaylistService // T0453: Interface pour vérifier l'existence des utilisateurs type UserRepositoryForPlaylist interface { GetByID(ctx context.Context, id string) (*models.User, error) GetByEmail(ctx context.Context, email string) (*models.User, error) GetByUsername(ctx context.Context, username string) (*models.User, error) Create(ctx context.Context, user *models.User) error Update(ctx context.Context, user *models.User) error Delete(ctx context.Context, id string) error } // PlaylistService gère les opérations sur les playlists // T0453: Utilise le repository pattern pour l'accès aux données // BE-SVC-001: Add cache service for playlist caching type PlaylistService struct { playlistRepo repositories.PlaylistRepository playlistTrackRepo repositories.PlaylistTrackRepository playlistCollaboratorRepo repositories.PlaylistCollaboratorRepository playlistShareService *PlaylistShareService playlistFollowService *PlaylistFollowService playlistNotificationService *PlaylistNotificationService playlistVersionService *PlaylistVersionService userRepo UserRepositoryForPlaylist cacheService *CacheService logger *zap.Logger } // NewPlaylistService crée un nouveau service de playlists avec repositories func NewPlaylistService(playlistRepo repositories.PlaylistRepository, playlistTrackRepo repositories.PlaylistTrackRepository, playlistCollaboratorRepo repositories.PlaylistCollaboratorRepository, userRepo UserRepositoryForPlaylist, logger *zap.Logger) *PlaylistService { if logger == nil { logger = zap.NewNop() } return &PlaylistService{ playlistRepo: playlistRepo, playlistTrackRepo: playlistTrackRepo, playlistCollaboratorRepo: playlistCollaboratorRepo, userRepo: userRepo, logger: logger, } } // SetPlaylistShareService définit le service de partage de playlist // T0488: Create Playlist Public Share Link func (s *PlaylistService) SetPlaylistShareService(shareService *PlaylistShareService) { s.playlistShareService = shareService } // SetPlaylistFollowService définit le service de follow de playlist // T0489: Create Playlist Follow Feature func (s *PlaylistService) SetPlaylistFollowService(followService *PlaylistFollowService) { s.playlistFollowService = followService } // SetPlaylistNotificationService définit le service de notifications de playlist // T0508: Create Playlist Notifications func (s *PlaylistService) SetPlaylistNotificationService(notificationService *PlaylistNotificationService) { s.playlistNotificationService = notificationService } // SetPlaylistVersionService définit le service de versions de playlist // T0509: Create Playlist Version History func (s *PlaylistService) SetPlaylistVersionService(versionService *PlaylistVersionService) { s.playlistVersionService = versionService } // SetCacheService définit le service de cache pour PlaylistService // BE-SVC-001: Implement caching layer for frequently accessed data func (s *PlaylistService) SetCacheService(cacheService *CacheService) { s.cacheService = cacheService } // NewPlaylistServiceWithDB crée un nouveau service de playlists avec GORM (compatibilité) // Cette fonction crée les repositories en interne pour maintenir la compatibilité func NewPlaylistServiceWithDB(db *gorm.DB, logger *zap.Logger) *PlaylistService { if logger == nil { logger = zap.NewNop() } playlistRepo := repositories.NewPlaylistRepository(db) playlistTrackRepo := repositories.NewPlaylistTrackRepository(db) playlistCollaboratorRepo := repositories.NewPlaylistCollaboratorRepository(db) // Pour UserRepository, on utilise une implémentation simple qui utilise GORM // Note: On pourrait créer un UserRepository GORM aussi, mais pour l'instant on garde la compatibilité userRepo := &gormUserRepository{db: db} service := &PlaylistService{ playlistRepo: playlistRepo, playlistTrackRepo: playlistTrackRepo, playlistCollaboratorRepo: playlistCollaboratorRepo, userRepo: userRepo, logger: logger, } // Créer et injecter le service de partage shareService := NewPlaylistShareService(db) service.SetPlaylistShareService(shareService) return service } // gormUserRepository est une implémentation temporaire de UserRepository avec GORM // pour maintenir la compatibilité avec le code existant type gormUserRepository struct { db *gorm.DB } func (r *gormUserRepository) GetByID(ctx context.Context, id string) (*models.User, error) { var user models.User if err := r.db.WithContext(ctx).First(&user, "id = ?", id).Error; err != nil { return nil, err } return &user, nil } func (r *gormUserRepository) GetByEmail(ctx context.Context, email string) (*models.User, error) { var user models.User if err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil { return nil, err } return &user, nil } func (r *gormUserRepository) GetByUsername(ctx context.Context, username string) (*models.User, error) { var user models.User if err := r.db.WithContext(ctx).Where("username = ?", username).First(&user).Error; err != nil { return nil, err } return &user, nil } func (r *gormUserRepository) Create(ctx context.Context, user *models.User) error { return r.db.WithContext(ctx).Create(user).Error } func (r *gormUserRepository) Update(ctx context.Context, user *models.User) error { return r.db.WithContext(ctx).Save(user).Error } func (r *gormUserRepository) Delete(ctx context.Context, id string) error { return r.db.WithContext(ctx).Delete(&models.User{}, "id = ?", id).Error } // Exists vérifie si un utilisateur existe (méthode helper pour le service) func (r *gormUserRepository) Exists(ctx context.Context, userID uuid.UUID) (bool, error) { var count int64 err := r.db.WithContext(ctx).Model(&models.User{}).Where("id = ?", userID).Count(&count).Error return count > 0, err } // CreatePlaylist crée une nouvelle playlist // T0453: Utilise le repository pattern avec validation // MOD-P2-003: Enregistre la métrique business func (s *PlaylistService) CreatePlaylist(ctx context.Context, userID uuid.UUID, title, description string, isPublic bool) (*models.Playlist, error) { // Validation if title == "" { return nil, errors.New("title is required") } if len(title) > 200 { return nil, errors.New("title must be less than 200 characters") } // Vérifier que l'utilisateur existe // Note: On utilise une méthode helper Exists si disponible if gormRepo, ok := s.userRepo.(interface { Exists(ctx context.Context, userID uuid.UUID) (bool, error) }); ok { exists, err := gormRepo.Exists(ctx, userID) if err != nil { return nil, fmt.Errorf("failed to check user: %w", err) } if !exists { return nil, errors.New("user not found") } } else { // Pour les autres implémentations, on essaie de récupérer l'utilisateur _, err := s.userRepo.GetByID(ctx, userID.String()) if err != nil { return nil, errors.New("user not found") } } // Créer la playlist playlist := &models.Playlist{ UserID: userID, Title: title, Description: description, IsPublic: isPublic, TrackCount: 0, } if err := s.playlistRepo.Create(ctx, playlist); err != nil { return nil, fmt.Errorf("failed to create playlist: %w", err) } s.logger.Info("Playlist created", zap.String("playlist_id", playlist.ID.String()), zap.String("user_id", userID.String()), zap.String("title", title), ) // T0509: Sauvegarder la version initiale if s.playlistVersionService != nil { // Let's try to pass it. if _, err := s.playlistVersionService.SaveVersion(ctx, playlist.ID, userID, models.PlaylistVersionActionCreated); err != nil { s.logger.Warn("Failed to save initial playlist version", zap.Error(err)) } } return playlist, nil } // GetPlaylist récupère une playlist avec ses tracks // T0453: Utilise le repository pattern avec vérification d'accès // MIGRATION UUID: userID migré vers *uuid.UUID // BE-SVC-001: Add caching for playlist data func (s *PlaylistService) GetPlaylist(ctx context.Context, playlistID uuid.UUID, userID *uuid.UUID) (*models.Playlist, error) { cacheConfig := DefaultCacheConfig() // Try to get from cache first if s.cacheService != nil { var cachedPlaylist models.Playlist if err := s.cacheService.GetPlaylist(ctx, playlistID, &cachedPlaylist); err == nil { // Cache hit - but we still need to check access // Vérifier accès si playlist privée if !cachedPlaylist.IsPublic { if userID == nil || *userID != cachedPlaylist.UserID { return nil, ErrPlaylistNotFound } } return &cachedPlaylist, nil } } // Cache miss - fetch from database playlist, err := s.playlistRepo.GetByIDWithTracks(ctx, playlistID) // Use GetByIDWithTracks if err != nil { if err == gorm.ErrRecordNotFound { return nil, ErrPlaylistNotFound } return nil, fmt.Errorf("failed to get playlist: %w", err) } // Vérifier accès si playlist privée if !playlist.IsPublic { if userID == nil || *userID != playlist.UserID { return nil, ErrPlaylistNotFound // Return NotFound for security (hide private playlists) } } // Cache the playlist if s.cacheService != nil { if err := s.cacheService.SetPlaylist(ctx, playlistID, playlist, cacheConfig); err != nil { s.logger.Warn("Failed to cache playlist", zap.Error(err), zap.String("playlist_id", playlistID.String())) } } return playlist, nil } // GetPlaylists récupère une liste de playlists avec pagination // T0453: Utilise le repository pattern avec filtres // T0501: Optimisé avec pagination efficace et lazy loading // MIGRATION UUID: currentUserID et filterUserID migrés vers *uuid.UUID // MOD: Utilisation du filtre viewerID pour gestion SQL de la visibilité func (s *PlaylistService) GetPlaylists(ctx context.Context, currentUserID *uuid.UUID, filterUserID *uuid.UUID, page, limit int) ([]*models.Playlist, int64, error) { // Appliquer la pagination avec limites optimisées if limit <= 0 { limit = 20 } if limit > 100 { limit = 100 } if page < 1 { page = 1 } offset := (page - 1) * limit // T0501: Optimisation - Utiliser un offset calculé efficacement // Pour les grandes pages, utiliser un curseur si disponible if page > 100 { // Pour les très grandes pages, limiter à 100 pour éviter les problèmes de performance page = 100 offset = (page - 1) * limit } // Déterminer le filtre isPublic var isPublic *bool // Gestion simplifiée grâce au viewerID dans le repository: // Si on filtre par utilisateur if filterUserID != nil { if currentUserID == nil { // Visiteur anonyme -> Public only public := true isPublic = &public } else if *filterUserID != *currentUserID { // Visiteur authentifié regardant un autre user -> Public only // (Sauf si on implémente logic ami/collaborateur plus tard, mais pour l'instant Public) public := true isPublic = &public } // Si (filterUserID == currentUserID), on laisse isPublic à nil pour tout voir } else { // Liste globale (Feed) if currentUserID == nil { // Anonyme -> Public only public := true isPublic = &public } // Si authentifié, on laisse isPublic à nil et on passe viewerID=currentUserID // Le repository fera (is_public=true OR user_id=viewerID) } // Appel optimisé au repository // On passe currentUserID comme viewerID playlists, total, err := s.playlistRepo.List(ctx, filterUserID, currentUserID, isPublic, limit, offset) if err != nil { return nil, 0, fmt.Errorf("playlist repository List failed: %w", err) } // T0501: Lazy loading - Ne pas charger les tracks pour la liste for _, p := range playlists { p.Tracks = nil } // Plus besoin de filtrage en mémoire, le SQL a tout géré ! return playlists, total, nil } // SearchPlaylistsParams représente les paramètres de recherche de playlists // T0496: Create Playlist Search Backend // MIGRATION UUID: UserID et CurrentUserID migrés vers *uuid.UUID type SearchPlaylistsParams struct { Query string // Recherche par titre ou description UserID *uuid.UUID // Filtrer par utilisateur IsPublic *bool // Filtrer par statut public/privé Page int // Numéro de page (défaut: 1) Limit int // Nombre de résultats par page (défaut: 20, max: 100) CurrentUserID *uuid.UUID // ID de l'utilisateur actuel pour les règles d'accès } // SearchPlaylists recherche des playlists selon les critères fournis // T0496: Create Playlist Search Backend func (s *PlaylistService) SearchPlaylists(ctx context.Context, params SearchPlaylistsParams) ([]*models.Playlist, int64, error) { // Appliquer la pagination if params.Limit <= 0 { params.Limit = 20 } if params.Limit > 100 { params.Limit = 100 } if params.Page < 1 { params.Page = 1 } offset := (params.Page - 1) * params.Limit // Déterminer le filtre isPublic selon les règles d'accès var isPublic *bool if params.IsPublic != nil { isPublic = params.IsPublic } else if params.CurrentUserID == nil { // Si pas d'utilisateur authentifié, seulement les playlists publiques public := true isPublic = &public } else if params.UserID != nil && *params.UserID != *params.CurrentUserID { // Si on recherche les playlists d'un autre utilisateur, seulement publiques public := true isPublic = &public } // Si params.UserID == nil ou params.UserID == params.CurrentUserID, on ne filtre pas par isPublic // (on laisse le repository gérer) // Utiliser la méthode Search du repository playlists, total, err := s.playlistRepo.Search(ctx, params.Query, params.UserID, isPublic, params.Limit, offset) if err != nil { return nil, 0, fmt.Errorf("failed to search playlists: %w", err) } // Filtrer les playlists selon les règles d'accès si nécessaire if params.CurrentUserID != nil && params.UserID == nil && isPublic == nil { // Recherche globale : filtrer pour ne garder que les publiques ou celles de l'utilisateur filtered := make([]*models.Playlist, 0) for _, p := range playlists { if p.IsPublic || p.UserID == *params.CurrentUserID { filtered = append(filtered, p) } } playlists = filtered } s.logger.Debug("Playlists searched", zap.String("query", params.Query), zap.Any("user_id", params.UserID), zap.Any("is_public", params.IsPublic), zap.Int("page", params.Page), zap.Int("limit", params.Limit), zap.Int64("total", total), zap.Int("results", len(playlists)), ) return playlists, total, nil } // UpdatePlaylist met à jour une playlist // T0453: Utilise le repository pattern avec vérification d'ownership // MIGRATION UUID: userID en uuid.UUID, playlistID en uuid.UUID func (s *PlaylistService) UpdatePlaylist(ctx context.Context, playlistID uuid.UUID, userID uuid.UUID, title, description *string, isPublic *bool) (*models.Playlist, error) { // Vérifier ownership playlist, err := s.playlistRepo.GetByID(ctx, playlistID) if err != nil { if err == gorm.ErrRecordNotFound { return nil, ErrPlaylistNotFound } return nil, fmt.Errorf("failed to check playlist: %w", err) } if playlist.UserID != userID { return nil, ErrAccessDenied } // Validation if title != nil { if *title == "" { return nil, ErrTitleEmpty } if len(*title) > 200 { return nil, ErrTitleTooLong } playlist.Title = *title } if description != nil { playlist.Description = *description } if isPublic != nil { playlist.IsPublic = *isPublic } if err := s.playlistRepo.Update(ctx, playlist); err != nil { return nil, fmt.Errorf("failed to update playlist: %w", err) } s.logger.Info("Playlist updated", zap.String("playlist_id", playlistID.String()), zap.String("user_id", userID.String()), ) // T0509: Sauvegarder une version avant la mise à jour if s.playlistVersionService != nil { if _, err := s.playlistVersionService.SaveVersion(ctx, playlistID, userID, models.PlaylistVersionActionUpdated); err != nil { s.logger.Warn("Failed to save playlist version", zap.Error(err)) } } // T0508: Envoyer une notification if s.playlistNotificationService != nil { if err := s.playlistNotificationService.NotifyPlaylistUpdated(ctx, playlistID, userID); err != nil { s.logger.Warn("Failed to send playlist updated notification", zap.Error(err)) } } return playlist, nil } // DeletePlaylist supprime une playlist (soft delete) // T0453: Utilise le repository pattern avec vérification d'ownership // MIGRATION UUID: userID en uuid.UUID, playlistID en uuid.UUID func (s *PlaylistService) DeletePlaylist(ctx context.Context, playlistID uuid.UUID, userID uuid.UUID) error { // Vérifier ownership playlist, err := s.playlistRepo.GetByID(ctx, playlistID) if err != nil { if err == gorm.ErrRecordNotFound { return ErrPlaylistNotFound } return fmt.Errorf("failed to check playlist: %w", err) } if playlist.UserID != userID { return ErrAccessDenied } if err := s.playlistRepo.Delete(ctx, playlistID); err != nil { return fmt.Errorf("failed to delete playlist: %w", err) } s.logger.Info("Playlist deleted", zap.String("playlist_id", playlistID.String()), zap.String("user_id", userID.String()), ) return nil } // AddTrackToPlaylist ajoute un track à une playlist // T0466: Implémentation avec PlaylistTrackRepository // MIGRATION UUID: userID en uuid.UUID, playlistID et trackID en uuid.UUID func (s *PlaylistService) AddTrackToPlaylist(ctx context.Context, playlistID, trackID uuid.UUID, userID uuid.UUID, position int) error { // Vérifier ownership playlist, err := s.playlistRepo.GetByID(ctx, playlistID) if err != nil { if err == gorm.ErrRecordNotFound { return ErrPlaylistNotFound } return fmt.Errorf("failed to check playlist: %w", err) } if playlist.UserID != userID { return ErrAccessDenied } // Ajouter le track via le repository (qui vérifie l'existence du track) if err := s.playlistTrackRepo.AddTrack(ctx, playlistID, trackID, position); err != nil { if err.Error() == "track not found" { return ErrTrackNotFound } if err.Error() == "track already in playlist" { return ErrTrackAlreadyInPlaylist } return fmt.Errorf("failed to add track to playlist: %w", err) } s.logger.Info("Track added to playlist", zap.String("playlist_id", playlistID.String()), zap.String("track_id", trackID.String()), zap.String("user_id", userID.String()), zap.Int("position", position), ) // T0508: Envoyer une notification (trackTitle sera vide, le service utilisera un message générique) if s.playlistNotificationService != nil { if err := s.playlistNotificationService.NotifyTrackAdded(ctx, playlistID, "", userID); err != nil { s.logger.Warn("Failed to send track added notification", zap.Error(err)) } } return nil } // AddTrack est un alias pour AddTrackToPlaylist (compatibilité) // MIGRATION UUID: userID en uuid.UUID, playlistID et trackID en uuid.UUID func (s *PlaylistService) AddTrack(ctx context.Context, playlistID, trackID uuid.UUID, userID uuid.UUID) error { return s.AddTrackToPlaylist(ctx, playlistID, trackID, userID, 0) } // RemoveTrackFromPlaylist retire un track d'une playlist // T0466: Implémentation avec PlaylistTrackRepository // MIGRATION UUID: userID en uuid.UUID, playlistID et trackID en uuid.UUID func (s *PlaylistService) RemoveTrackFromPlaylist(ctx context.Context, playlistID, trackID uuid.UUID, userID uuid.UUID) error { // Vérifier ownership playlist, err := s.playlistRepo.GetByID(ctx, playlistID) if err != nil { if err == gorm.ErrRecordNotFound { return ErrPlaylistNotFound } return fmt.Errorf("failed to check playlist: %w", err) } if playlist.UserID != userID { return errors.New("forbidden") } // Retirer le track via le repository if err := s.playlistTrackRepo.RemoveTrack(ctx, playlistID, trackID); err != nil { if err.Error() == "track not found in playlist" { return errors.New("track not found in playlist") } return fmt.Errorf("failed to remove track from playlist: %w", err) } s.logger.Info("Track removed from playlist", zap.String("playlist_id", playlistID.String()), zap.String("track_id", trackID.String()), zap.String("user_id", userID.String()), ) return nil } // RemoveTrack est un alias pour RemoveTrackFromPlaylist (compatibilité) // MIGRATION UUID: userID en uuid.UUID, playlistID et trackID en uuid.UUID func (s *PlaylistService) RemoveTrack(ctx context.Context, playlistID, trackID uuid.UUID, userID uuid.UUID) error { return s.RemoveTrackFromPlaylist(ctx, playlistID, trackID, userID) } // ReorderPlaylistTracks réorganise les tracks d'une playlist // T0466: Implémentation avec PlaylistTrackRepository // trackPositions est une map de trackID -> position // MIGRATION UUID: userID en uuid.UUID, playlistID en uuid.UUID func (s *PlaylistService) ReorderPlaylistTracks(ctx context.Context, playlistID uuid.UUID, userID uuid.UUID, trackPositions map[uuid.UUID]int) error { // Vérifier ownership playlist, err := s.playlistRepo.GetByID(ctx, playlistID) if err != nil { if err == gorm.ErrRecordNotFound { return ErrPlaylistNotFound } return fmt.Errorf("failed to check playlist: %w", err) } if playlist.UserID != userID { return errors.New("forbidden") } // Réorganiser les tracks via le repository if err := s.playlistTrackRepo.ReorderTracks(ctx, playlistID, trackPositions); err != nil { return fmt.Errorf("failed to reorder tracks: %w", err) } s.logger.Info("Playlist tracks reordered", zap.String("playlist_id", playlistID.String()), zap.String("user_id", userID.String()), zap.Int("tracks_count", len(trackPositions)), ) return nil } // ReorderTracks est un alias pour ReorderPlaylistTracks (compatibilité) // trackIDs est une liste de trackIDs dans l'ordre souhaité (position = index + 1) // MIGRATION UUID: userID en uuid.UUID, playlistID en uuid.UUID func (s *PlaylistService) ReorderTracks(ctx context.Context, playlistID uuid.UUID, userID uuid.UUID, trackIDs []uuid.UUID) error { trackPositions := make(map[uuid.UUID]int) for i, trackID := range trackIDs { trackPositions[trackID] = i + 1 } return s.ReorderPlaylistTracks(ctx, playlistID, userID, trackPositions) } // AddCollaborator ajoute un collaborateur à une playlist // T0478: Implémentation avec vérification d'ownership // MIGRATION UUID: ownerID et collaboratorUserID migrés vers uuid.UUID, playlistID en uuid.UUID func (s *PlaylistService) AddCollaborator(ctx context.Context, playlistID uuid.UUID, ownerID, collaboratorUserID uuid.UUID, permission models.PlaylistPermission) (*models.PlaylistCollaborator, error) { // Vérifier ownership playlist, err := s.playlistRepo.GetByID(ctx, playlistID) if err != nil { if err == gorm.ErrRecordNotFound { return nil, ErrPlaylistNotFound } return nil, fmt.Errorf("failed to check playlist: %w", err) } if playlist.UserID != ownerID { return nil, errors.New("forbidden: only playlist owner can add collaborators") } // Vérifier que l'utilisateur collaborateur existe if gormRepo, ok := s.userRepo.(interface { Exists(ctx context.Context, userID uuid.UUID) (bool, error) }); ok { exists, err := gormRepo.Exists(ctx, collaboratorUserID) if err != nil { return nil, fmt.Errorf("failed to check user: %w", err) } if !exists { return nil, errors.New("user not found") } } else { _, err := s.userRepo.GetByID(ctx, collaboratorUserID.String()) if err != nil { return nil, errors.New("user not found") } } // Vérifier qu'on n'ajoute pas le propriétaire comme collaborateur if collaboratorUserID == ownerID { return nil, errors.New("cannot add playlist owner as collaborator") } // Ajouter le collaborateur via le repository collaborator, err := s.playlistCollaboratorRepo.AddCollaborator(ctx, playlistID, collaboratorUserID, permission) if err != nil { if err.Error() == "collaborator already exists" { return nil, errors.New("user is already a collaborator") } return nil, fmt.Errorf("failed to add collaborator: %w", err) } s.logger.Info("Collaborator added to playlist", zap.String("playlist_id", playlistID.String()), zap.String("owner_id", ownerID.String()), zap.String("collaborator_user_id", collaboratorUserID.String()), zap.String("permission", string(permission)), ) // T0508: Envoyer une notification au collaborateur if s.playlistNotificationService != nil { if err := s.playlistNotificationService.NotifyCollaboratorAdded(ctx, playlistID, collaboratorUserID, ownerID); err != nil { s.logger.Warn("Failed to send collaborator added notification", zap.Error(err)) } } return collaborator, nil } // RemoveCollaborator retire un collaborateur d'une playlist // T0478: Implémentation avec vérification d'ownership // MIGRATION UUID: ownerID et collaboratorUserID migrés vers uuid.UUID, playlistID en uuid.UUID func (s *PlaylistService) RemoveCollaborator(ctx context.Context, playlistID uuid.UUID, ownerID, collaboratorUserID uuid.UUID) error { // Vérifier ownership playlist, err := s.playlistRepo.GetByID(ctx, playlistID) if err != nil { if err == gorm.ErrRecordNotFound { return ErrPlaylistNotFound } return fmt.Errorf("failed to check playlist: %w", err) } if playlist.UserID != ownerID { return errors.New("forbidden: only playlist owner can remove collaborators") } // Retirer le collaborateur via le repository if err := s.playlistCollaboratorRepo.RemoveCollaborator(ctx, playlistID, collaboratorUserID); err != nil { if err == gorm.ErrRecordNotFound { return errors.New("collaborator not found") } return fmt.Errorf("failed to remove collaborator: %w", err) } s.logger.Info("Collaborator removed from playlist", zap.String("playlist_id", playlistID.String()), zap.String("owner_id", ownerID.String()), zap.String("collaborator_user_id", collaboratorUserID.String()), ) return nil } // UpdateCollaboratorPermission met à jour la permission d'un collaborateur // T0478: Implémentation avec vérification d'ownership // MIGRATION UUID: ownerID et collaboratorUserID migrés vers uuid.UUID, playlistID en uuid.UUID func (s *PlaylistService) UpdateCollaboratorPermission(ctx context.Context, playlistID uuid.UUID, ownerID, collaboratorUserID uuid.UUID, permission models.PlaylistPermission) error { // Vérifier ownership playlist, err := s.playlistRepo.GetByID(ctx, playlistID) if err != nil { if err == gorm.ErrRecordNotFound { return ErrPlaylistNotFound } return fmt.Errorf("failed to check playlist: %w", err) } if playlist.UserID != ownerID { return errors.New("forbidden: only playlist owner can update collaborator permissions") } // Valider la permission if !permission.IsValid() { return errors.New("invalid permission") } // Mettre à jour la permission via le repository if err := s.playlistCollaboratorRepo.UpdatePermission(ctx, playlistID, collaboratorUserID, permission); err != nil { if err == gorm.ErrRecordNotFound { return errors.New("collaborator not found") } return fmt.Errorf("failed to update collaborator permission: %w", err) } s.logger.Info("Collaborator permission updated", zap.String("playlist_id", playlistID.String()), zap.String("owner_id", ownerID.String()), zap.String("collaborator_user_id", collaboratorUserID.String()), zap.String("permission", string(permission)), ) return nil } // CheckPermission vérifie si un utilisateur a une certaine permission sur une playlist // T0478: Vérifie les permissions (read, write, admin) // MIGRATION UUID: userID en uuid.UUID, playlistID en uuid.UUID func (s *PlaylistService) CheckPermission(ctx context.Context, playlistID uuid.UUID, userID uuid.UUID, requiredPermission models.PlaylistPermission) (bool, error) { // Récupérer la playlist playlist, err := s.playlistRepo.GetByID(ctx, playlistID) if err != nil { if err == gorm.ErrRecordNotFound { return false, errors.New("playlist not found") } return false, fmt.Errorf("failed to check playlist: %w", err) } // Le propriétaire a toujours toutes les permissions if playlist.UserID == userID { return true, nil } // Si la playlist est publique, tout le monde peut la lire if playlist.IsPublic && requiredPermission == models.PlaylistPermissionRead { return true, nil } // Vérifier si l'utilisateur est collaborateur collaborator, err := s.playlistCollaboratorRepo.GetCollaborator(ctx, playlistID, userID) if err != nil { if err == gorm.ErrRecordNotFound { return false, nil // Pas de permission } return false, fmt.Errorf("failed to check collaborator: %w", err) } // Vérifier la permission selon le niveau requis switch requiredPermission { case models.PlaylistPermissionRead: return collaborator.CanRead(), nil case models.PlaylistPermissionWrite: return collaborator.CanWrite(), nil case models.PlaylistPermissionAdmin: return collaborator.CanAdmin(), nil default: return false, errors.New("invalid permission") } } // GetCollaborators récupère tous les collaborateurs d'une playlist // T0478: Helper method pour récupérer les collaborateurs // MIGRATION UUID: userID en uuid.UUID, playlistID en uuid.UUID func (s *PlaylistService) GetCollaborators(ctx context.Context, playlistID uuid.UUID, userID uuid.UUID) ([]*models.PlaylistCollaborator, error) { // Vérifier que l'utilisateur a accès à la playlist (propriétaire ou collaborateur) hasAccess, err := s.CheckPermission(ctx, playlistID, userID, models.PlaylistPermissionRead) if err != nil { return nil, err } if !hasAccess { return nil, errors.New("forbidden: access denied") } // Récupérer les collaborateurs collaborators, err := s.playlistCollaboratorRepo.GetCollaborators(ctx, playlistID) if err != nil { return nil, fmt.Errorf("failed to get collaborators: %w", err) } return collaborators, nil } // CreateShareLink crée un nouveau lien de partage public pour une playlist // T0488: Create Playlist Public Share Link // MIGRATION UUID: userID en uuid.UUID, playlistID en uuid.UUID func (s *PlaylistService) CreateShareLink(ctx context.Context, playlistID uuid.UUID, userID uuid.UUID, expiresAt *time.Time) (*models.PlaylistShareLink, error) { if s.playlistShareService == nil { return nil, errors.New("playlist share service not initialized") } // Vérifier que l'utilisateur a la permission (owner ou admin) hasPermission, err := s.CheckPermission(ctx, playlistID, userID, models.PlaylistPermissionAdmin) if err != nil { return nil, err } if !hasPermission { // Vérifier si l'utilisateur est le propriétaire playlist, err := s.GetPlaylist(ctx, playlistID, &userID) if err != nil { return nil, err } if playlist.UserID != userID { return nil, errors.New("forbidden: only owner or admin can create share links") } } shareLink, err := s.playlistShareService.CreateShareLink(ctx, playlistID, userID, expiresAt) if err != nil { return nil, err } // T0508: Envoyer une notification if s.playlistNotificationService != nil { if err := s.playlistNotificationService.NotifyPlaylistShared(ctx, playlistID, userID); err != nil { s.logger.Warn("Failed to send playlist shared notification", zap.Error(err)) } } return shareLink, nil } // GetPlaylistByShareToken returns a playlist by its public share token (v0.10.4 F143). // No auth required; valid token grants access even to private playlists. func (s *PlaylistService) GetPlaylistByShareToken(ctx context.Context, token string) (*models.Playlist, error) { if s.playlistShareService == nil { return nil, errors.New("playlist share service not initialized") } shareLink, err := s.playlistShareService.ValidateShareToken(ctx, token) if err != nil { if errors.Is(err, ErrPlaylistShareNotFound) || errors.Is(err, ErrPlaylistShareExpired) { return nil, ErrPlaylistNotFound } return nil, err } // Bypass privacy check: valid share token grants access playlist, err := s.playlistRepo.GetByIDWithTracks(ctx, shareLink.PlaylistID) if err != nil { if err == gorm.ErrRecordNotFound { return nil, ErrPlaylistNotFound } return nil, fmt.Errorf("failed to get playlist: %w", err) } return playlist, nil } // 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 creates a playlist from imported data (v0.10.4 F145) func (s *PlaylistService) ImportPlaylist(ctx context.Context, userID uuid.UUID, req *ImportPlaylistRequest) (*models.Playlist, error) { if req == nil { return nil, errors.New("import request is required") } title := req.Playlist.Title if title == "" { title = "Imported Playlist" } 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 { s.logger.Warn("Import: invalid track id skipped", zap.String("id", t.ID)) continue } trackIDs = append(trackIDs, id) } return s.ImportPlaylistWithTracks(ctx, userID, title, req.Playlist.Description, req.Playlist.IsPublic, trackIDs) } // ImportPlaylistWithTracks creates a playlist and adds tracks (v0.10.4 F145). // Accepts parsed track IDs for handlers that decode JSON themselves. func (s *PlaylistService) ImportPlaylistWithTracks(ctx context.Context, userID uuid.UUID, title, description string, isPublic bool, trackIDs []uuid.UUID) (*models.Playlist, error) { if title == "" { title = "Imported Playlist" } playlist, err := s.CreatePlaylist(ctx, userID, title, description, isPublic) if err != nil { return nil, fmt.Errorf("create playlist: %w", err) } for i, trackID := range trackIDs { if err := s.AddTrackToPlaylist(ctx, playlist.ID, trackID, userID, i+1); err != nil { s.logger.Warn("Import: failed to add track", zap.String("track_id", trackID.String()), zap.Error(err)) } } return s.playlistRepo.GetByIDWithTracks(ctx, playlist.ID) } // GetOrCreateFavorisPlaylist returns the user's Favoris playlist, creating it if needed (v0.10.4 F136) func (s *PlaylistService) GetOrCreateFavorisPlaylist(ctx context.Context, userID uuid.UUID) (*models.Playlist, error) { existing, err := s.playlistRepo.GetFavorisByUserID(ctx, userID) if err == nil { return s.playlistRepo.GetByIDWithTracks(ctx, existing.ID) } if err != gorm.ErrRecordNotFound { return nil, fmt.Errorf("failed to get favoris playlist: %w", err) } // Create Favoris playlist playlist := &models.Playlist{ UserID: userID, Title: "Favoris", IsDefaultFavorites: true, IsPublic: false, } if err := s.playlistRepo.Create(ctx, playlist); err != nil { return nil, fmt.Errorf("failed to create favoris playlist: %w", err) } s.logger.Info("Favoris playlist created", zap.String("user_id", userID.String()), zap.String("playlist_id", playlist.ID.String())) return s.playlistRepo.GetByIDWithTracks(ctx, playlist.ID) } // FollowPlaylist permet à un utilisateur de suivre une playlist // T0489: Create Playlist Follow Feature // MIGRATION UUID: userID en uuid.UUID, playlistID en uuid.UUID func (s *PlaylistService) FollowPlaylist(ctx context.Context, playlistID uuid.UUID, userID uuid.UUID) error { if s.playlistFollowService == nil { return errors.New("playlist follow service not initialized") } return s.playlistFollowService.FollowPlaylist(ctx, userID, playlistID) } // UnfollowPlaylist permet à un utilisateur de ne plus suivre une playlist // T0489: Create Playlist Follow Feature // MIGRATION UUID: userID en uuid.UUID, playlistID en uuid.UUID func (s *PlaylistService) UnfollowPlaylist(ctx context.Context, playlistID uuid.UUID, userID uuid.UUID) error { if s.playlistFollowService == nil { return errors.New("playlist follow service not initialized") } return s.playlistFollowService.UnfollowPlaylist(ctx, userID, playlistID) } // IsFollowing vérifie si un utilisateur suit une playlist // T0489: Create Playlist Follow Feature // MIGRATION UUID: userID en uuid.UUID, playlistID en uuid.UUID func (s *PlaylistService) IsFollowing(ctx context.Context, playlistID uuid.UUID, userID uuid.UUID) (bool, error) { if s.playlistFollowService == nil { return false, errors.New("playlist follow service not initialized") } return s.playlistFollowService.IsFollowing(ctx, userID, playlistID) }