myheats

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

commit 4ec3f79cf92408497e2288e94544dbe3a97a365e
parent 5b54a388e20db1f7b21739b279068e1d8236a920
Author: Andreas Gruhler <andreas.gruhler@adfinis.com>
Date:   Sat, 28 Sep 2024 14:23:46 +0200

feat: authenticate heats, athletes, startlists

Diffstat:
Msrc/api/server.cjs | 5+++--
Msrc/frontend/Heats.jsx | 27+++++++++++++++++----------
Msrc/frontend/Leaderboard.jsx | 28+++++++++++++++++-----------
Msrc/frontend/Score.jsx | 22++++++++++++++--------
Msrc/frontend/Startlist.jsx | 45++++++++++++++++++++++++++++++---------------
Msrc/frontend/utils.js | 26++++++++++++++++----------
6 files changed, 97 insertions(+), 56 deletions(-)

diff --git a/src/api/server.cjs b/src/api/server.cjs @@ -103,7 +103,7 @@ server.on('request', async (req, res) => { // cors pre-flight request uses options method res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization'); // extact authorization token const authHeader = req.headers.authorization @@ -374,7 +374,8 @@ server.on('request', async (req, res) => { ); if (scores.length < 1) { - throw new Error("Score not found") + noContent(res); + return } res.end(JSON.stringify({ diff --git a/src/frontend/Heats.jsx b/src/frontend/Heats.jsx @@ -8,11 +8,14 @@ const locale = import.meta.env.VITE_LOCALE const Auth = lazy(() => import('./Auth')) -export async function addNewHeat(name, heatLocation, plannedStart) { +export async function addNewHeat(name, heatLocation, plannedStart, session) { try { const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/newHeat`, { method: 'POST', - headers: {'Content-Type': 'application/json'}, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.auth.token}`, + }, body: JSON.stringify({ "name": name, "location": heatLocation, @@ -26,7 +29,7 @@ export async function addNewHeat(name, heatLocation, plannedStart) { } } -async function addHeat(e) { +async function addHeat(e, session) { e.preventDefault() // Read the form data @@ -40,7 +43,8 @@ async function addHeat(e) { 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.planned_start === '' ? null : formJson.planned_start, + session ) window.location.reload() } catch (error) { @@ -48,10 +52,13 @@ async function addHeat(e) { } } -export async function removeHeat(heatId) { +export async function removeHeat(heatId, session) { const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/removeHeat`, { method: 'POST', - headers: {'Content-Type': 'application/json'}, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.auth.token}`, + }, body: JSON.stringify({ "heat_id": heatId }), @@ -59,11 +66,11 @@ export async function removeHeat(heatId) { return await res.json() } -async function deleteHeat(e, heatId, heatName) { +async function deleteHeat(e, heatId, heatName, session) { e.preventDefault() if (window.confirm('Do you really want to delete heat "' + heatName + '"?')) { - const { data, error } = await removeHeat(heatId) + const { data, error } = await removeHeat(heatId, session) if (error === undefined) { window.location.reload() } else { @@ -108,7 +115,7 @@ function HeatForm({session}) { return ( <div> <button disabled={!loading}>{loading ? '↺ loading' : ''}</button> - <form method='post' onSubmit={addHeat}> + <form method='post' onSubmit={e => addHeat(e, session)}> <table> <thead> <tr> @@ -126,7 +133,7 @@ function HeatForm({session}) { <td data-title='Name'><Link to={generatePath('/startlist/:heatId', {heatId:h.id})}>{h.name}</Link></td> <td data-title='Location'>{h.location}</td> <td data-title='Planned start' className='right'>{h.planned_start}</td> - <td><button onClick={e => deleteHeat(e, h.id, h.name)}>&ndash; del</button></td> + <td><button onClick={e => deleteHeat(e, h.id, h.name, session)}>&ndash; del</button></td> </tr> ))} <tr className='input'> diff --git a/src/frontend/Leaderboard.jsx b/src/frontend/Leaderboard.jsx @@ -1,4 +1,4 @@ -import { exportLeaderboardToCSV, rankByHeat, getScores } from './utils' +import { exportLeaderboardToCSV, rankByHeat, formatScores } from './utils' import { Fragment, useEffect, useState, useRef } from 'react' import Select from 'react-select' import { addNewHeat } from './Heats' @@ -23,10 +23,13 @@ socket.onopen = function (event) { })) } -export async function addAthleteToHeat(athlete, heat) { +export async function addAthleteToHeat(athlete, heat, session) { const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/addAthleteToHeat`, { method: 'POST', - headers: {'Content-Type': 'application/json'}, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.auth.token}`, + }, body: JSON.stringify({ "athlete": athlete, "heat": heat, @@ -120,8 +123,8 @@ async function getScoreSummary(heatIds) { } // find best/worst heat, use 'n/a' if no score yet - i.bestHeat = i.heats.length > 0 ? Math.max(...i.heats.map(h => h.summary)) : 'n/a' - i.worstHeat = i.heats.length > 0 ? Math.min(...i.heats.map(h => h.summary)) : 'n/a' + i.bestHeat = i.heats.length > 0 ? Math.max(...i.heats.map(h => h.summary.toFixed(1))) : 'n/a' + i.worstHeat = i.heats.length > 0 ? Math.min(...i.heats.map(h => h.summary.toFixed(1))) : 'n/a' // sum up all totals across heats i.sum = i.heats.map(h => h.summary).reduce((a, b) => a + b, 0).toFixed(1) @@ -133,7 +136,7 @@ async function getScoreSummary(heatIds) { return startListWithScores } -async function newHeatFromLeaderboard(e, {leaderboard, rankingComp, selectHeatRef, selectRankRef}) { +async function newHeatFromLeaderboard(e, {leaderboard, rankingComp, selectHeatRef, selectRankRef, session}) { e.preventDefault() if (leaderboard.length === 0) { @@ -151,7 +154,8 @@ async function newHeatFromLeaderboard(e, {leaderboard, rankingComp, selectHeatRe heat = await addNewHeat( formJson.name, formJson.location, - formJson.planned_start === '' ? null : formJson.planned_start + formJson.planned_start === '' ? null : formJson.planned_start, + session ) } catch (error) { console.error(error) @@ -161,7 +165,7 @@ async function newHeatFromLeaderboard(e, {leaderboard, rankingComp, selectHeatRe for (let i = 0; i < formJson.size && i < sortedBoard.length; i++ ) { // add top N athletes from current leaderboard to new heat try { - await addAthleteToHeat(sortedBoard[i].athlete, heat.id) + await addAthleteToHeat(sortedBoard[i].athlete, heat.id, session) } catch (error) { console.error(error) } @@ -191,7 +195,7 @@ function ExportForm({leaderboard, heatSelection, rankingComp}) { ) } -function NewHeatForm(leaderboard, rankingComp, selectHeatRef, selectRankRef) { +function NewHeatForm(leaderboard, rankingComp, selectHeatRef, selectRankRef, session) { return ( <div className='newHeatForm'> <h2>New Heat from top N</h2> @@ -203,7 +207,8 @@ function NewHeatForm(leaderboard, rankingComp, selectHeatRef, selectRankRef) { leaderboard, rankingComp, selectHeatRef, - selectRankRef + selectRankRef, + session, )}> <table> <thead> @@ -398,7 +403,7 @@ function Leaderboard({session}) { {heatSelection.map(h => ( <Fragment key={h.value}> {/* list all scores from the judges seperated with '+' signs, show sum on right side */} - <td className='right' data-title={h.label}>{getScores(i, h)} = {i.heats.find(heat => heat.heatId === h.value)?.summary}</td> + <td className='right' data-title={h.label}>{formatScores(i, h)}</td> </Fragment> ))} <td className='right' data-title='Best'>{i.bestHeat}</td> @@ -418,6 +423,7 @@ function Leaderboard({session}) { rankingComp={rankingComp} selectHeatRef={selectHeatRef} selectRankRef={selectRankRef} + session={session} /> : ''} </div> ) diff --git a/src/frontend/Score.jsx b/src/frontend/Score.jsx @@ -6,14 +6,17 @@ import Select from 'react-select' const api_uri = import.meta.env.VITE_API_URI const api_port = import.meta.env.VITE_API_PORT -async function getScore(heatId, athleteId, userId) { +async function getScore(heatId, athleteId, session) { const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/getScore`, { method: 'POST', - headers: {'Content-Type': 'application/json'}, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.auth.token}`, + }, body: JSON.stringify({ "heat": heatId, "athlete": athleteId, - "judge": userId, + "judge": session.auth.id, }), }) const { data, error } = await res.json() @@ -23,15 +26,18 @@ async function getScore(heatId, athleteId, userId) { return data } -async function updateScore(score, heatId, athleteId, userId) { +async function updateScore(score, heatId, athleteId, session) { const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/setScore`, { method: 'POST', - headers: {'Content-Type': 'application/json'}, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.auth.token}`, + }, body: JSON.stringify({ "score": score, "heat": heatId, "athlete": athleteId, - "judge": userId, + "judge": session.auth.id, }), }) const { data, error } = await res.json() @@ -93,7 +99,7 @@ function ScoringForm({session}) { const currentScore = await getScore( heatSelection.value, athleteSelection.value, - session.auth.id + session ) if (score === 0) { // fallback to current score when no new scoring took place @@ -108,7 +114,7 @@ function ScoringForm({session}) { await updateScore(score, heatSelection.value, athleteSelection.value, - session.auth.id) + session) } catch (error) { console.error(error) } diff --git a/src/frontend/Startlist.jsx b/src/frontend/Startlist.jsx @@ -7,24 +7,27 @@ import Select from 'react-select' const api_uri = import.meta.env.VITE_API_URI const api_port = import.meta.env.VITE_API_PORT -async function addtoHeat(e, athlete, heatId) { +async function addtoHeat(e, athlete, heatId, session) { e.preventDefault() try { - await addAthleteToHeat(athlete.value, heatId) + await addAthleteToHeat(athlete.value, heatId, session) window.location.reload() } catch(error) { console.error(error) } } -async function removeAthleteFromHeat(e, startlistId, athleteFirstName, athleteLastName, heatName) { +async function removeAthleteFromHeat(e, startlistId, athleteFirstName, athleteLastName, heatName, session) { e.preventDefault() const athleteName = athleteFirstName + (athleteLastName ? ' ' + athleteLastName : '') if (window.confirm('Do you really want to remove athlete "' + athleteName + '" from heat "' + heatName + '"?')) { const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/removeAthleteFromHeat`, { method: 'POST', - headers: {'Content-Type': 'application/json'}, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.auth.token}`, + }, body: JSON.stringify({ "startlist_id": startlistId, }), @@ -37,10 +40,13 @@ async function removeAthleteFromHeat(e, startlistId, athleteFirstName, athleteLa } } -async function startlistWithAthletes(heatId) { +async function startlistWithAthletes(heatId, session) { const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/startlistWithAthletes`, { method: 'POST', - headers: {'Content-Type': 'application/json'}, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.auth.token}`, + }, body: JSON.stringify({ "heat_id": heatId, }), @@ -52,10 +58,13 @@ async function startlistWithAthletes(heatId) { return data } -async function getHeat(heatId) { +async function getHeat(heatId, session) { const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/getHeat`, { method: 'POST', - headers: {'Content-Type': 'application/json'}, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.auth.token}`, + }, body: JSON.stringify({ "heat_id": heatId, }), @@ -67,7 +76,7 @@ async function getHeat(heatId) { return data } -function StartlistForm({heatId}) { +function StartlistForm({heatId, session}) { const [heatName, setheatName] = useState("") const [heatLocation, setheatLocation] = useState("") const [heatStart, setheatStart] = useState("") @@ -82,10 +91,10 @@ function StartlistForm({heatId}) { setLoading(true) try { - const startlist = await startlistWithAthletes(heatId) + const startlist = await startlistWithAthletes(heatId, session) setStartlist(startlist) - const heat = await getHeat(heatId) + const heat = await getHeat(heatId, session) setheatName(heat.name) setheatLocation(heat.location) setheatStart(heat.planned_start) @@ -93,7 +102,12 @@ function StartlistForm({heatId}) { console.error(error) } - const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/allAthletes`) + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/allAthletes`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.auth.token}`, + }, + }) const { data, error } = await res.json() if (error) { console.error(error) @@ -144,7 +158,8 @@ function StartlistForm({heatId}) { i.startlist_id, i.firstname, i.lastname, - heatName + heatName, + session )}>&ndash; del</button></td> </tr> ))} @@ -156,7 +171,7 @@ function StartlistForm({heatId}) { /> </td> <td> - <button onClick={(e) => addtoHeat(e, selectedAthlete, heatId)}>&#43; add</button> + <button onClick={(e) => addtoHeat(e, selectedAthlete, heatId, session)}>&#43; add</button> </td> </tr> </tbody> @@ -170,7 +185,7 @@ function Startlist({session}) { return ( <div> - {!session.auth ? <Auth /> : <StartlistForm heatId={heatId} />} + {!session.auth ? <Auth /> : <StartlistForm heatId={heatId} session={session} />} </div> ) } diff --git a/src/frontend/utils.js b/src/frontend/utils.js @@ -42,15 +42,10 @@ export const exportLeaderboardToCSV = async function(e, leaderboard, heatSelecti // concatenate heat labels const heatNames = heatSelection.map(h => h.label) - // rank leaderboard by selected comparator + // rank leaderboard by selected comparator for each entry i in the board, + // for every heat h const heatSummaries = leaderboard.sort(rankByHeat(rankingComp)).map(i => - // for each entry i in the board, for every heat h - heatSelection.map(h => - // get individual scores of the heat - getScores(i, h) + " = " - // get heat score sum - + i.heats.find(heat => heat.heatId === h.value)?.summary - ) + heatSelection.map(h => formatScores(i, h)) ) // csv header @@ -102,8 +97,19 @@ export const rankByHeat = function(rankingComp) { // Scores concat with "+" for leaderboard entry i and heat h export const getScores = function(i, h) { - const scores = i.heats.find(heat => heat.heatId === h.value)?.scores?.map(s => s.score).join(" + ") - return scores === "" ? 0 : scores + const scores = i.heats.find(heat => heat.heatId === h.value)?.scores?.map(s => s.score.toFixed(1)).join(" + ") + return scores +} +// Returns formatted string with summed scores and summary for leaderboard +// entry i and heat h +export const formatScores = function(i, h) { + const scores = getScores(i, h) + if (scores) { + // get individual scores of the heat and score sum + return getScores(i, h) + " = " + i.heats.find(heat => heat.heatId === h.value)?.summary.toFixed(1) + } else { + return "n/a" + } } // export CSV as blob