760 lines
27 KiB
Go
760 lines
27 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
apperrors "veza-backend-api/internal/errors"
|
|
"veza-backend-api/internal/services"
|
|
"veza-backend-api/internal/types"
|
|
"veza-backend-api/internal/utils"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// ProfileHandler handles profile-related operations
|
|
type ProfileHandler struct {
|
|
userService *services.UserService
|
|
commonHandler *CommonHandler
|
|
permissionService *services.PermissionService // MOD-P1-003: Added for admin check
|
|
socialService *services.SocialService // BE-API-017: Added for follow/unfollow functionality
|
|
notificationService *services.NotificationService // Phase 2.2: Optional, for follow notifications
|
|
logger *zap.Logger // BE-API-017: Added for logging
|
|
}
|
|
|
|
// NewProfileHandler creates a new ProfileHandler instance
|
|
func NewProfileHandler(userService *services.UserService, logger *zap.Logger) *ProfileHandler {
|
|
return &ProfileHandler{
|
|
userService: userService,
|
|
commonHandler: NewCommonHandler(logger),
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// SetSocialService sets the social service for follow/unfollow functionality
|
|
// BE-API-017: Implement user follow/unfollow endpoints
|
|
func (h *ProfileHandler) SetSocialService(socialService *services.SocialService) {
|
|
h.socialService = socialService
|
|
}
|
|
|
|
// SetNotificationService sets the notification service for follow notifications (Phase 2.2)
|
|
func (h *ProfileHandler) SetNotificationService(notificationService *services.NotificationService) {
|
|
h.notificationService = notificationService
|
|
}
|
|
|
|
// SetPermissionService définit le service de permissions (pour injection de dépendance)
|
|
// MOD-P1-003: Added for admin check in ownership verification
|
|
func (h *ProfileHandler) SetPermissionService(permissionService *services.PermissionService) {
|
|
h.permissionService = permissionService
|
|
}
|
|
|
|
// GetProfile retrieves a public user profile by ID
|
|
// @Summary Get Profile by ID
|
|
// @Description Get public profile information for a user
|
|
// @Tags User
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "User ID"
|
|
// @Success 200 {object} handlers.APIResponse{data=object{profile=object}}
|
|
// @Failure 400 {object} handlers.APIResponse "Invalid ID"
|
|
// @Failure 404 {object} handlers.APIResponse "User not found"
|
|
// @Router /users/{id} [get]
|
|
func (h *ProfileHandler) GetProfile(c *gin.Context) {
|
|
userIDStr := c.Param("id")
|
|
userID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "invalid user id"))
|
|
return
|
|
}
|
|
|
|
// Get the requesting user ID if authenticated (optional)
|
|
var requesterID *uuid.UUID
|
|
if reqID, exists := c.Get("user_id"); exists {
|
|
if reqUUID, ok := reqID.(uuid.UUID); ok {
|
|
requesterID = &reqUUID
|
|
}
|
|
}
|
|
|
|
// Get user profile with privacy check
|
|
profile, err := h.userService.GetProfile(userID, requesterID)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeNotFound, "user not found"))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, profile)
|
|
}
|
|
|
|
// GetProfileByUsername retrieves a public profile by username
|
|
// @Summary Get Profile by Username
|
|
// @Description Get public profile information for a user by username
|
|
// @Tags User
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param username path string true "Username"
|
|
// @Success 200 {object} handlers.APIResponse{data=object{profile=object}}
|
|
// @Failure 400 {object} handlers.APIResponse "Missing username"
|
|
// @Failure 404 {object} handlers.APIResponse "User not found"
|
|
// @Router /users/by-username/{username} [get]
|
|
func (h *ProfileHandler) GetProfileByUsername(c *gin.Context) {
|
|
username := c.Param("username")
|
|
// BE-SEC-009: Sanitize username parameter to prevent injection
|
|
username = utils.SanitizeUsername(username)
|
|
if username == "" {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "username required"))
|
|
return
|
|
}
|
|
|
|
// Get the requesting user ID if authenticated (optional)
|
|
var requesterID *uuid.UUID
|
|
if reqID, exists := c.Get("user_id"); exists {
|
|
if reqUUID, ok := reqID.(uuid.UUID); ok {
|
|
requesterID = &reqUUID
|
|
}
|
|
}
|
|
|
|
// Get profile with privacy check
|
|
profile, err := h.userService.GetProfileByUsername(username, requesterID)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeNotFound, "user not found"))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, profile)
|
|
}
|
|
|
|
// GetProfileCompletion retrieves the profile completion status
|
|
// GET /api/v1/users/:id/completion
|
|
// BE-API-023: Implement user completion endpoint validation
|
|
// T0220: Returns percentage and missing fields
|
|
// @Summary Get Profile Completion
|
|
// @Description Get profile completion percentage and missing fields
|
|
// @Tags User
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "User ID"
|
|
// @Success 200 {object} handlers.APIResponse{data=object}
|
|
// @Failure 400 {object} handlers.APIResponse "Invalid ID"
|
|
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
|
|
// @Failure 403 {object} handlers.APIResponse "Forbidden"
|
|
// @Router /users/{id}/completion [get]
|
|
func (h *ProfileHandler) GetProfileCompletion(c *gin.Context) {
|
|
userIDStr := c.Param("id")
|
|
userID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("invalid user id"))
|
|
return
|
|
}
|
|
|
|
// Get authenticated user ID
|
|
authenticatedUserID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return // Erreur déjà envoyée par GetUserIDUUID
|
|
}
|
|
|
|
// Verify that user_id corresponds to authenticated user
|
|
if userID != authenticatedUserID {
|
|
RespondWithAppError(c, apperrors.NewForbiddenError("cannot access other user's profile completion"))
|
|
return
|
|
}
|
|
|
|
// Calculate profile completion
|
|
completion, err := h.userService.CalculateProfileCompletion(userID)
|
|
if err != nil {
|
|
if err.Error() == "user not found" {
|
|
RespondWithAppError(c, apperrors.NewNotFoundError("user"))
|
|
return
|
|
}
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to calculate profile completion", err))
|
|
return
|
|
}
|
|
|
|
// Verify that percentage is between 0 and 100
|
|
if completion.Percentage < 0 || completion.Percentage > 100 {
|
|
h.logger.Warn("Invalid completion percentage calculated",
|
|
zap.Int("percentage", completion.Percentage),
|
|
zap.String("user_id", userID.String()))
|
|
// Clamp to valid range
|
|
if completion.Percentage < 0 {
|
|
completion.Percentage = 0
|
|
} else if completion.Percentage > 100 {
|
|
completion.Percentage = 100
|
|
}
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, completion)
|
|
}
|
|
|
|
// ListUsers gère la liste des utilisateurs avec pagination et filtrage
|
|
// BE-API-040: Implement user list endpoint
|
|
// @Summary List Users
|
|
// @Description Get a paginated list of users with optional filtering
|
|
// @Tags User
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param page query int false "Page number" default(1)
|
|
// @Param limit query int false "Items per page" default(20)
|
|
// @Param role query string false "Filter by role"
|
|
// @Param is_active query bool false "Filter by active status"
|
|
// @Param is_verified query bool false "Filter by verified status"
|
|
// @Param search query string false "Search by username, email, first_name, last_name"
|
|
// @Param sort_by query string false "Sort field (created_at, username, email, last_login_at)" default(created_at)
|
|
// @Param sort_order query string false "Sort order (asc, desc)" default(desc)
|
|
// @Success 200 {object} handlers.APIResponse{data=object{users=array,pagination=object}}
|
|
// @Failure 500 {object} handlers.APIResponse "Internal Error"
|
|
// @Router /users [get]
|
|
func (h *ProfileHandler) ListUsers(c *gin.Context) {
|
|
// Récupérer les paramètres de pagination
|
|
pageParam := c.DefaultQuery("page", "1")
|
|
limitParam := c.DefaultQuery("limit", "20")
|
|
|
|
// Parser les paramètres de pagination — return 400 if out of bounds (no silent normalization)
|
|
page, err := strconv.Atoi(pageParam)
|
|
if err != nil || page < 1 {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "pagination: page must be >= 1 and limit must be between 1 and 100"))
|
|
return
|
|
}
|
|
limit, err := strconv.Atoi(limitParam)
|
|
if err != nil || limit < 1 || limit > 100 {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "pagination: page must be >= 1 and limit must be between 1 and 100"))
|
|
return
|
|
}
|
|
|
|
// Récupérer les paramètres de filtrage
|
|
// BE-SEC-009: Sanitize search query to prevent injection
|
|
searchQuery := utils.SanitizeText(c.Query("search"), 100)
|
|
params := services.ListUsersParams{
|
|
Page: page,
|
|
Limit: limit,
|
|
Role: utils.SanitizeText(c.Query("role"), 50),
|
|
Search: searchQuery,
|
|
SortBy: utils.SanitizeText(c.DefaultQuery("sort_by", "created_at"), 50),
|
|
SortOrder: utils.SanitizeText(c.DefaultQuery("sort_order", "desc"), 10),
|
|
}
|
|
|
|
// Parser is_active si fourni
|
|
if isActiveStr := c.Query("is_active"); isActiveStr != "" {
|
|
if isActive, err := strconv.ParseBool(isActiveStr); err == nil {
|
|
params.IsActive = &isActive
|
|
}
|
|
}
|
|
|
|
// Parser is_verified si fourni
|
|
if isVerifiedStr := c.Query("is_verified"); isVerifiedStr != "" {
|
|
if isVerified, err := strconv.ParseBool(isVerifiedStr); err == nil {
|
|
params.IsVerified = &isVerified
|
|
}
|
|
}
|
|
|
|
// Lister les utilisateurs
|
|
users, total, err := h.userService.ListUsers(c.Request.Context(), params)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to list users", err))
|
|
return
|
|
}
|
|
|
|
// INT-007: Standardize pagination format
|
|
pagination := BuildPaginationData(page, limit, total)
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{
|
|
"users": users,
|
|
"pagination": pagination,
|
|
})
|
|
}
|
|
|
|
// SearchUsers gère la recherche d'utilisateurs
|
|
// BE-API-008: Implement user search endpoint
|
|
func (h *ProfileHandler) SearchUsers(c *gin.Context) {
|
|
// Récupérer les paramètres de recherche
|
|
query := c.Query("q")
|
|
pageParam := c.DefaultQuery("page", "1")
|
|
limitParam := c.DefaultQuery("limit", "20")
|
|
|
|
// Bounds checking: return 400 instead of silently normalizing (DoS prevention)
|
|
page, err := strconv.Atoi(pageParam)
|
|
if err != nil || page < 1 {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "pagination: page must be >= 1 and limit must be between 1 and 100"))
|
|
return
|
|
}
|
|
limit, err := strconv.Atoi(limitParam)
|
|
if err != nil || limit < 1 || limit > 100 {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "pagination: page must be >= 1 and limit must be between 1 and 100"))
|
|
return
|
|
}
|
|
|
|
// Rechercher les utilisateurs
|
|
users, total, err := h.userService.SearchUsers(c.Request.Context(), services.SearchUsersParams{
|
|
Query: query,
|
|
Page: page,
|
|
Limit: limit,
|
|
})
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to search users", err))
|
|
return
|
|
}
|
|
|
|
// INT-007: Standardize pagination format
|
|
pagination := BuildPaginationData(page, limit, total)
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{
|
|
"users": users,
|
|
"pagination": pagination,
|
|
})
|
|
}
|
|
|
|
// FollowUser gère le suivi d'un utilisateur
|
|
// POST /api/v1/users/:id/follow
|
|
// BE-API-017: Implement user follow/unfollow endpoints
|
|
func (h *ProfileHandler) FollowUser(c *gin.Context) {
|
|
// Récupérer l'ID de l'utilisateur à suivre depuis l'URL
|
|
userIDStr := c.Param("id")
|
|
userID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("invalid user id"))
|
|
return
|
|
}
|
|
|
|
// Récupérer l'ID de l'utilisateur authentifié
|
|
followerID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return // Erreur déjà envoyée par GetUserIDUUID
|
|
}
|
|
|
|
// Vérifier qu'on ne peut pas se suivre soi-même
|
|
if followerID == userID {
|
|
RespondWithAppError(c, apperrors.NewValidationError("cannot follow yourself"))
|
|
return
|
|
}
|
|
|
|
// Vérifier que l'utilisateur existe (on peut utiliser GetProfile qui vérifie l'existence)
|
|
// Pour simplifier, on laisse le service social gérer l'erreur si l'utilisateur n'existe pas
|
|
|
|
// Vérifier que le service social est initialisé
|
|
if h.socialService == nil {
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Social service not initialized", nil))
|
|
return
|
|
}
|
|
|
|
// Suivre l'utilisateur
|
|
err = h.socialService.FollowUser(c.Request.Context(), followerID, userID)
|
|
if err != nil {
|
|
h.logger.Error("failed to follow user",
|
|
zap.Error(err),
|
|
zap.String("follower_id", followerID.String()),
|
|
zap.String("followed_id", userID.String()))
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to follow user", err))
|
|
return
|
|
}
|
|
|
|
h.logger.Info("user followed",
|
|
zap.String("follower_id", followerID.String()),
|
|
zap.String("followed_id", userID.String()))
|
|
|
|
// Phase 2.2: Create notification for the followed user
|
|
if h.notificationService != nil {
|
|
link := "/users/" + followerID.String()
|
|
if err := h.notificationService.CreateNotification(userID, "follow", "New follower", "Someone started following you", link); err != nil {
|
|
h.logger.Warn("failed to create follow notification", zap.Error(err), zap.String("followed_id", userID.String()))
|
|
}
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{"message": "User followed successfully"})
|
|
}
|
|
|
|
// GetFollowSuggestions returns users to follow (v0.10.0 F211)
|
|
// GET /api/v1/users/suggestions?limit=10
|
|
func (h *ProfileHandler) GetFollowSuggestions(c *gin.Context) {
|
|
userID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if h.socialService == nil {
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Social service not initialized", nil))
|
|
return
|
|
}
|
|
limit := 10
|
|
if l := c.Query("limit"); l != "" {
|
|
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 20 {
|
|
limit = n
|
|
}
|
|
}
|
|
suggestions, err := h.socialService.GetFollowSuggestions(c.Request.Context(), userID, limit)
|
|
if err != nil {
|
|
h.logger.Error("failed to get follow suggestions", zap.Error(err), zap.String("user_id", userID.String()))
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get suggestions", err))
|
|
return
|
|
}
|
|
RespondSuccess(c, http.StatusOK, gin.H{"suggestions": suggestions})
|
|
}
|
|
|
|
// UnfollowUser gère l'arrêt du suivi d'un utilisateur
|
|
// DELETE /api/v1/users/:id/follow
|
|
// BE-API-017: Implement user follow/unfollow endpoints
|
|
func (h *ProfileHandler) UnfollowUser(c *gin.Context) {
|
|
// Récupérer l'ID de l'utilisateur à ne plus suivre depuis l'URL
|
|
userIDStr := c.Param("id")
|
|
userID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("invalid user id"))
|
|
return
|
|
}
|
|
|
|
// Récupérer l'ID de l'utilisateur authentifié
|
|
followerID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return // Erreur déjà envoyée par GetUserIDUUID
|
|
}
|
|
|
|
// Vérifier que le service social est initialisé
|
|
if h.socialService == nil {
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Social service not initialized", nil))
|
|
return
|
|
}
|
|
|
|
// Ne plus suivre l'utilisateur
|
|
err = h.socialService.UnfollowUser(c.Request.Context(), followerID, userID)
|
|
if err != nil {
|
|
h.logger.Error("failed to unfollow user",
|
|
zap.Error(err),
|
|
zap.String("follower_id", followerID.String()),
|
|
zap.String("followed_id", userID.String()))
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to unfollow user", err))
|
|
return
|
|
}
|
|
|
|
h.logger.Info("user unfollowed",
|
|
zap.String("follower_id", followerID.String()),
|
|
zap.String("followed_id", userID.String()))
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{"message": "User unfollowed successfully"})
|
|
}
|
|
|
|
// BlockUser gère le blocage d'un utilisateur
|
|
// POST /api/v1/users/:id/block
|
|
// BE-API-018: Implement user block/unblock endpoints
|
|
func (h *ProfileHandler) BlockUser(c *gin.Context) {
|
|
// Récupérer l'ID de l'utilisateur à bloquer depuis l'URL
|
|
userIDStr := c.Param("id")
|
|
userID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("invalid user id"))
|
|
return
|
|
}
|
|
|
|
// Récupérer l'ID de l'utilisateur authentifié
|
|
blockerID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return // Erreur déjà envoyée par GetUserIDUUID
|
|
}
|
|
|
|
// Vérifier qu'on ne peut pas se bloquer soi-même
|
|
if blockerID == userID {
|
|
RespondWithAppError(c, apperrors.NewValidationError("cannot block yourself"))
|
|
return
|
|
}
|
|
|
|
// Vérifier que le service social est initialisé
|
|
if h.socialService == nil {
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Social service not initialized", nil))
|
|
return
|
|
}
|
|
|
|
// Bloquer l'utilisateur
|
|
err = h.socialService.BlockUser(blockerID, userID)
|
|
if err != nil {
|
|
if err.Error() == "cannot block yourself" {
|
|
RespondWithAppError(c, apperrors.NewValidationError("cannot block yourself"))
|
|
return
|
|
}
|
|
h.logger.Error("failed to block user",
|
|
zap.Error(err),
|
|
zap.String("blocker_id", blockerID.String()),
|
|
zap.String("blocked_id", userID.String()))
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to block user", err))
|
|
return
|
|
}
|
|
|
|
h.logger.Info("user blocked",
|
|
zap.String("blocker_id", blockerID.String()),
|
|
zap.String("blocked_id", userID.String()))
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{"message": "User blocked successfully"})
|
|
}
|
|
|
|
// UnblockUser gère le déblocage d'un utilisateur
|
|
// DELETE /api/v1/users/:id/block
|
|
// BE-API-018: Implement user block/unblock endpoints
|
|
func (h *ProfileHandler) UnblockUser(c *gin.Context) {
|
|
// Récupérer l'ID de l'utilisateur à débloquer depuis l'URL
|
|
userIDStr := c.Param("id")
|
|
userID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("invalid user id"))
|
|
return
|
|
}
|
|
|
|
// Récupérer l'ID de l'utilisateur authentifié
|
|
blockerID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return // Erreur déjà envoyée par GetUserIDUUID
|
|
}
|
|
|
|
// Vérifier que le service social est initialisé
|
|
if h.socialService == nil {
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Social service not initialized", nil))
|
|
return
|
|
}
|
|
|
|
// Débloquer l'utilisateur
|
|
err = h.socialService.UnblockUser(blockerID, userID)
|
|
if err != nil {
|
|
h.logger.Error("failed to unblock user",
|
|
zap.Error(err),
|
|
zap.String("blocker_id", blockerID.String()),
|
|
zap.String("blocked_id", userID.String()))
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to unblock user", err))
|
|
return
|
|
}
|
|
|
|
h.logger.Info("user unblocked",
|
|
zap.String("blocker_id", blockerID.String()),
|
|
zap.String("blocked_id", userID.String()))
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{"message": "User unblocked successfully"})
|
|
}
|
|
|
|
// UpdateProfileRequest represents the request body for updating a user profile
|
|
type UpdateProfileRequest struct {
|
|
FirstName string `json:"first_name" binding:"omitempty,max=100" validate:"omitempty,max=100"`
|
|
LastName string `json:"last_name" binding:"omitempty,max=100" validate:"omitempty,max=100"`
|
|
Username string `json:"username" binding:"omitempty,min=3,max=30" validate:"omitempty,min=3,max=30,username"`
|
|
Bio string `json:"bio" binding:"omitempty,max=500" validate:"omitempty,max=500"`
|
|
Location string `json:"location" binding:"omitempty,max=100" validate:"omitempty,max=100"`
|
|
Birthdate string `json:"birthdate" binding:"omitempty,datetime=2006-01-02" validate:"omitempty,datetime=2006-01-02"`
|
|
Gender string `json:"gender" binding:"omitempty,oneof=Male Female Other 'Prefer not to say'" validate:"omitempty,oneof=Male Female Other 'Prefer not to say'"`
|
|
SocialLinks map[string]interface{} `json:"social_links" binding:"omitempty"`
|
|
BannerURL string `json:"banner_url" binding:"omitempty,max=2048"`
|
|
IsPublic *bool `json:"is_public" binding:"omitempty"`
|
|
}
|
|
|
|
// UpdateProfile updates a user profile
|
|
// @Summary Update Profile
|
|
// @Description Update user profile details
|
|
// @Tags User
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Param id path string true "User ID"
|
|
// @Param profile body UpdateProfileRequest true "Profile Data"
|
|
// @Success 200 {object} handlers.APIResponse{data=object{profile=object}}
|
|
// @Failure 400 {object} handlers.APIResponse "Validation Error"
|
|
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
|
|
// @Failure 403 {object} handlers.APIResponse "Forbidden"
|
|
// @Router /users/{id} [put]
|
|
func (h *ProfileHandler) UpdateProfile(c *gin.Context) {
|
|
userIDStr := c.Param("id")
|
|
userID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "invalid user id"))
|
|
return
|
|
}
|
|
|
|
// Get authenticated user ID
|
|
var authenticatedUserID uuid.UUID
|
|
if reqID, exists := c.Get("user_id"); exists {
|
|
if reqUUID, ok := reqID.(uuid.UUID); ok {
|
|
authenticatedUserID = reqUUID
|
|
} else {
|
|
RespondWithAppError(c, apperrors.NewUnauthorizedError("user not authenticated"))
|
|
return
|
|
}
|
|
} else {
|
|
RespondWithAppError(c, apperrors.NewUnauthorizedError("user not authenticated"))
|
|
return
|
|
}
|
|
|
|
// MOD-P1-003: Verify that user_id corresponds to authenticated user or user is admin
|
|
isAdmin := false
|
|
if h.permissionService != nil {
|
|
hasRole, err := h.permissionService.HasRole(c.Request.Context(), authenticatedUserID, "admin")
|
|
if err == nil && hasRole {
|
|
isAdmin = true
|
|
}
|
|
}
|
|
|
|
if userID != authenticatedUserID && !isAdmin {
|
|
RespondWithAppError(c, apperrors.NewForbiddenError("cannot update other user's profile"))
|
|
return
|
|
}
|
|
|
|
var req UpdateProfileRequest
|
|
if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
|
|
RespondWithAppError(c, appErr)
|
|
return
|
|
}
|
|
|
|
// BE-SEC-009: Sanitize user inputs to prevent XSS and injection attacks
|
|
if req.Username != "" {
|
|
req.Username = utils.SanitizeUsername(req.Username)
|
|
}
|
|
if req.Bio != "" {
|
|
req.Bio = utils.SanitizeText(req.Bio, 500)
|
|
}
|
|
if req.FirstName != "" {
|
|
req.FirstName = utils.SanitizeText(req.FirstName, 100)
|
|
}
|
|
if req.LastName != "" {
|
|
req.LastName = utils.SanitizeText(req.LastName, 100)
|
|
}
|
|
|
|
// Validate username if provided
|
|
if req.Username != "" {
|
|
// Validate username format (alphanumeric + underscore, 3-30 chars)
|
|
if !isValidUsername(req.Username) {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "username must be 3-30 characters, alphanumeric and underscore only"))
|
|
return
|
|
}
|
|
|
|
// Validate username uniqueness if modified
|
|
if err := h.userService.ValidateUsername(userID, req.Username); err != nil {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, err.Error()))
|
|
return
|
|
}
|
|
|
|
// Check if username can be modified (once per month)
|
|
canChange, err := h.userService.CanChangeUsername(userID)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "failed to check username change eligibility"))
|
|
return
|
|
}
|
|
if !canChange {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "username can only be changed once per month"))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Validate birthdate if provided
|
|
if req.Birthdate != "" {
|
|
birthdate, err := time.Parse("2006-01-02", req.Birthdate)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "invalid birthdate format, expected YYYY-MM-DD"))
|
|
return
|
|
}
|
|
|
|
// Check if user is at least 13 years old
|
|
age := time.Since(birthdate)
|
|
minAge := 13 * 365 * 24 * time.Hour // 13 years
|
|
if age < minAge {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "user must be at least 13 years old"))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Convert UpdateProfileRequest to types.UpdateProfileRequest
|
|
serviceReq := types.UpdateProfileRequest{
|
|
FirstName: &req.FirstName,
|
|
LastName: &req.LastName,
|
|
Username: &req.Username,
|
|
Bio: &req.Bio,
|
|
Location: &req.Location,
|
|
Gender: &req.Gender,
|
|
SocialLinks: req.SocialLinks,
|
|
}
|
|
|
|
if req.BannerURL != "" {
|
|
serviceReq.BannerURL = &req.BannerURL
|
|
}
|
|
|
|
if req.Birthdate != "" {
|
|
birthdate, _ := time.Parse("2006-01-02", req.Birthdate)
|
|
birthdateStr := birthdate.Format("2006-01-02")
|
|
serviceReq.BirthDate = &birthdateStr
|
|
}
|
|
if req.IsPublic != nil {
|
|
serviceReq.IsPublic = req.IsPublic
|
|
}
|
|
|
|
// Update profile using the new UpdateProfile method
|
|
profile, err := h.userService.UpdateProfile(userID, serviceReq)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "failed to update profile"))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, profile)
|
|
}
|
|
|
|
// isValidUsername validates username format (alphanumeric + underscore, 3-30 chars)
|
|
func isValidUsername(username string) bool {
|
|
if len(username) < 3 || len(username) > 30 {
|
|
return false
|
|
}
|
|
|
|
for _, char := range username {
|
|
if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_') {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// DeleteUser gère la suppression d'un utilisateur (soft delete)
|
|
// BE-API-041: DELETE /api/v1/users/:id with soft delete support
|
|
// @Summary Delete user
|
|
// @Description Soft delete a user (only user owner or admin can delete)
|
|
// @Tags User
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Param id path string true "User ID"
|
|
// @Success 200 {object} handlers.APIResponse "User deleted successfully"
|
|
// @Failure 400 {object} handlers.APIResponse "Invalid ID"
|
|
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
|
|
// @Failure 403 {object} handlers.APIResponse "Forbidden - Not user owner or admin"
|
|
// @Failure 404 {object} handlers.APIResponse "User not found"
|
|
// @Router /users/{id} [delete]
|
|
func (h *ProfileHandler) DeleteUser(c *gin.Context) {
|
|
userIDStr := c.Param("id")
|
|
userID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "invalid user id"))
|
|
return
|
|
}
|
|
|
|
// Get the requesting user ID
|
|
requesterID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return // Erreur déjà envoyée par GetUserIDUUID
|
|
}
|
|
|
|
// Check if requester is the user owner or admin
|
|
if requesterID != userID {
|
|
// Check if requester is admin
|
|
if h.permissionService != nil {
|
|
isAdmin, err := h.permissionService.HasRole(c.Request.Context(), requesterID, "admin")
|
|
if err != nil || !isAdmin {
|
|
RespondWithAppError(c, apperrors.NewForbiddenError("only user owner or admin can delete user"))
|
|
return
|
|
}
|
|
} else {
|
|
RespondWithAppError(c, apperrors.NewForbiddenError("only user owner or admin can delete user"))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Delete user (soft delete)
|
|
if err := h.userService.DeleteUser(c.Request.Context(), userID); err != nil {
|
|
if err.Error() == "user not found" {
|
|
RespondWithAppError(c, apperrors.NewNotFoundError("user"))
|
|
return
|
|
}
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to delete user", err))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{"message": "user deleted successfully"})
|
|
}
|