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;