'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'); 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 { 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', 'public, max-age=30'); return res.json({ sport, updated_at: snap.updated_at, refreshed_at: snap.refreshed_at || snap.updated_at || null, grades: enrich(snap.grades), deltas: snap.deltas || [] }); } // 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', 'public, max-age=30'); return res.json({ sport, updated_at: env && env.updated_at, grades: enrich(grades), deltas: [] }); } catch (err) { console.error('[snapshot]', err.message); return res.status(200).json({ sport, grades: [], deltas: [] }); } }); module.exports = router;