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
+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' },
});
}