diff --git a/go-backend/cmd/api/main.go b/go-backend/cmd/api/main.go index 5b14f1c..71d49c1 100644 --- a/go-backend/cmd/api/main.go +++ b/go-backend/cmd/api/main.go @@ -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) + } } } diff --git a/go-backend/internal/database/migrations/20260205000002_user_appeal_system.up.sql b/go-backend/internal/database/migrations/20260205000002_user_appeal_system.up.sql new file mode 100644 index 0000000..5ab8a95 --- /dev/null +++ b/go-backend/internal/database/migrations/20260205000002_user_appeal_system.up.sql @@ -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; diff --git a/go-backend/internal/handlers/appeal_handler.go b/go-backend/internal/handlers/appeal_handler.go new file mode 100644 index 0000000..22cc1f7 --- /dev/null +++ b/go-backend/internal/handlers/appeal_handler.go @@ -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, + }) +} diff --git a/go-backend/internal/models/appeal.go b/go-backend/internal/models/appeal.go new file mode 100644 index 0000000..3a22672 --- /dev/null +++ b/go-backend/internal/models/appeal.go @@ -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"` +} diff --git a/go-backend/internal/services/appeal_service.go b/go-backend/internal/services/appeal_service.go new file mode 100644 index 0000000..04b9f18 --- /dev/null +++ b/go-backend/internal/services/appeal_service.go @@ -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) +} diff --git a/go-backend/internal/services/moderation_service.go b/go-backend/internal/services/moderation_service.go index f75d5cc..481168e 100644 --- a/go-backend/internal/services/moderation_service.go +++ b/go-backend/internal/services/moderation_service.go @@ -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 }