package handlers import ( "context" "errors" "net/http" "strconv" apperrors "veza-backend-api/internal/errors" "veza-backend-api/internal/models" "veza-backend-api/internal/services" "veza-backend-api/internal/utils" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" ) // CommentServiceInterface defines the interface for comment operations // This allows for easier testing with mocks type CommentServiceInterface interface { CreateComment(ctx context.Context, trackID uuid.UUID, userID uuid.UUID, content string, timestamp float64, parentID *uuid.UUID) (*models.TrackComment, error) GetComments(ctx context.Context, trackID uuid.UUID, page, limit int) ([]models.TrackComment, int64, error) UpdateComment(ctx context.Context, commentID uuid.UUID, userID uuid.UUID, content string) (*models.TrackComment, error) DeleteComment(ctx context.Context, commentID uuid.UUID, userID uuid.UUID, isAdmin bool) error GetReplies(ctx context.Context, parentID uuid.UUID, page, limit int) ([]models.TrackComment, int64, error) GetTrackCreatorID(ctx context.Context, trackID uuid.UUID) (uuid.UUID, error) } // CommentHandler gère les opérations sur les commentaires de tracks type CommentHandler struct { commentService CommentServiceInterface notificationService *services.NotificationService // Phase 2.2: Optional, for comment notifications commonHandler *CommonHandler } // NewCommentHandler crée un nouveau handler de commentaires func NewCommentHandler(commentService *services.CommentService, logger *zap.Logger) *CommentHandler { return &CommentHandler{ commentService: commentService, commonHandler: NewCommonHandler(logger), } } // NewCommentHandlerWithInterface crée un nouveau handler avec une interface (pour les tests) func NewCommentHandlerWithInterface(commentService CommentServiceInterface, logger *zap.Logger) *CommentHandler { return &CommentHandler{ commentService: commentService, commonHandler: NewCommonHandler(logger), } } // SetNotificationService sets the notification service for comment notifications (Phase 2.2) func (h *CommentHandler) SetNotificationService(notificationService *services.NotificationService) { h.notificationService = notificationService } // CreateCommentRequest représente la requête pour créer un commentaire // MOD-P1-001: Ajout tags validate pour validation systématique // v0.10.3 F201: Timestamp (position in track in seconds) for seekable comments type CreateCommentRequest struct { Content string `json:"content" binding:"required,min=1,max=5000" validate:"required,min=1,max=5000"` ParentID *uuid.UUID `json:"parent_id,omitempty" validate:"omitempty"` Timestamp *float64 `json:"timestamp,omitempty"` // Position in seconds (0 = top-level, no specific time) } // UpdateCommentRequest représente la requête pour mettre à jour un commentaire // MOD-P1-001: Ajout tags validate pour validation systématique type UpdateCommentRequest struct { Content string `json:"content" binding:"required,min=1,max=5000" validate:"required,min=1,max=5000"` } // CreateComment gère la création d'un commentaire sur un track // @Summary Create comment // @Description Create a new comment on a track. Can be a top-level comment or a reply to another comment (using parent_id). // @Tags Comment // @Accept json // @Produce json // @Security BearerAuth // @Param id path string true "Track ID (UUID)" // @Param comment body handlers.CreateCommentRequest true "Comment data" // @Success 201 {object} handlers.APIResponse{data=object{comment=object}} // @Failure 400 {object} handlers.APIResponse "Validation error" // @Failure 401 {object} handlers.APIResponse "Unauthorized" // @Failure 404 {object} handlers.APIResponse "Track not found" // @Failure 500 {object} handlers.APIResponse "Internal server error" // @Router /tracks/{id}/comments [post] func (h *CommentHandler) CreateComment(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return // Erreur déjà envoyée par GetUserIDUUID } if userID == uuid.Nil { // Action 1.3.2.1: Use wrapped format helper for errors RespondWithError(c, int(apperrors.ErrCodeUnauthorized), "unauthorized") return } trackIDStr := c.Param("id") if trackIDStr == "" { // Action 1.3.2.1: Use wrapped format helper for errors RespondWithError(c, int(apperrors.ErrCodeValidation), "track id is required") return } trackID, err := uuid.Parse(trackIDStr) // Changed to uuid.Parse if err != nil { // Action 1.3.2.1: Use wrapped format helper for errors RespondWithError(c, int(apperrors.ErrCodeValidation), "invalid track id") return } var req CreateCommentRequest 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 req.Content = utils.SanitizeText(req.Content, 5000) timestamp := 0.0 if req.Timestamp != nil && *req.Timestamp >= 0 { timestamp = *req.Timestamp } comment, err := h.commentService.CreateComment(c.Request.Context(), trackID, userID, req.Content, timestamp, req.ParentID) if err != nil { if errors.Is(err, services.ErrModerationRejected) { RespondWithAppError(c, apperrors.NewValidationError("content does not comply with moderation rules")) return } if errors.Is(err, services.ErrTrackNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("track")) return } if errors.Is(err, services.ErrParentCommentNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("parent comment")) return } if errors.Is(err, services.ErrParentTrackMismatch) { RespondWithAppError(c, apperrors.NewValidationError("parent comment does not belong to the same track")) return } h.commonHandler.logger.Error("failed to create comment", zap.Error(err)) RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to create comment", err)) return } // Phase 2.2: Create notification for track creator (skip if user comments on own track). F554: grouping by track if h.notificationService != nil { creatorID, err := h.commentService.GetTrackCreatorID(c.Request.Context(), trackID) if err == nil && creatorID != userID { link := "/tracks/" + trackID.String() if err := h.notificationService.CreateNotificationWithGroup(creatorID, "comment", "New comment", "Someone commented on your track", link, "comment:track:"+trackID.String(), userID); err != nil { h.commonHandler.logger.Warn("failed to create comment notification", zap.Error(err)) } } } RespondSuccess(c, http.StatusCreated, comment) } // GetComments gère la récupération des commentaires d'un track // @Summary Get track comments // @Description Get paginated list of comments for a track // @Tags Comment // @Accept json // @Produce json // @Param id path string true "Track ID" // @Param page query int false "Page number" default(1) // @Param limit query int false "Items per page" default(20) // @Success 200 {object} handlers.APIResponse{data=object{comments=array,pagination=object}} // @Failure 400 {object} handlers.APIResponse "Validation error" // @Failure 404 {object} handlers.APIResponse "Track not found" // @Failure 500 {object} handlers.APIResponse "Internal server error" // @Router /tracks/{id}/comments [get] func (h *CommentHandler) GetComments(c *gin.Context) { trackIDStr := c.Param("id") if trackIDStr == "" { RespondWithAppError(c, apperrors.NewValidationError("track id is required")) return } trackID, err := uuid.Parse(trackIDStr) // Changed to uuid.Parse if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid track id")) return } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) limit = clampLimit(limit) // SECURITY(MEDIUM-004) if page < 1 { page = 1 } if limit < 1 { limit = 20 } if limit > 100 { limit = 100 } comments, total, err := h.commentService.GetComments(c.Request.Context(), trackID, page, limit) if err != nil { h.commonHandler.logger.Error("failed to get comments", zap.Error(err)) RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get comments", err)) return } // INT-007: Standardize pagination format pagination := BuildPaginationData(page, limit, total) RespondSuccess(c, http.StatusOK, gin.H{ "comments": comments, "pagination": pagination, }) } // UpdateComment gère la mise à jour d'un commentaire // @Summary Update comment // @Description Update a comment (only by owner) // @Tags Comment // @Accept json // @Produce json // @Security BearerAuth // @Param id path string true "Comment ID (UUID)" // @Param comment body handlers.UpdateCommentRequest true "Updated comment content" // @Success 200 {object} handlers.APIResponse{data=object{comment=object}} // @Failure 400 {object} handlers.APIResponse "Validation error" // @Failure 401 {object} handlers.APIResponse "Unauthorized" // @Failure 403 {object} handlers.APIResponse "Forbidden - can only edit own comments" // @Failure 404 {object} handlers.APIResponse "Comment not found" // @Failure 500 {object} handlers.APIResponse "Internal server error" // @Router /comments/{id} [put] func (h *CommentHandler) UpdateComment(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return // Erreur déjà envoyée par GetUserIDUUID } if userID == uuid.Nil { RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized")) return } commentIDStr := c.Param("id") if commentIDStr == "" { RespondWithAppError(c, apperrors.NewValidationError("comment id is required")) return } commentID, err := uuid.Parse(commentIDStr) // Changed to uuid.Parse if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid comment id")) return } var req UpdateCommentRequest 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 req.Content = utils.SanitizeText(req.Content, 5000) comment, err := h.commentService.UpdateComment(c.Request.Context(), commentID, userID, req.Content) if err != nil { if errors.Is(err, services.ErrCommentNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("comment")) return } if errors.Is(err, services.ErrForbidden) { RespondWithAppError(c, apperrors.NewForbiddenError("unauthorized: you can only edit your own comments")) return } RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to update comment", err)) return } RespondSuccess(c, http.StatusOK, gin.H{"comment": comment}) } // DeleteComment gère la suppression d'un commentaire // @Summary Delete comment // @Description Delete a comment (only by owner or admin) // @Tags Comment // @Accept json // @Produce json // @Security BearerAuth // @Param id path string true "Track ID" // @Param comment_id path string true "Comment ID" // @Success 200 {object} handlers.APIResponse{data=object{message=string}} // @Failure 400 {object} handlers.APIResponse "Validation error" // @Failure 401 {object} handlers.APIResponse "Unauthorized" // @Failure 403 {object} handlers.APIResponse "Forbidden - not comment owner" // @Failure 404 {object} handlers.APIResponse "Comment not found" // @Router /tracks/{id}/comments/{comment_id} [delete] func (h *CommentHandler) DeleteComment(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return // Erreur déjà envoyée par GetUserIDUUID } if userID == uuid.Nil { RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized")) return } commentIDStr := c.Param("id") if commentIDStr == "" { RespondWithAppError(c, apperrors.NewValidationError("comment id is required")) return } commentID, err := uuid.Parse(commentIDStr) // Changed to uuid.Parse if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid comment id")) return } err = h.commentService.DeleteComment(c.Request.Context(), commentID, userID, false) // Added false for isAdmin if err != nil { if errors.Is(err, services.ErrCommentNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("comment")) return } if errors.Is(err, services.ErrForbidden) { RespondWithAppError(c, apperrors.NewForbiddenError("you can only delete your own comments")) return } h.commonHandler.logger.Error("failed to delete comment", zap.Error(err)) RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to delete comment", err)) return } RespondSuccess(c, http.StatusOK, gin.H{"message": "Comment deleted successfully"}) } // GetReplies gère la récupération des réponses d'un commentaire // @Summary Get comment replies // @Description Get paginated list of replies to a comment // @Tags Comment // @Accept json // @Produce json // @Param id path string true "Parent Comment ID (UUID)" // @Param page query int false "Page number" default(1) minimum(1) // @Param limit query int false "Items per page" default(20) minimum(1) maximum(100) // @Success 200 {object} handlers.APIResponse{data=object{replies=array,pagination=object}} // @Failure 400 {object} handlers.APIResponse "Validation error" // @Failure 404 {object} handlers.APIResponse "Parent comment not found" // @Failure 500 {object} handlers.APIResponse "Internal server error" // @Router /comments/{id}/replies [get] func (h *CommentHandler) GetReplies(c *gin.Context) { parentIDStr := c.Param("id") if parentIDStr == "" { RespondWithAppError(c, apperrors.NewValidationError("parent comment id is required")) return } parentID, err := uuid.Parse(parentIDStr) // Changed to uuid.Parse if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid parent comment id")) return } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) limit = clampLimit(limit) // SECURITY(MEDIUM-004) if page < 1 { page = 1 } if limit < 1 { limit = 20 } if limit > 100 { limit = 100 } replies, total, err := h.commentService.GetReplies(c.Request.Context(), parentID, page, limit) if err != nil { if errors.Is(err, services.ErrParentCommentNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("parent comment")) return } RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get replies", err)) return } RespondSuccess(c, http.StatusOK, gin.H{ "replies": replies, "total": total, "page": page, "limit": limit, }) }