Session 18: Admin dashboard + Tank01 prefetch endpoint (1443 tests)
This commit is contained in:
+116
-1
@@ -4,7 +4,122 @@
|
||||
2026-06-11
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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: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-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"}
|
||||
|
||||
@@ -22,6 +22,7 @@ const shareCardRoutes = require('./routes/shareCard');
|
||||
const pushRoutes = require('./routes/push');
|
||||
const gradingRoutes = require('./routes/grading');
|
||||
const correctionRoutes = require('./routes/corrections');
|
||||
const internalRoutes = require('./routes/internal');
|
||||
const { missionHeader } = require('./middleware/mission');
|
||||
|
||||
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);
|
||||
const widgetRoutes = require('./routes/widget');
|
||||
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
|
||||
// errors from every route mounted above. Must come AFTER routes but
|
||||
|
||||
@@ -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;
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user