'use strict'; /** * GET /api/snapshot/:sport (Session 45) — the latest pre-graded slate. * * Public, cache-only read of `snapshot:{sport}:latest` (enriched grades with * archetype + gradedAt, plus line deltas). Falls back to the `grades:{sport}` * envelope when no snapshot has run yet. NEVER triggers a snapshot (that's the * internal cron's job) — so it can't drain the PropLine quota. */ const express = require('express'); const { createRateLimit } = require('../middleware/rateLimit'); const { cacheGet } = require('../utils/redis'); const { nameKey } = require('../utils/playerName'); // S6 (A1 board) — ●●○●● last-10 vs tonight's locked line, computed from the // rosterlogs blob the snapshot pipeline already writes. Pure, cache-only. const { indexRosterLogs, attachLast10Dots } = require('../services/last10Dots'); // Session 67 — the model price never leaves the server for an unentitled // viewer. This endpoint is PUBLIC, so the Session-66 gate on /api/analyze was // being bypassed here on every graded row. Same layer as the CLV gate. const { stripModelPrice, gateItemizedGrades, liveLockedSummary, freeSample, entitledToItemizedGrades } = require('../utils/snapshotGating'); const { rankByForecast, gradeKey, ranksOnForecast } = require('../utils/gradeRanking'); const { resolveTierFromRequest } = require('../utils/requestTier'); const router = express.Router(); router.use(createRateLimit({ windowMs: 60_000, max: 60 })); // Session 55 — overlay settled outcomes (self-learning loop) onto the grades so // a completed prop can render "✅ HIT (2)" / "❌ MISS". Keyed by player+stat+line+side. function outcomeIndex(log) { const map = {}; for (const o of Array.isArray(log) ? log : []) { const side = String(o.side || 'O').toUpperCase() === 'U' ? 'U' : 'O'; map[`${nameKey(o.player)}|${String(o.stat).toLowerCase()}|${o.line}|${side}`] = o; } return map; } function attachOutcomes(grades, index) { if (!index || Object.keys(index).length === 0) return grades; return grades.map((g) => { const side = String(g.direction || 'over').toLowerCase() === 'under' ? 'U' : 'O'; const o = index[`${nameKey(g.player || g.player_name)}|${String(g.stat_type || g.stat || '').toLowerCase()}|${g.line}|${side}`]; return o ? { ...g, outcome: { result: o.result, actual: o.actual } } : g; }); } // Session 57 (Phase 0) — GET /api/snapshot/summary: the HeartbeatBar's honest // data source. Cheap cache-only Redis reads; total graded props in the current // snapshots + the latest pipeline run time. Registered BEFORE /:sport or // Express captures "summary" as a sport. const SUMMARY_SPORTS = ['nba', 'wnba', 'mlb', 'soccer']; router.get('/summary', async (req, res) => { try { const reads = await Promise.all(SUMMARY_SPORTS.map(async (sp) => { const snap = await cacheGet(`snapshot:${sp}:latest`); if (snap && Array.isArray(snap.grades)) { return { sport: sp, graded: snap.grades.length, updated_at: snap.updated_at || null, refreshed_at: snap.refreshed_at || snap.updated_at || null, }; } const env = await cacheGet(`grades:${sp}`); return { sport: sp, graded: env && Array.isArray(env.grades) ? env.grades.length : 0, updated_at: (env && env.updated_at) || null, refreshed_at: (env && (env.refreshed_at || env.updated_at)) || null, }; })); const graded = reads.reduce((n, r) => n + r.graded, 0); // ISO timestamps sort lexicographically — the max is the latest run. // `updated_at` = last grade LOCK (5×/day, intentionally stable). `refreshed_at` // = last freshness heartbeat (grade lock OR intraday refresh, every ~20 min). // The SYNC badge measures against refreshed_at; updated_at is exposed so the // UI can distinguish "grades locked at X" from "lines synced at Y". const updated_at = reads.map((r) => r.updated_at).filter(Boolean).sort().pop() || null; const refreshed_at = reads.map((r) => r.refreshed_at).filter(Boolean).sort().pop() || updated_at; const sports = {}; for (const r of reads) sports[r.sport] = r.graded; // Session 58 (Task 5) — the SYNC badge thresholds key off the pipeline's // EXPECTED cadence, not a flat 5 minutes: normal < 1.5x, amber ≥ 1.5x, // STALE red ≥ 3x. Default = the 5h max cron gap; when Phase 2.5's // intraday refresh ships, drop the env value and the badge goes live // with zero UI changes. const expected_interval_s = Number(process.env.SNAPSHOT_EXPECTED_INTERVAL) > 0 ? Number(process.env.SNAPSHOT_EXPECTED_INTERVAL) : 18000; res.set('Cache-Control', 'public, max-age=30'); return res.json({ graded, updated_at, refreshed_at, sports, expected_interval_s }); } catch (err) { console.error('[snapshot/summary]', err.message); return res.status(200).json({ graded: 0, updated_at: null, sports: {} }); } }); router.get('/:sport', async (req, res) => { const sport = String(req.params.sport || '').toLowerCase(); try { // Tier is resolved from the bearer token when one is present; anonymous // and free callers get the market legs only. Because the response now // VARIES by entitlement, the shared `public` cache directive below is // downgraded to `private` for authenticated callers — a CDN must never // hand a paid payload to an anonymous viewer. const tier = await resolveTierFromRequest(req); // BUILD 1 CORRECTED (2026-07-31) — ITEMIZED GRADES ARE PAID, LIVE AND SETTLED. // The earlier resolution-flip freed settled grades, which made the free tier a // ONE-DAY-DELAYED FEED of the whole product. Order still matters: strip the model // PRICE first (S67), then withhold judgment on EVERY itemized grade. // FORECAST RANK (Order: rank on p_win, 2026-08-01) — stamped BEFORE the // model-price strip, so every tier receives the CORRECT ORDER without the // paid values. Same precedent as topGradedService: `p_win` is stripped for // unentitled callers, so a client cannot rank on it; an ordinal can travel // where the magnitude cannot. // // ADDITIVE ONLY IN THIS ORDER. Nothing sorts by it yet — the live ordering // is byte-identical until the flip is reviewed against the recorded delta. // It leaks ordering, not magnitude, which is the same trade already made // and accepted for the top-graded board. const stampForecastRank = (grades) => { if (!Array.isArray(grades) || grades.length === 0) return grades; // ARMED ROLLBACK. The boards sort by `forecast_rank` WHEN PRESENT, so // setting FORECAST_RANK=0 reverts every surface to the incumbent order on // the next response — no deploy, no code change, no client release. if (String(process.env.FORECAST_RANK || '1') === '0') return grades; // PER-SPORT DOCTRINE, enforced as a gate: a sport ranks on p_win only // once its OWN model is built and shown to predict. MLB is the only one // that has passed; the rest are held out as NOT-BUILT, not as failed. // (WNBA's -0.12 came from NBA-template machinery on WNBA data — an // unbuilt model's expected failure, not a verdict on the sport.) if (!ranksOnForecast(sport)) return grades; const ranked = rankByForecast(grades); const pos = new Map(); ranked.forEach((g, i) => pos.set(gradeKey(g), i + 1)); return grades.map((g) => { const r = pos.get(gradeKey(g)); return r == null ? g : { ...g, forecast_rank: r }; }); }; const gate = (grades) => gateItemizedGrades(stripModelPrice(stampForecastRank(grades), tier), tier); // Free proof, none of it itemizing the nightly slate: // - the tease: AGGREGATE count + tier shape, computed from the ungated rows and // never joined back to one, so nobody can tell WHICH prop is the A // - the sample: a CAPPED, day-rotated handful of resolved calls for texture const unentitled = !entitledToItemizedGrades(tier); const dayKey = new Date().toISOString().slice(0, 10); const teaseFor = (grades) => (unentitled ? liveLockedSummary(grades) : null); const sampleFor = (grades) => (unentitled ? freeSample(grades, dayKey) : null); const cacheHeader = req.headers.authorization ? 'private, max-age=30' : 'public, max-age=30'; const [snap, outcomeLog, rosterBlob] = await Promise.all([ cacheGet(`snapshot:${sport}:latest`), cacheGet(`outcomes:${sport}:log`), cacheGet(`rosterlogs:${sport}`), ]); const idx = outcomeIndex(outcomeLog); const roster = indexRosterLogs(rosterBlob); const enrich = (grades) => attachLast10Dots(attachOutcomes(grades, idx), roster, sport); if (snap && Array.isArray(snap.grades)) { res.set('Cache-Control', cacheHeader); const enrichedSnap = enrich(snap.grades); return res.json({ sport, updated_at: snap.updated_at, refreshed_at: snap.refreshed_at || snap.updated_at || null, grades: gate(enrichedSnap), deltas: snap.deltas || [], live_locked: teaseFor(enrichedSnap), free_sample: sampleFor(enrichedSnap) }); } // Fallback: the grades envelope (no deltas yet). const env = await cacheGet(`grades:${sport}`); const grades = env && Array.isArray(env.grades) ? env.grades : []; res.set('Cache-Control', cacheHeader); const enrichedEnv = enrich(grades); return res.json({ sport, updated_at: env && env.updated_at, grades: gate(enrichedEnv), deltas: [], live_locked: teaseFor(enrichedEnv), free_sample: sampleFor(enrichedEnv) }); } catch (err) { console.error('[snapshot]', err.message); return res.status(200).json({ sport, grades: [], deltas: [] }); } }); module.exports = router;