/** * Morning correction sweep. * * POST /api/grading/correct → re-checks recently resolved props against * the current ESPN box score. * * ESPN occasionally corrects late stat lines (an awarded steal becomes a * turnover the next morning, a rebound gets retroactively credited to a * different player). The sweep groups by game_id so we make ONE API call * per game, not per prop. * * Distribution: result flips go to Telegram. Push is intentionally NOT * fired — getting a "your prop hit … actually missed" notification 12 * hours after the fact is confusing UX. Telegram is for the operator log. */ const express = require('express'); const axios = require('axios'); const { getSupabaseServiceClient } = require('../utils/supabase'); const { getSportConfig } = require('../config/sports'); const { createLimiter, API_BUDGETS } = require('../utils/rateLimiter'); const telegram = require('../services/distribution/telegram'); const { __helpers: gradingHelpers } = require('./grading'); const router = express.Router(); const espnLimiter = createLimiter(API_BUDGETS.espn); // Session 10 — uses src/middleware/internalAuth.js. /correct stays // loopback-restricted because the morning sweep runs co-located with // the API; n8n doesn't call this one. const { requireInternalAuth } = require('../middleware/internalAuth'); const requireInternal = requireInternalAuth({ loopbackOnly: true }); async function fetchBoxScore(sportCfg, gameId) { await espnLimiter.waitForToken(); if (sportCfg.useMlbStatsApi) { const res = await axios.get(`${sportCfg.mlbStatsApiBase}/game/${gameId}/feed/live`, { timeout: 15_000 }); return res.data; } const res = await axios.get(`${sportCfg.espnSummary}?event=${encodeURIComponent(gameId)}`, { timeout: 15_000 }); return res.data; } router.post('/correct', requireInternal, async (req, res) => { const hours = Number(req.body?.hours) || 72; const supabase = getSupabaseServiceClient(); const cutoff = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString(); // Pull every resolved prop in the window. Pre-corrected ones (already // carrying a correction_note) are still re-checked — ESPN can correct a // correction. const { data: rows, error } = await supabase .from('resolution_results') .select('id, grade_id, game_id, sport, player_espn_id, player_name, stat_type, line, direction, actual_value, result, margin') .gte('resolved_at', cutoff); if (error) { console.error('[VYNDR] correction lookup failed:', error.message); return res.status(503).json({ error: 'Correction lookup failed' }); } if (!rows || rows.length === 0) { return res.json({ checked: 0, corrected: 0, details: [] }); } // Group by game so we hit ESPN once per game instead of once per prop. const byGame = new Map(); for (const r of rows) { const key = `${r.sport}:${r.game_id}`; if (!byGame.has(key)) byGame.set(key, []); byGame.get(key).push(r); } let checked = 0; let corrected = 0; const details = []; for (const [, propsForGame] of byGame.entries()) { const sport = propsForGame[0].sport; const gameId = propsForGame[0].game_id; let sportCfg; try { sportCfg = getSportConfig(sport); } catch { continue; } let boxScore; try { boxScore = await fetchBoxScore(sportCfg, gameId); } catch (err) { console.warn(`[VYNDR] correction box-score fetch failed for ${gameId}: ${err.message}`); continue; } const idx = gradingHelpers.indexBoxScore(sport, boxScore); for (const prop of propsForGame) { checked += 1; const found = idx.get(String(prop.player_espn_id)); if (!found) continue; const newActual = gradingHelpers.calculateStat(found.statsBag, prop.stat_type, sportCfg); if (newActual == null) continue; const newMargin = newActual - Number(prop.line); let newResult; if (prop.direction === 'over') { if (newActual > prop.line) newResult = 'hit'; else if (newActual < prop.line) newResult = 'miss'; else newResult = 'push'; } else { if (newActual < prop.line) newResult = 'hit'; else if (newActual > prop.line) newResult = 'miss'; else newResult = 'push'; } // Only act if the value actually changed. Identical replays are no-ops. const valueChanged = Number(prop.actual_value) !== newActual; if (!valueChanged) continue; const flipped = prop.result !== newResult; const patch = { actual_value: newActual, margin: newMargin, }; if (flipped) { patch.result = newResult; patch.correction_note = `Corrected ${prop.result} → ${newResult}`; patch.correction_original_value = prop.actual_value; patch.correction_original_result = prop.result; } await supabase.from('resolution_results').update(patch).eq('id', prop.id); await supabase.from('grade_history').update({ actual_value: newActual, result: flipped ? newResult : prop.result, margin: newMargin, ...(flipped ? { correction_note: patch.correction_note, correction_original_value: patch.correction_original_value, correction_original_result: patch.correction_original_result, } : {}), }).eq('id', prop.grade_id); details.push({ grade_id: prop.grade_id, player_name: prop.player_name, stat_type: prop.stat_type, old: { actual: prop.actual_value, result: prop.result }, new: { actual: newActual, result: newResult }, flipped, }); if (flipped) corrected += 1; if (flipped && telegram.configured?.()) { telegram.postToTelegram({ text: `🔄 CORRECTION | ${prop.player_name} ${prop.direction} ${prop.line} ${prop.stat_type} | ${prop.actual_value}→${newActual} | ${prop.result.toUpperCase()}→${newResult.toUpperCase()}`, }).catch(() => { /* fire-and-forget */ }); } } } return res.json({ checked, corrected, details }); }); module.exports = router;