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()
|
moderationConfig := config.NewModerationConfig()
|
||||||
moderationService := services.NewModerationService(dbPool, moderationConfig.OpenAIKey, moderationConfig.GoogleKey)
|
moderationService := services.NewModerationService(dbPool, moderationConfig.OpenAIKey, moderationConfig.GoogleKey)
|
||||||
|
|
||||||
|
// Initialize appeal service
|
||||||
|
appealService := services.NewAppealService(dbPool)
|
||||||
|
|
||||||
hub := realtime.NewHub()
|
hub := realtime.NewHub()
|
||||||
wsHandler := handlers.NewWSHandler(hub, cfg.JWTSecret)
|
wsHandler := handlers.NewWSHandler(hub, cfg.JWTSecret)
|
||||||
|
|
||||||
|
|
@ -129,6 +132,7 @@ func main() {
|
||||||
backupHandler := handlers.NewBackupHandler(repository.NewBackupRepository(dbPool))
|
backupHandler := handlers.NewBackupHandler(repository.NewBackupRepository(dbPool))
|
||||||
settingsHandler := handlers.NewSettingsHandler(userRepo, notifRepo)
|
settingsHandler := handlers.NewSettingsHandler(userRepo, notifRepo)
|
||||||
analysisHandler := handlers.NewAnalysisHandler()
|
analysisHandler := handlers.NewAnalysisHandler()
|
||||||
|
appealHandler := handlers.NewAppealHandler(appealService)
|
||||||
|
|
||||||
var s3Client *s3.Client
|
var s3Client *s3.Client
|
||||||
if cfg.R2AccessKey != "" && cfg.R2SecretKey != "" && cfg.R2Endpoint != "" {
|
if cfg.R2AccessKey != "" && cfg.R2SecretKey != "" && cfg.R2Endpoint != "" {
|
||||||
|
|
@ -322,6 +326,23 @@ func main() {
|
||||||
authorized.POST("/notifications/device", notificationHandler.RegisterDevice)
|
authorized.POST("/notifications/device", notificationHandler.RegisterDevice)
|
||||||
authorized.DELETE("/notifications/device", notificationHandler.UnregisterDevice)
|
authorized.DELETE("/notifications/device", notificationHandler.UnregisterDevice)
|
||||||
authorized.DELETE("/notifications/devices", notificationHandler.UnregisterAllDevices)
|
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 := `
|
query := `
|
||||||
INSERT INTO moderation_flags (post_id, flag_reason, scores, status)
|
INSERT INTO moderation_flags (post_id, flag_reason, scores, status)
|
||||||
VALUES ($1, $2, $3, 'pending')
|
VALUES ($1, $2, $3, 'pending')
|
||||||
RETURNING id, created_at
|
RETURNING id, created_at, user_id
|
||||||
`
|
`
|
||||||
|
|
||||||
var flagID uuid.UUID
|
var flagID uuid.UUID
|
||||||
var createdAt time.Time
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to insert moderation flag: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -371,16 +393,38 @@ func (s *ModerationService) FlagComment(ctx context.Context, commentID uuid.UUID
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO moderation_flags (comment_id, flag_reason, scores, status)
|
INSERT INTO moderation_flags (comment_id, flag_reason, scores, status)
|
||||||
VALUES ($1, $2, $3, 'pending')
|
VALUES ($1, $2, $3, 'pending')
|
||||||
RETURNING id, created_at
|
RETURNING id, created_at, user_id
|
||||||
`
|
`
|
||||||
|
|
||||||
var flagID uuid.UUID
|
var flagID uuid.UUID
|
||||||
var createdAt time.Time
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to insert comment moderation flag: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue