veza/veza-backend-api/internal/api/user/service.go
2026-03-05 23:03:43 +01:00

745 lines
21 KiB
Go

// 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
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++
}
if req.IsActive != nil {
setParts = append(setParts, fmt.Sprintf("is_active = $%d", argIndex))
args = append(args, req.IsActive)
argIndex++
}
if req.IsVerified != nil {
setParts = append(setParts, fmt.Sprintf("is_verified = $%d", argIndex))
args = append(args, req.IsVerified)
argIndex++
}
if req.Role != nil {
setParts = append(setParts, fmt.Sprintf("role = $%d", argIndex))
args = append(args, req.Role)
argIndex++
}
// 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(&currentHash)
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, &notificationsJSON, &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(&currentHash)
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, &currentHash, &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(&currentHash)
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
}