Files
courtside/server/routes.js
T
bkvargyasandClaude Fable 5.1 eb58eb9ad9 Courtside Manager score keeper page per court; Phase below Courts on the desk; estimated finish on court cards
A per-court signed link from the desk opens a full-screen scoring page with
big +/- buttons, undo, best-of sets, and a confirmed Finalize step; it follows
whatever match is on the court and can never edit a finished result. Public
court cards show the estimated finish time. Release 0.4.0.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
2026-09-04 02:10:40 +00:00

210 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import QRCode from 'qrcode';
import { Router, HttpError, readBody, send, esc, sign, verify } from './http.js';
import { publicState } from './public-state.js';
import { VERSION } from './version.js';
import * as pub from './views/public.js';
import * as adm from './views/admin.js';
export function buildRouter({ registry, auth, store }) {
const r = new Router();
const origin = req => `${(req.headers['x-forwarded-proto'] ?? 'http').split(',')[0]}://${req.headers['x-forwarded-host'] ?? req.headers.host}`;
const item = slug => { const it = registry.get(slug); if (!it) throw new HttpError(404, 'No such tournament'); return it; };
const q = req => new URL(req.url, 'http://x').searchParams;
// ---------- public ----------
r.get('/', (req, res) => send.html(res, pub.homePage(registry.list())));
r.get('/healthz', (req, res) => send.json(res, { ok: true, version: VERSION, tournaments: registry.items.size, uptime: process.uptime() }));
r.get('/t/:slug', (req, res, { slug }) => {
const it = item(slug);
const state = publicState(it);
if (state.phase === 'checkin') return send.html(res, pub.registerPage(state, { error: q(req).get('error') }));
send.html(res, pub.boardPage(state));
});
r.get('/t/:slug/state.json', (req, res, { slug }) => send.json(res, publicState(item(slug))));
r.get('/t/:slug/display', (req, res, { slug }) => send.html(res, pub.boardPage(publicState(item(slug)), { display: true })));
r.get('/t/:slug/team/:code', (req, res, { slug, code }) => {
const it = item(slug);
const team = it.t.teamByCode(code);
if (!team) throw new HttpError(404, 'No team with that code');
const state = publicState(it);
if (state.phase === 'checkin' && q(req).get('new')) return send.html(res, pub.registeredPage(state, team, origin(req)));
send.html(res, pub.boardPage(state, { teamId: team.id }));
});
r.get('/t/:slug/teams/:id', (req, res, { slug, id }) => {
const it = item(slug);
if (!it.t.teams.has(id)) throw new HttpError(404, 'No such team');
send.html(res, pub.boardPage(publicState(it), { teamId: id }));
});
r.post('/t/:slug/register', async (req, res, { slug }) => {
const it = item(slug);
const body = await readBody(req);
try {
const team = registry.mutate(slug, t => t.registerTeam({
name: body.name, captain: String(body.captain ?? '').trim().slice(0, 60), phone: String(body.phone ?? '').trim().slice(0, 30),
players: body.players, playerNames: String(body.playerNames ?? '').split('\n').map(s => s.trim()).filter(Boolean).slice(0, 20),
}));
send.redirect(res, `/t/${slug}/team/${team.code}?new=1`);
} catch (e) {
send.html(res, pub.registerPage(publicState(it), { error: e.message, values: body }), 400);
}
});
// ---------- score keeper: a per-court link the organizer hands to whoever is scoring that court ----------
// The link carries an HMAC of slug+court signed with the session secret. The page always shows
// whatever match is live on that court, can update its running score and finalize it, and moves on
// to the next match by itself. Corrections after a match is final need the organizer desk.
const keeperToken = (slug, court) => sign(`${slug}:court${court}`, auth.secret).split('.').pop();
const keeperUrl = (req, slug, court) => `${origin(req)}/keeper/${slug}/${court}?k=${keeperToken(slug, court)}`;
const keeperCheck = (slug, court, k) => { if (!k || verify(`${slug}:court${court}.${k}`, auth.secret) === null) throw new HttpError(403, 'This score keeper link is not valid (links change when the server secret changes; get a fresh one from the desk)'); };
const courtMatch = (it, court, matchId) => {
const c = it.engine.courts.find(c => c.number === +court); if (!c) throw new HttpError(404, 'No such court');
const m = c.matchId ? it.t.match(c.matchId) : null;
if (!m || m.status !== 'live') throw new Error('No live match on this court');
if (matchId && m.id !== matchId) throw new Error('A different match is on this court now; the page has been refreshed');
return m;
};
r.get('/keeper/:slug/:court', (req, res, { slug, court }) => {
const it = item(slug);
keeperCheck(slug, court, q(req).get('k'));
if (!it.engine.courts.some(c => c.number === +court)) throw new HttpError(404, 'No such court');
send.html(res, adm.keeperPage(it.t, +court, q(req).get('k'), publicState(it)));
});
r.post('/keeper/:slug/:court/live', async (req, res, { slug, court }) => {
const body = await readBody(req);
keeperCheck(slug, court, body.k);
try {
registry.mutate(slug, (t, engine) => { const m = courtMatch({ t, engine }, court, body.match); m.live = [Math.max(0, +body.a | 0), Math.max(0, +body.b | 0)]; m.liveSets = Array.isArray(body.sets) ? body.sets.map(s => [+s[0] | 0, +s[1] | 0]).slice(0, 5) : []; });
send.json(res, { ok: true });
} catch (e) { send.json(res, { ok: false, error: e.message }, 400); }
});
r.post('/keeper/:slug/:court/final', async (req, res, { slug, court }) => {
const body = await readBody(req);
keeperCheck(slug, court, body.k);
try {
const msg = registry.mutate(slug, (t, engine) => {
const m = courtMatch({ t, engine }, court, body.match);
const sets = (Array.isArray(body.sets) ? body.sets : []).map(s => [+s[0] | 0, +s[1] | 0]);
engine.recordResult(m.id, sets, { actor: `keeper:court${court}` });
delete m.live; delete m.liveSets;
return `${t.teamName(m.winner)} wins ${sets.map(s => s.join('-')).join(', ')}`;
});
send.json(res, { ok: true, msg });
} catch (e) { send.json(res, { ok: false, error: e.message }, 400); }
});
// ---------- organizer ----------
const requireAdmin = (req, res) => {
const who = auth.identify(req);
if (!who) { send.redirect(res, `/admin/login?next=${encodeURIComponent(req.url)}`); return null; }
return who;
};
r.get('/admin/login', (req, res) => send.html(res, adm.loginPage(q(req).get('error'), q(req).get('next') ?? '/admin')));
r.post('/admin/login', async (req, res) => {
const body = await readBody(req);
if (!auth.checkPassword(body.password)) return send.redirect(res, '/admin/login?error=' + encodeURIComponent('Wrong password'));
const secure = (req.headers['x-forwarded-proto'] ?? '').includes('https');
send.redirect(res, safeNext(body.next), { 'set-cookie': auth.cookie('organizer', secure) });
});
r.get('/admin/logout', (req, res) => send.redirect(res, '/', { 'set-cookie': auth.clearCookie() }));
r.get('/admin', (req, res) => { const who = requireAdmin(req, res); if (who) send.html(res, adm.adminHome(registry.list(), who, q(req).get('msg'))); });
r.post('/admin/new', async (req, res) => {
const who = requireAdmin(req, res); if (!who) return;
const b = await readBody(req);
const rules = { teamSize: +b.teamSize || 2, pointsTo: +b.pointsTo || 21, winBy: +b.winBy || 2, cap: +b.cap || 0, bestOf: +b.bestOf || 1 };
const it = registry.create({ name: b.name, date: b.date, courtCount: +b.courtCount || 2, rules, stages: String(b.stages ?? 'pool,bracket').split(','), notes: b.notes ?? '' });
send.redirect(res, `/admin/t/${it.t.slug}?msg=${encodeURIComponent('Created. Print the QR code and open registration when ready.')}`);
});
r.get('/admin/t/:slug', (req, res, { slug }) => {
const who = requireAdmin(req, res); if (!who) return;
send.html(res, adm.adminTournament(item(slug), who, origin(req), q(req).get('msg'), q(req).get('error'), store.events(slug, 40), (t, c) => keeperUrl(req, t.slug, c.number)));
});
r.get('/admin/t/:slug/qr', async (req, res, { slug }) => {
const who = requireAdmin(req, res); if (!who) return;
const it = item(slug);
const dataUrl = await QRCode.toDataURL(`${origin(req)}/t/${slug}`, { width: 720, margin: 2, errorCorrectionLevel: 'M' });
send.html(res, adm.qrPage(it.t, origin(req), dataUrl, who));
});
r.get('/admin/t/:slug/qr.png', async (req, res, { slug }) => {
const who = requireAdmin(req, res); if (!who) return;
item(slug);
const buf = await QRCode.toBuffer(`${origin(req)}/t/${slug}`, { width: 1200, margin: 2 });
res.writeHead(200, { 'content-type': 'image/png' }); res.end(buf);
});
// Every organizer action: POST, mutate, redirect back with a message (or JSON for fetch callers).
const action = (path, fn) => r.post(`/admin/t/:slug${path}`, async (req, res, params) => {
const who = requireAdmin(req, res); if (!who) return;
const body = await readBody(req);
const wantsJson = (req.headers['content-type'] ?? '').includes('json');
try {
const msg = registry.mutate(params.slug, (t, engine) => fn({ t, engine, body, who, params })) ?? 'Done';
if (wantsJson) send.json(res, { ok: true, msg }); else send.redirect(res, `/admin/t/${params.slug}?msg=${encodeURIComponent(msg)}`);
} catch (e) {
if (e instanceof HttpError) throw e;
if (wantsJson) send.json(res, { ok: false, error: e.message }, 400); else send.redirect(res, `/admin/t/${params.slug}?error=${encodeURIComponent(e.message)}`);
}
});
action('/phase', ({ t, engine, body }) => {
const p = body.phase;
if (p === 'checkin') { t.reopenRegistration(); return 'Registration reopened'; }
if (p === 'closed') { if (t.phase === 'live') throw new Error('Already live; use Final or reopen is not possible'); t.closeRegistration(); return 'Registration closed'; }
if (p === 'live') { if (!t.matches.length) throw new Error('Generate pools or a bracket first'); engine.start(); return 'Live. Courts assigned.'; }
if (p === 'final') { t.phase = 'final'; t.emit({ type: 'phase', phase: 'final', champion: t.champion() }); return 'Marked final'; }
throw new Error('Unknown phase');
});
action('/banner', ({ t, body }) => { t.banner = String(body.banner ?? '').trim().slice(0, 200) || null; t.emit({ type: 'banner', banner: t.banner }); return t.banner ? 'Banner set' : 'Banner cleared'; });
action('/broadcast', ({ t, engine, body }) => { const text = String(body.text ?? '').trim().slice(0, 300); if (!text) throw new Error('Nothing to send'); engine.broadcast(text); return 'Broadcast sent'; });
action('/generate', ({ t, body }) => {
if (body.what === 'pools') { t.generatePools(+body.poolSize || 4); return `Pools generated: ${t.pools.map(p => `${p.id} (${p.teams.length})`).join(', ')}`; }
const seeded = t.pools.length ? t.advanceFromPools({ perPool: +body.perPool || 2, wildcards: +body.wildcards || 0 }) : t.activeTeams().map(x => x.id);
if (seeded.length < 2) throw new Error('Need at least two teams');
const res = t.generateBracket(seeded, { type: body.type === 'double' ? 'double' : 'single', thirdPlace: !!body.thirdPlace });
return `${body.type === 'double' ? 'Double' : 'Single'} elimination bracket of ${res.size} generated with ${seeded.length} teams`;
});
action('/regenerate', ({ t }) => { if (t.matches.some(m => ['final', 'forfeit'].includes(m.status))) throw new Error('Scores already entered'); t.matches = []; t.pools = []; for (const x of t.teams.values()) x.poolId = null; if (t.phase === 'live') t.phase = 'closed'; t.emit({ type: 'play_cleared' }); return 'Generated play cleared'; });
action('/score', ({ t, engine, body, who }) => {
const sets = parseSets(body.sets);
const m = t.match(body.match); if (!m) throw new Error('No such match');
const wasLive = m.status === 'live';
if (wasLive) engine.recordResult(m.id, sets, { actor: who });
else { t.recordResult(m.id, sets, { actor: who }); m.court = null; }
delete m.live; delete m.liveSets;
return `Saved ${t.teamName(m.teamA)} vs ${t.teamName(m.teamB)}: ${sets.map(s => s.join('-')).join(', ')}`;
});
action('/live', ({ t, body }) => { const m = t.match(body.match); if (!m || m.status !== 'live') throw new Error('Match is not live'); m.live = [Math.max(0, +body.a | 0), Math.max(0, +body.b | 0)]; return 'ok'; });
action('/forfeit', ({ t, engine, body, who }) => { const m = t.match(body.match); if (!m) throw new Error('No such match'); if (m.status === 'live') engine.recordResult(m.id, [], { actor: who, forfeit: body.team }); else t.recordResult(m.id, [], { actor: who, forfeit: body.team }); return `${t.teamName(body.team)} forfeits`; });
action('/swap', ({ engine, body }) => { engine.swapToCourt(body.match, +body.court); return `Moved to court ${body.court}`; });
action('/pushback', ({ engine, body }) => { engine.pushBack(body.match, 2); return 'Pushed back'; });
action('/court', ({ engine, body }) => { if (body.op === 'pause') engine.pauseCourt(+body.court, String(body.reason ?? '').slice(0, 60)); else engine.resumeCourt(+body.court); return `Court ${body.court} ${body.op}d`; });
action('/team', ({ t, body }) => {
if (body.op === 'add') { const team = t.registerTeam({ name: body.name, captain: body.captain, players: body.players }, { force: true }); return `Added ${team.name} (code ${team.code})`; }
if (body.op === 'remove') { t.removeTeam(body.id); return 'Team removed'; }
if (body.op === 'seed') { const list = t.activeTeams().filter(x => x.id !== body.id); list.splice(Math.max(0, +body.seed - 1), 0, t.teams.get(body.id)); t.setSeeds(list.map(x => x.id)); return 'Seeds updated'; }
t.updateTeam(body.id, { name: body.name, captain: body.captain, players: body.players }); return 'Team saved';
});
action('/withdraw', ({ t, engine, body, who }) => { const team = t.teams.get(body.id); if (!team) throw new Error('No such team'); if (t.phase === 'live') engine.withdrawTeam(body.id, { mode: body.mode, actor: who }); else t.withdrawTeam(body.id, { mode: body.mode, actor: who }); return `${team.name} withdrawn (${body.mode})`; });
r.post('/admin/t/:slug/delete', async (req, res, { slug }) => {
const who = requireAdmin(req, res); if (!who) return;
const body = await readBody(req);
if (body.confirm !== 'yes') return send.redirect(res, `/admin/t/${slug}?error=Not+confirmed`);
item(slug); registry.remove(slug);
send.redirect(res, '/admin?msg=' + encodeURIComponent('Tournament deleted'));
});
return r;
}
function parseSets(str) {
const sets = String(str ?? '').split(/[,;]/).map(s => s.trim()).filter(Boolean).map(s => {
const m = s.match(/^(\d+)\s*[-:]\s*(\d+)$/); if (!m) throw new Error(`Can't read set "${s}" — use 21-18`);
return [+m[1], +m[2]];
});
if (!sets.length) throw new Error('Enter at least one set, like 21-18');
return sets;
}
const safeNext = n => (typeof n === 'string' && n.startsWith('/') && !n.startsWith('//')) ? n : '/admin';