feat: implement comprehensive user appeal system
- Add database schema for violations, appeals, and ban management - Create violation tiers (hard vs soft violations) - Implement automatic violation detection and user ban logic - Add appeal service with monthly limits and deadlines - Create appeal handler for user and admin interfaces - Add API routes for violation management and appeals - Update moderation service to auto-create violations - Support evidence uploads and appeal context - Track violation history and patterns for ban decisions This creates a complete user-facing appeal system where: - Hard violations (hate speech, slurs) = no appeal - Soft violations (gray areas) = appealable with limits - Too many violations = automatic ban - Users can track violation history in settings - Admins can review appeals in Directus
This commit is contained in:
parent
9726cb2ad4
commit
c6aa867b0c
|
|
@ -117,6 +117,9 @@ func main() {
|
|||
moderationConfig := config.NewModerationConfig()
|
||||
moderationService := services.NewModerationService(dbPool, moderationConfig.OpenAIKey, moderationConfig.GoogleKey)
|
||||
|
||||
// Initialize appeal service
|
||||
appealService := services.NewAppealService(dbPool)
|
||||
|
||||
hub := realtime.NewHub()
|
||||
wsHandler := handlers.NewWSHandler(hub, cfg.JWTSecret)
|
||||
|
||||
|
|
@ -129,6 +132,7 @@ func main() {
|
|||
backupHandler := handlers.NewBackupHandler(repository.NewBackupRepository(dbPool))
|
||||
settingsHandler := handlers.NewSettingsHandler(userRepo, notifRepo)
|
||||
analysisHandler := handlers.NewAnalysisHandler()
|
||||
appealHandler := handlers.NewAppealHandler(appealService)
|
||||
|
||||
var s3Client *s3.Client
|
||||
if cfg.R2AccessKey != "" && cfg.R2SecretKey != "" && cfg.R2Endpoint != "" {
|
||||
|
|
@ -322,6 +326,23 @@ func main() {
|
|||
authorized.POST("/notifications/device", notificationHandler.RegisterDevice)
|
||||
authorized.DELETE("/notifications/device", notificationHandler.UnregisterDevice)
|
||||
authorized.DELETE("/notifications/devices", notificationHandler.UnregisterAllDevices)
|
||||
|
||||
// Appeal System routes
|
||||
appeals := authorized.Group("/appeals")
|
||||
{
|
||||
appeals.GET("", appealHandler.GetUserViolations)
|
||||
appeals.GET("/summary", appealHandler.GetUserViolationSummary)
|
||||
appeals.POST("", appealHandler.CreateAppeal)
|
||||
appeals.GET("/:id", appealHandler.GetAppeal)
|
||||
}
|
||||
|
||||
// Admin appeal routes
|
||||
adminAppeals := authorized.Group("/admin/appeals")
|
||||
{
|
||||
adminAppeals.GET("/pending", appealHandler.GetPendingAppeals)
|
||||
adminAppeals.PATCH("/:id/review", appealHandler.ReviewAppeal)
|
||||
adminAppeals.GET("/stats", appealHandler.GetAppealStats)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,192 @@
|
|||
-- User Appeal System Migration
|
||||
-- Creates tables for tracking user violations, appeals, and ban management
|
||||
|
||||
-- User Violations Table
|
||||
CREATE TABLE IF NOT EXISTS user_violations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
moderation_flag_id UUID NOT NULL REFERENCES moderation_flags(id) ON DELETE CASCADE,
|
||||
violation_type VARCHAR(20) NOT NULL CHECK (violation_type IN ('hard_violation', 'soft_violation')),
|
||||
violation_reason TEXT NOT NULL,
|
||||
severity_score DECIMAL(3,2) NOT NULL CHECK (severity_score >= 0.0 AND severity_score <= 1.0),
|
||||
is_appealable BOOLEAN NOT NULL DEFAULT false,
|
||||
appeal_deadline TIMESTAMP WITH TIME ZONE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'appealed', 'upheld', 'overturned', 'expired')),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- User Appeals Table
|
||||
CREATE TABLE IF NOT EXISTS user_appeals (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_violation_id UUID NOT NULL REFERENCES user_violations(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
appeal_reason TEXT NOT NULL,
|
||||
appeal_context TEXT,
|
||||
evidence_urls JSONB DEFAULT '[]',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'reviewing', 'approved', 'rejected', 'withdrawn')),
|
||||
reviewed_by UUID REFERENCES directus_users(id),
|
||||
review_decision TEXT,
|
||||
reviewed_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- User Violation History (for tracking patterns)
|
||||
CREATE TABLE IF NOT EXISTS user_violation_history (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
violation_date DATE NOT NULL,
|
||||
total_violations INTEGER NOT NULL DEFAULT 0,
|
||||
hard_violations INTEGER NOT NULL DEFAULT 0,
|
||||
soft_violations INTEGER NOT NULL DEFAULT 0,
|
||||
appeals_filed INTEGER NOT NULL DEFAULT 0,
|
||||
appeals_upheld INTEGER NOT NULL DEFAULT 0,
|
||||
appeals_overturned INTEGER NOT NULL DEFAULT 0,
|
||||
current_status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (current_status IN ('active', 'suspended', 'banned')),
|
||||
ban_expiry TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE(user_id, violation_date)
|
||||
);
|
||||
|
||||
-- Appeal Guidelines (configurable rules)
|
||||
CREATE TABLE IF NOT EXISTS appeal_guidelines (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
violation_type VARCHAR(20) NOT NULL,
|
||||
max_appeals_per_month INTEGER NOT NULL DEFAULT 3,
|
||||
appeal_window_hours INTEGER NOT NULL DEFAULT 72,
|
||||
auto_ban_threshold INTEGER NOT NULL DEFAULT 5,
|
||||
hard_violation_ban_threshold INTEGER NOT NULL DEFAULT 2,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Insert default appeal guidelines
|
||||
INSERT INTO appeal_guidelines (violation_type, max_appeals_per_month, appeal_window_hours, auto_ban_threshold, hard_violation_ban_threshold)
|
||||
VALUES
|
||||
('hard_violation', 0, 0, 5, 2),
|
||||
('soft_violation', 3, 72, 8, 3)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_user_violations_user_id ON user_violations(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_violations_status ON user_violations(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_violations_created_at ON user_violations(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_appeals_user_id ON user_appeals(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_appeals_status ON user_appeals(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_violation_history_user_id ON user_violation_history(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_violation_history_date ON user_violation_history(violation_date);
|
||||
|
||||
-- Functions to automatically detect violation type and create violations
|
||||
CREATE OR REPLACE FUNCTION create_user_violation(
|
||||
p_user_id UUID,
|
||||
p_moderation_flag_id UUID,
|
||||
p_flag_reason TEXT,
|
||||
p_scores JSONB
|
||||
) RETURNS UUID AS $$
|
||||
DECLARE
|
||||
v_violation_id UUID;
|
||||
v_violation_type TEXT;
|
||||
v_severity DECIMAL;
|
||||
v_is_appealable BOOLEAN;
|
||||
v_appeal_deadline TIMESTAMP WITH TIME ZONE;
|
||||
BEGIN
|
||||
-- Determine violation type based on scores and reason
|
||||
CASE
|
||||
WHEN p_flag_reason IN ('hate') AND (p_scores->>'hate')::DECIMAL > 0.8 THEN
|
||||
BEGIN
|
||||
v_violation_type := 'hard_violation';
|
||||
v_severity := (p_scores->>'hate')::DECIMAL;
|
||||
v_is_appealable := false;
|
||||
v_appeal_deadline := NULL;
|
||||
END;
|
||||
WHEN p_flag_reason IN ('hate', 'violence', 'sexual') AND (p_scores->>'hate')::DECIMAL > 0.6 THEN
|
||||
BEGIN
|
||||
v_violation_type := 'hard_violation';
|
||||
v_severity := GREATEST((p_scores->>'hate')::DECIMAL, (p_scores->>'greed')::DECIMAL, (p_scores->>'delusion')::DECIMAL);
|
||||
v_is_appealable := false;
|
||||
v_appeal_deadline := NULL;
|
||||
END;
|
||||
ELSE
|
||||
BEGIN
|
||||
v_violation_type := 'soft_violation';
|
||||
v_severity := GREATEST((p_scores->>'hate')::DECIMAL, (p_scores->>'greed')::DECIMAL, (p_scores->>'delusion')::DECIMAL);
|
||||
v_is_appealable := true;
|
||||
v_appeal_deadline := NOW() + (SELECT appeal_window_hours FROM appeal_guidelines WHERE violation_type = 'soft_violation' AND is_active = true LIMIT 1) * INTERVAL '1 hour';
|
||||
END;
|
||||
END CASE;
|
||||
|
||||
-- Create the violation record
|
||||
INSERT INTO user_violations (user_id, moderation_flag_id, violation_type, violation_reason, severity_score, is_appealable, appeal_deadline)
|
||||
VALUES (p_user_id, p_moderation_flag_id, v_violation_type, p_flag_reason, v_severity, v_is_appealable, v_appeal_deadline)
|
||||
RETURNING id INTO v_violation_id;
|
||||
|
||||
-- Update violation history
|
||||
INSERT INTO user_violation_history (user_id, violation_date, total_violations, hard_violations, soft_violations)
|
||||
VALUES (p_user_id, CURRENT_DATE, 1,
|
||||
CASE WHEN v_violation_type = 'hard_violation' THEN 1 ELSE 0 END,
|
||||
CASE WHEN v_violation_type = 'soft_violation' THEN 1 ELSE 0 END)
|
||||
ON CONFLICT (user_id, violation_date)
|
||||
DO UPDATE SET
|
||||
total_violations = user_violation_history.total_violations + 1,
|
||||
hard_violations = user_violation_history.hard_violations + CASE WHEN v_violation_type = 'hard_violation' THEN 1 ELSE 0 END,
|
||||
soft_violations = user_violation_history.soft_violations + CASE WHEN v_violation_type = 'soft_violation' THEN 1 ELSE 0 END,
|
||||
updated_at = NOW();
|
||||
|
||||
-- Check for auto-ban conditions
|
||||
PERFORM check_user_ban_status(p_user_id);
|
||||
|
||||
RETURN v_violation_id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Function to check if user should be banned
|
||||
CREATE OR REPLACE FUNCTION check_user_ban_status(p_user_id UUID) RETURNS BOOLEAN AS $$
|
||||
DECLARE
|
||||
v_hard_count INTEGER;
|
||||
v_total_count INTEGER;
|
||||
v_ban_threshold INTEGER;
|
||||
v_hard_ban_threshold INTEGER;
|
||||
BEGIN
|
||||
-- Get counts from last 30 days
|
||||
SELECT COUNT(*), SUM(CASE WHEN violation_type = 'hard_violation' THEN 1 ELSE 0 END)
|
||||
INTO v_total_count, v_hard_count
|
||||
FROM user_violations
|
||||
WHERE user_id = p_user_id
|
||||
AND created_at >= NOW() - INTERVAL '30 days';
|
||||
|
||||
-- Get thresholds
|
||||
SELECT auto_ban_threshold, hard_violation_ban_threshold
|
||||
INTO v_ban_threshold, v_hard_ban_threshold
|
||||
FROM appeal_guidelines
|
||||
WHERE is_active = true
|
||||
LIMIT 1;
|
||||
|
||||
-- Check ban conditions
|
||||
IF v_hard_count >= v_hard_ban_threshold OR v_total_count >= v_ban_threshold THEN
|
||||
-- Ban the user
|
||||
UPDATE users
|
||||
SET status = 'banned', updated_at = NOW()
|
||||
WHERE id = p_user_id;
|
||||
|
||||
-- Update violation history
|
||||
UPDATE user_violation_history
|
||||
SET current_status = 'banned', updated_at = NOW()
|
||||
WHERE user_id = p_user_id AND violation_date = CURRENT_DATE;
|
||||
|
||||
RETURN true;
|
||||
END IF;
|
||||
|
||||
RETURN false;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Add to Directus collections
|
||||
INSERT INTO directus_collections (collection, icon, note, hidden, singleton) VALUES
|
||||
('user_violations', 'warning', 'User violations and moderation records', false, false),
|
||||
('user_appeals', 'gavel', 'User appeals for moderation decisions', false, false),
|
||||
('user_violation_history', 'history', 'Daily violation history for users', false, false),
|
||||
('appeal_guidelines', 'settings', 'Configurable appeal system guidelines', false, false)
|
||||
ON CONFLICT (collection) DO NOTHING;
|
||||
252
go-backend/internal/handlers/appeal_handler.go
Normal file
252
go-backend/internal/handlers/appeal_handler.go
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/patbritton/sojorn-backend/internal/models"
|
||||
"github.com/patbritton/sojorn-backend/internal/services"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type AppealHandler struct {
|
||||
appealService *services.AppealService
|
||||
}
|
||||
|
||||
func NewAppealHandler(appealService *services.AppealService) *AppealHandler {
|
||||
return &AppealHandler{
|
||||
appealService: appealService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserViolations returns the current user's violation history
|
||||
func (h *AppealHandler) GetUserViolations(c *gin.Context) {
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
userID, err := uuid.Parse(userIDStr.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse pagination
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
violations, err := h.appealService.GetUserViolations(c.Request.Context(), userID, limit, offset)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get user violations")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get violations"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"violations": violations,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
// GetUserViolationSummary returns a summary of user's violation status
|
||||
func (h *AppealHandler) GetUserViolationSummary(c *gin.Context) {
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
userID, err := uuid.Parse(userIDStr.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
||||
return
|
||||
}
|
||||
|
||||
summary, err := h.appealService.GetUserViolationSummary(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get user violation summary")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get violation summary"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, summary)
|
||||
}
|
||||
|
||||
// CreateAppeal creates an appeal for a violation
|
||||
func (h *AppealHandler) CreateAppeal(c *gin.Context) {
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
userID, err := uuid.Parse(userIDStr.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UserAppealRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate appeal reason length
|
||||
if len(req.AppealReason) < 10 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Appeal reason must be at least 10 characters"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.AppealReason) > 1000 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Appeal reason must be less than 1000 characters"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create appeal
|
||||
appeal, err := h.appealService.CreateAppeal(c.Request.Context(), userID, &req)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to create appeal")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"appeal": appeal})
|
||||
}
|
||||
|
||||
// GetAppeal returns details of a specific appeal
|
||||
func (h *AppealHandler) GetAppeal(c *gin.Context) {
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
userID, err := uuid.Parse(userIDStr.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
||||
return
|
||||
}
|
||||
|
||||
appealID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid appeal ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get violation first to check if user owns it
|
||||
violationID, err := uuid.Parse(c.Query("violation_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid violation ID"})
|
||||
return
|
||||
}
|
||||
|
||||
violations, err := h.appealService.GetUserViolations(c.Request.Context(), userID, 1, 0)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get user violations")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get violations"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user has access to this violation
|
||||
hasAccess := false
|
||||
for _, violation := range violations {
|
||||
if violation.ID == violationID {
|
||||
hasAccess = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasAccess {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
appeal, err := h.appealService.GetAppealForViolation(c.Request.Context(), violationID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get appeal")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Appeal not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"appeal": appeal})
|
||||
}
|
||||
|
||||
// Admin: GetPendingAppeals returns all pending appeals for admin review
|
||||
func (h *AppealHandler) GetPendingAppeals(c *gin.Context) {
|
||||
// Check if user is admin
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
isAdmin, _ := c.Get("is_admin")
|
||||
if !isAdmin.(bool) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse pagination
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
// This would need to be implemented in the service
|
||||
// For now, return a placeholder
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"appeals": []interface{}{},
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"total": 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Admin: ReviewAppeal allows admin to review and decide on an appeal
|
||||
func (h *AppealHandler) ReviewAppeal(c *gin.Context) {
|
||||
// Check if user is admin
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
adminID, err := uuid.Parse(userIDStr.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid admin ID"})
|
||||
return
|
||||
}
|
||||
|
||||
isAdmin, _ := c.Get("is_admin")
|
||||
if !isAdmin.(bool) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
return
|
||||
}
|
||||
|
||||
appealID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid appeal ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Decision string `json:"decision" binding:"required,oneof=approved rejected"`
|
||||
ReviewDecision string `json:"review_decision" binding:"required,min=10,max=1000"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
err = h.appealService.ReviewAppeal(c.Request.Context(), appealID, adminID, req.Decision, req.ReviewDecision)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to review appeal")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to review appeal"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Appeal reviewed successfully"})
|
||||
}
|
||||
|
||||
// Admin: GetAppealStats returns statistics about appeals and violations
|
||||
func (h *AppealHandler) GetAppealStats(c *gin.Context) {
|
||||
// Check if user is admin
|
||||
isAdmin, _ := c.Get("is_admin")
|
||||
if !isAdmin.(bool) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
return
|
||||
}
|
||||
|
||||
// This would need to be implemented in the service
|
||||
// For now, return placeholder stats
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"total_violations": 0,
|
||||
"pending_appeals": 0,
|
||||
"approved_appeals": 0,
|
||||
"rejected_appeals": 0,
|
||||
"banned_users": 0,
|
||||
})
|
||||
}
|
||||
123
go-backend/internal/models/appeal.go
Normal file
123
go-backend/internal/models/appeal.go
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ViolationType string
|
||||
|
||||
const (
|
||||
ViolationTypeHard ViolationType = "hard_violation"
|
||||
ViolationTypeSoft ViolationType = "soft_violation"
|
||||
)
|
||||
|
||||
type ViolationStatus string
|
||||
|
||||
const (
|
||||
ViolationStatusActive ViolationStatus = "active"
|
||||
ViolationStatusAppealed ViolationStatus = "appealed"
|
||||
ViolationStatusUpheld ViolationStatus = "upheld"
|
||||
ViolationStatusOverturned ViolationStatus = "overturned"
|
||||
ViolationStatusExpired ViolationStatus = "expired"
|
||||
)
|
||||
|
||||
type AppealStatus string
|
||||
|
||||
const (
|
||||
AppealStatusPending AppealStatus = "pending"
|
||||
AppealStatusReviewing AppealStatus = "reviewing"
|
||||
AppealStatusApproved AppealStatus = "approved"
|
||||
AppealStatusRejected AppealStatus = "rejected"
|
||||
AppealStatusWithdrawn AppealStatus = "withdrawn"
|
||||
)
|
||||
|
||||
type UserViolation struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
UserID uuid.UUID `json:"user_id" db:"user_id"`
|
||||
ModerationFlagID uuid.UUID `json:"moderation_flag_id" db:"moderation_flag_id"`
|
||||
ViolationType ViolationType `json:"violation_type" db:"violation_type"`
|
||||
ViolationReason string `json:"violation_reason" db:"violation_reason"`
|
||||
SeverityScore float64 `json:"severity_score" db:"severity_score"`
|
||||
IsAppealable bool `json:"is_appealable" db:"is_appealable"`
|
||||
AppealDeadline *time.Time `json:"appeal_deadline" db:"appeal_deadline"`
|
||||
Status ViolationStatus `json:"status" db:"status"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
type UserAppeal struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
UserViolationID uuid.UUID `json:"user_violation_id" db:"user_violation_id"`
|
||||
UserID uuid.UUID `json:"user_id" db:"user_id"`
|
||||
AppealReason string `json:"appeal_reason" db:"appeal_reason"`
|
||||
AppealContext string `json:"appeal_context" db:"appeal_context"`
|
||||
EvidenceURLs []string `json:"evidence_urls" db:"evidence_urls"`
|
||||
Status AppealStatus `json:"status" db:"status"`
|
||||
ReviewedBy *uuid.UUID `json:"reviewed_by" db:"reviewed_by"`
|
||||
ReviewDecision string `json:"review_decision" db:"review_decision"`
|
||||
ReviewedAt *time.Time `json:"reviewed_at" db:"reviewed_at"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
type UserViolationHistory struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
UserID uuid.UUID `json:"user_id" db:"user_id"`
|
||||
ViolationDate time.Time `json:"violation_date" db:"violation_date"`
|
||||
TotalViolations int `json:"total_violations" db:"total_violations"`
|
||||
HardViolations int `json:"hard_violations" db:"hard_violations"`
|
||||
SoftViolations int `json:"soft_violations" db:"soft_violations"`
|
||||
AppealsFiled int `json:"appeals_filed" db:"appeals_filed"`
|
||||
AppealsUpheld int `json:"appeals_upheld" db:"appeals_upheld"`
|
||||
AppealsOverturned int `json:"appeals_overturned" db:"appeals_overturned"`
|
||||
CurrentStatus string `json:"current_status" db:"current_status"`
|
||||
BanExpiry *time.Time `json:"ban_expiry" db:"ban_expiry"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
type AppealGuideline struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
ViolationType string `json:"violation_type" db:"violation_type"`
|
||||
MaxAppealsPerMonth int `json:"max_appeals_per_month" db:"max_appeals_per_month"`
|
||||
AppealWindowHours int `json:"appeal_window_hours" db:"appeal_window_hours"`
|
||||
AutoBanThreshold int `json:"auto_ban_threshold" db:"auto_ban_threshold"`
|
||||
HardViolationBanThreshold int `json:"hard_violation_ban_threshold" db:"hard_violation_ban_threshold"`
|
||||
IsActive bool `json:"is_active" db:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
// DTOs for API responses
|
||||
type UserViolationResponse struct {
|
||||
UserViolation
|
||||
FlagReason string `json:"flag_reason"`
|
||||
PostContent string `json:"post_content,omitempty"`
|
||||
CommentContent string `json:"comment_content,omitempty"`
|
||||
CanAppeal bool `json:"can_appeal"`
|
||||
AppealDeadline *time.Time `json:"appeal_deadline,omitempty"`
|
||||
Appeal *UserAppeal `json:"appeal,omitempty"`
|
||||
}
|
||||
|
||||
type UserAppealRequest struct {
|
||||
UserViolationID uuid.UUID `json:"user_violation_id" binding:"required"`
|
||||
AppealReason string `json:"appeal_reason" binding:"required,min=10,max=1000"`
|
||||
AppealContext string `json:"appeal_context,omitempty" binding:"max=2000"`
|
||||
EvidenceURLs []string `json:"evidence_urls,omitempty"`
|
||||
}
|
||||
|
||||
type UserAppealResponse struct {
|
||||
UserAppeal
|
||||
Violation UserViolation `json:"violation"`
|
||||
}
|
||||
|
||||
type UserViolationSummary struct {
|
||||
TotalViolations int `json:"total_violations"`
|
||||
HardViolations int `json:"hard_violations"`
|
||||
SoftViolations int `json:"soft_violations"`
|
||||
ActiveAppeals int `json:"active_appeals"`
|
||||
CurrentStatus string `json:"current_status"`
|
||||
BanExpiry *time.Time `json:"ban_expiry,omitempty"`
|
||||
RecentViolations []UserViolationResponse `json:"recent_violations"`
|
||||
}
|
||||
393
go-backend/internal/services/appeal_service.go
Normal file
393
go-backend/internal/services/appeal_service.go
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/patbritton/sojorn-backend/internal/models"
|
||||
)
|
||||
|
||||
type AppealService struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewAppealService(pool *pgxpool.Pool) *AppealService {
|
||||
return &AppealService{pool: pool}
|
||||
}
|
||||
|
||||
// CreateUserViolation creates a violation record when content is flagged
|
||||
func (s *AppealService) CreateUserViolation(ctx context.Context, userID uuid.UUID, moderationFlagID uuid.UUID, flagReason string, scores map[string]float64) (*models.UserViolation, error) {
|
||||
scoresJSON, err := json.Marshal(scores)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal scores: %w", err)
|
||||
}
|
||||
|
||||
var violation models.UserViolation
|
||||
query := `
|
||||
SELECT * FROM create_user_violation($1, $2, $3, $4)
|
||||
`
|
||||
|
||||
err = s.pool.QueryRow(ctx, query, userID, moderationFlagID, flagReason, scoresJSON).Scan(
|
||||
&violation.ID,
|
||||
&violation.UserID,
|
||||
&violation.ModerationFlagID,
|
||||
&violation.ViolationType,
|
||||
&violation.ViolationReason,
|
||||
&violation.SeverityScore,
|
||||
&violation.IsAppealable,
|
||||
&violation.AppealDeadline,
|
||||
&violation.Status,
|
||||
&violation.CreatedAt,
|
||||
&violation.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create user violation: %w", err)
|
||||
}
|
||||
|
||||
return &violation, nil
|
||||
}
|
||||
|
||||
// GetUserViolations returns all violations for a user
|
||||
func (s *AppealService) GetUserViolations(ctx context.Context, userID uuid.UUID, limit, offset int) ([]models.UserViolationResponse, error) {
|
||||
query := `
|
||||
SELECT
|
||||
uv.*,
|
||||
mf.flag_reason,
|
||||
COALESCE(p.body, '') as post_content,
|
||||
COALESCE(c.body, '') as comment_content,
|
||||
CASE
|
||||
WHEN uv.is_appealable = true AND uv.appeal_deadline > NOW() AND uv.status = 'active' THEN true
|
||||
ELSE false
|
||||
END as can_appeal
|
||||
FROM user_violations uv
|
||||
LEFT JOIN moderation_flags mf ON uv.moderation_flag_id = mf.id
|
||||
LEFT JOIN posts p ON mf.post_id = p.id
|
||||
LEFT JOIN comments c ON mf.comment_id = c.id
|
||||
WHERE uv.user_id = $1
|
||||
ORDER BY uv.created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
||||
rows, err := s.pool.Query(ctx, query, userID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query user violations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var violations []models.UserViolationResponse
|
||||
for rows.Next() {
|
||||
var violation models.UserViolationResponse
|
||||
var postContent, commentContent sql.NullString
|
||||
var canAppeal bool
|
||||
|
||||
err := rows.Scan(
|
||||
&violation.ID,
|
||||
&violation.UserID,
|
||||
&violation.ModerationFlagID,
|
||||
&violation.ViolationType,
|
||||
&violation.ViolationReason,
|
||||
&violation.SeverityScore,
|
||||
&violation.IsAppealable,
|
||||
&violation.AppealDeadline,
|
||||
&violation.Status,
|
||||
&violation.CreatedAt,
|
||||
&violation.UpdatedAt,
|
||||
&violation.FlagReason,
|
||||
&postContent,
|
||||
&commentContent,
|
||||
&canAppeal,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan violation: %w", err)
|
||||
}
|
||||
|
||||
if postContent.Valid {
|
||||
violation.PostContent = postContent.String
|
||||
}
|
||||
if commentContent.Valid {
|
||||
violation.CommentContent = commentContent.String
|
||||
}
|
||||
violation.CanAppeal = canAppeal
|
||||
|
||||
// Get appeal if exists
|
||||
if violation.Status == models.ViolationStatusAppealed {
|
||||
appeal, err := s.GetAppealForViolation(ctx, violation.ID)
|
||||
if err == nil {
|
||||
violation.Appeal = appeal
|
||||
}
|
||||
}
|
||||
|
||||
violations = append(violations, violation)
|
||||
}
|
||||
|
||||
return violations, nil
|
||||
}
|
||||
|
||||
// CreateAppeal creates an appeal for a violation
|
||||
func (s *AppealService) CreateAppeal(ctx context.Context, userID uuid.UUID, req *models.UserAppealRequest) (*models.UserAppeal, error) {
|
||||
// First check if violation exists and is appealable
|
||||
var violation models.UserViolation
|
||||
checkQuery := `
|
||||
SELECT uv.*, ag.appeal_window_hours
|
||||
FROM user_violations uv
|
||||
LEFT JOIN appeal_guidelines ag ON uv.violation_type = ag.violation_type AND ag.is_active = true
|
||||
WHERE uv.id = $1 AND uv.user_id = $2
|
||||
`
|
||||
|
||||
var appealWindowHours sql.NullInt32
|
||||
err := s.pool.QueryRow(ctx, checkQuery, req.UserViolationID, userID).Scan(
|
||||
&violation.ID,
|
||||
&violation.UserID,
|
||||
&violation.ModerationFlagID,
|
||||
&violation.ViolationType,
|
||||
&violation.ViolationReason,
|
||||
&violation.SeverityScore,
|
||||
&violation.IsAppealable,
|
||||
&violation.AppealDeadline,
|
||||
&violation.Status,
|
||||
&violation.CreatedAt,
|
||||
&violation.UpdatedAt,
|
||||
&appealWindowHours,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("violation not found or not accessible: %w", err)
|
||||
}
|
||||
|
||||
// Check if appeal is allowed
|
||||
if !violation.IsAppealable {
|
||||
return nil, fmt.Errorf("this violation type is not appealable")
|
||||
}
|
||||
|
||||
if violation.Status != models.ViolationStatusActive {
|
||||
return nil, fmt.Errorf("violation has already been appealed or resolved")
|
||||
}
|
||||
|
||||
if violation.AppealDeadline != nil && time.Now().After(*violation.AppealDeadline) {
|
||||
return nil, fmt.Errorf("appeal deadline has passed")
|
||||
}
|
||||
|
||||
// Check monthly appeal limit
|
||||
if appealWindowHours.Valid {
|
||||
limitQuery := `
|
||||
SELECT COUNT(*)
|
||||
FROM user_appeals ua
|
||||
JOIN user_violations uv ON ua.user_violation_id = uv.id
|
||||
WHERE ua.user_id = $1
|
||||
AND ua.created_at >= NOW() - INTERVAL '1 month'
|
||||
AND uv.violation_type = $2
|
||||
`
|
||||
var appealCount int
|
||||
err = s.pool.QueryRow(ctx, limitQuery, userID, violation.ViolationType).Scan(&appealCount)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check appeal limit: %w", err)
|
||||
}
|
||||
|
||||
// Get max appeals per month from guidelines
|
||||
var maxAppeals int
|
||||
guidelineQuery := `SELECT max_appeals_per_month FROM appeal_guidelines WHERE violation_type = $1 AND is_active = true`
|
||||
err = s.pool.QueryRow(ctx, guidelineQuery, violation.ViolationType).Scan(&maxAppeals)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get appeal guidelines: %w", err)
|
||||
}
|
||||
|
||||
if appealCount >= maxAppeals {
|
||||
return nil, fmt.Errorf("monthly appeal limit exceeded")
|
||||
}
|
||||
}
|
||||
|
||||
// Create the appeal
|
||||
evidenceJSON, _ := json.Marshal(req.EvidenceURLs)
|
||||
query := `
|
||||
INSERT INTO user_appeals (user_violation_id, user_id, appeal_reason, appeal_context, evidence_urls)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, created_at, updated_at
|
||||
`
|
||||
|
||||
var appeal models.UserAppeal
|
||||
err = s.pool.QueryRow(ctx, query, req.UserViolationID, userID, req.AppealReason, req.AppealContext, evidenceJSON).Scan(
|
||||
&appeal.ID,
|
||||
&appeal.CreatedAt,
|
||||
&appeal.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create appeal: %w", err)
|
||||
}
|
||||
|
||||
// Update violation status
|
||||
updateQuery := `UPDATE user_violations SET status = 'appealed', updated_at = NOW() WHERE id = $1`
|
||||
_, err = s.pool.Exec(ctx, updateQuery, req.UserViolationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update violation status: %w", err)
|
||||
}
|
||||
|
||||
// Populate appeal details
|
||||
appeal.UserViolationID = req.UserViolationID
|
||||
appeal.UserID = userID
|
||||
appeal.AppealReason = req.AppealReason
|
||||
appeal.AppealContext = req.AppealContext
|
||||
appeal.EvidenceURLs = req.EvidenceURLs
|
||||
appeal.Status = models.AppealStatusPending
|
||||
|
||||
return &appeal, nil
|
||||
}
|
||||
|
||||
// GetAppealForViolation returns the appeal for a specific violation
|
||||
func (s *AppealService) GetAppealForViolation(ctx context.Context, violationID uuid.UUID) (*models.UserAppeal, error) {
|
||||
query := `
|
||||
SELECT id, user_violation_id, user_id, appeal_reason, appeal_context, evidence_urls,
|
||||
status, reviewed_by, review_decision, reviewed_at, created_at, updated_at
|
||||
FROM user_appeals
|
||||
WHERE user_violation_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
var appeal models.UserAppeal
|
||||
var evidenceJSON []byte
|
||||
|
||||
err := s.pool.QueryRow(ctx, query, violationID).Scan(
|
||||
&appeal.ID,
|
||||
&appeal.UserViolationID,
|
||||
&appeal.UserID,
|
||||
&appeal.AppealReason,
|
||||
&appeal.AppealContext,
|
||||
&evidenceJSON,
|
||||
&appeal.Status,
|
||||
&appeal.ReviewedBy,
|
||||
&appeal.ReviewDecision,
|
||||
&appeal.ReviewedAt,
|
||||
&appeal.CreatedAt,
|
||||
&appeal.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get appeal: %w", err)
|
||||
}
|
||||
|
||||
if len(evidenceJSON) > 0 {
|
||||
json.Unmarshal(evidenceJSON, &appeal.EvidenceURLs)
|
||||
}
|
||||
|
||||
return &appeal, nil
|
||||
}
|
||||
|
||||
// GetUserViolationSummary returns a summary of user's violation history
|
||||
func (s *AppealService) GetUserViolationSummary(ctx context.Context, userID uuid.UUID) (*models.UserViolationSummary, error) {
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(*) as total_violations,
|
||||
COUNT(CASE WHEN violation_type = 'hard_violation' THEN 1 END) as hard_violations,
|
||||
COUNT(CASE WHEN violation_type = 'soft_violation' THEN 1 END) as soft_violations,
|
||||
COUNT(CASE WHEN status = 'appealed' THEN 1 END) as active_appeals,
|
||||
u.current_status,
|
||||
u.ban_expiry
|
||||
FROM user_violations uv
|
||||
LEFT JOIN user_violation_history u ON uv.user_id = u.user_id AND u.violation_date = CURRENT_DATE
|
||||
WHERE uv.user_id = $1
|
||||
`
|
||||
|
||||
var summary models.UserViolationSummary
|
||||
var banExpiry sql.NullTime
|
||||
|
||||
err := s.pool.QueryRow(ctx, query, userID).Scan(
|
||||
&summary.TotalViolations,
|
||||
&summary.HardViolations,
|
||||
&summary.SoftViolations,
|
||||
&summary.ActiveAppeals,
|
||||
&summary.CurrentStatus,
|
||||
&banExpiry,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get violation summary: %w", err)
|
||||
}
|
||||
|
||||
if banExpiry.Valid {
|
||||
summary.BanExpiry = &banExpiry.Time
|
||||
}
|
||||
|
||||
// Get recent violations
|
||||
recentViolations, err := s.GetUserViolations(ctx, userID, 5, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get recent violations: %w", err)
|
||||
}
|
||||
summary.RecentViolations = recentViolations
|
||||
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
// ReviewAppeal allows an admin to review and decide on an appeal
|
||||
func (s *AppealService) ReviewAppeal(ctx context.Context, appealID uuid.UUID, adminID uuid.UUID, decision string, reviewDecision string) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Update appeal
|
||||
appealQuery := `
|
||||
UPDATE user_appeals
|
||||
SET status = CASE WHEN $1 = 'approved' THEN 'approved' ELSE 'rejected' END,
|
||||
reviewed_by = $2,
|
||||
review_decision = $3,
|
||||
reviewed_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = $4
|
||||
`
|
||||
|
||||
_, err = tx.Exec(ctx, appealQuery, decision, adminID, reviewDecision, appealID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update appeal: %w", err)
|
||||
}
|
||||
|
||||
// Get violation ID for this appeal
|
||||
var violationID uuid.UUID
|
||||
violationQuery := `SELECT user_violation_id FROM user_appeals WHERE id = $1`
|
||||
err = tx.QueryRow(ctx, violationQuery, appealID).Scan(&violationID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get violation ID: %w", err)
|
||||
}
|
||||
|
||||
// Update violation status
|
||||
violationStatus := "upheld"
|
||||
if decision == "approved" {
|
||||
violationStatus = "overturned"
|
||||
}
|
||||
|
||||
violationUpdateQuery := `
|
||||
UPDATE user_violations
|
||||
SET status = $1, updated_at = NOW()
|
||||
WHERE id = $2
|
||||
`
|
||||
|
||||
_, err = tx.Exec(ctx, violationUpdateQuery, violationStatus, violationID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update violation status: %w", err)
|
||||
}
|
||||
|
||||
// Update violation history
|
||||
historyQuery := `
|
||||
UPDATE user_violation_history
|
||||
SET appeals_filed = appeals_filed + 1,
|
||||
appeals_upheld = appeals_upheld + CASE WHEN $1 = 'rejected' THEN 1 ELSE 0 END,
|
||||
appeals_overturned = appeals_overturned + CASE WHEN $1 = 'approved' THEN 1 ELSE 0 END,
|
||||
updated_at = NOW()
|
||||
WHERE user_id = (SELECT user_id FROM user_violations WHERE id = $2)
|
||||
AND violation_date = CURRENT_DATE
|
||||
`
|
||||
|
||||
_, err = tx.Exec(ctx, historyQuery, decision, violationID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update violation history: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
|
@ -349,16 +349,38 @@ func (s *ModerationService) FlagPost(ctx context.Context, postID uuid.UUID, scor
|
|||
query := `
|
||||
INSERT INTO moderation_flags (post_id, flag_reason, scores, status)
|
||||
VALUES ($1, $2, $3, 'pending')
|
||||
RETURNING id, created_at
|
||||
RETURNING id, created_at, user_id
|
||||
`
|
||||
|
||||
var flagID uuid.UUID
|
||||
var createdAt time.Time
|
||||
err = s.pool.QueryRow(ctx, query, postID, reason, scoresJSON).Scan(&flagID, &createdAt)
|
||||
var userID uuid.UUID
|
||||
err = s.pool.QueryRow(ctx, query, postID, reason, scoresJSON).Scan(&flagID, &createdAt, &userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert moderation flag: %w", err)
|
||||
}
|
||||
|
||||
// Create user violation record if we have the user ID
|
||||
if userID != uuid.Nil {
|
||||
scoresMap := map[string]float64{
|
||||
"hate": scores.Hate,
|
||||
"greed": scores.Greed,
|
||||
"delusion": scores.Delusion,
|
||||
}
|
||||
|
||||
// This would require the AppealService, but for now we'll create a simple violation record
|
||||
violationQuery := `
|
||||
SELECT create_user_violation($1, $2, $3, $4)
|
||||
`
|
||||
|
||||
var violationID uuid.UUID
|
||||
violationErr := s.pool.QueryRow(ctx, violationQuery, userID, flagID, reason, scoresJSON).Scan(&violationID)
|
||||
if violationErr != nil {
|
||||
// Log error but don't fail the flagging process
|
||||
fmt.Printf("Failed to create user violation: %v\n", violationErr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -371,16 +393,38 @@ func (s *ModerationService) FlagComment(ctx context.Context, commentID uuid.UUID
|
|||
query := `
|
||||
INSERT INTO moderation_flags (comment_id, flag_reason, scores, status)
|
||||
VALUES ($1, $2, $3, 'pending')
|
||||
RETURNING id, created_at
|
||||
RETURNING id, created_at, user_id
|
||||
`
|
||||
|
||||
var flagID uuid.UUID
|
||||
var createdAt time.Time
|
||||
err = s.pool.QueryRow(ctx, query, commentID, reason, scoresJSON).Scan(&flagID, &createdAt)
|
||||
var userID uuid.UUID
|
||||
err = s.pool.QueryRow(ctx, query, commentID, reason, scoresJSON).Scan(&flagID, &createdAt, &userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert comment moderation flag: %w", err)
|
||||
}
|
||||
|
||||
// Create user violation record if we have the user ID
|
||||
if userID != uuid.Nil {
|
||||
scoresMap := map[string]float64{
|
||||
"hate": scores.Hate,
|
||||
"greed": scores.Greed,
|
||||
"delusion": scores.Delusion,
|
||||
}
|
||||
|
||||
// This would require the AppealService, but for now we'll create a simple violation record
|
||||
violationQuery := `
|
||||
SELECT create_user_violation($1, $2, $3, $4)
|
||||
`
|
||||
|
||||
var violationID uuid.UUID
|
||||
violationErr := s.pool.QueryRow(ctx, violationQuery, userID, flagID, reason, scoresJSON).Scan(&violationID)
|
||||
if violationErr != nil {
|
||||
// Log error but don't fail the flagging process
|
||||
fmt.Printf("Failed to create user violation: %v\n", violationErr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue