f0c8b4f29b
- 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>
227 lines
6.5 KiB
JavaScript
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;
|