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:
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)}>– 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'>+ 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)