Fix posts query (like/comment counts), add multi-select with bulk actions to all list pages

This commit is contained in:
Patrick Britton 2026-02-06 11:06:54 -06:00
parent 766392e5b0
commit ec5a0aad8b
8 changed files with 493 additions and 51 deletions

View file

@ -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 */}

View file

@ -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>

View file

@ -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 || '—'}

View file

@ -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">

View 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>
);
}

View file

@ -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');

View file

@ -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)

View file

@ -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
// ────────────────────────────────────────────── // ──────────────────────────────────────────────