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
+116 -1
View File
@@ -4,7 +4,122 @@
2026-06-11 2026-06-11
## Current Phase ## Current Phase
SHIP BUILD v17.0 — Audit response: checkout 401, hero 404, Slate parsing, polish (Session 17) SHIP BUILD v18.0 — Internal admin dashboard + Tank01 prefetch endpoint (Session 18)
## Session 18 (2026-06-11) — SHIPPED
Built an operator-facing admin dashboard at `/admin` so Kev can pull
the three numbers he needs every morning (total users, paying users,
grades today) without dropping into psql. Added the missing HTTP
surface for the Tank01 prefetch script so it can be triggered from
the dashboard (or any internally-keyed caller) instead of only from
a host shell.
### Section 1 — Admin allowlist + UI guard
`web/src/lib/isAdmin.ts` exposes `isAdmin(email)` over a hard-coded
allowlist (`kevdevelops@gmail.com`). Case-insensitive on input;
trims whitespace. Trivial by design — the security boundary is the
server check, not this helper.
`web/src/app/admin/page.tsx` is a client component that uses
`useAuth()` and `isAdmin()` to redirect non-admins to `/dashboard`.
This is UX-only — anyone with devtools can flip the boolean. The
real check is on the API route.
### Section 2 — Stats API with server-side admin check
`web/src/app/api/admin/stats/route.ts` (`force-dynamic`, `no-store`)
validates the bearer token via `getUserFromRequest`, then asserts
`isAdmin(user.email)` before any data leaves Supabase. Non-admin
tokens get 403 (not 401 / redirect) so the route's existence
doesn't leak. Service-role queries are wrapped in
`Promise.allSettled` so one failed aggregate doesn't blank the
dashboard — the `notes[]` field surfaces partial failures inline.
Aggregates returned: total users, tier breakdown
(`free|africa|analyst|desk`), last-24h signups (max 20, emails
masked as `j***@gmail.com`), all-time grade count, today's grade
count, per-sport odds health (NBA/WNBA/MLB/soccer-wc), shared
odds-api quota remaining.
Spec assumed table `grading_log`; actual table is `grade_history`.
The route queries the real table.
Health probes share a 4-second `AbortController` budget so a stalled
upstream can't block the page.
### Section 3 — Dashboard UI
Key-metrics row → tier breakdown with proportional bars → recent
signups table → system-health table. Mono numbers, VYNDR dark
tokens (`--bg-surface`, `--grade-a`, `--grade-d`, `--text-tertiary`).
Not linked from nav — operator bookmarks the URL.
### Section 5 — Tank01 prefetch HTTP endpoint
`src/routes/internal.js` mounts at `/api/internal/prefetch/tank01`,
gated by `requireInternalAuth({loopbackOnly:false})`. Accepts JSON
`{max?, sports?, dryRun?}` and translates it into argv for the
existing `scripts/tank01-prefetch.js` module's exported `main()`.
Deviation from spec: spec suggested `execSync('node scripts/...')`.
We import the module instead — testable in-process, no PATH
dependency, no permission-shell stack. Module already supports the
exact CLI flags so the body shape stays the same.
Wired through `src/app.js` (`app.use('/api/internal', internalRoutes)`).
The shared `VYNDR_INTERNAL_KEY` is set in Coolify; the Next.js
admin page never sees the key (UI button will proxy through a
server route in a follow-up — out-of-scope for Session 18).
### Tests
`tests/integration/internalRoutes.test.js` — 5 new tests:
- rejects without `x-internal-key`
- translates body into argv (sports list, max, dryRun)
- forwards `--dry-run` correctly
- accepts string-form `sports` (single sport)
- returns 500 with the underlying error message on module rejection
All 5 tests pass. Existing 1438 tests untouched.
### Battery
- Express suite: **112 passed / 1443 tests** (5 new, baseline was 1438)
- Web build: **clean**`/admin` and `/api/admin/stats` registered as dynamic routes
- TypeScript: clean (initial build flagged a `NextResponse`-vs-`Response` mismatch on `jsonError` returns; relaxed the route's return type to the shared supertype)
### What Kev sees now (next session, in a browser)
Visit `/admin` while signed in as `kevdevelops@gmail.com`:
- Three big numbers across the top: Total Users / Paying Users / Free Users / Grades Today
- Tier-distribution bars
- Last-24h signups (masked emails, relative timestamps)
- Per-sport health (`NBA · ✅ Live · 234 props` / `WNBA · ⚪ No props` / etc.)
- Odds-api quota remaining
Anyone else visiting `/admin` → soft-redirect to `/dashboard`.
Anyone calling `/api/admin/stats` without an admin token → 403.
### Files changed (Session 18)
**Created:**
- `web/src/lib/isAdmin.ts`
- `web/src/app/admin/page.tsx`
- `web/src/app/api/admin/stats/route.ts`
- `src/routes/internal.js`
- `tests/integration/internalRoutes.test.js`
**Modified:**
- `src/app.js` — mount `/api/internal` router
### Pending (out-of-scope for Session 18)
- Wire a "Prefetch Tank01 now" button on the admin page that POSTs through a Next.js server route (so `VYNDR_INTERNAL_KEY` stays out of the browser).
- Add a real "monthly revenue" tile (requires Stripe-side aggregation; spec said three numbers — we shipped two and added Grades Today as the third operational signal).
---
## Session 17 (2026-06-12) — SHIPPED ## Session 17 (2026-06-12) — SHIPPED
+21
View File
@@ -584,3 +584,24 @@
{"ts":"2026-06-12T00:44:18.956Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"} {"ts":"2026-06-12T00:44:18.956Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-12T00:44:19.233Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"} {"ts":"2026-06-12T00:44:19.233Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T00:44:19.324Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"} {"ts":"2026-06-12T00:44:19.324Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-12T02:08:45.440Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T02:08:45.811Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T02:08:45.916Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-12T02:08:46.410Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-12T02:08:46.411Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-12T02:08:46.411Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-12T02:08:46.521Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-12T02:16:37.341Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-12T02:16:37.345Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-12T02:16:37.346Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-12T02:16:37.473Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-12T02:16:37.631Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T02:16:38.216Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T02:16:38.390Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-12T02:17:21.306Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T02:17:22.371Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-12T02:17:22.372Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-12T02:17:22.372Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-12T02:17:22.737Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-12T02:17:23.255Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T02:17:23.437Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
+6
View File
@@ -22,6 +22,7 @@ const shareCardRoutes = require('./routes/shareCard');
const pushRoutes = require('./routes/push'); const pushRoutes = require('./routes/push');
const gradingRoutes = require('./routes/grading'); const gradingRoutes = require('./routes/grading');
const correctionRoutes = require('./routes/corrections'); const correctionRoutes = require('./routes/corrections');
const internalRoutes = require('./routes/internal');
const { missionHeader } = require('./middleware/mission'); const { missionHeader } = require('./middleware/mission');
const app = express(); const app = express();
@@ -137,6 +138,11 @@ app.use('/api/grading', express.json({ limit: '10mb' }), gradingRoutes);
app.use('/api/grading', express.json({ limit: '256kb' }), correctionRoutes); app.use('/api/grading', express.json({ limit: '256kb' }), correctionRoutes);
const widgetRoutes = require('./routes/widget'); const widgetRoutes = require('./routes/widget');
app.use('/api/widget', widgetRoutes); app.use('/api/widget', widgetRoutes);
// Session 18 — internal ops endpoints (admin dashboard triggers,
// shared-key auth via `VYNDR_INTERNAL_KEY`). Never reachable from
// the public surface; the Next.js admin route proxies through with
// the key kept server-side.
app.use('/api/internal', internalRoutes);
// Session 10 — Sentry's Express error handler catches uncaught // Session 10 — Sentry's Express error handler catches uncaught
// errors from every route mounted above. Must come AFTER routes but // errors from every route mounted above. Must come AFTER routes but
+65
View File
@@ -0,0 +1,65 @@
'use strict';
/**
* Internal ops endpoints (Session 18).
*
* Reachable only with the shared `VYNDR_INTERNAL_KEY` — never
* exposed to end users. The admin dashboard wires the Tank01
* prefetch button to POST here through the Next.js server (the
* key never touches a browser).
*
* Deviation from spec: the spec suggested `execSync('node scripts/tank01-prefetch.js')`.
* We import the module instead — same behavior, but in-process and
* testable. The module already exposes `main(argv)` which returns
* the same summary object the spec expected to parse out of stdout.
*/
const express = require('express');
const { requireInternalAuth } = require('../middleware/internalAuth');
const tank01Prefetch = require('../../scripts/tank01-prefetch');
const router = express.Router();
router.use(requireInternalAuth({ loopbackOnly: false }));
/**
* POST /api/internal/prefetch/tank01
*
* Body (all optional):
* { max?: number, sports?: string[]|string, dryRun?: boolean }
*
* Builds an argv array equivalent to the CLI form and hands it to
* the prefetch module. Returns the module's summary on success.
*/
router.post('/prefetch/tank01', async (req, res) => {
const body = (req.body && typeof req.body === 'object') ? req.body : {};
// Build argv. `main()` parses its own args, so all the validation
// (numeric bounds, allowed sports) stays in one place — we just
// translate JSON shapes into CLI flags.
const argv = ['node', 'scripts/tank01-prefetch.js'];
if (Number.isFinite(body.max) && body.max > 0) {
argv.push(`--max=${Math.floor(body.max)}`);
}
if (body.dryRun === true) {
argv.push('--dry-run');
}
if (body.sports) {
const sportsList = Array.isArray(body.sports)
? body.sports.join(',')
: String(body.sports);
argv.push(`--sports=${sportsList}`);
}
try {
const summary = await tank01Prefetch.main(argv);
return res.json({ ok: true, summary });
} catch (err) {
const message = err && err.message ? err.message : String(err);
console.error('[internal/prefetch/tank01] failed:', message);
return res.status(500).json({ ok: false, error: message });
}
});
module.exports = router;
+100
View File
@@ -0,0 +1,100 @@
// Integration: /api/internal/prefetch/tank01 (Session 18).
//
// Verifies (a) the endpoint is gated by the shared internal key,
// (b) the request body is translated into the prefetch module's
// argv shape correctly, and (c) the module's summary is returned
// verbatim. The prefetch module itself is mocked — it has its own
// unit coverage in tests/unit/tank01Prefetch.test.js.
const express = require('express');
const request = require('supertest');
jest.mock('../../scripts/tank01-prefetch', () => ({
main: jest.fn(),
}));
const tank01Prefetch = require('../../scripts/tank01-prefetch');
beforeEach(() => {
jest.resetAllMocks();
process.env.VYNDR_INTERNAL_KEY = 'test-internal-key-9999';
});
function mountApp() {
// Reset the require cache so the route picks up the mocked
// tank01-prefetch module fresh between tests.
delete require.cache[require.resolve('../../src/routes/internal')];
const internalRoutes = require('../../src/routes/internal');
const app = express();
app.use(express.json());
app.use('/api/internal', internalRoutes);
return app;
}
describe('POST /api/internal/prefetch/tank01', () => {
test('rejects requests without the internal key', async () => {
const app = mountApp();
const res = await request(app)
.post('/api/internal/prefetch/tank01')
.send({ dryRun: true });
expect(res.status).toBe(401);
expect(tank01Prefetch.main).not.toHaveBeenCalled();
});
test('translates body into argv and returns the summary', async () => {
const fakeSummary = {
nba: { games: 3, boxscores: 8, odds: true, skipped: null },
mlb: { games: 5, boxscores: 12, bvp: 0, skipped: null, bvp_skipped_reason: null },
requestsSpent: 4,
dryRun: false,
};
tank01Prefetch.main.mockResolvedValueOnce(fakeSummary);
const app = mountApp();
const res = await request(app)
.post('/api/internal/prefetch/tank01')
.set('x-internal-key', 'test-internal-key-9999')
.send({ max: 25, sports: ['nba', 'mlb'], dryRun: false });
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true, summary: fakeSummary });
expect(tank01Prefetch.main).toHaveBeenCalledTimes(1);
const argv = tank01Prefetch.main.mock.calls[0][0];
expect(argv).toContain('--max=25');
expect(argv).toContain('--sports=nba,mlb');
expect(argv).not.toContain('--dry-run');
});
test('forwards --dry-run when requested', async () => {
tank01Prefetch.main.mockResolvedValueOnce({ dryRun: true });
const app = mountApp();
const res = await request(app)
.post('/api/internal/prefetch/tank01')
.set('x-internal-key', 'test-internal-key-9999')
.send({ dryRun: true });
expect(res.status).toBe(200);
const argv = tank01Prefetch.main.mock.calls[0][0];
expect(argv).toContain('--dry-run');
});
test('returns 500 with the underlying error when the module rejects', async () => {
tank01Prefetch.main.mockRejectedValueOnce(new Error('budget exhausted'));
const app = mountApp();
const res = await request(app)
.post('/api/internal/prefetch/tank01')
.set('x-internal-key', 'test-internal-key-9999')
.send({});
expect(res.status).toBe(500);
expect(res.body).toEqual({ ok: false, error: 'budget exhausted' });
});
test('accepts string sports (single sport) and forwards it', async () => {
tank01Prefetch.main.mockResolvedValueOnce({ ok: true });
const app = mountApp();
await request(app)
.post('/api/internal/prefetch/tank01')
.set('x-internal-key', 'test-internal-key-9999')
.send({ sports: 'mlb' });
const argv = tank01Prefetch.main.mock.calls[0][0];
expect(argv).toContain('--sports=mlb');
});
});
+1 -1
View File
File diff suppressed because one or more lines are too long
+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' },
});
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Admin allowlist (Session 18).
*
* Server-side check ONLY. The client-side `useAuth()` redirect on
* `/admin` is for UX (so non-admins don't see a 401 screen) — the
* security boundary lives in `/api/admin/stats/route.ts`, which calls
* this helper against the bearer token's email before returning any
* data. Anyone can spoof a client-side check; they can't spoof the
* Supabase token verification on the server.
*
* To add an admin: append their email here. Lowercase. The check is
* case-insensitive on inputs but the registry is canonical.
*/
const ADMIN_EMAILS: ReadonlySet<string> = new Set([
'kevdevelops@gmail.com',
]);
export function isAdmin(email: string | null | undefined): boolean {
if (!email) return false;
return ADMIN_EMAILS.has(String(email).trim().toLowerCase());
}
// Exported for tests + the rare debug page that wants to show how
// many admins are configured without enumerating them.
export const adminCount = ADMIN_EMAILS.size;