- Fixed nested response structures in profile_handler.go (3 occurrences)
- Fixed nested response structures in playlist_handler.go (4 occurrences)
- Changed gin.H{"profile": profile} to profile directly
- Changed gin.H{"playlist": playlist} to playlist directly
- Changed gin.H{"collaborator": collaborator} to collaborator directly
- All responses now use consistent { success: true, data: {...} } format
- Frontend interceptor already handles unwrapping correctly
Phase: PHASE-1
Priority: P0
Progress: 6/267 (2.2%)
310 lines
11 KiB
Go
310 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"time"
|
|
|
|
apperrors "veza-backend-api/internal/errors"
|
|
"veza-backend-api/internal/services"
|
|
"veza-backend-api/internal/types"
|
|
|
|
"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
|
|
}
|
|
|
|
// NewProfileHandler creates a new ProfileHandler instance
|
|
func NewProfileHandler(userService *services.UserService, logger *zap.Logger) *ProfileHandler {
|
|
return &ProfileHandler{
|
|
userService: userService,
|
|
commonHandler: NewCommonHandler(logger),
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
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
|
|
// 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.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
|
|
}
|
|
|
|
// 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 {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "failed to calculate profile completion"))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, completion)
|
|
}
|
|
|
|
// 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'"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
|
|
if req.Birthdate != "" {
|
|
birthdate, _ := time.Parse("2006-01-02", req.Birthdate)
|
|
birthdateStr := birthdate.Format("2006-01-02")
|
|
serviceReq.BirthDate = &birthdateStr
|
|
}
|
|
|
|
// 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
|
|
}
|