- Add development bypass to Flutter sign_in_screen.dart - Re-enable Turnstile in admin_handler.go with dev bypass - Add development bypass to admin login page - Resolves login issues during development/testing
4142 lines
136 KiB
Go
4142 lines
136 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"path/filepath"
|
|
"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
|
|
azureOpenAIService *services.AzureOpenAIService
|
|
officialAccountsService *services.OfficialAccountsService
|
|
linkPreviewService *services.LinkPreviewService
|
|
localAIService *services.LocalAIService
|
|
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, azureOpenAIService *services.AzureOpenAIService, officialAccountsService *services.OfficialAccountsService, linkPreviewService *services.LinkPreviewService, localAIService *services.LocalAIService, 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,
|
|
azureOpenAIService: azureOpenAIService,
|
|
officialAccountsService: officialAccountsService,
|
|
linkPreviewService: linkPreviewService,
|
|
localAIService: localAIService,
|
|
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 != "" {
|
|
// Allow bypass for development
|
|
if req.TurnstileToken != "BYPASS_DEV_MODE" {
|
|
if strings.TrimSpace(req.TurnstileToken) == "" {
|
|
log.Warn().Str("email", req.Email).Msg("Admin login: missing Turnstile token")
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"})
|
|
return
|
|
}
|
|
turnstileService := services.NewTurnstileService(h.turnstileSecret)
|
|
turnstileResp, err := turnstileService.VerifyToken(req.TurnstileToken, "")
|
|
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).
|
|
Str("hostname", turnstileResp.Hostname).
|
|
Str("action", turnstileResp.Action).
|
|
Msg("Admin login: Turnstile validation failed")
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"})
|
|
return
|
|
}
|
|
} else {
|
|
log.Info().Str("email", req.Email).Msg("Admin login: using development bypass")
|
|
}
|
|
}
|
|
|
|
// 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"})
|
|
}
|
|
|
|
// AdminUpdateProfile allows full profile editing for official accounts.
|
|
func (h *AdminHandler) AdminUpdateProfile(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
targetUserID := c.Param("id")
|
|
|
|
var req struct {
|
|
Handle *string `json:"handle"`
|
|
DisplayName *string `json:"display_name"`
|
|
Bio *string `json:"bio"`
|
|
AvatarURL *string `json:"avatar_url"`
|
|
CoverURL *string `json:"cover_url"`
|
|
Location *string `json:"location"`
|
|
Website *string `json:"website"`
|
|
Country *string `json:"origin_country"`
|
|
BirthMonth *int `json:"birth_month"`
|
|
BirthYear *int `json:"birth_year"`
|
|
IsPrivate *bool `json:"is_private"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Build dynamic UPDATE
|
|
sets := []string{}
|
|
args := []interface{}{}
|
|
idx := 1
|
|
|
|
addField := func(col string, val interface{}) {
|
|
sets = append(sets, fmt.Sprintf("%s = $%d", col, idx))
|
|
args = append(args, val)
|
|
idx++
|
|
}
|
|
|
|
if req.Handle != nil {
|
|
addField("handle", *req.Handle)
|
|
}
|
|
if req.DisplayName != nil {
|
|
addField("display_name", *req.DisplayName)
|
|
}
|
|
if req.Bio != nil {
|
|
addField("bio", *req.Bio)
|
|
}
|
|
if req.AvatarURL != nil {
|
|
addField("avatar_url", *req.AvatarURL)
|
|
}
|
|
if req.CoverURL != nil {
|
|
addField("cover_url", *req.CoverURL)
|
|
}
|
|
if req.Location != nil {
|
|
addField("location", *req.Location)
|
|
}
|
|
if req.Website != nil {
|
|
addField("website", *req.Website)
|
|
}
|
|
if req.Country != nil {
|
|
addField("origin_country", *req.Country)
|
|
}
|
|
if req.BirthMonth != nil {
|
|
addField("birth_month", *req.BirthMonth)
|
|
}
|
|
if req.BirthYear != nil {
|
|
addField("birth_year", *req.BirthYear)
|
|
}
|
|
if req.IsPrivate != nil {
|
|
addField("is_private", *req.IsPrivate)
|
|
}
|
|
|
|
if len(sets) == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"})
|
|
return
|
|
}
|
|
|
|
sets = append(sets, fmt.Sprintf("updated_at = $%d", idx))
|
|
args = append(args, time.Now())
|
|
idx++
|
|
|
|
query := fmt.Sprintf("UPDATE profiles SET %s WHERE id = $%d::uuid", strings.Join(sets, ", "), idx)
|
|
args = append(args, targetUserID)
|
|
|
|
_, err := h.pool.Exec(ctx, query, args...)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("user_id", targetUserID).Msg("Failed to update profile")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Profile updated"})
|
|
}
|
|
|
|
// resolveUserID accepts either a UUID or a handle and returns the user's UUID.
|
|
func (h *AdminHandler) resolveUserID(ctx context.Context, input string) (string, error) {
|
|
// If it parses as a UUID, return as-is
|
|
if _, err := uuid.Parse(input); err == nil {
|
|
return input, nil
|
|
}
|
|
// Otherwise, treat as a handle and look it up
|
|
handle := strings.TrimPrefix(input, "@")
|
|
var id uuid.UUID
|
|
err := h.pool.QueryRow(ctx, `SELECT id FROM profiles WHERE handle = $1`, handle).Scan(&id)
|
|
if err != nil {
|
|
return "", fmt.Errorf("user not found: %s", input)
|
|
}
|
|
return id.String(), nil
|
|
}
|
|
|
|
// AdminManageFollow adds or removes follow relationships for official accounts.
|
|
func (h *AdminHandler) AdminManageFollow(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
targetUserID := c.Param("id")
|
|
|
|
var req struct {
|
|
Action string `json:"action"` // "add" or "remove"
|
|
UserID string `json:"user_id"` // UUID or handle of the other user
|
|
Relation string `json:"relation"` // "follower" (user follows target) or "following" (target follows user)
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if req.Action != "add" && req.Action != "remove" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "action must be 'add' or 'remove'"})
|
|
return
|
|
}
|
|
if req.Relation != "follower" && req.Relation != "following" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "relation must be 'follower' or 'following'"})
|
|
return
|
|
}
|
|
|
|
// Resolve handle or UUID to a UUID
|
|
resolvedID, err := h.resolveUserID(ctx, req.UserID)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Determine follower_id and following_id
|
|
var followerID, followingID string
|
|
if req.Relation == "follower" {
|
|
followerID = resolvedID
|
|
followingID = targetUserID
|
|
} else {
|
|
followerID = targetUserID
|
|
followingID = resolvedID
|
|
}
|
|
|
|
if req.Action == "add" {
|
|
_, err := h.pool.Exec(ctx, `
|
|
INSERT INTO follows (follower_id, following_id, status) VALUES ($1::uuid, $2::uuid, 'accepted')
|
|
ON CONFLICT (follower_id, following_id) DO UPDATE SET status = 'accepted'
|
|
`, followerID, followingID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add follow: " + err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"message": "Follow added"})
|
|
} else {
|
|
_, err := h.pool.Exec(ctx, `DELETE FROM follows WHERE follower_id = $1::uuid AND following_id = $2::uuid`, followerID, followingID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove follow: " + err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"message": "Follow removed"})
|
|
}
|
|
}
|
|
|
|
// AdminListFollows lists followers or following for a user.
|
|
func (h *AdminHandler) AdminListFollows(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
targetUserID := c.Param("id")
|
|
relation := c.DefaultQuery("relation", "followers") // "followers" or "following"
|
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
|
|
|
var query string
|
|
if relation == "following" {
|
|
query = `
|
|
SELECT p.id, p.handle, p.display_name, p.avatar_url, p.is_official, f.created_at
|
|
FROM follows f JOIN profiles p ON p.id = f.following_id
|
|
WHERE f.follower_id = $1::uuid AND f.status = 'accepted'
|
|
ORDER BY f.created_at DESC LIMIT $2
|
|
`
|
|
} else {
|
|
query = `
|
|
SELECT p.id, p.handle, p.display_name, p.avatar_url, p.is_official, f.created_at
|
|
FROM follows f JOIN profiles p ON p.id = f.follower_id
|
|
WHERE f.following_id = $1::uuid AND f.status = 'accepted'
|
|
ORDER BY f.created_at DESC LIMIT $2
|
|
`
|
|
}
|
|
|
|
rows, err := h.pool.Query(ctx, query, targetUserID, limit)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list follows"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var users []gin.H
|
|
for rows.Next() {
|
|
var id uuid.UUID
|
|
var handle, displayName *string
|
|
var avatarURL *string
|
|
var isOfficial *bool
|
|
var createdAt time.Time
|
|
if err := rows.Scan(&id, &handle, &displayName, &avatarURL, &isOfficial, &createdAt); err != nil {
|
|
continue
|
|
}
|
|
users = append(users, gin.H{
|
|
"id": id, "handle": handle, "display_name": displayName,
|
|
"avatar_url": avatarURL, "is_official": isOfficial, "followed_at": createdAt,
|
|
})
|
|
}
|
|
if users == nil {
|
|
users = []gin.H{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"users": users, "relation": relation})
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// 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.SendContentRemovalEmail(authorEmail, displayName, "post", 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.SendContentRemovalEmail(authorEmail, displayName, "post", 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)})
|
|
}
|
|
|
|
// ListLocalModels returns models available on the local Ollama instance.
|
|
func (h *AdminHandler) ListLocalModels(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
// Query Ollama's /api/tags endpoint for locally available models
|
|
req, err := http.NewRequestWithContext(ctx, "GET", "http://localhost:11434/api/tags", nil)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create request"})
|
|
return
|
|
}
|
|
|
|
client := &http.Client{Timeout: 5 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Ollama not reachable", "models": []any{}})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var result struct {
|
|
Models []struct {
|
|
Name string `json:"name"`
|
|
Model string `json:"model"`
|
|
ModifiedAt string `json:"modified_at"`
|
|
Size int64 `json:"size"`
|
|
} `json:"models"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse Ollama response"})
|
|
return
|
|
}
|
|
|
|
type localModel struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Size int64 `json:"size"`
|
|
}
|
|
models := make([]localModel, 0, len(result.Models))
|
|
for _, m := range result.Models {
|
|
models = append(models, localModel{
|
|
ID: m.Name,
|
|
Name: m.Name,
|
|
Size: m.Size,
|
|
})
|
|
}
|
|
|
|
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"`
|
|
Engines []string `json:"engines"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
allowedTypes := map[string]bool{"text": true, "image": true, "video": true, "group_text": true, "group_image": true, "beacon_text": true, "beacon_image": true}
|
|
if !allowedTypes[req.ModerationType] {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid moderation_type"})
|
|
return
|
|
}
|
|
|
|
adminID := c.GetString("user_id")
|
|
err := h.openRouterService.SetModerationConfig(c.Request.Context(), req.ModerationType, req.ModelID, req.ModelName, req.SystemPrompt, req.Enabled, req.Engines, 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) {
|
|
var req struct {
|
|
ModerationType string `json:"moderation_type" binding:"required"`
|
|
Content string `json:"content"`
|
|
ImageURL string `json:"image_url"`
|
|
Engine string `json:"engine"` // "local_ai", "openrouter", "openai" — empty = openrouter
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
engine := req.Engine
|
|
if engine == "" {
|
|
engine = "openrouter"
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
// Determine the input text for display
|
|
inputDisplay := req.Content
|
|
if inputDisplay == "" && req.ImageURL != "" {
|
|
inputDisplay = req.ImageURL
|
|
}
|
|
|
|
response := gin.H{
|
|
"engine": engine,
|
|
"moderation_type": req.ModerationType,
|
|
"input": inputDisplay,
|
|
}
|
|
|
|
switch engine {
|
|
case "local_ai":
|
|
if h.localAIService == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Local AI not configured"})
|
|
return
|
|
}
|
|
// Local AI only does text moderation
|
|
content := req.Content
|
|
if content == "" {
|
|
content = req.ImageURL // fallback
|
|
}
|
|
localResult, err := h.localAIService.ModerateText(ctx, content)
|
|
if err != nil {
|
|
response["error"] = err.Error()
|
|
c.JSON(http.StatusOK, response)
|
|
return
|
|
}
|
|
action := "clean"
|
|
flagged := false
|
|
if !localResult.Allowed {
|
|
action = "flag"
|
|
flagged = true
|
|
}
|
|
explanation := "Local AI (llama-guard) determined this content is safe."
|
|
if flagged {
|
|
explanation = fmt.Sprintf("Local AI (llama-guard) flagged this content. Categories: %v, Severity: %s", localResult.Categories, localResult.Severity)
|
|
}
|
|
response["result"] = gin.H{
|
|
"action": action,
|
|
"flagged": flagged,
|
|
"reason": localResult.Reason,
|
|
"categories": localResult.Categories,
|
|
"explanation": explanation,
|
|
"raw_content": fmt.Sprintf("allowed=%v cached=%v categories=%v severity=%s reason=%s", localResult.Allowed, localResult.Cached, localResult.Categories, localResult.Severity, localResult.Reason),
|
|
}
|
|
|
|
case "openai":
|
|
if h.moderationService == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OpenAI moderation service not configured"})
|
|
return
|
|
}
|
|
content := req.Content
|
|
if content == "" {
|
|
content = req.ImageURL
|
|
}
|
|
mediaURLs := []string{}
|
|
if req.ImageURL != "" {
|
|
mediaURLs = append(mediaURLs, req.ImageURL)
|
|
}
|
|
scores, reason, err := h.moderationService.AnalyzeContent(ctx, content, mediaURLs)
|
|
if err != nil {
|
|
response["error"] = err.Error()
|
|
c.JSON(http.StatusOK, response)
|
|
return
|
|
}
|
|
action := "clean"
|
|
flagged := false
|
|
if reason != "" {
|
|
action = "flag"
|
|
flagged = true
|
|
}
|
|
response["result"] = gin.H{
|
|
"action": action,
|
|
"flagged": flagged,
|
|
"reason": reason,
|
|
"hate": scores.Hate,
|
|
"greed": scores.Greed,
|
|
"delusion": scores.Delusion,
|
|
"hate_detail": fmt.Sprintf("Hate=%.3f", scores.Hate),
|
|
"greed_detail": fmt.Sprintf("Greed=%.3f", scores.Greed),
|
|
"delusion_detail": fmt.Sprintf("Delusion=%.3f", scores.Delusion),
|
|
"explanation": fmt.Sprintf("OpenAI Three Poisons analysis. Hate=%.3f, Greed=%.3f, Delusion=%.3f. %s", scores.Hate, scores.Greed, scores.Delusion, reason),
|
|
"raw_content": fmt.Sprintf("Hate=%.4f Greed=%.4f Delusion=%.4f", scores.Hate, scores.Greed, scores.Delusion),
|
|
}
|
|
|
|
case "google":
|
|
if h.moderationService == nil || !h.moderationService.HasGoogleVision() {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Google Vision not configured"})
|
|
return
|
|
}
|
|
imageURL := req.ImageURL
|
|
if imageURL == "" {
|
|
// If they passed text content as a URL
|
|
imageURL = req.Content
|
|
}
|
|
if imageURL == "" {
|
|
response["error"] = "Image URL required for Google Vision test"
|
|
c.JSON(http.StatusOK, response)
|
|
return
|
|
}
|
|
gResult, err := h.moderationService.AnalyzeImageWithGoogleVision(ctx, imageURL)
|
|
if err != nil {
|
|
response["error"] = err.Error()
|
|
c.JSON(http.StatusOK, response)
|
|
return
|
|
}
|
|
response["result"] = gResult
|
|
|
|
case "openrouter":
|
|
if h.openRouterService == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OpenRouter not configured"})
|
|
return
|
|
}
|
|
var result *services.ModerationResult
|
|
var err error
|
|
isImage := strings.Contains(req.ModerationType, "image") || req.ModerationType == "video"
|
|
if isImage && req.ImageURL != "" {
|
|
if req.ModerationType == "video" {
|
|
urls := strings.Split(req.ImageURL, ",")
|
|
result, err = h.openRouterService.ModerateVideo(ctx, urls)
|
|
} else {
|
|
result, err = h.openRouterService.ModerateImage(ctx, req.ImageURL)
|
|
}
|
|
} else {
|
|
// Use type-specific config if available, fall back to generic text
|
|
result, err = h.openRouterService.ModerateWithType(ctx, req.ModerationType, req.Content, nil)
|
|
if err != nil {
|
|
// Fall back to generic text moderation
|
|
result, err = h.openRouterService.ModerateText(ctx, req.Content)
|
|
}
|
|
}
|
|
if err != nil {
|
|
response["error"] = err.Error()
|
|
c.JSON(http.StatusOK, response)
|
|
return
|
|
}
|
|
response["result"] = result
|
|
|
|
case "azure":
|
|
if h.azureOpenAIService == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Azure OpenAI not configured"})
|
|
return
|
|
}
|
|
var result *services.ModerationResult
|
|
var err error
|
|
isImage := strings.Contains(req.ModerationType, "image") || req.ModerationType == "video"
|
|
if isImage && req.ImageURL != "" {
|
|
if req.ModerationType == "video" {
|
|
urls := strings.Split(req.ImageURL, ",")
|
|
result, err = h.azureOpenAIService.ModerateVideo(ctx, urls)
|
|
} else {
|
|
result, err = h.azureOpenAIService.ModerateImage(ctx, req.ImageURL)
|
|
}
|
|
} else {
|
|
// Use type-specific config if available, fall back to generic text
|
|
result, err = h.azureOpenAIService.ModerateWithType(ctx, req.ModerationType, req.Content, nil)
|
|
if err != nil {
|
|
// Fall back to generic text moderation
|
|
result, err = h.azureOpenAIService.ModerateText(ctx, req.Content)
|
|
}
|
|
}
|
|
if err != nil {
|
|
response["error"] = err.Error()
|
|
c.JSON(http.StatusOK, response)
|
|
return
|
|
}
|
|
response["result"] = result
|
|
|
|
default:
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid engine: " + engine})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// AI Moderation Audit Log
|
|
// ──────────────────────────────────────────────
|
|
|
|
func (h *AdminHandler) GetAIModerationLog(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
|
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
|
decision := c.Query("decision")
|
|
contentType := c.Query("content_type")
|
|
search := c.Query("search")
|
|
feedbackFilter := c.Query("feedback")
|
|
|
|
items, total, err := h.moderationService.GetAIModerationLog(ctx, limit, offset, decision, contentType, search, feedbackFilter)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to fetch AI moderation log")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch AI moderation log"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"items": items,
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
})
|
|
}
|
|
|
|
func (h *AdminHandler) SubmitAIModerationFeedback(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
adminID, _ := c.Get("user_id")
|
|
logID := c.Param("id")
|
|
|
|
var req struct {
|
|
Correct bool `json:"correct"`
|
|
Reason string `json:"reason" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
logUUID, err := uuid.Parse(logID)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid log ID"})
|
|
return
|
|
}
|
|
adminUUID, _ := uuid.Parse(adminID.(string))
|
|
|
|
if err := h.moderationService.SubmitAIFeedback(ctx, logUUID, req.Correct, req.Reason, adminUUID); err != nil {
|
|
log.Error().Err(err).Msg("Failed to submit AI feedback")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to submit feedback"})
|
|
return
|
|
}
|
|
|
|
// Audit log
|
|
h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, target_id, details) VALUES ($1, 'ai_moderation_feedback', 'ai_moderation_log', $2, $3)`,
|
|
adminUUID, logID, fmt.Sprintf(`{"correct":%v,"reason":"%s"}`, req.Correct, req.Reason))
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Feedback submitted"})
|
|
}
|
|
|
|
func (h *AdminHandler) ExportAITrainingData(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
data, err := h.moderationService.GetAITrainingData(ctx)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to export AI training data")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to export training data"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"training_data": data,
|
|
"count": len(data),
|
|
})
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Admin Create User
|
|
// ──────────────────────────────────────────────
|
|
|
|
func (h *AdminHandler) AdminCreateUser(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
adminID, _ := c.Get("user_id")
|
|
|
|
var req struct {
|
|
Email string `json:"email" binding:"required,email"`
|
|
Password string `json:"password" binding:"required,min=8"`
|
|
Handle string `json:"handle" binding:"required"`
|
|
DisplayName string `json:"display_name" binding:"required"`
|
|
Bio string `json:"bio"`
|
|
Role string `json:"role"`
|
|
Verified bool `json:"verified"`
|
|
Official bool `json:"official"`
|
|
SkipEmail bool `json:"skip_email"`
|
|
}
|
|
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))
|
|
req.Handle = strings.ToLower(strings.TrimSpace(req.Handle))
|
|
|
|
// Check for existing email
|
|
var existsCount int
|
|
h.pool.QueryRow(ctx, "SELECT COUNT(*) FROM public.users WHERE email = $1", req.Email).Scan(&existsCount)
|
|
if existsCount > 0 {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
|
|
return
|
|
}
|
|
|
|
// Check for existing handle
|
|
h.pool.QueryRow(ctx, "SELECT COUNT(*) FROM public.profiles WHERE handle = $1", req.Handle).Scan(&existsCount)
|
|
if existsCount > 0 {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Handle already taken"})
|
|
return
|
|
}
|
|
|
|
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
|
return
|
|
}
|
|
|
|
userID := uuid.New()
|
|
now := time.Now()
|
|
|
|
tx, err := h.pool.Begin(ctx)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start transaction"})
|
|
return
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
// Create user (already active + verified, admin-created)
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO public.users (id, email, encrypted_password, status, mfa_enabled, created_at, updated_at)
|
|
VALUES ($1, $2, $3, 'active', false, $4, $4)
|
|
`, userID, req.Email, string(hashedBytes), now)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
role := "user"
|
|
if req.Role != "" {
|
|
role = req.Role
|
|
}
|
|
|
|
// Create profile
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO public.profiles (id, handle, display_name, bio, is_verified, is_official, role, has_completed_onboarding)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, true)
|
|
`, userID, req.Handle, req.DisplayName, req.Bio, req.Verified, req.Official, role)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create profile: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// Initialize trust state
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO public.trust_state (user_id, harmony_score, tier)
|
|
VALUES ($1, 50, 'new')
|
|
`, userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to init trust state: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// Audit log
|
|
h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, target_id, details) VALUES ($1, 'admin_create_user', 'user', $2, $3)`,
|
|
adminID, userID.String(), fmt.Sprintf(`{"email":"%s","handle":"%s"}`, req.Email, req.Handle))
|
|
|
|
log.Info().Str("admin", adminID.(string)).Str("new_user", userID.String()).Str("handle", req.Handle).Msg("Admin created user")
|
|
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"message": "User created successfully",
|
|
"user_id": userID.String(),
|
|
"email": req.Email,
|
|
"handle": req.Handle,
|
|
})
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Admin Import Content (Posts / Quips / Beacons)
|
|
// ──────────────────────────────────────────────
|
|
|
|
func (h *AdminHandler) AdminImportContent(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
adminID, _ := c.Get("user_id")
|
|
|
|
var req struct {
|
|
AuthorID string `json:"author_id" binding:"required"`
|
|
ContentType string `json:"content_type" binding:"required"` // post, quip, beacon
|
|
Items []struct {
|
|
Body string `json:"body"`
|
|
MediaURL string `json:"media_url"`
|
|
ThumbnailURL string `json:"thumbnail_url"`
|
|
DurationMS int `json:"duration_ms"`
|
|
Tags []string `json:"tags"`
|
|
CategoryID string `json:"category_id"`
|
|
IsNSFW bool `json:"is_nsfw"`
|
|
NSFWReason string `json:"nsfw_reason"`
|
|
Visibility string `json:"visibility"`
|
|
BeaconType string `json:"beacon_type"`
|
|
Lat float64 `json:"lat"`
|
|
Long float64 `json:"long"`
|
|
} `json:"items" binding:"required,min=1"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
authorUUID, err := uuid.Parse(req.AuthorID)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid author_id"})
|
|
return
|
|
}
|
|
|
|
// Verify author exists
|
|
var authorExists int
|
|
h.pool.QueryRow(ctx, "SELECT COUNT(*) FROM public.profiles WHERE id = $1", authorUUID).Scan(&authorExists)
|
|
if authorExists == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Author not found"})
|
|
return
|
|
}
|
|
|
|
validTypes := map[string]bool{"post": true, "quip": true, "beacon": true}
|
|
if !validTypes[req.ContentType] {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "content_type must be post, quip, or beacon"})
|
|
return
|
|
}
|
|
|
|
var created []string
|
|
var errors []string
|
|
|
|
for i, item := range req.Items {
|
|
postID := uuid.New()
|
|
visibility := item.Visibility
|
|
if visibility == "" {
|
|
visibility = "public"
|
|
}
|
|
|
|
var imageURL, videoURL, thumbnailURL *string
|
|
var categoryID *uuid.UUID
|
|
var beaconType *string
|
|
var lat, long *float64
|
|
isBeacon := false
|
|
durationMS := item.DurationMS
|
|
|
|
// Determine media type from URL
|
|
mediaURL := strings.TrimSpace(item.MediaURL)
|
|
if mediaURL != "" {
|
|
lower := strings.ToLower(mediaURL)
|
|
if strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".mov") || strings.HasSuffix(lower, ".webm") || req.ContentType == "quip" {
|
|
videoURL = &mediaURL
|
|
} else {
|
|
imageURL = &mediaURL
|
|
}
|
|
}
|
|
|
|
if item.ThumbnailURL != "" {
|
|
thumbnailURL = &item.ThumbnailURL
|
|
}
|
|
|
|
if item.CategoryID != "" {
|
|
if catUUID, err := uuid.Parse(item.CategoryID); err == nil {
|
|
categoryID = &catUUID
|
|
}
|
|
}
|
|
|
|
if req.ContentType == "beacon" {
|
|
isBeacon = true
|
|
if item.BeaconType != "" {
|
|
beaconType = &item.BeaconType
|
|
} else {
|
|
bt := "general"
|
|
beaconType = &bt
|
|
}
|
|
if item.Lat != 0 || item.Long != 0 {
|
|
lat = &item.Lat
|
|
long = &item.Long
|
|
}
|
|
}
|
|
|
|
tx, err := h.pool.Begin(ctx)
|
|
if err != nil {
|
|
errors = append(errors, fmt.Sprintf("item %d: tx start failed", i))
|
|
continue
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO public.posts (
|
|
id, author_id, category_id, body, status, tone_label, cis_score,
|
|
image_url, video_url, thumbnail_url, duration_ms, body_format, tags,
|
|
is_beacon, beacon_type, location, confidence_score,
|
|
is_active_beacon, allow_chain, visibility,
|
|
is_nsfw, nsfw_reason
|
|
) VALUES (
|
|
$1, $2, $3, $4, 'active', 'neutral', 0.8,
|
|
$5, $6, $7, $8, 'plain', $9,
|
|
$10, $11,
|
|
CASE WHEN ($12::double precision) IS NOT NULL AND ($13::double precision) IS NOT NULL
|
|
THEN ST_SetSRID(ST_MakePoint(($13::double precision), ($12::double precision)), 4326)::geography
|
|
ELSE NULL END,
|
|
0.5, $10, true, $14,
|
|
$15, $16
|
|
) RETURNING id
|
|
`, postID, authorUUID, categoryID, item.Body,
|
|
imageURL, videoURL, thumbnailURL, durationMS, item.Tags,
|
|
isBeacon, beaconType, lat, long, visibility,
|
|
item.IsNSFW, item.NSFWReason,
|
|
)
|
|
if err != nil {
|
|
tx.Rollback(ctx)
|
|
errors = append(errors, fmt.Sprintf("item %d: %s", i, err.Error()))
|
|
continue
|
|
}
|
|
|
|
// Initialize metrics
|
|
_, err = tx.Exec(ctx, "INSERT INTO public.post_metrics (post_id) VALUES ($1)", postID)
|
|
if err != nil {
|
|
tx.Rollback(ctx)
|
|
errors = append(errors, fmt.Sprintf("item %d: metrics init failed", i))
|
|
continue
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
errors = append(errors, fmt.Sprintf("item %d: commit failed", i))
|
|
continue
|
|
}
|
|
|
|
created = append(created, postID.String())
|
|
}
|
|
|
|
// Audit log
|
|
h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, target_id, details) VALUES ($1, 'admin_import_content', 'post', $2, $3)`,
|
|
adminID, req.AuthorID, fmt.Sprintf(`{"type":"%s","count":%d,"errors":%d}`, req.ContentType, len(created), len(errors)))
|
|
|
|
log.Info().Str("admin", adminID.(string)).Str("type", req.ContentType).Int("created", len(created)).Int("errors", len(errors)).Msg("Admin import content")
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": fmt.Sprintf("Imported %d/%d items", len(created), len(req.Items)),
|
|
"created": created,
|
|
"errors": errors,
|
|
"total": len(req.Items),
|
|
"success": len(created),
|
|
"failures": len(errors),
|
|
})
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────
|
|
// Official Accounts Management
|
|
// ──────────────────────────────────────────────────────
|
|
|
|
func (h *AdminHandler) ListOfficialAccounts(c *gin.Context) {
|
|
configs, err := h.officialAccountsService.ListConfigs(c.Request.Context())
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if configs == nil {
|
|
configs = []services.OfficialAccountConfig{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"configs": configs})
|
|
}
|
|
|
|
func (h *AdminHandler) ListOfficialProfiles(c *gin.Context) {
|
|
profiles, err := h.officialAccountsService.ListOfficialProfiles(c.Request.Context())
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if profiles == nil {
|
|
profiles = []services.OfficialProfile{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"profiles": profiles})
|
|
}
|
|
|
|
func (h *AdminHandler) GetOfficialAccount(c *gin.Context) {
|
|
id := c.Param("id")
|
|
cfg, err := h.officialAccountsService.GetConfig(c.Request.Context(), id)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, cfg)
|
|
}
|
|
|
|
func (h *AdminHandler) UpsertOfficialAccount(c *gin.Context) {
|
|
var req struct {
|
|
ProfileID string `json:"profile_id"`
|
|
Handle string `json:"handle"`
|
|
AccountType string `json:"account_type"`
|
|
Enabled bool `json:"enabled"`
|
|
ModelID string `json:"model_id"`
|
|
SystemPrompt string `json:"system_prompt"`
|
|
Temperature float64 `json:"temperature"`
|
|
MaxTokens int `json:"max_tokens"`
|
|
PostIntervalMinutes int `json:"post_interval_minutes"`
|
|
MaxPostsPerDay int `json:"max_posts_per_day"`
|
|
NewsSources []services.NewsSource `json:"news_sources"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
// Resolve handle to profile_id if provided
|
|
profileID := req.ProfileID
|
|
if profileID == "" && req.Handle != "" {
|
|
pid, err := h.officialAccountsService.LookupProfileID(ctx, req.Handle)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
profileID = pid
|
|
}
|
|
if profileID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "profile_id or handle required"})
|
|
return
|
|
}
|
|
|
|
// Defaults
|
|
if req.ModelID == "" {
|
|
req.ModelID = "google/gemini-2.0-flash-001"
|
|
}
|
|
if req.MaxTokens == 0 {
|
|
req.MaxTokens = 500
|
|
}
|
|
if req.PostIntervalMinutes == 0 {
|
|
req.PostIntervalMinutes = 60
|
|
}
|
|
if req.MaxPostsPerDay == 0 {
|
|
req.MaxPostsPerDay = 24
|
|
}
|
|
if req.AccountType == "" {
|
|
req.AccountType = "general"
|
|
}
|
|
|
|
newsJSON, _ := json.Marshal(req.NewsSources)
|
|
|
|
cfg := services.OfficialAccountConfig{
|
|
ProfileID: profileID,
|
|
AccountType: req.AccountType,
|
|
Enabled: req.Enabled,
|
|
ModelID: req.ModelID,
|
|
SystemPrompt: req.SystemPrompt,
|
|
Temperature: req.Temperature,
|
|
MaxTokens: req.MaxTokens,
|
|
PostIntervalMinutes: req.PostIntervalMinutes,
|
|
MaxPostsPerDay: req.MaxPostsPerDay,
|
|
NewsSources: newsJSON,
|
|
}
|
|
|
|
result, err := h.officialAccountsService.UpsertConfig(ctx, cfg)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
adminID, _ := c.Get("user_id")
|
|
h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, target_id, details) VALUES ($1, 'admin_upsert_official_account', 'official_account', $2, $3)`,
|
|
adminID, result.ID, fmt.Sprintf(`{"profile_id":"%s","type":"%s"}`, profileID, req.AccountType))
|
|
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
func (h *AdminHandler) DeleteOfficialAccount(c *gin.Context) {
|
|
id := c.Param("id")
|
|
ctx := c.Request.Context()
|
|
|
|
if err := h.officialAccountsService.DeleteConfig(ctx, id); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
adminID, _ := c.Get("user_id")
|
|
h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, target_id) VALUES ($1, 'admin_delete_official_account', 'official_account', $2)`, adminID, id)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Deleted"})
|
|
}
|
|
|
|
func (h *AdminHandler) ToggleOfficialAccount(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var req struct {
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if err := h.officialAccountsService.ToggleEnabled(c.Request.Context(), id, req.Enabled); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"enabled": req.Enabled})
|
|
}
|
|
|
|
// Manually trigger a post for an official account.
|
|
// Accepts query param ?count=N to post multiple articles at once.
|
|
// count=0 or count=all posts ALL pending articles.
|
|
func (h *AdminHandler) TriggerOfficialPost(c *gin.Context) {
|
|
id := c.Param("id")
|
|
ctx := c.Request.Context()
|
|
|
|
cfg, err := h.officialAccountsService.GetConfig(ctx, id)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
|
|
return
|
|
}
|
|
|
|
// Parse count param: default 1, "all" or "0" = post everything
|
|
countStr := c.DefaultQuery("count", "1")
|
|
postAll := countStr == "all" || countStr == "0"
|
|
count := 1
|
|
if !postAll {
|
|
if n, err := strconv.Atoi(countStr); err == nil && n > 0 {
|
|
count = n
|
|
}
|
|
}
|
|
|
|
switch cfg.AccountType {
|
|
case "news", "rss":
|
|
// Phase 1: Discover new articles
|
|
discovered, discErr := h.officialAccountsService.DiscoverArticles(ctx, id)
|
|
if discErr != nil {
|
|
// Log but continue — there may be previously discovered articles
|
|
log.Warn().Err(discErr).Str("config", id).Msg("Discover error during trigger")
|
|
}
|
|
|
|
// If posting all, get the pending count
|
|
if postAll {
|
|
stats, _ := h.officialAccountsService.GetArticleStats(ctx, id)
|
|
if stats != nil {
|
|
count = stats.Discovered
|
|
}
|
|
}
|
|
|
|
// Phase 2: Post N articles from the queue
|
|
var posted []gin.H
|
|
var errors []string
|
|
for i := 0; i < count; i++ {
|
|
article, postID, err := h.officialAccountsService.PostNextArticle(ctx, id)
|
|
if err != nil {
|
|
errors = append(errors, err.Error())
|
|
continue
|
|
}
|
|
if article == nil {
|
|
break // no more articles
|
|
}
|
|
posted = append(posted, gin.H{
|
|
"post_id": postID,
|
|
"title": article.Title,
|
|
"link": article.Link,
|
|
"source": article.SourceName,
|
|
})
|
|
}
|
|
|
|
if len(posted) == 0 && len(errors) == 0 {
|
|
msg := "No new articles found"
|
|
if discErr != nil {
|
|
msg += " (discover error: " + discErr.Error() + ")"
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"message": msg, "post_id": nil, "discovered": discovered})
|
|
return
|
|
}
|
|
|
|
stats, _ := h.officialAccountsService.GetArticleStats(ctx, id)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": fmt.Sprintf("Posted %d article(s)", len(posted)),
|
|
"posted": posted,
|
|
"errors": errors,
|
|
"discovered": discovered,
|
|
"stats": stats,
|
|
})
|
|
|
|
default:
|
|
postID, body, err := h.officialAccountsService.GenerateAndPost(ctx, id, nil, "")
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "Post created",
|
|
"post_id": postID,
|
|
"body": body,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Preview what AI would generate without actually posting
|
|
func (h *AdminHandler) PreviewOfficialPost(c *gin.Context) {
|
|
id := c.Param("id")
|
|
ctx := c.Request.Context()
|
|
|
|
cfg, err := h.officialAccountsService.GetConfig(ctx, id)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
|
|
return
|
|
}
|
|
|
|
switch cfg.AccountType {
|
|
case "news", "rss":
|
|
// Discover then show the next article that would be posted
|
|
_, _ = h.officialAccountsService.DiscoverArticles(ctx, id)
|
|
|
|
pending, err := h.officialAccountsService.GetArticleQueue(ctx, id, "discovered", 10)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if len(pending) == 0 {
|
|
c.JSON(http.StatusOK, gin.H{"message": "No pending articles", "preview": nil})
|
|
return
|
|
}
|
|
|
|
next := pending[0]
|
|
stats, _ := h.officialAccountsService.GetArticleStats(ctx, id)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"preview": next.Link,
|
|
"source": next.SourceName,
|
|
"article_title": next.Title,
|
|
"article_link": next.Link,
|
|
"pending_count": len(pending),
|
|
"stats": stats,
|
|
})
|
|
|
|
default:
|
|
body, err := h.officialAccountsService.GeneratePost(ctx, id, nil, "")
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"preview": body})
|
|
}
|
|
}
|
|
|
|
// Fetch available news articles (without posting)
|
|
func (h *AdminHandler) FetchNewsArticles(c *gin.Context) {
|
|
id := c.Param("id")
|
|
ctx := c.Request.Context()
|
|
|
|
// Discover new articles first
|
|
_, _ = h.officialAccountsService.DiscoverArticles(ctx, id)
|
|
|
|
// Return the full CachedArticle objects so frontend has IDs for per-article actions
|
|
articles, err := h.officialAccountsService.GetArticleQueue(ctx, id, "discovered", 100)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if articles == nil {
|
|
articles = []services.CachedArticle{}
|
|
}
|
|
stats, _ := h.officialAccountsService.GetArticleStats(ctx, id)
|
|
c.JSON(http.StatusOK, gin.H{"articles": articles, "count": len(articles), "stats": stats})
|
|
}
|
|
|
|
// Get articles by status for an account (defaults to "posted")
|
|
func (h *AdminHandler) GetPostedArticles(c *gin.Context) {
|
|
id := c.Param("id")
|
|
status := c.DefaultQuery("status", "posted")
|
|
limit := 50
|
|
if l := c.Query("limit"); l != "" {
|
|
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
|
limit = parsed
|
|
}
|
|
}
|
|
|
|
articles, err := h.officialAccountsService.GetArticleQueue(c.Request.Context(), id, status, limit)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if articles == nil {
|
|
articles = []services.CachedArticle{}
|
|
}
|
|
stats, _ := h.officialAccountsService.GetArticleStats(c.Request.Context(), id)
|
|
c.JSON(http.StatusOK, gin.H{"articles": articles, "stats": stats})
|
|
}
|
|
|
|
// ── Article Pipeline Management ──────────────────────
|
|
|
|
// SkipArticle marks a pending article as skipped.
|
|
func (h *AdminHandler) SkipArticle(c *gin.Context) {
|
|
articleID := c.Param("article_id")
|
|
if err := h.officialAccountsService.SkipArticle(c.Request.Context(), articleID); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"message": "Article skipped"})
|
|
}
|
|
|
|
// DeleteArticle permanently removes an article from the pipeline.
|
|
func (h *AdminHandler) DeleteArticle(c *gin.Context) {
|
|
articleID := c.Param("article_id")
|
|
if err := h.officialAccountsService.DeleteArticle(c.Request.Context(), articleID); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"message": "Article deleted"})
|
|
}
|
|
|
|
// PostSpecificArticle posts a single pending article by its ID.
|
|
func (h *AdminHandler) PostSpecificArticle(c *gin.Context) {
|
|
articleID := c.Param("article_id")
|
|
article, postID, err := h.officialAccountsService.PostSpecificArticle(c.Request.Context(), articleID)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "Article posted",
|
|
"post_id": postID,
|
|
"title": article.Title,
|
|
"link": article.Link,
|
|
})
|
|
}
|
|
|
|
// CleanupPendingArticles skips or deletes all pending articles older than a date.
|
|
func (h *AdminHandler) CleanupPendingArticles(c *gin.Context) {
|
|
configID := c.Param("id")
|
|
var req struct {
|
|
Before string `json:"before" binding:"required"` // ISO date: 2026-02-10
|
|
Action string `json:"action" binding:"required"` // "skip" or "delete"
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
before, err := time.Parse("2006-01-02", req.Before)
|
|
if err != nil {
|
|
before, err = time.Parse(time.RFC3339, req.Before)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid date format. Use YYYY-MM-DD or RFC3339."})
|
|
return
|
|
}
|
|
}
|
|
|
|
affected, err := h.officialAccountsService.CleanupPendingByDate(c.Request.Context(), configID, before, req.Action)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
stats, _ := h.officialAccountsService.GetArticleStats(c.Request.Context(), configID)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": fmt.Sprintf("%d article(s) %sed", affected, req.Action),
|
|
"affected": affected,
|
|
"stats": stats,
|
|
})
|
|
}
|
|
|
|
// ── Safe Domains Management ─────────────────────────
|
|
|
|
func (h *AdminHandler) ListSafeDomains(c *gin.Context) {
|
|
category := c.Query("category")
|
|
approvedOnly := c.Query("approved_only") == "true"
|
|
|
|
domains, err := h.linkPreviewService.ListSafeDomains(c.Request.Context(), category, approvedOnly)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"domains": domains})
|
|
}
|
|
|
|
func (h *AdminHandler) UpsertSafeDomain(c *gin.Context) {
|
|
var req struct {
|
|
Domain string `json:"domain" binding:"required"`
|
|
Category string `json:"category"`
|
|
IsApproved *bool `json:"is_approved"`
|
|
Notes string `json:"notes"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
cat := req.Category
|
|
if cat == "" {
|
|
cat = "general"
|
|
}
|
|
approved := true
|
|
if req.IsApproved != nil {
|
|
approved = *req.IsApproved
|
|
}
|
|
|
|
domain, err := h.linkPreviewService.UpsertSafeDomain(c.Request.Context(), req.Domain, cat, approved, req.Notes)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"domain": domain})
|
|
}
|
|
|
|
func (h *AdminHandler) DeleteSafeDomain(c *gin.Context) {
|
|
id := c.Param("id")
|
|
if err := h.linkPreviewService.DeleteSafeDomain(c.Request.Context(), id); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
|
}
|
|
|
|
// GetAIEngines returns the status of all moderation engines (local AI, OpenRouter, OpenAI).
|
|
func (h *AdminHandler) GetAIEngines(c *gin.Context) {
|
|
type EngineStatus struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Status string `json:"status"`
|
|
Configured bool `json:"configured"`
|
|
Details any `json:"details,omitempty"`
|
|
}
|
|
|
|
engines := []EngineStatus{}
|
|
|
|
// 1. Local AI (Ollama via AI Gateway)
|
|
localEngine := EngineStatus{
|
|
ID: "local_ai",
|
|
Name: "Local AI (Ollama)",
|
|
Description: "On-server moderation via llama-guard3:1b + content generation via qwen2.5:7b. Free, private, ~2s latency.",
|
|
Configured: h.localAIService != nil,
|
|
}
|
|
if h.localAIService != nil {
|
|
health, err := h.localAIService.Healthz(c.Request.Context())
|
|
if err != nil {
|
|
localEngine.Status = "down"
|
|
localEngine.Details = map[string]string{"error": err.Error()}
|
|
} else {
|
|
localEngine.Status = health.Status
|
|
localEngine.Details = health
|
|
}
|
|
} else {
|
|
localEngine.Status = "not_configured"
|
|
}
|
|
engines = append(engines, localEngine)
|
|
|
|
// 2. OpenRouter
|
|
openRouterEngine := EngineStatus{
|
|
ID: "openrouter",
|
|
Name: "OpenRouter",
|
|
Description: "Cloud AI moderation via configurable models. Supports text, image, and video moderation with customizable system prompts.",
|
|
Configured: h.openRouterService != nil,
|
|
}
|
|
if h.openRouterService != nil {
|
|
configs, err := h.openRouterService.GetModerationConfigs(c.Request.Context())
|
|
if err != nil {
|
|
openRouterEngine.Status = "error"
|
|
} else {
|
|
enabledCount := 0
|
|
for _, cfg := range configs {
|
|
if cfg.Enabled {
|
|
enabledCount++
|
|
}
|
|
}
|
|
openRouterEngine.Status = "ready"
|
|
openRouterEngine.Details = map[string]any{
|
|
"total_configs": len(configs),
|
|
"enabled_configs": enabledCount,
|
|
}
|
|
}
|
|
} else {
|
|
openRouterEngine.Status = "not_configured"
|
|
}
|
|
engines = append(engines, openRouterEngine)
|
|
|
|
// 3. OpenAI Moderation (Three Poisons)
|
|
openAIEngine := EngineStatus{
|
|
ID: "openai",
|
|
Name: "OpenAI Moderation (Three Poisons)",
|
|
Description: "OpenAI content moderation API with Three Poisons scoring (Hate, Greed, Delusion). Used for posts and comments.",
|
|
Configured: h.moderationService != nil,
|
|
}
|
|
if h.moderationService != nil {
|
|
openAIEngine.Status = "ready"
|
|
} else {
|
|
openAIEngine.Status = "not_configured"
|
|
}
|
|
engines = append(engines, openAIEngine)
|
|
|
|
// 4. Google Vision (SafeSearch)
|
|
googleEngine := EngineStatus{
|
|
ID: "google",
|
|
Name: "Google Vision (SafeSearch)",
|
|
Description: "Google Cloud Vision SafeSearch detection for images. Returns Adult, Violence, Racy, Spoof, Medical likelihoods.",
|
|
Configured: h.moderationService != nil && h.moderationService.HasGoogleVision(),
|
|
}
|
|
if h.moderationService != nil && h.moderationService.HasGoogleVision() {
|
|
googleEngine.Status = "ready"
|
|
} else {
|
|
googleEngine.Status = "not_configured"
|
|
}
|
|
engines = append(engines, googleEngine)
|
|
|
|
// 5. Azure OpenAI
|
|
azureEngine := EngineStatus{
|
|
ID: "azure",
|
|
Name: "Azure OpenAI",
|
|
Description: "Microsoft Azure OpenAI service with vision models. Uses your Azure credits. Supports text and image moderation with customizable prompts.",
|
|
Configured: h.azureOpenAIService != nil,
|
|
}
|
|
if h.azureOpenAIService != nil {
|
|
azureEngine.Status = "ready"
|
|
azureEngine.Details = map[string]any{
|
|
"uses_azure_credits": true,
|
|
}
|
|
} else {
|
|
azureEngine.Status = "not_configured"
|
|
}
|
|
engines = append(engines, azureEngine)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"engines": engines})
|
|
}
|
|
|
|
func (h *AdminHandler) UploadTestImage(c *gin.Context) {
|
|
file, header, err := c.Request.FormFile("file")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "No file uploaded"})
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
// Validate file type
|
|
if !strings.HasPrefix(header.Header.Get("Content-Type"), "image/") {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Only image files are allowed"})
|
|
return
|
|
}
|
|
|
|
// Validate file size (5MB limit)
|
|
if header.Size > 5*1024*1024 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "File too large (max 5MB)"})
|
|
return
|
|
}
|
|
|
|
// Generate unique filename
|
|
ext := filepath.Ext(header.Filename)
|
|
filename := fmt.Sprintf("test-%s%s", uuid.New().String()[:8], ext)
|
|
|
|
// Upload to R2
|
|
key := fmt.Sprintf("test-images/%s", filename)
|
|
|
|
// Read file content
|
|
fileData, err := io.ReadAll(file)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read file"})
|
|
return
|
|
}
|
|
|
|
// Upload to R2
|
|
contentType := header.Header.Get("Content-Type")
|
|
_, err = h.s3Client.PutObject(c.Request.Context(), &s3.PutObjectInput{
|
|
Bucket: aws.String(h.mediaBucket),
|
|
Key: aws.String(key),
|
|
Body: bytes.NewReader(fileData),
|
|
ContentType: aws.String(contentType),
|
|
})
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Upload failed"})
|
|
return
|
|
}
|
|
|
|
// Return the URL
|
|
url := fmt.Sprintf("https://%s/%s", h.imgDomain, key)
|
|
c.JSON(http.StatusOK, gin.H{"url": url, "filename": filename})
|
|
}
|
|
|
|
func (h *AdminHandler) CheckURLSafety(c *gin.Context) {
|
|
urlStr := c.Query("url")
|
|
if urlStr == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "url parameter required"})
|
|
return
|
|
}
|
|
result := h.linkPreviewService.CheckURLSafety(c.Request.Context(), urlStr)
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Email Template Management
|
|
// ──────────────────────────────────────────────
|
|
|
|
func (h *AdminHandler) ListEmailTemplates(c *gin.Context) {
|
|
rows, err := h.pool.Query(c.Request.Context(),
|
|
`SELECT id, slug, name, description, subject, title, header, content, button_text, button_url, button_color, footer, text_body, enabled, updated_at, created_at
|
|
FROM email_templates ORDER BY name ASC`)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to list email templates")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list templates"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
type EmailTemplate struct {
|
|
ID string `json:"id"`
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Subject string `json:"subject"`
|
|
Title string `json:"title"`
|
|
Header string `json:"header"`
|
|
Content string `json:"content"`
|
|
ButtonText string `json:"button_text"`
|
|
ButtonURL string `json:"button_url"`
|
|
ButtonColor string `json:"button_color"`
|
|
Footer string `json:"footer"`
|
|
TextBody string `json:"text_body"`
|
|
Enabled bool `json:"enabled"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
var templates []EmailTemplate
|
|
for rows.Next() {
|
|
var t EmailTemplate
|
|
if err := rows.Scan(&t.ID, &t.Slug, &t.Name, &t.Description, &t.Subject, &t.Title, &t.Header, &t.Content, &t.ButtonText, &t.ButtonURL, &t.ButtonColor, &t.Footer, &t.TextBody, &t.Enabled, &t.UpdatedAt, &t.CreatedAt); err != nil {
|
|
log.Error().Err(err).Msg("Failed to scan email template")
|
|
continue
|
|
}
|
|
templates = append(templates, t)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"templates": templates})
|
|
}
|
|
|
|
func (h *AdminHandler) GetEmailTemplate(c *gin.Context) {
|
|
id := c.Param("id")
|
|
|
|
type EmailTemplate struct {
|
|
ID string `json:"id"`
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Subject string `json:"subject"`
|
|
Title string `json:"title"`
|
|
Header string `json:"header"`
|
|
Content string `json:"content"`
|
|
ButtonText string `json:"button_text"`
|
|
ButtonURL string `json:"button_url"`
|
|
ButtonColor string `json:"button_color"`
|
|
Footer string `json:"footer"`
|
|
TextBody string `json:"text_body"`
|
|
Enabled bool `json:"enabled"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
var t EmailTemplate
|
|
err := h.pool.QueryRow(c.Request.Context(),
|
|
`SELECT id, slug, name, description, subject, title, header, content, button_text, button_url, button_color, footer, text_body, enabled, updated_at, created_at
|
|
FROM email_templates WHERE id = $1`, id).
|
|
Scan(&t.ID, &t.Slug, &t.Name, &t.Description, &t.Subject, &t.Title, &t.Header, &t.Content, &t.ButtonText, &t.ButtonURL, &t.ButtonColor, &t.Footer, &t.TextBody, &t.Enabled, &t.UpdatedAt, &t.CreatedAt)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, t)
|
|
}
|
|
|
|
func (h *AdminHandler) UpdateEmailTemplate(c *gin.Context) {
|
|
id := c.Param("id")
|
|
|
|
var req struct {
|
|
Subject *string `json:"subject"`
|
|
Title *string `json:"title"`
|
|
Header *string `json:"header"`
|
|
Content *string `json:"content"`
|
|
ButtonText *string `json:"button_text"`
|
|
ButtonURL *string `json:"button_url"`
|
|
ButtonColor *string `json:"button_color"`
|
|
Footer *string `json:"footer"`
|
|
TextBody *string `json:"text_body"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
return
|
|
}
|
|
|
|
// Build dynamic UPDATE
|
|
sets := []string{}
|
|
args := []interface{}{}
|
|
argIdx := 1
|
|
|
|
if req.Subject != nil {
|
|
sets = append(sets, fmt.Sprintf("subject = $%d", argIdx))
|
|
args = append(args, *req.Subject)
|
|
argIdx++
|
|
}
|
|
if req.Title != nil {
|
|
sets = append(sets, fmt.Sprintf("title = $%d", argIdx))
|
|
args = append(args, *req.Title)
|
|
argIdx++
|
|
}
|
|
if req.Header != nil {
|
|
sets = append(sets, fmt.Sprintf("header = $%d", argIdx))
|
|
args = append(args, *req.Header)
|
|
argIdx++
|
|
}
|
|
if req.Content != nil {
|
|
sets = append(sets, fmt.Sprintf("content = $%d", argIdx))
|
|
args = append(args, *req.Content)
|
|
argIdx++
|
|
}
|
|
if req.ButtonText != nil {
|
|
sets = append(sets, fmt.Sprintf("button_text = $%d", argIdx))
|
|
args = append(args, *req.ButtonText)
|
|
argIdx++
|
|
}
|
|
if req.ButtonURL != nil {
|
|
sets = append(sets, fmt.Sprintf("button_url = $%d", argIdx))
|
|
args = append(args, *req.ButtonURL)
|
|
argIdx++
|
|
}
|
|
if req.ButtonColor != nil {
|
|
sets = append(sets, fmt.Sprintf("button_color = $%d", argIdx))
|
|
args = append(args, *req.ButtonColor)
|
|
argIdx++
|
|
}
|
|
if req.Footer != nil {
|
|
sets = append(sets, fmt.Sprintf("footer = $%d", argIdx))
|
|
args = append(args, *req.Footer)
|
|
argIdx++
|
|
}
|
|
if req.TextBody != nil {
|
|
sets = append(sets, fmt.Sprintf("text_body = $%d", argIdx))
|
|
args = append(args, *req.TextBody)
|
|
argIdx++
|
|
}
|
|
if req.Enabled != nil {
|
|
sets = append(sets, fmt.Sprintf("enabled = $%d", argIdx))
|
|
args = append(args, *req.Enabled)
|
|
argIdx++
|
|
}
|
|
|
|
if len(sets) == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"})
|
|
return
|
|
}
|
|
|
|
sets = append(sets, "updated_at = NOW()")
|
|
args = append(args, id)
|
|
|
|
query := fmt.Sprintf("UPDATE email_templates SET %s WHERE id = $%d RETURNING id", strings.Join(sets, ", "), argIdx)
|
|
|
|
var returnedID string
|
|
err := h.pool.QueryRow(c.Request.Context(), query, args...).Scan(&returnedID)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("template_id", id).Msg("Failed to update email template")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update template"})
|
|
return
|
|
}
|
|
|
|
// Log to audit
|
|
adminID, _ := c.Get("user_id")
|
|
_, _ = h.pool.Exec(c.Request.Context(),
|
|
`INSERT INTO audit_log (admin_id, action, target_type, target_id, details) VALUES ($1, 'update_email_template', 'email_template', $2, $3)`,
|
|
adminID, id, fmt.Sprintf("Updated email template %s", id))
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Template updated", "id": returnedID})
|
|
}
|
|
|
|
func (h *AdminHandler) SendTestEmail(c *gin.Context) {
|
|
var req struct {
|
|
TemplateID string `json:"template_id"`
|
|
ToEmail string `json:"to_email"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
return
|
|
}
|
|
|
|
if req.TemplateID == "" || req.ToEmail == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "template_id and to_email are required"})
|
|
return
|
|
}
|
|
|
|
var subject, title, header, content, buttonText, buttonURL, buttonColor, footer, textBody string
|
|
err := h.pool.QueryRow(c.Request.Context(),
|
|
`SELECT subject, title, header, content, button_text, button_url, button_color, footer, text_body
|
|
FROM email_templates WHERE id = $1`, req.TemplateID).
|
|
Scan(&subject, &title, &header, &content, &buttonText, &buttonURL, &buttonColor, &footer, &textBody)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
|
return
|
|
}
|
|
|
|
// Replace placeholders with sample data for test
|
|
replacer := strings.NewReplacer(
|
|
"{{name}}", "Test User",
|
|
"{{verify_url}}", "https://mp.ls/verified",
|
|
"{{reset_url}}", "https://mp.ls/sojorn",
|
|
"{{reason}}", "This is a test reason",
|
|
"{{duration}}", "7 days",
|
|
"{{deletion_date}}", time.Now().AddDate(0, 0, 14).Format("January 2, 2006"),
|
|
"{{confirm_url}}", "https://mp.ls/destroyed",
|
|
"{{content_type}}", "post",
|
|
"{{strike_count}}", "1",
|
|
"{{strike_warning}}", "",
|
|
)
|
|
|
|
subject = replacer.Replace(subject)
|
|
header = replacer.Replace(header)
|
|
content = replacer.Replace(content)
|
|
buttonText = replacer.Replace(buttonText)
|
|
buttonURL = replacer.Replace(buttonURL)
|
|
textBody = replacer.Replace(textBody)
|
|
|
|
htmlBody := h.emailService.BuildHTMLEmailWithColor(title, header, content, buttonURL, buttonText, footer, buttonColor)
|
|
|
|
if err := h.emailService.SendGenericEmail(req.ToEmail, "Test User", subject, htmlBody, textBody); err != nil {
|
|
log.Error().Err(err).Msg("Failed to send test email")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test email: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Test email sent to " + req.ToEmail})
|
|
}
|