package services import ( "context" "errors" "fmt" "github.com/google/uuid" "veza-backend-api/internal/models" "go.uber.org/zap" "gorm.io/gorm" ) // PlaylistDuplicateService gère la duplication de playlists // T0495: Create Playlist Duplicate Feature type PlaylistDuplicateService struct { playlistService PlaylistServiceInterface db *gorm.DB logger *zap.Logger } // NewPlaylistDuplicateService crée un nouveau service de duplication de playlists func NewPlaylistDuplicateService(playlistService PlaylistServiceInterface, db *gorm.DB, logger *zap.Logger) *PlaylistDuplicateService { if logger == nil { logger = zap.NewNop() } return &PlaylistDuplicateService{ playlistService: playlistService, db: db, logger: logger, } } // DuplicatePlaylistRequest représente la requête de duplication type DuplicatePlaylistRequest struct { NewTitle string `json:"new_title"` NewDescription string `json:"new_description,omitempty"` IsPublic *bool `json:"is_public,omitempty"` } // DuplicatePlaylist duplique une playlist avec tous ses tracks // T0495: Create Playlist Duplicate Feature // MIGRATION UUID: Completée. playlistID et userID sont des UUIDs. // Transactionnelle : Toute la duplication (playlist + tracks + compteur) est dans une seule transaction func (s *PlaylistDuplicateService) DuplicatePlaylist( ctx context.Context, playlistID uuid.UUID, userID uuid.UUID, request DuplicatePlaylistRequest, ) (*models.Playlist, error) { var newPlaylist *models.Playlist err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 1. VALIDATION : Charger playlist originale (sans tracks pour l'instant) var originalPlaylist models.Playlist err := tx.First(&originalPlaylist, "id = ?", playlistID).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("playlist not found") } return fmt.Errorf("DuplicatePlaylist: failed to load original playlist: %w", err) } // 1b. Charger tous les PlaylistTrack de la playlist originale (même si le Track associé est supprimé) var playlistTracks []models.PlaylistTrack if err := tx.Where("playlist_id = ?", playlistID).Order("position ASC").Find(&playlistTracks).Error; err != nil { return fmt.Errorf("DuplicatePlaylist: failed to load playlist tracks: %w", err) } // 2. VALIDATION : Vérifier que l'utilisateur a accès à la playlist (propriétaire, collaborateur ou publique) // Note: On fait cette vérification dans la transaction pour éviter les race conditions if originalPlaylist.UserID != userID && !originalPlaylist.IsPublic { // Vérifier si l'utilisateur est collaborateur (simplifié pour la transaction) // On peut faire une requête simple dans la transaction var collaboratorCount int64 err := tx.Raw("SELECT COUNT(*) FROM playlist_collaborators WHERE playlist_id = ? AND user_id = ?", playlistID, userID).Scan(&collaboratorCount).Error if err != nil || collaboratorCount == 0 { return errors.New("forbidden: you don't have access to this playlist") } } // 3. DÉTERMINATION : Titre, description, isPublic newTitle := request.NewTitle if newTitle == "" { newTitle = originalPlaylist.Title + " (Copy)" } newDescription := request.NewDescription if newDescription == "" { newDescription = originalPlaylist.Description } isPublic := originalPlaylist.IsPublic if request.IsPublic != nil { isPublic = *request.IsPublic } // 4. CRÉATION : Nouvelle playlist (INSERT dans la transaction) newPlaylist = &models.Playlist{ UserID: userID, Title: newTitle, Description: newDescription, IsPublic: isPublic, TrackCount: 0, // Sera mis à jour après l'ajout des tracks } if err := tx.Create(newPlaylist).Error; err != nil { return fmt.Errorf("DuplicatePlaylist: failed to create duplicate playlist: %w", err) } // 5. DUPLICATION : Tous les tracks dans la même transaction for i, playlistTrack := range playlistTracks { trackID := playlistTrack.TrackID if trackID == uuid.Nil { return fmt.Errorf("DuplicatePlaylist: track not found for playlist track at position %d", i+1) } // Vérifier que le track existe toujours dans la base de données (non supprimé) var trackExists bool if err := tx.Model(&models.Track{}).Select("1").Where("id = ? AND deleted_at IS NULL", trackID).Limit(1).Scan(&trackExists).Error; err != nil { return fmt.Errorf("DuplicatePlaylist: failed to verify track existence: %w", err) } if !trackExists { return fmt.Errorf("DuplicatePlaylist: track %s no longer exists", trackID) } // Créer le PlaylistTrack directement dans la transaction newPlaylistTrack := models.PlaylistTrack{ PlaylistID: newPlaylist.ID, TrackID: trackID, Position: playlistTrack.Position, AddedBy: userID, // Use the userID who is duplicating the playlist } // Si position <= 0, utiliser l'index + 1 if newPlaylistTrack.Position <= 0 { newPlaylistTrack.Position = i + 1 } if err := tx.Create(&newPlaylistTrack).Error; err != nil { return fmt.Errorf("DuplicatePlaylist: failed to add track %s to duplicate: %w", trackID, err) } } // 6. MISE À JOUR : Compteur de tracks (UPDATE dans la transaction) trackCount := len(playlistTracks) if err := tx.Model(newPlaylist).Update("track_count", trackCount).Error; err != nil { return fmt.Errorf("DuplicatePlaylist: failed to update track_count: %w", err) } newPlaylist.TrackCount = trackCount // 7. LOG (dans la transaction, mais ne dépend pas d'états non commit) s.logger.Info("Playlist duplicated", zap.String("original_playlist_id", playlistID.String()), zap.String("new_playlist_id", newPlaylist.ID.String()), zap.String("user_id", userID.String()), zap.Int("tracks_count", trackCount), ) // 8. RETOUR nil = commit automatique return nil }) if err != nil { return nil, err // Rollback automatique si erreur } return newPlaylist, nil }