From 4587503d03bd1df233ef5998fe74ce0213c42fe6 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Fri, 21 Aug 2026 21:46:38 +0200 Subject: [PATCH] Flight/gruppe-scoping i ScoreTab (ADR-099) -- steg 1 av parity-planen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScoreTab viste hele turneringsfeltet i stedet for brukerens egen utslagsgruppe. Defaulter nå til egen flight med "vis hele feltet"- veksling og fallback når gruppe mangler. is_self beregnes server-side (aldri rå user_id). Lukker et reelt RLS-hull på tournament_round_group funnet underveis (migrasjon 089). Co-Authored-By: Claude Sonnet 5 --- 089_tournament_round_group_rls.sql | 27 ++++++ ARCHITECTURE_DECISIONS.md | 63 +++++++++++++ CHANGELOG.md | 68 ++++++++++++++ app/routers/individual_tournaments.py | 24 ++++- .../individual-tournament-detail.tsx | 37 +++++++- .../test_round_participant_flight_scoping.py | 93 +++++++++++++++++++ tests/test_round_participant_tee.py | 9 +- 7 files changed, 314 insertions(+), 7 deletions(-) create mode 100644 089_tournament_round_group_rls.sql create mode 100644 tests/test_round_participant_flight_scoping.py diff --git a/089_tournament_round_group_rls.sql b/089_tournament_round_group_rls.sql new file mode 100644 index 0000000..ef0eb72 --- /dev/null +++ b/089_tournament_round_group_rls.sql @@ -0,0 +1,27 @@ +-- ===================================================================== +-- TeeCup — migrasjon 089 +-- RLS på tournament_round_group (lukker et hull fra migrasjon 084) +-- ===================================================================== +-- Oppdaget under kartlegging til parity-arbeidet (score-føring i +-- turnering vs. frittstående runde, 2026-08-21): tournament_round_group +-- (opprettet i 084_tournament_round_groups.sql, utvidet i +-- 088_shotgun_start.sql) fikk ALDRI standard org_isolation-RLS-policyen +-- som ellers gjelder alle domenetabeller (ADR-003) -- i strid med +-- arkitektur-invarianten i CLAUDE.md. Ingen begrunnet unntak (i +-- motsetning til f.eks. round_shot/round_hole/round_message, som +-- bevisst står utenfor RLS per ADR-033 Beslutning A). I praksis kun +-- beskyttet av WHERE-ledd i applikasjonskoden frem til nå. +-- +-- Ingen dataendring, kun policy -- alle eksisterende spørringer går +-- allerede via org_connection() (setter app.current_org), så dette er +-- additivt, ikke en atferdsendring for korrekt scopet kode. +-- ===================================================================== + +\set ON_ERROR_STOP on + +ALTER TABLE tournament_round_group ENABLE ROW LEVEL SECURITY; +ALTER TABLE tournament_round_group FORCE ROW LEVEL SECURITY; + +CREATE POLICY org_isolation ON tournament_round_group + USING (organization_id = current_setting('app.current_org', true)::uuid) + WITH CHECK (organization_id = current_setting('app.current_org', true)::uuid); diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index b0ba02a..740106b 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -10005,6 +10005,69 @@ Ingen migrasjon. --- +## ADR-099: Flight/gruppe-scoping i ScoreTab (2026-08-21) + +**Kontekst:** bruker ba om full opplevelses-paritet mellom score-føring i +en org-turnering og en frittstående individuell runde ("Jeg skal i +utgangspunktet ha en identisk opplevelse når jeg fører score for meg (og +eventuelt de andre i flighten min)..."). Grundig undersøkelse fant det +mest strukturelle gapet: `ScoreTab` viste HELE turneringsfeltet +(alfabetisk, alle deltakere) i stedet for kun brukerens egen flight/ +gruppe -- `tournament_round_group` (bygget for utslags-/tee-sheet-paring, +ADR-096) ble KUN lest av administrator-parings-skjermen, aldri av selve +score-føringsskjermen. Første punkt i en avtalt 8-punkts parity- +rekkefølge; auto-advance mellom spillere i flighten er et EGET, senere +steg -- denne runden endrer kun HVEM som vises/velges som standard. + +**Beslutning A -- visningsendring, IKKE en ny autorisasjonsmodell.** +`update_hole` er allerede self-only (eller org-admin) via +`user_is_own_tournament_participant` (`team_authz.py`, innstrammet +2026-07-30). Denne runden endrer kun `ScoreTab`s participant-VELGER +(pill-listen) og standardvalg av `participantId` -- en org-admin kan +fortsatt bla til og rette en hvilken som helst deltaker via "Vis hele +feltet". Lesetilgang på andres hull (`list_round_participant_holes`) og +BBB-scoring (bevisst hele-feltet, egen docstring-begrunnelse) er UENDRET. + +**Beslutning B -- fallback til hele feltet når gruppe mangler, ikke en +blokkerende tilstand.** Ikke alle runder har kjørt "Grupper og +startliste" ennå. `ScoreTab` faller da tilbake til dagens oppførsel (hele +feltet) med en synlig inline-merknad om hvorfor, i stedet for å skjule +scoring-muligheten. Verifisert eksplisitt i scratch (se Status). + +**Beslutning C -- `is_self` beregnes server-side, ikke rå `user_id` til +klienten.** `list_round_participants`/`add_round_participant`/ +`update_round_participant_tee` (`individual_tournaments.py`) utvidet med +`(p.user_id::text = $2)`-sammenligning (samme `is_mine`-mønster som +`rounds.py`), IKKE et rått `user_id`-felt -- unngår å lekke andre +spilleres kontokobling til enhver org-medlem-klient. `player.user_id` er +nullable (spillere uten konto) -- NULL-verdien måtte eksplisitt +`COALESCE`-es til `false` (fanget av en dedikert ny test, +`test_round_participant_flight_scoping.py`, ellers 500 pga. +Pydantic-validering av et NULL-boolsk felt). + +**Beslutning D -- lukket et reelt RLS-hull funnet underveis.** +`tournament_round_group` (migrasjon 084/088) manglet HELT standard +org_isolation-RLS (bekreftet: ingen `ENABLE ROW LEVEL SECURITY`/ +`CREATE POLICY` i noen av de to migrasjonene, uten den typen begrunnede +unntak `round_shot`/`round_hole` har per ADR-033). Lukket med ny +migrasjon `089_tournament_round_group_rls.sql`, samme mønster som +`001_initial_schema.sql`. I praksis var isolasjonen tidligere kun +app-lagets WHERE-ledd -- additiv, ikke en atferdsendring. + +**Status:** 164/164 pytest (inkl. ny dedikert test), `tsc --noEmit` rent. +RLS-policyen verifisert direkte mot en engangs scratch-database (SQL som +`teecup_app`-rollen med feil `app.current_org` satt returnerte 0 rader, +riktig org 1 rad). Full-stack scratch-verifisering (egen scratch-db, egen +API-container, egen MinIO, ekte innlogging via `/auth/login-password`, +ingen fetch-mocking) bekreftet: gruppert runde defaulter til egen flight +(3 av 6 vist), "Vis hele feltet"/"Vis kun min flight" veksler korrekt +begge veier, ugruppert runde faller korrekt tilbake til hele feltet med +riktig merknad, mørk modus og smal mobilskjerm uendret lesbare. Alle +scratch-ressurser (container/db/rolle) ryddet opp etter verifisering. +IKKE bygget/rullet ut mot ekte containere ennå. + +--- + ## Utviklingsplan (rekkefølge) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a4c9d2..315f2b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14233,3 +14233,71 @@ Neste steg: `/public/tournaments/{id}` returnerer `format_type: "individual"`, `/t/{id}/live` svarer 200. Scratch-ressursene (containere, dev- server) ryddet opp og bekreftet stoppet etterpå. + +154. **Rett Thru-kolonnens vertikale justering i offentlig leaderboard, + 2026-08-21 -- ekte bruk-funnet bug etter #153 sin utrulling.** Bruker + rapporterte fra faktisk mobilbruk (teecup.golf, live): THRU-etiketten + sto lavere enn I DAG/TOTAL. Årsak: Thru-verdien manglet samme + `min-h-10` som `ScoreShape` (brukt av I dag/Total) gir de andre + kolonnene -- hele Thru-kolonnen ble dermed lavere, senterte etiketten + ut av linje med søsknene sine. Rettet i + `public-individual-live.tsx`. Rullet ut sammen med punkt 155. + +155. **Skjul HCP på offentlig leaderboard for rene bruttoslagspill- + turneringer, 2026-08-21.** Bruker: "når det er brutto som er + hovedkonkurransen trenger vi ikke se hcp i leaderboarden" -- HCP er + kun meningsfullt der den faktisk brukes til slagfordeling (netto/ + stableford/Københavner/BBB), ikke ren brutto/eclectic-gross. + `PublicTournamentInfo` += `scoring_method` (manglet før på den + offentlige modellen), ny `shouldShowHcp(scoringMethod)`-hjelper i + `public-individual-live.tsx` (`GROSS_ONLY_METHODS = {"stroke_gross", + "eclectic_gross"}`). Bruker stilte oppfølgingsspørsmål om dette var en + systemendring (ja) -- eskalerte til den bredere parity-diskusjonen som + punkt 156 svarer på. Rullet ut sammen med punkt 154 (`docker compose + build teecup_api teecup_frontend && up -d`). + +156. **Flight/gruppe-scoping i ScoreTab (ADR-099), 2026-08-21 -- steg 1 + av en avtalt 8-punkts parity-plan.** Bruker: "Jeg skal i utgangspunktet + ha en identisk opplevelse når jeg fører score for meg (og eventuelt de + andre i flighten min) i en org-turnering som det jeg har når jeg fører + en individuell runde." Grundig Explore-undersøkelse kartla hele gapet + mellom org-turneringers scoring og frittstående runders scoring + (rapportert til bruker, som ba om rekkefølgen). Denne runden bygger + punkt 1: `ScoreTab` viste tidligere HELE turneringsfeltet i stedet for + brukerens egen flight -- `tournament_round_group` ble kun lest av + admin-parings-skjermen. Se ADR-099 for de fire beslutningene (visning + ikke autorisasjon; fallback til hele feltet uten gruppe; `is_self` + server-side, aldri rå `user_id`; et reelt RLS-hull på + `tournament_round_group` funnet og lukket underveis, migrasjon 089). + + **Backend:** `RoundParticipantOut` += `tournament_round_group_id`, + `is_self` (tre spørringer i `individual_tournaments.py` utvidet: + `add_round_participant`, `list_round_participants`, + `update_round_participant_tee`). Ny migrasjon + `089_tournament_round_group_rls.sql`. + + **Frontend:** `ScoreTab` (`individual-tournament-detail.tsx`) + defaulter pill-listen og `participantId`-valget til egen gruppe når + en finnes, med "Vis hele feltet"/"Vis kun min flight"-veksling og en + inline-merknad når runden mangler gruppeinndeling. Ingen ny V0-runde + -- filter+lenke lagt til en allerede eksisterende, V0-bygget skjerm. + + **Verifisert:** 164/164 pytest (inkl. ny + `test_round_participant_flight_scoping.py` -- fanget en reell NULL- + håndteringsbug: `p.user_id::text = $2` ga SQL NULL, ikke `false`, for + spillere uten konto, rettet med `COALESCE`), `tsc --noEmit` rent. + RLS-policyen verifisert direkte (engangs scratch-db: `teecup_app`- + rollen med feil `app.current_org` satt fikk 0 rader, riktig org 1 + rad). Full-stack scratch-verifisering (egen scratch-db, egen + API-container, egen isolert MinIO, ekte innlogging via + `/auth/login-password`, INGEN fetch-mocking -- et tidligere forsøk med + `window.fetch`-mocking i chrome-devtools-MCP-miljøet brøt selve + sidenavigasjonen på en måte som ikke var reproduserbar med ekte + nettverkstrafikk, byttet derfor til en ekte scratch-backend i stedet + for å grave videre i et miljøspesifikt verktøyproblem) bekreftet: + gruppert runde defaulter riktig til 3 av 6 (egen flight), veksling + fungerer begge veier, ugruppert runde faller korrekt tilbake med + riktig merknad, mørk modus og 390px mobilskjerm uendret lesbare. + Alle scratch-ressurser (API-/MinIO-container, db, rolle) ryddet opp + etter verifisering. IKKE bygget/rullet ut mot ekte containere ennå -- + venter på bekreftelse. diff --git a/app/routers/individual_tournaments.py b/app/routers/individual_tournaments.py index 793d554..7ff4636 100644 --- a/app/routers/individual_tournaments.py +++ b/app/routers/individual_tournaments.py @@ -520,6 +520,12 @@ class RoundParticipantOut(BaseModel): # level), speilet ut her slik at frontend vet om HoleGrid-cellen skal # åpne det utvidede HoleStatsSheet-et for denne deltakeren. stat_level: str + # Flight-scoping i ScoreTab (ADR-099) -- gruppe-id fra migrasjon 084 + # (NULL = ikke gruppert ennå) og om raden er den innloggede brukeren + # selv (aldri rå user_id til klienten -- samme is_mine-mønster som + # rounds.py). + tournament_round_group_id: str | None + is_self: bool async def _compute_round_participant_handicap(conn, round_participant_id: str) -> None: @@ -637,6 +643,7 @@ async def add_round_participant( round_id: str, body: RoundParticipantCreate, organization_id: str = Depends(get_authorized_org), + user: CurrentUser = Depends(get_current_user), ) -> RoundParticipantOut: async with org_connection(organization_id) as conn, translate_db_errors(): await _validate_round_tee_choice(conn, tournament_id, round_id, body.tournament_participant_id, body.tee_id) @@ -664,7 +671,9 @@ async def add_round_participant( """ SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id, p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name, - trp.course_handicap, trp.playing_handicap, tp.stat_level + trp.course_handicap, trp.playing_handicap, tp.stat_level, + trp.tournament_round_group_id::text AS tournament_round_group_id, + COALESCE(p.user_id::text = $2, false) AS is_self FROM tournament_round_participant trp JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id JOIN player p ON p.id = tp.player_id @@ -672,6 +681,7 @@ async def add_round_participant( WHERE trp.id = $1 """, round_participant_id, + user.user_id, ) return RoundParticipantOut(**dict(out)) @@ -684,13 +694,16 @@ async def list_round_participants( tournament_id: str, round_id: str, organization_id: str = Depends(get_authorized_org), + user: CurrentUser = Depends(get_current_user), ) -> list[RoundParticipantOut]: async with org_connection(organization_id) as conn: rows = await conn.fetch( """ SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id, p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name, - trp.course_handicap, trp.playing_handicap, tp.stat_level + trp.course_handicap, trp.playing_handicap, tp.stat_level, + trp.tournament_round_group_id::text AS tournament_round_group_id, + COALESCE(p.user_id::text = $2, false) AS is_self FROM tournament_round_participant trp JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id JOIN player p ON p.id = tp.player_id @@ -699,6 +712,7 @@ async def list_round_participants( ORDER BY p.display_name """, round_id, + user.user_id, ) return [RoundParticipantOut(**dict(r)) for r in rows] @@ -733,6 +747,7 @@ async def update_round_participant_tee( round_participant_id: str, body: RoundParticipantTeeUpdate, organization_id: str = Depends(get_authorized_org), + user: CurrentUser = Depends(get_current_user), ) -> RoundParticipantOut: async with org_connection(organization_id) as conn, translate_db_errors(): existing = await conn.fetchrow( @@ -759,7 +774,9 @@ async def update_round_participant_tee( """ SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id, p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name, - trp.course_handicap, trp.playing_handicap, tp.stat_level + trp.course_handicap, trp.playing_handicap, tp.stat_level, + trp.tournament_round_group_id::text AS tournament_round_group_id, + COALESCE(p.user_id::text = $2, false) AS is_self FROM tournament_round_participant trp JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id JOIN player p ON p.id = tp.player_id @@ -767,6 +784,7 @@ async def update_round_participant_tee( WHERE trp.id = $1 """, round_participant_id, + user.user_id, ) return RoundParticipantOut(**dict(out)) diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx index 92622a8..7dbded3 100644 --- a/frontend/components/individual-tournament-detail.tsx +++ b/frontend/components/individual-tournament-detail.tsx @@ -203,6 +203,9 @@ type ApiRoundParticipant = { // "strokes_and_putts" / "full", styrer om HoleGrid sin celle åpner det // enkle inline-tallfeltet (uendret oppførsel) eller HoleStatsSheet. stat_level: "strokes_only" | "strokes_and_putts" | "full" + // Flight-scoping i ScoreTab (ADR-099). + tournament_round_group_id: string | null + is_self: boolean } type ApiHole = { @@ -2965,6 +2968,18 @@ function ScoreTab({ // fetch) vet de skal hente på nytt. const [refreshTick, setRefreshTick] = useState(0) + // Flight-scoping (ADR-099) -- standard er å vise KUN egen + // utslagsgruppe i velgeren under, med en lenke for å se hele feltet. + // roundParticipants selv holdes ALLTID ufiltrert (BBB/Flag-panelene + // under bruker den direkte, uavhengig av dette). + const [showFullField, setShowFullField] = useState(false) + const myParticipant = roundParticipants.find((rp) => rp.is_self) ?? null + const myGroupId = myParticipant?.tournament_round_group_id ?? null + const visibleParticipants = + showFullField || !myGroupId + ? roundParticipants + : roundParticipants.filter((rp) => rp.tournament_round_group_id === myGroupId) + useEffect(() => { if (!roundId) return let cancelled = false @@ -2972,7 +2987,11 @@ function ScoreTab({ if (cancelled) return const list = data ?? [] setRoundParticipants(list) - setParticipantId(list[0]?.id ?? "") + setShowFullField(false) + const self = list.find((rp) => rp.is_self) + const selfGroupId = self?.tournament_round_group_id ?? null + const defaultList = selfGroupId ? list.filter((rp) => rp.tournament_round_group_id === selfGroupId) : list + setParticipantId(self?.id ?? defaultList[0]?.id ?? "") }) return () => { cancelled = true @@ -3068,7 +3087,7 @@ function ScoreTab({ ) : ( <>
- {roundParticipants.map((rp) => ( + {visibleParticipants.map((rp) => ( + ) : ( +

+ Ingen gruppeinndeling er satt for denne runden ennå -- viser hele feltet. +

+ )} + {round && holes && (