437 lines
16 KiB
JavaScript
437 lines
16 KiB
JavaScript
/**
|
||
* 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 };
|