Files
courtside/server/routes.js
T
bkvargyas 6a3f966758 Release 0.2.0: changelog, version reporting, push-to-deploy
- CHANGELOG.md following Keep a Changelog, covering 0.1.0 and 0.2.0
- server/version.js reads the version from package.json; /healthz returns it
  and the footer renders it, so a deployment always states what it is serving
- bump package.json 0.1.0 -> 0.2.0
2026-09-03 17:31:21 -04:00

162 lines
11 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 } 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.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);
}
});
// ---------- 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)));
});
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;
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';