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:
Patrick Britton 2026-02-17 16:21:42 -06:00
parent 4315da74b2
commit e06b7252c4
8 changed files with 593 additions and 11 deletions

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

View file

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

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

View file

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

View file

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

View file

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

View file

@ -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 (01, 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, &notes, &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
// ────────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────────

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