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

db.cjs (11882B)


      1 const postgres = require('postgres');
      2 let scoreSubscription = null
      3 
      4 // Keep track of connected websocket clients
      5 // https://javascript.info/websocket
      6 const clients = new Set();
      7 
      8 require('dotenv').config({
      9   // Configure common config files and modes
     10   // - https://www.npmjs.com/package/dotenv
     11   // - https://vitejs.dev/guide/env-and-mode
     12   path: [
     13     '.env.local',
     14     '.env.development', '.env.development.local',
     15     '.env.test',        '.env.test.local',
     16     '.env.production',  '.env.production.local',
     17     '.env'
     18   ]
     19 });
     20 
     21 // Use psql environment variables
     22 // https://github.com/porsager/postgres?tab=readme-ov-file#environmental-variables
     23 sql = postgres({
     24   publications: [
     25     'myheats_realtime',
     26   ]
     27 })
     28 
     29 async function invalidateToken(token) {
     30   try {
     31     const users = await sql`
     32       update public.judges set
     33         token = null,
     34         expires_at = null
     35       where token = ${token}
     36     `
     37     return users
     38   } catch (error) {
     39     console.error('Error occurred in invalidateToken:', error);
     40     throw error
     41   }
     42 }
     43 
     44 async function lookupToken(token) {
     45   try {
     46     const users = await sql`
     47       select * from public.judges where
     48       token = ${token}
     49     `
     50     return users
     51   } catch (error) {
     52     console.error('Error occurred in lookupToken:', error);
     53     throw error
     54   }
     55 }
     56 
     57 async function saveToken(id, token, exp) {
     58   try {
     59     const users = await sql`
     60       update public.judges set
     61         token = ${token},
     62         expires_at = ${exp}
     63       where id = ${id}
     64     `
     65     return users
     66   } catch (error) {
     67     console.error('Error occurred in saveToken:', error);
     68     throw error
     69   }
     70 }
     71 
     72 // Request a signed magic link
     73 // https://clerk.com/blog/magic-links#building-a-magic-link-system
     74 async function getUser(email) {
     75   try {
     76     const users = await sql`
     77       select
     78         id,
     79         firstname,
     80         lastname,
     81         token,
     82         email
     83       from public.judges
     84       where email = ${email}
     85     `
     86     return users
     87   } catch (error) {
     88     console.error('Error occurred in getUser:', error);
     89     throw error
     90   }
     91 }
     92 
     93 async function allHeats(publicOnly) {
     94   try {
     95     const heats = await sql`
     96       select * from heats as h
     97       where (${publicOnly} = false or h.private = false)
     98     `
     99     return heats
    100   } catch (error) {
    101     console.error('Error occurred in allHeats:', error);
    102     throw error
    103   }
    104 }
    105 
    106 async function getHeat(heatId) {
    107   try {
    108     const heat = await sql`
    109       select * from heats
    110       where id = ${heatId}
    111     `
    112     return heat
    113   } catch (error) {
    114     console.error('Error occurred in getHeat:', error);
    115     throw error
    116   }
    117 }
    118 
    119 async function allAthletes() {
    120   try {
    121     const athletes = await sql`
    122       select * from athletes
    123     `
    124     return athletes
    125   } catch (error) {
    126     console.error('Error occurred in allAthletes:', error);
    127     throw error
    128   }
    129 }
    130 
    131 async function allJudges() {
    132   try {
    133     const athletes = await sql`
    134       select * from judges
    135     `
    136     return athletes
    137   } catch (error) {
    138     console.error('Error occurred in allJudges:', error);
    139     throw error
    140   }
    141 }
    142 
    143 async function addAthleteToHeat(athlete, heat) {
    144   try {
    145     const startlist = await sql`
    146       insert into startlist (
    147         athlete,
    148         heat
    149       )
    150       values (
    151         ${athlete},
    152         ${heat}
    153       )
    154       returning *
    155     `
    156     return startlist
    157   } catch (error) {
    158     console.error('Error occurred in addAthleteToHeat:', error);
    159     throw error
    160   }
    161 }
    162 
    163 async function addAthlete(nr, firstname, lastname, birthday, school) {
    164   try {
    165     const athlete = await sql`
    166       insert into athletes (
    167         nr,
    168         firstname,
    169         lastname,
    170         birthday,
    171         school
    172       )
    173       values (
    174         ${nr},
    175         ${firstname},
    176         ${lastname},
    177         ${birthday},
    178         ${school}
    179       )
    180       returning *
    181     `
    182     return athlete
    183   } catch (error) {
    184     console.error('Error occurred in addAthlete:', error);
    185     throw error
    186   }
    187 }
    188 
    189 async function removeAthlete(id) {
    190   try {
    191     const athlete = await sql`
    192       delete from athletes where id = ${id}
    193       returning *
    194     `
    195     return athlete
    196   } catch (error) {
    197     console.error('Error occurred in removeAthlete:', error);
    198     throw error
    199   }
    200 }
    201 
    202 async function addJudge(email, firstname, lastname) {
    203   try {
    204     const judge = await sql`
    205       insert into judges (
    206         email,
    207         firstname,
    208         lastname
    209       )
    210       values (
    211         ${email},
    212         ${firstname},
    213         ${lastname}
    214       )
    215       returning *
    216     `
    217     return judge
    218   } catch (error) {
    219     console.error('Error occurred in addJudge:', error);
    220     throw error
    221   }
    222 }
    223 
    224 async function removeJudge(id) {
    225   try {
    226     const judge = await sql`
    227       delete from judges where id = ${id}
    228       returning *
    229     `
    230     return judge
    231   } catch (error) {
    232     console.error('Error occurred in removeJudge:', error);
    233     throw error
    234   }
    235 }
    236 
    237 async function removeAthleteFromHeat(startlistId) {
    238   try {
    239     const startlist = await sql`
    240       delete from public.startlist
    241       where id = ${startlistId}
    242       returning *
    243     `
    244     return startlist
    245   } catch (error) {
    246     console.error('Error occurred in removeAthleteFromHeat:', error);
    247     throw error
    248   }
    249 }
    250 
    251 async function newHeat(name, heatLocation, plannedStart, privateHeat) {
    252   try {
    253     const heat = await sql`
    254       insert into heats (
    255         name,
    256         location,
    257         planned_start,
    258         private
    259       )
    260       values (
    261         ${name},
    262         ${heatLocation},
    263         ${plannedStart},
    264         ${privateHeat}
    265       )
    266       returning *
    267     `
    268     return heat
    269   } catch (error) {
    270     console.error('Error occurred in newHeat:', error);
    271     throw error
    272   }
    273 }
    274 
    275 async function removeHeat(heatId) {
    276   try {
    277     const heat = await sql`
    278       delete from public.heats where id = ${heatId}
    279       returning *
    280     `
    281     return heat
    282   } catch (error) {
    283     console.error('Error occurred in removeHeat:', error);
    284     throw error
    285   }
    286 }
    287 
    288 async function toggleHeatVisibility(heatId) {
    289   try {
    290     const heat = await sql`
    291       update public.heats set private = not private where id = ${heatId}
    292       returning *
    293     `
    294     return heat
    295   } catch (error) {
    296     console.error('Error occurred in toggleHeatVisibility:', error);
    297     throw error
    298   }
    299 }
    300 
    301 async function distinctStartlist(heatIds, publicOnly) {
    302   try {
    303     const startlist = await sql`
    304       select
    305         id,
    306         athlete,
    307         nr,
    308         firstname,
    309         lastname,
    310         birthday,
    311         school
    312       from distinct_startlist(${heatIds}, ${publicOnly}) as athlete
    313     `
    314     return startlist
    315   } catch (error) {
    316     console.error('Error occurred in distinctStartlist:', error);
    317     throw error
    318   }
    319 }
    320 
    321 async function startlistWithAthletes(heatId) {
    322   try {
    323     // left outer join, to fetch heats with no athletes
    324     // https://www.postgresql.org/docs/current/tutorial-join.html
    325     const startlist = await sql`
    326       select
    327         s.id as startlist_id,
    328         s.heat,
    329         s.athlete,
    330         a.id as athlete_id,
    331         a.nr,
    332         a.firstname,
    333         a.lastname,
    334         a.birthday,
    335         a.school
    336       from public.startlist as s
    337       left outer join athletes as a
    338       on s.athlete = a.id
    339       where s.heat = ${heatId}
    340     `
    341     return startlist
    342   } catch (error) {
    343     console.error('Error occurred in startlistWithAthletes:', error);
    344     throw error
    345   }
    346 }
    347 
    348 async function scoresForHeatAndAthlete(heat, athlete, publicOnly) {
    349   try {
    350     const score = await sql`
    351       select
    352         s.id,
    353         s.athlete,
    354         s.judge,
    355         s.score
    356       from public.scores as s
    357       join public.heats as h
    358       on s.heat = h.id
    359       where s.heat = ${heat} and s.athlete = ${athlete} and (${publicOnly} = false or h.private = false);
    360     `
    361     return score
    362   } catch (error) {
    363     console.error('Error occurred in scoresForHeatAndAthlete:', error);
    364     throw error
    365   }
    366 }
    367 
    368 async function scoreSummaryForHeatAndAthlete(heat, athlete, publicOnly) {
    369   try {
    370     const summary = await sql`
    371       select score_summary
    372       from score_summary(${publicOnly}) where heat_id = ${heat} and athlete_id = ${athlete}
    373     `
    374     return summary
    375   } catch (error) {
    376     console.error('Error occurred in scoreSummaryForHeatAndAthlete:', error);
    377     throw error
    378   }
    379 }
    380 
    381 async function getScore(heat, athlete, judge) {
    382   try {
    383     const scores = await sql`
    384       select * from public.scores
    385       where heat = ${heat} and athlete = ${athlete} and judge = ${judge}
    386     `
    387     return scores
    388   } catch (error) {
    389     console.error('Error occurred in getScore:', error);
    390     throw error
    391   }
    392 }
    393 
    394 // "upsert" score (https://www.postgresql.org/docs/current/sql-insert.html)
    395 // For ON CONFLICT DO UPDATE, a conflict_target must be provided.
    396 async function setScore(heat, athlete, judge, score) {
    397   try {
    398     const scores = await sql`
    399       insert into public.scores as s (athlete, judge, score, heat)
    400         values (${athlete}, ${judge}, ${score}, ${heat})
    401       on conflict (athlete, judge, heat) do update
    402       set score = ${score}
    403       where s.heat = ${heat} and s.athlete = ${athlete} and s.judge = ${judge}
    404       returning *
    405     `
    406     return scores
    407   } catch (error) {
    408     console.error('Error occurred in setScore:', error);
    409     throw error
    410   }
    411 }
    412 
    413 async function getSetting(name) {
    414   try {
    415     const setting = await sql`
    416       select * from settings
    417       where name = ${name}
    418     `
    419     return setting
    420   } catch (error) {
    421     console.error('Error occurred in getSetting:', error);
    422     throw error
    423   }
    424 }
    425 
    426 async function allSettings() {
    427   try {
    428     const settings = await sql`
    429       select * from settings
    430     `
    431     return settings
    432   } catch (error) {
    433     console.error('Error occurred in allSettings:', error);
    434     throw error
    435   }
    436 }
    437 
    438 async function removeSetting(name) {
    439   try {
    440     const setting = await sql`
    441       delete from settings where name = ${name}
    442       returning *
    443     `
    444     return setting
    445   } catch (error) {
    446     console.error('Error occurred in removeSetting:', error);
    447     throw error
    448   }
    449 }
    450 
    451 // "upsert" setting
    452 async function updateSetting(name, value) {
    453   try {
    454     const setting = await sql`
    455       insert into public.settings as s (name, value)
    456         values (${name}, ${value})
    457       on conflict (name) do update
    458       set value = ${value}
    459       where s.name = ${name}
    460       returning *
    461     `
    462     return setting
    463   } catch (error) {
    464     console.error('Error occurred in updateSetting:', error);
    465     throw error
    466   }
    467 }
    468 
    469 function removeClient(sock) {
    470   console.log("- Client removed")
    471   clients.delete(sock)
    472 }
    473 
    474 function addClient(sock) {
    475   console.log("+ Client added")
    476   clients.add(sock)
    477 }
    478 
    479 // add client to subscription
    480 async function watchScores(sock) {
    481   if (scoreSubscription !== null) {
    482     // we are already subscribed to this publication
    483     return scoreSubscription
    484   }
    485 
    486   // subscribe to score publication
    487   try {
    488     scoreSubscription = await sql.subscribe(
    489       '*:scores',
    490       (row, { command, relation, key, old }) => {
    491         // distributed score updates to all connected clients
    492         for(let c of clients) {
    493           c.send(JSON.stringify({
    494             "message": "Received new scores",
    495             "data": row,
    496           }))
    497         }
    498       },
    499       () => {
    500         // callback on initial connect and potential reconnects
    501         console.log("~ Start watching scores, created scoreSubscription")
    502       }
    503     )
    504     return scoreSubscription
    505   } catch (error) {
    506     console.error('Error occurred in watchScores:', error);
    507     throw error
    508   }
    509 }
    510 
    511 module.exports = {
    512   sql,
    513   invalidateToken,
    514   lookupToken,
    515   saveToken,
    516   getUser,
    517   allHeats,
    518   getHeat,
    519   allAthletes,
    520   allJudges,
    521   newHeat,
    522   removeHeat,
    523   toggleHeatVisibility,
    524   distinctStartlist,
    525   startlistWithAthletes,
    526   scoresForHeatAndAthlete,
    527   scoreSummaryForHeatAndAthlete,
    528   getScore,
    529   setScore,
    530   watchScores,
    531   removeClient,
    532   addClient,
    533   addAthleteToHeat,
    534   addAthlete,
    535   removeAthlete,
    536   addJudge,
    537   removeJudge,
    538   removeAthleteFromHeat,
    539   getSetting,
    540   allSettings,
    541   updateSetting,
    542   removeSetting,
    543 }