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;