Files
vyndr/src/routes/odds.js
T
builtbykev f0c8b4f29b Session 32: Grades pipeline + NFL/NHL wiring + rate limiting + audit cleanup (1718 tests)
- gradeSlateService writes grades:{sport} cache (closes content pipeline →
  dataLevel full); fire-and-forget from oddsService.recordDownstream, gated
  by shouldGradeSlate (off in test, GRADE_SLATE_ON_FETCH override)
- NFL/NHL wired: oddsService SPORT_KEYS/SPORT_MARKETS (correct the-odds-api
  keys americanfootball_nfl/icehockey_nhl), proplineAdapter MARKETS, NHL
  MARKET_MAP keys to avoid silent-zero
- rate limiting mounted on 8 public cached routers (odds/parlay 30/min,
  rest 60/min)
- jsonlLogger writes to temp under test (no more dirtied tracked artifact);
  5MB pipeline test given 20s timeout

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:21:32 -04:00

227 lines
6.5 KiB
JavaScript

const express = require('express');
const { getOdds } = require('../services/oddsService');
const { MARKET_MAP, ALLOWED_BOOKS } = require('../utils/oddsNormalizer');
const { createRateLimit } = require('../middleware/rateLimit');
const router = express.Router();
// Session 32 — public throttle. /odds hits PropLine/odds-api upstream on a
// cache miss, so it gets the tighter 30/min bucket. Independent per-IP
// bucket scoped to this router (createRateLimit allocates its own Map).
router.use(createRateLimit({ windowMs: 60_000, max: 30 }));
const VALID_STAT_TYPES = new Set(Object.values(MARKET_MAP));
const VALID_BOOKS = ALLOWED_BOOKS;
// NCAAB is in-season November through April
function isNcaabSeason() {
const month = new Date().getUTCMonth() + 1; // 1-indexed
return month >= 11 || month <= 4;
}
function validateQueryParams(query) {
const errors = [];
if (query.stat_type && !VALID_STAT_TYPES.has(query.stat_type)) {
errors.push(`Invalid stat_type: ${query.stat_type}`);
}
if (query.book && !VALID_BOOKS.has(query.book)) {
errors.push(`Invalid book: ${query.book}`);
}
return errors;
}
function filterProps(props, query) {
let filtered = props;
if (query.stat_type) {
filtered = filtered.filter((p) => p.stat_type === query.stat_type);
}
if (query.player) {
const search = query.player.toLowerCase();
filtered = filtered.filter((p) => p.player.toLowerCase().includes(search));
}
if (query.book) {
filtered = filtered.filter((p) => p.book === query.book);
}
return filtered;
}
// Group flat props into the response format: grouped by player+stat with nested lines
function groupProps(flatProps) {
const grouped = {};
for (const prop of flatProps) {
const key = `${prop.player}::${prop.stat_type}::${prop.game_time}`;
if (!grouped[key]) {
grouped[key] = {
player: prop.player,
home_team: prop.home_team,
away_team: prop.away_team,
game_time: prop.game_time,
stat_type: prop.stat_type,
lines: [],
};
}
grouped[key].lines.push({
book: prop.book,
line: prop.line,
over_odds: prop.over_odds,
under_odds: prop.under_odds,
fetched_at: prop.fetched_at,
});
}
return Object.values(grouped);
}
router.get('/nba', async (req, res) => {
const errors = validateQueryParams(req.query);
if (errors.length > 0) {
return res.status(400).json({ error: errors.join('; ') });
}
try {
const result = await getOdds('nba');
const filtered = filterProps(result.props, req.query);
const props = groupProps(filtered);
if (result.stale) {
res.set('X-VYNDR-Stale', 'true');
}
const response = {
sport: 'nba',
updated_at: result.updated_at,
source: result.source,
quota_remaining: result.quota_remaining,
props,
};
if (result.movements && result.movements.length > 0) {
response.movements = result.movements;
}
return res.json(response);
} catch (err) {
const status = err.statusCode || 500;
return res.status(status).json({ error: err.message });
}
});
// Session 14 — WNBA + MLB. Same pattern as /nba: validate query,
// fetch via cached oddsService, project to the {sport, props}
// envelope the Slate consumes. odds-api may return empty during
// off-season; we still return 200 with an empty `props` array so
// the Slate can render its empty-state UX.
function buildSportRoute(sport) {
return async (req, res) => {
const errors = validateQueryParams(req.query);
if (errors.length > 0) {
return res.status(400).json({ error: errors.join('; ') });
}
try {
const result = await getOdds(sport);
const filtered = filterProps(result.props || [], req.query);
const props = groupProps(filtered);
if (result.stale) res.set('X-VYNDR-Stale', 'true');
return res.json({
sport,
updated_at: result.updated_at,
source: result.source,
quota_remaining: result.quota_remaining,
props,
});
} catch (err) {
const status = err.statusCode || 500;
return res.status(status).json({ error: err.message });
}
};
}
router.get('/wnba', buildSportRoute('wnba'));
router.get('/mlb', buildSportRoute('mlb'));
router.get('/ncaab', async (req, res) => {
if (!isNcaabSeason()) {
return res.json({
sport: 'ncaab',
updated_at: new Date().toISOString(),
source: 'none',
quota_remaining: null,
props: [],
message: 'NCAAB is off-season. Props return in November.',
});
}
const errors = validateQueryParams(req.query);
if (errors.length > 0) {
return res.status(400).json({ error: errors.join('; ') });
}
try {
const result = await getOdds('ncaab');
const filtered = filterProps(result.props, req.query);
const props = groupProps(filtered);
if (result.stale) {
res.set('X-VYNDR-Stale', 'true');
}
return res.json({
sport: 'ncaab',
updated_at: result.updated_at,
source: result.source,
quota_remaining: result.quota_remaining,
props,
});
} catch (err) {
const status = err.statusCode || 500;
return res.status(status).json({ error: err.message });
}
});
// Session 7j — soccer odds route. League is a path segment so each
// league has its own cache key (`odds:soccer_wc:2026-06-15` etc.) and
// queries don't cross-pollute. Falls through to getOdds → odds-api on
// demand; cached 15min like every other sport.
const { SOCCER_SPORT_KEYS } = require('../services/oddsService');
const SOCCER_KEY_SET = new Set(SOCCER_SPORT_KEYS);
router.get('/soccer/:league', async (req, res) => {
const leagueKey = `soccer_${String(req.params.league || '').toLowerCase()}`;
if (!SOCCER_KEY_SET.has(leagueKey)) {
return res.status(400).json({
error: `Unknown soccer league. Valid: ${SOCCER_SPORT_KEYS.map((k) => k.replace('soccer_', '')).join(', ')}.`,
});
}
const errors = validateQueryParams(req.query);
if (errors.length > 0) {
return res.status(400).json({ error: errors.join('; ') });
}
try {
const result = await getOdds(leagueKey);
const filtered = filterProps(result.props || [], req.query);
const props = groupProps(filtered);
if (result.stale) res.set('X-VYNDR-Stale', 'true');
return res.json({
sport: leagueKey,
updated_at: result.updated_at,
source: result.source,
quota_remaining: result.quota_remaining,
props,
});
} catch (err) {
const status = err.statusCode || 500;
return res.status(status).json({ error: err.message });
}
});
module.exports = router;