diff --git a/VEZA_COMPLETE_MVP_TODOLIST.json b/VEZA_COMPLETE_MVP_TODOLIST.json index eadf45e01..2c9ffb2a5 100644 --- a/VEZA_COMPLETE_MVP_TODOLIST.json +++ b/VEZA_COMPLETE_MVP_TODOLIST.json @@ -2042,7 +2042,18 @@ "description": "POST /api/v1/users/:userId/avatar for avatar image upload", "owner": "backend", "estimated_hours": 3, - "status": "todo", + "status": "completed", + "completion": { + "completed_at": "2025-12-23T10:01:30Z", + "actual_hours": 1.0, + "commits": [], + "files_changed": [ + "veza-backend-api/internal/handlers/avatar_handler.go", + "veza-backend-api/internal/api/router.go" + ], + "notes": "Standardized UploadAvatar handler to use RespondSuccess and RespondWithAppError. Replaced common.GetUserIDFromContext with GetUserIDUUID. Handler accepts both :userId and :id parameters. Added route: POST /users/:userId/avatar (protected). Handler validates user authentication and ownership. Handler uses existing ImageService.ProcessAvatar and ImageService.UploadToS3 methods. Handler updates avatar URL in database via UserService.UpdateAvatarURL. Handler uses standard API response format.", + "issues_encountered": [] + }, "files_involved": [], "implementation_steps": [ { diff --git a/veza-backend-api/internal/api/router.go b/veza-backend-api/internal/api/router.go index 2786cb131..e5544e086 100644 --- a/veza-backend-api/internal/api/router.go +++ b/veza-backend-api/internal/api/router.go @@ -403,6 +403,15 @@ func (r *APIRouter) setupUserRoutes(router *gin.RouterGroup) { roleHandler := handlers.NewRoleHandler(roleService, r.logger) protected.POST("/:userId/roles", roleHandler.AssignRole) // POST /api/v1/users/:userId/roles protected.DELETE("/:userId/roles/:roleId", roleHandler.RevokeRole) // DELETE /api/v1/users/:userId/roles/:roleId + + // BE-API-021: Avatar upload endpoint + avatarUploadDir := r.config.UploadDir + if avatarUploadDir == "" { + avatarUploadDir = "uploads/avatars" + } + imageService := services.NewImageService(avatarUploadDir) + avatarHandler := handlers.NewAvatarHandler(imageService, userService) + protected.POST("/:userId/avatar", avatarHandler.UploadAvatar) // BE-API-021: Upload avatar endpoint } } } diff --git a/veza-backend-api/internal/handlers/avatar_handler.go b/veza-backend-api/internal/handlers/avatar_handler.go index ebdfb7f36..d4ed71d53 100644 --- a/veza-backend-api/internal/handlers/avatar_handler.go +++ b/veza-backend-api/internal/handlers/avatar_handler.go @@ -1,10 +1,12 @@ package handlers import ( + "net/http" + "github.com/gin-gonic/gin" "github.com/google/uuid" - "net/http" - "veza-backend-api/internal/common" + + apperrors "veza-backend-api/internal/errors" "veza-backend-api/internal/services" ) @@ -23,53 +25,60 @@ func NewAvatarHandler(imageService *services.ImageService, userService *services } // UploadAvatar handles avatar upload +// POST /api/v1/users/:userId/avatar +// BE-API-021: Implement avatar upload endpoint // T0221: Validates user_id, file format/size, processes image, uploads to S3, and updates DB func (h *AvatarHandler) UploadAvatar(c *gin.Context) { - userIDStr := c.Param("id") + // Récupérer l'ID utilisateur depuis l'URL (peut être "id" ou "userId") + userIDStr := c.Param("userId") + if userIDStr == "" { + userIDStr = c.Param("id") + } userID, err := uuid.Parse(userIDStr) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"}) + RespondWithAppError(c, apperrors.NewValidationError("invalid user id")) return } - // Check that user_id corresponds to authenticated user - authenticatedUserID, exists := common.GetUserIDFromContext(c) - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) - return + // Vérifier que l'utilisateur est authentifié + authenticatedUserID, ok := GetUserIDUUID(c) + if !ok { + return // Erreur déjà envoyée par GetUserIDUUID } + // Vérifier que l'utilisateur ne peut modifier que son propre avatar if userID != authenticatedUserID { - c.JSON(http.StatusForbidden, gin.H{"error": "cannot update other user's avatar"}) + RespondWithAppError(c, apperrors.NewForbiddenError("cannot update other user's avatar")) return } + // Récupérer le fichier fileHeader, err := c.FormFile("avatar") if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "no file provided"}) + RespondWithAppError(c, apperrors.NewValidationError("no file provided")) return } - // Validate and process image + // Valider et traiter l'image resizedImage, err := h.imageService.ProcessAvatar(fileHeader) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + RespondWithAppError(c, apperrors.NewValidationError(err.Error())) return } - // Generate S3 key + // Générer la clé S3 s3Key := h.imageService.GenerateS3Key(userID) - // Upload to S3 (or local storage for now) + // Upload vers S3 (ou stockage local pour l'instant) avatarURL, err := h.imageService.UploadToS3(resizedImage, s3Key) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to upload avatar"}) + RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to upload avatar", err)) return } - // Update avatar_url in DB + // Mettre à jour l'URL de l'avatar dans la DB if err := h.userService.UpdateAvatarURL(userID, avatarURL); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update avatar"}) + RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to update avatar", err)) return } @@ -77,46 +86,52 @@ func (h *AvatarHandler) UploadAvatar(c *gin.Context) { } // DeleteAvatar handles avatar deletion +// DELETE /api/v1/users/:userId/avatar +// BE-API-022: Implement avatar delete endpoint (will be implemented in next task) // T0222: Validates user_id, deletes file from S3, and sets avatar_url to NULL in DB func (h *AvatarHandler) DeleteAvatar(c *gin.Context) { - userIDStr := c.Param("id") + // Récupérer l'ID utilisateur depuis l'URL (peut être "id" ou "userId") + userIDStr := c.Param("userId") + if userIDStr == "" { + userIDStr = c.Param("id") + } userID, err := uuid.Parse(userIDStr) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"}) + RespondWithAppError(c, apperrors.NewValidationError("invalid user id")) return } - // Check that user_id corresponds to authenticated user - authenticatedUserID, exists := common.GetUserIDFromContext(c) - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) - return + // Vérifier que l'utilisateur est authentifié + authenticatedUserID, ok := GetUserIDUUID(c) + if !ok { + return // Erreur déjà envoyée par GetUserIDUUID } + // Vérifier que l'utilisateur ne peut supprimer que son propre avatar if userID != authenticatedUserID { - c.JSON(http.StatusForbidden, gin.H{"error": "cannot delete other user's avatar"}) + RespondWithAppError(c, apperrors.NewForbiddenError("cannot delete other user's avatar")) return } - // Get current avatar_url from DB + // Récupérer l'utilisateur actuel pour obtenir l'URL de l'avatar user, err := h.userService.GetByID(userID) if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + RespondWithAppError(c, apperrors.NewNotFoundError("user")) return } - // Delete file from S3 (or local storage) if exists + // Supprimer le fichier de S3 (ou stockage local) s'il existe if user.Avatar != "" { if err := h.imageService.DeleteFromS3(user.Avatar); err != nil { - // Log error but continue (file may already be deleted) - // In production, you might want to use a logger here + // Logger l'erreur mais continuer (le fichier peut déjà être supprimé) + // En production, vous pourriez vouloir utiliser un logger ici _ = err } } - // Set avatar_url to empty string (NULL in DB) + // Mettre l'URL de l'avatar à une chaîne vide (NULL dans la DB) if err := h.userService.UpdateAvatarURL(userID, ""); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete avatar"}) + RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to delete avatar", err)) return }