Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user