From e06b7252c4e2f27f610045f10428538c2a0f08cc Mon Sep 17 00:00:00 2001 From: Patrick Britton Date: Tue, 17 Feb 2026 16:21:42 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20Admin=20panel=20completions=20=E2=80=94?= =?UTF-8?q?=20audit=20log,=20waitlist,=20feed=20reset,=20profile=20edit=20?= =?UTF-8?q?for=20all=20users?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove is_official gate: profile editor and follow manager now shown for all users - Add /audit-log page: paginated view of all admin actions - Add /waitlist page: approve/reject/delete waitlist entries with notes - Add Feed Impression Reset button on user detail (clears user's seen-posts history) - Add feed cooling/diversity thresholds to algorithm_config defaults (configurable via /algorithm) - Go: AdminListWaitlist, AdminUpdateWaitlist, AdminDeleteWaitlist handlers - Go: AdminResetFeedImpressions handler (DELETE /admin/users/:id/feed-impressions) - Go: Register all new routes in main.go - Sidebar: add Waitlist (Users & Content) and Audit Log (Platform) links - DB: add 20260218_waitlist.sql migration - api.ts: listWaitlist, updateWaitlist, deleteWaitlist, resetFeedImpressions methods Co-Authored-By: Claude Sonnet 4.6 --- admin/src/app/audit-log/page.tsx | 136 ++++++++++ admin/src/app/users/[id]/page.tsx | 36 ++- admin/src/app/waitlist/page.tsx | 250 ++++++++++++++++++ admin/src/components/Sidebar.tsx | 4 +- admin/src/lib/api.ts | 25 ++ go-backend/cmd/api/main.go | 8 + go-backend/internal/handlers/admin_handler.go | 129 +++++++++ go-backend/migrations/20260218_waitlist.sql | 16 ++ 8 files changed, 593 insertions(+), 11 deletions(-) create mode 100644 admin/src/app/audit-log/page.tsx create mode 100644 admin/src/app/waitlist/page.tsx create mode 100644 go-backend/migrations/20260218_waitlist.sql diff --git a/admin/src/app/audit-log/page.tsx b/admin/src/app/audit-log/page.tsx new file mode 100644 index 0000000..5287812 --- /dev/null +++ b/admin/src/app/audit-log/page.tsx @@ -0,0 +1,136 @@ +'use client'; + +import AdminShell from '@/components/AdminShell'; +import { api } from '@/lib/api'; +import { formatDateTime } from '@/lib/utils'; +import { useEffect, useState } from 'react'; +import { ScrollText, RefreshCw, ChevronLeft, ChevronRight } from 'lucide-react'; + +const ACTION_COLORS: Record = { + ban: 'bg-red-100 text-red-700', + suspend: 'bg-orange-100 text-orange-700', + activate: 'bg-green-100 text-green-700', + delete: 'bg-red-100 text-red-700', + admin_create_user: 'bg-blue-100 text-blue-700', + admin_import_content: 'bg-blue-100 text-blue-700', + waitlist_update: 'bg-purple-100 text-purple-700', + reset_feed_impressions: 'bg-yellow-100 text-yellow-700', +}; + +function actionColor(action: string) { + return ACTION_COLORS[action] || 'bg-gray-100 text-gray-600'; +} + +export default function AuditLogPage() { + const [entries, setEntries] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(0); + const limit = 50; + + const fetchLog = (p = page) => { + setLoading(true); + api.getAuditLog({ limit, offset: p * limit }) + .then((data) => { + setEntries(data.entries || []); + setTotal(data.total || 0); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }; + + useEffect(() => { fetchLog(page); }, [page]); + + const totalPages = Math.max(1, Math.ceil(total / limit)); + + return ( + +
+
+

+ Admin Audit Log +

+

Every admin action is recorded here

+
+ +
+ +
+ {loading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+ ) : entries.length === 0 ? ( +
No audit log entries found.
+ ) : ( +
+ + + + + + + + + + + + {entries.map((e) => ( + + + + + + + + ))} + +
WhenAdminActionTargetDetails
+ {e.created_at ? formatDateTime(e.created_at) : '—'} + + {e.actor_handle ? `@${e.actor_handle}` : e.actor_id ? e.actor_id.slice(0, 8) + '…' : '—'} + + + {e.action?.replace(/_/g, ' ')} + + + {e.target_type && {e.target_type}} + {e.target_id && {String(e.target_id).slice(0, 8)}…} + + {e.details || '—'} +
+
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {page + 1} of {totalPages} ({total} entries) +

+
+ + +
+
+ )} +
+ + ); +} diff --git a/admin/src/app/users/[id]/page.tsx b/admin/src/app/users/[id]/page.tsx index 264241d..905bcb0 100644 --- a/admin/src/app/users/[id]/page.tsx +++ b/admin/src/app/users/[id]/page.tsx @@ -5,7 +5,7 @@ import { api } from '@/lib/api'; import { statusColor, formatDateTime } from '@/lib/utils'; import { useEffect, useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; -import { ArrowLeft, Shield, Ban, CheckCircle, XCircle, Star, RotateCcw, Pencil, UserPlus, UserMinus, Users, Save, X } from 'lucide-react'; +import { ArrowLeft, Shield, Ban, CheckCircle, XCircle, Star, RotateCcw, Pencil, UserPlus, UserMinus, Users, Save, X, RefreshCcw } from 'lucide-react'; import Link from 'next/link'; export default function UserDetailPage() { @@ -100,6 +100,18 @@ export default function UserDetailPage() { setActionLoading(false); }; + const handleResetFeedImpressions = async () => { + if (!confirm('Reset this user\'s feed impression history? They will see previously-seen posts again.')) return; + setActionLoading(true); + try { + const result = await api.resetFeedImpressions(params.id as string); + alert(`Feed impressions reset. ${result.deleted ?? 0} records cleared.`); + } catch (e: any) { + alert(`Reset failed: ${e.message}`); + } + setActionLoading(false); + }; + return ( @@ -246,6 +258,14 @@ export default function UserDetailPage() {
)} + {/* Feed Impressions */} +
+

Feed History

+ +
+ {/* View Posts */}
@@ -256,15 +276,11 @@ export default function UserDetailPage() {
- {/* Official Account: Editable Profile */} - {user.is_official && ( - - )} + {/* Editable Profile */} + - {/* Official Account: Follower/Following Management */} - {user.is_official && ( - - )} + {/* Follower/Following Management */} + ) : (
User not found
@@ -391,7 +407,7 @@ function OfficialProfileEditor({ user, onSaved }: { user: any; onSaved: () => vo

- Official Account Profile + Edit Profile

{!editing ? ( diff --git a/admin/src/app/waitlist/page.tsx b/admin/src/app/waitlist/page.tsx new file mode 100644 index 0000000..40a8e7b --- /dev/null +++ b/admin/src/app/waitlist/page.tsx @@ -0,0 +1,250 @@ +'use client'; + +import AdminShell from '@/components/AdminShell'; +import { api } from '@/lib/api'; +import { formatDateTime } from '@/lib/utils'; +import { useEffect, useState } from 'react'; +import { Users, RefreshCw, CheckCircle, XCircle, Trash2, ChevronLeft, ChevronRight, Filter } from 'lucide-react'; + +const STATUS_COLORS: Record = { + pending: 'bg-yellow-100 text-yellow-700', + approved: 'bg-green-100 text-green-700', + rejected: 'bg-red-100 text-red-700', + invited: 'bg-blue-100 text-blue-700', +}; + +export default function WaitlistPage() { + const [entries, setEntries] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [statusFilter, setStatusFilter] = useState(''); + const [page, setPage] = useState(0); + const [actionLoading, setActionLoading] = useState(null); + const [notesModal, setNotesModal] = useState<{ id: string; notes: string } | null>(null); + const limit = 50; + + const fetchList = (p = page, status = statusFilter) => { + setLoading(true); + api.listWaitlist({ status: status || undefined, limit, offset: p * limit }) + .then((data) => { + setEntries(data.entries || []); + setTotal(data.total || 0); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }; + + useEffect(() => { fetchList(page, statusFilter); }, [page, statusFilter]); + + const handleStatusChange = async (id: string, status: string) => { + setActionLoading(id + status); + try { + await api.updateWaitlist(id, { status }); + fetchList(); + } catch (e: any) { + alert(`Failed: ${e.message}`); + } + setActionLoading(null); + }; + + const handleDelete = async (id: string, email: string) => { + if (!confirm(`Delete waitlist entry for ${email}?`)) return; + setActionLoading(id + 'del'); + try { + await api.deleteWaitlist(id); + fetchList(); + } catch (e: any) { + alert(`Failed: ${e.message}`); + } + setActionLoading(null); + }; + + const handleSaveNotes = async () => { + if (!notesModal) return; + setActionLoading('notes'); + try { + await api.updateWaitlist(notesModal.id, { notes: notesModal.notes }); + setNotesModal(null); + fetchList(); + } catch (e: any) { + alert(`Failed: ${e.message}`); + } + setActionLoading(null); + }; + + const totalPages = Math.max(1, Math.ceil(total / limit)); + + const counts: Record = {}; + entries.forEach((e) => { counts[e.status || 'pending'] = (counts[e.status || 'pending'] || 0) + 1; }); + + return ( + +
+
+

+ Waitlist +

+

+ {total} total {statusFilter ? `(filtered: ${statusFilter})` : ''} +

+
+ +
+ + {/* Filter tabs */} +
+ {['', 'pending', 'approved', 'rejected', 'invited'].map((s) => ( + + ))} +
+ +
+ {loading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+ ) : entries.length === 0 ? ( +
+ No waitlist entries{statusFilter ? ` with status "${statusFilter}"` : ''}. +
+ ) : ( +
+ + + + + + + + + + + + + + {entries.map((e) => ( + + + + + + + + + + ))} + +
EmailNameReferralStatusJoinedNotesActions
{e.email}{e.name || '—'} + {e.referral_code ? {e.referral_code} : '—'} + {e.invited_by && via {e.invited_by}} + + + {e.status || 'pending'} + + + {e.created_at ? formatDateTime(e.created_at) : '—'} + + + +
+ {e.status !== 'approved' && ( + + )} + {e.status !== 'rejected' && ( + + )} + +
+
+
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+

Page {page + 1} of {totalPages}

+
+ + +
+
+ )} +
+ + {/* Notes Modal */} + {notesModal && ( +
setNotesModal(null)}> +
e.stopPropagation()}> +

Admin Notes

+