Session 18: Admin dashboard + Tank01 prefetch endpoint (1443 tests)
This commit is contained in:
@@ -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' },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user