- 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
162 lines
11 KiB
JavaScript
162 lines
11 KiB
JavaScript
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';
|