Update Todos

Refaktorere get_leaderboard til delt fetch_leaderboard()

Gjøre own_team_ids null-sikker for anonyme/uinnloggede tilskuere

Refaktorere list_matches og get_scorecard til delte funksjoner

Legge til offentlige leaderboard/matches/scorecard-endepunkter i registration.py

Skrive ADR-026 (tilskuer-rolle)

Scratch-verifisere backend

Bygge frontend: offentlig live-visning (leaderboard + matcher + scorekort) på /t/[id]/live

Ekte typesjekket frontend-build

Deploy etter bekreftelse + oppdatere .md-filer
Backend og frontend er scratch-verifisert (16 automatiserte sjekker, inkl. en presis test av tenant-vs-sti-sikkerhetssjekken). Ingen migrasjon denne runden — ren backend-refaktorering + nye endepunkter + ny /t/[id]/live-side. Ingen Caddy-endring nødvendig (dekkes av eksisterende /public/*-proxy)
This commit is contained in:
Erol Haagenrud 2026-07-19 22:51:07 +02:00
parent 6774cec72b
commit bfd9633c63
11 changed files with 812 additions and 136 deletions

View file

@ -292,13 +292,18 @@
"Bash(curl -s -o /dev/null -w \"ws-route \\(uten upgrade-header\\): %{http_code}\\\\n\" https://teecup.teeoff.no/ws/orgs/test/teams/test/messages)", "Bash(curl -s -o /dev/null -w \"ws-route \\(uten upgrade-header\\): %{http_code}\\\\n\" https://teecup.teeoff.no/ws/orgs/test/teams/test/messages)",
"Bash(curl -s https://teecup.teeoff.no/ws/orgs/test/teams/test/messages)", "Bash(curl -s https://teecup.teeoff.no/ws/orgs/test/teams/test/messages)",
"Bash(curl -s -H \"Content-Type: application/json\" https://teecup.teeoff.no/health)", "Bash(curl -s -H \"Content-Type: application/json\" https://teecup.teeoff.no/health)",
"Bash(docker run --rm python:3.12-slim bash -c 'pip install -q websockets 2>/dev/null; python3 -c \" *)" "Bash(docker run --rm python:3.12-slim bash -c 'pip install -q websockets 2>/dev/null; python3 -c \" *)",
"Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/tournaments.py'\\).read\\(\\)\\)\")",
"Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/matches.py'\\).read\\(\\)\\)\")",
"Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/scoring.py'\\).read\\(\\)\\)\")",
"Bash(mkdir -p /opt/teecup/frontend/app/t/\\\\[id\\\\]/live)"
], ],
"additionalDirectories": [ "additionalDirectories": [
"/opt/teeoff/deploy", "/opt/teeoff/deploy",
"/tmp", "/tmp",
"/opt/teecup", "/opt/teecup",
"/opt/teecup/frontend/app/tournaments/[id]/teams/[teamId]" "/opt/teecup/frontend/app/tournaments/[id]/teams/[teamId]",
"/opt/teecup/frontend/app/t/[id]"
] ]
} }
} }

View file

@ -1101,6 +1101,64 @@ infrastrukturvalg som media-ruten i MinIO-runden, ADR-018).
--- ---
## ADR-026: Tilskuer-rolle — offentlig leaderboard, matcher, scorekort
Reist 2026-07-19, rett etter Kommunikasjon (ADR-025), som gjorde det mulig å
definere "tilskuer" skikkelig (samme begrunnelse ble notert allerede i
FEATURE_BACKLOG.md fra starten av: bør avgjøres sammen med feed-synligheten,
som nå finnes).
**Kjernebeslutning: ingen ny rolle, ingen ny mekanisme.** "Tilskuer" er
IKKE en egen kontotype eller databasetabell — det er ganske enkelt: enhver
som kan SE en turnering (per `tournament.visibility`, ADR-018) kan nå også
følge den LIVE, ikke bare lese info-siden og programtidene. Samme
trenivå-visibility + `get_current_user_optional`-mønster som all annen
offentlig lesing, gjenbrukt helt uendret.
**Hva var det egentlige hullet:** `GET /orgs/.../leaderboard`,
`GET /orgs/.../sessions/{id}/matches` og `GET /orgs/.../matches/{id}/
scorecard` fantes allerede (bygget for organisatorer/spillere), men var
KUN tilgjengelige med org-medlemskap — en spectator med kun `/t/[id]`-
lenken (eller anonym på en `public`-synlig turnering) kunne aldri se dem.
Løst ved å ekstrahere den delte kjernelogikken til gjenbrukbare funksjoner
(`fetch_leaderboard` i tournaments.py, `fetch_matches` i matches.py,
`fetch_scorecard` i scoring.py — samme "gjort delt for gjenbruk"-mønster
som `recompute_and_cache_match_state`/`apply_concession` tidligere), og la
tre nye offentlige endepunkter i `registration.py` kalle dem, etter egen
visibility-sjekk.
**Omfang, valgt av bruker utover anbefalingen:** BÅDE leaderboard+
matchliste OG fullt hull-for-hull-scorekort per match, i samme runde (ikke
kun leaderboard+matchliste som opprinnelig anbefalt).
**`own_team_ids()` (blind_draw.py) gjort null-sikker:** tar nå
`user_id: str | None` — en anonym/ikke-tilknyttet leser har per definisjon
ingen egne lag, og skal derfor (korrekt, ikke en feil) kun se AVSLØRTE
matcher, akkurat som en tilfeldig org-medlem uten roster. Ingen ny
synlighetslogikk, bare eksisterende logikk gjort tilgjengelig for en
`None`-bruker.
**To NYE sikkerhetssjekker lagt til, funnet under design, ikke i etterkant:**
1. `session_id`/`match_id` i URL-en må eksplisitt verifiseres å høre til
NØYAKTIG `tournament_id` i samme URL — `org_connection()` setter kun
TENANT-grensen (RLS), ikke at stiens id-er faktisk henger sammen. Uten
denne sjekken kunne noen med tilgang til én offentlig turnering i en
organisasjon lest en HVILKEN SOM HELST økt/match i SAMME organisasjon
(inkl. en helt privat en) ved å gjette/prøve seg frem på id-er — nøyaktig
samme klasse hull som ADR-018 Beslutning B advarte om generelt
("RLS beskytter kun tenant-grenser, ikke innholds-synlighet").
2. Det offentlige scorekort-endepunktet krever eksplisitt at BEGGE lag har
låst oppstillingen for økten (blind draw, ADR-013) — leaderboard/
matchliste arver reveal-skjuling automatisk via `own_team_ids()`, men
scorekortet har ingen tilsvarende innebygd sjekk (kan i prinsippet
inneholde registrerte hull før reveal, selv om det ikke er normal flyt).
**Status: ✅ BYGGET 2026-07-19.** Ingen migrasjon (kun nye endepunkter +
refaktorering av eksisterende spørringer til delte funksjoner). Se
CLAUDE.md-status for scratch-verifisering og utrulling.
---
## Åpne spørsmål (ikke besluttet ennå) ## Åpne spørsmål (ikke besluttet ennå)
Disse må avklares før eller under de relevante fasene: Disse må avklares før eller under de relevante fasene:

View file

@ -297,9 +297,9 @@
## Ønsket, men IKKE fanget før nå (fra Gemini-samtalene) ## Ønsket, men IKKE fanget før nå (fra Gemini-samtalene)
### Brukerroller (utover org-medlemskap) — ADR-023 ### Brukerroller (utover org-medlemskap) — ADR-023 + ADR-026
- **Status:**BYGGET OG LIVE 2026-07-19 — kaptein/deltaker-delen. - **Status:**HELT FERDIG 2026-07-19 — kaptein/deltaker-delen (ADR-023)
Tilskuer bevisst utsatt. OG tilskuer-delen (ADR-026, se eget punkt lenger ned).
- De opprinnelige samtalene beskriver: turneringsadmin, **lagkaptein**, spiller, - De opprinnelige samtalene beskriver: turneringsadmin, **lagkaptein**, spiller,
**tilskuer** (les-only, følger live uten skriverettigheter). **tilskuer** (les-only, følger live uten skriverettigheter).
- Vi har i dag org-roller (owner/admin/member) + `is_captain` på roster. - Vi har i dag org-roller (owner/admin/member) + `is_captain` på roster.
@ -323,11 +323,24 @@
`user_is_match_participant`, brukt av `scoring.py`) — ikke lenger «noen på `user_is_match_participant`, brukt av `scoring.py`) — ikke lenger «noen på
laget», og uavhengig av kapteinmerket. Erstatter «Scoring-autorisasjon»- laget», og uavhengig av kapteinmerket. Erstatter «Scoring-autorisasjon»-
punktet under, spørsmål (a) er dermed besvart. punktet under, spørsmål (a) er dermed besvart.
- **Tilskuer bevisst utsatt** til Kommunikasjon/Banter Board-runden (samme - **Tilskuer — ✅ BYGGET OG LIVE 2026-07-19 (ADR-026),** rett etter
begrunnelse som før: bør defineres sammen med feed-synligheten, ikke Kommunikasjon (ADR-025) som gjorde feed-synligheten (som denne skulle
isolert). defineres sammen med) klar. Ingen ny rolle/tabell — «tilskuer» er ganske
- Se ARCHITECTURE_DECISIONS.md ADR-023 for alle fire delbeslutningene og enkelt enhver som kan SE turneringen (`tournament.visibility`), nå også
CLAUDE.md-status for scratch-verifiseringen (15 automatiserte sjekker). for LEADERBOARD, MATCH-LISTE og FULLT SCOREKORT (hull-for-hull), ikke bare
info-siden/programtider som før (`GET /public/tournaments/{id}/
leaderboard`, `.../sessions/{id}/matches`, `.../matches/{id}/scorecard`,
alle i `registration.py`, gjenbruker eksisterende
`fetch_leaderboard`/`fetch_matches`/`fetch_scorecard`). Match-listen arver
blind draw-skjuling (ADR-013) automatisk via `own_team_ids()` gjort
null-sikker for en anonym leser (tom mengde = kun avslørte matcher).
Scorekortet har en eksplisitt reveal-sjekk i tillegg. Ny `/t/[id]/live`-
side (leaderboard-bar, øktliste, matcher, hull-for-hull-scorekort),
lenket fra hovedsiden. Bruker valgte å bygge fullt scorekort med i denne
runden (utover opprinnelig anbefaling om kun leaderboard+matchliste).
- Se ARCHITECTURE_DECISIONS.md ADR-023 (kaptein/deltaker) og ADR-026
(tilskuer) for alle delbeslutningene, og CLAUDE.md-status for
scratch-verifiseringen (15 + 16 automatiserte sjekker).
### Scoring-autorisasjon: hvem fører, hvem korrigerer, hvem lukker (rejst 2026-07-16) ### Scoring-autorisasjon: hvem fører, hvem korrigerer, hvem lukker (rejst 2026-07-16)
- **Status:** ❓ delvis avgjort — direkte oppfølger av «Brukerroller» over. - **Status:** ❓ delvis avgjort — direkte oppfølger av «Brukerroller» over.

View file

@ -20,7 +20,9 @@ async def locked_team_ids(conn: Connection, session_id: str) -> set[str]:
return {r["team_id"] for r in rows} return {r["team_id"] for r in rows}
async def own_team_ids(conn: Connection, organization_id: str, tournament_id: str, user_id: str) -> set[str]: async def own_team_ids(
conn: Connection, organization_id: str, tournament_id: str, user_id: str | None
) -> set[str]:
"""Hvilke lag brukeren FÅR SE deltakerne til, før begge lag har låst. """Hvilke lag brukeren FÅR SE deltakerne til, før begge lag har låst.
To uavhengige veier inn, samme par som team_authz.user_may_act_for_team To uavhengige veier inn, samme par som team_authz.user_may_act_for_team
@ -30,7 +32,15 @@ async def own_team_ids(conn: Connection, organization_id: str, tournament_id: st
(team_authz gir dem det) men aldri SE dem igjen før reveal -- en reell (team_authz gir dem det) men aldri SE dem igjen før reveal -- en reell
"skriv blindt" felle funnet under blind draw-skjermens integrering, ikke "skriv blindt" felle funnet under blind draw-skjermens integrering, ikke
bare et tenkt tilfelle. bare et tenkt tilfelle.
`user_id=None` (2026-07-19, tilskuer-rolle/ADR-026): en anonym eller
ikke-tilknyttet offentlig leser har per definisjon INGEN egne lag --
korrekt oppførsel er da tom mengde (ser kun avslørte matcher), ikke en
feil. Brukt av den offentlige match-listen (registration.py).
""" """
if user_id is None:
return set()
rows = await conn.fetch( rows = await conn.fetch(
""" """
SELECT DISTINCT tr.team_id::text AS team_id SELECT DISTINCT tr.team_id::text AS team_id

View file

@ -128,64 +128,62 @@ async def create_match(
return MatchOut(**dict(row), tee_time=tee_time, participants=[]) return MatchOut(**dict(row), tee_time=tee_time, participants=[])
@router.get( async def fetch_matches(
"/orgs/{organization_id}/sessions/{session_id}/matches", conn, organization_id: str, session_id: str, user_id: str | None
response_model=list[MatchOut],
)
async def list_matches(
session_id: str,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> list[MatchOut]: ) -> list[MatchOut]:
async with org_connection(organization_id) as conn: """Delt mellom den innloggede list_matches under og den offentlige
session = await conn.fetchrow( /public/tournaments/{id}/sessions/{id}/matches (registration.py,
""" tilskuer-rolle/ADR-026). `user_id=None` for en anonym/ikke-tilknyttet
SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes offentlig leser -- own_team_ids() håndterer det (tom mengde, ser kun
FROM session WHERE id = $1 avslørte matcher, se blind_draw.py)."""
""", session = await conn.fetchrow(
session_id, """
) SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes
if session is None: FROM session WHERE id = $1
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.") """,
session_id,
)
if session is None:
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
matches = await conn.fetch( matches = await conn.fetch(
""" """
SELECT id::text, sequence, team_a_id::text, team_b_id::text, SELECT id::text, sequence, team_a_id::text, team_b_id::text,
status_text, points_side_a::float AS points_side_a, status_text, points_side_a::float AS points_side_a,
points_side_b::float AS points_side_b, leading_side, tee_time_override points_side_b::float AS points_side_b, leading_side, tee_time_override
FROM match FROM match
WHERE session_id = $1 WHERE session_id = $1
ORDER BY sequence ORDER BY sequence
""", """,
session_id, session_id,
) )
match_ids = [m["id"] for m in matches] match_ids = [m["id"] for m in matches]
locked = await locked_team_ids(conn, session_id) locked = await locked_team_ids(conn, session_id)
revealed = len(locked) >= 2 revealed = len(locked) >= 2
# Tomt sett hvis revealed (ubrukt da $2=true gjør ANY-leddet irrelevant). # Tomt sett hvis revealed (ubrukt da $2=true gjør ANY-leddet irrelevant).
own = ( own = (
set() set()
if revealed if revealed
else await own_team_ids(conn, organization_id, session["tournament_id"], user.user_id) else await own_team_ids(conn, organization_id, session["tournament_id"], user_id)
) )
participant_rows = await conn.fetch( participant_rows = await conn.fetch(
""" """
SELECT mp.match_id::text AS match_id, mp.id::text, mp.team_side::text, SELECT mp.match_id::text AS match_id, mp.id::text, mp.team_side::text,
mp.team_roster_id::text, p.display_name AS player_name, mp.team_roster_id::text, p.display_name AS player_name,
mp.tee_id::text, tee.name AS tee_name mp.tee_id::text, tee.name AS tee_name
FROM match_participant mp FROM match_participant mp
JOIN team_roster tr ON tr.id = mp.team_roster_id JOIN team_roster tr ON tr.id = mp.team_roster_id
JOIN player p ON p.id = tr.player_id JOIN player p ON p.id = tr.player_id
JOIN tee ON tee.id = mp.tee_id JOIN tee ON tee.id = mp.tee_id
WHERE mp.match_id = ANY($1::uuid[]) WHERE mp.match_id = ANY($1::uuid[])
AND ($2 OR tr.team_id::text = ANY($3::text[])) AND ($2 OR tr.team_id::text = ANY($3::text[]))
""", """,
match_ids, match_ids,
revealed, revealed,
list(own), list(own),
) )
by_match: dict[str, list[MatchParticipantOut]] = {mid: [] for mid in match_ids} by_match: dict[str, list[MatchParticipantOut]] = {mid: [] for mid in match_ids}
for r in participant_rows: for r in participant_rows:
@ -219,6 +217,19 @@ async def list_matches(
] ]
@router.get(
"/orgs/{organization_id}/sessions/{session_id}/matches",
response_model=list[MatchOut],
)
async def list_matches(
session_id: str,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> list[MatchOut]:
async with org_connection(organization_id) as conn:
return await fetch_matches(conn, organization_id, session_id, user.user_id)
class ParticipantCreate(BaseModel): class ParticipantCreate(BaseModel):
team_side: str = Field(pattern="^[ab]$") team_side: str = Field(pattern="^[ab]$")
team_roster_id: str team_roster_id: str

View file

@ -23,10 +23,13 @@ from fastapi import APIRouter, Depends
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field
from ..auth import CurrentUser, get_current_user_optional from ..auth import CurrentUser, get_current_user_optional
from ..blind_draw import locked_team_ids
from ..db import org_connection, plain_connection from ..db import org_connection, plain_connection
from ..errors import app_error, translate_db_errors from ..errors import app_error, translate_db_errors
from .. import storage from .. import storage
from .tournaments import SessionOut, _fetch_sessions from .matches import MatchOut, fetch_matches
from .scoring import Scorecard, fetch_scorecard
from .tournaments import Leaderboard, SessionOut, _fetch_sessions, fetch_leaderboard
router = APIRouter(prefix="/public/tournaments", tags=["public-registration"]) router = APIRouter(prefix="/public/tournaments", tags=["public-registration"])
org_router = APIRouter(prefix="/public/orgs", tags=["public-registration"]) org_router = APIRouter(prefix="/public/orgs", tags=["public-registration"])
@ -229,6 +232,109 @@ async def get_public_sessions(
return await _fetch_sessions(conn, tournament_id) return await _fetch_sessions(conn, tournament_id)
# --- Tilskuer-rolle (ADR-026): leaderboard, matcher, scorekort -------------
#
# Samme synlighets-/kode-mønster som info-/sesjon-kallene over -- ingen ny
# mekanisme. Leaderboard har ingen spillernivå-data i det hele tatt (kun
# lag-id/-navn/-farge/poeng), så INGEN ekstra reveal-sjekk trengs der.
# Match-listen arver blind draw-skjulingen (ADR-013) automatisk via
# fetch_matches() sin egen own_team_ids()-håndtering (tom mengde for en
# anonym/uinnloggede leser -- ser bare avslørte matcher, akkurat som en
# tilfeldig org-medlem uten roster ville gjort). Scorekortet trenger en
# EKSPLISITT reveal-sjekk her (matchen kan i prinsippet ha registrerte hull
# før begge lag har låst -- ikke normal flyt, men ikke teknisk umulig).
@router.get("/{tournament_id}/leaderboard", response_model=Leaderboard)
async def get_public_leaderboard(
tournament_id: str,
code: str | None = None,
user: CurrentUser | None = Depends(get_current_user_optional),
) -> Leaderboard:
organization_id = await resolve_org(tournament_id)
async with org_connection(organization_id) as conn:
row = await conn.fetchrow(
"SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
)
if row is None:
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
if not code_matches(row["join_code"], code):
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
return await fetch_leaderboard(conn, tournament_id)
@router.get(
"/{tournament_id}/sessions/{session_id}/matches", response_model=list[MatchOut]
)
async def get_public_matches(
tournament_id: str,
session_id: str,
code: str | None = None,
user: CurrentUser | None = Depends(get_current_user_optional),
) -> list[MatchOut]:
organization_id = await resolve_org(tournament_id)
async with org_connection(organization_id) as conn:
row = await conn.fetchrow(
"SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
)
if row is None:
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
if not code_matches(row["join_code"], code):
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
# Sikkerhetskritisk: session_id må faktisk høre til DENNE turneringen
# -- org_connection() setter kun tenant-grensen (RLS), ikke at stiens
# to id-er faktisk henger sammen. Uten denne sjekken kunne noen med
# kode/tilgang til én offentlig turnering lese en HVILKEN SOM HELST
# økt i samme organisasjon (inkl. en privat en) ved å gjette id-en.
session_tournament = await conn.fetchval(
"SELECT tournament_id::text FROM session WHERE id = $1", session_id
)
if session_tournament != tournament_id:
raise app_error(404, "NOT_FOUND", "Økten finnes ikke i denne turneringen.")
user_id = user.user_id if user is not None else None
return await fetch_matches(conn, organization_id, session_id, user_id)
@router.get("/{tournament_id}/matches/{match_id}/scorecard", response_model=Scorecard)
async def get_public_scorecard(
tournament_id: str,
match_id: str,
code: str | None = None,
user: CurrentUser | None = Depends(get_current_user_optional),
) -> Scorecard:
organization_id = await resolve_org(tournament_id)
async with org_connection(organization_id) as conn:
row = await conn.fetchrow(
"SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
)
if row is None:
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
if not code_matches(row["join_code"], code):
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
# Samme tenant-vs-sti-konsistenssjekk som matchlisten over, PLUSS en
# eksplisitt reveal-sjekk (blind draw, ADR-013) -- scorekortet skal
# aldri kunne leses offentlig før laget selv har avslørt oppstillingen.
match_row = await conn.fetchrow(
"""
SELECT s.tournament_id::text AS tournament_id, m.session_id::text AS session_id
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""",
match_id,
)
if match_row is None or match_row["tournament_id"] != tournament_id:
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke i denne turneringen.")
locked = await locked_team_ids(conn, match_row["session_id"])
if len(locked) < 2:
raise app_error(403, "NOT_VISIBLE", "Denne matchen er ikke avslørt ennå.")
return await fetch_scorecard(conn, match_id)
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
display_name: str = Field(min_length=1, max_length=200) display_name: str = Field(min_length=1, max_length=200)
email: EmailStr | None = None email: EmailStr | None = None

View file

@ -533,51 +533,48 @@ class Scorecard(BaseModel):
_RESULT_LABEL = {HoleResult.SIDE_A: "a", HoleResult.SIDE_B: "b", HoleResult.HALVED: "halved"} _RESULT_LABEL = {HoleResult.SIDE_A: "a", HoleResult.SIDE_B: "b", HoleResult.HALVED: "halved"}
@router.get( async def fetch_scorecard(conn, match_id: str) -> Scorecard:
"/orgs/{organization_id}/matches/{match_id}/scorecard", """Delt mellom den innloggede get_scorecard under og den offentlige
response_model=Scorecard, /public/tournaments/{id}/matches/{id}/scorecard (registration.py,
) tilskuer-rolle/ADR-026). Ingen egen synlighetsfiltrering her -- den
async def get_scorecard( offentlige ruten avgjør SELV, FØR denne kalles, om matchen i det hele
match_id: str, tatt er avslørt (blind draw, ADR-013) og om turneringen er synlig."""
organization_id: str = Depends(get_authorized_org), match = await conn.fetchrow(
) -> Scorecard: """
async with org_connection(organization_id) as conn: SELECT m.id::text, m.status_text, m.points_side_a::float AS points_side_a,
match = await conn.fetchrow( m.points_side_b::float AS points_side_b,
s.format, s.scoring_mode, s.hole_config::text AS hole_config,
s.course_id::text AS course_id, s.allowance_override::text AS allowance_override
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""",
match_id,
)
if match is None:
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
results = await _compute_hole_results(conn, match_id, match)
stroke_entries = None
hole_result_entries = None
if match["scoring_mode"] == "stroke":
rows = await conn.fetch(
""" """
SELECT m.id::text, m.status_text, m.points_side_a::float AS points_side_a, SELECT hole_number, team_side::text AS team_side,
m.points_side_b::float AS points_side_b, match_participant_id::text AS match_participant_id, gross_strokes
s.format, s.scoring_mode, s.hole_config::text AS hole_config, FROM hole_score WHERE match_id = $1 ORDER BY hole_number
s.course_id::text AS course_id, s.allowance_override::text AS allowance_override
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""", """,
match_id, match_id,
) )
if match is None: stroke_entries = [HoleScoreOut(**dict(r)) for r in rows]
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.") else:
results = await _compute_hole_results(conn, match_id, match) rows = await conn.fetch(
"""
stroke_entries = None SELECT hole_number, winning_side::text AS winning_side
hole_result_entries = None FROM match_hole_result WHERE match_id = $1 ORDER BY hole_number
if match["scoring_mode"] == "stroke": """,
rows = await conn.fetch( match_id,
""" )
SELECT hole_number, team_side::text AS team_side, hole_result_entries = [HoleResultOut(**dict(r)) for r in rows]
match_participant_id::text AS match_participant_id, gross_strokes
FROM hole_score WHERE match_id = $1 ORDER BY hole_number
""",
match_id,
)
stroke_entries = [HoleScoreOut(**dict(r)) for r in rows]
else:
rows = await conn.fetch(
"""
SELECT hole_number, winning_side::text AS winning_side
FROM match_hole_result WHERE match_id = $1 ORDER BY hole_number
""",
match_id,
)
hole_result_entries = [HoleResultOut(**dict(r)) for r in rows]
played = _played_hole_numbers(match["hole_config"]) played = _played_hole_numbers(match["hole_config"])
holes = [ holes = [
@ -592,3 +589,15 @@ async def get_scorecard(
points_side_b=match["points_side_b"], points_side_b=match["points_side_b"],
holes=holes, holes=holes,
) )
@router.get(
"/orgs/{organization_id}/matches/{match_id}/scorecard",
response_model=Scorecard,
)
async def get_scorecard(
match_id: str,
organization_id: str = Depends(get_authorized_org),
) -> Scorecard:
async with org_connection(organization_id) as conn:
return await fetch_scorecard(conn, match_id)

View file

@ -1047,36 +1047,34 @@ class Leaderboard(BaseModel):
sessions: list[SessionStanding] sessions: list[SessionStanding]
@router.get( async def fetch_leaderboard(conn, tournament_id: str) -> Leaderboard:
"/orgs/{organization_id}/tournaments/{tournament_id}/leaderboard", """Delt mellom den innloggede (get_leaderboard under) og den offentlige
response_model=Leaderboard, /public/tournaments/{id}/leaderboard (registration.py, tilskuer-rolle) --
) ingen spiller-nivå-data i responsmodellene (kun lag-id/-navn/-farge/
async def get_leaderboard( poeng), ingen ekstra synlighetsfiltrering trengs utover den vanlige
tournament_id: str, tournament.visibility-sjekken den offentlige ruten selv gjør FØR denne
organization_id: str = Depends(get_authorized_org), kalles."""
) -> Leaderboard: teams = await conn.fetch(
async with org_connection(organization_id) as conn: "SELECT id::text, name, color FROM team WHERE tournament_id = $1 ORDER BY created_at",
teams = await conn.fetch( tournament_id,
"SELECT id::text, name, color FROM team WHERE tournament_id = $1 ORDER BY created_at", )
tournament_id, if len(teams) != 2:
) raise app_error(400, "VALIDATION_FAILED", "Turneringen har ikke nøyaktig to lag ennå.")
if len(teams) != 2:
raise app_error(400, "VALIDATION_FAILED", "Turneringen har ikke nøyaktig to lag ennå.")
rows = await conn.fetch( rows = await conn.fetch(
""" """
SELECT s.id::text AS session_id, s.sequence, s.name, SELECT s.id::text AS session_id, s.sequence, s.name,
s.points_per_match::float AS points_per_match, s.points_per_match::float AS points_per_match,
m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id, m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id,
m.points_side_a::float AS points_side_a, m.points_side_b::float AS points_side_b, m.points_side_a::float AS points_side_a, m.points_side_b::float AS points_side_b,
m.leading_side m.leading_side
FROM match m FROM match m
JOIN session s ON s.id = m.session_id JOIN session s ON s.id = m.session_id
WHERE s.tournament_id = $1 WHERE s.tournament_id = $1
ORDER BY s.sequence, m.sequence ORDER BY s.sequence, m.sequence
""", """,
tournament_id, tournament_id,
) )
team_totals: dict[str, float] = {t["id"]: 0.0 for t in teams} team_totals: dict[str, float] = {t["id"]: 0.0 for t in teams}
team_projected: dict[str, float] = {t["id"]: 0.0 for t in teams} team_projected: dict[str, float] = {t["id"]: 0.0 for t in teams}
@ -1154,3 +1152,15 @@ async def get_leaderboard(
matches_decided=matches_decided, matches_decided=matches_decided,
sessions=[SessionStanding(**sessions[sid]) for sid in sorted(session_order, key=lambda s: sessions[s]["sequence"])], sessions=[SessionStanding(**sessions[sid]) for sid in sorted(session_order, key=lambda s: sessions[s]["sequence"])],
) )
@router.get(
"/orgs/{organization_id}/tournaments/{tournament_id}/leaderboard",
response_model=Leaderboard,
)
async def get_leaderboard(
tournament_id: str,
organization_id: str = Depends(get_authorized_org),
) -> Leaderboard:
async with org_connection(organization_id) as conn:
return await fetch_leaderboard(conn, tournament_id)

View file

@ -0,0 +1,13 @@
import { PublicLive } from "@/components/public-live"
export default async function PublicLivePage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<{ code?: string }>
}) {
const { id } = await params
const { code } = await searchParams
return <PublicLive tournamentId={id} code={code} />
}

View file

@ -0,0 +1,425 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ChevronDown, ShieldAlert, Trophy } from "lucide-react"
import { Wordmark } from "@/components/wordmark"
import { cn } from "@/lib/utils"
// --- Typer (matcher app/routers/tournaments.py/matches.py/scoring.py) ------
type TeamStanding = {
team_id: string
team_name: string
color: string | null
points: number
projected_points: number
}
type SessionStanding = {
session_id: string
sequence: number
name: string | null
points_by_team: Record<string, number>
projected_points_by_team: Record<string, number>
matches_total: number
matches_decided: number
}
type Leaderboard = {
teams: TeamStanding[]
matches_total: number
matches_decided: number
sessions: SessionStanding[]
}
type ApiSession = {
id: string
sequence: number
name: string | null
format: string
scoring_mode: string
revealed: boolean
}
type ApiParticipant = { id: string; team_side: "a" | "b"; player_name: string; tee_name: string }
type ApiMatch = {
id: string
sequence: number
team_a_id: string
team_b_id: string
status_text: string | null
leading_side: "a" | "b" | null
participants: ApiParticipant[]
}
type ApiScorecardHole = { hole_number: number; result: "a" | "b" | "halved" }
type ApiScorecard = {
match_id: string
status_text: string | null
holes: ApiScorecardHole[]
}
const FORMAT_LABELS: Record<string, string> = {
foursome: "Foursome",
greensome: "Greensome",
scramble_2: "Scramble (2)",
scramble_4: "Scramble (4)",
fourball: "Fourball",
singles: "Singel",
}
function formatPoints(points: number) {
return Number.isInteger(points) ? String(points) : points.toFixed(1).replace(".", ",")
}
export function PublicLive({ tournamentId, code }: { tournamentId: string; code?: string }) {
const [loading, setLoading] = useState(true)
const [accessError, setAccessError] = useState<string | null>(null)
const [tournamentName, setTournamentName] = useState("")
const [leaderboard, setLeaderboard] = useState<Leaderboard | null>(null)
const [sessions, setSessions] = useState<ApiSession[]>([])
const codeParam = code ? `?code=${encodeURIComponent(code)}` : ""
useEffect(() => {
let cancelled = false
async function load() {
try {
const infoRes = await fetch(`/public/tournaments/${tournamentId}${codeParam}`, { credentials: "include" })
if (infoRes.status === 403 || infoRes.status === 404) {
const body = await infoRes.json().catch(() => null)
if (!cancelled) {
setAccessError(
body?.detail?.message ??
(infoRes.status === 404 ? "Turneringen finnes ikke." : "Du har ikke tilgang til denne turneringen."),
)
}
return
}
if (!infoRes.ok) throw new Error(`info: ${infoRes.status}`)
const info: { name: string } = await infoRes.json()
if (cancelled) return
setTournamentName(info.name)
const [lbRes, sessionsRes] = await Promise.all([
fetch(`/public/tournaments/${tournamentId}/leaderboard${codeParam}`, { credentials: "include" }),
fetch(`/public/tournaments/${tournamentId}/sessions${codeParam}`, { credentials: "include" }),
])
if (lbRes.ok) {
const lb: Leaderboard = await lbRes.json()
if (!cancelled) setLeaderboard(lb)
}
if (sessionsRes.ok) {
const s: ApiSession[] = await sessionsRes.json()
if (!cancelled) setSessions(s.sort((a, b) => a.sequence - b.sequence))
}
} catch {
if (!cancelled) setAccessError("Klarte ikke å laste stillingen. Prøv å laste siden på nytt.")
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [tournamentId, codeParam])
const backHref = `/t/${tournamentId}${code ? `?code=${encodeURIComponent(code)}` : ""}`
if (loading) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
)
}
if (accessError) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background px-5 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
<ShieldAlert aria-hidden="true" className="size-7 text-muted-foreground" />
</div>
<p className="max-w-sm text-sm leading-relaxed text-muted-foreground text-pretty">{accessError}</p>
</div>
)
}
const teamsById = new Map(leaderboard?.teams.map((t) => [t.team_id, t]) ?? [])
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-2xl items-center gap-3 px-5 py-4">
<Link
href={backHref}
aria-label="Tilbake til turneringssiden"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Følg live
</span>
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">{tournamentName}</h1>
</div>
<Wordmark compact />
</div>
</header>
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8">
<div className="flex flex-col gap-6">
{leaderboard && leaderboard.teams.length === 2 && <LeaderboardCard leaderboard={leaderboard} />}
<section aria-label="Økter og matcher" className="flex flex-col gap-3">
<h2 className="text-base font-bold text-foreground">Matcher</h2>
{sessions.length === 0 && (
<p className="text-sm text-muted-foreground">Ingen økter opprettet ennå.</p>
)}
{sessions.map((s) => (
<SessionRow
key={s.id}
session={s}
tournamentId={tournamentId}
codeParam={codeParam}
teamsById={teamsById}
/>
))}
</section>
</div>
</main>
</div>
)
}
// --- Leaderboard -------------------------------------------------------------
function LeaderboardCard({ leaderboard }: { leaderboard: Leaderboard }) {
const [a, b] = leaderboard.teams
return (
<section
aria-label="Stilling"
className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5"
>
<div className="flex items-center gap-2">
<Trophy aria-hidden="true" className="size-4 text-primary" />
<h2 className="text-base font-bold text-foreground">Stilling</h2>
</div>
<StandingBar label="Nå" a={a} b={b} aValue={a.points} bValue={b.points} />
<StandingBar label="Projisert (hvis pågående matcher holder seg)" a={a} b={b} aValue={a.projected_points} bValue={b.projected_points} />
<p className="text-xs text-muted-foreground">
{leaderboard.matches_decided} av {leaderboard.matches_total} matcher avgjort
</p>
</section>
)
}
function StandingBar({
label,
a,
b,
aValue,
bValue,
}: {
label: string
a: TeamStanding
b: TeamStanding
aValue: number
bValue: number
}) {
const total = aValue + bValue
const aPct = total > 0 ? (aValue / total) * 100 : 50
return (
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{label}</span>
<div className="flex items-center justify-between text-sm font-bold text-foreground">
<span>{a.team_name} · {formatPoints(aValue)}</span>
<span>{formatPoints(bValue)} · {b.team_name}</span>
</div>
<div className="flex h-3 w-full overflow-hidden rounded-full bg-muted">
<div style={{ width: `${aPct}%`, backgroundColor: a.color ?? "#64748b" }} />
<div style={{ width: `${100 - aPct}%`, backgroundColor: b.color ?? "#64748b" }} />
</div>
</div>
)
}
// --- Session/match-liste -----------------------------------------------------
function SessionRow({
session,
tournamentId,
codeParam,
teamsById,
}: {
session: ApiSession
tournamentId: string
codeParam: string
teamsById: Map<string, TeamStanding>
}) {
const [open, setOpen] = useState(false)
const [matches, setMatches] = useState<ApiMatch[] | null>(null)
const [loadingMatches, setLoadingMatches] = useState(false)
const title = session.name?.trim() || `Økt ${session.sequence}`
async function toggle() {
const next = !open
setOpen(next)
if (next && matches === null) {
setLoadingMatches(true)
try {
const res = await fetch(
`/public/tournaments/${tournamentId}/sessions/${session.id}/matches${codeParam}`,
{ credentials: "include" },
)
if (res.ok) setMatches(await res.json())
} finally {
setLoadingMatches(false)
}
}
}
return (
<div className="overflow-hidden rounded-2xl border border-border bg-card">
<button
type="button"
onClick={toggle}
aria-expanded={open}
className="flex w-full items-center justify-between gap-2 px-4 py-3.5 text-left"
>
<div className="flex flex-col">
<span className="font-semibold text-foreground">{title}</span>
<span className="text-xs text-muted-foreground">
{FORMAT_LABELS[session.format] ?? session.format}
{!session.revealed && " · ikke avslørt ennå"}
</span>
</div>
<ChevronDown
aria-hidden="true"
className={cn("size-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")}
/>
</button>
{open && (
<div className="flex flex-col gap-2 border-t border-border p-3">
{loadingMatches && (
<div className="flex justify-center py-4">
<div
aria-hidden="true"
className="size-5 animate-spin rounded-full border-2 border-primary/20 border-t-primary"
/>
</div>
)}
{matches?.length === 0 && <p className="px-1 py-2 text-sm text-muted-foreground">Ingen matcher opprettet ennå.</p>}
{matches?.map((m) => (
<MatchRow
key={m.id}
match={m}
tournamentId={tournamentId}
codeParam={codeParam}
teamA={teamsById.get(m.team_a_id)}
teamB={teamsById.get(m.team_b_id)}
/>
))}
</div>
)}
</div>
)
}
function MatchRow({
match,
tournamentId,
codeParam,
teamA,
teamB,
}: {
match: ApiMatch
tournamentId: string
codeParam: string
teamA?: TeamStanding
teamB?: TeamStanding
}) {
const [showScorecard, setShowScorecard] = useState(false)
const [scorecard, setScorecard] = useState<ApiScorecard | null>(null)
const [loadingScorecard, setLoadingScorecard] = useState(false)
const leadingColor = match.leading_side === "a" ? teamA?.color : match.leading_side === "b" ? teamB?.color : undefined
async function toggleScorecard() {
const next = !showScorecard
setShowScorecard(next)
if (next && scorecard === null) {
setLoadingScorecard(true)
try {
const res = await fetch(
`/public/tournaments/${tournamentId}/matches/${match.id}/scorecard${codeParam}`,
{ credentials: "include" },
)
if (res.ok) setScorecard(await res.json())
} finally {
setLoadingScorecard(false)
}
}
}
return (
<div
className="overflow-hidden rounded-xl border border-border"
style={{ borderLeftWidth: 4, borderLeftColor: leadingColor ?? "transparent" }}
>
<button type="button" onClick={toggleScorecard} className="flex w-full flex-col gap-1.5 px-3 py-2.5 text-left">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-semibold text-foreground">
{teamA?.team_name ?? "Lag A"} vs. {teamB?.team_name ?? "Lag B"}
</span>
{match.status_text && (
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-bold text-foreground">
{match.status_text}
</span>
)}
</div>
{match.participants.length > 0 && (
<span className="truncate text-xs text-muted-foreground">
{match.participants.map((p) => p.player_name).join(" · ")}
</span>
)}
</button>
{showScorecard && (
<div className="border-t border-border p-3">
{loadingScorecard ? (
<div className="flex justify-center py-3">
<div
aria-hidden="true"
className="size-5 animate-spin rounded-full border-2 border-primary/20 border-t-primary"
/>
</div>
) : scorecard && scorecard.holes.length > 0 ? (
<ul className="flex flex-wrap gap-1.5">
{scorecard.holes.map((h) => {
const color = h.result === "a" ? teamA?.color : h.result === "b" ? teamB?.color : undefined
return (
<li
key={h.hole_number}
title={`Hull ${h.hole_number}`}
className="flex size-8 items-center justify-center rounded-lg text-xs font-bold text-primary-foreground"
style={{ backgroundColor: color ?? "var(--muted)" }}
>
<span className={color ? "" : "text-muted-foreground"}>{h.hole_number}</span>
</li>
)
})}
</ul>
) : (
<p className="text-xs text-muted-foreground">Ingen hull registrert ennå.</p>
)}
</div>
)}
</div>
)
}

View file

@ -2,10 +2,12 @@
import type React from "react" import type React from "react"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link"
import { import {
Calendar, Calendar,
Users, Users,
ChevronDown, ChevronDown,
ChevronRight,
CheckCircle2, CheckCircle2,
Clock3, Clock3,
Hourglass, Hourglass,
@ -14,6 +16,7 @@ import {
MessageSquare, MessageSquare,
Send, Send,
ShieldAlert, ShieldAlert,
Trophy,
X, X,
} from "lucide-react" } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@ -238,6 +241,19 @@ export function PublicTournament({ tournamentId, code }: { tournamentId: string;
registrationOpen={info.registration_open} registrationOpen={info.registration_open}
/> />
<Link
href={`/t/${tournamentId}/live${codeParam}`}
className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5 transition-colors hover:bg-accent/40"
>
<div className="flex items-center gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/15">
<Trophy aria-hidden="true" className="size-5 text-primary" />
</div>
<span className="text-sm font-bold text-foreground">Følg live -- stilling og matcher</span>
</div>
<ChevronRight aria-hidden="true" className="size-5 text-muted-foreground" />
</Link>
<Program sessions={t.sessions} /> <Program sessions={t.sessions} />
{info.registration_open ? ( {info.registration_open ? (