Session 18: Admin dashboard + Tank01 prefetch endpoint (1443 tests)

This commit is contained in:
Kev
2026-06-11 22:29:38 -04:00
parent beaf8b2a61
commit 0e3839a90a
9 changed files with 813 additions and 2 deletions
+268
View File
@@ -0,0 +1,268 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import { isAdmin } from '@/lib/isAdmin';
/**
* /admin — internal dashboard (Session 18).
*
* Not linked from nav. Operator bookmarks the URL.
*
* Two-layer access control:
* 1. Client-side `isAdmin(user.email)` redirect — UX-only. Prevents
* a confused non-admin from seeing a 403 screen. Anyone with
* devtools can bypass this.
* 2. Server-side check in `/api/admin/stats/route.ts` — the real
* security boundary. Non-admin tokens hit a 403 before any data
* leaves Supabase.
*
* Data shape: see AdminStats interface in the API route.
*/
interface AdminStats {
generated_at: string;
users: {
total: number;
by_tier: Record<string, number>;
recent_24h: Array<{ email_masked: string; tier: string; created_at: string }>;
};
grades: {
total: number;
today: number;
};
health: {
sports: Array<{ sport: string; status: 'ok' | 'error' | 'empty'; quota?: number | null; props?: number; error?: string }>;
odds_quota_remaining: number | null;
};
notes: string[];
}
function timeAgo(iso: string): string {
if (!iso) return '';
const then = Date.parse(iso);
if (!Number.isFinite(then)) return '';
const diffSec = Math.floor((Date.now() - then) / 1000);
if (diffSec < 60) return `${diffSec}s ago`;
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
return `${Math.floor(diffSec / 86400)}d ago`;
}
function pct(part: number, whole: number): string {
if (!whole) return '0%';
return `${Math.round((part / whole) * 1000) / 10}%`;
}
const cellStyle: React.CSSProperties = {
padding: 16,
border: '1px solid var(--border, #1A1A24)',
background: 'var(--bg-surface, #12121A)',
borderRadius: 8,
};
const labelStyle: React.CSSProperties = {
fontSize: 10,
textTransform: 'uppercase',
letterSpacing: '0.12em',
color: 'var(--text-tertiary, #6B6B7B)',
marginBottom: 6,
};
const numberStyle: React.CSSProperties = {
fontSize: 32,
fontWeight: 800,
color: 'var(--text-0, #F0F0F5)',
letterSpacing: '-0.02em',
fontVariantNumeric: 'tabular-nums',
};
export default function AdminPage() {
const router = useRouter();
const { user, session, loading: authLoading } = useAuth();
const [stats, setStats] = useState<AdminStats | null>(null);
const [error, setError] = useState<string | null>(null);
const [fetching, setFetching] = useState(true);
// Client-side guard — UX only. Server enforces the real boundary.
useEffect(() => {
if (authLoading) return;
if (!user) {
router.replace('/login?next=/admin');
return;
}
if (!isAdmin(user.email)) {
router.replace('/dashboard');
}
}, [authLoading, user, router]);
// Stats fetch. Triggered once `session` is available; the server
// 403s if the token isn't an admin's, which we surface inline.
useEffect(() => {
if (!session || !isAdmin(user?.email ?? null)) return;
let alive = true;
(async () => {
try {
const res = await fetch('/api/admin/stats', {
method: 'GET',
headers: { Authorization: `Bearer ${session.access_token}`, Accept: 'application/json' },
cache: 'no-store',
});
const body = (await res.json().catch(() => null)) as AdminStats | { error?: string } | null;
if (!alive) return;
if (!res.ok) {
setError((body as { error?: string })?.error || `HTTP ${res.status}`);
setStats(null);
} else {
setStats(body as AdminStats);
}
} catch (err) {
if (!alive) return;
setError(err instanceof Error ? err.message : 'Fetch failed');
} finally {
if (alive) setFetching(false);
}
})();
return () => { alive = false; };
}, [session, user?.email]);
if (authLoading || !user || !isAdmin(user.email)) {
return (
<main style={{ minHeight: '60vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p className="mono" style={{ color: 'var(--text-tertiary, #6B6B7B)' }}>Resolving</p>
</main>
);
}
const totalUsers = stats?.users.total ?? 0;
const byTier = stats?.users.by_tier ?? { free: 0, africa: 0, analyst: 0, desk: 0 };
const payingUsers = (byTier.africa ?? 0) + (byTier.analyst ?? 0) + (byTier.desk ?? 0);
const freeUsers = byTier.free ?? 0;
return (
<main style={{ padding: '24px 16px 80px', maxWidth: 1100, margin: '0 auto' }}>
<header style={{ marginBottom: 24 }}>
<h1 style={{ fontSize: 26, fontWeight: 700, letterSpacing: '-0.02em' }}>Admin</h1>
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary, #6B6B7B)', marginTop: 4, letterSpacing: '0.06em', textTransform: 'uppercase' }}>
{stats?.generated_at ? `Generated ${timeAgo(stats.generated_at)}` : fetching ? 'Loading…' : 'No data'}
</p>
</header>
{error && (
<div role="alert" style={{ ...cellStyle, borderColor: 'var(--grade-d, #FF6B6B)', color: 'var(--grade-d, #FF6B6B)', marginBottom: 24, fontSize: 13 }}>
{error}
</div>
)}
{/* Section 3.1 — Key metrics */}
<section style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12, marginBottom: 24 }}>
<div style={cellStyle}>
<div style={labelStyle}>Total Users</div>
<div style={numberStyle}>{totalUsers}</div>
</div>
<div style={cellStyle}>
<div style={labelStyle}>Paying Users</div>
<div style={{ ...numberStyle, color: 'var(--grade-a, #00D4A0)' }}>{payingUsers}</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary, #6B6B7B)', marginTop: 4 }}>{pct(payingUsers, totalUsers)} of total</div>
</div>
<div style={cellStyle}>
<div style={labelStyle}>Free Users</div>
<div style={numberStyle}>{freeUsers}</div>
</div>
<div style={cellStyle}>
<div style={labelStyle}>Grades Today</div>
<div style={numberStyle}>{stats?.grades.today ?? 0}</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary, #6B6B7B)', marginTop: 4 }}>{stats?.grades.total ?? 0} all-time</div>
</div>
</section>
{/* Section 3.2 — Tier breakdown */}
<section style={{ ...cellStyle, marginBottom: 24 }}>
<h2 style={{ fontSize: 14, fontWeight: 700, marginBottom: 14, letterSpacing: '-0.01em' }}>Tier breakdown</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', columnGap: 14, rowGap: 8, fontSize: 13 }}>
{(['free', 'africa', 'analyst', 'desk'] as const).map((tier) => {
const count = byTier[tier] ?? 0;
const p = totalUsers > 0 ? (count / totalUsers) * 100 : 0;
return (
<div key={tier} style={{ display: 'contents' }}>
<div className="mono" style={{ textTransform: 'uppercase', color: 'var(--text-secondary, #8A8A9A)' }}>{tier}</div>
<div style={{ background: 'var(--bg-2, #15151F)', height: 8, borderRadius: 4, overflow: 'hidden', alignSelf: 'center' }}>
<div style={{ width: `${Math.max(2, p)}%`, height: '100%', background: tier === 'free' ? 'var(--text-tertiary, #6B6B7B)' : 'var(--grade-a, #00D4A0)' }} />
</div>
<div className="mono" style={{ color: 'var(--text-0, #F0F0F5)', fontVariantNumeric: 'tabular-nums' }}>
{count} <span style={{ color: 'var(--text-tertiary, #6B6B7B)' }}>({pct(count, totalUsers)})</span>
</div>
</div>
);
})}
</div>
</section>
{/* Section 3.3 — Recent signups */}
<section style={{ ...cellStyle, marginBottom: 24 }}>
<h2 style={{ fontSize: 14, fontWeight: 700, marginBottom: 14, letterSpacing: '-0.01em' }}>Recent signups (24h)</h2>
{!stats?.users.recent_24h?.length ? (
<p style={{ fontSize: 13, color: 'var(--text-tertiary, #6B6B7B)' }}>No signups in the last 24 hours.</p>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ textAlign: 'left', color: 'var(--text-tertiary, #6B6B7B)', fontSize: 10, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
<th style={{ padding: '6px 0' }}>Email</th>
<th style={{ padding: '6px 0' }}>Tier</th>
<th style={{ padding: '6px 0', textAlign: 'right' }}>Signed up</th>
</tr>
</thead>
<tbody>
{stats.users.recent_24h.map((row, i) => (
<tr key={i} style={{ borderTop: '1px solid var(--border, #1A1A24)' }}>
<td style={{ padding: '8px 0', fontFamily: 'monospace' }}>{row.email_masked}</td>
<td style={{ padding: '8px 0', color: 'var(--text-secondary, #8A8A9A)' }}>{row.tier}</td>
<td className="mono" style={{ padding: '8px 0', textAlign: 'right', color: 'var(--text-tertiary, #6B6B7B)' }}>{timeAgo(row.created_at)}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
{/* Section 3.4 — System health */}
<section style={cellStyle}>
<h2 style={{ fontSize: 14, fontWeight: 700, marginBottom: 14, letterSpacing: '-0.01em' }}>System health</h2>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<tbody>
<tr style={{ borderBottom: '1px solid var(--border, #1A1A24)' }}>
<td style={{ padding: '8px 0', color: 'var(--text-secondary, #8A8A9A)' }}>Odds API quota remaining</td>
<td className="mono" style={{ padding: '8px 0', textAlign: 'right', color: 'var(--text-0, #F0F0F5)' }}>
{stats?.health.odds_quota_remaining ?? '—'}
</td>
</tr>
{stats?.health.sports.map((s) => {
const indicator = s.status === 'ok' ? '✅ Live'
: s.status === 'empty' ? '⚪ No props'
: `${s.error || 'error'}`;
const color = s.status === 'ok' ? 'var(--grade-a, #00D4A0)'
: s.status === 'empty' ? 'var(--text-tertiary, #6B6B7B)'
: 'var(--grade-d, #FF6B6B)';
return (
<tr key={s.sport} style={{ borderBottom: '1px solid var(--border, #1A1A24)' }}>
<td style={{ padding: '8px 0', textTransform: 'uppercase', fontSize: 11, color: 'var(--text-secondary, #8A8A9A)' }}>{s.sport}</td>
<td className="mono" style={{ padding: '8px 0', textAlign: 'right', color, fontSize: 12 }}>{indicator}{typeof s.props === 'number' ? ` · ${s.props} props` : ''}</td>
</tr>
);
})}
</tbody>
</table>
</section>
{!!stats?.notes?.length && (
<section style={{ ...cellStyle, marginTop: 24, fontSize: 12, color: 'var(--grade-c, #FFD93D)' }}>
<h2 style={{ fontSize: 12, fontWeight: 700, marginBottom: 8, letterSpacing: '-0.01em' }}>Query notes</h2>
<ul style={{ paddingLeft: 18, display: 'grid', gap: 4 }}>
{stats.notes.map((n, i) => <li key={i}>{n}</li>)}
</ul>
</section>
)}
</main>
);
}
+210
View File
@@ -0,0 +1,210 @@
import { NextRequest, NextResponse } from 'next/server';
// `NextResponse.json` for the success path; the `jsonError` helper
// returns a plain `Response`, so the function's return type is the
// shared supertype (`Response`).
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
import { getServiceRoleSupabase } from '@/lib/supabase';
import { isAdmin } from '@/lib/isAdmin';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
const HEALTH_PROBE_TIMEOUT_MS = 4000;
/**
* Admin stats endpoint (Session 18). The security boundary for the
* /admin dashboard.
*
* Flow:
* 1. Verify the bearer token via Supabase (getUserFromRequest)
* 2. Verify the user's email is in the admin allowlist (isAdmin)
* 3. Service-role Supabase queries for aggregates
* 4. Best-effort probes of the Express odds endpoints for health
* 5. Return consolidated JSON
*
* No data is returned — including count totals — to non-admin
* callers. A non-admin who somehow learns this URL gets a 403, not
* "empty stats" or "redirect to dashboard" (those leak the route's
* existence).
*
* Email masking: recent-signup rows are returned with the local
* part collapsed to `<first letter>***`. The full email is never
* exposed via this endpoint — even to admins. Operators who need
* the actual email can query Supabase directly.
*/
interface AdminStats {
generated_at: string;
users: {
total: number;
by_tier: Record<string, number>;
recent_24h: Array<{ email_masked: string; tier: string; created_at: string }>;
};
grades: {
total: number;
today: number;
};
health: {
sports: Array<{ sport: string; status: 'ok' | 'error' | 'empty'; quota?: number | null; props?: number; error?: string }>;
odds_quota_remaining: number | null;
};
notes: string[];
}
function maskEmail(raw: string | null | undefined): string {
if (!raw) return '***@***';
const at = raw.indexOf('@');
if (at < 0) return '***';
const local = raw.slice(0, at);
const domain = raw.slice(at + 1);
const first = local.charAt(0) || '*';
return `${first}***@${domain}`;
}
async function probeSport(sport: string, signal: AbortSignal): Promise<{
sport: string;
status: 'ok' | 'error' | 'empty';
quota?: number | null;
props?: number;
error?: string;
}> {
try {
const res = await fetch(`${BACKEND_URL}/api/odds/${sport}`, {
signal,
headers: { Accept: 'application/json' },
cache: 'no-store',
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
return { sport, status: 'error', error: body.error || `HTTP ${res.status}` };
}
const body = (await res.json().catch(() => ({}))) as {
props?: unknown[];
quota_remaining?: number;
};
const propCount = Array.isArray(body.props) ? body.props.length : 0;
return {
sport,
status: propCount > 0 ? 'ok' : 'empty',
quota: typeof body.quota_remaining === 'number' ? body.quota_remaining : null,
props: propCount,
};
} catch (err) {
return { sport, status: 'error', error: err instanceof Error ? err.message : 'unknown' };
}
}
const TODAY_START = (): string => {
const d = new Date();
d.setUTCHours(0, 0, 0, 0);
return d.toISOString();
};
const HOURS_24_AGO = (): string => new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
export async function GET(req: NextRequest): Promise<Response> {
const user = await getUserFromRequest(req);
if (!user) return jsonError(401, 'Authentication required');
if (!isAdmin(user.email)) {
// Forbidden — non-admins should not learn whether the route exists.
return jsonError(403, 'Forbidden');
}
const sb = getServiceRoleSupabase();
if (!sb) return jsonError(503, 'Service role not configured');
const notes: string[] = [];
// Aggregate Supabase reads. Each is wrapped so a single failed
// query doesn't blank the entire dashboard.
const [
totalUsersResult,
tierRowsResult,
recentSignupsResult,
totalGradesResult,
gradesTodayResult,
] = await Promise.allSettled([
sb.from('users').select('id', { count: 'exact', head: true }),
sb.from('users').select('tier'),
sb.from('users').select('id, email, tier, created_at')
.gte('created_at', HOURS_24_AGO())
.order('created_at', { ascending: false })
.limit(20),
sb.from('grade_history').select('id', { count: 'exact', head: true }),
sb.from('grade_history').select('id', { count: 'exact', head: true })
.gte('created_at', TODAY_START()),
]);
const totalUsers = totalUsersResult.status === 'fulfilled'
? (totalUsersResult.value.count ?? 0)
: (notes.push('users count query failed'), 0);
const byTier: Record<string, number> = { free: 0, africa: 0, analyst: 0, desk: 0 };
if (tierRowsResult.status === 'fulfilled' && Array.isArray(tierRowsResult.value.data)) {
for (const row of tierRowsResult.value.data) {
const t = String(row.tier || 'free').toLowerCase();
byTier[t] = (byTier[t] || 0) + 1;
}
} else {
notes.push('tier breakdown query failed');
}
const recent24h: AdminStats['users']['recent_24h'] =
recentSignupsResult.status === 'fulfilled' && Array.isArray(recentSignupsResult.value.data)
? recentSignupsResult.value.data.map((row: { email?: string; tier?: string; created_at?: string }) => ({
email_masked: maskEmail(row.email),
tier: row.tier || 'free',
created_at: row.created_at || '',
}))
: (notes.push('recent signups query failed'), []);
const totalGrades = totalGradesResult.status === 'fulfilled'
? (totalGradesResult.value.count ?? 0)
: (notes.push('grade_history total count failed'), 0);
const gradesToday = gradesTodayResult.status === 'fulfilled'
? (gradesTodayResult.value.count ?? 0)
: (notes.push('grade_history today count failed'), 0);
// Health probes — fire all four sports in parallel with a shared
// 4s budget so the dashboard doesn't block on a slow upstream.
const controller = new AbortController();
const probeTimer = setTimeout(() => controller.abort(), HEALTH_PROBE_TIMEOUT_MS);
let sports: AdminStats['health']['sports'];
try {
sports = await Promise.all([
probeSport('nba', controller.signal),
probeSport('wnba', controller.signal),
probeSport('mlb', controller.signal),
probeSport('soccer/wc', controller.signal),
]);
} finally {
clearTimeout(probeTimer);
}
// The first sport that reports a quota wins — odds-api returns the
// same quota number for every sport since they share the account.
const oddsQuotaRemaining = sports.map((s) => s.quota).find((q) => typeof q === 'number') ?? null;
const payload: AdminStats = {
generated_at: new Date().toISOString(),
users: {
total: totalUsers,
by_tier: byTier,
recent_24h: recent24h,
},
grades: {
total: totalGrades,
today: gradesToday,
},
health: {
sports,
odds_quota_remaining: oddsQuotaRemaining,
},
notes,
};
return NextResponse.json(payload, {
headers: { 'Cache-Control': 'private, no-store' },
});
}