/** * Resolution + correction endpoints. * * POST /api/grading/resolve — called by the ESPN poller at FINAL * POST /api/grading/correct — called by the morning sweep (Section 8) * * Auth model: * - X-VYNDR-Internal-Key header must match VYNDR_INTERNAL_KEY * - Source IP must be loopback (127.0.0.1 / ::1 / ::ffff:127.0.0.1) * Two-factor "in-cluster only": even if the key leaks, an attacker still * needs a foothold inside Docker's network. PM2 pollers and the morning * cron run from the same host, so this is fine in practice. * * Service-role Supabase: we read & write across every user's grade_history * for the game, bypassing RLS intentionally. */ const express = require('express'); const path = require('path'); const { getSupabaseServiceClient } = require('../utils/supabase'); const { getSportConfig } = require('../config/sports'); const { logResolution } = require('../services/training/jsonlLogger'); const webPush = require('../services/distribution/webPush'); const telegram = require('../services/distribution/telegram'); const discord = require('../services/distribution/discord'); const clvTracker = require('../services/intelligence/clvTracker'); const accuracyTracker = require('../services/intelligence/accuracyTracker'); const weightAdjuster = require('../services/intelligence/weightAdjuster'); const router = express.Router(); const LOOPBACK_IPS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']); function requireInternal(req, res, next) { const expected = process.env.VYNDR_INTERNAL_KEY; if (!expected) { // Refuse to serve if the secret isn't configured — better than // accidentally exposing the endpoint with a default value. return res.status(503).json({ error: 'Internal auth not configured' }); } const provided = req.get('X-VYNDR-Internal-Key'); if (!provided || provided !== expected) { return res.status(401).json({ error: 'Invalid internal key' }); } const remoteIp = req.ip || req.socket?.remoteAddress; if (!LOOPBACK_IPS.has(remoteIp)) { return res.status(403).json({ error: 'Origin not permitted' }); } return next(); } // --------------------------------------------------------------- // Box-score traversal — sport-specific shapes flattened into a uniform // { playerEspnId, statsBag } pair so downstream calculateStat doesn't // need to know which sport produced the box. // --------------------------------------------------------------- function indexBasketballBox(boxScore) { // Two teams, each with statistics[0].athletes[] const out = new Map(); for (const team of boxScore?.boxscore?.players || []) { const stats = team?.statistics?.[0]; if (!stats?.athletes) continue; for (const a of stats.athletes) { const id = a?.athlete?.id || a?.id; if (!id) continue; out.set(String(id), { statsBag: a.stats || [], starter: !!a.starter, }); } } return out; } function indexMlbBox(boxScore) { const out = new Map(); const teams = boxScore?.liveData?.boxscore?.teams || {}; for (const side of ['home', 'away']) { const players = teams[side]?.players || {}; for (const key of Object.keys(players)) { const p = players[key]; // MLB Stats API "person" carries a numeric ID. The player_id_map's // mlbam_id column is matched to this for ESPN-graded props. const id = p?.person?.id; if (!id) continue; // Aggregate batting + pitching stats into a single bag. out.set(String(id), { statsBag: { ...(p.stats?.batting || {}), ...(p.stats?.pitching || {}) }, starter: !!p.gameStatus?.isCurrentBatter, }); } } return out; } function indexFootballBox(boxScore) { // statistics is an array of categories: passing, rushing, receiving, … const out = new Map(); for (const team of boxScore?.boxscore?.players || []) { const cats = team?.statistics || []; if (!Array.isArray(cats)) continue; for (const cat of cats) { for (const a of cat?.athletes || []) { const id = a?.athlete?.id || a?.id; if (!id) continue; const existing = out.get(String(id)) || { statsBag: {}, starter: !!a.starter }; // Each category exposes a different stat label ordering; build a // named map per category from labels[] × stats[]. const labels = cat?.labels || cat?.keys || []; const stats = a?.stats || []; const named = {}; labels.forEach((label, i) => { named[label] = stats[i]; }); existing.statsBag[cat.name || cat.text] = named; out.set(String(id), existing); } } } return out; } function indexNhlBox(boxScore) { // ESPN NHL surfaces statistics as a labels/stats pair per athlete. const out = new Map(); for (const team of boxScore?.boxscore?.players || []) { for (const cat of team?.statistics || []) { const labels = cat?.labels || []; for (const a of cat?.athletes || []) { const id = a?.athlete?.id || a?.id; if (!id) continue; const stats = a?.stats || []; const named = {}; labels.forEach((label, i) => { named[label.toLowerCase()] = Number(stats[i]) || 0; }); const existing = out.get(String(id)) || { statsBag: {}, starter: !!a.starter }; Object.assign(existing.statsBag, named); out.set(String(id), existing); } } } return out; } function indexBoxScore(sport, boxScore) { switch (sport) { case 'nba': case 'wnba': case 'ncaab': return indexBasketballBox(boxScore); case 'nfl': case 'ncaafb': return indexFootballBox(boxScore); case 'mlb': return indexMlbBox(boxScore); case 'nhl': return indexNhlBox(boxScore); default: return new Map(); } } // calculateStat: pick the parse strategy based on which key the statMap // entry carries. Format pecking order matches the four documented styles // in src/config/sports.js. function calculateStat(statsBag, statType, sportCfg) { const map = sportCfg.statMap?.[statType]; if (!map) return null; if (map.calc) return Number(map.calc(statsBag)) || 0; if (map.mlbCalc) return Number(map.mlbCalc(statsBag)) || 0; if (map.mlbField) { const raw = statsBag?.[map.mlbField]; return map.parse ? map.parse(raw) : (Number(raw) || 0); } if (map.category) { const cat = statsBag?.[map.category]; if (!cat) return 0; return Number(cat[map.field]) || 0; } if (map.idx !== undefined) { const raw = statsBag?.[map.idx]; if (raw === undefined || raw === null || raw === '') return 0; return map.parse ? map.parse(raw) : (Number(raw) || 0); } if (map.field) return Number(statsBag?.[map.field]) || 0; return 0; } function emoji(result) { return { hit: '✅', miss: '❌', push: '➡️', void: '⚪' }[result] || '•'; } // --------------------------------------------------------------- // Resolution endpoint // --------------------------------------------------------------- async function handleVoidGame(supabase, gameId, reason) { // Mark every unresolved prop for this game as void with the reason. const { data: rows, error: lookupErr } = await supabase .from('grade_history') .select('id, player_id, player_name, stat_type, line, direction, grade, sport') .eq('game_id', gameId) .is('resolved_at', null); if (lookupErr) throw lookupErr; if (!rows || rows.length === 0) return { resolved: 0, voided: 0, results: [] }; const nowIso = new Date().toISOString(); const ids = rows.map((r) => r.id); await supabase .from('grade_history') .update({ result: 'void', resolved_at: nowIso, correction_note: reason }) .in('id', ids); return { resolved: 0, voided: rows.length, results: rows.map((r) => ({ ...r, result: 'void' })) }; } router.post('/resolve', requireInternal, async (req, res) => { const { gameId, sport, boxScore, void: isVoid, reason } = req.body || {}; if (!gameId || !sport) { return res.status(400).json({ error: 'gameId and sport are required' }); } let sportCfg; try { sportCfg = getSportConfig(sport); } catch (err) { return res.status(400).json({ error: err.message }); } const supabase = getSupabaseServiceClient(); if (isVoid) { try { const summary = await handleVoidGame(supabase, gameId, reason || 'void'); return res.json(summary); } catch (err) { console.error('[VYNDR] Void resolution error:', err.message); return res.status(503).json({ error: 'Void processing failed' }); } } if (!boxScore) return res.status(400).json({ error: 'boxScore required (or void: true)' }); // 1. Pull unresolved props for this game. const { data: unresolved, error: lookupErr } = await supabase .from('grade_history') .select('id, player_id, player_name, stat_type, line, direction, grade, sport, projection, closing_line_id, factors') .eq('game_id', gameId) .is('resolved_at', null); if (lookupErr) { console.error('[VYNDR] grade_history lookup error:', lookupErr.message); return res.status(503).json({ error: 'Resolution lookup failed' }); } if (!unresolved || unresolved.length === 0) { return res.json({ resolved: 0, voided: 0, results: [] }); } // 2. Walk the box score into a per-player stats bag. const boxIndex = indexBoxScore(sport, boxScore); const nowIso = new Date().toISOString(); const updates = []; // { id, patch } per prop const resolutionRows = []; // resolution_results inserts const results = []; // response payload for (const prop of unresolved) { const playerEspnId = String(prop.player_id); const found = boxIndex.get(playerEspnId); if (!found) { const patch = { result: 'void', resolved_at: nowIso, correction_note: 'DNP - Player not in box score', }; updates.push({ id: prop.id, patch }); resolutionRows.push({ grade_id: prop.id, game_id: gameId, sport, player_espn_id: playerEspnId, player_name: prop.player_name, stat_type: prop.stat_type, line: Number(prop.line), direction: prop.direction, actual_value: 0, result: 'void', margin: null, correction_note: 'DNP', }); results.push({ ...prop, result: 'void', actual_value: null }); continue; } const actual = calculateStat(found.statsBag, prop.stat_type, sportCfg); let result, margin; if (actual == null) { result = 'void'; margin = null; } else { const line = Number(prop.line); margin = actual - line; if (prop.direction === 'over') { if (actual > line) result = 'hit'; else if (actual < line) result = 'miss'; else result = 'push'; } else { if (actual < line) result = 'hit'; else if (actual > line) result = 'miss'; else result = 'push'; } } const actualNum = actual == null ? 0 : actual; updates.push({ id: prop.id, patch: { result, actual_value: actualNum, margin, resolved_at: nowIso, was_starter: !!found.starter }, }); resolutionRows.push({ grade_id: prop.id, game_id: gameId, sport, player_espn_id: playerEspnId, player_name: prop.player_name, stat_type: prop.stat_type, line: Number(prop.line), direction: prop.direction, actual_value: actualNum, result, margin, was_starter: !!found.starter, closing_line_id: prop.closing_line_id || null, }); results.push({ ...prop, result, actual_value: actualNum, margin }); } // 3. Atomic-ish batch write — Supabase doesn't expose a true transaction, // but a single .insert / single .upsert is one round-trip. if (resolutionRows.length) { const { error: insertErr } = await supabase.from('resolution_results').insert(resolutionRows); if (insertErr) console.warn('[VYNDR] resolution_results insert error:', insertErr.message); } // grade_history updates can't be batched (different patches per row), but // they're indexed by primary key so latency is bounded. for (const u of updates) { const { error: updErr } = await supabase.from('grade_history').update(u.patch).eq('id', u.id); if (updErr) console.warn('[VYNDR] grade_history update error:', updErr.message); } // 4. Side effects — none can block the response or each other. const sideEffects = []; for (const r of results) { sideEffects.push(Promise.resolve().then(() => { logResolution({ sport, player_espn_id: String(r.player_id), player_name: r.player_name, stat_type: r.stat_type, line: Number(r.line), direction: r.direction, actual_value: r.actual_value, result: r.result, margin: r.margin, grade: r.grade, }); })); if (webPush.configured() && r.result !== 'void') { sideEffects.push(webPush.sendPushToSport(sport, { title: 'VYNDR Grade Resolved', body: `${r.player_name} ${r.direction} ${r.line} ${r.stat_type}: ${(r.result || '').toUpperCase()} ${emoji(r.result)} (actual ${r.actual_value}, margin ${r.margin})`, url: '/ledger', }, { kind: 'resolution' })); } if (telegram.configured?.()) { sideEffects.push(telegram.postToTelegram({ text: `${emoji(r.result)} ${(r.result || '').toUpperCase()} | ${r.player_name} ${r.direction} ${r.line} ${r.stat_type} | Actual: ${r.actual_value} | Grade: ${r.grade}`, })); } if (discord.webhookFor?.('results')) { sideEffects.push(discord.postToDiscord('results', { text: `${(r.result || '').toUpperCase()} ${emoji(r.result)} — ${r.player_name} ${r.direction} ${r.line} ${r.stat_type} → ${r.actual_value} (margin ${r.margin}) — Grade ${r.grade}`, })); } // Learning-loop hooks — all fire-and-forget. None can block the // response or each other. Failures inside any of these are logged // by the service itself and never propagate up. if (r.result === 'hit' || r.result === 'miss' || r.result === 'push' || r.result === 'void') { sideEffects.push(accuracyTracker.recordResolution(sport, r.grade, r.result)); } if (r.result === 'hit' || r.result === 'miss') { sideEffects.push(clvTracker.computeCLV(r.id)); sideEffects.push(weightAdjuster.adjustWeights({ sport, stat_type: r.stat_type, grade: r.grade, result: r.result, factors: Array.isArray(r.factors) ? r.factors : [], grade_id: r.id, })); } } // Fire-and-forget; one failure can't block another. Promise.allSettled(sideEffects).catch(() => { /* swallowed */ }); const resolvedCount = results.filter((r) => r.result === 'hit' || r.result === 'miss' || r.result === 'push').length; const voidedCount = results.length - resolvedCount; return res.json({ resolved: resolvedCount, voided: voidedCount, results }); }); // --------------------------------------------------------------- // POST /pipeline — called by n8n (or curl) to run the grading pipeline // for one sport. Same auth as /resolve. The orchestrator handles all // upstream calls; we just wrap it in HTTP with input validation. // --------------------------------------------------------------- const VALID_SPORTS = new Set(['nba', 'wnba', 'mlb', 'nfl', 'nhl', 'ncaab', 'ncaafb']); router.post('/pipeline', requireInternal, async (req, res) => { const { sport, options } = req.body || {}; if (!sport || !VALID_SPORTS.has(sport)) { return res.status(400).json({ error: 'sport must be one of: nba, wnba, mlb, nfl, nhl, ncaab, ncaafb' }); } // Lazy-load the orchestrator so this route doesn't pay the require cost // until it's actually invoked (and so unit tests of /resolve don't pull // in the whole adapter graph). const { runPipeline } = require('../services/intelligence/gradingOrchestrator'); try { const summary = await runPipeline(sport, options || {}); return res.json(summary); } catch (err) { console.error('[VYNDR] Pipeline error:', err.message); return res.status(503).json({ error: 'Pipeline run failed' }); } }); // Exported so server.js can wire it up with a larger body limit; also lets // tests import the helper without binding to Express. module.exports = router; module.exports.__helpers = { calculateStat, indexBoxScore, requireInternal };