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 ed78f3a09cbbeb427f0fba0df9d3ba28717529ba
parent 1d90aa2aeda491f5933d2f53c59cf5d8e1c2af46
Author: Andreas Gruhler <agruhl@gmx.ch>
Date:   Mon,  9 Mar 2026 23:09:01 +0100

feat(137): authenticate startlist & score endpoints

Diffstat:
MCHANGELOG.md | 7++++---
Adocs/POSTGRESQL_FUNCTIONS.md | 39+++++++++++++++++++++++++++++++++++++++
Mschema/99-init-db.sql | 26++++++++++++++++++--------
Aschema/migrations/02-distinct-startlist-private-heats.sql | 21+++++++++++++++++++++
Aschema/migrations/03-score-summary-function.sql | 20++++++++++++++++++++
Msrc/api/db.cjs | 42+++++++++++++++++++++++++++++-------------
Msrc/api/server.cjs | 70+++++++++++++++++++++++++++++++++++++++-------------------------------
Msrc/frontend/Leaderboard.jsx | 35++++++++++++++++++++++-------------
Msrc/frontend/Score.jsx | 2+-
9 files changed, 193 insertions(+), 69 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -1,8 +1,9 @@ ## [0.9 Unreleased] ### Added -* `private` heats only visible to judges - - DB migration - [`schema/migrations/01-heats.sql`](schema/migrations/01-heats.sql) +* `private` heats only visible to judges. DB migrations: + - [`schema/migrations/01-heats.sql`](schema/migrations/01-heats.sql) + - [`schema/migrations/02-distinct-startlist-private-heats.sql`](schema/migrations/02-distinct-startlist-private-heats.sql) + - [`schema/migrations/03-score-summary-function.sql`](schema/migrations/03-score-summary-function.sql) ### Changed * PostgreSQL 17 in docker-compose dev setup diff --git a/docs/POSTGRESQL_FUNCTIONS.md b/docs/POSTGRESQL_FUNCTIONS.md @@ -0,0 +1,39 @@ +# Notes on PostgreSQL Functions + +## Reduction of conditional WHERE statement + +The WHERE statement for the function `distinct_startlist` was reduced as follows. +* Source: https://stackoverflow.com/a/73452497 + +Step 1, function with conditional where (CASE): +```sql +where (case when publicOnly = true + then -- return public heats only for unauthenticated users + s.heat = any(heat_ids) and + h.private = false + else -- return all heats for authenticated users + s.heat = any(heat_ids) +end); +``` + +Step 2: +```sql +where s.heat = any(heat_ids) and +(case when publicOnly = true then h.private = false else true end); +``` + +Step 3: +```sql +where s.heat = any(heat_ids) and +(publicOnly = false or h.private = false); +``` + +Truth table: + +| "Private" selected? | Description | Input variables | Result | +|---------------------|---------------|------------------------------------|--------------| +| yes | private heat | publicOnly=true & h.private=true | not selected | +| no | public heat | publicOnly=true & h.private=false | selected | +| yes | private heat | publicOnly=false & h.private=true | selected | +| no | public heat | publicOnly=false & h.private=false | selected | +``` diff --git a/schema/99-init-db.sql b/schema/99-init-db.sql @@ -1,14 +1,22 @@ -- Summarize all scores of a heat -create or replace view score_summary - with (security_invoker=on) - as - select a.id as athlete_id, s.heat as heat_id, SUM(s.score) as score_summary - from scores s - join athletes as a on a.id = s.athlete +create or replace function score_summary(publicOnly boolean) +returns table(athlete_id bigint, heat_id bigint, score_summary double precision) +language plpgsql +set search_path = '' +as $$ +begin + return query + select a.id, s.heat, SUM(s.score) + from public.scores s + join public.athletes as a on a.id = s.athlete + join public.heats as h on s.heat = h.id + where (publicOnly = false or h.private = false) group by a.id, s.heat; +end; +$$; -- Create distinct set of athletes (startlist) from a set of heats -create or replace function distinct_startlist(heat_ids numeric[]) +create or replace function distinct_startlist(heat_ids numeric[], publicOnly boolean) returns table(id bigint, athlete bigint, nr bigint, firstname text, lastname text, birthday date, school text) language plpgsql @@ -19,7 +27,9 @@ begin select distinct on (a.id) s.id, a.id, a.nr, a.firstname, a.lastname, a.birthday, a.school from public.startlist as s join public.athletes as a on s.athlete = a.id - where s.heat = any (heat_ids); + join public.heats as h on s.heat = h.id + where s.heat = any(heat_ids) and + (publicOnly = false or h.private = false); end; $$; diff --git a/schema/migrations/02-distinct-startlist-private-heats.sql b/schema/migrations/02-distinct-startlist-private-heats.sql @@ -0,0 +1,20 @@ +-- remove the function with the old signature +drop function if exists distinct_startlist(heat_ids numeric[]); + +-- add function that returns startlists considering the heat visibility +create or replace function distinct_startlist(heat_ids numeric[], publicOnly boolean) +returns table(id bigint, athlete bigint, nr bigint, firstname text, lastname text, +birthday date, school text) +language plpgsql +set search_path = '' +as $$ +begin + return query + select distinct on (a.id) s.id, a.id, a.nr, a.firstname, a.lastname, a.birthday, a.school + from public.startlist as s + join public.athletes as a on s.athlete = a.id + join public.heats as h on s.heat = h.id + where s.heat = any(heat_ids) and + (publicOnly = false or h.private = false); +end; +$$; +\ No newline at end of file diff --git a/schema/migrations/03-score-summary-function.sql b/schema/migrations/03-score-summary-function.sql @@ -0,0 +1,19 @@ +-- remove the old view +drop view if exists score_summary; + +-- create new function with input parameter "publicOnly" +create or replace function score_summary(publicOnly boolean) +returns table(athlete_id bigint, heat_id bigint, score_summary double precision) +language plpgsql +set search_path = '' +as $$ +begin + return query + select a.id, s.heat, SUM(s.score) + from public.scores s + join public.athletes as a on a.id = s.athlete + join public.heats as h on s.heat = h.id + where (publicOnly = false or h.private = false) + group by a.id, s.heat; +end; +$$; +\ No newline at end of file diff --git a/src/api/db.cjs b/src/api/db.cjs @@ -306,7 +306,7 @@ async function toggleHeatVisibility(heatId) { } } -async function distinctStartlist(heatIds) { +async function distinctStartlist(heatIds, publicOnly) { try { const startlist = await sql` select @@ -317,7 +317,7 @@ async function distinctStartlist(heatIds) { lastname, birthday, school - from distinct_startlist(${heatIds}) as athlete + from distinct_startlist(${heatIds}, ${publicOnly}) as athlete ` return startlist } catch (error) { @@ -353,16 +353,32 @@ async function startlistWithAthletes(heatId) { } } -async function scoresForHeatAndAthlete(heat, athlete) { +async function scoresForHeatAndAthlete(heat, athlete, publicOnly) { try { - const score = await sql` - select - id, - athlete, - judge, - score - from public.scores where heat = ${heat} and athlete = ${athlete} - ` + let score = undefined + if (publicOnly) { + score = await sql` + select + s.id, + s.athlete, + s.judge, + s.score + from public.scores as s + join public.heats as h + on s.heat = h.id + where s.heat = ${heat} and s.athlete = ${athlete} and h.private = false; + ` + } else { + // return all heats (private & public) + score = await sql` + select + id, + athlete, + judge, + score + from public.scores where heat = ${heat} and athlete = ${athlete}; + ` + } return score } catch (error) { console.error('Error occurred in scoresForHeatAndAthlete:', error); @@ -370,11 +386,11 @@ async function scoresForHeatAndAthlete(heat, athlete) { } } -async function scoreSummaryForHeatAndAthlete(heat, athlete) { +async function scoreSummaryForHeatAndAthlete(heat, athlete, publicOnly) { try { const summary = await sql` select score_summary - from score_summary where heat_id = ${heat} and athlete_id = ${athlete} + from score_summary(${publicOnly}) where heat_id = ${heat} and athlete_id = ${athlete} ` return summary } catch (error) { diff --git a/src/api/server.cjs b/src/api/server.cjs @@ -53,34 +53,34 @@ const transport = nodemailer.createTransport({ // Define API paths and allowed request methods const paths = [ - '/v1/healthz', - '/v1/auth/verify', - '/v1/echo', - '/v1/auth/requestMagicLink', - '/v1/auth/invalidateToken', // not implemented - '/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', - '/v1/leaderboard/scoreSummaryForHeatAndAthlete', - '/v1/leaderboard/getScore', // 🔒 authenticated - '/v1/leaderboard/setScore', // 🔒 authenticated - '/v1/leaderboard/addAthleteToHeat', // 🔒 authenticated - '/v1/leaderboard/removeAthleteFromHeat', // 🔒 authenticated - '/v1/leaderboard/addAthlete', // 🔒 authenticated - '/v1/leaderboard/removeAthlete', // 🔒 authenticated - '/v1/leaderboard/addJudge', // 🔒 authenticated - '/v1/leaderboard/removeJudge', // 🔒 authenticated - '/v1/leaderboard/allSettings', - '/v1/leaderboard/getSetting', // 🔒 authenticated - '/v1/leaderboard/updateSetting', // 🔒 authenticated - '/v1/leaderboard/removeSetting', // 🔒 authenticated + '/v1/healthz', // not authenticated + '/v1/auth/verify', // not authenticated + '/v1/echo', // not authenticated + '/v1/auth/requestMagicLink', // not authenticated + '/v1/auth/invalidateToken', // not implemented + '/v1/leaderboard/allHeats', // 🔓 partly authenticated + '/v1/leaderboard/allJudges', // 🔐 fully authenticated + '/v1/leaderboard/allAthletes', // 🔐 fully authenticated + '/v1/leaderboard/newHeat', // 🔐 fully authenticated + '/v1/leaderboard/getHeat', // 🔐 fully authenticated + '/v1/leaderboard/removeHeat', // 🔐 fully authenticated + '/v1/leaderboard/toggleHeatVisibility', // 🔐 fully authenticated + '/v1/leaderboard/distinctStartlist', // 🔓 partly authenticated + '/v1/leaderboard/startlistWithAthletes', // 🔐 fully authenticated + '/v1/leaderboard/scoresForHeatAndAthlete', // 🔓 partly authenticated + '/v1/leaderboard/scoreSummaryForHeatAndAthlete', // 🔓 partly authenticated + '/v1/leaderboard/getScore', // 🔐 fully authenticated + '/v1/leaderboard/setScore', // 🔐 fully authenticated + '/v1/leaderboard/addAthleteToHeat', // 🔐 fully authenticated + '/v1/leaderboard/removeAthleteFromHeat', // 🔐 fully authenticated + '/v1/leaderboard/addAthlete', // 🔐 fully authenticated + '/v1/leaderboard/removeAthlete', // 🔐 fully authenticated + '/v1/leaderboard/addJudge', // 🔐 fully authenticated + '/v1/leaderboard/removeJudge', // 🔐 fully authenticated + '/v1/leaderboard/allSettings', // not authenticated + '/v1/leaderboard/getSetting', // 🔐 fully authenticated + '/v1/leaderboard/updateSetting', // 🔐 fully authenticated + '/v1/leaderboard/removeSetting', // 🔐 fully authenticated ] console.log("Backend API:", api_uri); @@ -324,7 +324,9 @@ server.on('request', async (req, res) => { input = JSON.parse(b); console.log(' distinctStartlist request with headIds:', input.heat_ids); - const startlist = await db.distinctStartlist(input.heat_ids); + const user = await verifyToken(req, token) + // return public heats only for unauthenticated users + const startlist = await db.distinctStartlist(input.heat_ids, user === false ? true : false); if (startlist.length < 1) { noContent(res); @@ -383,9 +385,12 @@ server.on('request', async (req, res) => { console.log(' scoresForHeatAndAthlete request with heat and athlete:', input.heat, input.athlete); + const user = await verifyToken(req, token) + // for unauthenticated users only return public heat scores const scores = await db.scoresForHeatAndAthlete( input.heat, - input.athlete + input.athlete, + user === false ? true : false ) if (scores.length < 1) { @@ -414,9 +419,12 @@ server.on('request', async (req, res) => { console.log(' scoreSummaryForHeatAndAthlete request with heat and athlete:', input.heat, input.athlete); + const user = await verifyToken(req, token) + // for unauthenticated users only return public heat summary const summary = await db.scoreSummaryForHeatAndAthlete( input.heat, - input.athlete + input.athlete, + user === false ? true : false ) if (summary.length < 1) { diff --git a/src/frontend/Leaderboard.jsx b/src/frontend/Leaderboard.jsx @@ -44,10 +44,13 @@ export async function addAthleteToHeat(athlete, heat, session) { return data } -export async function getStartlistForHeats(heatIds) { +export async function getStartlistForHeats(heatIds, session) { const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/distinctStartlist`, { method: 'POST', - headers: {'Content-Type': 'application/json'}, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.auth ? session.auth.token : ""}`, + }, body: JSON.stringify({ "heat_ids": heatIds, }), @@ -64,13 +67,16 @@ export async function getStartlistForHeats(heatIds) { } } -async function getScoresForHeatAndAthlete(heatId, athleteId) { +async function getScoresForHeatAndAthlete(heatId, athleteId, session) { const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/scoresForHeatAndAthlete`, { method: 'POST', - headers: {'Content-Type': 'application/json'}, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.auth ? session.auth.token : ""}`, + }, body: JSON.stringify({ "heat": heatId, - "athlete": athleteId, + "athlete": athleteId }), }) if (res.status !== 204) { @@ -82,10 +88,13 @@ async function getScoresForHeatAndAthlete(heatId, athleteId) { } } -async function getScoreSummaryForHeatAndAthlete(heatId, athleteId) { +async function getScoreSummaryForHeatAndAthlete(heatId, athleteId, session) { const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/scoreSummaryForHeatAndAthlete`, { method: 'POST', - headers: {'Content-Type': 'application/json'}, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.auth ? session.auth.token : ""}`, + }, body: JSON.stringify({ "heat": heatId, "athlete": athleteId, @@ -100,9 +109,9 @@ async function getScoreSummaryForHeatAndAthlete(heatId, athleteId) { } } -async function getScoreSummary(heatIds) { +async function getScoreSummary(heatIds, session) { const startListWithScores = [] - const startlist = await getStartlistForHeats(heatIds) + const startlist = await getStartlistForHeats(heatIds, session) for (const i of startlist) { i.heats = [] @@ -110,8 +119,8 @@ async function getScoreSummary(heatIds) { for (const h of heatIds) { try { // this is an array, because the athlete can be scored by multiple judges - const scores = await getScoresForHeatAndAthlete(h, i.athlete) - const summary = await getScoreSummaryForHeatAndAthlete(h, i.athlete) + const scores = await getScoresForHeatAndAthlete(h, i.athlete, session) + const summary = await getScoreSummaryForHeatAndAthlete(h, i.athlete, session) if (scores && summary) { // add heat results of athlete to startlist entry i.heats.push({ @@ -336,7 +345,7 @@ function Leaderboard({session}) { setLoading(true) // reload entire leaderboard when heat selection is changed - const scoreSummary = await getScoreSummary(heatSelection.map(h => h.value)) + const scoreSummary = await getScoreSummary(heatSelection.map(h => h.value), session) setLeaderboard(scoreSummary) setLoading(false) })(); @@ -346,7 +355,7 @@ function Leaderboard({session}) { (async() => { socket.onmessage = async function(event) { // todo: reload only required scores - const scoreSummary = await getScoreSummary(heatSelection.map(h => h.value)) + const scoreSummary = await getScoreSummary(heatSelection.map(h => h.value), session) setLeaderboard(scoreSummary) } setLoading(false) diff --git a/src/frontend/Score.jsx b/src/frontend/Score.jsx @@ -80,7 +80,7 @@ function ScoringForm({session}) { let startlist = undefined try { - startlist = await getStartlistForHeats([heatSelection.value]) + startlist = await getStartlistForHeats([heatSelection.value], session) } catch (error) { console.error(error) setLoading(false)