paddock-api / src /oddsApi.js
Shaurya2020's picture
Odds: 3 endpoints with version history
6a44b15 verified
Raw History Blame Contribute Delete
4.48 kB
const { Router } = require('express');
const { db, newId, h, bad, conflict, loadEvent, currentOdds, participants } = require('./core.js');
const r = Router({ mergeParams: true });
/* 1 ── GET /race-events/:id/odds β€” the live board ───────────────────────── */
r.get('/', h((req, res) => {
const e = loadEvent(req.admin, req.params.id);
const field = participants(e.id).filter(p => !p.is_scratched && p.decimal_odds);
const book = field.reduce((s, p) => s + 1 / p.decimal_odds, 0) * 100;
res.json({ data: {
event_id: e.id,
version: e.current_odds_version,
book_percentage: +book.toFixed(2),
margin: +(book - 100).toFixed(2),
prices: participants(e.id).map(p => ({
participant_id: p.id, cloth_no: p.cloth_no, horse_name: p.horse_name,
decimal_odds: p.decimal_odds, previous_odds: p.previous_odds, is_scratched: p.is_scratched
}))
}});
}));
/* 2 ── POST /race-events/:id/odds β€” write a new version ─────────────────── */
r.post('/', h((req, res) => {
const e = loadEvent(req.admin, req.params.id);
if (!['draft', 'open'].includes(e.status))
conflict(`Odds are locked once a race is ${e.status}.`);
const incoming = (req.body || {}).odds;
if (!Array.isArray(incoming) || !incoming.length) bad('odds must be a non-empty array.');
const valid = new Set(db.prepare('SELECT id FROM race_participants WHERE event_id = ?').all(e.id).map(p => p.id));
for (const o of incoming) {
if (!valid.has(o.participant_id)) bad('Unknown participant_id.', { participant_id: o.participant_id });
if (!(o.decimal_odds > 1)) bad('decimal_odds must be greater than 1.00.', { participant_id: o.participant_id, decimal_odds: o.decimal_odds });
}
const prev = Object.fromEntries(currentOdds(e.id).map(o => [o.participant_id, o.decimal_odds]));
const nextNo = e.current_odds_version + 1;
// One transaction: a half-written version would leave the board inconsistent.
db.exec('BEGIN');
try {
const vid = newId('ver');
db.prepare(`INSERT INTO odds_versions (id, event_id, version_no, created_by, note) VALUES (?,?,?,?,?)`)
.run(vid, e.id, nextNo, req.admin.id, (req.body.note || '').trim() || null);
// Carry forward every runner so each version is a complete board, not a delta.
const merged = { ...prev };
for (const o of incoming) merged[o.participant_id] = o.decimal_odds;
const changes = [];
for (const [pid, price] of Object.entries(merged)) {
db.prepare(`INSERT INTO odds_entries (id, version_id, participant_id, decimal_odds, previous_odds)
VALUES (?,?,?,?,?)`).run(newId('oe'), vid, pid, price, prev[pid] ?? null);
if (prev[pid] != null && Math.abs(prev[pid] - price) > 1e-9)
changes.push({ participant_id: pid, from: prev[pid], to: price });
}
db.prepare(`UPDATE race_events SET current_odds_version = ?, updated_at = datetime('now') WHERE id = ?`)
.run(nextNo, e.id);
db.exec('COMMIT');
res.status(201).json({ data: { event_id: e.id, version: nextNo, changes } });
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
}));
/* 3 ── GET /race-events/:id/odds/history β€” the audit trail ──────────────── */
r.get('/history', h((req, res) => {
const e = loadEvent(req.admin, req.params.id);
const versions = db.prepare(`
SELECT ov.id, ov.version_no, ov.note, ov.created_at, a.name AS created_by
FROM odds_versions ov JOIN admins a ON a.id = ov.created_by
WHERE ov.event_id = ? ORDER BY ov.version_no DESC`).all(e.id);
const entries = db.prepare(`
SELECT oe.version_id, oe.decimal_odds, oe.previous_odds, p.horse_name, p.cloth_no
FROM odds_entries oe JOIN race_participants p ON p.id = oe.participant_id
WHERE oe.version_id IN (SELECT id FROM odds_versions WHERE event_id = ?)`).all(e.id);
res.json({ data: versions.map(v => ({
version: v.version_no, note: v.note, created_by: v.created_by, created_at: v.created_at,
is_current: v.version_no === e.current_odds_version,
changes: entries
.filter(x => x.version_id === v.id && x.previous_odds != null
&& Math.abs(x.previous_odds - x.decimal_odds) > 1e-9)
.map(x => ({ cloth_no: x.cloth_no, horse_name: x.horse_name, from: x.previous_odds, to: x.decimal_odds }))
.sort((a, b) => a.cloth_no - b.cloth_no)
}))});
}));
module.exports = r;