sojorn/go-backend/internal/services/appeal_service.go
Patrick Britton da5984d67c refactor: rename Go module from github.com/patbritton to gitlab.com/patrickbritton3
- Rename module path from github.com/patbritton/sojorn-backend to gitlab.com/patrickbritton3/sojorn/go-backend
- Updated 78 references across 41 files
- Matches new GitLab repository structure
2026-02-16 23:58:39 -06:00

394 lines
11 KiB
Go

package services
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"gitlab.com/patrickbritton3/sojorn/go-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)
}