veza/veza-backend-api/internal/services/user_service.go

807 lines
24 KiB
Go

package services
import (
"context"
"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
cacheService *CacheService // BE-SVC-001: Cache service for user profiles
}
// 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,
}
}
// SetCacheService définit le service de cache pour UserService
// BE-SVC-001: Implement caching layer for frequently accessed data
func (s *UserService) SetCacheService(cacheService *CacheService) {
s.cacheService = cacheService
}
// 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
// BE-SVC-001: Add caching for user profiles
func (s *UserService) GetProfile(userID uuid.UUID, requesterID *uuid.UUID) (*Profile, error) {
ctx := context.Background()
cacheConfig := DefaultCacheConfig()
// Try to get from cache first
if s.cacheService != nil {
var cachedProfile Profile
if err := s.cacheService.GetUser(ctx, userID, &cachedProfile); err == nil {
// Cache hit - but we still need to check privacy settings
// For now, return cached profile (privacy check would need to be cached too)
return &cachedProfile, nil
}
}
// Cache miss - fetch from database
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
}
// Cache the profile
if s.cacheService != nil {
if err := s.cacheService.SetUser(ctx, userID, profile, cacheConfig); err != nil {
// Log error but don't fail the request
// Could add logger here if available
}
}
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
// BE-SVC-001: Add caching for user profiles
func (s *UserService) GetProfileByUsername(username string, requesterID *uuid.UUID) (*Profile, error) {
// Get user first to get userID for cache
user, err := s.userRepo.GetByUsername(username)
if err != nil {
return nil, fmt.Errorf("user not found")
}
// Use GetProfile which handles caching
return s.GetProfile(user.ID, requesterID)
}
// UpdateProfile updates a user profile and returns the updated profile
// BE-SVC-001: Invalidate cache on profile update
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")
}
// Invalidate cache before update
if s.cacheService != nil {
ctx := context.Background()
if err := s.cacheService.InvalidateUserCache(ctx, userID); err != nil {
// Log error but don't fail the request
}
}
// 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
}
// DeleteUser soft deletes a user
// BE-API-041: Implement user delete endpoint with soft delete support
func (s *UserService) DeleteUser(ctx context.Context, userID uuid.UUID) error {
// Check if user exists
_, err := s.userRepo.GetByID(userID.String())
if err != nil {
return fmt.Errorf("user not found")
}
// Use repository Delete method (soft delete via GORM)
if err := s.userRepo.Delete(userID.String()); err != nil {
return fmt.Errorf("failed to delete user: %w", err)
}
// Also set is_active to false for consistency
if s.db != nil {
if err := s.db.WithContext(ctx).Model(&models.User{}).
Where("id = ?", userID).
Update("is_active", false).Error; err != nil {
// Log but don't fail if this update fails
return fmt.Errorf("failed to deactivate user: %w", err)
}
}
return nil
}