const { Router } = require('express'); const { db, newId, h, bad, conflict, scope, ownerFor, loadEvent, participants } = require('./core.js'); const r = Router(); /** Legal lifecycle moves. Anything not listed here is rejected. */ const TRANSITIONS = { draft: ['open', 'void'], open: ['closed', 'void'], closed: ['resulted', 'void', 'open'], // reopening is allowed while nothing is settled resulted: ['settled', 'void', 'closed'], // result can be corrected before settlement settled: [], // terminal — money has moved void: [] // terminal }; const shape = e => ({ id: e.id, owner_admin_id: e.owner_admin_id, name: e.name, venue: e.venue, race_no: e.race_no, distance_m: e.distance_m, going: e.going, race_class: e.race_class, post_time: e.post_time, status: e.status, odds_version: e.current_odds_version, void_reason: e.void_reason, created_at: e.created_at }); /* 1 ── GET /race-events ─────────────────────────────────────────────────── */ r.get('/', h((req, res) => { const s = scope(req.admin, 'e'); const where = [s.sql], params = [...s.params]; if (req.query.status) { where.push('e.status = ?'); params.push(req.query.status); } if (req.query.venue) { where.push('e.venue LIKE ?'); params.push(`%${req.query.venue}%`); } const rows = db.prepare(` SELECT e.*, (SELECT count(*) FROM race_participants p WHERE p.event_id = e.id AND p.is_scratched = 0) AS runners, (SELECT coalesce(sum(b.stake),0) FROM bets b WHERE b.event_id = e.id AND b.status <> 'cancelled') AS held FROM race_events e WHERE ${where.join(' AND ')} ORDER BY e.post_time`).all(...params); res.json({ data: rows.map(e => ({ ...shape(e), runners: e.runners, total_staked: e.held })) }); })); /* 2 ── GET /race-events/:id ─────────────────────────────────────────────── */ r.get('/:id', h((req, res) => { const e = loadEvent(req.admin, req.params.id); res.json({ data: { ...shape(e), field: participants(e.id) } }); })); /* 3 ── POST /race-events ────────────────────────────────────────────────── */ r.post('/', h((req, res) => { const b = req.body || {}; if (!b.name) bad('name is required.'); if (!b.venue) bad('venue is required.'); if (!b.distance_m) bad('distance_m is required.'); if (!b.post_time) bad('post_time is required.'); const id = newId('evt'); db.prepare(`INSERT INTO race_events (id, owner_admin_id, name, venue, race_no, distance_m, going, race_class, post_time, status) VALUES (?,?,?,?,?,?,?,?,?, 'draft')`) .run(id, ownerFor(req.admin, b.owner_admin_id), b.name, b.venue, b.race_no ?? 1, b.distance_m, b.going ?? null, b.race_class ?? null, b.post_time); // Runners can arrive with the race or be added later. for (const p of b.field || []) { db.prepare(`INSERT INTO race_participants (id, event_id, cloth_no, horse_name, jockey_id, team_id, carried_weight, recent_form) VALUES (?,?,?,?,?,?,?,?)`) .run(`${id}_p${p.cloth_no}`, id, p.cloth_no, p.horse_name, p.jockey_id ?? null, p.team_id ?? null, p.carried_weight ?? null, p.recent_form ?? null); } res.status(201).json({ data: { ...shape(loadEvent(req.admin, id)), field: participants(id) } }); })); /* 4 ── PUT /race-events/:id ─────────────────────────────────────────────── */ r.put('/:id', h((req, res) => { const e = loadEvent(req.admin, req.params.id); if (['settled', 'void'].includes(e.status)) conflict(`A ${e.status} race cannot be edited.`); const allowed = ['name', 'venue', 'race_no', 'distance_m', 'going', 'race_class', 'post_time']; const sets = [], params = []; for (const k of allowed) if (k in (req.body || {})) { sets.push(`${k} = ?`); params.push(req.body[k]); } if (!sets.length) bad('No editable fields supplied.', { allowed }); sets.push(`updated_at = datetime('now')`); db.prepare(`UPDATE race_events SET ${sets.join(', ')} WHERE id = ?`).run(...params, e.id); res.json({ data: shape(loadEvent(req.admin, e.id)) }); })); /* 5 ── PATCH /race-events/:id/status ────────────────────────────────────── */ r.patch('/:id/status', h((req, res) => { const e = loadEvent(req.admin, req.params.id); const next = (req.body || {}).status; if (!next) bad('status is required.'); const legal = TRANSITIONS[e.status] || []; if (!legal.includes(next)) conflict(`Cannot move a race from ${e.status} to ${next}.`); // Opening betting on a race with no priced runners would take money on nothing. if (next === 'open') { const priced = db.prepare(` SELECT count(*) n FROM race_participants p JOIN odds_entries oe ON oe.participant_id = p.id JOIN odds_versions ov ON ov.id = oe.version_id AND ov.version_no = ? WHERE p.event_id = ? AND p.is_scratched = 0`).get(e.current_odds_version, e.id).n; if (priced < 2) conflict('At least two priced runners are needed before betting can open.'); } db.prepare(`UPDATE race_events SET status = ?, updated_at = datetime('now') WHERE id = ?`).run(next, e.id); res.json({ data: shape(loadEvent(req.admin, e.id)) }); })); /* 6 ── DELETE /race-events/:id ──────────────────────────────────────────── */ r.delete('/:id', h((req, res) => { const e = loadEvent(req.admin, req.params.id); const bets = db.prepare(`SELECT count(*) n FROM bets WHERE event_id = ?`).get(e.id).n; if (bets) conflict(`This race has ${bets} bets against it. Void it instead of deleting.`); if (e.status !== 'draft') conflict('Only a draft race can be deleted.'); db.prepare('DELETE FROM race_events WHERE id = ?').run(e.id); res.json({ data: { id: e.id, deleted: true } }); })); module.exports = r;