1286 lines
42 KiB
Go
1286 lines
42 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/patbritton/sojorn-backend/internal/services"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
type AdminHandler struct {
|
|
pool *pgxpool.Pool
|
|
moderationService *services.ModerationService
|
|
appealService *services.AppealService
|
|
}
|
|
|
|
func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationService, appealService *services.AppealService) *AdminHandler {
|
|
return &AdminHandler{
|
|
pool: pool,
|
|
moderationService: moderationService,
|
|
appealService: appealService,
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// 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
|
|
_, err := h.pool.Exec(ctx, `UPDATE users SET status = $1 WHERE id = $2::uuid`, req.Status, targetUserID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user status"})
|
|
return
|
|
}
|
|
|
|
// 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)
|
|
|
|
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,
|
|
p.like_count, p.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,
|
|
p.like_count, p.comment_count, 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))
|
|
|
|
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")
|
|
|
|
_, 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)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Post deleted"})
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// 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)
|
|
|
|
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 {
|
|
h.pool.QueryRow(ctx, `SELECT author_id FROM comments WHERE id = $1`, commentID).Scan(&authorID)
|
|
}
|
|
if authorID != nil {
|
|
h.moderationService.UpdateUserStatus(ctx, *authorID, "banned", adminUUID, 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})
|
|
}
|