From 88e364f4c50fa99f3a7142bb805a909f59de1f3d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Jan 2026 22:39:47 +0000 Subject: [PATCH] Add admin moderation and analytics dashboards Moderation Dashboard: - API with admin-only access for content moderation - View/filter reports by status (open, resolved, ignored) - View/resolve contract disputes - Manage flagged users (warn, ban, unban) - Resolution notes for audit trail - Stats for open reports, disputes, resolved today Analytics Dashboard: - Comprehensive platform metrics API - User stats: total, new, active, creators - Opportunity and application metrics - Contract tracking and completion rates - Revenue tracking by period - Daily signup trend visualization - Top performing opportunities ranking - Period selector (7, 30, 90, 365 days) Both dashboards have proper admin authorization checks. --- api/admin/analytics.ts | 187 ++++++++ api/admin/moderation.ts | 245 ++++++++++ client/App.tsx | 18 + client/pages/admin/AdminAnalytics.tsx | 362 +++++++++++++++ client/pages/admin/AdminModeration.tsx | 594 +++++++++++++++++++++++++ 5 files changed, 1406 insertions(+) create mode 100644 api/admin/analytics.ts create mode 100644 api/admin/moderation.ts create mode 100644 client/pages/admin/AdminAnalytics.tsx create mode 100644 client/pages/admin/AdminModeration.tsx diff --git a/api/admin/analytics.ts b/api/admin/analytics.ts new file mode 100644 index 00000000..d294dd8d --- /dev/null +++ b/api/admin/analytics.ts @@ -0,0 +1,187 @@ +import { supabase } from "../_supabase.js"; + +export default async (req: Request) => { + const token = req.headers.get("Authorization")?.replace("Bearer ", ""); + if (!token) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const { data: userData } = await supabase.auth.getUser(token); + if (!userData.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + // Check if user is admin + const { data: profile } = await supabase + .from("profiles") + .select("role") + .eq("id", userData.user.id) + .single(); + + if (!profile || profile.role !== "admin") { + return new Response(JSON.stringify({ error: "Forbidden" }), { status: 403, headers: { "Content-Type": "application/json" } }); + } + + const url = new URL(req.url); + const period = url.searchParams.get("period") || "30"; // days + + try { + if (req.method === "GET") { + const daysAgo = new Date(); + daysAgo.setDate(daysAgo.getDate() - parseInt(period)); + + // Get total users and growth + const { count: totalUsers } = await supabase + .from("profiles") + .select("*", { count: "exact", head: true }); + + const { count: newUsersThisPeriod } = await supabase + .from("profiles") + .select("*", { count: "exact", head: true }) + .gte("created_at", daysAgo.toISOString()); + + // Get active users (logged in within period) + const { count: activeUsers } = await supabase + .from("profiles") + .select("*", { count: "exact", head: true }) + .gte("last_login_at", daysAgo.toISOString()); + + // Get opportunities stats + const { count: totalOpportunities } = await supabase + .from("aethex_opportunities") + .select("*", { count: "exact", head: true }); + + const { count: openOpportunities } = await supabase + .from("aethex_opportunities") + .select("*", { count: "exact", head: true }) + .eq("status", "open"); + + const { count: newOpportunities } = await supabase + .from("aethex_opportunities") + .select("*", { count: "exact", head: true }) + .gte("created_at", daysAgo.toISOString()); + + // Get applications stats + const { count: totalApplications } = await supabase + .from("aethex_applications") + .select("*", { count: "exact", head: true }); + + const { count: newApplications } = await supabase + .from("aethex_applications") + .select("*", { count: "exact", head: true }) + .gte("created_at", daysAgo.toISOString()); + + // Get contracts stats + const { count: totalContracts } = await supabase + .from("nexus_contracts") + .select("*", { count: "exact", head: true }); + + const { count: activeContracts } = await supabase + .from("nexus_contracts") + .select("*", { count: "exact", head: true }) + .eq("status", "active"); + + // Get community stats + const { count: totalPosts } = await supabase + .from("community_posts") + .select("*", { count: "exact", head: true }); + + const { count: newPosts } = await supabase + .from("community_posts") + .select("*", { count: "exact", head: true }) + .gte("created_at", daysAgo.toISOString()); + + // Get creator stats + const { count: totalCreators } = await supabase + .from("aethex_creators") + .select("*", { count: "exact", head: true }); + + // Get daily signups for trend (last 30 days) + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const { data: signupTrend } = await supabase + .from("profiles") + .select("created_at") + .gte("created_at", thirtyDaysAgo.toISOString()) + .order("created_at"); + + // Group signups by date + const signupsByDate: Record = {}; + signupTrend?.forEach((user) => { + const date = new Date(user.created_at).toISOString().split("T")[0]; + signupsByDate[date] = (signupsByDate[date] || 0) + 1; + }); + + const dailySignups = Object.entries(signupsByDate).map(([date, count]) => ({ + date, + count + })); + + // Revenue data (if available) + const { data: revenueData } = await supabase + .from("nexus_payments") + .select("amount, created_at") + .eq("status", "completed") + .gte("created_at", daysAgo.toISOString()); + + const totalRevenue = revenueData?.reduce((sum, p) => sum + (p.amount || 0), 0) || 0; + + // Top performing opportunities + const { data: topOpportunities } = await supabase + .from("aethex_opportunities") + .select(` + id, + title, + aethex_applications(count) + `) + .eq("status", "open") + .order("created_at", { ascending: false }) + .limit(5); + + return new Response(JSON.stringify({ + users: { + total: totalUsers || 0, + new: newUsersThisPeriod || 0, + active: activeUsers || 0, + creators: totalCreators || 0 + }, + opportunities: { + total: totalOpportunities || 0, + open: openOpportunities || 0, + new: newOpportunities || 0 + }, + applications: { + total: totalApplications || 0, + new: newApplications || 0 + }, + contracts: { + total: totalContracts || 0, + active: activeContracts || 0 + }, + community: { + posts: totalPosts || 0, + newPosts: newPosts || 0 + }, + revenue: { + total: totalRevenue, + period: `${period} days` + }, + trends: { + dailySignups, + topOpportunities: topOpportunities?.map(o => ({ + id: o.id, + title: o.title, + applications: o.aethex_applications?.[0]?.count || 0 + })) || [] + }, + period: parseInt(period) + }), { headers: { "Content-Type": "application/json" } }); + } + + return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } }); + } catch (err: any) { + console.error("Analytics API error:", err); + return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } }); + } +}; diff --git a/api/admin/moderation.ts b/api/admin/moderation.ts new file mode 100644 index 00000000..54877038 --- /dev/null +++ b/api/admin/moderation.ts @@ -0,0 +1,245 @@ +import { supabase } from "../_supabase.js"; + +export default async (req: Request) => { + const token = req.headers.get("Authorization")?.replace("Bearer ", ""); + if (!token) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const { data: userData } = await supabase.auth.getUser(token); + if (!userData.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + // Check if user is admin + const { data: profile } = await supabase + .from("profiles") + .select("role") + .eq("id", userData.user.id) + .single(); + + if (!profile || profile.role !== "admin") { + return new Response(JSON.stringify({ error: "Forbidden" }), { status: 403, headers: { "Content-Type": "application/json" } }); + } + + const url = new URL(req.url); + + try { + // GET - Fetch reports and stats + if (req.method === "GET") { + const status = url.searchParams.get("status") || "open"; + const type = url.searchParams.get("type"); // report, dispute, user + + // Get content reports + let reportsQuery = supabase + .from("moderation_reports") + .select(` + *, + reporter:profiles!moderation_reports_reporter_id_fkey(id, full_name, email, avatar_url) + `) + .order("created_at", { ascending: false }) + .limit(50); + + if (status !== "all") { + reportsQuery = reportsQuery.eq("status", status); + } + if (type && type !== "all") { + reportsQuery = reportsQuery.eq("target_type", type); + } + + const { data: reports, error: reportsError } = await reportsQuery; + if (reportsError) console.error("Reports error:", reportsError); + + // Get disputes + let disputesQuery = supabase + .from("nexus_disputes") + .select(` + *, + reporter:profiles!nexus_disputes_reported_by_fkey(id, full_name, email) + `) + .order("created_at", { ascending: false }) + .limit(50); + + if (status !== "all") { + disputesQuery = disputesQuery.eq("status", status); + } + + const { data: disputes, error: disputesError } = await disputesQuery; + if (disputesError) console.error("Disputes error:", disputesError); + + // Get flagged users (users with warnings/bans) + const { data: flaggedUsers } = await supabase + .from("profiles") + .select("id, full_name, email, avatar_url, is_banned, warning_count, created_at") + .or("is_banned.eq.true,warning_count.gt.0") + .order("created_at", { ascending: false }) + .limit(50); + + // Calculate stats + const { count: openReports } = await supabase + .from("moderation_reports") + .select("*", { count: "exact", head: true }) + .eq("status", "open"); + + const { count: openDisputes } = await supabase + .from("nexus_disputes") + .select("*", { count: "exact", head: true }) + .eq("status", "open"); + + const { count: resolvedToday } = await supabase + .from("moderation_reports") + .select("*", { count: "exact", head: true }) + .eq("status", "resolved") + .gte("updated_at", new Date(new Date().setHours(0, 0, 0, 0)).toISOString()); + + const stats = { + openReports: openReports || 0, + openDisputes: openDisputes || 0, + resolvedToday: resolvedToday || 0, + flaggedUsers: flaggedUsers?.length || 0 + }; + + return new Response(JSON.stringify({ + reports: reports || [], + disputes: disputes || [], + flaggedUsers: flaggedUsers || [], + stats + }), { headers: { "Content-Type": "application/json" } }); + } + + // POST - Take moderation action + if (req.method === "POST") { + const body = await req.json(); + + // Resolve/ignore report + if (body.action === "update_report") { + const { report_id, status, resolution_notes } = body; + + const { data, error } = await supabase + .from("moderation_reports") + .update({ + status, + resolution_notes, + resolved_by: userData.user.id, + updated_at: new Date().toISOString() + }) + .eq("id", report_id) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ report: data }), { headers: { "Content-Type": "application/json" } }); + } + + // Resolve dispute + if (body.action === "update_dispute") { + const { dispute_id, status, resolution_notes } = body; + + const { data, error } = await supabase + .from("nexus_disputes") + .update({ + status, + resolution_notes, + resolved_by: userData.user.id, + resolved_at: new Date().toISOString() + }) + .eq("id", dispute_id) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ dispute: data }), { headers: { "Content-Type": "application/json" } }); + } + + // Ban/warn user + if (body.action === "moderate_user") { + const { user_id, action_type, reason } = body; + + if (action_type === "ban") { + const { data, error } = await supabase + .from("profiles") + .update({ + is_banned: true, + ban_reason: reason, + banned_at: new Date().toISOString(), + banned_by: userData.user.id + }) + .eq("id", user_id) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ user: data, action: "banned" }), { headers: { "Content-Type": "application/json" } }); + } + + if (action_type === "warn") { + const { data: currentUser } = await supabase + .from("profiles") + .select("warning_count") + .eq("id", user_id) + .single(); + + const { data, error } = await supabase + .from("profiles") + .update({ + warning_count: (currentUser?.warning_count || 0) + 1, + last_warning_at: new Date().toISOString(), + last_warning_reason: reason + }) + .eq("id", user_id) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ user: data, action: "warned" }), { headers: { "Content-Type": "application/json" } }); + } + + if (action_type === "unban") { + const { data, error } = await supabase + .from("profiles") + .update({ + is_banned: false, + ban_reason: null, + unbanned_at: new Date().toISOString(), + unbanned_by: userData.user.id + }) + .eq("id", user_id) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ user: data, action: "unbanned" }), { headers: { "Content-Type": "application/json" } }); + } + } + + // Delete content + if (body.action === "delete_content") { + const { content_type, content_id } = body; + + const tableMap: Record = { + post: "community_posts", + comment: "community_comments", + project: "projects", + opportunity: "aethex_opportunities" + }; + + const table = tableMap[content_type]; + if (!table) { + return new Response(JSON.stringify({ error: "Invalid content type" }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + + const { error } = await supabase.from(table).delete().eq("id", content_id); + if (error) throw error; + + return new Response(JSON.stringify({ success: true, deleted: content_type }), { headers: { "Content-Type": "application/json" } }); + } + + return new Response(JSON.stringify({ error: "Invalid action" }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + + return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } }); + } catch (err: any) { + console.error("Moderation API error:", err); + return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } }); + } +}; diff --git a/client/App.tsx b/client/App.tsx index 529560d7..10fa4523 100644 --- a/client/App.tsx +++ b/client/App.tsx @@ -167,6 +167,8 @@ import CandidateInterviews from "./pages/candidate/CandidateInterviews"; import CandidateOffers from "./pages/candidate/CandidateOffers"; import StaffOKRs from "./pages/staff/StaffOKRs"; import StaffTimeTracking from "./pages/staff/StaffTimeTracking"; +import AdminModeration from "./pages/admin/AdminModeration"; +import AdminAnalytics from "./pages/admin/AdminAnalytics"; const queryClient = new QueryClient(); @@ -248,6 +250,22 @@ const App = () => ( } /> } /> } /> + + + + } + /> + + + + } + /> } /> } /> } /> diff --git a/client/pages/admin/AdminAnalytics.tsx b/client/pages/admin/AdminAnalytics.tsx new file mode 100644 index 00000000..fa6e079f --- /dev/null +++ b/client/pages/admin/AdminAnalytics.tsx @@ -0,0 +1,362 @@ +import { useState, useEffect } from "react"; +import Layout from "@/components/Layout"; +import SEO from "@/components/SEO"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + BarChart3, + Users, + Briefcase, + FileText, + DollarSign, + TrendingUp, + Activity, + MessageSquare, + Loader2, + ArrowUpRight, + ArrowDownRight, +} from "lucide-react"; +import { useAuth } from "@/lib/auth"; +import { aethexToast } from "@/components/ui/aethex-toast"; + +interface Analytics { + users: { + total: number; + new: number; + active: number; + creators: number; + }; + opportunities: { + total: number; + open: number; + new: number; + }; + applications: { + total: number; + new: number; + }; + contracts: { + total: number; + active: number; + }; + community: { + posts: number; + newPosts: number; + }; + revenue: { + total: number; + period: string; + }; + trends: { + dailySignups: Array<{ date: string; count: number }>; + topOpportunities: Array<{ id: string; title: string; applications: number }>; + }; + period: number; +} + +export default function AdminAnalytics() { + const { session } = useAuth(); + const [analytics, setAnalytics] = useState(null); + const [loading, setLoading] = useState(true); + const [period, setPeriod] = useState("30"); + + useEffect(() => { + if (session?.access_token) { + fetchAnalytics(); + } + }, [session?.access_token, period]); + + const fetchAnalytics = async () => { + try { + const res = await fetch(`/api/admin/analytics?period=${period}`, { + headers: { Authorization: `Bearer ${session?.access_token}` }, + }); + const data = await res.json(); + if (res.ok) { + setAnalytics(data); + } else { + aethexToast.error(data.error || "Failed to load analytics"); + } + } catch (err) { + aethexToast.error("Failed to load analytics"); + } finally { + setLoading(false); + } + }; + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 0 + }).format(amount); + }; + + const maxSignups = analytics?.trends.dailySignups + ? Math.max(...analytics.trends.dailySignups.map(d => d.count), 1) + : 1; + + if (loading) { + return ( + +
+ +
+
+ ); + } + + return ( + + + +
+
+
+
+
+ +
+
+ {/* Header */} +
+
+
+ +
+
+

Analytics

+

Platform insights and metrics

+
+
+ +
+ + {/* Overview Stats */} +
+ + +
+
+

Total Users

+

{analytics?.users.total.toLocaleString()}

+
+ + +{analytics?.users.new} this period +
+
+ +
+
+
+ + +
+
+

Active Users

+

{analytics?.users.active.toLocaleString()}

+

+ {analytics?.users.total ? Math.round((analytics.users.active / analytics.users.total) * 100) : 0}% of total +

+
+ +
+
+
+ + +
+
+

Opportunities

+

{analytics?.opportunities.open}

+
+ + +{analytics?.opportunities.new} new +
+
+ +
+
+
+ + +
+
+

Revenue

+

{formatCurrency(analytics?.revenue.total || 0)}

+

Last {period} days

+
+ +
+
+
+
+ + {/* Detailed Stats Grid */} +
+ {/* Applications */} + + + + + Applications + + + +
+
+ Total + {analytics?.applications.total} +
+
+ This Period + +{analytics?.applications.new} +
+
+ Avg per Opportunity + + {analytics?.opportunities.total + ? (analytics.applications.total / analytics.opportunities.total).toFixed(1) + : 0} + +
+
+
+
+ + {/* Contracts */} + + + + + Contracts + + + +
+
+ Total + {analytics?.contracts.total} +
+
+ Active + {analytics?.contracts.active} +
+
+ Completion Rate + + {analytics?.contracts.total + ? Math.round(((analytics.contracts.total - analytics.contracts.active) / analytics.contracts.total) * 100) + : 0}% + +
+
+
+
+ + {/* Community */} + + + + + Community + + + +
+
+ Total Posts + {analytics?.community.posts} +
+
+ New Posts + +{analytics?.community.newPosts} +
+
+ Creators + {analytics?.users.creators} +
+
+
+
+
+ + {/* Charts Row */} +
+ {/* Signup Trend */} + + + Daily Signups + User registrations over the last 30 days + + +
+ {analytics?.trends.dailySignups.slice(-30).map((day, i) => ( +
+ ))} +
+
+ 30 days ago + Today +
+ + + + {/* Top Opportunities */} + + + Top Opportunities + By number of applications + + +
+ {analytics?.trends.topOpportunities.map((opp, i) => ( +
+ #{i + 1} +
+

{opp.title}

+

{opp.applications} applications

+
+
+ ))} + {(!analytics?.trends.topOpportunities || analytics.trends.topOpportunities.length === 0) && ( +

No opportunities yet

+ )} +
+
+
+
+
+
+
+ + ); +} diff --git a/client/pages/admin/AdminModeration.tsx b/client/pages/admin/AdminModeration.tsx new file mode 100644 index 00000000..f714c65b --- /dev/null +++ b/client/pages/admin/AdminModeration.tsx @@ -0,0 +1,594 @@ +import { useState, useEffect } from "react"; +import Layout from "@/components/Layout"; +import SEO from "@/components/SEO"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs"; +import { + Shield, + AlertTriangle, + Flag, + UserX, + CheckCircle, + XCircle, + Loader2, + Eye, + Ban, + AlertCircle, +} from "lucide-react"; +import { useAuth } from "@/lib/auth"; +import { aethexToast } from "@/components/ui/aethex-toast"; + +interface Report { + id: string; + reporter_id: string; + target_type: string; + target_id: string; + reason: string; + details?: string; + status: string; + created_at: string; + reporter?: { + id: string; + full_name: string; + email: string; + avatar_url?: string; + }; +} + +interface Dispute { + id: string; + contract_id: string; + reason: string; + status: string; + resolution_notes?: string; + created_at: string; + reporter?: { + id: string; + full_name: string; + email: string; + }; +} + +interface FlaggedUser { + id: string; + full_name: string; + email: string; + avatar_url?: string; + is_banned: boolean; + warning_count: number; + created_at: string; +} + +interface Stats { + openReports: number; + openDisputes: number; + resolvedToday: number; + flaggedUsers: number; +} + +const getStatusColor = (status: string) => { + switch (status) { + case "open": + return "bg-red-500/20 text-red-300 border-red-500/30"; + case "resolved": + return "bg-green-500/20 text-green-300 border-green-500/30"; + case "ignored": + return "bg-slate-500/20 text-slate-300 border-slate-500/30"; + default: + return "bg-slate-500/20 text-slate-300"; + } +}; + +const getTypeColor = (type: string) => { + switch (type) { + case "post": + return "bg-blue-500/20 text-blue-300"; + case "comment": + return "bg-purple-500/20 text-purple-300"; + case "user": + return "bg-amber-500/20 text-amber-300"; + case "project": + return "bg-cyan-500/20 text-cyan-300"; + default: + return "bg-slate-500/20 text-slate-300"; + } +}; + +export default function AdminModeration() { + const { session } = useAuth(); + const [reports, setReports] = useState([]); + const [disputes, setDisputes] = useState([]); + const [flaggedUsers, setFlaggedUsers] = useState([]); + const [stats, setStats] = useState({ openReports: 0, openDisputes: 0, resolvedToday: 0, flaggedUsers: 0 }); + const [loading, setLoading] = useState(true); + const [statusFilter, setStatusFilter] = useState("open"); + const [selectedReport, setSelectedReport] = useState(null); + const [selectedDispute, setSelectedDispute] = useState(null); + const [selectedUser, setSelectedUser] = useState(null); + const [resolution, setResolution] = useState(""); + + useEffect(() => { + if (session?.access_token) { + fetchModeration(); + } + }, [session?.access_token, statusFilter]); + + const fetchModeration = async () => { + try { + const res = await fetch(`/api/admin/moderation?status=${statusFilter}`, { + headers: { Authorization: `Bearer ${session?.access_token}` }, + }); + const data = await res.json(); + if (res.ok) { + setReports(data.reports || []); + setDisputes(data.disputes || []); + setFlaggedUsers(data.flaggedUsers || []); + setStats(data.stats || { openReports: 0, openDisputes: 0, resolvedToday: 0, flaggedUsers: 0 }); + } else { + aethexToast.error(data.error || "Failed to load moderation data"); + } + } catch (err) { + aethexToast.error("Failed to load moderation data"); + } finally { + setLoading(false); + } + }; + + const updateReport = async (reportId: string, status: string) => { + try { + const res = await fetch("/api/admin/moderation", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session?.access_token}`, + }, + body: JSON.stringify({ + action: "update_report", + report_id: reportId, + status, + resolution_notes: resolution + }), + }); + if (res.ok) { + aethexToast.success(`Report ${status}`); + setSelectedReport(null); + setResolution(""); + fetchModeration(); + } + } catch (err) { + aethexToast.error("Failed to update report"); + } + }; + + const updateDispute = async (disputeId: string, status: string) => { + try { + const res = await fetch("/api/admin/moderation", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session?.access_token}`, + }, + body: JSON.stringify({ + action: "update_dispute", + dispute_id: disputeId, + status, + resolution_notes: resolution + }), + }); + if (res.ok) { + aethexToast.success(`Dispute ${status}`); + setSelectedDispute(null); + setResolution(""); + fetchModeration(); + } + } catch (err) { + aethexToast.error("Failed to update dispute"); + } + }; + + const moderateUser = async (userId: string, actionType: string) => { + const reason = prompt(`Enter reason for ${actionType}:`); + if (!reason && actionType !== "unban") return; + + try { + const res = await fetch("/api/admin/moderation", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session?.access_token}`, + }, + body: JSON.stringify({ + action: "moderate_user", + user_id: userId, + action_type: actionType, + reason + }), + }); + if (res.ok) { + aethexToast.success(`User ${actionType}ned successfully`); + setSelectedUser(null); + fetchModeration(); + } + } catch (err) { + aethexToast.error(`Failed to ${actionType} user`); + } + }; + + if (loading) { + return ( + +
+ +
+
+ ); + } + + return ( + + + +
+
+
+
+
+ +
+
+ {/* Header */} +
+
+
+ +
+
+

Moderation

+

Content moderation and user management

+
+
+ +
+ + {/* Stats */} +
+ + +
+
+

Open Reports

+

{stats.openReports}

+
+ +
+
+
+ + +
+
+

Open Disputes

+

{stats.openDisputes}

+
+ +
+
+
+ + +
+
+

Resolved Today

+

{stats.resolvedToday}

+
+ +
+
+
+ + +
+
+

Flagged Users

+

{stats.flaggedUsers}

+
+ +
+
+
+
+ + {/* Tabs */} + + + Reports ({reports.length}) + Disputes ({disputes.length}) + Flagged Users ({flaggedUsers.length}) + + + {/* Reports Tab */} + + {reports.map((report) => ( + + +
+
+
+ + {report.status} + + + {report.target_type} + +
+

{report.reason}

+ {report.details && ( +

{report.details}

+ )} +
+ By: {report.reporter?.full_name || report.reporter?.email || "Unknown"} + {new Date(report.created_at).toLocaleDateString()} +
+
+ {report.status === "open" && ( +
+ + +
+ )} +
+
+
+ ))} + {reports.length === 0 && ( +
No reports found
+ )} +
+ + {/* Disputes Tab */} + + {disputes.map((dispute) => ( + + +
+
+
+ + {dispute.status} + + + Contract Dispute + +
+

{dispute.reason}

+
+ Contract: {dispute.contract_id?.slice(0, 8)}... + By: {dispute.reporter?.full_name || dispute.reporter?.email || "Unknown"} + {new Date(dispute.created_at).toLocaleDateString()} +
+
+ {dispute.status === "open" && ( + + )} +
+
+
+ ))} + {disputes.length === 0 && ( +
No disputes found
+ )} +
+ + {/* Flagged Users Tab */} + + {flaggedUsers.map((user) => ( + + +
+
+
+ {user.avatar_url ? ( + + ) : ( + {user.full_name?.[0] || "?"} + )} +
+
+

{user.full_name || "Unknown"}

+

{user.email}

+
+
+
+
+ {user.is_banned && ( + + Banned + + )} + {user.warning_count > 0 && ( + + {user.warning_count} Warnings + + )} +
+
+ {user.is_banned ? ( + + ) : ( + <> + + + + )} +
+
+
+
+
+ ))} + {flaggedUsers.length === 0 && ( +
No flagged users
+ )} +
+
+
+
+
+ + {/* Resolve Report Dialog */} + setSelectedReport(null)}> + + + Resolve Report + + {selectedReport && ( +
+
+

Reason

+

{selectedReport.reason}

+ {selectedReport.details && ( + <> +

Details

+

{selectedReport.details}

+ + )} +
+