feat: Feature 1.1 — Odds API integration complete, 28 tests passing

This commit is contained in:
Kev
2026-03-21 08:31:15 -04:00
parent f70db389e2
commit 00409fd6cd
16 changed files with 6896 additions and 6 deletions
+144
View File
@@ -0,0 +1,144 @@
const express = require('express');
const { getOdds } = require('../services/oddsService');
const { MARKET_MAP, ALLOWED_BOOKS } = require('../utils/oddsNormalizer');
const router = express.Router();
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-BetonBLK-Stale', 'true');
}
return res.json({
sport: 'nba',
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('/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-BetonBLK-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 });
}
});
module.exports = router;