diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c4866dc..d83eaff 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -272,7 +272,11 @@ "Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/patch_cookies.txt)", "Bash(awk 'NR==499,/^---$/{print NR\": \"$0}' /opt/teecup/FEATURE_BACKLOG.md)", "Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/v0-zip-9)", - "Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/ui_cookies.txt)" + "Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/ui_cookies.txt)", + "Bash(grep -v '^$')", + "Bash(grep -rl \"POSTGRES_PASSWORD\" /opt/teeoff --include='*.yml' --include='*.env*' 2>/dev/null)", + "Bash(grep -n \"^ [a-zA-Z0-9_-]*:$\" /opt/teeoff/docker-compose.prod.yml)", + "Bash(set -e)" ], "additionalDirectories": [ "/opt/teeoff/deploy", diff --git a/011_join_code_and_leading_side.sql b/011_join_code_and_leading_side.sql new file mode 100644 index 0000000..414ce24 --- /dev/null +++ b/011_join_code_and_leading_side.sql @@ -0,0 +1,75 @@ +-- Migrasjon 011: invitasjonskode (oppdagelse) + cachet matchledelse (ADR-020). +-- +-- To uavhengige, additive endringer: +-- 1. tournament.join_code -- kort, menneske-skrivbar kode som løses til +-- riktig turnering FØR org-kontekst er kjent (samme "løs kontekst FØR +-- RLS kan håndheve noe"-problem som public_tournament_org, 007). +-- Bevisst OVERSTYRER visibility ved bruk (se ADR-020 Beslutning A) -- +-- dette er ikke noe skjemaet håndhever, kun API-laget. +-- 2. match.leading_side -- cachet fortegn av MatchState.lead (ADR-020 +-- Beslutning C), samme mønster som status_text/points_side_a/b. + +-- --------------------------------------------------------------------- +-- 1. tournament.join_code +-- --------------------------------------------------------------------- + +ALTER TABLE tournament ADD COLUMN join_code text; + +-- Backfyll eksisterende rader (typisk 1-2 i prod på dette tidspunktet) med +-- en tilfeldig 6-tegns kode fra et alfabet uten forvekslingsbare tegn +-- (0/O, 1/I droppet). Kollisjonssjekk mot allerede-tildelte koder i samme +-- løkke -- radantallet er lite nok til at en enkel retry-løkke er trygg. +DO $$ +DECLARE + r RECORD; + alphabet text := 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + candidate text; + attempt int; + i int; +BEGIN + FOR r IN SELECT id FROM tournament WHERE join_code IS NULL LOOP + attempt := 0; + LOOP + candidate := ''; + FOR i IN 1..6 LOOP + candidate := candidate || substr(alphabet, 1 + floor(random() * length(alphabet))::int, 1); + END LOOP; + attempt := attempt + 1; + EXIT WHEN attempt > 20 OR NOT EXISTS ( + SELECT 1 FROM tournament WHERE join_code = candidate + ); + END LOOP; + UPDATE tournament SET join_code = candidate WHERE id = r.id; + END LOOP; +END $$; + +ALTER TABLE tournament ALTER COLUMN join_code SET NOT NULL; +ALTER TABLE tournament ADD CONSTRAINT tournament_join_code_unique UNIQUE (join_code); + +-- --------------------------------------------------------------------- +-- 2. match.leading_side +-- --------------------------------------------------------------------- + +ALTER TABLE match ADD COLUMN leading_side text CHECK (leading_side IN ('a', 'b')); + +-- --------------------------------------------------------------------- +-- 3. public_tournament_by_code() -- fjerde SECURITY DEFINER-bro i +-- prosjektet (etter public_tournament_org 007, link_player_by_email 008, +-- public_org_by_slug 009), samme herding (SET search_path = public mot +-- kapring). Eksponerer KUN uuid<-text-koblingen, ingenting annet fra +-- tournament-raden. NULL for ukjent kode -- ingen egen anti- +-- enumereringsbekymring her siden koden er MENT å deles (se ADR-020 +-- Beslutning A), men samme returform som de tre andre broene uansett. +-- --------------------------------------------------------------------- + +CREATE FUNCTION public_tournament_by_code(p_code text) +RETURNS uuid +LANGUAGE sql +SECURITY DEFINER +SET search_path = public +STABLE +AS $$ + SELECT id FROM tournament WHERE upper(join_code) = upper(p_code); +$$; + +GRANT EXECUTE ON FUNCTION public_tournament_by_code(text) TO teecup_app; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 994f646..706f84d 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -643,6 +643,84 @@ ref.sql`. --- +## ADR-020 — Invitasjonskode (oppdagelse), matchledelse og projisert stilling + +**Kontekst:** Reist av brukeren 2026-07-19, rett etter at "bygg i rekkefølgen +ting brukes"-serien var ferdig. Tre relaterte, men separate mangler: + +1. Den eneste veien inn til en turnering er en direkte lenke (`/t/[id]`) eller + org-ens klubbside (kun `public`-synlige turneringer). En spiller som bare + har fått muntlig beskjed ("du spiller lørdag") har i dag ingen vei inn i + det hele tatt — dette var reelt ikke gjennomtenkt tidligere. +2. Leaderboardet viser kun FAKTISK opptjente poeng. Ingen visning av hva + stillingen ville blitt om pågående, ikke-avgjorte matcher holder seg som + de står nå (vanlig i profesjonell golf-TV-dekning av Ryder Cup). +3. Ingen visuell indikasjon i matchlister på hvem som leder en pågående + match — brukeren viste et skjermbilde av referanseproduktets fargekodede + matchrader (rød/beige etter ledende side) som ønsket retning. + +**Beslutning A — Kort, menneske-skrivbar invitasjonskode per turnering, som +OVERSTYRER `tournament.visibility`.** Ny `tournament.join_code` (6 tegn, fra +et alfabet uten forvekslingsbare tegn — `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`, +altså uten `0/O/1/I`), generert automatisk ved opprettelse, globalt unik +(kodeoppslag skjer FØR org-kontekst er kjent, samme problem som +`public_tournament_org()` løste for ADR-017). Login-skjermet får et eget +kode-felt (fungerer FØR innlogging) som løser koden til riktig turnering og +sender brukeren til `/t/{id}?code=...`. + +**Bevisst valgt fremfor å la koden respektere synlighet:** en kode gitt +muntlig eller på en lapp ER selve invitasjonen — å likevel kreve org- +medlemskap eller deltakerstatus for en `org`/`participants`-synlig turnering +ville gjort koden verdiløs for akkurat den situasjonen den er ment å løse. +Koden er ikke hemmelig i sikkerhetsforstand (den er MENT å deles), men +6 tegn fra et 33-tegns alfabet (≈1,3 milliarder kombinasjoner) gjør blind +gjetting upraktisk uten separat rate-limiting — akseptabelt for et +tillitsbasert klubb-/vennegjeng-verktøy (samme trusselmodell-resonnement som +`team_authz.py`, se «Brukerroller» i FEATURE_BACKLOG.md). + +**Konsekvens:** `GET /public/tournaments/{id}` og +`POST /public/tournaments/{id}/register` godtar en valgfri `code`-parameter; +matcher den turneringens `join_code` (case-insensitivt), hoppes den vanlige +`_check_visibility()`-sjekken helt over. Ny SECURITY DEFINER-bro +`public_tournament_by_code(code) RETURNS uuid` (tournament_id) — fjerde +instans av samme mønster som `public_tournament_org()` (007), +`link_player_by_email()` (008), `public_org_by_slug()` (009). Ingen +kode-regenerering bygget denne runden (organisator kan i dag ikke bytte ut +en lekket kode) — egen, senere sak i FEATURE_BACKLOG.md om det blir +etterspurt. + +**Beslutning B — Projisert stilling: pågående matchers nåværende leder får +full poengsum, uavgjort/ikke-startet splittes likt.** For hver IKKE avgjort +match brukes `match.leading_side` (se Beslutning C) til å tildele hele +øktens `points_per_match` til den ledende siden i den PROJISERTE summen — +"AS" (all square) eller en match som ennå ikke har noen registrerte hull +splittes 0,5/0,5, samme regel som en faktisk halvert match. Avgjorte +matcher bidrar likt til både faktisk og projisert sum (de er jo allerede +det de blir). Speiler hvordan TV-dekning av Ryder Cup vanligvis viser +"hvis det sluttet nå"-tavler. + +**Beslutning C — Ny cachet kolonne `match.leading_side`, samme mønster som +`status_text`/`points_side_a/b`.** `recompute_and_cache_match_state()` +(`app/routers/scoring.py`) beregner den allerede tilgjengelige +`MatchState.lead`-verdien (fortegn = ledende side) ved HVER hull-innsending +uansett om matchen er avgjort ennå — lagres nå også i en egen kolonne i +stedet for kun å ligge innbakt i den menneskelesbare `status_text`-strengen +("2 UP (A)"), slik at frontend kan style etter et strukturert felt +(`"a" | "b" | null`) i stedet for å parse norsk/engelsk tekst. Brukes til +BÅDE projisert stilling (Beslutning B) og fargekoding av matchlister +(Beslutning D). + +**Beslutning D — Fargekoding er ren frontend-presentasjon, ingen ny +backend-modell.** Matchlister (blind draw sin avslørte visning, ev. flere +steder senere) farger den ledende sidens kant/bakgrunn med lagets EKSISTERENDE +`team.color` når `leading_side` er satt og matchen ikke er avgjort — samme +fargekilde som resten av appen (leaderboard, roster) allerede bruker, ingen +ny fargemodell innført. + +**Migrasjon:** `011_join_code_and_leading_side.sql`. + +--- + ## Åpne spørsmål (ikke besluttet ennå) Disse må avklares før eller under de relevante fasene: diff --git a/app/routers/matches.py b/app/routers/matches.py index e2a1e57..84687db 100644 --- a/app/routers/matches.py +++ b/app/routers/matches.py @@ -70,6 +70,7 @@ class MatchOut(BaseModel): status_text: str | None points_side_a: float | None points_side_b: float | None + leading_side: str | None tee_time: datetime | None participants: list[MatchParticipantOut] @@ -112,7 +113,7 @@ async def create_match( VALUES ($1, $2, $3, $4, $5) RETURNING 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, tee_time_override + points_side_b::float AS points_side_b, leading_side, tee_time_override """, organization_id, session_id, @@ -150,7 +151,7 @@ async def list_matches( """ 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, tee_time_override + points_side_b::float AS points_side_b, leading_side, tee_time_override FROM match WHERE session_id = $1 ORDER BY sequence @@ -207,6 +208,7 @@ async def list_matches( status_text=m["status_text"], points_side_a=m["points_side_a"], points_side_b=m["points_side_b"], + leading_side=m["leading_side"], tee_time=_compute_tee_time( session["scheduled_at"], session["tee_interval_minutes"], m["sequence"], m["tee_time_override"] ), diff --git a/app/routers/registration.py b/app/routers/registration.py index 63ca8c0..d720b5d 100644 --- a/app/routers/registration.py +++ b/app/routers/registration.py @@ -67,6 +67,10 @@ async def _is_participant(conn, user_id: str, organization_id: str, tournament_i ) +def _code_matches(actual_join_code: str, provided_code: str | None) -> bool: + return provided_code is not None and provided_code.strip().lower() == actual_join_code.lower() + + async def _check_visibility( conn, visibility: str, @@ -91,6 +95,35 @@ async def _check_visibility( raise app_error(403, "NOT_VISIBLE", "Du har ikke tilgang til denne turneringen.") +class JoinCodeResult(BaseModel): + tournament_id: str + organization_id: str + tournament_name: str + + +@router.get("/by-code/{code}", response_model=JoinCodeResult) +async def resolve_join_code(code: str) -> JoinCodeResult: + """Løser en invitasjonskode til riktig turnering (ADR-020 Beslutning A) -- + MÅ registreres FØR /{tournament_id} under, ellers ville "by-code" blitt + tolket som en tournament_id (ugyldig UUID -> 500, ikke 404). + + Bevisst UAVHENGIG av tournament.visibility -- ingen _check_visibility()- + kall her, i motsetning til alle andre endepunkter i denne filen. Koden + ER selve invitasjonen; å likevel kreve org-medlemskap/deltakerstatus + ville gjort den verdiløs nettopp for personer som mangler det.""" + async with plain_connection() as conn: + tournament_id = await conn.fetchval("SELECT public_tournament_by_code($1)", code) + if tournament_id is None: + raise app_error(404, "NOT_FOUND", "Fant ingen turnering med denne koden.") + + organization_id = await _resolve_org(str(tournament_id)) + async with org_connection(organization_id) as conn: + name = await conn.fetchval("SELECT name FROM tournament WHERE id = $1", tournament_id) + return JoinCodeResult( + tournament_id=str(tournament_id), organization_id=organization_id, tournament_name=name + ) + + class SponsorOut(BaseModel): id: str name: str @@ -118,6 +151,7 @@ class PublicTournamentInfo(BaseModel): @router.get("/{tournament_id}", response_model=PublicTournamentInfo) async def get_public_tournament( tournament_id: str, + code: str | None = None, user: CurrentUser | None = Depends(get_current_user_optional), ) -> PublicTournamentInfo: organization_id = await _resolve_org(tournament_id) @@ -125,7 +159,7 @@ async def get_public_tournament( row = await conn.fetchrow( """ SELECT t.id::text, t.name, o.name AS organization_name, t.status::text, - t.visibility, t.description, t.hero_image_key, + t.visibility, t.description, t.hero_image_key, t.join_code, t.start_date, t.end_date, t.registration_deadline, t.registration_capacity, (SELECT count(*)::int FROM tournament_registration tr @@ -140,7 +174,9 @@ async def get_public_tournament( if row is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") - await _check_visibility(conn, row["visibility"], organization_id, tournament_id, user) + # ADR-020 Beslutning A: en matchende kode overstyrer visibility helt. + if not _code_matches(row["join_code"], code): + await _check_visibility(conn, row["visibility"], organization_id, tournament_id, user) sponsor_rows = await conn.fetch( "SELECT id::text, name, url, logo_key FROM tournament_sponsor WHERE tournament_id = $1 ORDER BY created_at", @@ -178,14 +214,18 @@ async def get_public_tournament( @router.get("/{tournament_id}/sessions", response_model=list[SessionOut]) async def get_public_sessions( tournament_id: str, + code: str | None = None, user: CurrentUser | None = Depends(get_current_user_optional), ) -> list[SessionOut]: organization_id = await _resolve_org(tournament_id) async with org_connection(organization_id) as conn: - visibility = await conn.fetchval("SELECT visibility FROM tournament WHERE id = $1", tournament_id) - if visibility is None: + 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.") - await _check_visibility(conn, visibility, organization_id, tournament_id, user) + if not _code_matches(row["join_code"], code): + await _check_visibility(conn, row["visibility"], organization_id, tournament_id, user) return await _fetch_sessions(conn, tournament_id) @@ -202,6 +242,8 @@ class RegisterRequest(BaseModel): handicap_index: float | None = None # API-et krever eksplisitt True -- ingen implisitt samtykke (ADR-017). consent: bool + # ADR-020 Beslutning A: en matchende invitasjonskode overstyrer visibility. + code: str | None = None class RegistrationResult(BaseModel): @@ -226,7 +268,7 @@ async def register_for_tournament( # mønster som to-lags-grensen i tournaments.py sin create_team. tournament = await conn.fetchrow( """ - SELECT visibility, registration_deadline, registration_capacity, + SELECT visibility, join_code, registration_deadline, registration_capacity, registration_overflow_policy, registration_requires_approval FROM tournament WHERE id = $1 FOR UPDATE """, @@ -236,8 +278,10 @@ async def register_for_tournament( raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") # ADR-018 Beslutning D: registrering følger samme synlighetsgrense - # som selve landingssiden -- ingen særbehandling. - await _check_visibility(conn, tournament["visibility"], organization_id, tournament_id, user) + # som selve landingssiden -- ingen særbehandling. ADR-020 Beslutning A: + # unntaket er en matchende invitasjonskode, som overstyrer dette helt. + if not _code_matches(tournament["join_code"], body.code): + await _check_visibility(conn, tournament["visibility"], organization_id, tournament_id, user) deadline = tournament["registration_deadline"] if deadline is not None and deadline < datetime.now(timezone.utc): diff --git a/app/routers/scoring.py b/app/routers/scoring.py index a60d893..940744a 100644 --- a/app/routers/scoring.py +++ b/app/routers/scoring.py @@ -165,6 +165,18 @@ async def recompute_and_cache_match_state(conn, match_id: str) -> None: played = _played_hole_numbers(match["hole_config"]) state = compute_match_state(results, total_holes=len(played)) + # ADR-020 Beslutning C: fortegnet av state.lead cachet i egen kolonne + # (i tillegg til den menneskelesbare status_text), slik at frontend kan + # style etter et strukturert felt i stedet for å parse tekst. Gjelder + # BÅDE pågående og avgjorte matcher -- vinnersiden i en avgjort match + # er alltid den samme som lead sitt fortegn allerede pekte på. + if state.lead > 0: + leading_side = "a" + elif state.lead < 0: + leading_side = "b" + else: + leading_side = None + complete = state.is_closed or state.holes_remaining == 0 if complete: ppm = match["points_per_match"] @@ -175,16 +187,22 @@ async def recompute_and_cache_match_state(conn, match_id: str) -> None: else: points_a = points_b = ppm / 2 await conn.execute( - "UPDATE match SET status_text = $1, points_side_a = $2, points_side_b = $3 WHERE id = $4", + """ + UPDATE match SET status_text = $1, points_side_a = $2, points_side_b = $3, + leading_side = $4 + WHERE id = $5 + """, state.describe(), points_a, points_b, + leading_side, match_id, ) else: await conn.execute( - "UPDATE match SET status_text = $1 WHERE id = $2", + "UPDATE match SET status_text = $1, leading_side = $2 WHERE id = $3", state.describe(), + leading_side, match_id, ) diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py index f3444e3..27b59c5 100644 --- a/app/routers/tournaments.py +++ b/app/routers/tournaments.py @@ -8,8 +8,10 @@ rostret på et lag som ikke finnes ennå). """ import json +import secrets from datetime import date, datetime +import asyncpg from fastapi import APIRouter, Depends, UploadFile from pydantic import BaseModel, Field @@ -37,9 +39,17 @@ class TournamentCreate(BaseModel): _TOURNAMENT_COLUMNS = """ id::text, name, status::text, start_date, end_date, visibility, description, registration_deadline, registration_capacity, - registration_overflow_policy, registration_requires_approval, hero_image_key + registration_overflow_policy, registration_requires_approval, hero_image_key, + join_code """ +# Uten 0/O/1/I -- lett å lese/skrive/si høyt (ADR-020 Beslutning A). +_JOIN_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + + +def _generate_join_code() -> str: + return "".join(secrets.choice(_JOIN_CODE_ALPHABET) for _ in range(6)) + class Tournament(BaseModel): id: str @@ -54,6 +64,7 @@ class Tournament(BaseModel): registration_overflow_policy: str registration_requires_approval: bool hero_image_key: str | None + join_code: str @router.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament]) @@ -73,17 +84,30 @@ async def create_tournament( organization_id: str = Depends(get_authorized_org), ) -> Tournament: async with org_connection(organization_id) as conn, translate_db_errors(): - row = await conn.fetchrow( - f""" - INSERT INTO tournament (organization_id, name, start_date, end_date) - VALUES ($1, $2, $3, $4) - RETURNING {_TOURNAMENT_COLUMNS} - """, - organization_id, - body.name, - body.start_date, - body.end_date, - ) + # join_code er globalt unikt (migrasjon 011) -- kollisjon er + # astronomisk usannsynlig (33^6 ≈ 1,3 mrd. kombinasjoner mot et + # lite antall turneringer totalt), men en liten retry-løkke koster + # ingenting og gjør oppførselen presis i stedet for å anta. + row = None + for _ in range(5): + try: + row = await conn.fetchrow( + f""" + INSERT INTO tournament (organization_id, name, start_date, end_date, join_code) + VALUES ($1, $2, $3, $4, $5) + RETURNING {_TOURNAMENT_COLUMNS} + """, + organization_id, + body.name, + body.start_date, + body.end_date, + _generate_join_code(), + ) + break + except asyncpg.UniqueViolationError: + continue + if row is None: + raise app_error(500, "VALIDATION_FAILED", "Klarte ikke å generere en unik invitasjonskode. Prøv igjen.") return Tournament(**dict(row)) @@ -904,6 +928,10 @@ class TeamStanding(BaseModel): team_name: str color: str | None points: float + # ADR-020 Beslutning B: hvis pågående matcher holder seg som de står nå -- + # ikke-avgjorte matcher bidrar med full poengsum til leading_side (delt + # likt ved "AS"/ikke startet). Avgjorte matcher bidrar likt til begge. + projected_points: float class SessionStanding(BaseModel): @@ -911,6 +939,7 @@ class SessionStanding(BaseModel): sequence: int name: str | None points_by_team: dict[str, float] + projected_points_by_team: dict[str, float] matches_total: int matches_decided: int @@ -941,8 +970,10 @@ async def get_leaderboard( 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.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 @@ -952,6 +983,7 @@ async def get_leaderboard( ) 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} session_order: list[str] = [] sessions: dict[str, dict] = {} matches_total = 0 @@ -967,11 +999,35 @@ async def get_leaderboard( "sequence": r["sequence"], "name": r["name"], "points_by_team": {t["id"]: 0.0 for t in teams}, + "projected_points_by_team": {t["id"]: 0.0 for t in teams}, "matches_total": 0, "matches_decided": 0, } sessions[sid]["matches_total"] += 1 + # Projisert bidrag: avgjorte matcher gir samme tall som faktisk + # (de ER allerede det de blir). Ikke-avgjorte matcher gir hele + # øktens poengsum til den som leder NÅ, delt 0,5/0,5 ved "AS" + # eller en match som ennå ikke har noen registrerte hull. + if r["points_side_a"] is not None: + proj_a, proj_b = r["points_side_a"], r["points_side_b"] + else: + ppm = r["points_per_match"] + if r["leading_side"] == "a": + proj_a, proj_b = ppm, 0.0 + elif r["leading_side"] == "b": + proj_a, proj_b = 0.0, ppm + else: + proj_a = proj_b = ppm / 2 + team_projected[r["team_a_id"]] = team_projected.get(r["team_a_id"], 0.0) + proj_a + team_projected[r["team_b_id"]] = team_projected.get(r["team_b_id"], 0.0) + proj_b + sessions[sid]["projected_points_by_team"][r["team_a_id"]] = ( + sessions[sid]["projected_points_by_team"].get(r["team_a_id"], 0.0) + proj_a + ) + sessions[sid]["projected_points_by_team"][r["team_b_id"]] = ( + sessions[sid]["projected_points_by_team"].get(r["team_b_id"], 0.0) + proj_b + ) + if r["points_side_a"] is None: continue matches_decided += 1 @@ -986,7 +1042,13 @@ async def get_leaderboard( ) team_standings = [ - TeamStanding(team_id=t["id"], team_name=t["name"], color=t["color"], points=team_totals[t["id"]]) + TeamStanding( + team_id=t["id"], + team_name=t["name"], + color=t["color"], + points=team_totals[t["id"]], + projected_points=team_projected[t["id"]], + ) for t in teams ] diff --git a/test_isolation.sql b/test_isolation.sql index dc4f958..e10b29c 100644 --- a/test_isolation.sql +++ b/test_isolation.sql @@ -25,9 +25,9 @@ INSERT INTO organization (id, name) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Org A'), ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Org B'); -INSERT INTO tournament (organization_id, name) VALUES - ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'A-Cup'), - ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'B-Cup'); +INSERT INTO tournament (organization_id, name, join_code) VALUES + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'A-Cup', 'TESTA1'), + ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'B-Cup', 'TESTB1'); -- --- Seed nok struktur til å teste match_hole_result og lineup_lock --- -- (ADR-012/013, migrasjon 003). Fortsatt privilegert rolle, RLS omgås her. @@ -106,8 +106,8 @@ END $$; DO $$ BEGIN BEGIN - INSERT INTO tournament (organization_id, name) - VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'kryss-org-forsøk'); + INSERT INTO tournament (organization_id, name, join_code) + VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'kryss-org-forsøk', 'TESTX01'); -- Kommer vi hit, ble det tillatt -> isolasjonen er brutt. RAISE EXCEPTION 'ISOLASJON FEILET (skriving): fikk sette inn data for annen org'; EXCEPTION @@ -228,8 +228,8 @@ END $$; DO $$ BEGIN BEGIN - INSERT INTO tournament (organization_id, name) - VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'tomstreng-forsøk'); + INSERT INTO tournament (organization_id, name, join_code) + VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'tomstreng-forsøk', 'TESTX02'); RAISE EXCEPTION 'ISOLASJON FEILET (tomstreng-skriving): fikk sette inn data uten gyldig org-kontekst'; EXCEPTION WHEN insufficient_privilege THEN