Fix posts query (like/comment counts), add multi-select with bulk actions to all list pages
This commit is contained in:
parent
766392e5b0
commit
ec5a0aad8b
|
|
@ -1,6 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import AdminShell from '@/components/AdminShell';
|
import AdminShell from '@/components/AdminShell';
|
||||||
|
import SelectionBar from '@/components/SelectionBar';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { statusColor, formatDateTime } from '@/lib/utils';
|
import { statusColor, formatDateTime } from '@/lib/utils';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
@ -27,6 +28,8 @@ export default function ModerationPage() {
|
||||||
const [statusFilter, setStatusFilter] = useState('pending');
|
const [statusFilter, setStatusFilter] = useState('pending');
|
||||||
const [reviewingId, setReviewingId] = useState<string | null>(null);
|
const [reviewingId, setReviewingId] = useState<string | null>(null);
|
||||||
const [reason, setReason] = useState('');
|
const [reason, setReason] = useState('');
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [bulkLoading, setBulkLoading] = useState(false);
|
||||||
|
|
||||||
const fetchQueue = () => {
|
const fetchQueue = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -47,6 +50,20 @@ export default function ModerationPage() {
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleSelect = (id: string) => {
|
||||||
|
setSelected((prev) => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkAction = async (action: string) => {
|
||||||
|
setBulkLoading(true);
|
||||||
|
try {
|
||||||
|
await api.bulkReviewModeration(Array.from(selected), action, 'Bulk admin review');
|
||||||
|
setSelected(new Set());
|
||||||
|
fetchQueue();
|
||||||
|
} catch {}
|
||||||
|
setBulkLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell>
|
<AdminShell>
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
|
@ -61,6 +78,22 @@ export default function ModerationPage() {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{statusFilter === 'pending' && (
|
||||||
|
<SelectionBar
|
||||||
|
count={selected.size}
|
||||||
|
total={items.length}
|
||||||
|
onSelectAll={() => setSelected(new Set(items.map((i) => i.id)))}
|
||||||
|
onClearSelection={() => setSelected(new Set())}
|
||||||
|
loading={bulkLoading}
|
||||||
|
actions={[
|
||||||
|
{ label: 'Approve All', action: 'approve', color: 'bg-green-50 text-green-700 hover:bg-green-100', icon: <CheckCircle className="w-3.5 h-3.5" /> },
|
||||||
|
{ label: 'Dismiss All', action: 'dismiss', color: 'bg-gray-100 text-gray-700 hover:bg-gray-200', icon: <XCircle className="w-3.5 h-3.5" /> },
|
||||||
|
{ label: 'Remove Content', action: 'remove_content', confirm: true, color: 'bg-red-50 text-red-700 hover:bg-red-100', icon: <Trash2 className="w-3.5 h-3.5" /> },
|
||||||
|
]}
|
||||||
|
onAction={handleBulkAction}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{[...Array(3)].map((_, i) => <div key={i} className="card p-6 animate-pulse"><div className="h-20 bg-warm-300 rounded" /></div>)}
|
{[...Array(3)].map((_, i) => <div key={i} className="card p-6 animate-pulse"><div className="h-20 bg-warm-300 rounded" /></div>)}
|
||||||
|
|
@ -74,51 +107,56 @@ export default function ModerationPage() {
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<div key={item.id} className="card p-5">
|
<div key={item.id} className={`card p-5 ${selected.has(item.id) ? 'ring-2 ring-brand-300' : ''}`}>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex items-start gap-3 flex-1">
|
||||||
{/* Header */}
|
{statusFilter === 'pending' && (
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<input type="checkbox" className="rounded border-gray-300 mt-1" checked={selected.has(item.id)} onChange={() => toggleSelect(item.id)} />
|
||||||
<span className={`badge ${statusColor(item.status)}`}>{item.status}</span>
|
)}
|
||||||
<span className="badge bg-gray-100 text-gray-600">{item.content_type}</span>
|
<div className="flex-1">
|
||||||
<span className="badge bg-red-50 text-red-700">
|
{/* Header */}
|
||||||
<AlertTriangle className="w-3 h-3 mr-1" />
|
<div className="flex items-center gap-2 mb-2">
|
||||||
{item.flag_reason}
|
<span className={`badge ${statusColor(item.status)}`}>{item.status}</span>
|
||||||
</span>
|
<span className="badge bg-gray-100 text-gray-600">{item.content_type}</span>
|
||||||
<span className="text-xs text-gray-400">{formatDateTime(item.created_at)}</span>
|
<span className="badge bg-red-50 text-red-700">
|
||||||
</div>
|
<AlertTriangle className="w-3 h-3 mr-1" />
|
||||||
|
{item.flag_reason}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">{formatDateTime(item.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="bg-warm-100 rounded-lg p-3 mb-3">
|
<div className="bg-warm-100 rounded-lg p-3 mb-3">
|
||||||
{item.content_type === 'post' ? (
|
{item.content_type === 'post' ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-800 whitespace-pre-wrap">{item.post_body || 'No text content'}</p>
|
<p className="text-sm text-gray-800 whitespace-pre-wrap">{item.post_body || 'No text content'}</p>
|
||||||
{item.post_image && (
|
{item.post_image && (
|
||||||
<div className="mt-2 text-xs text-gray-400">📷 Has image: {item.post_image}</div>
|
<div className="mt-2 text-xs text-gray-400">📷 Has image: {item.post_image}</div>
|
||||||
)}
|
)}
|
||||||
{item.post_video && (
|
{item.post_video && (
|
||||||
<div className="mt-1 text-xs text-gray-400">🎬 Has video: {item.post_video}</div>
|
<div className="mt-1 text-xs text-gray-400">🎬 Has video: {item.post_video}</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-800">{item.comment_body || 'No content'}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Author */}
|
||||||
|
<p className="text-xs text-gray-500 mb-3">
|
||||||
|
By <span className="font-medium text-gray-700">@{item.author_handle || '—'}</span>
|
||||||
|
{item.author_name && ` (${item.author_name})`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* AI Scores */}
|
||||||
|
{item.scores && (
|
||||||
|
<div className="space-y-1 max-w-xs">
|
||||||
|
{item.scores.hate != null && <ScoreBar label="Hate" value={item.scores.hate} />}
|
||||||
|
{item.scores.greed != null && <ScoreBar label="Greed" value={item.scores.greed} />}
|
||||||
|
{item.scores.delusion != null && <ScoreBar label="Delusion" value={item.scores.delusion} />}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<p className="text-sm text-gray-800">{item.comment_body || 'No content'}</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Author */}
|
|
||||||
<p className="text-xs text-gray-500 mb-3">
|
|
||||||
By <span className="font-medium text-gray-700">@{item.author_handle || '—'}</span>
|
|
||||||
{item.author_name && ` (${item.author_name})`}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* AI Scores */}
|
|
||||||
{item.scores && (
|
|
||||||
<div className="space-y-1 max-w-xs">
|
|
||||||
{item.scores.hate != null && <ScoreBar label="Hate" value={item.scores.hate} />}
|
|
||||||
{item.scores.greed != null && <ScoreBar label="Greed" value={item.scores.greed} />}
|
|
||||||
{item.scores.delusion != null && <ScoreBar label="Delusion" value={item.scores.delusion} />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import AdminShell from '@/components/AdminShell';
|
import AdminShell from '@/components/AdminShell';
|
||||||
|
import SelectionBar from '@/components/SelectionBar';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { statusColor, formatDate, truncate } from '@/lib/utils';
|
import { statusColor, formatDate, truncate } from '@/lib/utils';
|
||||||
import { Suspense, useEffect, useState } from 'react';
|
import { Suspense, useEffect, useState } from 'react';
|
||||||
import { Search, ChevronLeft, ChevronRight, Image, Video, MapPin } from 'lucide-react';
|
import { Search, ChevronLeft, ChevronRight, Image, Video, MapPin, Trash2, XCircle, CheckCircle } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
|
@ -24,6 +25,8 @@ function PostsPageInner() {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
const [offset, setOffset] = useState(0);
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [bulkLoading, setBulkLoading] = useState(false);
|
||||||
const limit = 25;
|
const limit = 25;
|
||||||
const authorId = searchParams.get('author_id') || undefined;
|
const authorId = searchParams.get('author_id') || undefined;
|
||||||
|
|
||||||
|
|
@ -50,6 +53,24 @@ function PostsPageInner() {
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleSelect = (id: string) => {
|
||||||
|
setSelected((prev) => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; });
|
||||||
|
};
|
||||||
|
const toggleAll = () => {
|
||||||
|
if (selected.size === posts.length) setSelected(new Set());
|
||||||
|
else setSelected(new Set(posts.map((p) => p.id)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkAction = async (action: string) => {
|
||||||
|
setBulkLoading(true);
|
||||||
|
try {
|
||||||
|
await api.bulkUpdatePosts(Array.from(selected), action, 'Bulk admin action');
|
||||||
|
setSelected(new Set());
|
||||||
|
fetchPosts();
|
||||||
|
} catch {}
|
||||||
|
setBulkLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell>
|
<AdminShell>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
|
|
@ -71,12 +92,29 @@ function PostsPageInner() {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SelectionBar
|
||||||
|
count={selected.size}
|
||||||
|
total={posts.length}
|
||||||
|
onSelectAll={() => setSelected(new Set(posts.map((p) => p.id)))}
|
||||||
|
onClearSelection={() => setSelected(new Set())}
|
||||||
|
loading={bulkLoading}
|
||||||
|
actions={[
|
||||||
|
{ label: 'Remove', action: 'remove', confirm: true, color: 'bg-red-50 text-red-700 hover:bg-red-100', icon: <XCircle className="w-3.5 h-3.5" /> },
|
||||||
|
{ label: 'Activate', action: 'activate', color: 'bg-green-50 text-green-700 hover:bg-green-100', icon: <CheckCircle className="w-3.5 h-3.5" /> },
|
||||||
|
{ label: 'Delete', action: 'delete', confirm: true, color: 'bg-red-100 text-red-800 hover:bg-red-200', icon: <Trash2 className="w-3.5 h-3.5" /> },
|
||||||
|
]}
|
||||||
|
onAction={handleBulkAction}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="card overflow-hidden">
|
<div className="card overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-warm-200">
|
<thead className="bg-warm-200">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th className="table-header w-10">
|
||||||
|
<input type="checkbox" className="rounded border-gray-300" checked={posts.length > 0 && selected.size === posts.length} onChange={toggleAll} />
|
||||||
|
</th>
|
||||||
<th className="table-header">Content</th>
|
<th className="table-header">Content</th>
|
||||||
<th className="table-header">Author</th>
|
<th className="table-header">Author</th>
|
||||||
<th className="table-header">Media</th>
|
<th className="table-header">Media</th>
|
||||||
|
|
@ -89,13 +127,16 @@ function PostsPageInner() {
|
||||||
<tbody className="divide-y divide-warm-300">
|
<tbody className="divide-y divide-warm-300">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
[...Array(5)].map((_, i) => (
|
[...Array(5)].map((_, i) => (
|
||||||
<tr key={i}>{[...Array(7)].map((_, j) => <td key={j} className="table-cell"><div className="h-4 bg-warm-300 rounded animate-pulse w-20" /></td>)}</tr>
|
<tr key={i}>{[...Array(8)].map((_, j) => <td key={j} className="table-cell"><div className="h-4 bg-warm-300 rounded animate-pulse w-20" /></td>)}</tr>
|
||||||
))
|
))
|
||||||
) : posts.length === 0 ? (
|
) : posts.length === 0 ? (
|
||||||
<tr><td colSpan={7} className="table-cell text-center text-gray-400 py-8">No posts found</td></tr>
|
<tr><td colSpan={8} className="table-cell text-center text-gray-400 py-8">No posts found</td></tr>
|
||||||
) : (
|
) : (
|
||||||
posts.map((post) => (
|
posts.map((post) => (
|
||||||
<tr key={post.id} className="hover:bg-warm-50 transition-colors">
|
<tr key={post.id} className={`hover:bg-warm-50 transition-colors ${selected.has(post.id) ? 'bg-brand-50' : ''}`}>
|
||||||
|
<td className="table-cell">
|
||||||
|
<input type="checkbox" className="rounded border-gray-300" checked={selected.has(post.id)} onChange={() => toggleSelect(post.id)} />
|
||||||
|
</td>
|
||||||
<td className="table-cell max-w-xs">
|
<td className="table-cell max-w-xs">
|
||||||
<p className="text-sm text-gray-900 line-clamp-2">{truncate(post.body || '', 80)}</p>
|
<p className="text-sm text-gray-900 line-clamp-2">{truncate(post.body || '', 80)}</p>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import AdminShell from '@/components/AdminShell';
|
import AdminShell from '@/components/AdminShell';
|
||||||
|
import SelectionBar from '@/components/SelectionBar';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { statusColor, formatDateTime } from '@/lib/utils';
|
import { statusColor, formatDateTime } from '@/lib/utils';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
@ -12,6 +13,8 @@ export default function ReportsPage() {
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [statusFilter, setStatusFilter] = useState('pending');
|
const [statusFilter, setStatusFilter] = useState('pending');
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [bulkLoading, setBulkLoading] = useState(false);
|
||||||
|
|
||||||
const fetchReports = () => {
|
const fetchReports = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -30,6 +33,24 @@ export default function ReportsPage() {
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleSelect = (id: string) => {
|
||||||
|
setSelected((prev) => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; });
|
||||||
|
};
|
||||||
|
const toggleAll = () => {
|
||||||
|
if (selected.size === reports.length) setSelected(new Set());
|
||||||
|
else setSelected(new Set(reports.map((r) => r.id)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkAction = async (action: string) => {
|
||||||
|
setBulkLoading(true);
|
||||||
|
try {
|
||||||
|
await api.bulkUpdateReports(Array.from(selected), action);
|
||||||
|
setSelected(new Set());
|
||||||
|
fetchReports();
|
||||||
|
} catch {}
|
||||||
|
setBulkLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell>
|
<AdminShell>
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
|
@ -45,6 +66,21 @@ export default function ReportsPage() {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{statusFilter === 'pending' && (
|
||||||
|
<SelectionBar
|
||||||
|
count={selected.size}
|
||||||
|
total={reports.length}
|
||||||
|
onSelectAll={() => setSelected(new Set(reports.map((r) => r.id)))}
|
||||||
|
onClearSelection={() => setSelected(new Set())}
|
||||||
|
loading={bulkLoading}
|
||||||
|
actions={[
|
||||||
|
{ label: 'Action All', action: 'actioned', color: 'bg-green-50 text-green-700 hover:bg-green-100', icon: <CheckCircle className="w-3.5 h-3.5" /> },
|
||||||
|
{ label: 'Dismiss All', action: 'dismissed', color: 'bg-gray-100 text-gray-700 hover:bg-gray-200', icon: <XCircle className="w-3.5 h-3.5" /> },
|
||||||
|
]}
|
||||||
|
onAction={handleBulkAction}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{[...Array(3)].map((_, i) => <div key={i} className="card p-6 animate-pulse"><div className="h-16 bg-warm-300 rounded" /></div>)}
|
{[...Array(3)].map((_, i) => <div key={i} className="card p-6 animate-pulse"><div className="h-16 bg-warm-300 rounded" /></div>)}
|
||||||
|
|
@ -59,6 +95,11 @@ export default function ReportsPage() {
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-warm-200">
|
<thead className="bg-warm-200">
|
||||||
<tr>
|
<tr>
|
||||||
|
{statusFilter === 'pending' && (
|
||||||
|
<th className="table-header w-10">
|
||||||
|
<input type="checkbox" className="rounded border-gray-300" checked={reports.length > 0 && selected.size === reports.length} onChange={toggleAll} />
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
<th className="table-header">Reporter</th>
|
<th className="table-header">Reporter</th>
|
||||||
<th className="table-header">Target</th>
|
<th className="table-header">Target</th>
|
||||||
<th className="table-header">Type</th>
|
<th className="table-header">Type</th>
|
||||||
|
|
@ -71,7 +112,12 @@ export default function ReportsPage() {
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-warm-300">
|
<tbody className="divide-y divide-warm-300">
|
||||||
{reports.map((report) => (
|
{reports.map((report) => (
|
||||||
<tr key={report.id} className="hover:bg-warm-50 transition-colors">
|
<tr key={report.id} className={`hover:bg-warm-50 transition-colors ${selected.has(report.id) ? 'bg-brand-50' : ''}`}>
|
||||||
|
{statusFilter === 'pending' && (
|
||||||
|
<td className="table-cell">
|
||||||
|
<input type="checkbox" className="rounded border-gray-300" checked={selected.has(report.id)} onChange={() => toggleSelect(report.id)} />
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
<td className="table-cell">
|
<td className="table-cell">
|
||||||
<Link href={`/users/${report.reporter_id}`} className="text-brand-500 hover:text-brand-700 text-sm">
|
<Link href={`/users/${report.reporter_id}`} className="text-brand-500 hover:text-brand-700 text-sm">
|
||||||
@{report.reporter_handle || '—'}
|
@{report.reporter_handle || '—'}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import AdminShell from '@/components/AdminShell';
|
import AdminShell from '@/components/AdminShell';
|
||||||
|
import SelectionBar from '@/components/SelectionBar';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { statusColor, formatDate, truncate } from '@/lib/utils';
|
import { statusColor, formatDate, truncate } from '@/lib/utils';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Search, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { Search, ChevronLeft, ChevronRight, Ban, CheckCircle, PauseCircle } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
|
|
@ -14,6 +15,8 @@ export default function UsersPage() {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
const [offset, setOffset] = useState(0);
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [bulkLoading, setBulkLoading] = useState(false);
|
||||||
const limit = 25;
|
const limit = 25;
|
||||||
|
|
||||||
const fetchUsers = () => {
|
const fetchUsers = () => {
|
||||||
|
|
@ -32,6 +35,24 @@ export default function UsersPage() {
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleSelect = (id: string) => {
|
||||||
|
setSelected((prev) => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; });
|
||||||
|
};
|
||||||
|
const toggleAll = () => {
|
||||||
|
if (selected.size === users.length) setSelected(new Set());
|
||||||
|
else setSelected(new Set(users.map((u) => u.id)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkAction = async (action: string) => {
|
||||||
|
setBulkLoading(true);
|
||||||
|
try {
|
||||||
|
await api.bulkUpdateUsers(Array.from(selected), action, 'Bulk admin action');
|
||||||
|
setSelected(new Set());
|
||||||
|
fetchUsers();
|
||||||
|
} catch {}
|
||||||
|
setBulkLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell>
|
<AdminShell>
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
|
@ -62,12 +83,29 @@ export default function UsersPage() {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SelectionBar
|
||||||
|
count={selected.size}
|
||||||
|
total={users.length}
|
||||||
|
onSelectAll={() => setSelected(new Set(users.map((u) => u.id)))}
|
||||||
|
onClearSelection={() => setSelected(new Set())}
|
||||||
|
loading={bulkLoading}
|
||||||
|
actions={[
|
||||||
|
{ label: 'Activate', action: 'activate', color: 'bg-green-50 text-green-700 hover:bg-green-100', icon: <CheckCircle className="w-3.5 h-3.5" /> },
|
||||||
|
{ label: 'Suspend', action: 'suspend', confirm: true, color: 'bg-yellow-50 text-yellow-700 hover:bg-yellow-100', icon: <PauseCircle className="w-3.5 h-3.5" /> },
|
||||||
|
{ label: 'Ban', action: 'ban', confirm: true, color: 'bg-red-100 text-red-800 hover:bg-red-200', icon: <Ban className="w-3.5 h-3.5" /> },
|
||||||
|
]}
|
||||||
|
onAction={handleBulkAction}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="card overflow-hidden">
|
<div className="card overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-warm-200">
|
<thead className="bg-warm-200">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th className="table-header w-10">
|
||||||
|
<input type="checkbox" className="rounded border-gray-300" checked={users.length > 0 && selected.size === users.length} onChange={toggleAll} />
|
||||||
|
</th>
|
||||||
<th className="table-header">User</th>
|
<th className="table-header">User</th>
|
||||||
<th className="table-header">Email</th>
|
<th className="table-header">Email</th>
|
||||||
<th className="table-header">Role</th>
|
<th className="table-header">Role</th>
|
||||||
|
|
@ -81,16 +119,19 @@ export default function UsersPage() {
|
||||||
{loading ? (
|
{loading ? (
|
||||||
[...Array(5)].map((_, i) => (
|
[...Array(5)].map((_, i) => (
|
||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
{[...Array(7)].map((_, j) => (
|
{[...Array(8)].map((_, j) => (
|
||||||
<td key={j} className="table-cell"><div className="h-4 bg-warm-300 rounded animate-pulse w-20" /></td>
|
<td key={j} className="table-cell"><div className="h-4 bg-warm-300 rounded animate-pulse w-20" /></td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
) : users.length === 0 ? (
|
) : users.length === 0 ? (
|
||||||
<tr><td colSpan={7} className="table-cell text-center text-gray-400 py-8">No users found</td></tr>
|
<tr><td colSpan={8} className="table-cell text-center text-gray-400 py-8">No users found</td></tr>
|
||||||
) : (
|
) : (
|
||||||
users.map((user) => (
|
users.map((user) => (
|
||||||
<tr key={user.id} className="hover:bg-warm-50 transition-colors">
|
<tr key={user.id} className={`hover:bg-warm-50 transition-colors ${selected.has(user.id) ? 'bg-brand-50' : ''}`}>
|
||||||
|
<td className="table-cell">
|
||||||
|
<input type="checkbox" className="rounded border-gray-300" checked={selected.has(user.id)} onChange={() => toggleSelect(user.id)} />
|
||||||
|
</td>
|
||||||
<td className="table-cell">
|
<td className="table-cell">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 bg-brand-100 rounded-full flex items-center justify-center text-brand-600 text-xs font-bold">
|
<div className="w-8 h-8 bg-brand-100 rounded-full flex items-center justify-center text-brand-600 text-xs font-bold">
|
||||||
|
|
|
||||||
60
admin/src/components/SelectionBar.tsx
Normal file
60
admin/src/components/SelectionBar.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface BulkAction {
|
||||||
|
label: string;
|
||||||
|
action: string;
|
||||||
|
color?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
confirm?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectionBarProps {
|
||||||
|
count: number;
|
||||||
|
total: number;
|
||||||
|
onSelectAll: () => void;
|
||||||
|
onClearSelection: () => void;
|
||||||
|
actions: BulkAction[];
|
||||||
|
onAction: (action: string) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SelectionBar({ count, total, onSelectAll, onClearSelection, actions, onAction, loading }: SelectionBarProps) {
|
||||||
|
if (count === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-brand-50 border border-brand-200 rounded-lg px-4 py-3 mb-4 flex items-center gap-3 animate-in slide-in-from-top-2">
|
||||||
|
<span className="text-sm font-medium text-brand-700">
|
||||||
|
{count} selected
|
||||||
|
</span>
|
||||||
|
{count < total && (
|
||||||
|
<button onClick={onSelectAll} className="text-xs text-brand-600 hover:text-brand-800 underline">
|
||||||
|
Select all {total}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{actions.map((a) => (
|
||||||
|
<button
|
||||||
|
key={a.action}
|
||||||
|
onClick={() => {
|
||||||
|
if (a.confirm && !confirm(`Are you sure you want to ${a.label.toLowerCase()} ${count} items?`)) return;
|
||||||
|
onAction(a.action);
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||||
|
a.color || 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{a.icon}
|
||||||
|
{a.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button onClick={onClearSelection} className="p-1.5 rounded hover:bg-brand-100 text-brand-500" title="Clear selection">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -138,6 +138,20 @@ class ApiClient {
|
||||||
return this.request<any>(`/api/v1/admin/posts/${id}`, { method: 'DELETE' });
|
return this.request<any>(`/api/v1/admin/posts/${id}`, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async bulkUpdatePosts(ids: string[], action: string, reason?: string) {
|
||||||
|
return this.request<any>('/api/v1/admin/posts/bulk', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids, action, reason }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async bulkUpdateUsers(ids: string[], action: string, reason?: string) {
|
||||||
|
return this.request<any>('/api/v1/admin/users/bulk', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids, action, reason }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Moderation
|
// Moderation
|
||||||
async getModerationQueue(params: { limit?: number; offset?: number; status?: string } = {}) {
|
async getModerationQueue(params: { limit?: number; offset?: number; status?: string } = {}) {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
|
|
@ -154,6 +168,13 @@ class ApiClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async bulkReviewModeration(ids: string[], action: string, reason?: string) {
|
||||||
|
return this.request<any>('/api/v1/admin/moderation/bulk', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids, action, reason }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Appeals
|
// Appeals
|
||||||
async listAppeals(params: { limit?: number; offset?: number; status?: string } = {}) {
|
async listAppeals(params: { limit?: number; offset?: number; status?: string } = {}) {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
|
|
@ -186,6 +207,13 @@ class ApiClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async bulkUpdateReports(ids: string[], action: string) {
|
||||||
|
return this.request<any>('/api/v1/admin/reports/bulk', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids, action }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Algorithm
|
// Algorithm
|
||||||
async getAlgorithmConfig() {
|
async getAlgorithmConfig() {
|
||||||
return this.request<any>('/api/v1/admin/algorithm');
|
return this.request<any>('/api/v1/admin/algorithm');
|
||||||
|
|
|
||||||
|
|
@ -367,10 +367,15 @@ func main() {
|
||||||
admin.GET("/posts/:id", adminHandler.GetPost)
|
admin.GET("/posts/:id", adminHandler.GetPost)
|
||||||
admin.PATCH("/posts/:id/status", adminHandler.UpdatePostStatus)
|
admin.PATCH("/posts/:id/status", adminHandler.UpdatePostStatus)
|
||||||
admin.DELETE("/posts/:id", adminHandler.DeletePost)
|
admin.DELETE("/posts/:id", adminHandler.DeletePost)
|
||||||
|
admin.POST("/posts/bulk", adminHandler.BulkUpdatePosts)
|
||||||
|
|
||||||
|
// User Bulk
|
||||||
|
admin.POST("/users/bulk", adminHandler.BulkUpdateUsers)
|
||||||
|
|
||||||
// Moderation Queue
|
// Moderation Queue
|
||||||
admin.GET("/moderation", adminHandler.GetModerationQueue)
|
admin.GET("/moderation", adminHandler.GetModerationQueue)
|
||||||
admin.PATCH("/moderation/:id/review", adminHandler.ReviewModerationFlag)
|
admin.PATCH("/moderation/:id/review", adminHandler.ReviewModerationFlag)
|
||||||
|
admin.POST("/moderation/bulk", adminHandler.BulkReviewModeration)
|
||||||
|
|
||||||
// Appeals
|
// Appeals
|
||||||
admin.GET("/appeals", adminHandler.ListAppeals)
|
admin.GET("/appeals", adminHandler.ListAppeals)
|
||||||
|
|
@ -379,6 +384,7 @@ func main() {
|
||||||
// Reports
|
// Reports
|
||||||
admin.GET("/reports", adminHandler.ListReports)
|
admin.GET("/reports", adminHandler.ListReports)
|
||||||
admin.PATCH("/reports/:id", adminHandler.UpdateReportStatus)
|
admin.PATCH("/reports/:id", adminHandler.UpdateReportStatus)
|
||||||
|
admin.POST("/reports/bulk", adminHandler.BulkUpdateReports)
|
||||||
|
|
||||||
// Algorithm / Feed Config
|
// Algorithm / Feed Config
|
||||||
admin.GET("/algorithm", adminHandler.GetAlgorithmConfig)
|
admin.GET("/algorithm", adminHandler.GetAlgorithmConfig)
|
||||||
|
|
|
||||||
|
|
@ -551,7 +551,9 @@ func (h *AdminHandler) ListPosts(c *gin.Context) {
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT p.id, p.author_id, p.body, p.status, p.image_url, p.video_url,
|
SELECT p.id, p.author_id, p.body, p.status, p.image_url, p.video_url,
|
||||||
p.like_count, p.comment_count, p.is_beacon, p.visibility, p.created_at,
|
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
|
pr.handle, pr.display_name, pr.avatar_url
|
||||||
FROM posts p
|
FROM posts p
|
||||||
LEFT JOIN profiles pr ON p.author_id = pr.id
|
LEFT JOIN profiles pr ON p.author_id = pr.id
|
||||||
|
|
@ -649,7 +651,9 @@ func (h *AdminHandler) GetPost(c *gin.Context) {
|
||||||
SELECT p.id, p.author_id, p.body, p.status, p.body_format, p.image_url, p.video_url,
|
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.thumbnail_url, p.tone_label, p.cis_score, COALESCE(p.duration_ms, 0),
|
||||||
p.background_id, p.is_beacon, p.beacon_type, p.allow_chain, p.visibility,
|
p.background_id, p.is_beacon, p.beacon_type, p.allow_chain, p.visibility,
|
||||||
p.like_count, p.comment_count, p.created_at, p.edited_at,
|
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
|
pr.handle, pr.display_name, pr.avatar_url
|
||||||
FROM posts p
|
FROM posts p
|
||||||
LEFT JOIN profiles pr ON p.author_id = pr.id
|
LEFT JOIN profiles pr ON p.author_id = pr.id
|
||||||
|
|
@ -757,6 +761,184 @@ func (h *AdminHandler) DeletePost(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Post deleted"})
|
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
|
// Moderation Queue
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue