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:
parent
6774cec72b
commit
bfd9633c63
11 changed files with 812 additions and 136 deletions
|
|
@ -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]"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -128,16 +128,14 @@ 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:
|
||||
"""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
|
||||
|
|
@ -167,7 +165,7 @@ async def list_matches(
|
|||
own = (
|
||||
set()
|
||||
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(
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -533,15 +533,12 @@ 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:
|
||||
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,
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1047,15 +1047,13 @@ 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:
|
||||
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,
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
13
frontend/app/t/[id]/live/page.tsx
Normal file
13
frontend/app/t/[id]/live/page.tsx
Normal 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} />
|
||||
}
|
||||
425
frontend/components/public-live.tsx
Normal file
425
frontend/components/public-live.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
||||
<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} />
|
||||
|
||||
{info.registration_open ? (
|
||||
|
|
|
|||
Loading…
Reference in a new issue