Flight/gruppe-scoping i ScoreTab (ADR-099) -- steg 1 av parity-planen

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 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-21 21:46:38 +02:00
parent dd974678fa
commit 4587503d03
7 changed files with 314 additions and 7 deletions

View file

@ -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);

View file

@ -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)

View file

@ -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.

View file

@ -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))

View file

@ -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({
) : (
<>
<div className="flex flex-wrap gap-2">
{roundParticipants.map((rp) => (
{visibleParticipants.map((rp) => (
<button
key={rp.id}
type="button"
@ -3086,6 +3105,20 @@ function ScoreTab({
))}
</div>
{myGroupId ? (
<button
type="button"
onClick={() => setShowFullField((v) => !v)}
className="self-start text-sm font-bold text-info underline-offset-4 hover:underline"
>
{showFullField ? "Vis kun min flight" : `Vis hele feltet (${roundParticipants.length})`}
</button>
) : (
<p className="text-sm font-medium text-muted-foreground">
Ingen gruppeinndeling er satt for denne runden ennå -- viser hele feltet.
</p>
)}
{round && holes && (
<HoleGrid
base={base}

View file

@ -0,0 +1,93 @@
"""
Flight/gruppe-scoping i ScoreTab (ADR-099, 2026-08-21). list_round_participants
speiler ut tournament_round_group_id + is_self per rad, slik at frontend kan
la score-føringsskjermen default til brukerens egen utslagsgruppe i stedet for
hele feltet. is_self beregnes server-side (ikke user_id, samme is_mine-
mønster som rounds.py) for ikke å lekke andre spilleres kontokobling.
"""
from app.auth import CurrentUser
from app.routers.individual_tournaments import (
RoundGroupIn,
SaveRoundGroupsIn,
list_round_participants,
save_round_groups,
)
from tests.conftest import (
create_org,
create_course,
create_player,
create_tee,
create_tournament,
create_tournament_participant,
create_tournament_round,
create_tournament_round_participant,
create_user,
)
import app.db as app_db
async def _setup(org_id):
tournament_id = await create_tournament(org_id, name="Flight-scoping-turnering")
course_id = await create_course(org_id, name="Flight Links")
tee_id = await create_tee(org_id, course_id, name="Gul")
async with app_db.org_connection(org_id) as conn:
await conn.execute(
"UPDATE tournament SET format_type = 'individual', scoring_method = 'stroke_gross' WHERE id = $1",
tournament_id,
)
round_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=1)
return tournament_id, round_id, tee_id
async def test_is_self_and_group_id_reflect_viewer_and_grouping(pool):
org_id = await create_org()
tournament_id, round_id, tee_id = await _setup(org_id)
me_user_id = await create_user()
other_user_id = await create_user()
me_player_id = await create_player(org_id, display_name="Meg Selv", user_id=me_user_id)
other_player_id = await create_player(org_id, display_name="En Annen", user_id=other_user_id)
me_tp_id = await create_tournament_participant(org_id, tournament_id, me_player_id)
other_tp_id = await create_tournament_participant(org_id, tournament_id, other_player_id)
me_rp_id = await create_tournament_round_participant(org_id, round_id, me_tp_id, tee_id)
other_rp_id = await create_tournament_round_participant(org_id, round_id, other_tp_id, tee_id)
# Ingen gruppe satt ennå -- begge rader skal ha tournament_round_group_id=None.
ungrouped = await list_round_participants(
tournament_id, round_id, organization_id=org_id, user=CurrentUser(user_id=me_user_id),
)
by_id = {rp.id: rp for rp in ungrouped}
assert by_id[me_rp_id].tournament_round_group_id is None
assert by_id[other_rp_id].tournament_round_group_id is None
assert by_id[me_rp_id].is_self is True
assert by_id[other_rp_id].is_self is False
# Grupper begge i samme gruppe -- gruppe-id skal nå matche på begge rader,
# og is_self skal fortsatt kun være sann for den innloggede brukeren.
await save_round_groups(
tournament_id, round_id,
SaveRoundGroupsIn(groups=[RoundGroupIn(sequence=1, round_participant_ids=[me_rp_id, other_rp_id])]),
organization_id=org_id,
)
grouped = await list_round_participants(
tournament_id, round_id, organization_id=org_id, user=CurrentUser(user_id=me_user_id),
)
by_id = {rp.id: rp for rp in grouped}
assert by_id[me_rp_id].tournament_round_group_id is not None
assert by_id[me_rp_id].tournament_round_group_id == by_id[other_rp_id].tournament_round_group_id
assert by_id[me_rp_id].is_self is True
assert by_id[other_rp_id].is_self is False
# Sett fra den ANDRE brukerens perspektiv: is_self bytter side.
as_other = await list_round_participants(
tournament_id, round_id, organization_id=org_id, user=CurrentUser(user_id=other_user_id),
)
by_id = {rp.id: rp for rp in as_other}
assert by_id[me_rp_id].is_self is False
assert by_id[other_rp_id].is_self is True

View file

@ -13,6 +13,7 @@ får alltid tournament_round_group_id=NULL).
import pytest
from fastapi import HTTPException
from app.auth import CurrentUser
from app.routers.individual_tournaments import (
RoundGroupIn,
RoundParticipantTeeUpdate,
@ -30,6 +31,7 @@ from tests.conftest import (
create_tournament_participant,
create_tournament_round,
create_tournament_round_participant,
create_user,
)
import app.db as app_db
@ -69,8 +71,10 @@ async def test_changing_tee_preserves_group_membership(pool):
)
assert group_id_before is not None
user_id = await create_user()
updated = await update_round_participant_tee(
tournament_id, round_id, rp_id, RoundParticipantTeeUpdate(tee_id=tee_b), organization_id=org_id
tournament_id, round_id, rp_id, RoundParticipantTeeUpdate(tee_id=tee_b),
organization_id=org_id, user=CurrentUser(user_id=user_id),
)
assert updated.tee_id == tee_b
assert updated.id == rp_id # samme rad, ikke slett+gjenopprett
@ -88,11 +92,12 @@ async def test_changing_tee_to_one_outside_course_is_rejected(pool):
other_course_id = await create_course(org_id, name="En annen bane")
foreign_tee_id = await create_tee(org_id, other_course_id, name="Blå")
user_id = await create_user()
with pytest.raises(HTTPException) as exc_info:
await update_round_participant_tee(
tournament_id, round_id, rp_id,
RoundParticipantTeeUpdate(tee_id=foreign_tee_id),
organization_id=org_id,
organization_id=org_id, user=CurrentUser(user_id=user_id),
)
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == "OUT_OF_SCOPE"