import QRCode from 'qrcode'; import { Router, HttpError, readBody, send, esc } from './http.js'; import { publicState } from './public-state.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, 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';