myheats

Live heats, scoring and leaderboard for sport events
git clone https://git.in0rdr.ch/myheats.git
Log | Files | Refs | Pull requests |Archive | README | LICENSE

commit ba9a6e54c0b6c0787576b174a048e4781704c619
parent c8dafd76769717ab9a6590b5c7fd55c24ee412c5
Author: Andreas Gruhler <agruhl@gmx.ch>
Date:   Sun,  8 Mar 2026 23:53:25 +0100

feat(137): add private heats

Diffstat:
MCHANGELOG.md | 9+++++++++
MREADME.md | 11++++++++++-
Mdocs/DEVELOPMENT_SETUP.md | 4++--
Mdocs/DIGIALOCEAN.md | 3++-
Mpackage-lock.json | 36+++++++++++-------------------------
Mschema/01-heats.sql | 1+
Aschema/migrations/01-heats.sql | 2++
Msrc/api/db.cjs | 49+++++++++++++++++++++++++++++++++++++------------
Msrc/api/server.cjs | 106++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Msrc/frontend/Heats.jsx | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/frontend/Leaderboard.jsx | 7++++++-
Msrc/frontend/Score.jsx | 7++++++-
12 files changed, 223 insertions(+), 79 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -1,4 +1,13 @@ ## [0.9 Unreleased] +### Added +* `private` heats only visible to judges + - DB migration + [`schema/migrations/01-heats.sql`](schema/migrations/01-heats.sql) + +### Changed +* PostgreSQL 17 in docker-compose dev setup + +### Fixed ## [0.8] - 2026-01-07 * fix: CVE-2024-47764 and CVE-2024-21538 diff --git a/README.md b/README.md @@ -38,7 +38,7 @@ function. Following ranking options can be selected. Rank by: The PostgreSQL database schema is stored in the `schema` folder and can be created using plain psql. -To update the schema from the current database, use (example for table +To update the schema files from the current database, use (example for table `startlist`): ```bash pg_dump -h 127.0.0.1 -U postgres -t 'public.startlist' --schema-only > schema/04-startlist.sql @@ -50,6 +50,15 @@ publication](https://www.postgresql.org/docs/current/logical-replication-publica so the leaderboard can automatically update scores when they are created or changed by judges ("realtime functionality"). +### Schema migrations +Schema migrations are documented in the [CHANGELOG.md](./CHANGELOG.md) and are +required when upgrading from older releases. + +Example upgrade from v0.8 to v0.9: +```bash +psql < schema/migrations/01-heats.sql +``` + ## Authentication with magic links Authentication of judges is performed using Magic links. diff --git a/docs/DEVELOPMENT_SETUP.md b/docs/DEVELOPMENT_SETUP.md @@ -10,8 +10,8 @@ nix-shell nix/shell.nix ``` ## Docker environment -The docker-compose environment in the `./dev` folder configures a PostgreSQL database -with Adminer interface: +The docker-compose environment in the `./dev` folder configures a PostgreSQL +database with Adminer interface: * https://hub.docker.com/_/postgres * https://github.com/docker-library/docs/blob/master/postgres/README.md diff --git a/docs/DIGIALOCEAN.md b/docs/DIGIALOCEAN.md @@ -47,7 +47,8 @@ buildah push registry.digitalocean.com/myheats/myheats:api ## App service setup -Use `../dev/digital-ocean-app.yaml` app spec to configure the app in Digital Ocean +Use `../dev/digital-ocean-app.yaml` app spec to configure the app in Digital +Ocean ## Droplet setup diff --git a/package-lock.json b/package-lock.json @@ -1,12 +1,12 @@ { "name": "myheats", - "version": "0.8.0-nightly", + "version": "0.9.0-nightly", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "myheats", - "version": "0.8.0-nightly", + "version": "0.9.0-nightly", "dependencies": { "dotenv": "^16.6.1", "jsonwebtoken": "^9.0.3", @@ -74,6 +74,7 @@ "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -1416,14 +1417,6 @@ "hoist-non-react-statics": "^3.3.0" } }, - "node_modules/@types/node": { - "version": "20.3.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.1.tgz", - "integrity": "sha512-EhcH/wvidPy1WeML3TtYFGR83UzjxeWRen9V402T8aUGYsCHOmfoisV3ZSg03gAFIbLq8TnWOJ0f4cALtnSEUg==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -1439,6 +1432,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1496,6 +1490,7 @@ "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1755,6 +1750,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -2332,6 +2328,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4070,6 +4067,7 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4184,6 +4182,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -4210,6 +4209,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5076,6 +5076,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -5292,21 +5293,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", - "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/schema/01-heats.sql b/schema/01-heats.sql @@ -4,6 +4,7 @@ CREATE TABLE public.heats ( name text NOT NULL, location text, planned_start time without time zone, + private boolean default true, CONSTRAINT heats_name_check CHECK ((length(name) > 0)) ); diff --git a/schema/migrations/01-heats.sql b/schema/migrations/01-heats.sql @@ -0,0 +1 @@ +ALTER TABLE public.heats ADD COLUMN IF NOT EXISTS private boolean default true; +\ No newline at end of file diff --git a/src/api/db.cjs b/src/api/db.cjs @@ -90,11 +90,20 @@ async function getUser(email) { } } -async function allHeats() { +async function allHeats(publicOnly) { try { - const heats = await sql` - select * from heats - ` + let heats = undefined + if (publicOnly) { + heats = await sql` + select * from heats + where private = false + ` + } else { + // return all heats (private & public) + heats = await sql` + select * from heats + ` + } return heats } catch (error) { console.error('Error occurred in allHeats:', error); @@ -236,7 +245,7 @@ async function removeJudge(id) { async function removeAthleteFromHeat(startlistId) { try { const startlist = await sql` - delete from startlist + delete from public.startlist where id = ${startlistId} returning * ` @@ -247,18 +256,20 @@ async function removeAthleteFromHeat(startlistId) { } } -async function newHeat(name, heatLocation, plannedStart) { +async function newHeat(name, heatLocation, plannedStart, privateHeat) { try { const heat = await sql` insert into heats ( name, location, - planned_start + planned_start, + private ) values ( ${name}, ${heatLocation}, - ${plannedStart} + ${plannedStart}, + ${privateHeat} ) returning * ` @@ -272,7 +283,7 @@ async function newHeat(name, heatLocation, plannedStart) { async function removeHeat(heatId) { try { const heat = await sql` - delete from heats where id = ${heatId} + delete from public.heats where id = ${heatId} returning * ` return heat @@ -282,6 +293,19 @@ async function removeHeat(heatId) { } } +async function toggleHeatVisibility(heatId) { + try { + const heat = await sql` + update public.heats set private = not private where id = ${heatId} + returning * + ` + return heat + } catch (error) { + console.error('Error occurred in toggleHeatVisibility:', error); + throw error + } +} + async function distinctStartlist(heatIds) { try { const startlist = await sql` @@ -317,7 +341,7 @@ async function startlistWithAthletes(heatId) { a.lastname, a.birthday, a.school - from startlist as s + from public.startlist as s left outer join athletes as a on s.athlete = a.id where s.heat = ${heatId} @@ -337,7 +361,7 @@ async function scoresForHeatAndAthlete(heat, athlete) { athlete, judge, score - from scores where heat = ${heat} and athlete = ${athlete} + from public.scores where heat = ${heat} and athlete = ${athlete} ` return score } catch (error) { @@ -362,7 +386,7 @@ async function scoreSummaryForHeatAndAthlete(heat, athlete) { async function getScore(heat, athlete, judge) { try { const scores = await sql` - select * from scores + select * from public.scores where heat = ${heat} and athlete = ${athlete} and judge = ${judge} ` return scores @@ -501,6 +525,7 @@ module.exports = { allJudges, newHeat, removeHeat, + toggleHeatVisibility, distinctStartlist, startlistWithAthletes, scoresForHeatAndAthlete, diff --git a/src/api/server.cjs b/src/api/server.cjs @@ -58,12 +58,13 @@ const paths = [ '/v1/echo', '/v1/auth/requestMagicLink', '/v1/auth/invalidateToken', // not implemented - '/v1/leaderboard/allHeats', + '/v1/leaderboard/allHeats', // partly authenticated '/v1/leaderboard/allJudges', // 🔒 authenticated '/v1/leaderboard/allAthletes', // 🔒 authenticated '/v1/leaderboard/newHeat', // 🔒 authenticated '/v1/leaderboard/getHeat', // 🔒 authenticated '/v1/leaderboard/removeHeat', // 🔒 authenticated + '/v1/leaderboard/toggleHeatVisibility', // 🔒 authenticated '/v1/leaderboard/distinctStartlist', '/v1/leaderboard/startlistWithAthletes', // 🔒 authenticated '/v1/leaderboard/scoresForHeatAndAthlete', @@ -139,18 +140,23 @@ server.on('request', async (req, res) => { case '/v1/auth/verify': try { token = search_params.get('token'); - let user = await verifyToken(req, token) - res.end(JSON.stringify({ - message: 'Uncle roger verified 🤝 fuuiiyooh!', - data: user, - })); + const user = await verifyToken(req, token) + + if (user !== false) { + res.end(JSON.stringify({ + message: 'Uncle roger verified 🤝 fuuiiyooh!', + data: user, + })); + } } catch (error) { serverError(res, error); } break case '/v1/leaderboard/allHeats': try { - const heats = await db.allHeats() + const user = await verifyToken(req, token) + // return public heats only for unauthenticated users + const heats = await db.allHeats(user === false ? true : false) res.end(JSON.stringify({ message: 'All heats', @@ -162,33 +168,41 @@ server.on('request', async (req, res) => { break case '/v1/leaderboard/allJudges': try { - await verifyToken(req, token) + const user = await verifyToken(req, token) const judges = await db.allJudges() - res.end(JSON.stringify({ - message: 'All judges', - data: judges, - })); + if (user !== false) { + res.end(JSON.stringify({ + message: 'All judges', + data: judges, + })); + } else { + notAllowed(res, req.method) + } } catch(error) { serverError(res, error); } break case '/v1/leaderboard/allAthletes': try { - await verifyToken(req, token) + const user = await verifyToken(req, token) const athletes = await db.allAthletes() - res.end(JSON.stringify({ - message: 'All athletes', - data: athletes, - })); + if (user !== false) { + res.end(JSON.stringify({ + message: 'All athletes', + data: athletes, + })); + } else { + notAllowed(res, req.method) + } } catch(error) { serverError(res, error); } break case '/v1/leaderboard/getSetting': try { - await verifyToken(req, token) + const user = await verifyToken(req, token) let name = search_params.get('name'); const setting = await db.getSetting(name) @@ -197,10 +211,15 @@ server.on('request', async (req, res) => { noContent(res); return } - res.end(JSON.stringify({ - message: `Setting with name ${name}`, - data: setting[0].value, - })); + + if (user !== false) { + res.end(JSON.stringify({ + message: `Setting with name ${name}`, + data: setting[0].value, + })); + } else { + notAllowed(res, req.method) + } } catch(error) { serverError(res, error); } @@ -492,6 +511,7 @@ server.on('request', async (req, res) => { input.name, input.location, input.planned_start, + input.private ); if (heats.length < 1) { throw new Error("Heat not created") @@ -561,6 +581,32 @@ server.on('request', async (req, res) => { } }) break + case '/v1/leaderboard/toggleHeatVisibility': + req.on('data', chunk => { + body.push(chunk); + }).on('end', async () => { + const b = Buffer.concat(body); + try { + await verifyToken(req, token) + if (b.length < 1) { + throw new Error("Empty request body") + } + input = JSON.parse(b); + console.log(' toggleHeatVisibility request for:', input); + + const heats = await db.toggleHeatVisibility(input.heat_id) + if (heats.length < 1) { + throw new Error("Heat visibility unchanged") + } + res.end(JSON.stringify({ + message: 'Heat visibility toggled', + data: heats[0], + })); + } catch (error) { + serverError(res, error); + } + }) + break case '/v1/leaderboard/addAthleteToHeat': req.on('data', chunk => { body.push(chunk); @@ -800,18 +846,20 @@ server.on('request', async (req, res) => { } }); -// returns user on success, throws error otherwise +// returns user or false for valid/invalid tokens async function verifyToken(req, token) { if (!token){ - throw {message: "No token found in verifyToken"} + console.warn("No token found in verifyToken"); + return false } else if (token.length === 0) { - throw {message: "Token empty in verifyToken"} + console.warn("Token empty in verifyToken"); + return false } // lookup token in the database const users = await db.lookupToken(token); if (users.length < 1) { - throw {message: "User not found in verifyToken"} + console.warn("User not found in verifyToken"); } const user = users[0] @@ -821,7 +869,8 @@ async function verifyToken(req, token) { // check expiration date of token in the database if (new Date(users[0].expires_at) < new Date()) { - throw new Error('Token expired.. tthink about yu saad live 😿') + console.warn("Token expired.. tthink about yu saad live 😿"); + return false } // verify token signature @@ -830,7 +879,8 @@ async function verifyToken(req, token) { console.log(" Verified token", v); if (v.email !== user.email) { - throw new Error("Token signature invalid") + console.warn("Token signature invalid"); + return false } return user } diff --git a/src/frontend/Heats.jsx b/src/frontend/Heats.jsx @@ -8,7 +8,39 @@ const locale = import.meta.env.VITE_LOCALE const Auth = lazy(() => import('./Auth')) -export async function addNewHeat(name, heatLocation, plannedStart, session) { +async function changeVisibility(e, heatId, heatName, session) { + e.preventDefault() + + if (window.confirm('Change visibility of heat "' + heatName + '"?')) { + const { data, error } = await toggleVisibility(heatId, session) + if (error === undefined) { + window.location.reload() + } else { + console.error(error) + } + } +} + +async function toggleVisibility(heatId, session) { + try { + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/toggleHeatVisibility`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.auth.token}`, + }, + body: JSON.stringify({ + "heat_id": heatId + }), + }) + const { data, error } = await res.json() + return data + } catch (error) { + throw(error) + } +} + +export async function addNewHeat(name, heatLocation, plannedStart, privateHeat, session) { try { const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/newHeat`, { method: 'POST', @@ -19,7 +51,8 @@ export async function addNewHeat(name, heatLocation, plannedStart, session) { body: JSON.stringify({ "name": name, "location": heatLocation, - "planned_start": plannedStart + "planned_start": plannedStart, + "private": privateHeat }), }) const { data, error } = await res.json() @@ -39,11 +72,14 @@ async function addHeat(e, session) { // create new heat try { const heat = await addNewHeat( - formJson.name, - formJson.location, - // planned_start is an empty string if unset - // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/time - formJson.planned_start === '' ? null : formJson.planned_start, + formJson.name, + formJson.location, + // planned_start is an empty string if unset + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/time + formJson.planned_start === '' ? null : formJson.planned_start, + // the default checkbox value is 'on' + // https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/checkbox + formJson.private === 'on' ? true : false, session ) window.location.reload() @@ -92,6 +128,7 @@ function ExportForm(heats) { function HeatForm({session}) { const [loading, setLoading] = useState(false) + const [privateHeatChecked, setPrivateHeatChecked] = useState(true); const [heats, setHeats] = useState([]) const dateOptions = { year: "numeric", @@ -102,7 +139,12 @@ function HeatForm({session}) { useEffect(() => { (async () => { setLoading(true) - const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/allHeats`) + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/allHeats`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.auth.token}`, + } + }) const { data, error } = await res.json() if (error) { console.error(error) @@ -123,6 +165,7 @@ function HeatForm({session}) { <th>Name *</th> <th>Location</th> <th>Planned start</th> + <th>Private</th> <th>New/delete</th> </tr> </thead> @@ -133,6 +176,7 @@ function HeatForm({session}) { <td data-title='Name'><Link to={generatePath('/heats/startlist/:heatId', {heatId:h.id})}>{h.name}</Link></td> <td data-title='Location'>{h.location}</td> <td data-title='Planned start'>{h.planned_start}</td> + <td data-title='Private'><input type='checkbox' checked={h.private} onChange={e => changeVisibility(e, h.id, h.name, session)} /></td> <td><button onClick={e => deleteHeat(e, h.id, h.name, session)}>&ndash; del</button></td> </tr> ))} @@ -149,6 +193,13 @@ function HeatForm({session}) { type='time' name='planned_start' /> </td> + <td data-title='Private'> + <input + type='checkbox' + name='private' + checked={privateHeatChecked} + onChange={(e) => setPrivateHeatChecked(e.target.checked)} /> + </td> <td> <button type='submit'>&#43; new</button> </td> diff --git a/src/frontend/Leaderboard.jsx b/src/frontend/Leaderboard.jsx @@ -313,7 +313,12 @@ function Leaderboard({session}) { setLoading(true) // load initial list of heats - const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/allHeats`) + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/allHeats`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.auth ? session.auth.token : ""}`, + } + }) const { data, error } = await res.json() if (error) { console.error(error) diff --git a/src/frontend/Score.jsx b/src/frontend/Score.jsx @@ -66,7 +66,12 @@ function ScoringForm({session}) { useEffect(() => { (async () => { setLoading(true) - const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/allHeats`) + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/allHeats`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.auth.token}`, + } + }) const { data, error } = await res.json() if (error) { console.error(error)