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