package moderation import ( "net/http" "strconv" "strings" apperrors "veza-backend-api/internal/errors" "veza-backend-api/internal/handlers" "veza-backend-api/internal/services" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" ) // ModerationHandler handles advanced moderation HTTP requests (F411-F420) type ModerationHandler struct { service *services.ModerationService logger *zap.Logger } // NewModerationHandler creates a new moderation handler func NewModerationHandler(service *services.ModerationService, logger *zap.Logger) *ModerationHandler { if logger == nil { logger = zap.NewNop() } return &ModerationHandler{service: service, logger: logger} } // getModeratorID extracts the moderator's user ID from context func (h *ModerationHandler) getModeratorID(c *gin.Context) (uuid.UUID, bool) { userID, ok := handlers.GetUserIDUUID(c) if !ok || userID == uuid.Nil { handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeUnauthorized, "authentication required")) return uuid.Nil, false } return userID, true } // --- F411: Moderation Queue --- // GetModerationQueue handles GET /api/v1/admin/moderation/queue (F411) func (h *ModerationHandler) GetModerationQueue(c *gin.Context) { limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) params := services.ModerationQueueParams{ Status: c.DefaultQuery("status", "pending"), Category: c.Query("category"), Priority: c.Query("priority"), Limit: limit, Offset: offset, SortBy: c.DefaultQuery("sort_by", "created_at"), } items, total, err := h.service.GetModerationQueue(c.Request.Context(), params) if err != nil { handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get moderation queue", err)) return } handlers.RespondSuccess(c, http.StatusOK, gin.H{ "reports": items, "pagination": gin.H{ "total": total, "limit": limit, "offset": offset, }, }) } // ProcessReport handles POST /api/v1/admin/moderation/reports/:id/process (F411) func (h *ModerationHandler) ProcessReport(c *gin.Context) { moderatorID, ok := h.getModeratorID(c) if !ok { return } reportIDStr := c.Param("id") reportID, err := uuid.Parse(reportIDStr) if err != nil { handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid report ID")) return } var action services.ModerationAction if err := c.ShouldBindJSON(&action); err != nil { handlers.RespondWithAppError(c, apperrors.NewValidationError("action is required")) return } validActions := map[string]bool{ "approve": true, "reject": true, "ban_temp": true, "ban_perm": true, "warn": true, "dismiss": true, } if !validActions[action.Action] { handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid action: must be approve, reject, ban_temp, ban_perm, warn, or dismiss")) return } if err := h.service.ProcessReport(c.Request.Context(), reportID, moderatorID, action); err != nil { if strings.Contains(err.Error(), "not found") { handlers.RespondWithAppError(c, apperrors.NewNotFoundError("report")) return } if strings.Contains(err.Error(), "already processed") { handlers.RespondWithAppError(c, apperrors.NewValidationError("report already processed")) return } handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to process report", err)) return } handlers.RespondSuccess(c, http.StatusOK, gin.H{ "status": "processed", }) } // AssignReport handles POST /api/v1/admin/moderation/reports/:id/assign (F411) func (h *ModerationHandler) AssignReport(c *gin.Context) { reportIDStr := c.Param("id") reportID, err := uuid.Parse(reportIDStr) if err != nil { handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid report ID")) return } var req struct { ModeratorID string `json:"moderator_id" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { handlers.RespondWithAppError(c, apperrors.NewValidationError("moderator_id is required")) return } moderatorID, err := uuid.Parse(req.ModeratorID) if err != nil { handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid moderator ID")) return } if err := h.service.AssignReport(c.Request.Context(), reportID, moderatorID); err != nil { if strings.Contains(err.Error(), "not found") { handlers.RespondWithAppError(c, apperrors.NewNotFoundError("report")) return } handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to assign report", err)) return } handlers.RespondSuccess(c, http.StatusOK, gin.H{ "status": "assigned", }) } // --- F412: Enhanced Reporting --- // CreateEnhancedReport handles POST /api/v1/reports (F412) // Available to all authenticated users func (h *ModerationHandler) CreateEnhancedReport(c *gin.Context) { userID, ok := h.getModeratorID(c) if !ok { return } var req services.EnhancedReportRequest if err := c.ShouldBindJSON(&req); err != nil { handlers.RespondWithAppError(c, apperrors.NewValidationError("content_type, category, and reason are required")) return } reportID, err := h.service.CreateEnhancedReport(c.Request.Context(), userID, req) if err != nil { if strings.Contains(err.Error(), "invalid category") || strings.Contains(err.Error(), "invalid content type") { handlers.RespondWithAppError(c, apperrors.NewValidationError(err.Error())) return } handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to create report", err)) return } handlers.RespondSuccess(c, http.StatusCreated, gin.H{ "id": reportID.String(), "status": "created", }) } // --- F413: Spam Detection --- // GetSpamDetections handles GET /api/v1/admin/moderation/spam (F413) func (h *ModerationHandler) GetSpamDetections(c *gin.Context) { limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) if limit > 100 { limit = 100 } var total int64 var detections []struct { ID string `json:"id"` UserID string `json:"user_id"` RuleName string `json:"rule_name"` ContentType string `json:"content_type"` ActionTaken string `json:"action_taken"` Reviewed bool `json:"reviewed"` CreatedAt string `json:"created_at"` } db := h.service.DB() db.WithContext(c.Request.Context()).Table("spam_detections").Where("reviewed = FALSE").Count(&total) if err := db.WithContext(c.Request.Context()).Raw(` SELECT CAST(sd.id AS TEXT), CAST(sd.user_id AS TEXT) AS user_id, sr.name AS rule_name, sd.content_type, sd.action_taken, sd.reviewed, sd.created_at::text FROM spam_detections sd JOIN spam_rules sr ON sr.id = sd.rule_id WHERE sd.reviewed = FALSE ORDER BY sd.created_at DESC LIMIT ? OFFSET ? `, limit, offset).Scan(&detections).Error; err != nil { handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get spam detections", err)) return } handlers.RespondSuccess(c, http.StatusOK, gin.H{ "detections": detections, "pagination": gin.H{ "total": total, "limit": limit, "offset": offset, }, }) } // --- F414: Audio Fingerprints --- // GetPendingFingerprints handles GET /api/v1/admin/moderation/fingerprints (F414) func (h *ModerationHandler) GetPendingFingerprints(c *gin.Context) { limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) fingerprints, total, err := h.service.GetPendingFingerprints(c.Request.Context(), limit, offset) if err != nil { handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get fingerprints", err)) return } handlers.RespondSuccess(c, http.StatusOK, gin.H{ "fingerprints": fingerprints, "pagination": gin.H{ "total": total, "limit": limit, "offset": offset, }, }) } // ReviewFingerprint handles POST /api/v1/admin/moderation/fingerprints/:trackId/review (F414) func (h *ModerationHandler) ReviewFingerprint(c *gin.Context) { reviewerID, ok := h.getModeratorID(c) if !ok { return } trackIDStr := c.Param("trackId") trackID, err := uuid.Parse(trackIDStr) if err != nil { handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid track ID")) return } var req struct { Status string `json:"status" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { handlers.RespondWithAppError(c, apperrors.NewValidationError("status is required")) return } if err := h.service.ReviewFingerprint(c.Request.Context(), trackID, reviewerID, req.Status); err != nil { if strings.Contains(err.Error(), "invalid status") { handlers.RespondWithAppError(c, apperrors.NewValidationError(err.Error())) return } if strings.Contains(err.Error(), "not found") { handlers.RespondWithAppError(c, apperrors.NewNotFoundError("fingerprint")) return } handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to review fingerprint", err)) return } handlers.RespondSuccess(c, http.StatusOK, gin.H{ "status": "reviewed", }) } // --- F415: Strike System --- // GetUserStrikes handles GET /api/v1/admin/moderation/users/:userId/strikes (F415) func (h *ModerationHandler) GetUserStrikes(c *gin.Context) { userIDStr := c.Param("userId") userID, err := uuid.Parse(userIDStr) if err != nil { handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid user ID")) return } summary, err := h.service.GetUserStrikes(c.Request.Context(), userID) if err != nil { handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get strikes", err)) return } handlers.RespondSuccess(c, http.StatusOK, gin.H{ "strikes": summary, }) } // GetPendingAppeals handles GET /api/v1/admin/moderation/appeals (F415) func (h *ModerationHandler) GetPendingAppeals(c *gin.Context) { limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) appeals, total, err := h.service.GetPendingAppeals(c.Request.Context(), limit, offset) if err != nil { handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get appeals", err)) return } handlers.RespondSuccess(c, http.StatusOK, gin.H{ "appeals": appeals, "pagination": gin.H{ "total": total, "limit": limit, "offset": offset, }, }) } // ResolveAppeal handles POST /api/v1/admin/moderation/appeals/:strikeId/resolve (F415) func (h *ModerationHandler) ResolveAppeal(c *gin.Context) { resolverID, ok := h.getModeratorID(c) if !ok { return } strikeIDStr := c.Param("strikeId") strikeID, err := uuid.Parse(strikeIDStr) if err != nil { handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid strike ID")) return } var req struct { Upheld bool `json:"upheld"` } if err := c.ShouldBindJSON(&req); err != nil { handlers.RespondWithAppError(c, apperrors.NewValidationError("upheld field is required")) return } if err := h.service.ResolveAppeal(c.Request.Context(), strikeID, resolverID, req.Upheld); err != nil { if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "already resolved") { handlers.RespondWithAppError(c, apperrors.NewNotFoundError("appeal")) return } handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to resolve appeal", err)) return } handlers.RespondSuccess(c, http.StatusOK, gin.H{ "status": "resolved", }) } // --- User-facing: Appeals --- // AppealStrike handles POST /api/v1/strikes/:strikeId/appeal (F415) // Available to authenticated users for their own strikes func (h *ModerationHandler) AppealStrike(c *gin.Context) { userID, ok := h.getModeratorID(c) if !ok { return } strikeIDStr := c.Param("strikeId") strikeID, err := uuid.Parse(strikeIDStr) if err != nil { handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid strike ID")) return } var req struct { AppealText string `json:"appeal_text" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { handlers.RespondWithAppError(c, apperrors.NewValidationError("appeal_text is required")) return } if err := h.service.AppealStrike(c.Request.Context(), userID, strikeID, req.AppealText); err != nil { if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "already appealed") { handlers.RespondWithAppError(c, apperrors.NewValidationError(err.Error())) return } handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to submit appeal", err)) return } handlers.RespondSuccess(c, http.StatusOK, gin.H{ "status": "appeal_submitted", }) } // GetMyStrikes handles GET /api/v1/me/strikes (F415) // Returns the authenticated user's own strikes func (h *ModerationHandler) GetMyStrikes(c *gin.Context) { userID, ok := h.getModeratorID(c) if !ok { return } summary, err := h.service.GetUserStrikes(c.Request.Context(), userID) if err != nil { handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get strikes", err)) return } handlers.RespondSuccess(c, http.StatusOK, gin.H{ "strikes": summary, }) } // --- Moderation Stats --- // GetModerationStats handles GET /api/v1/admin/moderation/stats func (h *ModerationHandler) GetModerationStats(c *gin.Context) { stats, err := h.service.GetModerationStats(c.Request.Context()) if err != nil { handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get stats", err)) return } handlers.RespondSuccess(c, http.StatusOK, gin.H{ "stats": stats, }) }