450 lines
13 KiB
Go
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,
|
|
})
|
|
}
|