From bfd9633c637287dd6db3308d42897d1592f98441 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Sun, 19 Jul 2026 22:51:07 +0200 Subject: [PATCH] Update Todos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .claude/settings.local.json | 9 +- ARCHITECTURE_DECISIONS.md | 58 +++ FEATURE_BACKLOG.md | 29 +- app/blind_draw.py | 12 +- app/routers/matches.py | 119 +++--- app/routers/registration.py | 108 +++++- app/routers/scoring.py | 91 ++--- app/routers/tournaments.py | 68 ++-- frontend/app/t/[id]/live/page.tsx | 13 + frontend/components/public-live.tsx | 425 ++++++++++++++++++++++ frontend/components/public-tournament.tsx | 16 + 11 files changed, 812 insertions(+), 136 deletions(-) create mode 100644 frontend/app/t/[id]/live/page.tsx create mode 100644 frontend/components/public-live.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3330b47..ebd0cfe 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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 https://teecup.teeoff.no/ws/orgs/test/teams/test/messages)", "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": [ "/opt/teeoff/deploy", "/tmp", "/opt/teecup", - "/opt/teecup/frontend/app/tournaments/[id]/teams/[teamId]" + "/opt/teecup/frontend/app/tournaments/[id]/teams/[teamId]", + "/opt/teecup/frontend/app/t/[id]" ] } } diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index f07ec65..f111568 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -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å) Disse må avklares før eller under de relevante fasene: diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 1558a0a..87c4f79 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -297,9 +297,9 @@ ## Ønsket, men IKKE fanget før nå (fra Gemini-samtalene) -### Brukerroller (utover org-medlemskap) — ADR-023 -- **Status:** ✅ BYGGET OG LIVE 2026-07-19 — kaptein/deltaker-delen. - Tilskuer bevisst utsatt. +### Brukerroller (utover org-medlemskap) — ADR-023 + ADR-026 +- **Status:** ✅ HELT FERDIG 2026-07-19 — kaptein/deltaker-delen (ADR-023) + OG tilskuer-delen (ADR-026, se eget punkt lenger ned). - De opprinnelige samtalene beskriver: turneringsadmin, **lagkaptein**, spiller, **tilskuer** (les-only, følger live uten skriverettigheter). - 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å laget», og uavhengig av kapteinmerket. Erstatter «Scoring-autorisasjon»- punktet under, spørsmål (a) er dermed besvart. -- **Tilskuer bevisst utsatt** til Kommunikasjon/Banter Board-runden (samme - begrunnelse som før: bør defineres sammen med feed-synligheten, ikke - isolert). -- Se ARCHITECTURE_DECISIONS.md ADR-023 for alle fire delbeslutningene og - CLAUDE.md-status for scratch-verifiseringen (15 automatiserte sjekker). +- **Tilskuer — ✅ BYGGET OG LIVE 2026-07-19 (ADR-026),** rett etter + Kommunikasjon (ADR-025) som gjorde feed-synligheten (som denne skulle + defineres sammen med) klar. Ingen ny rolle/tabell — «tilskuer» er ganske + enkelt enhver som kan SE turneringen (`tournament.visibility`), nå også + 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) - **Status:** ❓ delvis avgjort — direkte oppfølger av «Brukerroller» over. diff --git a/app/blind_draw.py b/app/blind_draw.py index 1d9ba88..8b350b8 100644 --- a/app/blind_draw.py +++ b/app/blind_draw.py @@ -20,7 +20,9 @@ async def locked_team_ids(conn: Connection, session_id: str) -> set[str]: 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. 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 "skriv blindt" felle funnet under blind draw-skjermens integrering, ikke 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( """ SELECT DISTINCT tr.team_id::text AS team_id diff --git a/app/routers/matches.py b/app/routers/matches.py index 5136601..1041d35 100644 --- a/app/routers/matches.py +++ b/app/routers/matches.py @@ -128,64 +128,62 @@ async def create_match( return MatchOut(**dict(row), tee_time=tee_time, participants=[]) -@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), +async def fetch_matches( + conn, organization_id: str, session_id: str, user_id: str | None ) -> list[MatchOut]: - async with org_connection(organization_id) as conn: - session = await conn.fetchrow( - """ - SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes - FROM session WHERE id = $1 - """, - session_id, - ) - if session is None: - raise app_error(404, "NOT_FOUND", "Økten finnes ikke.") + """Delt mellom den innloggede list_matches under og den offentlige + /public/tournaments/{id}/sessions/{id}/matches (registration.py, + tilskuer-rolle/ADR-026). `user_id=None` for en anonym/ikke-tilknyttet + offentlig leser -- own_team_ids() håndterer det (tom mengde, ser kun + avslørte matcher, se blind_draw.py).""" + session = await conn.fetchrow( + """ + SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes + FROM session WHERE id = $1 + """, + session_id, + ) + if session is None: + raise app_error(404, "NOT_FOUND", "Økten finnes ikke.") - matches = await conn.fetch( - """ - SELECT id::text, sequence, team_a_id::text, team_b_id::text, - status_text, points_side_a::float AS points_side_a, - points_side_b::float AS points_side_b, leading_side, tee_time_override - FROM match - WHERE session_id = $1 - ORDER BY sequence - """, - session_id, - ) - match_ids = [m["id"] for m in matches] + matches = await conn.fetch( + """ + SELECT id::text, sequence, team_a_id::text, team_b_id::text, + status_text, points_side_a::float AS points_side_a, + points_side_b::float AS points_side_b, leading_side, tee_time_override + FROM match + WHERE session_id = $1 + ORDER BY sequence + """, + session_id, + ) + match_ids = [m["id"] for m in matches] - locked = await locked_team_ids(conn, session_id) - revealed = len(locked) >= 2 - # Tomt sett hvis revealed (ubrukt da $2=true gjør ANY-leddet irrelevant). - own = ( - set() - if revealed - else await own_team_ids(conn, organization_id, session["tournament_id"], user.user_id) - ) + locked = await locked_team_ids(conn, session_id) + revealed = len(locked) >= 2 + # Tomt sett hvis revealed (ubrukt da $2=true gjør ANY-leddet irrelevant). + own = ( + set() + if revealed + else await own_team_ids(conn, organization_id, session["tournament_id"], user_id) + ) - participant_rows = await conn.fetch( - """ - 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.tee_id::text, tee.name AS tee_name - FROM match_participant mp - JOIN team_roster tr ON tr.id = mp.team_roster_id - JOIN player p ON p.id = tr.player_id - JOIN tee ON tee.id = mp.tee_id - WHERE mp.match_id = ANY($1::uuid[]) - AND ($2 OR tr.team_id::text = ANY($3::text[])) - """, - match_ids, - revealed, - list(own), - ) + participant_rows = await conn.fetch( + """ + 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.tee_id::text, tee.name AS tee_name + FROM match_participant mp + JOIN team_roster tr ON tr.id = mp.team_roster_id + JOIN player p ON p.id = tr.player_id + JOIN tee ON tee.id = mp.tee_id + WHERE mp.match_id = ANY($1::uuid[]) + AND ($2 OR tr.team_id::text = ANY($3::text[])) + """, + match_ids, + revealed, + list(own), + ) by_match: dict[str, list[MatchParticipantOut]] = {mid: [] for mid in match_ids} 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): team_side: str = Field(pattern="^[ab]$") team_roster_id: str diff --git a/app/routers/registration.py b/app/routers/registration.py index 24cf666..7c54044 100644 --- a/app/routers/registration.py +++ b/app/routers/registration.py @@ -23,10 +23,13 @@ from fastapi import APIRouter, Depends from pydantic import BaseModel, EmailStr, Field from ..auth import CurrentUser, get_current_user_optional +from ..blind_draw import locked_team_ids from ..db import org_connection, plain_connection from ..errors import app_error, translate_db_errors 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"]) 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) +# --- 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): display_name: str = Field(min_length=1, max_length=200) email: EmailStr | None = None diff --git a/app/routers/scoring.py b/app/routers/scoring.py index 7bfa1cb..a2e2323 100644 --- a/app/routers/scoring.py +++ b/app/routers/scoring.py @@ -533,51 +533,48 @@ class Scorecard(BaseModel): _RESULT_LABEL = {HoleResult.SIDE_A: "a", HoleResult.SIDE_B: "b", HoleResult.HALVED: "halved"} -@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: - match = await conn.fetchrow( +async def fetch_scorecard(conn, match_id: str) -> Scorecard: + """Delt mellom den innloggede get_scorecard under og den offentlige + /public/tournaments/{id}/matches/{id}/scorecard (registration.py, + tilskuer-rolle/ADR-026). Ingen egen synlighetsfiltrering her -- den + offentlige ruten avgjør SELV, FØR denne kalles, om matchen i det hele + tatt er avslørt (blind draw, ADR-013) og om turneringen er synlig.""" + match = await conn.fetchrow( + """ + SELECT m.id::text, m.status_text, m.points_side_a::float AS points_side_a, + 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, - 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 + SELECT hole_number, team_side::text AS team_side, + match_participant_id::text AS match_participant_id, gross_strokes + FROM hole_score WHERE match_id = $1 ORDER BY hole_number """, 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 hole_number, team_side::text AS team_side, - 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] + 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"]) holes = [ @@ -592,3 +589,15 @@ async def get_scorecard( points_side_b=match["points_side_b"], 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) diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py index 901b8ee..89c39c6 100644 --- a/app/routers/tournaments.py +++ b/app/routers/tournaments.py @@ -1047,36 +1047,34 @@ class Leaderboard(BaseModel): sessions: list[SessionStanding] -@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: - teams = await conn.fetch( - "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å.") +async def fetch_leaderboard(conn, tournament_id: str) -> Leaderboard: + """Delt mellom den innloggede (get_leaderboard under) og den offentlige + /public/tournaments/{id}/leaderboard (registration.py, tilskuer-rolle) -- + ingen spiller-nivå-data i responsmodellene (kun lag-id/-navn/-farge/ + poeng), så ingen ekstra synlighetsfiltrering trengs utover den vanlige + tournament.visibility-sjekken den offentlige ruten selv gjør FØR denne + kalles.""" + teams = await conn.fetch( + "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å.") - rows = await conn.fetch( - """ - SELECT s.id::text AS session_id, s.sequence, s.name, - 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.points_side_a::float AS points_side_a, m.points_side_b::float AS points_side_b, - m.leading_side - FROM match m - JOIN session s ON s.id = m.session_id - WHERE s.tournament_id = $1 - ORDER BY s.sequence, m.sequence - """, - tournament_id, - ) + rows = await conn.fetch( + """ + SELECT s.id::text AS session_id, s.sequence, s.name, + 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.points_side_a::float AS points_side_a, m.points_side_b::float AS points_side_b, + m.leading_side + FROM match m + JOIN session s ON s.id = m.session_id + WHERE s.tournament_id = $1 + ORDER BY s.sequence, m.sequence + """, + tournament_id, + ) 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} @@ -1154,3 +1152,15 @@ async def get_leaderboard( matches_decided=matches_decided, 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) diff --git a/frontend/app/t/[id]/live/page.tsx b/frontend/app/t/[id]/live/page.tsx new file mode 100644 index 0000000..05b3662 --- /dev/null +++ b/frontend/app/t/[id]/live/page.tsx @@ -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 +} diff --git a/frontend/components/public-live.tsx b/frontend/components/public-live.tsx new file mode 100644 index 0000000..97c59c6 --- /dev/null +++ b/frontend/components/public-live.tsx @@ -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 + projected_points_by_team: Record + 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 = { + 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(null) + const [tournamentName, setTournamentName] = useState("") + const [leaderboard, setLeaderboard] = useState(null) + const [sessions, setSessions] = useState([]) + 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 ( +
+ + ) + } + + if (accessError) { + return ( +
+
+
+

{accessError}

+
+ ) + } + + const teamsById = new Map(leaderboard?.teams.map((t) => [t.team_id, t]) ?? []) + + return ( +
+
+
+ +
+
+ +
+
+ {leaderboard && leaderboard.teams.length === 2 && } + +
+

Matcher

+ {sessions.length === 0 && ( +

Ingen økter opprettet ennå.

+ )} + {sessions.map((s) => ( + + ))} +
+
+
+
+ ) +} + +// --- Leaderboard ------------------------------------------------------------- + +function LeaderboardCard({ leaderboard }: { leaderboard: Leaderboard }) { + const [a, b] = leaderboard.teams + return ( +
+
+
+ + +

+ {leaderboard.matches_decided} av {leaderboard.matches_total} matcher avgjort +

+
+ ) +} + +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 ( +
+ {label} +
+ {a.team_name} · {formatPoints(aValue)} + {formatPoints(bValue)} · {b.team_name} +
+
+
+
+
+
+ ) +} + +// --- Session/match-liste ----------------------------------------------------- + +function SessionRow({ + session, + tournamentId, + codeParam, + teamsById, +}: { + session: ApiSession + tournamentId: string + codeParam: string + teamsById: Map +}) { + const [open, setOpen] = useState(false) + const [matches, setMatches] = useState(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 ( +
+ + {open && ( +
+ {loadingMatches && ( +
+ + )} + {matches?.length === 0 &&

Ingen matcher opprettet ennå.

} + {matches?.map((m) => ( + + ))} +
+ )} +
+ ) +} + +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(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 ( +
+ + {showScorecard && ( +
+ {loadingScorecard ? ( +
+ + ) : scorecard && scorecard.holes.length > 0 ? ( +
    + {scorecard.holes.map((h) => { + const color = h.result === "a" ? teamA?.color : h.result === "b" ? teamB?.color : undefined + return ( +
  • + {h.hole_number} +
  • + ) + })} +
+ ) : ( +

Ingen hull registrert ennå.

+ )} +
+ )} +
+ ) +} diff --git a/frontend/components/public-tournament.tsx b/frontend/components/public-tournament.tsx index a18aa7c..08526bb 100644 --- a/frontend/components/public-tournament.tsx +++ b/frontend/components/public-tournament.tsx @@ -2,10 +2,12 @@ import type React from "react" import { useEffect, useState } from "react" +import Link from "next/link" import { Calendar, Users, ChevronDown, + ChevronRight, CheckCircle2, Clock3, Hourglass, @@ -14,6 +16,7 @@ import { MessageSquare, Send, ShieldAlert, + Trophy, X, } from "lucide-react" import { Button } from "@/components/ui/button" @@ -238,6 +241,19 @@ export function PublicTournament({ tournamentId, code }: { tournamentId: string; registrationOpen={info.registration_open} /> + +
+
+
+ Følg live -- stilling og matcher +
+