// veza-backend-api/internal/api/user/service.go package user import ( "database/sql" "encoding/json" "fmt" "strings" "time" "veza-backend-api/internal/database" "veza-backend-api/internal/utils" "github.com/google/uuid" ) // Service handles user business logic type Service struct { db *database.DB } // NewService creates a new user service func NewService(db *database.DB) *Service { return &Service{ db: db, } } // GetUsers retrieves users with pagination and optional search func (s *Service) GetUsers(page, limit int, search string) ([]UserResponse, int, error) { offset := (page - 1) * limit // Build the query with optional search baseQuery := ` SELECT id, email, first_name, last_name, username, avatar, bio, role, is_active, is_verified, last_login_at, created_at, updated_at FROM users ` countQuery := "SELECT COUNT(*) FROM users" var whereClause string var args []interface{} argIndex := 1 if search != "" { whereClause = ` WHERE ( email ILIKE $` + fmt.Sprintf("%d", argIndex) + ` OR first_name ILIKE $` + fmt.Sprintf("%d", argIndex) + ` OR last_name ILIKE $` + fmt.Sprintf("%d", argIndex) + ` OR username ILIKE $` + fmt.Sprintf("%d", argIndex) + ` )` args = append(args, "%"+search+"%") argIndex++ } // Get total count var total int err := s.db.QueryRow(countQuery+whereClause, args...).Scan(&total) if err != nil { return nil, 0, fmt.Errorf("failed to count users: %w", err) } // Get users orderClause := " ORDER BY created_at DESC" limitClause := fmt.Sprintf(" LIMIT $%d OFFSET $%d", argIndex, argIndex+1) args = append(args, limit, offset) query := baseQuery + whereClause + orderClause + limitClause rows, err := s.db.Query(query, args...) if err != nil { return nil, 0, fmt.Errorf("failed to query users: %w", err) } defer rows.Close() var users []UserResponse for rows.Next() { var user UserResponse err := rows.Scan( &user.ID, &user.Email, &user.FirstName, &user.LastName, &user.Username, &user.Avatar, &user.Bio, &user.Role, &user.IsActive, &user.IsVerified, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt, ) if err != nil { return nil, 0, fmt.Errorf("failed to scan user: %w", err) } users = append(users, user) } return users, total, nil } // GetUserByID retrieves a user by ID func (s *Service) GetUserByID(userID uuid.UUID) (*UserResponse, error) { query := ` SELECT id, email, first_name, last_name, username, avatar, bio, role, is_active, is_verified, last_login_at, created_at, updated_at FROM users WHERE id = $1 AND is_active = true ` var user UserResponse err := s.db.QueryRow(query, userID).Scan( &user.ID, &user.Email, &user.FirstName, &user.LastName, &user.Username, &user.Avatar, &user.Bio, &user.Role, &user.IsActive, &user.IsVerified, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt, ) if err != nil { if err == sql.ErrNoRows { return nil, fmt.Errorf("user not found") } return nil, fmt.Errorf("failed to get user: %w", err) } return &user, nil } // GetUserByEmail retrieves a user by email (includes password hash for auth) func (s *Service) GetUserByEmail(email string) (*User, error) { query := ` SELECT id, email, password_hash, first_name, last_name, username, avatar, bio, role, is_active, is_verified, last_login_at, created_at, updated_at FROM users WHERE email = $1 ` var user User err := s.db.QueryRow(query, email).Scan( &user.ID, &user.Email, &user.Password, &user.FirstName, &user.LastName, &user.Username, &user.Avatar, &user.Bio, &user.Role, &user.IsActive, &user.IsVerified, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt, ) if err != nil { if err == sql.ErrNoRows { return nil, fmt.Errorf("user not found") } return nil, fmt.Errorf("failed to get user: %w", err) } return &user, nil } // CreateUser creates a new user func (s *Service) CreateUser(req CreateUserRequest) (*UserResponse, error) { // Hash the password passwordHash, err := utils.HashPassword(req.Password) if err != nil { return nil, fmt.Errorf("failed to hash password: %w", err) } // Set default role if not provided role := req.Role if role == "" { role = "user" } query := ` INSERT INTO users (email, password_hash, first_name, last_name, username, role, is_active, is_verified, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, true, false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) RETURNING id, email, first_name, last_name, username, role, is_active, is_verified, created_at, updated_at ` var user UserResponse err = s.db.QueryRow( query, req.Email, passwordHash, req.FirstName, req.LastName, req.Username, role, ).Scan( &user.ID, &user.Email, &user.FirstName, &user.LastName, &user.Username, &user.Role, &user.IsActive, &user.IsVerified, &user.CreatedAt, &user.UpdatedAt, ) if err != nil { if strings.Contains(err.Error(), "unique") { return nil, fmt.Errorf("email already exists") } return nil, fmt.Errorf("failed to create user: %w", err) } return &user, nil } // UpdateUser updates an existing user. // SECURITY(REM-008): Privileged fields (role, is_active, is_verified) are ignored — // they can only be modified via admin endpoints. func (s *Service) UpdateUser(userID uuid.UUID, req UpdateUserRequest) (*UserResponse, error) { // Build dynamic update query setParts := []string{"updated_at = CURRENT_TIMESTAMP"} args := []interface{}{} argIndex := 1 if req.FirstName != nil { setParts = append(setParts, fmt.Sprintf("first_name = $%d", argIndex)) args = append(args, req.FirstName) argIndex++ } if req.LastName != nil { setParts = append(setParts, fmt.Sprintf("last_name = $%d", argIndex)) args = append(args, req.LastName) argIndex++ } if req.Username != nil { setParts = append(setParts, fmt.Sprintf("username = $%d", argIndex)) args = append(args, req.Username) argIndex++ } if req.Avatar != nil { setParts = append(setParts, fmt.Sprintf("avatar = $%d", argIndex)) args = append(args, req.Avatar) argIndex++ } if req.Bio != nil { setParts = append(setParts, fmt.Sprintf("bio = $%d", argIndex)) args = append(args, req.Bio) argIndex++ } // SECURITY(REM-008): Privileged fields blocked for non-admin callers. // IsActive, IsVerified, and Role are intentionally excluded from user self-update. // These fields can only be modified through admin endpoints with RequireRole("admin") middleware. // Add user ID as the last argument args = append(args, userID) query := fmt.Sprintf(` UPDATE users SET %s WHERE id = $%d RETURNING id, email, first_name, last_name, username, avatar, bio, role, is_active, is_verified, last_login_at, created_at, updated_at `, strings.Join(setParts, ", "), argIndex) var user UserResponse err := s.db.QueryRow(query, args...).Scan( &user.ID, &user.Email, &user.FirstName, &user.LastName, &user.Username, &user.Avatar, &user.Bio, &user.Role, &user.IsActive, &user.IsVerified, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt, ) if err != nil { if err == sql.ErrNoRows { return nil, fmt.Errorf("user not found") } return nil, fmt.Errorf("failed to update user: %w", err) } return &user, nil } // DeleteUser soft deletes a user (sets is_active to false) func (s *Service) DeleteUser(userID uuid.UUID) error { query := ` UPDATE users SET is_active = false, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND is_active = true ` result, err := s.db.Exec(query, userID) if err != nil { return fmt.Errorf("failed to delete user: %w", err) } rowsAffected, err := result.RowsAffected() if err != nil { return fmt.Errorf("failed to get rows affected: %w", err) } if rowsAffected == 0 { return fmt.Errorf("user not found") } return nil } // UpdateLastLogin updates the user's last login timestamp func (s *Service) UpdateLastLogin(userID uuid.UUID) error { query := ` UPDATE users SET last_login_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = $1 ` _, err := s.db.Exec(query, userID) if err != nil { return fmt.Errorf("failed to update last login: %w", err) } return nil } // ChangePassword updates a user's password func (s *Service) ChangePassword(userID uuid.UUID, currentPassword, newPassword string) error { // First, get the current password hash var currentHash string err := s.db.QueryRow("SELECT password_hash FROM users WHERE id = $1", userID).Scan(¤tHash) if err != nil { if err == sql.ErrNoRows { return fmt.Errorf("user not found") } return fmt.Errorf("failed to get user password: %w", err) } // Verify current password if err := utils.CheckPasswordHash(currentPassword, currentHash); err != nil { return fmt.Errorf("current password is incorrect") } // Hash new password newHash, err := utils.HashPassword(newPassword) if err != nil { return fmt.Errorf("failed to hash new password: %w", err) } // Update password query := ` UPDATE users SET password_hash = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 ` _, err = s.db.Exec(query, newHash, userID) if err != nil { return fmt.Errorf("failed to update password: %w", err) } return nil } // GetUserStats returns basic user statistics func (s *Service) GetUserStats() (map[string]interface{}, error) { stats := make(map[string]interface{}) // Total users var totalUsers int err := s.db.QueryRow("SELECT COUNT(*) FROM users WHERE is_active = true").Scan(&totalUsers) if err != nil { return nil, fmt.Errorf("failed to get total users: %w", err) } stats["total_users"] = totalUsers // Verified users var verifiedUsers int err = s.db.QueryRow("SELECT COUNT(*) FROM users WHERE is_active = true AND is_verified = true").Scan(&verifiedUsers) if err != nil { return nil, fmt.Errorf("failed to get verified users: %w", err) } stats["verified_users"] = verifiedUsers // Active users (logged in within last 30 days) var activeUsers int err = s.db.QueryRow(` SELECT COUNT(*) FROM users WHERE is_active = true AND last_login_at > CURRENT_TIMESTAMP - INTERVAL '30 days' `).Scan(&activeUsers) if err != nil { return nil, fmt.Errorf("failed to get active users: %w", err) } stats["active_users"] = activeUsers // New users this month var newUsersThisMonth int err = s.db.QueryRow(` SELECT COUNT(*) FROM users WHERE is_active = true AND created_at >= date_trunc('month', CURRENT_TIMESTAMP) `).Scan(&newUsersThisMonth) if err != nil { return nil, fmt.Errorf("failed to get new users this month: %w", err) } stats["new_users_this_month"] = newUsersThisMonth return stats, nil } // GetUserPreferences récupère les préférences d'un utilisateur func (s *Service) GetUserPreferences(userID uuid.UUID) (*UserPreferencesResponse, error) { query := ` SELECT user_id, theme, language, timezone, COALESCE(notifications, '{}') as notifications, COALESCE(privacy, '{}') as privacy, COALESCE(audio, '{}') as audio, COALESCE(contrast, 'normal') as contrast, COALESCE(density, 'comfortable') as density, COALESCE(accent_hue, 220) as accent_hue, COALESCE(font_size, 16) as font_size, updated_at FROM user_preferences WHERE user_id = $1 ` var preferences UserPreferencesResponse var notificationsJSON, privacyJSON, audioJSON string err := s.db.QueryRow(query, userID).Scan( &preferences.UserID, &preferences.Theme, &preferences.Language, &preferences.Timezone, ¬ificationsJSON, &privacyJSON, &audioJSON, &preferences.Contrast, &preferences.Density, &preferences.AccentHue, &preferences.FontSize, &preferences.UpdatedAt, ) if err != nil { if err == sql.ErrNoRows { // Retourner les préférences par défaut return &UserPreferencesResponse{ UserID: userID, Theme: "light", Language: "en", Timezone: "UTC", Notifications: NotificationSettings{ Email: true, Push: true, Desktop: true, NewFollowers: true, TrackComments: true, DirectMessages: true, Mentions: true, Likes: false, }, Privacy: PrivacySettings{ ShowEmail: false, ShowActivity: true, AllowDM: true, TrackVisibility: "public", ProfileVisibility: "public", }, Audio: AudioSettings{ AutoPlay: true, Quality: "high", Volume: 0.8, Crossfade: 5, }, Contrast: "normal", Density: "comfortable", AccentHue: 220, FontSize: 16, UpdatedAt: time.Now(), }, nil } return nil, fmt.Errorf("failed to get user preferences: %w", err) } // Parse JSON strings to structs if err := json.Unmarshal([]byte(notificationsJSON), &preferences.Notifications); err != nil { preferences.Notifications = NotificationSettings{ Email: true, Push: true, Desktop: true, NewFollowers: true, TrackComments: true, DirectMessages: true, Mentions: true, Likes: false, } } if err := json.Unmarshal([]byte(privacyJSON), &preferences.Privacy); err != nil { preferences.Privacy = PrivacySettings{ ShowEmail: false, ShowActivity: true, AllowDM: true, TrackVisibility: "public", ProfileVisibility: "public", } } if err := json.Unmarshal([]byte(audioJSON), &preferences.Audio); err != nil { preferences.Audio = AudioSettings{ AutoPlay: true, Quality: "high", Volume: 0.8, Crossfade: 5, } } return &preferences, nil } // UpdateUserPreferences met à jour les préférences d'un utilisateur func (s *Service) UpdateUserPreferences(userID uuid.UUID, req UserPreferencesRequest) (*UserPreferencesResponse, error) { // Récupérer les préférences actuelles current, err := s.GetUserPreferences(userID) if err != nil { return nil, err } // Appliquer les mises à jour if req.Theme != nil { current.Theme = *req.Theme } if req.Language != nil { current.Language = *req.Language } if req.Timezone != nil { current.Timezone = *req.Timezone } if req.Notifications != nil { current.Notifications = *req.Notifications } if req.Privacy != nil { current.Privacy = *req.Privacy } if req.Audio != nil { current.Audio = *req.Audio } if req.Contrast != nil { current.Contrast = *req.Contrast } if req.Density != nil { current.Density = *req.Density } if req.AccentHue != nil { current.AccentHue = *req.AccentHue } if req.FontSize != nil { current.FontSize = *req.FontSize } current.UpdatedAt = time.Now() // Serialize structs to JSON notificationsJSON, _ := json.Marshal(current.Notifications) privacyJSON, _ := json.Marshal(current.Privacy) audioJSON, _ := json.Marshal(current.Audio) // Sauvegarder en base (upsert) query := ` INSERT INTO user_preferences (user_id, theme, language, timezone, notifications, privacy, audio, contrast, density, accent_hue, font_size, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (user_id) DO UPDATE SET theme = EXCLUDED.theme, language = EXCLUDED.language, timezone = EXCLUDED.timezone, notifications = EXCLUDED.notifications, privacy = EXCLUDED.privacy, audio = EXCLUDED.audio, contrast = EXCLUDED.contrast, density = EXCLUDED.density, accent_hue = EXCLUDED.accent_hue, font_size = EXCLUDED.font_size, updated_at = EXCLUDED.updated_at ` _, err = s.db.Exec(query, userID, current.Theme, current.Language, current.Timezone, string(notificationsJSON), string(privacyJSON), string(audioJSON), current.Contrast, current.Density, current.AccentHue, current.FontSize, current.UpdatedAt) if err != nil { return nil, fmt.Errorf("failed to update user preferences: %w", err) } return current, nil } // DeleteAccount supprime le compte d'un utilisateur (soft delete) func (s *Service) DeleteAccount(userID uuid.UUID, password, reason string) error { // Vérifier le mot de passe var currentHash string err := s.db.QueryRow("SELECT password_hash FROM users WHERE id = $1", userID).Scan(¤tHash) if err != nil { if err == sql.ErrNoRows { return fmt.Errorf("user not found") } return fmt.Errorf("failed to get user password: %w", err) } if err := utils.CheckPasswordHash(password, currentHash); err != nil { return fmt.Errorf("invalid password") } // Marquer le compte comme supprimé avec période de grâce de 30 jours recoveryDeadline := time.Now().Add(30 * 24 * time.Hour) query := ` UPDATE users SET is_active = false, deleted_at = CURRENT_TIMESTAMP, deletion_reason = $2, recovery_deadline = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $1 ` _, err = s.db.Exec(query, userID, reason, recoveryDeadline) if err != nil { return fmt.Errorf("failed to delete account: %w", err) } return nil } // RecoverAccount récupère un compte supprimé func (s *Service) RecoverAccount(email, password string) error { // Vérifier l'utilisateur et son statut var userID uuid.UUID var currentHash string var deletedAt sql.NullTime var recoveryDeadline sql.NullTime query := ` SELECT id, password_hash, deleted_at, recovery_deadline FROM users WHERE email = $1 AND deleted_at IS NOT NULL ` err := s.db.QueryRow(query, email).Scan(&userID, ¤tHash, &deletedAt, &recoveryDeadline) if err != nil { if err == sql.ErrNoRows { return fmt.Errorf("no deleted account found for this email") } return fmt.Errorf("failed to find account: %w", err) } // Vérifier que la période de récupération n'est pas expirée if recoveryDeadline.Valid && time.Now().After(recoveryDeadline.Time) { return fmt.Errorf("recovery period has expired") } // Vérifier le mot de passe if err := utils.CheckPasswordHash(password, currentHash); err != nil { return fmt.Errorf("invalid password") } // Réactiver le compte updateQuery := ` UPDATE users SET is_active = true, deleted_at = NULL, deletion_reason = NULL, recovery_deadline = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = $1 ` _, err = s.db.Exec(updateQuery, userID) if err != nil { return fmt.Errorf("failed to recover account: %w", err) } return nil } // ExportUserData exporte toutes les données d'un utilisateur (RGPD) func (s *Service) ExportUserData(userID uuid.UUID) (*UserDataExport, error) { // Récupérer le profil profile, err := s.GetUserByID(userID) if err != nil { return nil, fmt.Errorf("failed to get user profile: %w", err) } // Récupérer les préférences preferences, err := s.GetUserPreferences(userID) if err != nil { return nil, fmt.Errorf("failed to get user preferences: %w", err) } // Récupérer l'activité (simplifié) activity := []UserActivity{ {ID: uuid.New(), Type: "login", Details: "User login", CreatedAt: time.Now()}, {ID: uuid.New(), Type: "profile_update", Details: "Profile updated", CreatedAt: time.Now()}, } // Récupérer le contenu (simplifié) content := []UserContent{ {ID: uuid.New(), Type: "track", Title: "Sample Track", URL: "/tracks/1", CreatedAt: time.Now()}, } // Récupérer les interactions (simplifié) interactions := []UserInteraction{ {ID: uuid.New(), Type: "like", TargetID: uuid.New(), CreatedAt: time.Now()}, } export := &UserDataExport{ UserID: userID, Profile: *profile, Preferences: *preferences, Activity: activity, Content: content, Interactions: interactions, ExportedAt: time.Now(), } return export, nil } // RequestDataDeletion demande la suppression définitive des données func (s *Service) RequestDataDeletion(userID uuid.UUID, password, reason string) error { // Vérifier le mot de passe var currentHash string err := s.db.QueryRow("SELECT password_hash FROM users WHERE id = $1", userID).Scan(¤tHash) if err != nil { if err == sql.ErrNoRows { return fmt.Errorf("user not found") } return fmt.Errorf("failed to get user password: %w", err) } if err := utils.CheckPasswordHash(password, currentHash); err != nil { return fmt.Errorf("invalid password") } // Créer une demande de suppression définitive query := ` INSERT INTO data_deletion_requests (user_id, reason, status, requested_at) VALUES ($1, $2, 'pending', CURRENT_TIMESTAMP) ` _, err = s.db.Exec(query, userID, reason) if err != nil { return fmt.Errorf("failed to create deletion request: %w", err) } return nil } // GetAccountStatus récupère le statut du compte func (s *Service) GetAccountStatus(userID uuid.UUID) (*AccountStatus, error) { query := ` SELECT id, is_active, is_verified, created_at, deleted_at, COALESCE(deletion_reason, '') as deletion_reason, recovery_deadline FROM users WHERE id = $1 ` var status AccountStatus var deletedAt sql.NullTime var recoveryDeadline sql.NullTime err := s.db.QueryRow(query, userID).Scan( &status.UserID, &status.IsActive, &status.IsVerified, &status.CreatedAt, &deletedAt, &status.DeletionReason, &recoveryDeadline, ) if err != nil { if err == sql.ErrNoRows { return nil, fmt.Errorf("user not found") } return nil, fmt.Errorf("failed to get account status: %w", err) } // Déterminer le statut if deletedAt.Valid { status.Status = "deleted" status.DeletedAt = &deletedAt.Time if recoveryDeadline.Valid { status.RecoveryDeadline = &recoveryDeadline.Time } } else if !status.IsActive { status.Status = "suspended" } else { status.Status = "active" } return &status, nil }