Nå dokumentasjonen er oppdatert. Klar for deploy — jeg viser planen først:

Update Todos

Legge til visuell hint på øktkort ("Sett opp flights") i program-skjermen

Avklare designbeslutninger for Brukerroller med bruker

Skrive team_authz.py på nytt (kaptein-only + match-deltaker-sjekk)

Oppdatere matches.py til å bruke user_is_team_captain

Oppdatere scoring.py til å bruke user_is_match_participant

Håndheve 'kun én kaptein per lag' i tournaments.py PATCH/POST roster

Sjekke ekte teecup_db for lag uten kaptein / med flere kapteiner (kun lesing)

Skrive ADR-023 for Brukerroller-beslutningene

Sette opp scratch MinIO + engangs API-container

Kjøre autorisasjonstester (kaptein/deltaker) mot scratch-API-et

Rydde opp scratch-infrastruktur

Ekte typesjekket frontend-build (øktkort-hint)

Deploy etter eksplisitt bekreftelse + oppdatere .md-filer

Alt er scratch-verifisert. Klar for utrulling — planen:

Ingen migrasjon (ren autorisasjonslogikk-endring, ingen skjema rørt).

docker compose up -d --build teecup_api teecup_frontend mot ekte teeoff_default-nettverk — bygger og bytter ut begge containere.

Endringen har reell konsekvens for ekte data akkurat nå: "De Unge" i "De Gamle er Eldst" har 0 kapteiner — de vil fortsette å kunne sette opp/føre score som før (fallback-regelen dekker dette), men fra nå av bør du utpeke en kaptein på det laget for at kaptein-rollen skal bety noe der òg.

Etter deploy: sjekke /health + /dashboard fortsatt 200, teeoff.no upåvirket (samme som alle tidligere runder).
This commit is contained in:
Erol Haagenrud 2026-07-19 11:41:57 +02:00
parent a216e3945a
commit e60a2aafe7
7 changed files with 266 additions and 103 deletions

View file

@ -909,6 +909,64 @@ migrasjon `012`). Se CLAUDE.md-status for full byggerunde.
--- ---
## ADR-023: Brukerroller — kaptein som reell autorisasjon, deltaker-avgrenset scoring
Reist 2026-07-19, direkte oppfølging av det lenge åpne «Brukerroller»-punktet
i FEATURE_BACKLOG.md (der siden prosjektets start beskrevet som turnerings-
admin/lagkaptein/spiller/tilskuer, men aldri fullt ut avgjort). Fire
delspørsmål, alle avklart eksplisitt med bruker før bygging.
**Beslutning A — Kaptein gir eksklusive rettigheter til å sette opp/låse
laget, ikke lenger «hvem som helst rostret».** `app/team_authz.py` sin
`user_is_team_captain` (erstatter `user_may_act_for_team`) krever
`team_roster.is_captain = true` for brukeren (eller org-eier/admin, uendret
fra 2026-07-18-runden) — brukt av `matches.py` sin `add_participant`/
`remove_participant`/`lock_lineup`. Ny feilkode `NOT_TEAM_CAPTAIN` (403,
erstatter `NOT_ROSTERED_ON_TEAM` for disse tre stedene).
**Bevisst unntak, funnet ved å faktisk sjekke ekte produksjonsdata FØR
utrulling, ikke antatt:** har et lag INGEN utpekt kaptein i det hele tatt,
godtas enhver rostret spiller i stedet — uten dette ville en kaptein-only-
regel umiddelbart LÅST ute et helt lag fra å sette opp seg selv. Sjekket mot
ekte `teecup_db`: laget «De Unge» i «De Gamle er Eldst» har i dag 0 av 2
roster-rader merket kaptein — dette er altså ikke et hypotetisk
kantscenario, det ville rammet en reell, allerede opprettet turnering med
en gang. Har laget FØRST fått en kaptein, gjelder utelukkende den.
**Beslutning B — «Kun én kaptein per lag» håndheves nå.** `PATCH .../
roster/{id}` og `POST .../roster` (`app/routers/tournaments.py`) fjerner
automatisk kapteinmerket fra enhver annen roster-rad på SAMME lag når en ny
kaptein settes, i samme transaksjon. Nødvendig konsekvens av Beslutning A —
uten dette ville det vært uklart hvem som faktisk har myndighet hvis flere
er merket. Sjekket mot ekte data: ingen eksisterende lag hadde flere
kapteiner (kun opprydningsbehovet «0 kapteiner» fantes reelt), så ingen
data-migrering var nødvendig.
**Beslutning C — Score-føring/-korrigering begrenses til matchens faktiske
deltakere.** `app/team_authz.py` sin nye `user_is_match_participant` krever
en `match_participant`-rad for brukeren i AKKURAT den matchen (valgfritt
begrenset til én side via `team_side` — brukt for `stroke`-modus sin
side-spesifikke innsending, `None`/hvilken som helst side for `hole_result`-
modus siden begge sider kan rapportere). Erstatter den brede
«rostret på laget»-sjekken i `app/routers/scoring.py` sin
`submit_hole_score`/`submit_hole_result`. UAVHENGIG av kapteinmerket — å
være kaptein gir ikke i seg selv rett til å føre score for en match man
selv ikke spiller; org-eier/admin har samme unntak som før. Ny feilkode
`NOT_MATCH_PARTICIPANT` (403, erstatter `NOT_ROSTERED_ON_TEAM` her).
**Beslutning D — «Tilskuer»-rollen utsettes bevisst.** Ingen kode denne
runden. Offentlig/deltaker-lesetilgang finnes allerede
(`tournament.visibility` + `get_current_user_optional`, ADR-018) — et
formelt tilskuer-begrep bør defineres sammen med det ennå ubesluttede
synlighetsspørsmålet for «Banter Board»-feeden (FEATURE_BACKLOG), ikke
isolert her, for å unngå å bygge to overlappende synlighetsmodeller.
**Status: ✅ BYGGET 2026-07-19.** Ingen migrasjon (ren autorisasjonslogikk,
ingen skjemaendring). Se CLAUDE.md-status for scratch-verifisering og
utrulling.
---
## Åpne spørsmål (ikke besluttet ennå) ## Åpne spørsmål (ikke besluttet ennå)
Disse må avklares før eller under de relevante fasene: Disse må avklares før eller under de relevante fasene:

View file

@ -297,39 +297,44 @@
## Ønsket, men IKKE fanget før nå (fra Gemini-samtalene) ## Ønsket, men IKKE fanget før nå (fra Gemini-samtalene)
### Brukerroller (utover org-medlemskap) ### Brukerroller (utover org-medlemskap) — ADR-023
- **Status:** ❓ trenger beslutning - **Status:** ✅ BYGGET OG SCRATCH-VERIFISERT 2026-07-19 (deploy venter på
bekreftelse) — kaptein/deltaker-delen. Tilskuer bevisst utsatt.
- De opprinnelige samtalene beskriver: turneringsadmin, **lagkaptein**, spiller, - De opprinnelige samtalene beskriver: turneringsadmin, **lagkaptein**, spiller,
**tilskuer** (les-only, følger live uten skriverettigheter). **tilskuer** (les-only, følger live uten skriverettigheter).
- Vi har i dag org-roller (owner/admin/member) + `is_captain` på roster. - Vi har i dag org-roller (owner/admin/member) + `is_captain` på roster.
- **Mangler:** «tilskuer» som begrep. Henger sammen med hvem som ser den - **2026-07-19, ✅ BYGGET (ADR-023):** Kaptein er nå en REELL autorisasjonsrolle,
offentlige feeden (se Kommunikasjon). Kaptein-rollen bør kanskje gi spesifikke ikke bare et merke. `app/team_authz.py` sin nye `user_is_team_captain`
rettigheter (sette oppstilling), ikke bare være et flagg. (erstatter `user_may_act_for_team`) krever `is_captain=true` (eller
- **Nytt 2026-07-18:** `PATCH .../roster/{id}` (sette/fjerne kaptein) håndhever org-eier/admin) for å legge til/fjerne deltakere og låse et lag
bevisst IKKE «kun én kaptein per lag» — flere spillere kan i dag merkes (`matches.py`). Bevisst unntak — funnet ved å faktisk sjekke ekte
kaptein samtidig på samme lag. Bør revurderes samtidig med resten av dette produksjonsdata FØR utrulling: har laget INGEN utpekt kaptein ennå, godtas
punktet, ikke løses isolert i roster-endepunktet. enhver rostret spiller i stedet (ellers ville «De Unge»-laget i den ekte
- **Nytt 2026-07-18, ✅ BYGGET:** `app/team_authz.py` sin `user_may_act_for_team` «De Gamle er Eldst»-turneringen vært låst ute umiddelbart — 0 av 2
(brukt av lås/deltakere i matches.py OG score-skriving i scoring.py) gir nå roster-rader er i dag merket kaptein der).
også org-eier/admin (`organization_membership.role IN ('owner','admin')`) - **2026-07-19, ✅ BYGGET (ADR-023):** «Kun én kaptein per lag» håndheves nå —
samme rettigheter som en rostret spiller, på ETHVERT lag — reist av `PATCH`/`POST .../roster` (`tournaments.py`) fjerner automatisk
brukeren rett før blind draw-skjermen: uten dette kunne INGEN sette opp kapteinmerket fra andre rader på samme lag når en ny kaptein settes.
eller låse et lag før minst én spiller hadde logget inn og blitt koblet. Nødvendig konsekvens av at kaptein nå er en autorisasjonsrolle. Ingen
Bevisst INGEN unntak for at organisatoren selv er rostret på eksisterende lag hadde flere kapteiner (sjekket mot ekte data), så ingen
MOTSTANDERLAGET (vurdert og avvist — se team_authz.py sin docstring for opprydning av data var nødvendig.
full begrunnelse: tillitsbasert verktøy, organisator ser uansett begge - **2026-07-19, ✅ BYGGET (ADR-023):** Score-føring/-korrigering begrenset til
rostre allerede, og et unntak ville skapt en reell låsning der ingen matchens FAKTISKE deltakere (`app/team_authz.py` sin nye
kunne sette opp motstanderlaget om det heller ikke har en innlogget `user_is_match_participant`, brukt av `scoring.py`) — ikke lenger «noen på
spiller). Verifisert i scratch: org-eier uten roster kan nå låse begge laget», og uavhengig av kapteinmerket. Erstatter «Scoring-autorisasjon»-
lag + føre score, vanlig 'member'-rolle fortsatt blokkert, rostret punktet under, spørsmål (a) er dermed besvart.
spiller uendret. Rullet ut live samme dag. - **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).
### Scoring-autorisasjon: hvem fører, hvem korrigerer, hvem lukker (rejst 2026-07-16) ### Scoring-autorisasjon: hvem fører, hvem korrigerer, hvem lukker (rejst 2026-07-16)
- **Status:** ❓ trenger beslutning — direkte oppfølger av «Brukerroller» over. - **Status:**delvis avgjort — direkte oppfølger av «Brukerroller» over.
- **Hvem fører score i dag:** alle med en `team_roster`-rad på laget, ELLER - **Hvem fører score i dag (2026-07-19, ADR-023):** kun matchens FAKTISKE
org-eier/admin (utvidet 2026-07-18, se «Brukerroller» over) — samme deltakere, ELLER org-eier/admin — `app/team_authz.py` sin
minimale grense som deltaker/lås, se ADR-013-relatert kode. Ikke `user_is_match_participant`. Byttet fra «noen på laget» samme dag. Se
kaptein-only, ikke begrenset til de(n) som faktisk spiller matchen. «Brukerroller» over for full begrunnelse.
- **Hvem kan korrigere en ført score i dag:** akkurat de samme — skriving er en - **Hvem kan korrigere en ført score i dag:** akkurat de samme — skriving er en
upsert (`ON CONFLICT ... DO UPDATE`), så en korrigering er ikke skilt fra en upsert (`ON CONFLICT ... DO UPDATE`), så en korrigering er ikke skilt fra en
førstegangs-innføring. Ingen godkjenning fra motstanderen, ingen audit-trail førstegangs-innføring. Ingen godkjenning fra motstanderen, ingen audit-trail
@ -357,13 +362,14 @@
- **Match-lås ved avgjørelse: ✅ bygget** — se eget punkt over. Automatisk - **Match-lås ved avgjørelse: ✅ bygget** — se eget punkt over. Automatisk
(ikke en handling noen utfører), så «hvem får låse» ble aldri et (ikke en handling noen utfører), så «hvem får låse» ble aldri et
spørsmål som trengte avklaring. spørsmål som trengte avklaring.
- **Walkover/konsesjon VENTER** til brukerroller (kaptein/organisator, - **Walkover/konsesjon kan nå tas fatt på** — brukerroller (kaptein/
punktet over) er avgjort — å bygge den nå på dagens løse deltaker-avgrensning) er avgjort og bygget (ADR-023), grunnlaget den
«rostret på laget»-grense betyr sannsynligvis å bygge den om senere. tidligere ventet på er ikke lenger løst.
- **Fortsatt åpent:** (a) skal score-føring begrenses til faktiske - **Fortsatt åpent:** (a) ~~skal score-føring begrenses til faktiske
matchdeltakere (ikke bare «noen på laget»)? (b) skal korrigering kreve matchdeltakere~~ ✅ avgjort/bygget 2026-07-19 (ADR-023, se over). (b) skal
motpartens godkjenning, eller er upsert-modellen god nok for v1? (c) skal korrigering kreve motpartens godkjenning, eller er upsert-modellen god nok
turnering-status (draft/active/completed/archived) kunne settes via API? for v1? (c) skal turnering-status (draft/active/completed/archived) kunne
settes via API?
### Blind draw (skjult lagoppstilling) ### Blind draw (skjult lagoppstilling)
- **Status:** ✅ skjema (migrasjon 003, `lineup_lock`) + API bygget og verifisert - **Status:** ✅ skjema (migrasjon 003, `lineup_lock`) + API bygget og verifisert
@ -379,9 +385,9 @@
(manglende handicap-indeks) funnet og fikset samtidig. (manglende handicap-indeks) funnet og fikset samtidig.
- Kapteinene låser oppstillingen skjult; matchene avsløres samtidig når begge er - Kapteinene låser oppstillingen skjult; matchene avsløres samtidig når begge er
ferdige. ferdige.
- **Gjenstår:** hvem som FÅR låse et lag er i dag «rostret på laget» ELLER - **2026-07-19 (ADR-023):** hvem som FÅR låse et lag er nå kaptein-spesifikt
org-eier/admin (utvidet 2026-07-18, se «Brukerroller» over), ikke (eller org-eier/admin) — se «Brukerroller» over. Med fallback for lag uten
kaptein-spesifikt. utpekt kaptein ennå.
- **Nytt 2026-07-18, ✅ BYGGET (fant og fikset rett før frontend-skjermen):** - **Nytt 2026-07-18, ✅ BYGGET (fant og fikset rett før frontend-skjermen):**
`match_participant.tee_id` er påkrevd, men det fantes INGEN vei til å `match_participant.tee_id` er påkrevd, men det fantes INGEN vei til å
liste EN banes tee-er (selv offisielt importerte), og egendefinerte liste EN banes tee-er (selv offisielt importerte), og egendefinerte

View file

@ -3,9 +3,10 @@ Matcher, deltakere og blind draw-lås (ADR-013).
Autorisasjonsgrense (se plan): å opprette en match er organisator-arbeid (kun Autorisasjonsgrense (se plan): å opprette en match er organisator-arbeid (kun
org-medlemskap, som tournaments.py). Å legge til en DELTAKER eller LÅSE et lags org-medlemskap, som tournaments.py). Å legge til en DELTAKER eller LÅSE et lags
oppstilling krever i tillegg at brukeren har en team_roster-rad DET laget oppstilling krever i tillegg at brukeren er KAPTEIN for DET laget (eller
her er ikke sirkularitet et problem, siden roster-en da allerede finnes. org-eier/admin) -- se app/team_authz.py sin user_is_team_captain for den fulle
Bevisst ikke kapteins-only ennå (FEATURE_BACKLOG: åpent spørsmål). begrunnelsen, inkl. fallback for lag uten utpekt kaptein ennå. Byttet fra
"hvem som helst rostret" 2026-07-19 (Brukerroller-runden, ADR-023).
Synlighet (ADR-013): motstanderens deltakere er skjult i app-laget (ikke RLS Synlighet (ADR-013): motstanderens deltakere er skjult i app-laget (ikke RLS
begge lag er i samme organisasjon) til BEGGE lag har låst for økten. Filteret begge lag er i samme organisasjon) til BEGGE lag har låst for økten. Filteret
@ -29,7 +30,7 @@ from ..blind_draw import locked_team_ids, own_team_ids
from ..db import org_connection from ..db import org_connection
from ..errors import app_error, translate_db_errors from ..errors import app_error, translate_db_errors
from ..handicap import compute_and_store_side_handicaps, parse_allowance_config from ..handicap import compute_and_store_side_handicaps, parse_allowance_config
from ..team_authz import user_may_act_for_team from ..team_authz import user_is_team_captain
router = APIRouter() router = APIRouter()
@ -273,8 +274,10 @@ async def add_participant(
if tee_course_id is None or tee_course_id != session["course_id"]: if tee_course_id is None or tee_course_id != session["course_id"]:
raise app_error(400, "OUT_OF_SCOPE", "tee_id tilhører ikke øktens bane.") raise app_error(400, "OUT_OF_SCOPE", "tee_id tilhører ikke øktens bane.")
if not await user_may_act_for_team(conn, organization_id, expected_team_id, user.user_id): if not await user_is_team_captain(conn, organization_id, expected_team_id, user.user_id):
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.") raise app_error(
403, "NOT_TEAM_CAPTAIN", "Du må være kaptein for laget (eller organisasjonsadministrator) for å gjøre dette."
)
locked = await locked_team_ids(conn, match["session_id"]) locked = await locked_team_ids(conn, match["session_id"])
if expected_team_id in locked: if expected_team_id in locked:
@ -370,8 +373,10 @@ async def remove_participant(
team_id = row["team_a_id"] if row["team_side"] == "a" else row["team_b_id"] team_id = row["team_a_id"] if row["team_side"] == "a" else row["team_b_id"]
if not await user_may_act_for_team(conn, organization_id, team_id, user.user_id): if not await user_is_team_captain(conn, organization_id, team_id, user.user_id):
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.") raise app_error(
403, "NOT_TEAM_CAPTAIN", "Du må være kaptein for laget (eller organisasjonsadministrator) for å gjøre dette."
)
locked = await locked_team_ids(conn, row["session_id"]) locked = await locked_team_ids(conn, row["session_id"])
if team_id in locked: if team_id in locked:
@ -407,8 +412,10 @@ async def lock_lineup(
if session is None: if session is None:
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.") raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
if not await user_may_act_for_team(conn, organization_id, body.team_id, user.user_id): if not await user_is_team_captain(conn, organization_id, body.team_id, user.user_id):
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.") raise app_error(
403, "NOT_TEAM_CAPTAIN", "Du må være kaptein for laget (eller organisasjonsadministrator) for å gjøre dette."
)
# UNIQUE(session_id, team_id) gir 409 via translate_db_errors ved # UNIQUE(session_id, team_id) gir 409 via translate_db_errors ved
# dobbel-lås — ingen manuell sjekk nødvendig. # dobbel-lås — ingen manuell sjekk nødvendig.

View file

@ -6,8 +6,11 @@ Scoring (ADR-012): to moduser per økt, cachet matchstatus.
selve hull-resultatet -- handicapen er allerede bakt inn i selve hull-resultatet -- handicapen er allerede bakt inn i
match_participant fra oppsettsrunden). match_participant fra oppsettsrunden).
Autorisasjon: samme "rostret på laget"-grense som matches.py (team_authz.py), Autorisasjon: begrenset til den enkelte matchens FAKTISKE deltakere (en
IKKE kapteins-only ennå (FEATURE_BACKLOG: åpent spørsmål). match_participant-rad for brukeren i akkurat denne matchen), ikke bare
"noen på laget" og UAVHENGIG av kapteinmerket -- se app/team_authz.py sin
user_is_match_participant. Byttet fra "rostret på laget" 2026-07-19
(Brukerroller-runden, ADR-023).
Individuell-vs-delt-ball (FEATURE_BACKLOG sitt app-lags-punkt, lukkes her): Individuell-vs-delt-ball (FEATURE_BACKLOG sitt app-lags-punkt, lukkes her):
singles/fourball ha match_participant_id; foursome/greensome/scramble singles/fourball ha match_participant_id; foursome/greensome/scramble
@ -40,7 +43,7 @@ from ..auth import CurrentUser, get_authorized_org, get_current_user
from ..db import org_connection from ..db import org_connection
from ..errors import app_error, translate_db_errors from ..errors import app_error, translate_db_errors
from ..handicap import parse_allowance_config, relative_strokes_for_match from ..handicap import parse_allowance_config, relative_strokes_for_match
from ..team_authz import user_may_act_for_team from ..team_authz import user_is_match_participant
router = APIRouter() router = APIRouter()
@ -235,8 +238,7 @@ async def submit_hole_score(
async with org_connection(organization_id) as conn, translate_db_errors(): async with org_connection(organization_id) as conn, translate_db_errors():
match = await conn.fetchrow( match = await conn.fetchrow(
""" """
SELECT m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id, SELECT m.points_side_a::float AS points_side_a,
m.points_side_a::float AS points_side_a,
s.format, s.scoring_mode, s.hole_config::text AS hole_config s.format, s.scoring_mode, s.hole_config::text AS hole_config
FROM match m JOIN session s ON s.id = m.session_id FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1 WHERE m.id = $1
@ -272,8 +274,6 @@ async def submit_hole_score(
"Dette formatet bruker delt ball -- oppgi ikke match_participant_id.", "Dette formatet bruker delt ball -- oppgi ikke match_participant_id.",
) )
expected_team_id = match["team_a_id"] if body.team_side == "a" else match["team_b_id"]
if body.match_participant_id is not None: if body.match_participant_id is not None:
participant = await conn.fetchrow( participant = await conn.fetchrow(
""" """
@ -290,8 +290,14 @@ async def submit_hole_score(
400, "MISMATCHED_SIDE", "match_participant_id tilhører ikke angitt side." 400, "MISMATCHED_SIDE", "match_participant_id tilhører ikke angitt side."
) )
if not await user_may_act_for_team(conn, organization_id, expected_team_id, user.user_id): if not await user_is_match_participant(
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.") conn, organization_id, match_id, user.user_id, team_side=body.team_side
):
raise app_error(
403,
"NOT_MATCH_PARTICIPANT",
"Du er ikke en av deltakerne i denne matchen (eller organisasjonsadministrator).",
)
if body.match_participant_id is not None: if body.match_participant_id is not None:
row = await conn.fetchrow( row = await conn.fetchrow(
@ -357,8 +363,7 @@ async def submit_hole_result(
async with org_connection(organization_id) as conn, translate_db_errors(): async with org_connection(organization_id) as conn, translate_db_errors():
match = await conn.fetchrow( match = await conn.fetchrow(
""" """
SELECT m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id, SELECT m.points_side_a::float AS points_side_a,
m.points_side_a::float AS points_side_a,
s.scoring_mode, s.hole_config::text AS hole_config s.scoring_mode, s.hole_config::text AS hole_config
FROM match m JOIN session s ON s.id = m.session_id FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1 WHERE m.id = $1
@ -380,14 +385,14 @@ async def submit_hole_result(
if body.hole_number not in played: if body.hole_number not in played:
raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor øktens spilte omfang.") raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor øktens spilte omfang.")
# Begge lag kan rapportere et hull-resultat (hvilken som helst side kan # Begge sider kan rapportere et hull-resultat (hvilken som helst side kan
# vinne/tape/dele) -- brukeren må være rostret på ETT av de to lagene. # vinne/tape/dele) -- brukeren må selv være deltaker i DENNE matchen,
if not ( # på hvilken som helst av de to sidene (team_side=None).
await user_may_act_for_team(conn, organization_id, match["team_a_id"], user.user_id) if not await user_is_match_participant(conn, organization_id, match_id, user.user_id):
or await user_may_act_for_team(conn, organization_id, match["team_b_id"], user.user_id)
):
raise app_error( raise app_error(
403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på noen av lagene i denne matchen." 403,
"NOT_MATCH_PARTICIPANT",
"Du er ikke en av deltakerne i denne matchen (eller organisasjonsadministrator).",
) )
row = await conn.fetchrow( row = await conn.fetchrow(

View file

@ -353,6 +353,15 @@ async def add_roster_entry(
"SELECT handicap_index FROM player WHERE id = $1", body.player_id "SELECT handicap_index FROM player WHERE id = $1", body.player_id
) )
# Samme "kun én kaptein per lag"-invariant som update_roster_entry
# (ADR-023) -- håndhevet her også, selv om dagens frontend aldri
# sender is_captain=true ved opprettelse, for at API-et er korrekt
# uavhengig av klient.
if body.is_captain:
await conn.execute(
"UPDATE team_roster SET is_captain = false WHERE team_id = $1", team_id
)
row = await conn.fetchrow( row = await conn.fetchrow(
""" """
WITH inserted AS ( WITH inserted AS (
@ -389,10 +398,19 @@ async def update_roster_entry(
body: RosterEntryUpdate, body: RosterEntryUpdate,
organization_id: str = Depends(get_authorized_org), organization_id: str = Depends(get_authorized_org),
) -> RosterEntry: ) -> RosterEntry:
# Bevisst enkelt: setter/fjerner kapteinmerket på NØYAKTIG denne raden, # 2026-07-19 (Brukerroller-runden, ADR-023): kaptein er nå en reell
# håndhever ikke "kun én kaptein per lag" -- kaptein er i dag bare et # autorisasjonsrolle (se app/team_authz.py sin user_is_team_captain), så
# merke, ikke en egen autorisasjonsrolle (se FEATURE_BACKLOG). # "kun én kaptein per lag" håndheves eksplisitt her -- å sette en NY
# kaptein fjerner automatisk merket fra en ev. tidligere kaptein på
# SAMME lag, i samme transaksjon (org_connection åpner allerede én).
# Å fjerne kapteinmerket (is_captain=false) rører ingen andre rader.
async with org_connection(organization_id) as conn: async with org_connection(organization_id) as conn:
if body.is_captain:
await conn.execute(
"UPDATE team_roster SET is_captain = false WHERE team_id = $1 AND id <> $2",
team_id,
roster_id,
)
row = await conn.fetchrow( row = await conn.fetchrow(
""" """
WITH updated AS ( WITH updated AS (

View file

@ -1,46 +1,46 @@
""" """
Delt autorisasjonssjekk: kan brukeren handle vegne av et gitt lag? Delt autorisasjonssjekk: to distinkte spørsmål, to funksjoner.
Brukt av både matches.py (deltaker/lås-skriving) og scoring.py (score-skriving). 1. `user_is_team_captain` -- "kan brukeren sette opp/fjerne/låse LAGETS
oppstilling?" Brukt av matches.py sin add_participant/remove_participant/
lock_lineup, FØR noen match_participant-rad i det hele tatt finnes for
brukeren selv (kylling-og-egg: man kan ikke kreve at brukeren ALLEREDE er
deltaker for å lov til å LEGGE TIL deltakere).
To uavhengige veier inn: 2026-07-19 (Brukerroller-runden): byttet fra "hvem som helst rostret på
1. Rostret laget -- spiller med `player.user_id` koblet nettopp DENNE laget" til kaptein (`team_roster.is_captain = true`) ELLER org-eier/admin
team_roster-raden (den opprinnelige, fortsatt gjeldende regelen). -- kapteinen er en reell autorisasjonsrolle, ikke bare et visningsmerke.
2. Org-eier/admin i organisasjonen laget tilhører. Lagt til 2026-07-18: uten Se ARCHITECTURE_DECISIONS.md for hele begrunnelsen.
dette kunne INGEN sette opp eller låse et lag før minst én spiller hadde
logget inn og blitt koblet -- vanlig tidlig i en turnering, og
organisatoren (som uansett allerede ser begge lags fulle troppe-liste,
blind draw skjuler kun selve kamp-paringen) satt fast.
Bevisst INGEN unntak for at organisatoren selv er rostret Bevisst unntak: har laget INGEN utpekt kaptein ennå, godtas enhver
MOTSTANDERLAGET i samme turnering -- vurdert og avvist: TeeCup er et rostret spiller i stedet for å låse laget helt ute -- vanlig tidlig i en
tillitsbasert verktøy for klubber/vennegjenger, ikke en sikkerhets- turnering før noen har rukket å utpeke en kaptein (og en reell risiko
grense mot en fiendtlig organisator (som uansett har full administrativ funnet i eksisterende produksjonsdata: langt fra alle roster-rader har en
tilgang), og et slikt unntak ville skapt en reell låsning (ingen kan kaptein i dag). Har laget FØRST fått en kaptein (`update_roster_entry`
sette opp MOTSTANDERLAGET om det heller ikke har noen innlogget spiller håndhever "kun én kaptein per lag"), er det utelukkende den som
-- verre enn problemet det skulle løse). gjelder -- ingen andre rostrede spillere.
2. `user_is_match_participant` -- "kan brukeren føre/korrigere score for
DENNE spesifikke matchen?" Brukt av scoring.py. Krever en faktisk
match_participant-rad for brukeren i akkurat denne matchen (valgfritt
begrenset til én side via `team_side`) -- IKKE bare rostret laget, og
UAVHENGIG av kapteinmerket (å være kaptein gir ikke i seg selv rett til å
føre score for en match man selv ikke spiller).
Begge har samme org-eier/admin-fallback, av samme grunn som tidligere
(2026-07-18-runden): uten den kunne INGEN sette opp eller føre noe før minst
én spiller hadde logget inn og blitt koblet til sin player-rad. Bevisst
INGEN unntak for at organisatoren selv er rostret MOTSTANDERLAGET i samme
turnering -- vurdert og avvist tidligere: TeeCup er et tillitsbasert verktøy
for klubber/vennegjenger, ikke en sikkerhetsgrense mot en fiendtlig
organisator (som uansett har full administrativ tilgang), og et slikt
unntak ville skapt en reell låsning.
""" """
from asyncpg import Connection from asyncpg import Connection
async def user_may_act_for_team( async def _is_org_admin(conn: Connection, organization_id: str, user_id: str) -> bool:
conn: Connection, organization_id: str, team_id: str, user_id: str
) -> bool:
is_rostered = await conn.fetchval(
"""
SELECT EXISTS (
SELECT 1 FROM team_roster tr
JOIN player p ON p.id = tr.player_id
WHERE tr.team_id = $1 AND p.user_id = $2
)
""",
team_id,
user_id,
)
if is_rostered:
return True
# organization_membership har ingen RLS-policy (se auth.get_authorized_org # organization_membership har ingen RLS-policy (se auth.get_authorized_org
# sin egen kommentar) -- filtrert eksplisitt på organization_id her, trygt # sin egen kommentar) -- filtrert eksplisitt på organization_id her, trygt
# på samme tilkobling uansett hvilken app.current_org som er satt. # på samme tilkobling uansett hvilken app.current_org som er satt.
@ -55,3 +55,65 @@ async def user_may_act_for_team(
user_id, user_id,
) )
return bool(is_org_admin) return bool(is_org_admin)
async def user_is_team_captain(
conn: Connection, organization_id: str, team_id: str, user_id: str
) -> bool:
has_captain = await conn.fetchval(
"SELECT EXISTS (SELECT 1 FROM team_roster WHERE team_id = $1 AND is_captain = true)",
team_id,
)
if has_captain:
may_act = await conn.fetchval(
"""
SELECT EXISTS (
SELECT 1 FROM team_roster tr
JOIN player p ON p.id = tr.player_id
WHERE tr.team_id = $1 AND p.user_id = $2 AND tr.is_captain = true
)
""",
team_id,
user_id,
)
else:
may_act = await conn.fetchval(
"""
SELECT EXISTS (
SELECT 1 FROM team_roster tr
JOIN player p ON p.id = tr.player_id
WHERE tr.team_id = $1 AND p.user_id = $2
)
""",
team_id,
user_id,
)
if may_act:
return True
return await _is_org_admin(conn, organization_id, user_id)
async def user_is_match_participant(
conn: Connection,
organization_id: str,
match_id: str,
user_id: str,
team_side: str | None = None,
) -> bool:
is_participant = await conn.fetchval(
"""
SELECT EXISTS (
SELECT 1 FROM match_participant mp
JOIN team_roster tr ON tr.id = mp.team_roster_id
JOIN player p ON p.id = tr.player_id
WHERE mp.match_id = $1 AND p.user_id = $2
AND ($3::text IS NULL OR mp.team_side::text = $3)
)
""",
match_id,
user_id,
team_side,
)
if is_participant:
return True
return await _is_org_admin(conn, organization_id, user_id)

View file

@ -8,6 +8,7 @@ import {
CalendarClock, CalendarClock,
Check, Check,
ChevronDown, ChevronDown,
ChevronRight,
Clock, Clock,
Flag, Flag,
MapPin, MapPin,
@ -16,6 +17,7 @@ import {
Plus, Plus,
Sparkles, Sparkles,
Trash2, Trash2,
Users,
X, X,
} from "lucide-react" } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@ -479,6 +481,11 @@ function SessionCard({
<span>Starter hull {session.start_hole}</span> <span>Starter hull {session.start_hole}</span>
</div> </div>
)} )}
<div className="mt-1 flex items-center gap-1.5 text-sm font-semibold text-primary">
<Users aria-hidden="true" className="size-4 shrink-0" />
<span>Sett opp flights og lås oppstilling</span>
<ChevronRight aria-hidden="true" className="size-4 shrink-0" />
</div>
</Link> </Link>
{mode === "confirmingDelete" && {mode === "confirmingDelete" &&