feat: Admin panel completions — audit log, waitlist, feed reset, profile edit for all users
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
4315da74b2
commit
e06b7252c4
136
admin/src/app/audit-log/page.tsx
Normal file
136
admin/src/app/audit-log/page.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||||
|
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<any[]>([]);
|
||||||
|
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 (
|
||||||
|
<AdminShell>
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||||
|
<ScrollText className="w-6 h-6 text-brand-500" /> Admin Audit Log
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Every admin action is recorded here</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => fetchLog(page)} className="btn-secondary text-sm flex items-center gap-1">
|
||||||
|
<RefreshCw className="w-4 h-4" /> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 animate-pulse space-y-3">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-10 bg-warm-300 rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-gray-400">No audit log entries found.</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-warm-100 border-b border-warm-300">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600">When</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600">Admin</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600">Action</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600">Target</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600">Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-warm-100">
|
||||||
|
{entries.map((e) => (
|
||||||
|
<tr key={e.id} className="hover:bg-warm-50">
|
||||||
|
<td className="px-4 py-2.5 text-gray-500 text-xs whitespace-nowrap">
|
||||||
|
{e.created_at ? formatDateTime(e.created_at) : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-700 font-medium">
|
||||||
|
{e.actor_handle ? `@${e.actor_handle}` : e.actor_id ? e.actor_id.slice(0, 8) + '…' : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${actionColor(e.action)}`}>
|
||||||
|
{e.action?.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-500 text-xs">
|
||||||
|
{e.target_type && <span className="font-medium text-gray-700">{e.target_type}</span>}
|
||||||
|
{e.target_id && <span className="ml-1 font-mono">{String(e.target_id).slice(0, 8)}…</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-500 text-xs max-w-xs truncate" title={e.details}>
|
||||||
|
{e.details || '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-warm-200">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Page {page + 1} of {totalPages} ({total} entries)
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
className="p-1.5 rounded border border-warm-300 disabled:opacity-40 hover:bg-warm-100"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
className="p-1.5 rounded border border-warm-300 disabled:opacity-40 hover:bg-warm-100"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AdminShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ 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';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
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';
|
import Link from 'next/link';
|
||||||
|
|
||||||
export default function UserDetailPage() {
|
export default function UserDetailPage() {
|
||||||
|
|
@ -100,6 +100,18 @@ export default function UserDetailPage() {
|
||||||
setActionLoading(false);
|
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 (
|
return (
|
||||||
<AdminShell>
|
<AdminShell>
|
||||||
<Link href="/users" className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-4">
|
<Link href="/users" className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-4">
|
||||||
|
|
@ -246,6 +258,14 @@ export default function UserDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Feed Impressions */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500 mb-2">Feed History</p>
|
||||||
|
<button onClick={handleResetFeedImpressions} className="btn-secondary text-xs py-1.5 flex items-center gap-1" disabled={actionLoading}>
|
||||||
|
<RefreshCcw className="w-3.5 h-3.5" /> Reset Feed Impressions
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* View Posts */}
|
{/* View Posts */}
|
||||||
<div className="pt-2 border-t border-warm-300">
|
<div className="pt-2 border-t border-warm-300">
|
||||||
<Link href={`/posts?author_id=${user.id}`} className="text-brand-500 hover:text-brand-700 text-sm font-medium">
|
<Link href={`/posts?author_id=${user.id}`} className="text-brand-500 hover:text-brand-700 text-sm font-medium">
|
||||||
|
|
@ -256,15 +276,11 @@ export default function UserDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Official Account: Editable Profile */}
|
{/* Editable Profile */}
|
||||||
{user.is_official && (
|
<OfficialProfileEditor user={user} onSaved={fetchUser} />
|
||||||
<OfficialProfileEditor user={user} onSaved={fetchUser} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Official Account: Follower/Following Management */}
|
{/* Follower/Following Management */}
|
||||||
{user.is_official && (
|
<FollowManager userId={user.id} />
|
||||||
<FollowManager userId={user.id} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="card p-8 text-center text-gray-500">User not found</div>
|
<div className="card p-8 text-center text-gray-500">User not found</div>
|
||||||
|
|
@ -391,7 +407,7 @@ function OfficialProfileEditor({ user, onSaved }: { user: any; onSaved: () => vo
|
||||||
<div className="card p-5">
|
<div className="card p-5">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-sm font-semibold text-gray-700 flex items-center gap-2">
|
<h3 className="text-sm font-semibold text-gray-700 flex items-center gap-2">
|
||||||
<Pencil className="w-4 h-4" /> Official Account Profile
|
<Pencil className="w-4 h-4" /> Edit Profile
|
||||||
</h3>
|
</h3>
|
||||||
{!editing ? (
|
{!editing ? (
|
||||||
<button onClick={() => setEditing(true)} className="btn-secondary text-xs py-1 px-3">Edit</button>
|
<button onClick={() => setEditing(true)} className="btn-secondary text-xs py-1 px-3">Edit</button>
|
||||||
|
|
|
||||||
250
admin/src/app/waitlist/page.tsx
Normal file
250
admin/src/app/waitlist/page.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||||
|
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<any[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [actionLoading, setActionLoading] = useState<string | null>(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<string, number> = {};
|
||||||
|
entries.forEach((e) => { counts[e.status || 'pending'] = (counts[e.status || 'pending'] || 0) + 1; });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminShell>
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||||
|
<Users className="w-6 h-6 text-brand-500" /> Waitlist
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{total} total {statusFilter ? `(filtered: ${statusFilter})` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => fetchList()} className="btn-secondary text-sm flex items-center gap-1">
|
||||||
|
<RefreshCw className="w-4 h-4" /> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter tabs */}
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
{['', 'pending', 'approved', 'rejected', 'invited'].map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => { setStatusFilter(s); setPage(0); }}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
statusFilter === s
|
||||||
|
? 'bg-brand-500 text-white'
|
||||||
|
: 'bg-warm-100 text-gray-600 hover:bg-warm-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s === '' ? 'All' : s.charAt(0).toUpperCase() + s.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 animate-pulse space-y-3">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-12 bg-warm-300 rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-gray-400">
|
||||||
|
No waitlist entries{statusFilter ? ` with status "${statusFilter}"` : ''}.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-warm-100 border-b border-warm-300">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600">Email</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600">Name</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600">Referral</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600">Status</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600">Joined</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600">Notes</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-warm-100">
|
||||||
|
{entries.map((e) => (
|
||||||
|
<tr key={e.id} className="hover:bg-warm-50">
|
||||||
|
<td className="px-4 py-2.5 font-medium text-gray-900">{e.email}</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-600">{e.name || '—'}</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-500 text-xs">
|
||||||
|
{e.referral_code ? <span className="font-mono bg-warm-100 px-1.5 py-0.5 rounded">{e.referral_code}</span> : '—'}
|
||||||
|
{e.invited_by && <span className="ml-1 text-gray-400">via {e.invited_by}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[e.status || 'pending'] || 'bg-gray-100 text-gray-600'}`}>
|
||||||
|
{e.status || 'pending'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-500 text-xs whitespace-nowrap">
|
||||||
|
{e.created_at ? formatDateTime(e.created_at) : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-500 text-xs max-w-[12rem] truncate" title={e.notes}>
|
||||||
|
<button
|
||||||
|
onClick={() => setNotesModal({ id: e.id, notes: e.notes || '' })}
|
||||||
|
className="text-brand-500 hover:underline"
|
||||||
|
>
|
||||||
|
{e.notes ? e.notes.slice(0, 30) + (e.notes.length > 30 ? '…' : '') : '+ add note'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{e.status !== 'approved' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatusChange(e.id, 'approved')}
|
||||||
|
disabled={actionLoading === e.id + 'approved'}
|
||||||
|
title="Approve"
|
||||||
|
className="p-1.5 rounded hover:bg-green-50 text-green-600 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{e.status !== 'rejected' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatusChange(e.id, 'rejected')}
|
||||||
|
disabled={actionLoading === e.id + 'rejected'}
|
||||||
|
title="Reject"
|
||||||
|
className="p-1.5 rounded hover:bg-red-50 text-red-500 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(e.id, e.email)}
|
||||||
|
disabled={actionLoading === e.id + 'del'}
|
||||||
|
title="Delete"
|
||||||
|
className="p-1.5 rounded hover:bg-red-50 text-red-400 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-warm-200">
|
||||||
|
<p className="text-xs text-gray-500">Page {page + 1} of {totalPages}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
className="p-1.5 rounded border border-warm-300 disabled:opacity-40 hover:bg-warm-100"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
className="p-1.5 rounded border border-warm-300 disabled:opacity-40 hover:bg-warm-100"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes Modal */}
|
||||||
|
{notesModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setNotesModal(null)}>
|
||||||
|
<div className="card p-5 w-full max-w-sm mx-4" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-800 mb-3">Admin Notes</h3>
|
||||||
|
<textarea
|
||||||
|
className="input w-full mb-3"
|
||||||
|
rows={4}
|
||||||
|
placeholder="Add notes about this applicant..."
|
||||||
|
value={notesModal.notes}
|
||||||
|
onChange={(e) => setNotesModal({ ...notesModal, notes: e.target.value })}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button onClick={() => setNotesModal(null)} className="btn-secondary text-sm">Cancel</button>
|
||||||
|
<button onClick={handleSaveNotes} disabled={actionLoading === 'notes'} className="btn-primary text-sm">
|
||||||
|
{actionLoading === 'notes' ? 'Saving…' : 'Save Notes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AdminShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
LayoutDashboard, Users, FileText, Shield, ShieldCheck, Scale, Flag,
|
LayoutDashboard, Users, FileText, Shield, ShieldCheck, Scale, Flag,
|
||||||
Settings, Activity, LogOut, ChevronLeft, ChevronRight, ChevronDown,
|
Settings, Activity, LogOut, ChevronLeft, ChevronRight, ChevronDown,
|
||||||
Sliders, FolderTree, HardDrive, AtSign, Brain, ScrollText, Wrench, Bot,
|
Sliders, FolderTree, HardDrive, AtSign, Brain, ScrollText, Wrench, Bot,
|
||||||
UserCog, ShieldAlert, Cog, Mail, MapPinned, Users2, Video,
|
UserCog, ShieldAlert, Cog, Mail, MapPinned, Users2, Video, ClipboardList, Clock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -32,6 +32,7 @@ const navigation: NavEntry[] = [
|
||||||
{ href: '/neighborhoods', label: 'Neighborhoods', icon: MapPinned },
|
{ href: '/neighborhoods', label: 'Neighborhoods', icon: MapPinned },
|
||||||
{ href: '/official-accounts', label: 'Official Accounts', icon: Bot },
|
{ href: '/official-accounts', label: 'Official Accounts', icon: Bot },
|
||||||
{ href: '/groups', label: 'Groups & Capsules', icon: Users2 },
|
{ href: '/groups', label: 'Groups & Capsules', icon: Users2 },
|
||||||
|
{ href: '/waitlist', label: 'Waitlist', icon: Clock },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -56,6 +57,7 @@ const navigation: NavEntry[] = [
|
||||||
{ href: '/usernames', label: 'Usernames', icon: AtSign },
|
{ href: '/usernames', label: 'Usernames', icon: AtSign },
|
||||||
{ href: '/storage', label: 'Storage', icon: HardDrive },
|
{ href: '/storage', label: 'Storage', icon: HardDrive },
|
||||||
{ href: '/system', label: 'System Health', icon: Activity },
|
{ href: '/system', label: 'System Health', icon: Activity },
|
||||||
|
{ href: '/audit-log', label: 'Audit Log', icon: ClipboardList },
|
||||||
{ href: '/quips', label: 'Quip Repair', icon: Video },
|
{ href: '/quips', label: 'Quip Repair', icon: Video },
|
||||||
{ href: '/settings/emails', label: 'Email Templates', icon: Mail },
|
{ href: '/settings/emails', label: 'Email Templates', icon: Mail },
|
||||||
{ href: '/settings', label: 'Settings', icon: Settings },
|
{ href: '/settings', label: 'Settings', icon: Settings },
|
||||||
|
|
|
||||||
|
|
@ -679,6 +679,31 @@ class ApiClient {
|
||||||
async getFeedScores(limit = 50) {
|
async getFeedScores(limit = 50) {
|
||||||
return this.request<any>(`/api/v1/admin/feed-scores?limit=${limit}`);
|
return this.request<any>(`/api/v1/admin/feed-scores?limit=${limit}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Waitlist
|
||||||
|
async listWaitlist(params: { status?: string; limit?: number; offset?: number } = {}) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.status) qs.set('status', params.status);
|
||||||
|
if (params.limit) qs.set('limit', String(params.limit));
|
||||||
|
if (params.offset) qs.set('offset', String(params.offset));
|
||||||
|
return this.request<any>(`/api/v1/admin/waitlist?${qs}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateWaitlist(id: string, data: { status?: string; notes?: string }) {
|
||||||
|
return this.request<any>(`/api/v1/admin/waitlist/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteWaitlist(id: string) {
|
||||||
|
return this.request<any>(`/api/v1/admin/waitlist/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed impression reset
|
||||||
|
async resetFeedImpressions(userId: string) {
|
||||||
|
return this.request<any>(`/api/v1/admin/users/${userId}/feed-impressions`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = new ApiClient();
|
export const api = new ApiClient();
|
||||||
|
|
|
||||||
|
|
@ -740,6 +740,14 @@ func main() {
|
||||||
|
|
||||||
// Feed scores viewer
|
// Feed scores viewer
|
||||||
admin.GET("/feed-scores", adminHandler.AdminGetFeedScores)
|
admin.GET("/feed-scores", adminHandler.AdminGetFeedScores)
|
||||||
|
|
||||||
|
// Waitlist management
|
||||||
|
admin.GET("/waitlist", adminHandler.AdminListWaitlist)
|
||||||
|
admin.PATCH("/waitlist/:id", adminHandler.AdminUpdateWaitlist)
|
||||||
|
admin.DELETE("/waitlist/:id", adminHandler.AdminDeleteWaitlist)
|
||||||
|
|
||||||
|
// Feed impression reset
|
||||||
|
admin.DELETE("/users/:id/feed-impressions", adminHandler.AdminResetFeedImpressions)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public claim request endpoint (no auth)
|
// Public claim request endpoint (no auth)
|
||||||
|
|
|
||||||
|
|
@ -1746,6 +1746,10 @@ func (h *AdminHandler) GetAlgorithmConfig(c *gin.Context) {
|
||||||
{"key": "feed_engagement_weight", "value": "0.3", "description": "Weight for engagement metrics"},
|
{"key": "feed_engagement_weight", "value": "0.3", "description": "Weight for engagement metrics"},
|
||||||
{"key": "feed_harmony_weight", "value": "0.2", "description": "Weight for author harmony score"},
|
{"key": "feed_harmony_weight", "value": "0.2", "description": "Weight for author harmony score"},
|
||||||
{"key": "feed_diversity_weight", "value": "0.1", "description": "Weight for content diversity"},
|
{"key": "feed_diversity_weight", "value": "0.1", "description": "Weight for content diversity"},
|
||||||
|
{"key": "feed_cooling_multiplier", "value": "0.2", "description": "Score multiplier for previously-seen posts (0–1, lower = stronger penalty)"},
|
||||||
|
{"key": "feed_diversity_personal_pct", "value": "60", "description": "% of feed from top personal scores"},
|
||||||
|
{"key": "feed_diversity_category_pct", "value": "20", "description": "% of feed from under-represented categories"},
|
||||||
|
{"key": "feed_diversity_discovery_pct", "value": "20", "description": "% of feed from authors viewer doesn't follow"},
|
||||||
{"key": "moderation_auto_flag_threshold", "value": "0.7", "description": "AI score threshold for auto-flagging"},
|
{"key": "moderation_auto_flag_threshold", "value": "0.7", "description": "AI score threshold for auto-flagging"},
|
||||||
{"key": "moderation_auto_remove_threshold", "value": "0.95", "description": "AI score threshold for auto-removal"},
|
{"key": "moderation_auto_remove_threshold", "value": "0.95", "description": "AI score threshold for auto-removal"},
|
||||||
},
|
},
|
||||||
|
|
@ -4405,6 +4409,131 @@ func (h *AdminHandler) RepairQuip(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Waitlist Management
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
// AdminListWaitlist GET /admin/waitlist?status=&limit=&offset=
|
||||||
|
func (h *AdminHandler) AdminListWaitlist(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||||
|
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||||
|
status := c.DefaultQuery("status", "")
|
||||||
|
if limit <= 0 || limit > 200 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `SELECT id, email, name, referral_code, invited_by, status, notes, created_at, updated_at
|
||||||
|
FROM waitlist`
|
||||||
|
args := []interface{}{}
|
||||||
|
if status != "" {
|
||||||
|
query += " WHERE status = $1"
|
||||||
|
args = append(args, status)
|
||||||
|
}
|
||||||
|
query += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d OFFSET $%d", len(args)+1, len(args)+2)
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
|
||||||
|
rows, err := h.pool.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
// Table may not exist yet
|
||||||
|
c.JSON(http.StatusOK, gin.H{"entries": []gin.H{}, "total": 0})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var entries []gin.H
|
||||||
|
for rows.Next() {
|
||||||
|
var id, email string
|
||||||
|
var name, referralCode, invitedBy, wlStatus, notes *string
|
||||||
|
var createdAt, updatedAt time.Time
|
||||||
|
if err := rows.Scan(&id, &email, &name, &referralCode, &invitedBy, &wlStatus, ¬es, &createdAt, &updatedAt); err == nil {
|
||||||
|
entries = append(entries, gin.H{
|
||||||
|
"id": id, "email": email, "name": name,
|
||||||
|
"referral_code": referralCode, "invited_by": invitedBy,
|
||||||
|
"status": wlStatus, "notes": notes,
|
||||||
|
"created_at": createdAt, "updated_at": updatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entries == nil {
|
||||||
|
entries = []gin.H{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int
|
||||||
|
countQuery := "SELECT COUNT(*) FROM waitlist"
|
||||||
|
if status != "" {
|
||||||
|
_ = h.pool.QueryRow(ctx, countQuery+" WHERE status = $1", status).Scan(&total)
|
||||||
|
} else {
|
||||||
|
_ = h.pool.QueryRow(ctx, countQuery).Scan(&total)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"entries": entries, "total": total, "limit": limit, "offset": offset})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateWaitlist PATCH /admin/waitlist/:id
|
||||||
|
func (h *AdminHandler) AdminUpdateWaitlist(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
id := c.Param("id")
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := h.pool.Exec(ctx,
|
||||||
|
`UPDATE waitlist SET status = COALESCE(NULLIF($1,''), status), notes = COALESCE(NULLIF($2,''), notes), updated_at = NOW() WHERE id = $3`,
|
||||||
|
req.Status, req.Notes, id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update waitlist entry"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
adminID, _ := c.Get("user_id")
|
||||||
|
h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, target_id, details) VALUES ($1, 'waitlist_update', 'waitlist', $2, $3)`,
|
||||||
|
adminID, id, fmt.Sprintf("status=%s", req.Status))
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDeleteWaitlist DELETE /admin/waitlist/:id
|
||||||
|
func (h *AdminHandler) AdminDeleteWaitlist(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
id := c.Param("id")
|
||||||
|
|
||||||
|
_, err := h.pool.Exec(ctx, `DELETE FROM waitlist WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete waitlist entry"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Feed Impression Reset
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
// AdminResetFeedImpressions DELETE /admin/users/:id/feed-impressions
|
||||||
|
func (h *AdminHandler) AdminResetFeedImpressions(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
userID := c.Param("id")
|
||||||
|
|
||||||
|
result, err := h.pool.Exec(ctx, `DELETE FROM user_feed_impressions WHERE user_id = $1`, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reset feed impressions"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
adminID, _ := c.Get("user_id")
|
||||||
|
h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, target_id, details) VALUES ($1, 'reset_feed_impressions', 'user', $2, $3)`,
|
||||||
|
adminID, userID, "Admin reset feed impression history")
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Feed impressions reset", "deleted": result.RowsAffected()})
|
||||||
|
}
|
||||||
|
|
||||||
// Feed scores viewer
|
// Feed scores viewer
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
16
go-backend/migrations/20260218_waitlist.sql
Normal file
16
go-backend/migrations/20260218_waitlist.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
-- Waitlist table for managing early-access signups
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS waitlist (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email text NOT NULL UNIQUE,
|
||||||
|
name text,
|
||||||
|
referral_code text,
|
||||||
|
invited_by text, -- email or user handle of referrer
|
||||||
|
status text NOT NULL DEFAULT 'pending', -- pending, approved, rejected, invited
|
||||||
|
notes text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_waitlist_status ON waitlist(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_waitlist_created ON waitlist(created_at DESC);
|
||||||
Loading…
Reference in a new issue