Sessions 5-7a: 955 tests, deployment ready

This commit is contained in:
Kev
2026-06-08 18:35:13 -04:00
parent 06b82624a2
commit 1fa04dc776
371 changed files with 49366 additions and 955 deletions
+2 -2
View File
@@ -15,7 +15,7 @@ router.get('/', requireAuth, async (req, res) => {
const result = await getAlertsForUser(req.user.id);
return res.json(result);
} catch (err) {
console.error('[BetonBLK] Alerts error:', err.message);
console.error('[VYNDR] Alerts error:', err.message);
return res.status(503).json({ error: 'Alerts temporarily unavailable' });
}
});
@@ -32,7 +32,7 @@ router.patch('/:id/read', requireAuth, async (req, res) => {
}
return res.json(result);
} catch (err) {
console.error('[BetonBLK] Alert update error:', err.message);
console.error('[VYNDR] Alert update error:', err.message);
return res.status(503).json({ error: 'Alert update failed' });
}
});
+1 -1
View File
@@ -41,7 +41,7 @@ router.post('/prop', async (req, res) => {
if (err.statusCode === 429 || err.statusCode === 503) {
return res.status(err.statusCode).json({ error: err.message });
}
console.error('[BetonBLK] Analysis error:', err.message);
console.error('[VYNDR] Analysis error:', err.message);
return res.status(503).json({ error: 'Analysis service temporarily unavailable' });
}
});
+6 -6
View File
@@ -35,7 +35,7 @@ router.post('/quickslip', requireAuth, async (req, res) => {
return res.status(201).json(result);
} catch (err) {
if (err.statusCode === 404) return res.status(404).json({ error: err.message });
console.error('[BetonBLK] Quickslip error:', err.message);
console.error('[VYNDR] Quickslip error:', err.message);
return res.status(503).json({ error: 'Bet submission failed' });
}
});
@@ -68,7 +68,7 @@ router.post('/screenshot/confirm', requireAuth, async (req, res) => {
return res.status(201).json(result);
} catch (err) {
if (err.statusCode === 404) return res.status(404).json({ error: err.message });
console.error('[BetonBLK] Screenshot confirm error:', err.message);
console.error('[VYNDR] Screenshot confirm error:', err.message);
return res.status(503).json({ error: 'Bet submission failed' });
}
});
@@ -78,7 +78,7 @@ router.post('/sync', requireAuth, async (req, res) => {
return res.json({
status: 'coming_soon',
message: 'Sportsbook sync is coming soon. Use quick slip or screenshot for now.',
supported_books: ['draftkings', 'fanduel', 'betmgm'],
supported_books: ['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers'],
});
});
@@ -96,7 +96,7 @@ router.patch('/:id/settle', requireAuth, async (req, res) => {
} catch (err) {
if (err.statusCode === 404) return res.status(404).json({ error: err.message });
if (err.statusCode === 422) return res.status(422).json({ error: err.message });
console.error('[BetonBLK] Settle error:', err.message);
console.error('[VYNDR] Settle error:', err.message);
return res.status(503).json({ error: 'Settlement failed' });
}
});
@@ -112,7 +112,7 @@ router.get('/', requireAuth, async (req, res) => {
});
return res.json(result);
} catch (err) {
console.error('[BetonBLK] List bets error:', err.message);
console.error('[VYNDR] List bets error:', err.message);
return res.status(503).json({ error: 'Failed to fetch bets' });
}
});
@@ -123,7 +123,7 @@ router.get('/performance', requireAuth, async (req, res) => {
const result = await recalculatePerformance(req.user.id);
return res.json(result);
} catch (err) {
console.error('[BetonBLK] Performance error:', err.message);
console.error('[VYNDR] Performance error:', err.message);
return res.status(503).json({ error: 'Failed to calculate performance' });
}
});
+168
View File
@@ -0,0 +1,168 @@
/**
* 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);
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) return res.status(503).json({ error: 'Internal auth not configured' });
if (req.get('X-VYNDR-Internal-Key') !== expected) {
return res.status(401).json({ error: 'Invalid internal key' });
}
if (!LOOPBACK_IPS.has(req.ip || req.socket?.remoteAddress)) {
return res.status(403).json({ error: 'Origin not permitted' });
}
return next();
}
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;
+436
View File
@@ -0,0 +1,436 @@
/**
* 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 };
+1 -1
View File
@@ -19,7 +19,7 @@ router.get('/', async (req, res) => {
movements,
});
} catch (err) {
console.error('[BetonBLK] Movements error:', err.message);
console.error('[VYNDR] Movements error:', err.message);
return res.status(503).json({ error: 'Movement data temporarily unavailable' });
}
});
+2 -2
View File
@@ -86,7 +86,7 @@ router.get('/nba', async (req, res) => {
const props = groupProps(filtered);
if (result.stale) {
res.set('X-BetonBLK-Stale', 'true');
res.set('X-VYNDR-Stale', 'true');
}
const response = {
@@ -131,7 +131,7 @@ router.get('/ncaab', async (req, res) => {
const props = groupProps(filtered);
if (result.stale) {
res.set('X-BetonBLK-Stale', 'true');
res.set('X-VYNDR-Stale', 'true');
}
return res.json({
+59
View File
@@ -0,0 +1,59 @@
/**
* Pipeline routes — orchestrate the data pipeline.
*
* POST /api/pipeline/refresh body: { sport, graded? }
* GET /api/pipeline/status
*
* Refresh is the only write path; it's the one n8n calls. We gate it with
* a shared secret so a stray POST from the open internet can't trigger an
* upstream fan-out.
*/
const express = require('express');
const provider = require('../services/UnifiedOddsProvider');
const { isActiveSport, shouldCollect, SPORTS } = require('../config/sports');
const router = express.Router();
const SUPPORTED = Object.keys(SPORTS);
function requirePipelineSecret(req, res, next) {
const expected = process.env.PIPELINE_SECRET;
if (!expected) return res.status(503).json({ error: 'PIPELINE_SECRET not configured' });
const got = req.get('X-Pipeline-Secret') || req.body?.secret;
if (!got || got !== expected) {
return res.status(401).json({ error: 'invalid pipeline secret' });
}
return next();
}
router.post('/refresh', requirePipelineSecret, async (req, res) => {
const sport = String(req.body?.sport || '').toLowerCase();
if (!sport || !SUPPORTED.includes(sport)) {
return res.status(400).json({ error: 'invalid or missing sport', supported: SUPPORTED });
}
try {
const out = await provider.fullRefresh(sport, {
gradedProps: Array.isArray(req.body?.graded) ? req.body.graded : [],
});
return res.json(out);
} catch (err) {
return res.status(502).json({ error: 'refresh failed', detail: err?.message || 'unknown' });
}
});
router.get('/status', async (_req, res) => {
const sports = Object.values(SPORTS).map((s) => ({
key: s.key,
label: s.label,
active: s.active,
collect: s.collectData,
}));
return res.json({
sports,
runtime: provider.status(),
ts: new Date().toISOString(),
});
});
module.exports = router;
+81
View File
@@ -0,0 +1,81 @@
const express = require('express');
const { requireAuth } = require('../middleware/auth');
const { getSupabaseServiceClient } = require('../utils/supabase');
const router = express.Router();
// GET /joint-history — joint outcome history and phi coefficient
router.get('/joint-history', requireAuth, async (req, res) => {
const { player_a, stat_a, player_b, stat_b } = req.query;
// Block free tier
if (!req.user.tier || req.user.tier === 'free') {
return res.status(403).json({
error: 'Joint history requires Analyst or Desk tier',
upgrade_url: '/pricing',
});
}
if (!player_a || !stat_a || !player_b || !stat_b) {
return res.status(400).json({
error: 'Required query params: player_a, stat_a, player_b, stat_b',
});
}
try {
const supabase = getSupabaseServiceClient();
const { data, error } = await supabase
.from('joint_outcomes')
.select('*')
.eq('player_a', player_a)
.eq('stat_a', stat_a)
.eq('player_b', player_b)
.eq('stat_b', stat_b);
if (error) throw error;
if (!data || data.length === 0) {
return res.json({
player_a,
stat_a,
player_b,
stat_b,
sample_size: 0,
phi_coefficient: null,
outcomes: [],
});
}
// Calculate phi coefficient from joint outcomes
let both_hit = 0, a_only = 0, b_only = 0, neither = 0;
for (const row of data) {
if (row.a_hit && row.b_hit) both_hit++;
else if (row.a_hit && !row.b_hit) a_only++;
else if (!row.a_hit && row.b_hit) b_only++;
else neither++;
}
const n = data.length;
const num = (both_hit * neither) - (a_only * b_only);
const denom = Math.sqrt(
(both_hit + a_only) * (b_only + neither) *
(both_hit + b_only) * (a_only + neither)
);
const phi = denom === 0 ? 0 : num / denom;
res.json({
player_a,
stat_a,
player_b,
stat_b,
sample_size: n,
phi_coefficient: Math.round(phi * 1000) / 1000,
outcomes: data,
});
} catch (err) {
console.error('[props/joint-history]', err.message);
res.status(503).json({ error: 'Service temporarily unavailable' });
}
});
module.exports = router;
+87
View File
@@ -0,0 +1,87 @@
/**
* Push subscription endpoints.
*
* POST /api/push/subscribe — register a new browser push endpoint
* DELETE /api/push/unsubscribe — remove a subscription by endpoint
*
* Subscriptions are stored in push_subscriptions (migration 015) with RLS
* gated to auth.uid() = user_id. We use the service role here so we don't
* have to thread the user JWT through Supabase — requireAuth has already
* verified the user.
*/
const express = require('express');
const { requireAuth } = require('../middleware/auth');
const { getSupabaseServiceClient } = require('../utils/supabase');
const router = express.Router();
function validSubscription(sub) {
if (!sub || typeof sub !== 'object') return false;
if (typeof sub.endpoint !== 'string' || !sub.endpoint.startsWith('https://')) return false;
if (!sub.keys || typeof sub.keys !== 'object') return false;
if (typeof sub.keys.p256dh !== 'string' || typeof sub.keys.auth !== 'string') return false;
return true;
}
router.post('/subscribe', requireAuth, async (req, res) => {
const { subscription, preferences } = req.body || {};
if (!validSubscription(subscription)) {
return res.status(400).json({ error: 'Invalid subscription payload' });
}
try {
const supabase = getSupabaseServiceClient();
const row = {
user_id: req.user.id,
endpoint: subscription.endpoint,
keys_p256dh: subscription.keys.p256dh,
keys_auth: subscription.keys.auth,
};
if (Array.isArray(preferences?.sports)) row.sport_preferences = preferences.sports;
if (typeof preferences?.notify_on_resolution === 'boolean') {
row.notify_on_resolution = preferences.notify_on_resolution;
}
if (typeof preferences?.notify_on_cascade === 'boolean') {
row.notify_on_cascade = preferences.notify_on_cascade;
}
if (typeof preferences?.notify_on_cheatsheet === 'boolean') {
row.notify_on_cheatsheet = preferences.notify_on_cheatsheet;
}
const { error } = await supabase
.from('push_subscriptions')
.upsert(row, { onConflict: 'user_id,endpoint' });
if (error) {
console.error('[VYNDR] Push subscribe error:', error.message);
return res.status(503).json({ error: 'Subscription save failed' });
}
return res.json({ ok: true });
} catch (err) {
console.error('[VYNDR] Push subscribe error:', err.message);
return res.status(503).json({ error: 'Subscription save failed' });
}
});
router.delete('/unsubscribe', requireAuth, async (req, res) => {
const { endpoint } = req.body || {};
if (typeof endpoint !== 'string' || !endpoint.startsWith('https://')) {
return res.status(400).json({ error: 'Invalid endpoint' });
}
try {
const supabase = getSupabaseServiceClient();
const { error } = await supabase
.from('push_subscriptions')
.delete()
.eq('user_id', req.user.id)
.eq('endpoint', endpoint);
if (error) {
console.error('[VYNDR] Push unsubscribe error:', error.message);
return res.status(503).json({ error: 'Unsubscribe failed' });
}
return res.json({ ok: true });
} catch (err) {
console.error('[VYNDR] Push unsubscribe error:', err.message);
return res.status(503).json({ error: 'Unsubscribe failed' });
}
});
module.exports = router;
+1 -1
View File
@@ -58,7 +58,7 @@ router.post('/parlay', requireAuth, async (req, res) => {
return res.json(result);
} catch (err) {
console.error('[BetonBLK] Scan error:', err.message);
console.error('[VYNDR] Scan error:', err.message);
return res.status(503).json({ error: 'Scan service temporarily unavailable' });
}
});
+218
View File
@@ -0,0 +1,218 @@
/**
* POST /api/share-card — returns a PNG (or SVG fallback) for social sharing.
*
* Inputs are validated against allowlists. Any caller-supplied text is
* XML-escaped in the renderer. Per-IP rate limit (in-memory) prevents
* abuse. Hashed inputs back a tiny disk cache in /tmp/share-cards so
* repeated requests serve from disk.
*
* SECURITY:
* - Sport / grade / type / direction / format ∈ allowlist
* - Player & stat & summary length-clamped
* - No HTML, no SVG injection (renderer escapes everything)
* - Rate limit: 30 cards / minute / IP
* - Sharp is invoked through a memory-capped sharp() chain
*/
const express = require('express');
const path = require('node:path');
const fs = require('node:fs/promises');
const crypto = require('node:crypto');
const renderer = require('../services/shareCards/renderer');
const router = express.Router();
const VALID_FORMATS = new Set(['twitter', 'story', 'square']);
const VALID_SPORTS = new Set(['nba', 'wnba', 'mlb', 'nfl', 'nhl', 'tennis', 'mma', 'boxing', 'golf']);
const VALID_GRADES = new Set([
'A+', 'A', 'A-', 'B+', 'B', 'B-', 'C+', 'C', 'C-', 'D', 'F',
]);
const VALID_DIRECTIONS = new Set(['over', 'under']);
const VALID_RESULTS = new Set(['hit', 'miss', 'push', 'pending']);
const MAX_PLAYER_LEN = 64;
const MAX_STAT_LEN = 32;
const MAX_SUMMARY_LEN = 160;
const MAX_RECAP_ENTRIES = 8;
const MAX_CHEATSHEET_ENTRIES = 8;
const CACHE_DIR = path.join('/tmp', 'vyndr-share-cards');
fs.mkdir(CACHE_DIR, { recursive: true }).catch(() => {});
// ── tiny in-memory rate limiter (per IP, 30/min sliding window) ───────────
const RATE_WINDOW_MS = 60_000;
const RATE_MAX = 30;
const ipBuckets = new Map();
function checkRate(ip) {
const now = Date.now();
const arr = ipBuckets.get(ip) || [];
const fresh = arr.filter((t) => now - t < RATE_WINDOW_MS);
if (fresh.length >= RATE_MAX) return false;
fresh.push(now);
ipBuckets.set(ip, fresh);
return true;
}
// Periodic prune so the map doesn't grow unbounded.
setInterval(() => {
const now = Date.now();
for (const [ip, arr] of ipBuckets.entries()) {
const fresh = arr.filter((t) => now - t < RATE_WINDOW_MS);
if (fresh.length === 0) ipBuckets.delete(ip);
else ipBuckets.set(ip, fresh);
}
}, RATE_WINDOW_MS).unref?.();
// ── input shaping & validation ────────────────────────────────────────────
function pickStr(v, max) {
if (typeof v !== 'string') return null;
const trimmed = v.trim();
if (!trimmed) return null;
return trimmed.slice(0, max);
}
function pickNum(v) {
const n = typeof v === 'number' ? v : Number(v);
return Number.isFinite(n) ? n : null;
}
function validateBase(body) {
const errors = [];
const type = pickStr(body.type, 16);
if (!type || !renderer.VALID_TYPES.has(type)) errors.push('type must be one of: grade, victory, recap, cheatsheet, gotd');
const format = pickStr(body.format, 16) || 'twitter';
if (!VALID_FORMATS.has(format)) errors.push(`format must be one of: ${[...VALID_FORMATS].join(', ')}`);
return { type, format, errors };
}
function shapeSinglePropPayload(b) {
return {
player: pickStr(b.player, MAX_PLAYER_LEN),
sport: (b.sport && VALID_SPORTS.has(String(b.sport).toLowerCase())) ? String(b.sport).toLowerCase() : null,
stat: pickStr(b.stat, MAX_STAT_LEN),
line: pickNum(b.line),
direction: VALID_DIRECTIONS.has(String(b.direction || '').toLowerCase()) ? String(b.direction).toLowerCase() : 'over',
grade: VALID_GRADES.has(String(b.grade || '').toUpperCase()) ? String(b.grade).toUpperCase() : null,
projection: pickNum(b.projection),
summary: pickStr(b.summary, MAX_SUMMARY_LEN),
};
}
function shapeRecapPayload(b) {
const entries = Array.isArray(b.entries) ? b.entries.slice(0, MAX_RECAP_ENTRIES) : [];
return {
date: pickStr(b.date, 32),
accuracy: pickNum(b.accuracy),
entries: entries.map((e) => ({
player: pickStr(e.player, MAX_PLAYER_LEN),
stat: pickStr(e.stat, MAX_STAT_LEN),
direction: VALID_DIRECTIONS.has(String(e.direction || '').toLowerCase()) ? String(e.direction).toLowerCase() : 'over',
line: pickNum(e.line),
grade: VALID_GRADES.has(String(e.grade || '').toUpperCase()) ? String(e.grade).toUpperCase() : null,
result: VALID_RESULTS.has(String(e.result || '').toLowerCase()) ? String(e.result).toLowerCase() : 'pending',
})).filter((e) => e.player && e.grade),
};
}
function shapeCheatsheetPayload(b) {
const grades = Array.isArray(b.grades) ? b.grades.slice(0, MAX_CHEATSHEET_ENTRIES) : [];
return {
date: pickStr(b.date, 32),
gameCount: pickNum(b.gameCount),
grades: grades.map((g) => ({
player: pickStr(g.player, MAX_PLAYER_LEN),
stat: pickStr(g.stat, MAX_STAT_LEN),
direction: VALID_DIRECTIONS.has(String(g.direction || '').toLowerCase()) ? String(g.direction).toLowerCase() : 'over',
line: pickNum(g.line),
grade: VALID_GRADES.has(String(g.grade || '').toUpperCase()) ? String(g.grade).toUpperCase() : null,
})).filter((g) => g.player && g.grade),
};
}
function shapeVictoryPayload(b) {
return {
...shapeSinglePropPayload(b),
result_actual: pickStr(b.result_actual || b.actual || '', 64) || 'HIT',
};
}
function shapePayload(type, body) {
switch (type) {
case 'grade': return shapeSinglePropPayload(body);
case 'gotd': return shapeSinglePropPayload(body);
case 'victory': return shapeVictoryPayload(body);
case 'recap': return shapeRecapPayload(body);
case 'cheatsheet': return shapeCheatsheetPayload(body);
default: return {};
}
}
function hashKey(type, format, payload) {
const json = JSON.stringify({ type, format, payload });
return crypto.createHash('sha256').update(json).digest('hex').slice(0, 24);
}
// ── route ─────────────────────────────────────────────────────────────────
router.post('/', async (req, res) => {
const ip = (req.headers['x-forwarded-for'] || req.ip || 'unknown').toString().split(',')[0].trim();
if (!checkRate(ip)) {
return res.status(429).json({ error: 'rate limit exceeded — 30 cards/min' });
}
const { type, format, errors } = validateBase(req.body || {});
if (errors.length) return res.status(400).json({ error: 'invalid input', detail: errors });
const payload = shapePayload(type, req.body || {});
const key = hashKey(type, format, payload);
const cachePath = path.join(CACHE_DIR, `${key}.png`);
// Cache check
try {
const cached = await fs.readFile(cachePath);
res.set('Content-Type', 'image/png');
res.set('X-Cache', 'HIT');
res.set('Cache-Control', 'public, max-age=900');
return res.send(cached);
} catch { /* miss */ }
let svg;
try {
svg = renderer.buildSvg(type, format, payload);
} catch (err) {
return res.status(400).json({ error: 'render failed', detail: err.message });
}
// Optional SVG-only mode (no rasterization)
if (req.query.svg === '1') {
res.set('Content-Type', 'image/svg+xml');
res.set('X-Cache', 'MISS');
return res.send(svg);
}
let png;
try {
png = await renderer.rasterize(svg);
} catch (err) {
if (err && err.code === 'SHARP_UNAVAILABLE') {
// Degrade: hand back SVG so the channel-side renderer can still embed.
res.set('Content-Type', 'image/svg+xml');
res.set('X-Cache', 'MISS');
res.set('X-Degraded', 'svg-fallback');
return res.send(svg);
}
return res.status(500).json({ error: 'rasterize failed', detail: err.message });
}
// Write cache (best-effort; ignore failures so the response still flies)
fs.writeFile(cachePath, png).catch(() => {});
res.set('Content-Type', 'image/png');
res.set('X-Cache', 'MISS');
res.set('Cache-Control', 'public, max-age=900');
return res.send(png);
});
module.exports = router;
+111
View File
@@ -0,0 +1,111 @@
const express = require('express');
const { getSupabaseServiceClient } = require('../utils/supabase');
const router = express.Router();
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Kill bad satisfieds before they satisfieds you' };
// GET /parlays-graded — total scan count
router.get('/parlays-graded', async (req, res) => {
try {
const supabase = getSupabaseServiceClient();
const { count, error } = await supabase
.from('scan_sessions')
.select('*', { count: 'exact', head: true });
if (error) throw error;
res.set(MISSION_HEADER).json({ count: count || 0 });
} catch (err) {
console.error('[stats/parlays-graded]', err.message);
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
}
});
// GET /public — public dashboard stats
router.get('/public', async (req, res) => {
try {
const supabase = getSupabaseServiceClient();
// Total parlays graded
const { count: parlaysGraded, error: countErr } = await supabase
.from('scan_sessions')
.select('*', { count: 'exact', head: true });
if (countErr) throw countErr;
// Most common grade
const { data: grades, error: gradesErr } = await supabase
.from('scan_sessions')
.select('final_grade');
if (gradesErr) throw gradesErr;
let avg_grade = null;
if (grades && grades.length > 0) {
const freq = {};
for (const row of grades) {
const g = row.final_grade;
if (g) freq[g] = (freq[g] || 0) + 1;
}
let maxCount = 0;
for (const [grade, c] of Object.entries(freq)) {
if (c > maxCount) {
maxCount = c;
avg_grade = grade;
}
}
}
// Kill conditions caught
const { data: picks, error: picksErr } = await supabase
.from('picks')
.select('kill_conditions')
.not('kill_conditions', 'eq', '[]');
if (picksErr) throw picksErr;
const kill_conditions_caught = picks ? picks.filter(p =>
p.kill_conditions && Array.isArray(p.kill_conditions) && p.kill_conditions.length > 0
).length : 0;
res.set(MISSION_HEADER).json({
parlays_graded: parlaysGraded || 0,
avg_grade,
kill_conditions_caught,
sports_covered: ['NBA', 'MLB'],
});
} catch (err) {
console.error('[stats/public]', err.message);
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
}
});
// GET /live — top 3 most recently graded props
router.get('/live', async (req, res) => {
try {
const supabase = getSupabaseServiceClient();
const { data, error } = await supabase
.from('picks')
.select('player, stat_type, line, direction, grade, confidence, created_at')
.order('created_at', { ascending: false })
.limit(3);
if (error) throw error;
const result = (data || []).map(row => ({
player: row.player,
stat: row.stat_type,
line: row.line,
direction: row.direction,
grade: row.grade,
confidence: row.confidence,
sport: 'NBA',
graded_at: row.created_at,
}));
res.set(MISSION_HEADER).json(result);
} catch (err) {
console.error('[stats/live]', err.message);
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
}
});
module.exports = router;
+5 -5
View File
@@ -22,7 +22,7 @@ router.post('/checkout', requireAuth, async (req, res) => {
const result = await createCheckoutSession(req.user.id, req.user.email, tier, founder_code);
return res.json(result);
} catch (err) {
console.error('[BetonBLK] Checkout error:', err.message);
console.error('[VYNDR] Checkout error:', err.message);
return res.status(503).json({ error: 'Checkout creation failed' });
}
});
@@ -40,7 +40,7 @@ router.post('/webhook', async (req, res) => {
try {
event = constructWebhookEvent(req.body, signature);
} catch (err) {
console.error('[BetonBLK] Webhook signature failed:', err.message);
console.error('[VYNDR] Webhook signature failed:', err.message);
return res.status(400).json({ error: 'Invalid signature' });
}
@@ -48,7 +48,7 @@ router.post('/webhook', async (req, res) => {
await handleWebhookEvent(event);
return res.json({ received: true });
} catch (err) {
console.error('[BetonBLK] Webhook handler error:', err.message);
console.error('[VYNDR] Webhook handler error:', err.message);
return res.status(500).json({ error: 'Webhook processing failed' });
}
});
@@ -63,7 +63,7 @@ router.post('/portal', requireAuth, async (req, res) => {
const result = await createPortalSession(req.user.stripe_customer_id);
return res.json(result);
} catch (err) {
console.error('[BetonBLK] Portal error:', err.message);
console.error('[VYNDR] Portal error:', err.message);
return res.status(503).json({ error: 'Portal creation failed' });
}
});
@@ -82,7 +82,7 @@ router.get('/status', requireAuth, async (req, res) => {
...subStatus,
});
} catch (err) {
console.error('[BetonBLK] Status error:', err.message);
console.error('[VYNDR] Status error:', err.message);
return res.status(503).json({ error: 'Status check failed' });
}
});
+32
View File
@@ -0,0 +1,32 @@
const express = require('express');
const { getSupabaseServiceClient } = require('../utils/supabase');
const router = express.Router();
router.post('/', async (req, res) => {
const { email, list, website } = req.body;
// Honeypot check — bots fill hidden "website" field, humans don't
if (website) {
// Silently discard — return 200 so bots think it worked
return res.json({ success: true });
}
if (!email || !email.includes('@') || !list) {
return res.status(400).json({ error: 'Email and list name required' });
}
try {
const supabase = getSupabaseServiceClient();
await supabase.from('waitlist').upsert(
{ email: email.toLowerCase().trim(), list_name: list },
{ onConflict: 'email,list_name' }
);
return res.json({ success: true });
} catch (err) {
console.error('[VYNDR] Waitlist error:', err.message);
return res.json({ success: true }); // Never reveal errors to potential bots
}
});
module.exports = router;
+108
View File
@@ -0,0 +1,108 @@
/**
* GET /api/widget — public embeddable widget data feed.
*
* - CORS: open to all origins (it's a public widget).
* - Cache: 15 minutes server-side + Cache-Control headers.
* - Rate limit: 60 req/min/Origin (60/min/IP if no Origin header).
*
* Response: tonight's top 3 grades, sport-filterable.
*/
const express = require('express');
const axios = require('axios');
const router = express.Router();
const API_BASE = process.env.API_BASE_URL || 'http://localhost:4000';
const CACHE_TTL_MS = 15 * 60_000;
const RATE_WINDOW_MS = 60_000;
const RATE_MAX = 60;
// Per-key sliding-window counter
const buckets = new Map();
function checkRate(key) {
const now = Date.now();
const arr = (buckets.get(key) || []).filter((t) => now - t < RATE_WINDOW_MS);
if (arr.length >= RATE_MAX) return false;
arr.push(now);
buckets.set(key, arr);
return true;
}
setInterval(() => {
const now = Date.now();
for (const [k, arr] of buckets.entries()) {
const fresh = arr.filter((t) => now - t < RATE_WINDOW_MS);
if (fresh.length === 0) buckets.delete(k);
else buckets.set(k, fresh);
}
}, RATE_WINDOW_MS).unref?.();
// Tiny in-memory cache keyed by sport
const cache = new Map();
function cacheGet(key) {
const hit = cache.get(key);
if (!hit) return null;
if (Date.now() - hit.at > CACHE_TTL_MS) { cache.delete(key); return null; }
return hit.value;
}
function cacheSet(key, value) {
cache.set(key, { at: Date.now(), value });
}
const VALID_SPORTS = new Set(['nba', 'wnba', 'mlb']);
router.get('/', async (req, res) => {
// CORS — open to all origins for the widget feed only. We DO NOT echo
// request headers; we send an explicit allow-list of headers we accept.
res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Allow-Methods', 'GET');
res.set('Access-Control-Allow-Headers', 'Content-Type');
res.set('Vary', 'Origin');
const origin = req.get('Origin') || (req.headers['x-forwarded-for'] || req.ip || 'unknown').toString();
if (!checkRate(origin)) return res.status(429).json({ error: 'rate limit exceeded' });
const sport = String(req.query.sport || 'nba').toLowerCase();
if (!VALID_SPORTS.has(sport)) return res.status(400).json({ error: 'invalid sport' });
const cached = cacheGet(sport);
if (cached) {
res.set('X-Cache', 'HIT');
res.set('Cache-Control', 'public, max-age=900');
return res.json(cached);
}
try {
const r = await axios.get(`${API_BASE}/api/props/top-graded?limit=3&sport=${encodeURIComponent(sport)}`, { timeout: 8_000 });
const props = Array.isArray(r.data?.props) ? r.data.props.slice(0, 3) : [];
const payload = {
sport,
generated_at: new Date().toISOString(),
props: props.map((p) => ({
player: p.player_name || p.player,
sport: p.sport,
stat: p.stat_type || p.stat,
direction: p.direction,
line: p.line,
grade: p.grade,
})),
link: 'https://vyndr.app',
};
cacheSet(sport, payload);
res.set('X-Cache', 'MISS');
res.set('Cache-Control', 'public, max-age=900');
return res.json(payload);
} catch (err) {
return res.status(502).json({ error: 'upstream unavailable', detail: err?.message || 'unknown' });
}
});
router.options('/', (_req, res) => {
res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Allow-Methods', 'GET');
res.set('Access-Control-Allow-Headers', 'Content-Type');
res.set('Access-Control-Max-Age', '86400');
res.status(204).end();
});
module.exports = router;