sojorn/go-backend/internal/handlers/admin_handler.go

2458 lines
82 KiB
Go

package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/patbritton/sojorn-backend/internal/services"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt"
)
type AdminHandler struct {
pool *pgxpool.Pool
moderationService *services.ModerationService
appealService *services.AppealService
emailService *services.EmailService
openRouterService *services.OpenRouterService
jwtSecret string
turnstileSecret string
s3Client *s3.Client
mediaBucket string
videoBucket string
imgDomain string
vidDomain string
}
func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationService, appealService *services.AppealService, emailService *services.EmailService, openRouterService *services.OpenRouterService, jwtSecret string, turnstileSecret string, s3Client *s3.Client, mediaBucket string, videoBucket string, imgDomain string, vidDomain string) *AdminHandler {
return &AdminHandler{
pool: pool,
moderationService: moderationService,
appealService: appealService,
emailService: emailService,
openRouterService: openRouterService,
jwtSecret: jwtSecret,
turnstileSecret: turnstileSecret,
s3Client: s3Client,
mediaBucket: mediaBucket,
videoBucket: videoBucket,
imgDomain: imgDomain,
vidDomain: vidDomain,
}
}
// ──────────────────────────────────────────────
// Admin Login (invisible Turnstile verification)
// ──────────────────────────────────────────────
func (h *AdminHandler) AdminLogin(c *gin.Context) {
ctx := c.Request.Context()
var req struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
TurnstileToken string `json:"turnstile_token"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
// Verify Turnstile token
if h.turnstileSecret != "" {
turnstileService := services.NewTurnstileService(h.turnstileSecret)
remoteIP := c.ClientIP()
turnstileResp, err := turnstileService.VerifyToken(req.TurnstileToken, remoteIP)
if err != nil {
log.Error().Err(err).Msg("Admin login: Turnstile verification failed")
c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"})
return
}
if !turnstileResp.Success {
log.Warn().Strs("errors", turnstileResp.ErrorCodes).Msg("Admin login: Turnstile validation failed")
c.JSON(http.StatusForbidden, gin.H{"error": "Security verification failed. Please try again."})
return
}
}
// Look up user
var userID uuid.UUID
var passwordHash, status string
err := h.pool.QueryRow(ctx,
`SELECT id, encrypted_password, COALESCE(status, 'active') FROM users WHERE email = $1 AND deleted_at IS NULL`,
req.Email).Scan(&userID, &passwordHash, &status)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
if status != "active" {
c.JSON(http.StatusForbidden, gin.H{"error": "Account is not active"})
return
}
// Check admin role
var role string
err = h.pool.QueryRow(ctx,
`SELECT COALESCE(role, 'user') FROM profiles WHERE id = $1`, userID).Scan(&role)
if err != nil || role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
// Generate JWT
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": userID.String(),
"exp": time.Now().Add(24 * time.Hour).Unix(),
"role": "authenticated",
})
tokenString, err := token.SignedString([]byte(h.jwtSecret))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
// Get profile info
var handle, displayName string
var avatarURL *string
h.pool.QueryRow(ctx,
`SELECT handle, display_name, avatar_url FROM profiles WHERE id = $1`, userID).Scan(&handle, &displayName, &avatarURL)
c.JSON(http.StatusOK, gin.H{
"access_token": tokenString,
"user": gin.H{
"id": userID,
"email": req.Email,
"handle": handle,
"display_name": displayName,
"avatar_url": avatarURL,
"role": role,
},
})
}
// ──────────────────────────────────────────────
// Dashboard / Stats
// ──────────────────────────────────────────────
func (h *AdminHandler) GetDashboardStats(c *gin.Context) {
ctx := c.Request.Context()
stats := gin.H{}
// Total users
var totalUsers, activeUsers, suspendedUsers, bannedUsers int
err := h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM public.profiles`).Scan(&totalUsers)
if err != nil {
log.Error().Err(err).Msg("Failed to count users")
}
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE status = 'active'`).Scan(&activeUsers)
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE status = 'suspended'`).Scan(&suspendedUsers)
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE status = 'banned'`).Scan(&bannedUsers)
// Total posts
var totalPosts, activePosts, flaggedPosts, removedPosts int
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM posts`).Scan(&totalPosts)
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM posts WHERE status = 'active'`).Scan(&activePosts)
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM posts WHERE status = 'flagged'`).Scan(&flaggedPosts)
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM posts WHERE status = 'removed'`).Scan(&removedPosts)
// Moderation
var pendingFlags, reviewedFlags int
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM moderation_flags WHERE status = 'pending'`).Scan(&pendingFlags)
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM moderation_flags WHERE status != 'pending'`).Scan(&reviewedFlags)
// Appeals
var pendingAppeals, approvedAppeals, rejectedAppeals int
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM user_appeals WHERE status = 'pending'`).Scan(&pendingAppeals)
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM user_appeals WHERE status = 'approved'`).Scan(&approvedAppeals)
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM user_appeals WHERE status = 'rejected'`).Scan(&rejectedAppeals)
// New users today
var newUsersToday int
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE`).Scan(&newUsersToday)
// New posts today
var newPostsToday int
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM posts WHERE created_at >= CURRENT_DATE`).Scan(&newPostsToday)
// Reports
var pendingReports int
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM reports WHERE status = 'pending'`).Scan(&pendingReports)
stats["users"] = gin.H{
"total": totalUsers,
"active": activeUsers,
"suspended": suspendedUsers,
"banned": bannedUsers,
"new_today": newUsersToday,
}
stats["posts"] = gin.H{
"total": totalPosts,
"active": activePosts,
"flagged": flaggedPosts,
"removed": removedPosts,
"new_today": newPostsToday,
}
stats["moderation"] = gin.H{
"pending_flags": pendingFlags,
"reviewed_flags": reviewedFlags,
}
stats["appeals"] = gin.H{
"pending": pendingAppeals,
"approved": approvedAppeals,
"rejected": rejectedAppeals,
}
stats["reports"] = gin.H{
"pending": pendingReports,
}
c.JSON(http.StatusOK, stats)
}
// GetGrowthStats returns user/post growth over time for charts
func (h *AdminHandler) GetGrowthStats(c *gin.Context) {
ctx := c.Request.Context()
days, _ := strconv.Atoi(c.DefaultQuery("days", "30"))
if days > 365 {
days = 365
}
// User growth
userRows, err := h.pool.Query(ctx, `
SELECT DATE(created_at) as day, COUNT(*) as count
FROM users
WHERE created_at >= NOW() - $1::int * INTERVAL '1 day'
GROUP BY DATE(created_at)
ORDER BY day
`, days)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch growth stats"})
return
}
defer userRows.Close()
var userGrowth []gin.H
for userRows.Next() {
var day time.Time
var count int
if err := userRows.Scan(&day, &count); err == nil {
userGrowth = append(userGrowth, gin.H{"date": day.Format("2006-01-02"), "count": count})
}
}
// Post growth
postRows, err := h.pool.Query(ctx, `
SELECT DATE(created_at) as day, COUNT(*) as count
FROM posts
WHERE created_at >= NOW() - $1::int * INTERVAL '1 day'
GROUP BY DATE(created_at)
ORDER BY day
`, days)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch post growth"})
return
}
defer postRows.Close()
var postGrowth []gin.H
for postRows.Next() {
var day time.Time
var count int
if err := postRows.Scan(&day, &count); err == nil {
postGrowth = append(postGrowth, gin.H{"date": day.Format("2006-01-02"), "count": count})
}
}
c.JSON(http.StatusOK, gin.H{
"user_growth": userGrowth,
"post_growth": postGrowth,
"days": days,
})
}
// ──────────────────────────────────────────────
// User Management
// ──────────────────────────────────────────────
func (h *AdminHandler) ListUsers(c *gin.Context) {
ctx := c.Request.Context()
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
search := c.Query("search")
statusFilter := c.Query("status")
roleFilter := c.Query("role")
if limit > 200 {
limit = 200
}
query := `
SELECT u.id, u.email, u.status, u.created_at,
p.handle, p.display_name, p.avatar_url, p.role, p.is_official, p.is_private, p.strikes
FROM users u
LEFT JOIN profiles p ON u.id = p.id
WHERE u.deleted_at IS NULL
`
args := []interface{}{}
argIdx := 1
if search != "" {
query += fmt.Sprintf(` AND (p.handle ILIKE $%d OR p.display_name ILIKE $%d OR u.email ILIKE $%d)`, argIdx, argIdx, argIdx)
args = append(args, "%"+search+"%")
argIdx++
}
if statusFilter != "" {
query += fmt.Sprintf(` AND u.status = $%d`, argIdx)
args = append(args, statusFilter)
argIdx++
}
if roleFilter != "" {
query += fmt.Sprintf(` AND p.role = $%d`, argIdx)
args = append(args, roleFilter)
argIdx++
}
// Count total
countQuery := "SELECT COUNT(*) FROM (" + query + ") sub"
var total int
h.pool.QueryRow(ctx, countQuery, args...).Scan(&total)
query += fmt.Sprintf(` ORDER BY u.created_at DESC LIMIT $%d OFFSET $%d`, argIdx, argIdx+1)
args = append(args, limit, offset)
rows, err := h.pool.Query(ctx, query, args...)
if err != nil {
log.Error().Err(err).Msg("Failed to list users")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list users"})
return
}
defer rows.Close()
var users []gin.H
for rows.Next() {
var id uuid.UUID
var email, status string
var createdAt time.Time
var handle, displayName, avatarURL, role *string
var isOfficial, isPrivate *bool
var strikes *int
if err := rows.Scan(&id, &email, &status, &createdAt, &handle, &displayName, &avatarURL, &role, &isOfficial, &isPrivate, &strikes); err != nil {
log.Error().Err(err).Msg("Failed to scan user row")
continue
}
users = append(users, gin.H{
"id": id,
"email": email,
"status": status,
"created_at": createdAt,
"handle": handle,
"display_name": displayName,
"avatar_url": avatarURL,
"role": role,
"is_official": isOfficial,
"is_private": isPrivate,
"strikes": strikes,
})
}
if users == nil {
users = []gin.H{}
}
c.JSON(http.StatusOK, gin.H{
"users": users,
"total": total,
"limit": limit,
"offset": offset,
})
}
func (h *AdminHandler) GetUser(c *gin.Context) {
ctx := c.Request.Context()
userID := c.Param("id")
// User + profile details
var id uuid.UUID
var email, status string
var createdAt time.Time
var lastLogin *time.Time
var handle, displayName, bio, avatarURL, coverURL, role, location, website, originCountry *string
var isOfficial, isPrivate, isVerified *bool
var strikes int
var beaconEnabled, hasCompletedOnboarding bool
err := h.pool.QueryRow(ctx, `
SELECT u.id, u.email, u.status, u.created_at, u.last_login,
p.handle, p.display_name, p.bio, p.avatar_url, p.cover_url,
p.role, p.is_official, p.is_private, p.is_verified, p.strikes,
p.beacon_enabled, p.location, p.website, p.origin_country, p.has_completed_onboarding
FROM users u
LEFT JOIN profiles p ON u.id = p.id
WHERE u.id = $1::uuid
`, userID).Scan(
&id, &email, &status, &createdAt, &lastLogin,
&handle, &displayName, &bio, &avatarURL, &coverURL,
&role, &isOfficial, &isPrivate, &isVerified, &strikes,
&beaconEnabled, &location, &website, &originCountry, &hasCompletedOnboarding,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
// Counts
var followerCount, followingCount, postCount int
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM follows WHERE following_id = $1::uuid AND status = 'accepted'`, userID).Scan(&followerCount)
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM follows WHERE follower_id = $1::uuid AND status = 'accepted'`, userID).Scan(&followingCount)
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM posts WHERE author_id = $1::uuid AND deleted_at IS NULL`, userID).Scan(&postCount)
// Violation count
var violationCount int
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM user_violations WHERE user_id = $1::uuid`, userID).Scan(&violationCount)
// Report count (received)
var reportCount int
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM reports WHERE target_user_id = $1::uuid`, userID).Scan(&reportCount)
c.JSON(http.StatusOK, gin.H{
"id": id, "email": email, "status": status, "created_at": createdAt, "last_login": lastLogin,
"handle": handle, "display_name": displayName, "bio": bio, "avatar_url": avatarURL, "cover_url": coverURL,
"role": role, "is_official": isOfficial, "is_private": isPrivate, "is_verified": isVerified,
"strikes": strikes, "beacon_enabled": beaconEnabled, "location": location, "website": website,
"origin_country": originCountry, "has_completed_onboarding": hasCompletedOnboarding,
"follower_count": followerCount, "following_count": followingCount, "post_count": postCount,
"violation_count": violationCount, "report_count": reportCount,
})
}
func (h *AdminHandler) UpdateUserStatus(c *gin.Context) {
ctx := c.Request.Context()
adminID, _ := c.Get("user_id")
targetUserID := c.Param("id")
var req struct {
Status string `json:"status" binding:"required,oneof=active suspended banned deactivated"`
Reason string `json:"reason" binding:"required,min=3"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get old status
var oldStatus string
h.pool.QueryRow(ctx, `SELECT status FROM users WHERE id = $1::uuid`, targetUserID).Scan(&oldStatus)
// Update user status
if req.Status == "banned" {
_, err := h.pool.Exec(ctx, `UPDATE users SET status = 'banned' WHERE id = $1::uuid`, targetUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user status"})
return
}
// Revoke ALL refresh tokens immediately
h.pool.Exec(ctx, `UPDATE refresh_tokens SET revoked = true WHERE user_id = $1::uuid`, targetUserID)
// Jail all their content (hidden from feeds until restored)
h.pool.Exec(ctx, `UPDATE posts SET status = 'jailed' WHERE author_id = $1::uuid AND status = 'active' AND deleted_at IS NULL`, targetUserID)
h.pool.Exec(ctx, `UPDATE comments SET status = 'jailed' WHERE author_id = $1::uuid AND status = 'active' AND deleted_at IS NULL`, targetUserID)
} else if req.Status == "suspended" {
suspendUntil := time.Now().Add(7 * 24 * time.Hour) // Default 7 day suspension from admin
_, err := h.pool.Exec(ctx, `UPDATE users SET status = 'suspended', suspended_until = $2 WHERE id = $1::uuid`, targetUserID, suspendUntil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user status"})
return
}
// Jail all their content during suspension
h.pool.Exec(ctx, `UPDATE posts SET status = 'jailed' WHERE author_id = $1::uuid AND status = 'active' AND deleted_at IS NULL`, targetUserID)
h.pool.Exec(ctx, `UPDATE comments SET status = 'jailed' WHERE author_id = $1::uuid AND status = 'active' AND deleted_at IS NULL`, targetUserID)
} else {
_, err := h.pool.Exec(ctx, `UPDATE users SET status = $1, suspended_until = NULL WHERE id = $2::uuid`, req.Status, targetUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user status"})
return
}
// If reactivating, restore any jailed content
if req.Status == "active" {
h.pool.Exec(ctx, `UPDATE posts SET status = 'active' WHERE author_id = $1::uuid AND status = 'jailed'`, targetUserID)
h.pool.Exec(ctx, `UPDATE comments SET status = 'active' WHERE author_id = $1::uuid AND status = 'jailed'`, targetUserID)
}
}
// Log status change
adminUUID, _ := uuid.Parse(adminID.(string))
h.pool.Exec(ctx, `
INSERT INTO user_status_history (user_id, old_status, new_status, reason, changed_by)
VALUES ($1::uuid, $2, $3, $4, $5)
`, targetUserID, oldStatus, req.Status, req.Reason, adminUUID)
// Send notification email
if h.emailService != nil {
var userEmail, displayName string
h.pool.QueryRow(ctx, `SELECT u.email, COALESCE(p.display_name, '') FROM users u LEFT JOIN profiles p ON p.id = u.id WHERE u.id = $1::uuid`, targetUserID).Scan(&userEmail, &displayName)
if userEmail != "" {
go func() {
switch req.Status {
case "banned":
if err := h.emailService.SendBanNotificationEmail(userEmail, displayName, req.Reason); err != nil {
log.Error().Err(err).Str("user", targetUserID).Msg("Failed to send ban notification email")
}
case "suspended":
if err := h.emailService.SendSuspensionNotificationEmail(userEmail, displayName, req.Reason, "7 days"); err != nil {
log.Error().Err(err).Str("user", targetUserID).Msg("Failed to send suspension notification email")
}
case "active":
if oldStatus == "banned" || oldStatus == "suspended" {
reason := req.Reason
if reason == "" {
reason = "Your account has been reviewed and restored."
}
if err := h.emailService.SendAccountRestoredEmail(userEmail, displayName, reason); err != nil {
log.Error().Err(err).Str("user", targetUserID).Msg("Failed to send account restored email")
}
}
}
}()
}
}
c.JSON(http.StatusOK, gin.H{"message": "User status updated", "status": req.Status})
}
func (h *AdminHandler) UpdateUserRole(c *gin.Context) {
ctx := c.Request.Context()
targetUserID := c.Param("id")
var req struct {
Role string `json:"role" binding:"required,oneof=user moderator admin"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := h.pool.Exec(ctx, `UPDATE profiles SET role = $1 WHERE id = $2::uuid`, req.Role, targetUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user role"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "User role updated", "role": req.Role})
}
func (h *AdminHandler) UpdateUserVerification(c *gin.Context) {
ctx := c.Request.Context()
targetUserID := c.Param("id")
var req struct {
IsOfficial bool `json:"is_official"`
IsVerified bool `json:"is_verified"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := h.pool.Exec(ctx, `UPDATE profiles SET is_official = $1, is_verified = $2 WHERE id = $3::uuid`,
req.IsOfficial, req.IsVerified, targetUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update verification"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Verification updated"})
}
func (h *AdminHandler) ResetUserStrikes(c *gin.Context) {
ctx := c.Request.Context()
targetUserID := c.Param("id")
_, err := h.pool.Exec(ctx, `UPDATE profiles SET strikes = 0 WHERE id = $1::uuid`, targetUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reset strikes"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Strikes reset"})
}
// ──────────────────────────────────────────────
// Post Management
// ──────────────────────────────────────────────
func (h *AdminHandler) ListPosts(c *gin.Context) {
ctx := c.Request.Context()
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
search := c.Query("search")
statusFilter := c.Query("status")
authorFilter := c.Query("author_id")
if limit > 200 {
limit = 200
}
query := `
SELECT p.id, p.author_id, p.body, p.status, p.image_url, p.video_url,
COALESCE((SELECT COUNT(*) FROM post_likes pl WHERE pl.post_id = p.id), 0) AS like_count,
COALESCE((SELECT COUNT(*) FROM comments c WHERE c.post_id = p.id AND c.deleted_at IS NULL), 0) AS comment_count,
p.is_beacon, p.visibility, p.created_at,
pr.handle, pr.display_name, pr.avatar_url
FROM posts p
LEFT JOIN profiles pr ON p.author_id = pr.id
WHERE p.deleted_at IS NULL
`
args := []interface{}{}
argIdx := 1
if search != "" {
query += fmt.Sprintf(` AND p.body ILIKE $%d`, argIdx)
args = append(args, "%"+search+"%")
argIdx++
}
if statusFilter != "" {
query += fmt.Sprintf(` AND p.status = $%d`, argIdx)
args = append(args, statusFilter)
argIdx++
}
if authorFilter != "" {
query += fmt.Sprintf(` AND p.author_id = $%d::uuid`, argIdx)
args = append(args, authorFilter)
argIdx++
}
countQuery := "SELECT COUNT(*) FROM (" + query + ") sub"
var total int
h.pool.QueryRow(ctx, countQuery, args...).Scan(&total)
query += fmt.Sprintf(` ORDER BY p.created_at DESC LIMIT $%d OFFSET $%d`, argIdx, argIdx+1)
args = append(args, limit, offset)
rows, err := h.pool.Query(ctx, query, args...)
if err != nil {
log.Error().Err(err).Msg("Failed to list posts")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list posts"})
return
}
defer rows.Close()
var posts []gin.H
for rows.Next() {
var id, authorID uuid.UUID
var body, status, visibility string
var imageURL, videoURL *string
var likeCount, commentCount int
var isBeacon bool
var createdAt time.Time
var authorHandle, authorDisplayName, authorAvatarURL *string
if err := rows.Scan(&id, &authorID, &body, &status, &imageURL, &videoURL,
&likeCount, &commentCount, &isBeacon, &visibility, &createdAt,
&authorHandle, &authorDisplayName, &authorAvatarURL); err != nil {
log.Error().Err(err).Msg("Failed to scan post row")
continue
}
posts = append(posts, gin.H{
"id": id, "author_id": authorID, "body": body, "status": status,
"image_url": imageURL, "video_url": videoURL,
"like_count": likeCount, "comment_count": commentCount,
"is_beacon": isBeacon, "visibility": visibility, "created_at": createdAt,
"author": gin.H{
"handle": authorHandle, "display_name": authorDisplayName, "avatar_url": authorAvatarURL,
},
})
}
if posts == nil {
posts = []gin.H{}
}
c.JSON(http.StatusOK, gin.H{
"posts": posts,
"total": total,
"limit": limit,
"offset": offset,
})
}
func (h *AdminHandler) GetPost(c *gin.Context) {
ctx := c.Request.Context()
postID := c.Param("id")
var id, authorID uuid.UUID
var body, status, bodyFormat, visibility string
var imageURL, videoURL, thumbnailURL, toneLabel, beaconType, backgroundID *string
var cisScore *float64
var durationMS, likeCount, commentCount int
var isBeacon, allowChain bool
var createdAt time.Time
var editedAt *time.Time
var authorHandle, authorDisplayName, authorAvatarURL *string
err := h.pool.QueryRow(ctx, `
SELECT p.id, p.author_id, p.body, p.status, p.body_format, p.image_url, p.video_url,
p.thumbnail_url, p.tone_label, p.cis_score, COALESCE(p.duration_ms, 0),
p.background_id, p.is_beacon, p.beacon_type, p.allow_chain, p.visibility,
COALESCE((SELECT COUNT(*) FROM post_likes pl WHERE pl.post_id = p.id), 0),
COALESCE((SELECT COUNT(*) FROM comments c WHERE c.post_id = p.id AND c.deleted_at IS NULL), 0),
p.created_at, p.edited_at,
pr.handle, pr.display_name, pr.avatar_url
FROM posts p
LEFT JOIN profiles pr ON p.author_id = pr.id
WHERE p.id = $1::uuid
`, postID).Scan(
&id, &authorID, &body, &status, &bodyFormat, &imageURL, &videoURL,
&thumbnailURL, &toneLabel, &cisScore, &durationMS,
&backgroundID, &isBeacon, &beaconType, &allowChain, &visibility,
&likeCount, &commentCount, &createdAt, &editedAt,
&authorHandle, &authorDisplayName, &authorAvatarURL,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Post not found"})
return
}
// Get moderation flags
flagRows, _ := h.pool.Query(ctx, `
SELECT id, flag_reason, scores, status, reviewed_by, reviewed_at, created_at
FROM moderation_flags WHERE post_id = $1::uuid ORDER BY created_at DESC
`, postID)
defer flagRows.Close()
var flags []gin.H
for flagRows.Next() {
var fID uuid.UUID
var fReason, fStatus string
var fScores []byte
var fReviewedBy *uuid.UUID
var fReviewedAt *time.Time
var fCreatedAt time.Time
if err := flagRows.Scan(&fID, &fReason, &fScores, &fStatus, &fReviewedBy, &fReviewedAt, &fCreatedAt); err == nil {
var scores map[string]float64
json.Unmarshal(fScores, &scores)
flags = append(flags, gin.H{
"id": fID, "flag_reason": fReason, "scores": scores, "status": fStatus,
"reviewed_by": fReviewedBy, "reviewed_at": fReviewedAt, "created_at": fCreatedAt,
})
}
}
c.JSON(http.StatusOK, gin.H{
"id": id, "author_id": authorID, "body": body, "status": status, "body_format": bodyFormat,
"image_url": imageURL, "video_url": videoURL, "thumbnail_url": thumbnailURL,
"tone_label": toneLabel, "cis_score": cisScore, "duration_ms": durationMS,
"background_id": backgroundID, "is_beacon": isBeacon, "beacon_type": beaconType,
"allow_chain": allowChain, "visibility": visibility,
"like_count": likeCount, "comment_count": commentCount,
"created_at": createdAt, "edited_at": editedAt,
"author": gin.H{
"handle": authorHandle, "display_name": authorDisplayName, "avatar_url": authorAvatarURL,
},
"moderation_flags": flags,
})
}
func (h *AdminHandler) UpdatePostStatus(c *gin.Context) {
ctx := c.Request.Context()
adminID, _ := c.Get("user_id")
postID := c.Param("id")
var req struct {
Status string `json:"status" binding:"required,oneof=active flagged removed"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := h.pool.Exec(ctx, `UPDATE posts SET status = $1 WHERE id = $2::uuid`, req.Status, postID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update post status"})
return
}
// Log the action
adminUUID, _ := uuid.Parse(adminID.(string))
h.pool.Exec(ctx, `
INSERT INTO audit_log (actor_id, action, target_type, target_id, details)
VALUES ($1, $2, 'post', $3::uuid, $4)
`, adminUUID, "post_status_change", postID, fmt.Sprintf(`{"status":"%s","reason":"%s"}`, req.Status, req.Reason))
// If post was removed, record a strike and notify the author
if req.Status == "removed" || req.Status == "flagged" {
var authorID uuid.UUID
var authorEmail, displayName string
err := h.pool.QueryRow(ctx, `
SELECT p.author_id, u.email, COALESCE(pr.display_name, '')
FROM posts p
JOIN users u ON u.id = p.author_id
LEFT JOIN profiles pr ON pr.id = p.author_id
WHERE p.id = $1::uuid
`, postID).Scan(&authorID, &authorEmail, &displayName)
if err == nil && req.Status == "removed" {
// Record a strike
reason := req.Reason
if reason == "" {
reason = "Post removed by moderation team"
}
h.pool.Exec(ctx, `
INSERT INTO content_strikes (user_id, category, content_snippet, created_at)
VALUES ($1, 'moderation', $2, NOW())
`, authorID, reason)
// Count strikes
var strikeCount int
h.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM content_strikes
WHERE user_id = $1 AND created_at > NOW() - INTERVAL '30 days'
`, authorID).Scan(&strikeCount)
// Send email notification
if h.emailService != nil && authorEmail != "" {
go func() {
if err := h.emailService.SendPostRemovalEmail(authorEmail, displayName, reason, strikeCount); err != nil {
log.Error().Err(err).Str("user", authorID.String()).Msg("Failed to send post removal email")
}
}()
}
}
}
c.JSON(http.StatusOK, gin.H{"message": "Post status updated", "status": req.Status})
}
func (h *AdminHandler) DeletePost(c *gin.Context) {
ctx := c.Request.Context()
adminID, _ := c.Get("user_id")
postID := c.Param("id")
// Get author info before deleting
var authorID uuid.UUID
var authorEmail, displayName string
h.pool.QueryRow(ctx, `
SELECT p.author_id, u.email, COALESCE(pr.display_name, '')
FROM posts p
JOIN users u ON u.id = p.author_id
LEFT JOIN profiles pr ON pr.id = p.author_id
WHERE p.id = $1::uuid
`, postID).Scan(&authorID, &authorEmail, &displayName)
_, err := h.pool.Exec(ctx, `UPDATE posts SET deleted_at = NOW(), status = 'removed' WHERE id = $1::uuid`, postID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete post"})
return
}
adminUUID, _ := uuid.Parse(adminID.(string))
h.pool.Exec(ctx, `
INSERT INTO audit_log (actor_id, action, target_type, target_id, details)
VALUES ($1, 'admin_delete_post', 'post', $2::uuid, '{}')
`, adminUUID, postID)
// Record a strike on the author
if authorID != uuid.Nil {
reason := "Post deleted by moderation team"
h.pool.Exec(ctx, `
INSERT INTO content_strikes (user_id, category, content_snippet, created_at)
VALUES ($1, 'moderation', $2, NOW())
`, authorID, reason)
var strikeCount int
h.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM content_strikes
WHERE user_id = $1 AND created_at > NOW() - INTERVAL '30 days'
`, authorID).Scan(&strikeCount)
if h.emailService != nil && authorEmail != "" {
go func() {
if err := h.emailService.SendPostRemovalEmail(authorEmail, displayName, reason, strikeCount); err != nil {
log.Error().Err(err).Str("user", authorID.String()).Msg("Failed to send post removal email")
}
}()
}
}
c.JSON(http.StatusOK, gin.H{"message": "Post deleted"})
}
// ──────────────────────────────────────────────
// Bulk Actions
// ──────────────────────────────────────────────
func (h *AdminHandler) BulkUpdatePosts(c *gin.Context) {
ctx := c.Request.Context()
adminID, _ := c.Get("user_id")
var req struct {
IDs []string `json:"ids" binding:"required"`
Action string `json:"action" binding:"required"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var affected int64
for _, id := range req.IDs {
var err error
switch req.Action {
case "remove":
tag, e := h.pool.Exec(ctx, `UPDATE posts SET status = 'removed' WHERE id = $1::uuid AND deleted_at IS NULL`, id)
err = e
affected += tag.RowsAffected()
case "activate":
tag, e := h.pool.Exec(ctx, `UPDATE posts SET status = 'active' WHERE id = $1::uuid AND deleted_at IS NULL`, id)
err = e
affected += tag.RowsAffected()
case "delete":
tag, e := h.pool.Exec(ctx, `UPDATE posts SET deleted_at = NOW(), status = 'removed' WHERE id = $1::uuid`, id)
err = e
affected += tag.RowsAffected()
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action"})
return
}
if err != nil {
log.Error().Err(err).Str("post_id", id).Msg("Bulk post action failed")
}
}
adminUUID, _ := uuid.Parse(adminID.(string))
h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, details) VALUES ($1, $2, 'post', $3)`,
adminUUID, "bulk_"+req.Action+"_posts", fmt.Sprintf(`{"count":%d,"reason":"%s"}`, affected, req.Reason))
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%d posts updated", affected), "affected": affected})
}
func (h *AdminHandler) BulkUpdateUsers(c *gin.Context) {
ctx := c.Request.Context()
adminID, _ := c.Get("user_id")
var req struct {
IDs []string `json:"ids" binding:"required"`
Action string `json:"action" binding:"required"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var affected int64
for _, id := range req.IDs {
var err error
switch req.Action {
case "ban":
tag, e := h.pool.Exec(ctx, `UPDATE users SET status = 'banned' WHERE id = $1::uuid`, id)
err = e
affected += tag.RowsAffected()
case "suspend":
tag, e := h.pool.Exec(ctx, `UPDATE users SET status = 'suspended' WHERE id = $1::uuid`, id)
err = e
affected += tag.RowsAffected()
case "activate":
tag, e := h.pool.Exec(ctx, `UPDATE users SET status = 'active' WHERE id = $1::uuid`, id)
err = e
affected += tag.RowsAffected()
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action"})
return
}
if err != nil {
log.Error().Err(err).Str("user_id", id).Msg("Bulk user action failed")
}
}
adminUUID, _ := uuid.Parse(adminID.(string))
h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, details) VALUES ($1, $2, 'user', $3)`,
adminUUID, "bulk_"+req.Action+"_users", fmt.Sprintf(`{"count":%d,"reason":"%s"}`, affected, req.Reason))
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%d users updated", affected), "affected": affected})
}
func (h *AdminHandler) BulkReviewModeration(c *gin.Context) {
ctx := c.Request.Context()
adminID, _ := c.Get("user_id")
var req struct {
IDs []string `json:"ids" binding:"required"`
Action string `json:"action" binding:"required"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
adminUUID, _ := uuid.Parse(adminID.(string))
var affected int64
for _, id := range req.IDs {
var newStatus string
switch req.Action {
case "approve":
newStatus = "approved"
case "dismiss":
newStatus = "dismissed"
case "remove_content":
newStatus = "actioned"
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action"})
return
}
tag, err := h.pool.Exec(ctx,
`UPDATE moderation_flags SET status = $1, reviewed_by = $2, reviewed_at = NOW() WHERE id = $3::uuid AND status = 'pending'`,
newStatus, adminUUID, id)
if err != nil {
log.Error().Err(err).Str("flag_id", id).Msg("Bulk moderation action failed")
}
affected += tag.RowsAffected()
if req.Action == "remove_content" {
h.pool.Exec(ctx, `UPDATE posts SET status = 'removed' WHERE id = (SELECT post_id FROM moderation_flags WHERE id = $1::uuid)`, id)
}
}
h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, details) VALUES ($1, $2, 'moderation', $3)`,
adminUUID, "bulk_"+req.Action+"_moderation", fmt.Sprintf(`{"count":%d}`, affected))
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%d flags updated", affected), "affected": affected})
}
func (h *AdminHandler) BulkUpdateReports(c *gin.Context) {
ctx := c.Request.Context()
adminID, _ := c.Get("user_id")
var req struct {
IDs []string `json:"ids" binding:"required"`
Action string `json:"action" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Action != "actioned" && req.Action != "dismissed" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action, must be actioned or dismissed"})
return
}
var affected int64
for _, id := range req.IDs {
tag, err := h.pool.Exec(ctx, `UPDATE reports SET status = $1 WHERE id = $2::uuid AND status = 'pending'`, req.Action, id)
if err != nil {
log.Error().Err(err).Str("report_id", id).Msg("Bulk report action failed")
}
affected += tag.RowsAffected()
}
adminUUID, _ := uuid.Parse(adminID.(string))
h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, details) VALUES ($1, $2, 'report', $3)`,
adminUUID, "bulk_"+req.Action+"_reports", fmt.Sprintf(`{"count":%d}`, affected))
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%d reports updated", affected), "affected": affected})
}
// ──────────────────────────────────────────────
// Moderation Queue
// ──────────────────────────────────────────────
func (h *AdminHandler) GetModerationQueue(c *gin.Context) {
ctx := c.Request.Context()
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
statusFilter := c.DefaultQuery("status", "pending")
if limit > 200 {
limit = 200
}
rows, err := h.pool.Query(ctx, `
SELECT mf.id, mf.post_id, mf.comment_id, mf.flag_reason, mf.scores,
mf.status, mf.reviewed_by, mf.reviewed_at, mf.created_at,
p.body as post_body, p.image_url as post_image, p.video_url as post_video, p.author_id as post_author_id,
c.body as comment_body, c.author_id as comment_author_id,
COALESCE(pr_post.handle, pr_comment.handle) as author_handle,
COALESCE(pr_post.display_name, pr_comment.display_name) as author_display_name
FROM moderation_flags mf
LEFT JOIN posts p ON mf.post_id = p.id
LEFT JOIN comments c ON mf.comment_id = c.id
LEFT JOIN profiles pr_post ON p.author_id = pr_post.id
LEFT JOIN profiles pr_comment ON c.author_id = pr_comment.id
WHERE mf.status = $1
ORDER BY mf.created_at ASC
LIMIT $2 OFFSET $3
`, statusFilter, limit, offset)
if err != nil {
log.Error().Err(err).Msg("Failed to fetch moderation queue")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch moderation queue"})
return
}
defer rows.Close()
var total int
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM moderation_flags WHERE status = $1`, statusFilter).Scan(&total)
var items []gin.H
for rows.Next() {
var fID uuid.UUID
var postID, commentID *uuid.UUID
var flagReason, fStatus string
var fScores []byte
var reviewedBy *uuid.UUID
var reviewedAt *time.Time
var fCreatedAt time.Time
var postBody, postImage, postVideo *string
var postAuthorID, commentAuthorID *uuid.UUID
var commentBody *string
var authorHandle, authorDisplayName *string
if err := rows.Scan(&fID, &postID, &commentID, &flagReason, &fScores,
&fStatus, &reviewedBy, &reviewedAt, &fCreatedAt,
&postBody, &postImage, &postVideo, &postAuthorID,
&commentBody, &commentAuthorID,
&authorHandle, &authorDisplayName); err != nil {
log.Error().Err(err).Msg("Failed to scan moderation flag")
continue
}
var scores map[string]float64
json.Unmarshal(fScores, &scores)
contentType := "post"
if commentID != nil {
contentType = "comment"
}
items = append(items, gin.H{
"id": fID, "post_id": postID, "comment_id": commentID,
"flag_reason": flagReason, "scores": scores, "status": fStatus,
"reviewed_by": reviewedBy, "reviewed_at": reviewedAt, "created_at": fCreatedAt,
"content_type": contentType,
"post_body": postBody,
"post_image": postImage,
"post_video": postVideo,
"comment_body": commentBody,
"author_handle": authorHandle,
"author_name": authorDisplayName,
"post_author_id": postAuthorID,
"comment_author_id": commentAuthorID,
})
}
if items == nil {
items = []gin.H{}
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": total,
"limit": limit,
"offset": offset,
})
}
func (h *AdminHandler) ReviewModerationFlag(c *gin.Context) {
ctx := c.Request.Context()
adminID, _ := c.Get("user_id")
flagID := c.Param("id")
var req struct {
Action string `json:"action" binding:"required,oneof=approve dismiss remove_content ban_user"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
adminUUID, _ := uuid.Parse(adminID.(string))
flagUUID, err := uuid.Parse(flagID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid flag ID"})
return
}
switch req.Action {
case "approve":
// Content is fine, dismiss the flag
h.moderationService.UpdateFlagStatus(ctx, flagUUID, "dismissed", adminUUID)
case "dismiss":
h.moderationService.UpdateFlagStatus(ctx, flagUUID, "dismissed", adminUUID)
case "remove_content":
// Remove the flagged content
h.moderationService.UpdateFlagStatus(ctx, flagUUID, "actioned", adminUUID)
// Get the post/comment ID and remove
var postID, commentID *uuid.UUID
h.pool.QueryRow(ctx, `SELECT post_id, comment_id FROM moderation_flags WHERE id = $1`, flagUUID).Scan(&postID, &commentID)
if postID != nil {
h.pool.Exec(ctx, `UPDATE posts SET status = 'removed', deleted_at = NOW() WHERE id = $1`, postID)
}
if commentID != nil {
h.pool.Exec(ctx, `UPDATE comments SET status = 'removed', deleted_at = NOW() WHERE id = $1`, commentID)
}
case "ban_user":
h.moderationService.UpdateFlagStatus(ctx, flagUUID, "actioned", adminUUID)
// Get the author and ban them
var postID, commentID *uuid.UUID
h.pool.QueryRow(ctx, `SELECT post_id, comment_id FROM moderation_flags WHERE id = $1`, flagUUID).Scan(&postID, &commentID)
// Remove the flagged content
if postID != nil {
h.pool.Exec(ctx, `UPDATE posts SET status = 'removed', deleted_at = NOW() WHERE id = $1`, postID)
}
if commentID != nil {
h.pool.Exec(ctx, `UPDATE comments SET status = 'removed', deleted_at = NOW() WHERE id = $1`, commentID)
}
var authorID *uuid.UUID
if postID != nil {
h.pool.QueryRow(ctx, `SELECT author_id FROM posts WHERE id = $1`, postID).Scan(&authorID)
}
if commentID != nil && authorID == nil {
h.pool.QueryRow(ctx, `SELECT author_id FROM comments WHERE id = $1`, commentID).Scan(&authorID)
}
if authorID != nil {
// Ban the user
h.pool.Exec(ctx, `UPDATE users SET status = 'banned' WHERE id = $1`, authorID)
// Revoke all refresh tokens
h.pool.Exec(ctx, `UPDATE refresh_tokens SET revoked = true WHERE user_id = $1`, authorID)
// Jail all their content
h.pool.Exec(ctx, `UPDATE posts SET status = 'jailed' WHERE author_id = $1 AND status = 'active' AND deleted_at IS NULL`, authorID)
h.pool.Exec(ctx, `UPDATE comments SET status = 'jailed' WHERE author_id = $1 AND status = 'active' AND deleted_at IS NULL`, authorID)
// Log status change
h.pool.Exec(ctx, `INSERT INTO user_status_history (user_id, old_status, new_status, reason, changed_by) VALUES ($1, 'active', 'banned', $2, $3)`, authorID, req.Reason, adminUUID)
// Send ban email
if h.emailService != nil {
var userEmail, displayName string
h.pool.QueryRow(ctx, `SELECT u.email, COALESCE(p.display_name, '') FROM users u LEFT JOIN profiles p ON p.id = u.id WHERE u.id = $1`, authorID).Scan(&userEmail, &displayName)
if userEmail != "" {
go func() {
h.emailService.SendBanNotificationEmail(userEmail, displayName, req.Reason)
}()
}
}
}
}
c.JSON(http.StatusOK, gin.H{"message": "Flag reviewed", "action": req.Action})
}
// ──────────────────────────────────────────────
// Appeal Management
// ──────────────────────────────────────────────
func (h *AdminHandler) ListAppeals(c *gin.Context) {
ctx := c.Request.Context()
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
statusFilter := c.DefaultQuery("status", "pending")
if limit > 200 {
limit = 200
}
rows, err := h.pool.Query(ctx, `
SELECT ua.id, ua.user_violation_id, ua.user_id, ua.appeal_reason, ua.appeal_context,
ua.status, ua.reviewed_by, ua.review_decision, ua.reviewed_at, ua.created_at,
uv.violation_type, uv.violation_reason, uv.severity_score,
mf.flag_reason, mf.scores,
p.body as post_body, p.image_url,
c.body as comment_body,
pr.handle, pr.display_name, pr.avatar_url
FROM user_appeals ua
JOIN user_violations uv ON ua.user_violation_id = uv.id
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
JOIN profiles pr ON ua.user_id = pr.id
WHERE ua.status = $1
ORDER BY ua.created_at ASC
LIMIT $2 OFFSET $3
`, statusFilter, limit, offset)
if err != nil {
log.Error().Err(err).Msg("Failed to list appeals")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list appeals"})
return
}
defer rows.Close()
var total int
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM user_appeals WHERE status = $1`, statusFilter).Scan(&total)
var appeals []gin.H
for rows.Next() {
var aID, violationID, userID uuid.UUID
var appealReason, appealContext, aStatus string
var reviewedBy *uuid.UUID
var reviewDecision *string
var reviewedAt *time.Time
var aCreatedAt time.Time
var violationType, violationReason string
var severityScore float64
var flagReason *string
var flagScores []byte
var postBody, postImage, commentBody *string
var handle, displayName, avatarURL *string
if err := rows.Scan(&aID, &violationID, &userID, &appealReason, &appealContext,
&aStatus, &reviewedBy, &reviewDecision, &reviewedAt, &aCreatedAt,
&violationType, &violationReason, &severityScore,
&flagReason, &flagScores,
&postBody, &postImage, &commentBody,
&handle, &displayName, &avatarURL); err != nil {
log.Error().Err(err).Msg("Failed to scan appeal")
continue
}
var scores map[string]float64
if flagScores != nil {
json.Unmarshal(flagScores, &scores)
}
appeals = append(appeals, gin.H{
"id": aID, "violation_id": violationID, "user_id": userID,
"appeal_reason": appealReason, "appeal_context": appealContext,
"status": aStatus, "reviewed_by": reviewedBy, "review_decision": reviewDecision,
"reviewed_at": reviewedAt, "created_at": aCreatedAt,
"violation_type": violationType, "violation_reason": violationReason,
"severity_score": severityScore, "flag_reason": flagReason, "flag_scores": scores,
"post_body": postBody, "post_image": postImage, "comment_body": commentBody,
"user": gin.H{
"handle": handle, "display_name": displayName, "avatar_url": avatarURL,
},
})
}
if appeals == nil {
appeals = []gin.H{}
}
c.JSON(http.StatusOK, gin.H{
"appeals": appeals,
"total": total,
"limit": limit,
"offset": offset,
})
}
func (h *AdminHandler) ReviewAppeal(c *gin.Context) {
ctx := c.Request.Context()
adminID, _ := c.Get("user_id")
appealID := c.Param("id")
var req struct {
Decision string `json:"decision" binding:"required,oneof=approved rejected"`
ReviewDecision string `json:"review_decision" binding:"required,min=5"`
RestoreContent bool `json:"restore_content"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
adminUUID, _ := uuid.Parse(adminID.(string))
appealUUID, err := uuid.Parse(appealID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid appeal ID"})
return
}
err = h.appealService.ReviewAppeal(ctx, appealUUID, adminUUID, req.Decision, req.ReviewDecision)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to review appeal"})
return
}
// If approved and restore_content, restore the content
if req.Decision == "approved" && req.RestoreContent {
var violationID uuid.UUID
h.pool.QueryRow(ctx, `SELECT user_violation_id FROM user_appeals WHERE id = $1`, appealUUID).Scan(&violationID)
var flagID uuid.UUID
h.pool.QueryRow(ctx, `SELECT moderation_flag_id FROM user_violations WHERE id = $1`, violationID).Scan(&flagID)
var postID, commentID *uuid.UUID
h.pool.QueryRow(ctx, `SELECT post_id, comment_id FROM moderation_flags WHERE id = $1`, flagID).Scan(&postID, &commentID)
if postID != nil {
h.pool.Exec(ctx, `UPDATE posts SET status = 'active', deleted_at = NULL WHERE id = $1`, postID)
}
if commentID != nil {
h.pool.Exec(ctx, `UPDATE comments SET status = 'active', deleted_at = NULL WHERE id = $1`, commentID)
}
}
c.JSON(http.StatusOK, gin.H{"message": "Appeal reviewed", "decision": req.Decision})
}
// ──────────────────────────────────────────────
// Reports Management
// ──────────────────────────────────────────────
func (h *AdminHandler) ListReports(c *gin.Context) {
ctx := c.Request.Context()
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
statusFilter := c.DefaultQuery("status", "pending")
rows, err := h.pool.Query(ctx, `
SELECT r.id, r.reporter_id, r.target_user_id, r.post_id, r.comment_id,
r.violation_type, r.description, r.status, r.created_at,
pr_reporter.handle as reporter_handle,
pr_target.handle as target_handle
FROM reports r
LEFT JOIN profiles pr_reporter ON r.reporter_id = pr_reporter.id
LEFT JOIN profiles pr_target ON r.target_user_id = pr_target.id
WHERE r.status = $1
ORDER BY r.created_at ASC
LIMIT $2 OFFSET $3
`, statusFilter, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list reports"})
return
}
defer rows.Close()
var total int
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM reports WHERE status = $1`, statusFilter).Scan(&total)
var reports []gin.H
for rows.Next() {
var rID, reporterID, targetUserID uuid.UUID
var postID, commentID *uuid.UUID
var violationType, description, rStatus string
var rCreatedAt time.Time
var reporterHandle, targetHandle *string
if err := rows.Scan(&rID, &reporterID, &targetUserID, &postID, &commentID,
&violationType, &description, &rStatus, &rCreatedAt,
&reporterHandle, &targetHandle); err != nil {
continue
}
reports = append(reports, gin.H{
"id": rID, "reporter_id": reporterID, "target_user_id": targetUserID,
"post_id": postID, "comment_id": commentID,
"violation_type": violationType, "description": description,
"status": rStatus, "created_at": rCreatedAt,
"reporter_handle": reporterHandle, "target_handle": targetHandle,
})
}
if reports == nil {
reports = []gin.H{}
}
c.JSON(http.StatusOK, gin.H{"reports": reports, "total": total, "limit": limit, "offset": offset})
}
func (h *AdminHandler) UpdateReportStatus(c *gin.Context) {
ctx := c.Request.Context()
reportID := c.Param("id")
var req struct {
Status string `json:"status" binding:"required,oneof=reviewed dismissed actioned"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := h.pool.Exec(ctx, `UPDATE reports SET status = $1 WHERE id = $2::uuid`, req.Status, reportID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update report"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Report updated"})
}
// ──────────────────────────────────────────────
// Algorithm / Feed Settings
// ──────────────────────────────────────────────
func (h *AdminHandler) GetAlgorithmConfig(c *gin.Context) {
ctx := c.Request.Context()
rows, err := h.pool.Query(ctx, `
SELECT key, value, description, updated_at
FROM algorithm_config
ORDER BY key
`)
if err != nil {
// Table may not exist yet, return defaults
c.JSON(http.StatusOK, gin.H{
"config": []gin.H{
{"key": "feed_recency_weight", "value": "0.4", "description": "Weight for post recency in feed ranking"},
{"key": "feed_engagement_weight", "value": "0.3", "description": "Weight for engagement metrics"},
{"key": "feed_harmony_weight", "value": "0.2", "description": "Weight for author harmony score"},
{"key": "feed_diversity_weight", "value": "0.1", "description": "Weight for content diversity"},
{"key": "moderation_auto_flag_threshold", "value": "0.7", "description": "AI score threshold for auto-flagging"},
{"key": "moderation_auto_remove_threshold", "value": "0.95", "description": "AI score threshold for auto-removal"},
},
})
return
}
defer rows.Close()
var configs []gin.H
for rows.Next() {
var key, value string
var description *string
var updatedAt time.Time
if err := rows.Scan(&key, &value, &description, &updatedAt); err == nil {
configs = append(configs, gin.H{
"key": key, "value": value, "description": description, "updated_at": updatedAt,
})
}
}
if configs == nil {
configs = []gin.H{}
}
c.JSON(http.StatusOK, gin.H{"config": configs})
}
func (h *AdminHandler) UpdateAlgorithmConfig(c *gin.Context) {
ctx := c.Request.Context()
var req struct {
Key string `json:"key" binding:"required"`
Value string `json:"value" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := h.pool.Exec(ctx, `
INSERT INTO algorithm_config (key, value, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()
`, req.Key, req.Value)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update config"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Config updated"})
}
// ──────────────────────────────────────────────
// Categories Management
// ──────────────────────────────────────────────
func (h *AdminHandler) ListCategories(c *gin.Context) {
ctx := c.Request.Context()
rows, err := h.pool.Query(ctx, `
SELECT id, slug, name, description, is_sensitive, created_at
FROM categories ORDER BY name
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list categories"})
return
}
defer rows.Close()
var categories []gin.H
for rows.Next() {
var id uuid.UUID
var slug, name string
var description *string
var isSensitive bool
var createdAt time.Time
if err := rows.Scan(&id, &slug, &name, &description, &isSensitive, &createdAt); err == nil {
categories = append(categories, gin.H{
"id": id, "slug": slug, "name": name, "description": description,
"is_sensitive": isSensitive, "created_at": createdAt,
})
}
}
c.JSON(http.StatusOK, gin.H{"categories": categories})
}
func (h *AdminHandler) CreateCategory(c *gin.Context) {
ctx := c.Request.Context()
var req struct {
Slug string `json:"slug" binding:"required"`
Name string `json:"name" binding:"required"`
Description string `json:"description"`
IsSensitive bool `json:"is_sensitive"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var id uuid.UUID
err := h.pool.QueryRow(ctx, `
INSERT INTO categories (slug, name, description, is_sensitive)
VALUES ($1, $2, $3, $4) RETURNING id
`, req.Slug, req.Name, req.Description, req.IsSensitive).Scan(&id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create category"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": id, "message": "Category created"})
}
func (h *AdminHandler) UpdateCategory(c *gin.Context) {
ctx := c.Request.Context()
catID := c.Param("id")
var req struct {
Name string `json:"name"`
Description string `json:"description"`
IsSensitive *bool `json:"is_sensitive"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Name != "" {
h.pool.Exec(ctx, `UPDATE categories SET name = $1 WHERE id = $2::uuid`, req.Name, catID)
}
if req.Description != "" {
h.pool.Exec(ctx, `UPDATE categories SET description = $1 WHERE id = $2::uuid`, req.Description, catID)
}
if req.IsSensitive != nil {
h.pool.Exec(ctx, `UPDATE categories SET is_sensitive = $1 WHERE id = $2::uuid`, *req.IsSensitive, catID)
}
c.JSON(http.StatusOK, gin.H{"message": "Category updated"})
}
// ──────────────────────────────────────────────
// System Health
// ──────────────────────────────────────────────
func (h *AdminHandler) GetSystemHealth(c *gin.Context) {
ctx := c.Request.Context()
health := gin.H{"status": "healthy"}
// Database check
start := time.Now()
err := h.pool.Ping(ctx)
dbLatency := time.Since(start).Milliseconds()
if err != nil {
health["database"] = gin.H{"status": "unhealthy", "error": err.Error()}
} else {
health["database"] = gin.H{"status": "healthy", "latency_ms": dbLatency}
}
// Pool stats
poolStats := h.pool.Stat()
health["connection_pool"] = gin.H{
"total": poolStats.TotalConns(),
"idle": poolStats.IdleConns(),
"acquired": poolStats.AcquiredConns(),
"max": poolStats.MaxConns(),
"constructing": poolStats.ConstructingConns(),
}
// Table sizes
var dbSize string
h.pool.QueryRow(ctx, `SELECT pg_size_pretty(pg_database_size('sojorn'))`).Scan(&dbSize)
health["database_size"] = dbSize
c.JSON(http.StatusOK, health)
}
// ──────────────────────────────────────────────
// Audit Log
// ──────────────────────────────────────────────
func (h *AdminHandler) GetAuditLog(c *gin.Context) {
ctx := c.Request.Context()
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
rows, err := h.pool.Query(ctx, `
SELECT al.id, al.actor_id, al.action, al.target_type, al.target_id, al.details, al.created_at,
pr.handle as actor_handle
FROM audit_log al
LEFT JOIN profiles pr ON al.actor_id = pr.id
ORDER BY al.created_at DESC
LIMIT $1 OFFSET $2
`, limit, offset)
if err != nil {
// Table may not exist
c.JSON(http.StatusOK, gin.H{"entries": []gin.H{}, "total": 0})
return
}
defer rows.Close()
var entries []gin.H
for rows.Next() {
var id uuid.UUID
var actorID *uuid.UUID
var action, targetType string
var targetID *uuid.UUID
var details *string
var createdAt time.Time
var actorHandle *string
if err := rows.Scan(&id, &actorID, &action, &targetType, &targetID, &details, &createdAt, &actorHandle); err == nil {
entries = append(entries, gin.H{
"id": id, "actor_id": actorID, "action": action,
"target_type": targetType, "target_id": targetID,
"details": details, "created_at": createdAt, "actor_handle": actorHandle,
})
}
}
if entries == nil {
entries = []gin.H{}
}
c.JSON(http.StatusOK, gin.H{"entries": entries, "limit": limit, "offset": offset})
}
// ──────────────────────────────────────────────
// R2 Storage Browser
// ──────────────────────────────────────────────
func (h *AdminHandler) GetStorageStats(c *gin.Context) {
if h.s3Client == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "R2 storage not configured"})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel()
type bucketStats struct {
Name string `json:"name"`
ObjectCount int `json:"object_count"`
TotalSize int64 `json:"total_size"`
Domain string `json:"domain"`
}
getBucketStats := func(bucket, domain string) bucketStats {
stats := bucketStats{Name: bucket, Domain: domain}
var continuationToken *string
for {
input := &s3.ListObjectsV2Input{
Bucket: aws.String(bucket),
ContinuationToken: continuationToken,
MaxKeys: aws.Int32(1000),
}
output, err := h.s3Client.ListObjectsV2(ctx, input)
if err != nil {
log.Error().Err(err).Str("bucket", bucket).Msg("Failed to list R2 objects for stats")
break
}
for _, obj := range output.Contents {
stats.ObjectCount++
stats.TotalSize += aws.ToInt64(obj.Size)
}
if !aws.ToBool(output.IsTruncated) {
break
}
continuationToken = output.NextContinuationToken
}
return stats
}
mediaStats := getBucketStats(h.mediaBucket, h.imgDomain)
videoStats := getBucketStats(h.videoBucket, h.vidDomain)
c.JSON(http.StatusOK, gin.H{
"buckets": []bucketStats{mediaStats, videoStats},
"total_objects": mediaStats.ObjectCount + videoStats.ObjectCount,
"total_size": mediaStats.TotalSize + videoStats.TotalSize,
})
}
func (h *AdminHandler) ListStorageObjects(c *gin.Context) {
if h.s3Client == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "R2 storage not configured"})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
bucket := c.DefaultQuery("bucket", h.mediaBucket)
prefix := c.Query("prefix")
marker := c.Query("cursor")
limitStr := c.DefaultQuery("limit", "50")
limit, _ := strconv.Atoi(limitStr)
if limit > 200 {
limit = 200
}
// Validate bucket
if bucket != h.mediaBucket && bucket != h.videoBucket {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bucket name"})
return
}
// Determine public domain
domain := h.imgDomain
if bucket == h.videoBucket {
domain = h.vidDomain
}
input := &s3.ListObjectsV2Input{
Bucket: aws.String(bucket),
MaxKeys: aws.Int32(int32(limit)),
}
if prefix != "" {
input.Prefix = aws.String(prefix)
input.Delimiter = aws.String("/")
}
if marker != "" {
input.ContinuationToken = aws.String(marker)
}
output, err := h.s3Client.ListObjectsV2(ctx, input)
if err != nil {
log.Error().Err(err).Str("bucket", bucket).Msg("Failed to list R2 objects")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list objects"})
return
}
// Folders (common prefixes)
var folders []string
for _, cp := range output.CommonPrefixes {
folders = append(folders, aws.ToString(cp.Prefix))
}
// Objects
var objects []gin.H
for _, obj := range output.Contents {
key := aws.ToString(obj.Key)
publicURL := fmt.Sprintf("https://%s/%s", domain, key)
objects = append(objects, gin.H{
"key": key,
"size": aws.ToInt64(obj.Size),
"last_modified": obj.LastModified,
"etag": strings.Trim(aws.ToString(obj.ETag), "\""),
"url": publicURL,
})
}
if objects == nil {
objects = []gin.H{}
}
if folders == nil {
folders = []string{}
}
var nextCursor *string
if aws.ToBool(output.IsTruncated) {
nextCursor = output.NextContinuationToken
}
c.JSON(http.StatusOK, gin.H{
"objects": objects,
"folders": folders,
"bucket": bucket,
"prefix": prefix,
"next_cursor": nextCursor,
"count": len(objects),
})
}
func (h *AdminHandler) GetStorageObject(c *gin.Context) {
if h.s3Client == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "R2 storage not configured"})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
bucket := c.DefaultQuery("bucket", h.mediaBucket)
key := c.Query("key")
if key == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "key parameter required"})
return
}
if bucket != h.mediaBucket && bucket != h.videoBucket {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bucket name"})
return
}
domain := h.imgDomain
if bucket == h.videoBucket {
domain = h.vidDomain
}
output, err := h.s3Client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
})
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Object not found"})
return
}
c.JSON(http.StatusOK, gin.H{
"key": key,
"bucket": bucket,
"size": aws.ToInt64(output.ContentLength),
"content_type": aws.ToString(output.ContentType),
"last_modified": output.LastModified,
"etag": strings.Trim(aws.ToString(output.ETag), "\""),
"url": fmt.Sprintf("https://%s/%s", domain, key),
"cache_control": aws.ToString(output.CacheControl),
})
}
func (h *AdminHandler) DeleteStorageObject(c *gin.Context) {
if h.s3Client == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "R2 storage not configured"})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
adminID, _ := c.Get("user_id")
var req struct {
Bucket string `json:"bucket" binding:"required"`
Key string `json:"key" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Bucket != h.mediaBucket && req.Bucket != h.videoBucket {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bucket name"})
return
}
_, err := h.s3Client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(req.Bucket),
Key: aws.String(req.Key),
})
if err != nil {
log.Error().Err(err).Str("bucket", req.Bucket).Str("key", req.Key).Msg("Failed to delete R2 object")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete object"})
return
}
// Audit log
adminUUID, _ := uuid.Parse(adminID.(string))
h.pool.Exec(ctx, `
INSERT INTO audit_log (actor_id, action, target_type, target_id, details)
VALUES ($1, 'admin_delete_storage_object', 'storage', NULL, $2)
`, adminUUID, fmt.Sprintf(`{"bucket":"%s","key":"%s"}`, req.Bucket, req.Key))
c.JSON(http.StatusOK, gin.H{"message": "Object deleted"})
}
// ──────────────────────────────────────────────
// Reserved Usernames Management
// ──────────────────────────────────────────────
func (h *AdminHandler) ListReservedUsernames(c *gin.Context) {
ctx := c.Request.Context()
category := c.Query("category")
search := c.Query("search")
limit := 50
offset := 0
if v, err := strconv.Atoi(c.Query("limit")); err == nil && v > 0 {
limit = v
}
if v, err := strconv.Atoi(c.Query("offset")); err == nil && v >= 0 {
offset = v
}
query := `SELECT id, username, category, reason, created_at FROM reserved_usernames WHERE 1=1`
args := []interface{}{}
argIdx := 1
if category != "" {
query += fmt.Sprintf(` AND category = $%d`, argIdx)
args = append(args, category)
argIdx++
}
if search != "" {
query += fmt.Sprintf(` AND username ILIKE $%d`, argIdx)
args = append(args, "%"+search+"%")
argIdx++
}
// Count
countQuery := strings.Replace(query, "id, username, category, reason, created_at", "COUNT(*)", 1)
var total int
h.pool.QueryRow(ctx, countQuery, args...).Scan(&total)
query += fmt.Sprintf(` ORDER BY username ASC LIMIT $%d OFFSET $%d`, argIdx, argIdx+1)
args = append(args, limit, offset)
rows, err := h.pool.Query(ctx, query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list reserved usernames"})
return
}
defer rows.Close()
type ReservedUsername struct {
ID string `json:"id"`
Username string `json:"username"`
Category string `json:"category"`
Reason *string `json:"reason"`
CreatedAt time.Time `json:"created_at"`
}
var items []ReservedUsername
for rows.Next() {
var item ReservedUsername
if err := rows.Scan(&item.ID, &item.Username, &item.Category, &item.Reason, &item.CreatedAt); err == nil {
items = append(items, item)
}
}
if items == nil {
items = []ReservedUsername{}
}
c.JSON(http.StatusOK, gin.H{"reserved_usernames": items, "total": total})
}
func (h *AdminHandler) AddReservedUsername(c *gin.Context) {
ctx := c.Request.Context()
adminID, _ := c.Get("user_id")
var req struct {
Username string `json:"username" binding:"required"`
Category string `json:"category"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Username = strings.ToLower(strings.TrimSpace(req.Username))
if req.Category == "" {
req.Category = "custom"
}
adminUUID, _ := uuid.Parse(adminID.(string))
_, err := h.pool.Exec(ctx, `
INSERT INTO reserved_usernames (username, category, reason, added_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (username) DO UPDATE SET category = $2, reason = $3
`, req.Username, req.Category, req.Reason, adminUUID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add reserved username"})
return
}
// Audit
h.pool.Exec(ctx, `
INSERT INTO audit_log (actor_id, action, target_type, details)
VALUES ($1, 'admin_reserve_username', 'username', $2)
`, adminUUID, fmt.Sprintf(`{"username":"%s","category":"%s"}`, req.Username, req.Category))
c.JSON(http.StatusOK, gin.H{"message": "Username reserved"})
}
func (h *AdminHandler) RemoveReservedUsername(c *gin.Context) {
ctx := c.Request.Context()
adminID, _ := c.Get("user_id")
id := c.Param("id")
var username string
h.pool.QueryRow(ctx, `SELECT username FROM reserved_usernames WHERE id = $1`, id).Scan(&username)
_, err := h.pool.Exec(ctx, `DELETE FROM reserved_usernames WHERE id = $1`, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove reserved username"})
return
}
adminUUID, _ := uuid.Parse(adminID.(string))
h.pool.Exec(ctx, `
INSERT INTO audit_log (actor_id, action, target_type, details)
VALUES ($1, 'admin_unreserve_username', 'username', $2)
`, adminUUID, fmt.Sprintf(`{"username":"%s"}`, username))
c.JSON(http.StatusOK, gin.H{"message": "Reserved username removed"})
}
func (h *AdminHandler) BulkAddReservedUsernames(c *gin.Context) {
ctx := c.Request.Context()
adminID, _ := c.Get("user_id")
var req struct {
Usernames []string `json:"usernames" binding:"required"`
Category string `json:"category"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Category == "" {
req.Category = "custom"
}
adminUUID, _ := uuid.Parse(adminID.(string))
added := 0
for _, u := range req.Usernames {
u = strings.ToLower(strings.TrimSpace(u))
if u == "" {
continue
}
_, err := h.pool.Exec(ctx, `
INSERT INTO reserved_usernames (username, category, reason, added_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (username) DO NOTHING
`, u, req.Category, req.Reason, adminUUID)
if err == nil {
added++
}
}
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Added %d reserved usernames", added), "added": added})
}
// ──────────────────────────────────────────────
// Username Claim Requests
// ──────────────────────────────────────────────
func (h *AdminHandler) ListClaimRequests(c *gin.Context) {
ctx := c.Request.Context()
status := c.DefaultQuery("status", "pending")
limit := 50
offset := 0
if v, err := strconv.Atoi(c.Query("limit")); err == nil && v > 0 {
limit = v
}
if v, err := strconv.Atoi(c.Query("offset")); err == nil && v >= 0 {
offset = v
}
var total int
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM username_claim_requests WHERE status = $1`, status).Scan(&total)
rows, err := h.pool.Query(ctx, `
SELECT id, requested_username, requester_email, requester_name, requester_user_id,
organization, justification, proof_url, status, review_notes, reviewed_at, created_at
FROM username_claim_requests
WHERE status = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`, status, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list claim requests"})
return
}
defer rows.Close()
type ClaimRequest struct {
ID string `json:"id"`
RequestedUsername string `json:"requested_username"`
RequesterEmail string `json:"requester_email"`
RequesterName *string `json:"requester_name"`
RequesterUserID *string `json:"requester_user_id"`
Organization *string `json:"organization"`
Justification string `json:"justification"`
ProofURL *string `json:"proof_url"`
Status string `json:"status"`
ReviewNotes *string `json:"review_notes"`
ReviewedAt *time.Time `json:"reviewed_at"`
CreatedAt time.Time `json:"created_at"`
}
var items []ClaimRequest
for rows.Next() {
var item ClaimRequest
if err := rows.Scan(
&item.ID, &item.RequestedUsername, &item.RequesterEmail, &item.RequesterName,
&item.RequesterUserID, &item.Organization, &item.Justification, &item.ProofURL,
&item.Status, &item.ReviewNotes, &item.ReviewedAt, &item.CreatedAt,
); err == nil {
items = append(items, item)
}
}
if items == nil {
items = []ClaimRequest{}
}
c.JSON(http.StatusOK, gin.H{"claim_requests": items, "total": total})
}
func (h *AdminHandler) ReviewClaimRequest(c *gin.Context) {
ctx := c.Request.Context()
adminID, _ := c.Get("user_id")
id := c.Param("id")
var req struct {
Decision string `json:"decision" binding:"required"` // approved, denied
Notes string `json:"notes"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Decision != "approved" && req.Decision != "denied" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Decision must be 'approved' or 'denied'"})
return
}
adminUUID, _ := uuid.Parse(adminID.(string))
_, err := h.pool.Exec(ctx, `
UPDATE username_claim_requests
SET status = $1, reviewer_id = $2, review_notes = $3, reviewed_at = NOW(), updated_at = NOW()
WHERE id = $4
`, req.Decision, adminUUID, req.Notes, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update claim request"})
return
}
// If approved, remove from reserved list so user can register it
if req.Decision == "approved" {
var username string
h.pool.QueryRow(ctx, `SELECT requested_username FROM username_claim_requests WHERE id = $1`, id).Scan(&username)
if username != "" {
h.pool.Exec(ctx, `DELETE FROM reserved_usernames WHERE username = $1`, strings.ToLower(username))
}
}
// Audit
h.pool.Exec(ctx, `
INSERT INTO audit_log (actor_id, action, target_type, target_id, details)
VALUES ($1, 'admin_review_claim', 'claim_request', $2, $3)
`, adminUUID, id, fmt.Sprintf(`{"decision":"%s"}`, req.Decision))
c.JSON(http.StatusOK, gin.H{"message": "Claim request " + req.Decision})
}
// ──────────────────────────────────────────────
// Public: Submit a claim request (no auth required)
// ──────────────────────────────────────────────
func (h *AdminHandler) SubmitClaimRequest(c *gin.Context) {
ctx := c.Request.Context()
var req struct {
RequestedUsername string `json:"requested_username" binding:"required"`
Email string `json:"email" binding:"required,email"`
Name string `json:"name"`
Organization string `json:"organization"`
Justification string `json:"justification" binding:"required"`
ProofURL string `json:"proof_url"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Check for duplicate pending request
var existing int
h.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM username_claim_requests
WHERE requested_username = $1 AND requester_email = $2 AND status = 'pending'
`, strings.ToLower(req.RequestedUsername), strings.ToLower(req.Email)).Scan(&existing)
if existing > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "You already have a pending claim for this username"})
return
}
_, err := h.pool.Exec(ctx, `
INSERT INTO username_claim_requests (requested_username, requester_email, requester_name, organization, justification, proof_url)
VALUES ($1, $2, $3, $4, $5, $6)
`, strings.ToLower(req.RequestedUsername), strings.ToLower(req.Email), req.Name, req.Organization, req.Justification, req.ProofURL)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to submit claim request"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Your claim request has been submitted and will be reviewed by our team."})
}
// ──────────────────────────────────────────────
// AI Moderation Config
// ──────────────────────────────────────────────
func (h *AdminHandler) ListOpenRouterModels(c *gin.Context) {
if h.openRouterService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OpenRouter not configured"})
return
}
models, err := h.openRouterService.ListModels(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to fetch models: %v", err)})
return
}
// Optional filter by capability
capability := c.Query("capability") // "text", "image", "vision", "free"
if capability != "" {
var filtered []services.OpenRouterModel
for _, m := range models {
switch capability {
case "free":
if m.Pricing.Prompt == "0" || m.Pricing.Prompt == "0.0" {
filtered = append(filtered, m)
}
case "vision", "image":
if arch, ok := m.Architecture["modality"].(string); ok {
if strings.Contains(arch, "image") {
filtered = append(filtered, m)
}
}
default:
filtered = append(filtered, m)
}
}
models = filtered
}
// Search filter
search := strings.ToLower(c.Query("search"))
if search != "" {
var filtered []services.OpenRouterModel
for _, m := range models {
if strings.Contains(strings.ToLower(m.ID), search) || strings.Contains(strings.ToLower(m.Name), search) {
filtered = append(filtered, m)
}
}
models = filtered
}
c.JSON(http.StatusOK, gin.H{"models": models, "total": len(models)})
}
func (h *AdminHandler) GetAIModerationConfigs(c *gin.Context) {
if h.openRouterService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OpenRouter not configured"})
return
}
configs, err := h.openRouterService.GetModerationConfigs(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to fetch configs: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"configs": configs})
}
func (h *AdminHandler) SetAIModerationConfig(c *gin.Context) {
if h.openRouterService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OpenRouter not configured"})
return
}
var req struct {
ModerationType string `json:"moderation_type" binding:"required"`
ModelID string `json:"model_id"`
ModelName string `json:"model_name"`
SystemPrompt string `json:"system_prompt"`
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.ModerationType != "text" && req.ModerationType != "image" && req.ModerationType != "video" {
c.JSON(http.StatusBadRequest, gin.H{"error": "moderation_type must be text, image, or video"})
return
}
adminID := c.GetString("user_id")
err := h.openRouterService.SetModerationConfig(c.Request.Context(), req.ModerationType, req.ModelID, req.ModelName, req.SystemPrompt, req.Enabled, adminID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to save config: %v", err)})
return
}
// Audit log
h.pool.Exec(c.Request.Context(), `
INSERT INTO audit_log (admin_id, action, target_type, details)
VALUES ($1, 'update_ai_moderation', 'ai_config', $2)
`, adminID, fmt.Sprintf("Set %s moderation model to %s (enabled=%v)", req.ModerationType, req.ModelID, req.Enabled))
c.JSON(http.StatusOK, gin.H{"message": "Configuration updated"})
}
func (h *AdminHandler) TestAIModeration(c *gin.Context) {
if h.openRouterService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OpenRouter not configured"})
return
}
var req struct {
ModerationType string `json:"moderation_type" binding:"required"`
Content string `json:"content"`
ImageURL string `json:"image_url"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
var result *services.ModerationResult
var err error
switch req.ModerationType {
case "text":
result, err = h.openRouterService.ModerateText(ctx, req.Content)
case "image":
result, err = h.openRouterService.ModerateImage(ctx, req.ImageURL)
case "video":
urls := strings.Split(req.ImageURL, ",")
result, err = h.openRouterService.ModerateVideo(ctx, urls)
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid moderation_type"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"result": result})
}