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å)
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)
### Brukerroller (utover org-medlemskap)
- **Status:** ❓ trenger beslutning
### Brukerroller (utover org-medlemskap) — ADR-023
- **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,
**tilskuer** (les-only, følger live uten skriverettigheter).
- Vi har i dag org-roller (owner/admin/member) + `is_captain` på roster.
- **Mangler:** «tilskuer» som begrep. Henger sammen med hvem som ser den
offentlige feeden (se Kommunikasjon). Kaptein-rollen bør kanskje gi spesifikke
rettigheter (sette oppstilling), ikke bare være et flagg.
- **Nytt 2026-07-18:** `PATCH .../roster/{id}` (sette/fjerne kaptein) håndhever
bevisst IKKE «kun én kaptein per lag» — flere spillere kan i dag merkes
kaptein samtidig på samme lag. Bør revurderes samtidig med resten av dette
punktet, ikke løses isolert i roster-endepunktet.
- **Nytt 2026-07-18, ✅ BYGGET:** `app/team_authz.py` sin `user_may_act_for_team`
(brukt av lås/deltakere i matches.py OG score-skriving i scoring.py) gir nå
også org-eier/admin (`organization_membership.role IN ('owner','admin')`)
samme rettigheter som en rostret spiller, på ETHVERT lag — reist av
brukeren rett før blind draw-skjermen: uten dette kunne INGEN sette opp
eller låse et lag før minst én spiller hadde logget inn og blitt koblet.
Bevisst INGEN unntak for at organisatoren selv er rostret på
MOTSTANDERLAGET (vurdert og avvist — se team_authz.py sin docstring for
full begrunnelse: tillitsbasert verktøy, organisator ser uansett begge
rostre allerede, og et unntak ville skapt en reell låsning der ingen
kunne sette opp motstanderlaget om det heller ikke har en innlogget
spiller). Verifisert i scratch: org-eier uten roster kan nå låse begge
lag + føre score, vanlig 'member'-rolle fortsatt blokkert, rostret
spiller uendret. Rullet ut live samme dag.
- **2026-07-19, ✅ BYGGET (ADR-023):** Kaptein er nå en REELL autorisasjonsrolle,
ikke bare et merke. `app/team_authz.py` sin nye `user_is_team_captain`
(erstatter `user_may_act_for_team`) krever `is_captain=true` (eller
org-eier/admin) for å legge til/fjerne deltakere og låse et lag
(`matches.py`). Bevisst unntak — funnet ved å faktisk sjekke ekte
produksjonsdata FØR utrulling: har laget INGEN utpekt kaptein ennå, godtas
enhver rostret spiller i stedet (ellers ville «De Unge»-laget i den ekte
«De Gamle er Eldst»-turneringen vært låst ute umiddelbart — 0 av 2
roster-rader er i dag merket kaptein der).
- **2026-07-19, ✅ BYGGET (ADR-023):** «Kun én kaptein per lag» håndheves nå —
`PATCH`/`POST .../roster` (`tournaments.py`) fjerner automatisk
kapteinmerket fra andre rader på samme lag når en ny kaptein settes.
Nødvendig konsekvens av at kaptein nå er en autorisasjonsrolle. Ingen
eksisterende lag hadde flere kapteiner (sjekket mot ekte data), så ingen
opprydning av data var nødvendig.
- **2026-07-19, ✅ BYGGET (ADR-023):** Score-føring/-korrigering begrenset til
matchens FAKTISKE deltakere (`app/team_authz.py` sin nye
`user_is_match_participant`, brukt av `scoring.py`) — ikke lenger «noen på
laget», og uavhengig av kapteinmerket. Erstatter «Scoring-autorisasjon»-
punktet under, spørsmål (a) er dermed besvart.
- **Tilskuer bevisst utsatt** til Kommunikasjon/Banter Board-runden (samme
begrunnelse som før: bør defineres sammen med feed-synligheten, ikke
isolert).
- Se ARCHITECTURE_DECISIONS.md ADR-023 for alle fire delbeslutningene og
CLAUDE.md-status for scratch-verifiseringen (15 automatiserte sjekker).
### Scoring-autorisasjon: hvem fører, hvem korrigerer, hvem lukker (rejst 2026-07-16)
- **Status:** ❓ trenger beslutning — direkte oppfølger av «Brukerroller» over.
- **Hvem fører score i dag:** alle med en `team_roster`-rad på laget, ELLER
org-eier/admin (utvidet 2026-07-18, se «Brukerroller» over) — samme
minimale grense som deltaker/lås, se ADR-013-relatert kode. Ikke
kaptein-only, ikke begrenset til de(n) som faktisk spiller matchen.
- **Status:**delvis avgjort — direkte oppfølger av «Brukerroller» over.
- **Hvem fører score i dag (2026-07-19, ADR-023):** kun matchens FAKTISKE
deltakere, ELLER org-eier/admin — `app/team_authz.py` sin
`user_is_match_participant`. Byttet fra «noen på laget» samme dag. Se
«Brukerroller» over for full begrunnelse.
- **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
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
(ikke en handling noen utfører), så «hvem får låse» ble aldri et
spørsmål som trengte avklaring.
- **Walkover/konsesjon VENTER** til brukerroller (kaptein/organisator,
punktet over) er avgjort — å bygge den nå på dagens løse
«rostret på laget»-grense betyr sannsynligvis å bygge den om senere.
- **Fortsatt åpent:** (a) skal score-føring begrenses til faktiske
matchdeltakere (ikke bare «noen på laget»)? (b) skal korrigering kreve
motpartens godkjenning, eller er upsert-modellen god nok for v1? (c) skal
turnering-status (draft/active/completed/archived) kunne settes via API?
- **Walkover/konsesjon kan nå tas fatt på** — brukerroller (kaptein/
deltaker-avgrensning) er avgjort og bygget (ADR-023), grunnlaget den
tidligere ventet på er ikke lenger løst.
- **Fortsatt åpent:** (a) ~~skal score-føring begrenses til faktiske
matchdeltakere~~ ✅ avgjort/bygget 2026-07-19 (ADR-023, se over). (b) skal
korrigering kreve motpartens godkjenning, eller er upsert-modellen god nok
for v1? (c) skal turnering-status (draft/active/completed/archived) kunne
settes via API?
### Blind draw (skjult lagoppstilling)
- **Status:** ✅ skjema (migrasjon 003, `lineup_lock`) + API bygget og verifisert
@ -379,9 +385,9 @@
(manglende handicap-indeks) funnet og fikset samtidig.
- Kapteinene låser oppstillingen skjult; matchene avsløres samtidig når begge er
ferdige.
- **Gjenstår:** hvem som FÅR låse et lag er i dag «rostret på laget» ELLER
org-eier/admin (utvidet 2026-07-18, se «Brukerroller» over), ikke
kaptein-spesifikt.
- **2026-07-19 (ADR-023):** hvem som FÅR låse et lag er nå kaptein-spesifikt
(eller org-eier/admin) — se «Brukerroller» over. Med fallback for lag uten
utpekt kaptein ennå.
- **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 å
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
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
her er ikke sirkularitet et problem, siden roster-en da allerede finnes.
Bevisst ikke kapteins-only ennå (FEATURE_BACKLOG: åpent spørsmål).
oppstilling krever i tillegg at brukeren er KAPTEIN for DET laget (eller
org-eier/admin) -- se app/team_authz.py sin user_is_team_captain for den fulle
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
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 ..errors import app_error, translate_db_errors
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()
@ -273,8 +274,10 @@ async def add_participant(
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.")
if not await user_may_act_for_team(conn, organization_id, expected_team_id, user.user_id):
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
if not await user_is_team_captain(conn, organization_id, expected_team_id, user.user_id):
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"])
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"]
if not await user_may_act_for_team(conn, organization_id, team_id, user.user_id):
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
if not await user_is_team_captain(conn, organization_id, team_id, user.user_id):
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"])
if team_id in locked:
@ -407,8 +412,10 @@ async def lock_lineup(
if session is None:
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):
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
if not await user_is_team_captain(conn, organization_id, body.team_id, user.user_id):
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
# 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
match_participant fra oppsettsrunden).
Autorisasjon: samme "rostret på laget"-grense som matches.py (team_authz.py),
IKKE kapteins-only ennå (FEATURE_BACKLOG: åpent spørsmål).
Autorisasjon: begrenset til den enkelte matchens FAKTISKE deltakere (en
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):
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 ..errors import app_error, translate_db_errors
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()
@ -235,8 +238,7 @@ async def submit_hole_score(
async with org_connection(organization_id) as conn, translate_db_errors():
match = await conn.fetchrow(
"""
SELECT 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,
SELECT m.points_side_a::float AS points_side_a,
s.format, s.scoring_mode, s.hole_config::text AS hole_config
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
@ -272,8 +274,6 @@ async def submit_hole_score(
"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:
participant = await conn.fetchrow(
"""
@ -290,8 +290,14 @@ async def submit_hole_score(
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):
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
if not await user_is_match_participant(
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:
row = await conn.fetchrow(
@ -357,8 +363,7 @@ async def submit_hole_result(
async with org_connection(organization_id) as conn, translate_db_errors():
match = await conn.fetchrow(
"""
SELECT 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,
SELECT m.points_side_a::float AS points_side_a,
s.scoring_mode, s.hole_config::text AS hole_config
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
@ -380,14 +385,14 @@ async def submit_hole_result(
if body.hole_number not in played:
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
# vinne/tape/dele) -- brukeren må være rostret på ETT av de to lagene.
if not (
await user_may_act_for_team(conn, organization_id, match["team_a_id"], user.user_id)
or await user_may_act_for_team(conn, organization_id, match["team_b_id"], user.user_id)
):
# Begge sider kan rapportere et hull-resultat (hvilken som helst side kan
# vinne/tape/dele) -- brukeren må selv være deltaker i DENNE matchen,
# på hvilken som helst av de to sidene (team_side=None).
if not await user_is_match_participant(conn, organization_id, match_id, user.user_id):
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(

View file

@ -353,6 +353,15 @@ async def add_roster_entry(
"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(
"""
WITH inserted AS (
@ -389,10 +398,19 @@ async def update_roster_entry(
body: RosterEntryUpdate,
organization_id: str = Depends(get_authorized_org),
) -> RosterEntry:
# Bevisst enkelt: setter/fjerner kapteinmerket på NØYAKTIG denne raden,
# håndhever ikke "kun én kaptein per lag" -- kaptein er i dag bare et
# merke, ikke en egen autorisasjonsrolle (se FEATURE_BACKLOG).
# 2026-07-19 (Brukerroller-runden, ADR-023): kaptein er nå en reell
# autorisasjonsrolle (se app/team_authz.py sin user_is_team_captain), så
# "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:
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(
"""
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:
1. Rostret laget -- spiller med `player.user_id` koblet nettopp DENNE
team_roster-raden (den opprinnelige, fortsatt gjeldende regelen).
2. Org-eier/admin i organisasjonen laget tilhører. Lagt til 2026-07-18: uten
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.
2026-07-19 (Brukerroller-runden): byttet fra "hvem som helst rostret på
laget" til kaptein (`team_roster.is_captain = true`) ELLER org-eier/admin
-- kapteinen er en reell autorisasjonsrolle, ikke bare et visningsmerke.
Se ARCHITECTURE_DECISIONS.md for hele begrunnelsen.
Bevisst INGEN unntak for at organisatoren selv er rostret
MOTSTANDERLAGET i samme turnering -- vurdert og avvist: TeeCup er et
tillitsbasert verktøy for klubber/vennegjenger, ikke en sikkerhets-
grense mot en fiendtlig organisator (som uansett har full administrativ
tilgang), og et slikt unntak ville skapt en reell låsning (ingen kan
sette opp MOTSTANDERLAGET om det heller ikke har noen innlogget spiller
-- verre enn problemet det skulle løse).
Bevisst unntak: har laget INGEN utpekt kaptein ennå, godtas enhver
rostret spiller i stedet for å låse laget helt ute -- vanlig tidlig i en
turnering før noen har rukket å utpeke en kaptein (og en reell risiko
funnet i eksisterende produksjonsdata: langt fra alle roster-rader har en
kaptein i dag). Har laget FØRST fått en kaptein (`update_roster_entry`
håndhever "kun én kaptein per lag"), er det utelukkende den som
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
async def user_may_act_for_team(
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
async def _is_org_admin(conn: Connection, organization_id: str, user_id: str) -> bool:
# organization_membership har ingen RLS-policy (se auth.get_authorized_org
# sin egen kommentar) -- filtrert eksplisitt på organization_id her, trygt
# på samme tilkobling uansett hvilken app.current_org som er satt.
@ -55,3 +55,65 @@ async def user_may_act_for_team(
user_id,
)
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,
Check,
ChevronDown,
ChevronRight,
Clock,
Flag,
MapPin,
@ -16,6 +17,7 @@ import {
Plus,
Sparkles,
Trash2,
Users,
X,
} from "lucide-react"
import { Button } from "@/components/ui/button"
@ -479,6 +481,11 @@ function SessionCard({
<span>Starter hull {session.start_hole}</span>
</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>
{mode === "confirmingDelete" &&