const crypto = require('node:crypto'); const { open } = require('../horse-racing-migrate.js'); const db = open(); const newId = p => p + '_' + crypto.randomBytes(6).toString('hex'); /* ── errors ──────────────────────────────────────────────────────────────── */ class ApiError extends Error { constructor(status, message, detail) { super(message); this.status = status; this.detail = detail; } } const bad = (m, d) => { throw new ApiError(400, m, d); }; const notFound = m => { throw new ApiError(404, m); }; const forbid = m => { throw new ApiError(403, m); }; const conflict = m => { throw new ApiError(409, m); }; /** Wraps an async handler so thrown ApiErrors become clean JSON responses. */ const h = fn => (req, res, next) => Promise.resolve(fn(req, res)).catch(next); /* ── auth ────────────────────────────────────────────────────────────────── */ // Demo token format: "admin:". A real deployment verifies a signed JWT here // and pulls id + role from the claims. Everything downstream is unchanged. function authenticate(req, _res, next) { const raw = (req.headers.authorization || '').replace(/^Bearer\s+/i, ''); if (!raw) return next(new ApiError(401, 'Missing bearer token.')); const id = raw.startsWith('admin:') ? raw.slice(6) : raw; const admin = db.prepare('SELECT id, name, role, parent_admin_id, label, status FROM admins WHERE id = ?').get(id); if (!admin) return next(new ApiError(401, 'Token does not match a known admin.')); if (admin.status !== 'active') return next(new ApiError(403, 'This admin is suspended.')); req.admin = admin; next(); } /** * The scope filter. This is the whole multi-tenant guarantee, and it lives in * the WHERE clause on purpose — a role-2 admin who forges another label's id in * the URL gets an empty result set from the database, not a row that * application code then has to remember to reject. * * Returns { sql, params } to splice into a query: * const s = scope(req.admin, 'e'); * db.prepare(`SELECT * FROM race_events e WHERE ${s.sql}`).all(...s.params) */ function scope(admin, alias = '', column = 'owner_admin_id') { const col = alias ? `${alias}.${column}` : column; if (admin.role === 1) return { sql: '1=1', params: [] }; // platform sees all return { sql: `${col} = ?`, params: [admin.id] }; // white label sees its own } /** For writes: the row a role-2 admin creates is always owned by that admin. */ function ownerFor(admin, requested) { if (admin.role === 2) { if (requested && requested !== admin.id) forbid('A white label cannot create rows for another operator.'); return admin.id; } if (!requested) bad('owner_admin_id is required when acting as super admin.'); const target = db.prepare('SELECT id, role FROM admins WHERE id = ?').get(requested); if (!target) bad('owner_admin_id does not match a known admin.', { owner_admin_id: requested }); return requested; } /** Fetch an event the caller is allowed to touch, or 404/403. */ function loadEvent(admin, eventId) { const s = scope(admin, 'e'); const row = db.prepare(`SELECT e.* FROM race_events e WHERE e.id = ? AND ${s.sql}`).get(eventId, ...s.params); if (!row) notFound('Race event not found.'); return row; } /* ── bet type rules (server side is the authority) ───────────────────────── */ const BET_TYPES = { win: { picks: 1, ordered: false, exotic: false, takeout: 0 }, place: { picks: 1, ordered: false, exotic: false, takeout: 0 }, show: { picks: 1, ordered: false, exotic: false, takeout: 0 }, exacta: { picks: 2, ordered: true, exotic: true, takeout: 0.18 }, trifecta: { picks: 3, ordered: true, exotic: true, takeout: 0.22 }, superfecta: { picks: 4, ordered: true, exotic: true, takeout: 0.25 } }; /** Live prices for an event's runners, from the current odds version. */ function currentOdds(eventId) { return db.prepare(` SELECT oe.participant_id, oe.decimal_odds, oe.previous_odds FROM odds_entries oe JOIN odds_versions ov ON ov.id = oe.version_id JOIN race_events e ON e.id = ov.event_id AND e.current_odds_version = ov.version_no WHERE ov.event_id = ?`).all(eventId); } function participants(eventId) { const prices = Object.fromEntries(currentOdds(eventId).map(o => [o.participant_id, o])); return db.prepare(` SELECT p.*, j.name AS jockey_name, t.name AS team_name FROM race_participants p LEFT JOIN jockeys j ON j.id = p.jockey_id LEFT JOIN teams t ON t.id = p.team_id WHERE p.event_id = ? ORDER BY p.cloth_no`).all(eventId) .map(p => ({ ...p, is_scratched: !!p.is_scratched, decimal_odds: prices[p.id]?.decimal_odds ?? null, previous_odds: prices[p.id]?.previous_odds ?? null, stats: JSON.parse(p.stats_json || '{}') })); } module.exports = { db, newId, ApiError, bad, notFound, forbid, conflict, h, authenticate, scope, ownerFor, loadEvent, BET_TYPES, currentOdds, participants };