backend-ci.yml's `test -z "$(gofmt -l .)"` strict gate (added in
13c21ac11) failed on a backlog of unformatted files. None of the
85 files in this commit had been edited since the gate was added
because no push touched veza-backend-api/** in between, so the
gate never fired until today's CI fixes triggered it.
The diff is exclusively whitespace alignment in struct literals
and trailing-space comments. `go build ./...` and the full test
suite (with VEZA_SKIP_INTEGRATION=1 -short) pass identically.
785 lines
26 KiB
Go
785 lines
26 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// ModerationService provides advanced moderation capabilities (F411-F420)
|
|
// All moderation decisions are ALWAYS human-final — automated rules only flag content.
|
|
type ModerationService struct {
|
|
db *gorm.DB
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// NewModerationService creates a new moderation service
|
|
func NewModerationService(db *gorm.DB, logger *zap.Logger) *ModerationService {
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
return &ModerationService{db: db, logger: logger}
|
|
}
|
|
|
|
// --- F411: Moderation Queue ---
|
|
|
|
// ModerationQueueItem represents an item in the moderation queue
|
|
type ModerationQueueItem struct {
|
|
ID string `json:"id"`
|
|
ReporterID string `json:"reporter_id"`
|
|
ReportedUserID *string `json:"reported_user_id,omitempty"`
|
|
ContentType string `json:"content_type"`
|
|
ContentID *string `json:"content_id,omitempty"`
|
|
Reason string `json:"reason"`
|
|
Category string `json:"category"`
|
|
Priority string `json:"priority"`
|
|
Status string `json:"status"`
|
|
AssignedTo *string `json:"assigned_to,omitempty"`
|
|
ResolutionNote string `json:"resolution_note,omitempty"`
|
|
ResolutionAction string `json:"resolution_action,omitempty"`
|
|
ResolvedBy *string `json:"resolved_by,omitempty"`
|
|
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
ReporterName string `json:"reporter_name,omitempty"`
|
|
ReportedName string `json:"reported_name,omitempty"`
|
|
}
|
|
|
|
// ModerationQueueParams holds filtering parameters for the queue
|
|
type ModerationQueueParams struct {
|
|
Status string
|
|
Category string
|
|
Priority string
|
|
Limit int
|
|
Offset int
|
|
SortBy string // "created_at", "priority"
|
|
}
|
|
|
|
// GetModerationQueue returns the moderation queue with filters (F411)
|
|
func (s *ModerationService) GetModerationQueue(ctx context.Context, params ModerationQueueParams) ([]ModerationQueueItem, int64, error) {
|
|
if params.Limit <= 0 {
|
|
params.Limit = 20
|
|
}
|
|
if params.Limit > 100 {
|
|
params.Limit = 100
|
|
}
|
|
|
|
query := s.db.WithContext(ctx).Table("reports r").
|
|
Select(`r.id, r.reporter_id, r.reported_user_id, r.content_type, r.content_id,
|
|
r.reason, r.category, r.priority, r.status, r.assigned_to,
|
|
r.resolution_note, r.resolution_action, r.resolved_by, r.resolved_at, r.created_at,
|
|
reporter.username AS reporter_name,
|
|
reported.username AS reported_name`).
|
|
Joins("LEFT JOIN users reporter ON reporter.id = r.reporter_id").
|
|
Joins("LEFT JOIN users reported ON reported.id = r.reported_user_id")
|
|
|
|
if params.Status != "" && params.Status != "all" {
|
|
query = query.Where("r.status = ?", params.Status)
|
|
}
|
|
if params.Category != "" {
|
|
query = query.Where("r.category = ?", params.Category)
|
|
}
|
|
if params.Priority != "" {
|
|
query = query.Where("r.priority = ?", params.Priority)
|
|
}
|
|
|
|
var total int64
|
|
countQuery := s.db.WithContext(ctx).Table("reports")
|
|
if params.Status != "" && params.Status != "all" {
|
|
countQuery = countQuery.Where("status = ?", params.Status)
|
|
}
|
|
if params.Category != "" {
|
|
countQuery = countQuery.Where("category = ?", params.Category)
|
|
}
|
|
if params.Priority != "" {
|
|
countQuery = countQuery.Where("priority = ?", params.Priority)
|
|
}
|
|
countQuery.Count(&total)
|
|
|
|
orderBy := "r.created_at DESC"
|
|
if params.SortBy == "priority" {
|
|
orderBy = "CASE r.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 WHEN 'low' THEN 3 END, r.created_at DESC"
|
|
}
|
|
|
|
var items []ModerationQueueItem
|
|
if err := query.Order(orderBy).Offset(params.Offset).Limit(params.Limit).Scan(&items).Error; err != nil {
|
|
return nil, 0, fmt.Errorf("failed to get moderation queue: %w", err)
|
|
}
|
|
|
|
return items, total, nil
|
|
}
|
|
|
|
// ModerationAction represents an action taken on a report
|
|
type ModerationAction struct {
|
|
Action string `json:"action" binding:"required"` // approve, reject, ban_temp, ban_perm, warn, dismiss
|
|
Reason string `json:"reason"`
|
|
BanDurationDays int `json:"ban_duration_days,omitempty"` // for ban_temp
|
|
}
|
|
|
|
// ProcessReport processes a moderation action on a report (F411)
|
|
// The decision is ALWAYS human — this just records and executes the moderator's decision.
|
|
func (s *ModerationService) ProcessReport(ctx context.Context, reportID uuid.UUID, moderatorID uuid.UUID, action ModerationAction) error {
|
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
// Get the report
|
|
var report struct {
|
|
ID uuid.UUID `gorm:"column:id"`
|
|
Status string `gorm:"column:status"`
|
|
ReportedUserID *uuid.UUID `gorm:"column:reported_user_id"`
|
|
ContentType string `gorm:"column:content_type"`
|
|
ContentID *uuid.UUID `gorm:"column:content_id"`
|
|
}
|
|
if err := tx.Table("reports").Where("id = ?", reportID).First(&report).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return fmt.Errorf("report not found")
|
|
}
|
|
return fmt.Errorf("failed to get report: %w", err)
|
|
}
|
|
|
|
if report.Status != "pending" {
|
|
return fmt.Errorf("report already processed")
|
|
}
|
|
|
|
now := time.Now()
|
|
status := "resolved"
|
|
if action.Action == "dismiss" {
|
|
status = "dismissed"
|
|
}
|
|
|
|
// Update the report
|
|
if err := tx.Table("reports").Where("id = ?", reportID).Updates(map[string]interface{}{
|
|
"status": status,
|
|
"resolved_by": moderatorID,
|
|
"resolved_at": now,
|
|
"resolution_note": action.Reason,
|
|
"resolution_action": action.Action,
|
|
"updated_at": now,
|
|
}).Error; err != nil {
|
|
return fmt.Errorf("failed to update report: %w", err)
|
|
}
|
|
|
|
// Log the moderation action
|
|
if err := tx.Exec(`
|
|
INSERT INTO moderation_actions (moderator_id, target_user_id, target_content_type, target_content_id, action, reason)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
`, moderatorID, report.ReportedUserID, report.ContentType, report.ContentID, action.Action, action.Reason).Error; err != nil {
|
|
s.logger.Warn("Failed to log moderation action", zap.Error(err))
|
|
}
|
|
|
|
// Execute the action
|
|
switch action.Action {
|
|
case "reject":
|
|
// Hide the content
|
|
if report.ContentID != nil {
|
|
s.hideContent(tx, report.ContentType, *report.ContentID)
|
|
}
|
|
// Issue a strike if reported user exists
|
|
if report.ReportedUserID != nil {
|
|
s.issueStrike(tx, *report.ReportedUserID, moderatorID, reportID, action.Reason, "minor")
|
|
}
|
|
|
|
case "ban_temp":
|
|
if report.ReportedUserID != nil {
|
|
duration := action.BanDurationDays
|
|
if duration <= 0 {
|
|
duration = 7
|
|
}
|
|
s.suspendUser(tx, *report.ReportedUserID, moderatorID, action.Reason, &duration)
|
|
s.issueStrike(tx, *report.ReportedUserID, moderatorID, reportID, action.Reason, "major")
|
|
}
|
|
|
|
case "ban_perm":
|
|
if report.ReportedUserID != nil {
|
|
s.suspendUser(tx, *report.ReportedUserID, moderatorID, action.Reason, nil)
|
|
s.issueStrike(tx, *report.ReportedUserID, moderatorID, reportID, action.Reason, "major")
|
|
}
|
|
|
|
case "warn":
|
|
if report.ReportedUserID != nil {
|
|
s.issueStrike(tx, *report.ReportedUserID, moderatorID, reportID, action.Reason, "warning")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// AssignReport assigns a report to a moderator (F411)
|
|
func (s *ModerationService) AssignReport(ctx context.Context, reportID uuid.UUID, moderatorID uuid.UUID) error {
|
|
result := s.db.WithContext(ctx).Table("reports").Where("id = ?", reportID).Updates(map[string]interface{}{
|
|
"assigned_to": moderatorID,
|
|
"updated_at": time.Now(),
|
|
})
|
|
if result.Error != nil {
|
|
return fmt.Errorf("failed to assign report: %w", result.Error)
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return fmt.Errorf("report not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- F412: Enhanced Reporting ---
|
|
|
|
// EnhancedReportRequest is the request for creating a report with categories
|
|
type EnhancedReportRequest struct {
|
|
ContentType string `json:"content_type" binding:"required"` // track, comment, profile, message
|
|
ContentID *uuid.UUID `json:"content_id"`
|
|
ReportedUserID *uuid.UUID `json:"reported_user_id"`
|
|
Category string `json:"category" binding:"required"` // spam, offensive, copyright, fake, other
|
|
Reason string `json:"reason" binding:"required"`
|
|
}
|
|
|
|
// CreateEnhancedReport creates a report with category and priority (F412)
|
|
func (s *ModerationService) CreateEnhancedReport(ctx context.Context, reporterID uuid.UUID, req EnhancedReportRequest) (uuid.UUID, error) {
|
|
validCategories := map[string]bool{"spam": true, "offensive": true, "copyright": true, "fake": true, "other": true}
|
|
if !validCategories[req.Category] {
|
|
return uuid.Nil, fmt.Errorf("invalid category: %s", req.Category)
|
|
}
|
|
|
|
validContentTypes := map[string]bool{"track": true, "comment": true, "profile": true, "message": true}
|
|
if !validContentTypes[req.ContentType] {
|
|
return uuid.Nil, fmt.Errorf("invalid content type: %s", req.ContentType)
|
|
}
|
|
|
|
// Determine priority based on category
|
|
priority := "normal"
|
|
switch req.Category {
|
|
case "copyright":
|
|
priority = "high"
|
|
case "offensive":
|
|
priority = "high"
|
|
case "spam":
|
|
priority = "normal"
|
|
case "fake":
|
|
priority = "normal"
|
|
}
|
|
|
|
id := uuid.New()
|
|
if err := s.db.WithContext(ctx).Exec(`
|
|
INSERT INTO reports (id, reporter_id, reported_user_id, content_type, content_id, reason, category, priority, status, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', NOW(), NOW())
|
|
`, id, reporterID, req.ReportedUserID, req.ContentType, req.ContentID, req.Reason, req.Category, priority).Error; err != nil {
|
|
return uuid.Nil, fmt.Errorf("failed to create report: %w", err)
|
|
}
|
|
|
|
return id, nil
|
|
}
|
|
|
|
// --- F413: Deterministic Spam Detection ---
|
|
|
|
// SpamCheckResult represents the result of a spam check
|
|
type SpamCheckResult struct {
|
|
IsSpam bool `json:"is_spam"`
|
|
Detections []SpamDetection `json:"detections"`
|
|
ActionTaken string `json:"action_taken"` // "none", "flagged", "auto_hidden"
|
|
}
|
|
|
|
// SpamDetection represents a single spam detection
|
|
type SpamDetection struct {
|
|
RuleName string `json:"rule_name"`
|
|
RuleType string `json:"rule_type"`
|
|
Severity string `json:"severity"`
|
|
Details string `json:"details"`
|
|
}
|
|
|
|
// CheckForSpam runs deterministic spam checks on content (F413)
|
|
// No ML — only explicit rules.
|
|
func (s *ModerationService) CheckForSpam(ctx context.Context, userID uuid.UUID, contentType string, contentID uuid.UUID, content string) (*SpamCheckResult, error) {
|
|
result := &SpamCheckResult{
|
|
Detections: []SpamDetection{},
|
|
}
|
|
|
|
// Load active rules
|
|
var rules []struct {
|
|
ID uuid.UUID `gorm:"column:id"`
|
|
Name string `gorm:"column:name"`
|
|
RuleType string `gorm:"column:rule_type"`
|
|
Config string `gorm:"column:config"`
|
|
Severity string `gorm:"column:severity"`
|
|
}
|
|
if err := s.db.WithContext(ctx).Raw(`
|
|
SELECT id, name, rule_type, config::text, severity
|
|
FROM spam_rules WHERE is_active = TRUE
|
|
`).Scan(&rules).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to load spam rules: %w", err)
|
|
}
|
|
|
|
for _, rule := range rules {
|
|
var config map[string]interface{}
|
|
json.Unmarshal([]byte(rule.Config), &config)
|
|
|
|
detected := false
|
|
details := ""
|
|
|
|
switch rule.RuleType {
|
|
case "duplicate_content":
|
|
detected, details = s.checkDuplicateContent(ctx, userID, contentType, content, config)
|
|
case "excessive_links":
|
|
detected, details = s.checkExcessiveLinks(content, config)
|
|
case "bot_pattern":
|
|
detected, details = s.checkBotPattern(ctx, userID, contentType, config)
|
|
}
|
|
|
|
if detected {
|
|
result.IsSpam = true
|
|
result.Detections = append(result.Detections, SpamDetection{
|
|
RuleName: rule.Name,
|
|
RuleType: rule.RuleType,
|
|
Severity: rule.Severity,
|
|
Details: details,
|
|
})
|
|
|
|
// Log the detection
|
|
detailsJSON, _ := json.Marshal(map[string]string{"details": details, "rule_name": rule.Name})
|
|
s.db.WithContext(ctx).Exec(`
|
|
INSERT INTO spam_detections (user_id, rule_id, content_type, content_id, details, action_taken)
|
|
VALUES (?, ?, ?, ?, ?::jsonb, 'flagged')
|
|
`, userID, rule.ID, contentType, contentID, string(detailsJSON))
|
|
}
|
|
}
|
|
|
|
if result.IsSpam {
|
|
result.ActionTaken = "flagged"
|
|
} else {
|
|
result.ActionTaken = "none"
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// checkDuplicateContent checks for duplicate titles/descriptions (F413a)
|
|
func (s *ModerationService) checkDuplicateContent(ctx context.Context, userID uuid.UUID, contentType string, content string, config map[string]interface{}) (bool, string) {
|
|
windowHours := 24
|
|
if v, ok := config["window_hours"].(float64); ok {
|
|
windowHours = int(v)
|
|
}
|
|
minDuplicates := 3
|
|
if v, ok := config["min_duplicates"].(float64); ok {
|
|
minDuplicates = int(v)
|
|
}
|
|
|
|
if contentType == "track" {
|
|
var count int64
|
|
s.db.WithContext(ctx).Raw(`
|
|
SELECT COUNT(*) FROM tracks
|
|
WHERE creator_id = ? AND LOWER(title) = LOWER(?) AND deleted_at IS NULL
|
|
AND created_at > NOW() - INTERVAL '1 hour' * ?
|
|
`, userID, content, windowHours).Scan(&count)
|
|
if count >= int64(minDuplicates) {
|
|
return true, fmt.Sprintf("Found %d tracks with identical title in last %dh", count, windowHours)
|
|
}
|
|
}
|
|
return false, ""
|
|
}
|
|
|
|
// checkExcessiveLinks checks for too many URLs in content (F413b)
|
|
func (s *ModerationService) checkExcessiveLinks(content string, config map[string]interface{}) (bool, string) {
|
|
maxLinks := 5
|
|
if v, ok := config["max_links"].(float64); ok {
|
|
maxLinks = int(v)
|
|
}
|
|
|
|
// Count URLs in content
|
|
linkCount := 0
|
|
words := strings.Fields(content)
|
|
for _, word := range words {
|
|
if _, err := url.ParseRequestURI(word); err == nil && (strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://")) {
|
|
linkCount++
|
|
}
|
|
}
|
|
|
|
if linkCount > maxLinks {
|
|
return true, fmt.Sprintf("Found %d links (max: %d)", linkCount, maxLinks)
|
|
}
|
|
return false, ""
|
|
}
|
|
|
|
// checkBotPattern checks for bot-like behavior patterns (F413c)
|
|
func (s *ModerationService) checkBotPattern(ctx context.Context, userID uuid.UUID, contentType string, config map[string]interface{}) (bool, string) {
|
|
switch contentType {
|
|
case "track":
|
|
maxPerHour := 10
|
|
if v, ok := config["max_uploads_per_hour"].(float64); ok {
|
|
maxPerHour = int(v)
|
|
}
|
|
var count int64
|
|
s.db.WithContext(ctx).Raw(`
|
|
SELECT COUNT(*) FROM tracks
|
|
WHERE creator_id = ? AND deleted_at IS NULL AND created_at > NOW() - INTERVAL '1 hour'
|
|
`, userID).Scan(&count)
|
|
if count >= int64(maxPerHour) {
|
|
return true, fmt.Sprintf("Uploaded %d tracks in last hour (max: %d)", count, maxPerHour)
|
|
}
|
|
|
|
case "comment":
|
|
maxPerMinute := 5
|
|
if v, ok := config["max_comments_per_minute"].(float64); ok {
|
|
maxPerMinute = int(v)
|
|
}
|
|
var count int64
|
|
s.db.WithContext(ctx).Raw(`
|
|
SELECT COUNT(*) FROM comments
|
|
WHERE user_id = ? AND deleted_at IS NULL AND created_at > NOW() - INTERVAL '1 minute'
|
|
`, userID).Scan(&count)
|
|
if count >= int64(maxPerMinute) {
|
|
return true, fmt.Sprintf("Posted %d comments in last minute (max: %d)", count, maxPerMinute)
|
|
}
|
|
}
|
|
return false, ""
|
|
}
|
|
|
|
// --- F414: Audio Fingerprinting ---
|
|
|
|
// FingerprintResult represents the result of an audio fingerprint check
|
|
type FingerprintResult struct {
|
|
TrackID string `json:"track_id"`
|
|
Status string `json:"status"`
|
|
MatchedTitle string `json:"matched_title,omitempty"`
|
|
MatchedArtist string `json:"matched_artist,omitempty"`
|
|
Confidence float64 `json:"confidence,omitempty"`
|
|
}
|
|
|
|
// RecordFingerprintResult stores fingerprint result for a track (F414)
|
|
// In production, this would be called after ACRCloud API response.
|
|
// The result is ALWAYS reviewed by a human before any action.
|
|
func (s *ModerationService) RecordFingerprintResult(ctx context.Context, trackID uuid.UUID, status string, matchedTitle, matchedArtist, matchedAlbum string, confidence float64, externalID string, rawResponse map[string]interface{}) error {
|
|
rawJSON, _ := json.Marshal(rawResponse)
|
|
return s.db.WithContext(ctx).Exec(`
|
|
INSERT INTO audio_fingerprints (track_id, status, matched_title, matched_artist, matched_album, confidence, external_id, raw_response)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?::jsonb)
|
|
ON CONFLICT (track_id) DO UPDATE SET
|
|
status = EXCLUDED.status,
|
|
matched_title = EXCLUDED.matched_title,
|
|
matched_artist = EXCLUDED.matched_artist,
|
|
matched_album = EXCLUDED.matched_album,
|
|
confidence = EXCLUDED.confidence,
|
|
external_id = EXCLUDED.external_id,
|
|
raw_response = EXCLUDED.raw_response,
|
|
updated_at = NOW()
|
|
`, trackID, status, matchedTitle, matchedArtist, matchedAlbum, confidence, externalID, string(rawJSON)).Error
|
|
}
|
|
|
|
// GetPendingFingerprints returns fingerprints awaiting human review (F414)
|
|
func (s *ModerationService) GetPendingFingerprints(ctx context.Context, limit, offset int) ([]FingerprintResult, int64, error) {
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
|
|
var total int64
|
|
s.db.WithContext(ctx).Table("audio_fingerprints").Where("status = 'matched' AND reviewed = FALSE").Count(&total)
|
|
|
|
var rows []struct {
|
|
TrackID string `gorm:"column:track_id"`
|
|
Status string `gorm:"column:status"`
|
|
MatchedTitle string `gorm:"column:matched_title"`
|
|
MatchedArtist string `gorm:"column:matched_artist"`
|
|
Confidence float64 `gorm:"column:confidence"`
|
|
}
|
|
|
|
if err := s.db.WithContext(ctx).Raw(`
|
|
SELECT CAST(track_id AS TEXT) AS track_id, status, matched_title, matched_artist, confidence
|
|
FROM audio_fingerprints
|
|
WHERE status = 'matched' AND reviewed = FALSE
|
|
ORDER BY confidence DESC, created_at ASC
|
|
LIMIT ? OFFSET ?
|
|
`, limit, offset).Scan(&rows).Error; err != nil {
|
|
return nil, 0, fmt.Errorf("failed to get pending fingerprints: %w", err)
|
|
}
|
|
|
|
results := make([]FingerprintResult, len(rows))
|
|
for i, r := range rows {
|
|
results[i] = FingerprintResult{
|
|
TrackID: r.TrackID,
|
|
Status: r.Status,
|
|
MatchedTitle: r.MatchedTitle,
|
|
MatchedArtist: r.MatchedArtist,
|
|
Confidence: r.Confidence,
|
|
}
|
|
}
|
|
|
|
return results, total, nil
|
|
}
|
|
|
|
// ReviewFingerprint marks a fingerprint as reviewed (F414)
|
|
func (s *ModerationService) ReviewFingerprint(ctx context.Context, trackID uuid.UUID, reviewerID uuid.UUID, newStatus string) error {
|
|
validStatuses := map[string]bool{"clean": true, "matched": true}
|
|
if !validStatuses[newStatus] {
|
|
return fmt.Errorf("invalid status: %s", newStatus)
|
|
}
|
|
|
|
now := time.Now()
|
|
result := s.db.WithContext(ctx).Table("audio_fingerprints").Where("track_id = ?", trackID).Updates(map[string]interface{}{
|
|
"status": newStatus,
|
|
"reviewed": true,
|
|
"reviewed_by": reviewerID,
|
|
"reviewed_at": now,
|
|
"updated_at": now,
|
|
})
|
|
if result.Error != nil {
|
|
return fmt.Errorf("failed to review fingerprint: %w", result.Error)
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return fmt.Errorf("fingerprint not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- F415: Strike System ---
|
|
|
|
// StrikeInfo represents a strike on a user's account
|
|
type StrikeInfo struct {
|
|
ID string `json:"id"`
|
|
Reason string `json:"reason"`
|
|
Severity string `json:"severity"`
|
|
IsActive bool `json:"is_active"`
|
|
Appealed bool `json:"appealed"`
|
|
AppealResult *string `json:"appeal_result,omitempty"`
|
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// UserStrikeSummary summarizes a user's strike history
|
|
type UserStrikeSummary struct {
|
|
UserID string `json:"user_id"`
|
|
ActiveStrikes int `json:"active_strikes"`
|
|
TotalStrikes int `json:"total_strikes"`
|
|
IsSuspended bool `json:"is_suspended"`
|
|
SuspendedUntil *time.Time `json:"suspended_until,omitempty"`
|
|
Strikes []StrikeInfo `json:"strikes"`
|
|
}
|
|
|
|
// GetUserStrikes returns strike summary for a user (F415)
|
|
func (s *ModerationService) GetUserStrikes(ctx context.Context, userID uuid.UUID) (*UserStrikeSummary, error) {
|
|
summary := &UserStrikeSummary{
|
|
UserID: userID.String(),
|
|
Strikes: []StrikeInfo{},
|
|
}
|
|
|
|
var strikes []struct {
|
|
ID uuid.UUID `gorm:"column:id"`
|
|
Reason string `gorm:"column:reason"`
|
|
Severity string `gorm:"column:severity"`
|
|
IsActive bool `gorm:"column:is_active"`
|
|
Appealed bool `gorm:"column:appealed"`
|
|
AppealResult *string `gorm:"column:appeal_result"`
|
|
ExpiresAt *time.Time `gorm:"column:expires_at"`
|
|
CreatedAt time.Time `gorm:"column:created_at"`
|
|
}
|
|
|
|
if err := s.db.WithContext(ctx).Raw(`
|
|
SELECT id, reason, severity, is_active, appealed, appeal_result, expires_at, created_at
|
|
FROM user_strikes
|
|
WHERE user_id = ?
|
|
ORDER BY created_at DESC
|
|
`, userID).Scan(&strikes).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to get strikes: %w", err)
|
|
}
|
|
|
|
for _, st := range strikes {
|
|
info := StrikeInfo{
|
|
ID: st.ID.String(),
|
|
Reason: st.Reason,
|
|
Severity: st.Severity,
|
|
IsActive: st.IsActive,
|
|
Appealed: st.Appealed,
|
|
AppealResult: st.AppealResult,
|
|
ExpiresAt: st.ExpiresAt,
|
|
CreatedAt: st.CreatedAt,
|
|
}
|
|
summary.Strikes = append(summary.Strikes, info)
|
|
summary.TotalStrikes++
|
|
if st.IsActive {
|
|
summary.ActiveStrikes++
|
|
}
|
|
}
|
|
|
|
// Check suspension status
|
|
var suspension struct {
|
|
IsActive bool `gorm:"column:is_active"`
|
|
SuspendedUntil *time.Time `gorm:"column:suspended_until"`
|
|
}
|
|
if err := s.db.WithContext(ctx).Raw(`
|
|
SELECT is_active, suspended_until
|
|
FROM user_suspensions
|
|
WHERE user_id = ? AND is_active = TRUE
|
|
ORDER BY created_at DESC LIMIT 1
|
|
`, userID).Scan(&suspension).Error; err == nil {
|
|
summary.IsSuspended = suspension.IsActive
|
|
summary.SuspendedUntil = suspension.SuspendedUntil
|
|
}
|
|
|
|
return summary, nil
|
|
}
|
|
|
|
// AppealStrike records an appeal for a strike (F415)
|
|
func (s *ModerationService) AppealStrike(ctx context.Context, userID uuid.UUID, strikeID uuid.UUID, appealText string) error {
|
|
if strings.TrimSpace(appealText) == "" {
|
|
return fmt.Errorf("appeal text is required")
|
|
}
|
|
|
|
result := s.db.WithContext(ctx).Exec(`
|
|
UPDATE user_strikes SET appealed = TRUE, appeal_text = ?, updated_at = NOW()
|
|
WHERE id = ? AND user_id = ? AND is_active = TRUE AND appealed = FALSE
|
|
`, appealText, strikeID, userID)
|
|
if result.Error != nil {
|
|
return fmt.Errorf("failed to submit appeal: %w", result.Error)
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return fmt.Errorf("strike not found or already appealed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ResolveAppeal resolves a strike appeal (F415)
|
|
func (s *ModerationService) ResolveAppeal(ctx context.Context, strikeID uuid.UUID, resolverID uuid.UUID, upheld bool) error {
|
|
result := "upheld"
|
|
if !upheld {
|
|
result = "overturned"
|
|
}
|
|
|
|
now := time.Now()
|
|
updates := map[string]interface{}{
|
|
"appeal_resolved": true,
|
|
"appeal_result": result,
|
|
"appeal_resolved_by": resolverID,
|
|
"appeal_resolved_at": now,
|
|
"updated_at": now,
|
|
}
|
|
if !upheld {
|
|
updates["is_active"] = false
|
|
}
|
|
|
|
res := s.db.WithContext(ctx).Table("user_strikes").Where("id = ? AND appealed = TRUE AND appeal_resolved = FALSE", strikeID).Updates(updates)
|
|
if res.Error != nil {
|
|
return fmt.Errorf("failed to resolve appeal: %w", res.Error)
|
|
}
|
|
if res.RowsAffected == 0 {
|
|
return fmt.Errorf("appeal not found or already resolved")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetPendingAppeals returns strikes with pending appeals (F415)
|
|
func (s *ModerationService) GetPendingAppeals(ctx context.Context, limit, offset int) ([]StrikeInfo, int64, error) {
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
|
|
var total int64
|
|
s.db.WithContext(ctx).Table("user_strikes").Where("appealed = TRUE AND appeal_resolved = FALSE").Count(&total)
|
|
|
|
var rows []struct {
|
|
ID uuid.UUID `gorm:"column:id"`
|
|
Reason string `gorm:"column:reason"`
|
|
Severity string `gorm:"column:severity"`
|
|
IsActive bool `gorm:"column:is_active"`
|
|
Appealed bool `gorm:"column:appealed"`
|
|
CreatedAt time.Time `gorm:"column:created_at"`
|
|
}
|
|
|
|
if err := s.db.WithContext(ctx).Raw(`
|
|
SELECT id, reason, severity, is_active, appealed, created_at
|
|
FROM user_strikes
|
|
WHERE appealed = TRUE AND appeal_resolved = FALSE
|
|
ORDER BY created_at ASC
|
|
LIMIT ? OFFSET ?
|
|
`, limit, offset).Scan(&rows).Error; err != nil {
|
|
return nil, 0, fmt.Errorf("failed to get pending appeals: %w", err)
|
|
}
|
|
|
|
result := make([]StrikeInfo, len(rows))
|
|
for i, r := range rows {
|
|
result[i] = StrikeInfo{
|
|
ID: r.ID.String(),
|
|
Reason: r.Reason,
|
|
Severity: r.Severity,
|
|
IsActive: r.IsActive,
|
|
Appealed: r.Appealed,
|
|
CreatedAt: r.CreatedAt,
|
|
}
|
|
}
|
|
|
|
return result, total, nil
|
|
}
|
|
|
|
// --- Internal helpers ---
|
|
|
|
func (s *ModerationService) hideContent(tx *gorm.DB, contentType string, contentID uuid.UUID) {
|
|
switch contentType {
|
|
case "track":
|
|
tx.Exec("UPDATE tracks SET deleted_at = NOW() WHERE id = ? AND deleted_at IS NULL", contentID)
|
|
case "comment":
|
|
tx.Exec("UPDATE comments SET deleted_at = NOW() WHERE id = ? AND deleted_at IS NULL", contentID)
|
|
}
|
|
}
|
|
|
|
func (s *ModerationService) issueStrike(tx *gorm.DB, userID, issuedBy, reportID uuid.UUID, reason, severity string) {
|
|
// SECURITY(HIGH-010): Prevent moderator from issuing a strike against themselves
|
|
if userID == issuedBy {
|
|
s.logger.Warn("Blocked self-strike attempt",
|
|
zap.String("moderator_id", issuedBy.String()),
|
|
zap.String("report_id", reportID.String()))
|
|
return
|
|
}
|
|
|
|
tx.Exec(`
|
|
INSERT INTO user_strikes (user_id, report_id, reason, severity, issued_by)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`, userID, reportID, reason, severity, issuedBy)
|
|
|
|
// Check for 3-strike auto-suspension
|
|
var activeCount int64
|
|
tx.Raw("SELECT COUNT(*) FROM user_strikes WHERE user_id = ? AND is_active = TRUE", userID).Scan(&activeCount)
|
|
if activeCount >= 3 {
|
|
s.suspendUser(tx, userID, issuedBy, "Automatic suspension: 3 active strikes reached", nil)
|
|
}
|
|
}
|
|
|
|
func (s *ModerationService) suspendUser(tx *gorm.DB, userID, suspendedBy uuid.UUID, reason string, durationDays *int) {
|
|
var suspendedUntil *time.Time
|
|
if durationDays != nil {
|
|
t := time.Now().AddDate(0, 0, *durationDays)
|
|
suspendedUntil = &t
|
|
}
|
|
|
|
tx.Exec(`
|
|
INSERT INTO user_suspensions (user_id, reason, suspended_by, suspended_until)
|
|
VALUES (?, ?, ?, ?)
|
|
`, userID, reason, suspendedBy, suspendedUntil)
|
|
|
|
// Mark user as banned
|
|
tx.Exec("UPDATE users SET is_banned = TRUE WHERE id = ?", userID)
|
|
}
|
|
|
|
// DB returns the underlying database for advanced queries in handlers
|
|
func (s *ModerationService) DB() *gorm.DB {
|
|
return s.db
|
|
}
|
|
|
|
// GetModerationStats returns moderation overview stats
|
|
func (s *ModerationService) GetModerationStats(ctx context.Context) (map[string]interface{}, error) {
|
|
stats := map[string]interface{}{}
|
|
|
|
var pendingReports, resolvedReports, pendingAppeals, pendingFingerprints int64
|
|
|
|
s.db.WithContext(ctx).Table("reports").Where("status = 'pending'").Count(&pendingReports)
|
|
s.db.WithContext(ctx).Table("reports").Where("status IN ('resolved', 'dismissed')").Count(&resolvedReports)
|
|
s.db.WithContext(ctx).Table("user_strikes").Where("appealed = TRUE AND appeal_resolved = FALSE").Count(&pendingAppeals)
|
|
s.db.WithContext(ctx).Table("audio_fingerprints").Where("status = 'matched' AND reviewed = FALSE").Count(&pendingFingerprints)
|
|
|
|
stats["pending_reports"] = pendingReports
|
|
stats["resolved_reports"] = resolvedReports
|
|
stats["pending_appeals"] = pendingAppeals
|
|
stats["pending_fingerprints"] = pendingFingerprints
|
|
|
|
return stats, nil
|
|
}
|