File size: 5,315 Bytes
2c6fec4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
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:<id>". 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
};