From ec5a0aad8bf28510ad2a815f852adc0aaf7ca8f8 Mon Sep 17 00:00:00 2001 From: Patrick Britton Date: Fri, 6 Feb 2026 11:06:54 -0600 Subject: [PATCH] Fix posts query (like/comment counts), add multi-select with bulk actions to all list pages --- admin/src/app/moderation/page.tsx | 118 +++++++---- admin/src/app/posts/page.tsx | 49 ++++- admin/src/app/reports/page.tsx | 48 ++++- admin/src/app/users/page.tsx | 49 ++++- admin/src/components/SelectionBar.tsx | 60 ++++++ admin/src/lib/api.ts | 28 +++ go-backend/cmd/api/main.go | 6 + go-backend/internal/handlers/admin_handler.go | 186 +++++++++++++++++- 8 files changed, 493 insertions(+), 51 deletions(-) create mode 100644 admin/src/components/SelectionBar.tsx diff --git a/admin/src/app/moderation/page.tsx b/admin/src/app/moderation/page.tsx index a414ea4..4b97274 100644 --- a/admin/src/app/moderation/page.tsx +++ b/admin/src/app/moderation/page.tsx @@ -1,6 +1,7 @@ 'use client'; import AdminShell from '@/components/AdminShell'; +import SelectionBar from '@/components/SelectionBar'; import { api } from '@/lib/api'; import { statusColor, formatDateTime } from '@/lib/utils'; import { useEffect, useState } from 'react'; @@ -27,6 +28,8 @@ export default function ModerationPage() { const [statusFilter, setStatusFilter] = useState('pending'); const [reviewingId, setReviewingId] = useState(null); const [reason, setReason] = useState(''); + const [selected, setSelected] = useState>(new Set()); + const [bulkLoading, setBulkLoading] = useState(false); const fetchQueue = () => { setLoading(true); @@ -47,6 +50,20 @@ export default function ModerationPage() { } 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 (
@@ -61,6 +78,22 @@ export default function ModerationPage() {
+ {statusFilter === 'pending' && ( + 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: }, + { label: 'Dismiss All', action: 'dismiss', color: 'bg-gray-100 text-gray-700 hover:bg-gray-200', icon: }, + { label: 'Remove Content', action: 'remove_content', confirm: true, color: 'bg-red-50 text-red-700 hover:bg-red-100', icon: }, + ]} + onAction={handleBulkAction} + /> + )} + {loading ? (
{[...Array(3)].map((_, i) =>
)} @@ -74,51 +107,56 @@ export default function ModerationPage() { ) : (
{items.map((item) => ( -
+
-
- {/* Header */} -
- {item.status} - {item.content_type} - - - {item.flag_reason} - - {formatDateTime(item.created_at)} -
+
+ {statusFilter === 'pending' && ( + toggleSelect(item.id)} /> + )} +
+ {/* Header */} +
+ {item.status} + {item.content_type} + + + {item.flag_reason} + + {formatDateTime(item.created_at)} +
- {/* Content */} -
- {item.content_type === 'post' ? ( -
-

{item.post_body || 'No text content'}

- {item.post_image && ( -
📷 Has image: {item.post_image}
- )} - {item.post_video && ( -
🎬 Has video: {item.post_video}
- )} + {/* Content */} +
+ {item.content_type === 'post' ? ( +
+

{item.post_body || 'No text content'}

+ {item.post_image && ( +
📷 Has image: {item.post_image}
+ )} + {item.post_video && ( +
🎬 Has video: {item.post_video}
+ )} +
+ ) : ( +

{item.comment_body || 'No content'}

+ )} +
+ + {/* Author */} +

+ By @{item.author_handle || '—'} + {item.author_name && ` (${item.author_name})`} +

+ + {/* AI Scores */} + {item.scores && ( +
+ {item.scores.hate != null && } + {item.scores.greed != null && } + {item.scores.delusion != null && }
- ) : ( -

{item.comment_body || 'No content'}

)}
- - {/* Author */} -

- By @{item.author_handle || '—'} - {item.author_name && ` (${item.author_name})`} -

- - {/* AI Scores */} - {item.scores && ( -
- {item.scores.hate != null && } - {item.scores.greed != null && } - {item.scores.delusion != null && } -
- )}
{/* Actions */} diff --git a/admin/src/app/posts/page.tsx b/admin/src/app/posts/page.tsx index 5c898a5..c24aeed 100644 --- a/admin/src/app/posts/page.tsx +++ b/admin/src/app/posts/page.tsx @@ -1,10 +1,11 @@ 'use client'; import AdminShell from '@/components/AdminShell'; +import SelectionBar from '@/components/SelectionBar'; import { api } from '@/lib/api'; import { statusColor, formatDate, truncate } from '@/lib/utils'; 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 { useSearchParams } from 'next/navigation'; @@ -24,6 +25,8 @@ function PostsPageInner() { const [search, setSearch] = useState(''); const [statusFilter, setStatusFilter] = useState(''); const [offset, setOffset] = useState(0); + const [selected, setSelected] = useState>(new Set()); + const [bulkLoading, setBulkLoading] = useState(false); const limit = 25; const authorId = searchParams.get('author_id') || undefined; @@ -50,6 +53,24 @@ function PostsPageInner() { } 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 (
@@ -71,12 +92,29 @@ function PostsPageInner() {
+ 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: }, + { label: 'Activate', action: 'activate', color: 'bg-green-50 text-green-700 hover:bg-green-100', icon: }, + { label: 'Delete', action: 'delete', confirm: true, color: 'bg-red-100 text-red-800 hover:bg-red-200', icon: }, + ]} + onAction={handleBulkAction} + /> + {/* Table */}
+ @@ -89,13 +127,16 @@ function PostsPageInner() { {loading ? ( [...Array(5)].map((_, i) => ( - {[...Array(7)].map((_, j) => )} + {[...Array(8)].map((_, j) => )} )) ) : posts.length === 0 ? ( - + ) : ( posts.map((post) => ( - + + diff --git a/admin/src/app/reports/page.tsx b/admin/src/app/reports/page.tsx index 2054c8a..51e5019 100644 --- a/admin/src/app/reports/page.tsx +++ b/admin/src/app/reports/page.tsx @@ -1,6 +1,7 @@ 'use client'; import AdminShell from '@/components/AdminShell'; +import SelectionBar from '@/components/SelectionBar'; import { api } from '@/lib/api'; import { statusColor, formatDateTime } from '@/lib/utils'; import { useEffect, useState } from 'react'; @@ -12,6 +13,8 @@ export default function ReportsPage() { const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); const [statusFilter, setStatusFilter] = useState('pending'); + const [selected, setSelected] = useState>(new Set()); + const [bulkLoading, setBulkLoading] = useState(false); const fetchReports = () => { setLoading(true); @@ -30,6 +33,24 @@ export default function ReportsPage() { } 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 (
@@ -45,6 +66,21 @@ export default function ReportsPage() {
+ {statusFilter === 'pending' && ( + 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: }, + { label: 'Dismiss All', action: 'dismissed', color: 'bg-gray-100 text-gray-700 hover:bg-gray-200', icon: }, + ]} + onAction={handleBulkAction} + /> + )} + {loading ? (
{[...Array(3)].map((_, i) =>
)} @@ -59,6 +95,11 @@ export default function ReportsPage() {
+ 0 && selected.size === posts.length} onChange={toggleAll} /> + Content Author Media
No posts found
No posts found
+ toggleSelect(post.id)} /> +

{truncate(post.body || '', 80)}

+ {statusFilter === 'pending' && ( + + )} @@ -71,7 +112,12 @@ export default function ReportsPage() { {reports.map((report) => ( - + + {statusFilter === 'pending' && ( + + )}
+ 0 && selected.size === reports.length} onChange={toggleAll} /> + Reporter Target Type
+ toggleSelect(report.id)} /> + @{report.reporter_handle || '—'} diff --git a/admin/src/app/users/page.tsx b/admin/src/app/users/page.tsx index 775d315..7f100e4 100644 --- a/admin/src/app/users/page.tsx +++ b/admin/src/app/users/page.tsx @@ -1,10 +1,11 @@ 'use client'; import AdminShell from '@/components/AdminShell'; +import SelectionBar from '@/components/SelectionBar'; import { api } from '@/lib/api'; import { statusColor, formatDate, truncate } from '@/lib/utils'; 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'; export default function UsersPage() { @@ -14,6 +15,8 @@ export default function UsersPage() { const [search, setSearch] = useState(''); const [statusFilter, setStatusFilter] = useState(''); const [offset, setOffset] = useState(0); + const [selected, setSelected] = useState>(new Set()); + const [bulkLoading, setBulkLoading] = useState(false); const limit = 25; const fetchUsers = () => { @@ -32,6 +35,24 @@ export default function UsersPage() { 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 (
@@ -62,12 +83,29 @@ export default function UsersPage() {
+ 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: }, + { label: 'Suspend', action: 'suspend', confirm: true, color: 'bg-yellow-50 text-yellow-700 hover:bg-yellow-100', icon: }, + { label: 'Ban', action: 'ban', confirm: true, color: 'bg-red-100 text-red-800 hover:bg-red-200', icon: }, + ]} + onAction={handleBulkAction} + /> + {/* Table */}
+ @@ -81,16 +119,19 @@ export default function UsersPage() { {loading ? ( [...Array(5)].map((_, i) => ( - {[...Array(7)].map((_, j) => ( + {[...Array(8)].map((_, j) => ( ))} )) ) : users.length === 0 ? ( - + ) : ( users.map((user) => ( - + +
+ 0 && selected.size === users.length} onChange={toggleAll} /> + User Email Role
No users found
No users found
+ toggleSelect(user.id)} /> +
diff --git a/admin/src/components/SelectionBar.tsx b/admin/src/components/SelectionBar.tsx new file mode 100644 index 0000000..4b4e9de --- /dev/null +++ b/admin/src/components/SelectionBar.tsx @@ -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 ( +
+ + {count} selected + + {count < total && ( + + )} +
+
+ {actions.map((a) => ( + + ))} + +
+
+ ); +} diff --git a/admin/src/lib/api.ts b/admin/src/lib/api.ts index fe4ff63..92111ed 100644 --- a/admin/src/lib/api.ts +++ b/admin/src/lib/api.ts @@ -138,6 +138,20 @@ class ApiClient { return this.request(`/api/v1/admin/posts/${id}`, { method: 'DELETE' }); } + async bulkUpdatePosts(ids: string[], action: string, reason?: string) { + return this.request('/api/v1/admin/posts/bulk', { + method: 'POST', + body: JSON.stringify({ ids, action, reason }), + }); + } + + async bulkUpdateUsers(ids: string[], action: string, reason?: string) { + return this.request('/api/v1/admin/users/bulk', { + method: 'POST', + body: JSON.stringify({ ids, action, reason }), + }); + } + // Moderation async getModerationQueue(params: { limit?: number; offset?: number; status?: string } = {}) { const qs = new URLSearchParams(); @@ -154,6 +168,13 @@ class ApiClient { }); } + async bulkReviewModeration(ids: string[], action: string, reason?: string) { + return this.request('/api/v1/admin/moderation/bulk', { + method: 'POST', + body: JSON.stringify({ ids, action, reason }), + }); + } + // Appeals async listAppeals(params: { limit?: number; offset?: number; status?: string } = {}) { const qs = new URLSearchParams(); @@ -186,6 +207,13 @@ class ApiClient { }); } + async bulkUpdateReports(ids: string[], action: string) { + return this.request('/api/v1/admin/reports/bulk', { + method: 'POST', + body: JSON.stringify({ ids, action }), + }); + } + // Algorithm async getAlgorithmConfig() { return this.request('/api/v1/admin/algorithm'); diff --git a/go-backend/cmd/api/main.go b/go-backend/cmd/api/main.go index 5a9ca61..0ccba8e 100644 --- a/go-backend/cmd/api/main.go +++ b/go-backend/cmd/api/main.go @@ -367,10 +367,15 @@ func main() { admin.GET("/posts/:id", adminHandler.GetPost) admin.PATCH("/posts/:id/status", adminHandler.UpdatePostStatus) admin.DELETE("/posts/:id", adminHandler.DeletePost) + admin.POST("/posts/bulk", adminHandler.BulkUpdatePosts) + + // User Bulk + admin.POST("/users/bulk", adminHandler.BulkUpdateUsers) // Moderation Queue admin.GET("/moderation", adminHandler.GetModerationQueue) admin.PATCH("/moderation/:id/review", adminHandler.ReviewModerationFlag) + admin.POST("/moderation/bulk", adminHandler.BulkReviewModeration) // Appeals admin.GET("/appeals", adminHandler.ListAppeals) @@ -379,6 +384,7 @@ func main() { // Reports admin.GET("/reports", adminHandler.ListReports) admin.PATCH("/reports/:id", adminHandler.UpdateReportStatus) + admin.POST("/reports/bulk", adminHandler.BulkUpdateReports) // Algorithm / Feed Config admin.GET("/algorithm", adminHandler.GetAlgorithmConfig) diff --git a/go-backend/internal/handlers/admin_handler.go b/go-backend/internal/handlers/admin_handler.go index 13efa7a..52a479f 100644 --- a/go-backend/internal/handlers/admin_handler.go +++ b/go-backend/internal/handlers/admin_handler.go @@ -551,7 +551,9 @@ func (h *AdminHandler) ListPosts(c *gin.Context) { query := ` SELECT p.id, p.author_id, p.body, p.status, p.image_url, p.video_url, - p.like_count, p.comment_count, p.is_beacon, p.visibility, p.created_at, + COALESCE((SELECT COUNT(*) FROM post_likes pl WHERE pl.post_id = p.id), 0) AS like_count, + COALESCE((SELECT COUNT(*) FROM comments c WHERE c.post_id = p.id AND c.deleted_at IS NULL), 0) AS comment_count, + p.is_beacon, p.visibility, p.created_at, pr.handle, pr.display_name, pr.avatar_url FROM posts p LEFT JOIN profiles pr ON p.author_id = pr.id @@ -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, p.thumbnail_url, p.tone_label, p.cis_score, COALESCE(p.duration_ms, 0), p.background_id, p.is_beacon, p.beacon_type, p.allow_chain, p.visibility, - p.like_count, p.comment_count, p.created_at, p.edited_at, + COALESCE((SELECT COUNT(*) FROM post_likes pl WHERE pl.post_id = p.id), 0), + COALESCE((SELECT COUNT(*) FROM comments c WHERE c.post_id = p.id AND c.deleted_at IS NULL), 0), + p.created_at, p.edited_at, pr.handle, pr.display_name, pr.avatar_url FROM posts p LEFT JOIN profiles pr ON p.author_id = pr.id @@ -757,6 +761,184 @@ func (h *AdminHandler) DeletePost(c *gin.Context) { 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 // ──────────────────────────────────────────────