package services import ( "errors" "fmt" "github.com/google/uuid" "mime/multipart" "os" "path/filepath" "time" "gorm.io/gorm" "veza-backend-api/internal/models" "veza-backend-api/internal/types" "veza-backend-api/internal/utils" ) // UserRepository defines the interface for user repository operations type UserRepository interface { GetByID(id string) (*models.User, error) GetByEmail(email string) (*models.User, error) GetByUsername(username string) (*models.User, error) Create(user *models.User) error Update(user *models.User) error Delete(id string) error } // UserService gère les opérations sur les utilisateurs type UserService struct { userRepo UserRepository db *gorm.DB // Optional DB access for settings } // UpdateProfileRequest represents profile update data type UpdateProfileRequest struct { FirstName *string `json:"first_name"` LastName *string `json:"last_name"` Username *string `json:"username"` Bio *string `json:"bio"` Location *string `json:"location"` BirthDate *string `json:"birth_date"` Gender *string `json:"gender"` Timezone *string `json:"timezone"` SocialLinks map[string]interface{} `json:"social_links"` WebsiteURL *string `json:"website_url"` ProfilePrivacy *string `json:"profile_privacy"` } // Profile represents a user profile with necessary fields // MIGRATION UUID: ID et UserID migrés vers uuid.UUID type Profile struct { ID uuid.UUID `json:"id"` UserID uuid.UUID `json:"user_id"` Username string `json:"username"` FirstName string `json:"first_name"` LastName string `json:"last_name"` AvatarURL *string `json:"avatar_url"` Bio *string `json:"bio"` Location *string `json:"location"` Birthdate *string `json:"birthdate"` Gender *string `json:"gender"` CreatedAt time.Time `json:"created_at"` } // UserStats est maintenant défini dans internal/types/stats.go // Import: veza-backend-api/internal/types // ProfileCompletion represents profile completion status type ProfileCompletion struct { Percentage int `json:"percentage"` Missing []string `json:"missing"` } // NewUserService crée une nouvelle instance d'UserService func NewUserService(userRepo UserRepository) *UserService { return &UserService{ userRepo: userRepo, } } // NewUserServiceWithDB crée une nouvelle instance d'UserService avec accès DB func NewUserServiceWithDB(userRepo UserRepository, db *gorm.DB) *UserService { return &UserService{ userRepo: userRepo, db: db, } } // GetProfileByString récupère le profil d'un utilisateur par ID string (legacy method) func (s *UserService) GetProfileByString(userID string) (*models.User, error) { user, err := s.userRepo.GetByID(userID) if err != nil { return nil, errors.New("user not found") } // PasswordHash est déjà exclu avec json:"-" return user, nil } // UpdateProfile met à jour le profil d'un utilisateur // UpdateProfileLegacy updates user profile using a map (legacy method, kept for backward compatibility) // DEPRECATED: Use UpdateProfile(userID uuid.UUID, req types.UpdateProfileRequest) instead func (s *UserService) UpdateProfileLegacy(userID string, updates map[string]interface{}) (*models.User, error) { user, err := s.userRepo.GetByID(userID) if err != nil { return nil, errors.New("user not found") } // Appliquer les mises à jour if username, ok := updates["username"].(string); ok { user.Username = username } if email, ok := updates["email"].(string); ok { user.Email = email } // Sauvegarder les modifications err = s.userRepo.Update(user) if err != nil { return nil, err } // PasswordHash est déjà exclu avec json:"-" return user, nil } // GetByID retrieves a user by ID func (s *UserService) GetByID(userID uuid.UUID) (*models.User, error) { return s.userRepo.GetByID(userID.String()) } // GetProfileByID retrieves a user profile by ID (alias for GetByID for clarity) func (s *UserService) GetProfileByID(userID uuid.UUID) (*models.User, error) { return s.GetByID(userID) } // GetByUsername retrieves a user by username func (s *UserService) GetByUsername(username string) (*models.User, error) { return s.userRepo.GetByUsername(username) } // UpdateProfileWithRequest updates user profile with new request structure func (s *UserService) UpdateProfileWithRequest(userID uuid.UUID, req *UpdateProfileRequest) (*models.User, error) { user, err := s.userRepo.GetByID(userID.String()) if err != nil { return nil, errors.New("user not found") } // Apply updates if req.Bio != nil { user.Bio = *req.Bio } // Add more field updates as needed // Save changes err = s.userRepo.Update(user) if err != nil { return nil, err } return user, nil } // GetProfile retrieves a user profile by ID // requesterID can be nil for unauthenticated requests // If profile is private and requesterID is different from userID, returns limited fields // MIGRATION UUID: requesterID migré vers *uuid.UUID func (s *UserService) GetProfile(userID uuid.UUID, requesterID *uuid.UUID) (*Profile, error) { user, err := s.userRepo.GetByID(userID.String()) if err != nil { return nil, fmt.Errorf("user not found") } profile := s.userToProfile(user) // If profile is private and requester is different from owner, limit fields if !user.IsPublic && (requesterID == nil || *requesterID != userID) { profile.Bio = nil profile.Location = nil profile.Birthdate = nil profile.Gender = nil } return profile, nil } // GetProfileByUsername retrieves a user profile by username // requesterID can be nil for unauthenticated requests // If profile is private and requesterID is different from userID, returns limited fields // MIGRATION UUID: requesterID migré vers *uuid.UUID func (s *UserService) GetProfileByUsername(username string, requesterID *uuid.UUID) (*Profile, error) { user, err := s.userRepo.GetByUsername(username) if err != nil { return nil, fmt.Errorf("user not found") } profile := s.userToProfile(user) // If profile is private and requester is different from owner, limit fields if !user.IsPublic && (requesterID == nil || *requesterID != user.ID) { profile.Bio = nil profile.Location = nil profile.Birthdate = nil profile.Gender = nil } return profile, nil } // UpdateProfile updates a user profile and returns the updated profile func (s *UserService) UpdateProfile(userID uuid.UUID, req types.UpdateProfileRequest) (*Profile, error) { user, err := s.userRepo.GetByID(userID.String()) if err != nil { return nil, fmt.Errorf("user not found") } // Build updates map dynamically based on provided fields updates := make(map[string]interface{}) if req.FirstName != nil && *req.FirstName != "" { updates["first_name"] = *req.FirstName } if req.LastName != nil && *req.LastName != "" { updates["last_name"] = *req.LastName } if req.Username != nil && *req.Username != "" { updates["username"] = *req.Username // Set username_changed_at when username changes now := time.Now() updates["username_changed_at"] = &now // T0219: Generate and update slug when username changes slug := utils.Slugify(*req.Username) // Simplified: let the database handle uniqueness via unique constraint updates["slug"] = slug } if req.Bio != nil && *req.Bio != "" { updates["bio"] = *req.Bio } if req.Location != nil && *req.Location != "" { updates["location"] = *req.Location } if req.BirthDate != nil && *req.BirthDate != "" { birthdate, err := time.Parse("2006-01-02", *req.BirthDate) if err == nil { updates["birthdate"] = &birthdate } } if req.Gender != nil && *req.Gender != "" { updates["gender"] = *req.Gender } // Apply updates to user object if firstname, ok := updates["first_name"].(string); ok { user.FirstName = firstname } if lastname, ok := updates["last_name"].(string); ok { user.LastName = lastname } if username, ok := updates["username"].(string); ok { user.Username = username } if slug, ok := updates["slug"].(string); ok { user.Slug = slug } if usernameChangedAt, ok := updates["username_changed_at"].(*time.Time); ok { user.UsernameChangedAt = usernameChangedAt } if bio, ok := updates["bio"].(string); ok { user.Bio = bio } if location, ok := updates["location"].(string); ok { user.Location = location } if birthdate, ok := updates["birthdate"].(*time.Time); ok { user.Birthdate = birthdate } if gender, ok := updates["gender"].(string); ok { user.Gender = gender } // Save changes err = s.userRepo.Update(user) if err != nil { return nil, fmt.Errorf("failed to update profile: %w", err) } // Return updated profile return s.userToProfile(user), nil } // userToProfile converts a models.User to a Profile struct func (s *UserService) userToProfile(user *models.User) *Profile { var avatarURL *string if user.Avatar != "" { avatarURL = &user.Avatar } var bio *string if user.Bio != "" { bio = &user.Bio } var location *string if user.Location != "" { location = &user.Location } var birthdate *string if user.Birthdate != nil { birthdateStr := user.Birthdate.Format("2006-01-02") birthdate = &birthdateStr } var gender *string if user.Gender != "" { gender = &user.Gender } return &Profile{ ID: user.ID, UserID: user.ID, Username: user.Username, FirstName: user.FirstName, LastName: user.LastName, AvatarURL: avatarURL, Bio: bio, Location: location, Birthdate: birthdate, Gender: gender, CreatedAt: user.CreatedAt, } } // UploadAvatar handles avatar file upload func (s *UserService) UploadAvatar(userID uuid.UUID, file *multipart.FileHeader) (string, error) { // Create uploads directory if it doesn't exist uploadDir := "uploads/avatars" if err := os.MkdirAll(uploadDir, 0755); err != nil { return "", fmt.Errorf("failed to create upload directory: %w", err) } // Generate unique filename filename := fmt.Sprintf("%s_%s%s", userID.String(), uuid.New().String(), filepath.Ext(file.Filename)) filePath := filepath.Join(uploadDir, filename) // Save file src, err := file.Open() if err != nil { return "", err } defer src.Close() dst, err := os.Create(filePath) if err != nil { return "", err } defer dst.Close() if _, err := dst.ReadFrom(src); err != nil { return "", err } // Return URL avatarURL := fmt.Sprintf("/uploads/avatars/%s", filename) return avatarURL, nil } // UpdateAvatarURL updates the avatar URL for a user // T0221: Updates the avatar field in the users table // T0222: Can accept empty string to set avatar to NULL func (s *UserService) UpdateAvatarURL(userID uuid.UUID, avatarURL string) error { user, err := s.userRepo.GetByID(userID.String()) if err != nil { return fmt.Errorf("user not found") } // If avatarURL is empty string, set to empty (will be NULL in DB) user.Avatar = avatarURL if err := s.userRepo.Update(user); err != nil { return fmt.Errorf("failed to update avatar URL: %w", err) } return nil } // GetUserStats retrieves user statistics func (s *UserService) GetUserStats(username string) (*types.UserStats, error) { // This would typically query the database for stats // For now, return empty stats return &types.UserStats{ FollowersCount: 0, FollowingCount: 0, TracksCount: 0, PlaylistsCount: 0, }, nil } // ValidateUsername checks if a username is unique and if it can be changed (once per month) func (s *UserService) ValidateUsername(userID uuid.UUID, username string) error { // Vérifier si username existe pour autre user existingUser, err := s.userRepo.GetByUsername(username) if err == nil && existingUser != nil && existingUser.ID != userID { return errors.New("username already taken") } // Vérifier si username modifiable (1 fois par mois) user, err := s.userRepo.GetByID(userID.String()) if err != nil { return fmt.Errorf("failed to check username change date: %w", err) } // Si le username actuel est le même, pas besoin de vérifier la date de changement if user.Username == username { return nil } // Vérifier si username_changed_at existe et si moins de 30 jours if user.UsernameChangedAt != nil { timeSinceChange := time.Since(*user.UsernameChangedAt) if timeSinceChange < 30*24*time.Hour { return errors.New("username can only be changed once per month") } } return nil } // CanChangeUsername checks if a user can change their username (once per month) func (s *UserService) CanChangeUsername(userID uuid.UUID) (bool, error) { user, err := s.userRepo.GetByID(userID.String()) if err != nil { return false, err } // If UsernameChangedAt is nil, user can change username if user.UsernameChangedAt == nil { return true, nil } // Check if it's been at least 1 month since last change oneMonthAgo := time.Now().AddDate(0, -1, 0) return user.UsernameChangedAt.Before(oneMonthAgo), nil } // CalculateProfileCompletion calculates the profile completion percentage // T0220: Returns percentage (0-100) and list of missing required fields func (s *UserService) CalculateProfileCompletion(userID uuid.UUID) (*ProfileCompletion, error) { // Get profile as owner (to see all fields) profile, err := s.GetProfile(userID, &userID) if err != nil { return nil, fmt.Errorf("user not found") } totalFields := 5 completedFields := 0 missing := []string{} // Check username if profile.Username != "" { completedFields++ } else { missing = append(missing, "username") } // Check first_name if profile.FirstName != "" { completedFields++ } else { missing = append(missing, "first_name") } // Check last_name if profile.LastName != "" { completedFields++ } else { missing = append(missing, "last_name") } // Check bio if profile.Bio != nil && *profile.Bio != "" { completedFields++ } else { missing = append(missing, "bio") } // Check avatar if profile.AvatarURL != nil && *profile.AvatarURL != "" { completedFields++ } else { missing = append(missing, "avatar") } // Calculate percentage percentage := (completedFields * 100) / totalFields return &ProfileCompletion{ Percentage: percentage, Missing: missing, }, nil } // UpdateProfileByID updates a user profile by ID with the new request structure func (s *UserService) UpdateProfileByID(userID uuid.UUID, req *UpdateProfileRequest) (*models.User, error) { user, err := s.userRepo.GetByID(userID.String()) if err != nil { return nil, errors.New("user not found") } // Apply updates if req.FirstName != nil && *req.FirstName != "" { user.FirstName = *req.FirstName } if req.LastName != nil && *req.LastName != "" { user.LastName = *req.LastName } if req.Username != nil && *req.Username != "" { user.Username = *req.Username now := time.Now() user.UsernameChangedAt = &now } if req.Bio != nil { user.Bio = *req.Bio } if req.Location != nil { user.Location = *req.Location } if req.BirthDate != nil && *req.BirthDate != "" { birthdate, err := time.Parse("2006-01-02", *req.BirthDate) if err == nil { user.Birthdate = &birthdate } } if req.Gender != nil { user.Gender = *req.Gender } // Save changes err = s.userRepo.Update(user) if err != nil { return nil, err } return user, nil } // GetUserSettings récupère les paramètres utilisateur // T0231: Récupère user_settings depuis DB et user_profiles pour language, timezone, theme func (s *UserService) GetUserSettings(userID uuid.UUID) (*types.UserSettingsResponse, error) { if s.db == nil { return nil, fmt.Errorf("database access not available") } // Récupérer ou créer user_settings var settings models.UserSettings result := s.db.Where("user_id = ?", userID).First(&settings) if result.Error != nil { if result.Error == gorm.ErrRecordNotFound { // Créer settings par défaut settings = models.UserSettings{ UserID: userID, EmailNotifications: true, PushNotifications: true, BrowserNotifications: true, EmailOnFollow: true, EmailOnLike: true, EmailOnComment: true, EmailOnMessage: true, EmailOnMention: true, AllowSearchIndexing: true, ShowActivity: true, Autoplay: true, } if err := s.db.Create(&settings).Error; err != nil { return nil, fmt.Errorf("failed to create default settings: %w", err) } } else { return nil, fmt.Errorf("failed to get settings: %w", result.Error) } } // Récupérer user_profiles pour preferences (language, timezone, theme) // T0233: Récupérer depuis user_profiles avec création auto si n'existe pas var profile models.UserProfile result = s.db.Where("user_id = ?", userID).First(&profile) if result.Error != nil { if result.Error == gorm.ErrRecordNotFound { // Créer profile par défaut profile = models.UserProfile{ UserID: userID, Language: "en", Timezone: "UTC", Theme: "auto", } if err := s.db.Create(&profile).Error; err != nil { return nil, fmt.Errorf("failed to create default profile: %w", err) } } else { return nil, fmt.Errorf("failed to get profile: %w", result.Error) } } language := profile.Language timezone := profile.Timezone // theme := profile.Theme // Not used in PreferenceSettings (no Theme field) return &types.UserSettingsResponse{ Notifications: types.NotificationSettings{ Email: settings.EmailNotifications, Push: settings.PushNotifications, InApp: settings.BrowserNotifications, Comments: settings.EmailOnComment, Likes: settings.EmailOnLike, Followers: settings.EmailOnFollow, Mentions: settings.EmailOnMention, Playlist: false, // Not mapped from settings }, Privacy: types.PrivacySettings{ ProfileVisibility: "public", // Default, should be read from settings if available PlaylistsPublic: true, // Default, should be read from settings if available }, Content: types.ContentSettings{ ExplicitContent: settings.ExplicitContent, }, Preferences: types.PreferenceSettings{ Language: language, Timezone: timezone, DateFormat: "YYYY-MM-DD", // Default }, }, nil } // UpdateUserSettings met à jour les paramètres utilisateur // T0232: Mettre à jour user_settings et user_profiles en DB func (s *UserService) UpdateUserSettings(userID uuid.UUID, req *types.UpdateSettingsRequest) error { if s.db == nil { return fmt.Errorf("database access not available") } // Mettre à jour user_settings if req.Notifications != nil || req.Privacy != nil || req.Content != nil { updates := map[string]interface{}{} if req.Notifications != nil { updates["email_notifications"] = req.Notifications.Email updates["push_notifications"] = req.Notifications.Push updates["browser_notifications"] = req.Notifications.InApp updates["email_on_follow"] = req.Notifications.Followers updates["email_on_like"] = req.Notifications.Likes updates["email_on_comment"] = req.Notifications.Comments updates["email_on_mention"] = req.Notifications.Mentions // EmailOnMessage and EmailMarketing not mapped (no corresponding fields in NotificationSettings) } if req.Privacy != nil { // AllowSearchIndexing and ShowActivity not mapped (no corresponding fields in PrivacySettings) // PrivacySettings only has ProfileVisibility and PlaylistsPublic } if req.Content != nil { updates["explicit_content"] = req.Content.ExplicitContent // Autoplay not available in ContentSettings type } if len(updates) > 0 { // S'assurer que user_settings existe d'abord var settings models.UserSettings result := s.db.Where("user_id = ?", userID).First(&settings) if result.Error == gorm.ErrRecordNotFound { // Créer settings par défaut si n'existe pas settings = models.UserSettings{ UserID: userID, EmailNotifications: true, PushNotifications: true, BrowserNotifications: true, EmailOnFollow: true, EmailOnLike: true, EmailOnComment: true, EmailOnMessage: true, EmailOnMention: true, AllowSearchIndexing: true, ShowActivity: true, Autoplay: true, } if err := s.db.Create(&settings).Error; err != nil { return fmt.Errorf("failed to create default settings: %w", err) } } else if result.Error != nil { return fmt.Errorf("failed to get settings: %w", result.Error) } // Mettre à jour if err := s.db.Model(&models.UserSettings{}).Where("user_id = ?", userID).Updates(updates).Error; err != nil { return fmt.Errorf("failed to update settings: %w", err) } } } // Mettre à jour user_profiles (preferences) // T0233: Mettre à jour user_profiles avec création auto si n'existe pas if req.Preferences != nil { profileUpdates := map[string]interface{}{} if req.Preferences.Language != "" { profileUpdates["language"] = req.Preferences.Language } if req.Preferences.Timezone != "" { profileUpdates["timezone"] = req.Preferences.Timezone } // Theme not available in PreferenceSettings type (only Language, Timezone, DateFormat) if len(profileUpdates) > 0 { // S'assurer que user_profiles existe d'abord var profile models.UserProfile result := s.db.Where("user_id = ?", userID).First(&profile) if result.Error == gorm.ErrRecordNotFound { // Créer profile par défaut si n'existe pas profile = models.UserProfile{ UserID: userID, Language: "en", Timezone: "UTC", Theme: "auto", } // Appliquer les updates avant création if lang, ok := profileUpdates["language"].(string); ok { profile.Language = lang } if tz, ok := profileUpdates["timezone"].(string); ok { profile.Timezone = tz } if th, ok := profileUpdates["theme"].(string); ok { profile.Theme = th } if err := s.db.Create(&profile).Error; err != nil { return fmt.Errorf("failed to create default profile: %w", err) } } else if result.Error != nil { return fmt.Errorf("failed to get profile: %w", result.Error) } else { // Mettre à jour if err := s.db.Model(&models.UserProfile{}).Where("user_id = ?", userID).Updates(profileUpdates).Error; err != nil { return fmt.Errorf("failed to update profile: %w", err) } } } } return nil }