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 }