Session 16: Live hero prop, sport-specific markets fix, soccer weather, Sentry CSP (1429 tests)
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
// Cache the response for 15 minutes (server-side) so cold visitors
|
||||
// don't trigger a fresh grade on every page load. The cache header
|
||||
// is what most CDNs / Coolify reverse proxies honor; Next.js itself
|
||||
// already opts into dynamic rendering via `dynamic = 'force-dynamic'`.
|
||||
export const revalidate = 900;
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
const HERO_FETCH_TIMEOUT_MS = 6000;
|
||||
|
||||
interface OddsResponseProp {
|
||||
player: string;
|
||||
stat_type: string;
|
||||
line: number;
|
||||
direction?: 'over' | 'under';
|
||||
book?: string;
|
||||
home_team?: string;
|
||||
away_team?: string;
|
||||
}
|
||||
|
||||
interface OddsResponse {
|
||||
sport?: string;
|
||||
source?: string;
|
||||
props?: OddsResponseProp[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface GradeResponse {
|
||||
grade?: string;
|
||||
confidence?: number;
|
||||
edge_pct?: number;
|
||||
projection?: number;
|
||||
reasoning?: { summary?: string; steps?: unknown };
|
||||
kill_conditions_triggered?: Array<{ code: string; reason: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live hero prop endpoint (Session 16).
|
||||
*
|
||||
* Picks one fresh, real prop from the day's odds and grades it. The
|
||||
* landing page hero renders this in place of the static Jokic mockup
|
||||
* — cold visitors see proof of live intelligence on first paint
|
||||
* instead of a hypothetical example.
|
||||
*
|
||||
* Sport cascade: NBA → WNBA → MLB. Whichever sport produces a non-
|
||||
* empty `props` list first wins. When every sport is empty (off-
|
||||
* hours, holiday slate, upstream odds quota burned), responds with
|
||||
* `{ isStatic: true }` and the client falls back to the existing
|
||||
* static card. Never throws — odds outages must not blank the
|
||||
* landing page.
|
||||
*
|
||||
* Two-stage flow:
|
||||
* 1. GET ${BACKEND}/api/odds/{sport} → pick random prop
|
||||
* 2. POST ${BACKEND}/api/analyze/prop → grade it
|
||||
*
|
||||
* Both calls share a 6s timeout (AbortController). The overall
|
||||
* route is wrapped in try/catch and always 200s (with `isStatic:true`
|
||||
* on failure) so the client renders gracefully.
|
||||
*/
|
||||
async function fetchWithTimeout(url: string, init?: RequestInit, ms = HERO_FETCH_TIMEOUT_MS): Promise<Response | null> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), ms);
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function pickPropFromSport(sport: string): Promise<{ prop: OddsResponseProp; sport: string } | null> {
|
||||
const res = await fetchWithTimeout(`${BACKEND_URL}/api/odds/${sport}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res || !res.ok) return null;
|
||||
const body = (await res.json().catch(() => null)) as OddsResponse | null;
|
||||
if (!body || !Array.isArray(body.props) || body.props.length === 0) return null;
|
||||
|
||||
// Bias toward A-list player names — props with longer player names
|
||||
// tend to be top-of-rotation stars (better hero material). Cheap
|
||||
// heuristic, not a hard filter; we still random-pick among the top
|
||||
// half of the sorted list so multiple page loads vary.
|
||||
const sorted = body.props
|
||||
.filter((p) => p.player && p.stat_type && Number.isFinite(p.line))
|
||||
.sort((a, b) => (b.player.length - a.player.length));
|
||||
if (sorted.length === 0) return null;
|
||||
const topHalf = sorted.slice(0, Math.max(3, Math.ceil(sorted.length / 2)));
|
||||
const pick = topHalf[Math.floor(Math.random() * topHalf.length)];
|
||||
return { prop: pick, sport };
|
||||
}
|
||||
|
||||
async function gradeProp(sport: string, prop: OddsResponseProp): Promise<GradeResponse | null> {
|
||||
const res = await fetchWithTimeout(`${BACKEND_URL}/api/analyze/prop`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sport,
|
||||
player: prop.player,
|
||||
stat_type: prop.stat_type,
|
||||
line: prop.line,
|
||||
direction: prop.direction || 'over',
|
||||
book: prop.book || 'draftkings',
|
||||
}),
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res || !res.ok) return null;
|
||||
return (await res.json().catch(() => null)) as GradeResponse | null;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
// The order matters: NBA props lead because mid-summer the cascade
|
||||
// would otherwise constantly land on the same sport. After NBA
|
||||
// off-season concludes, swap to a season-aware ordering (winter:
|
||||
// NBA, summer: MLB + WNBA, fall: NFL — when supported).
|
||||
const sportsToTry = ['nba', 'wnba', 'mlb'];
|
||||
|
||||
try {
|
||||
for (const sport of sportsToTry) {
|
||||
const picked = await pickPropFromSport(sport);
|
||||
if (!picked) continue;
|
||||
const grade = await gradeProp(picked.sport, picked.prop);
|
||||
if (!grade) continue;
|
||||
return NextResponse.json(
|
||||
{
|
||||
isStatic: false,
|
||||
sport: picked.sport,
|
||||
prop: picked.prop,
|
||||
grade,
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'public, s-maxage=900, stale-while-revalidate=60' } },
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Falls through to static fallback below.
|
||||
}
|
||||
|
||||
// Static fallback — keeps the hero alive when every sport is empty.
|
||||
return NextResponse.json(
|
||||
{
|
||||
isStatic: true,
|
||||
sport: 'nba',
|
||||
prop: {
|
||||
player: 'Nikola Jokic',
|
||||
stat_type: 'points',
|
||||
line: 26.5,
|
||||
direction: 'over',
|
||||
book: 'draftkings',
|
||||
home_team: 'DEN',
|
||||
away_team: 'LAL',
|
||||
},
|
||||
grade: {
|
||||
grade: 'A-',
|
||||
confidence: 73,
|
||||
edge_pct: 6.2,
|
||||
projection: 29.4,
|
||||
reasoning: {
|
||||
summary: 'L5 form is 28.6 over 5 games, +2.1 above the line. Lakers are bottom-five vs Cs.',
|
||||
},
|
||||
kill_conditions_triggered: [],
|
||||
},
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=60' } },
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user