veza/veza-backend-api/internal/core/moderation/handler.go
senke 025c7aae45 feat(v0.11.2): F411-F420 moderation handler and routes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:49:51 +01:00

450 lines
13 KiB
Go

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,
})
}