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:
parent
a216e3945a
commit
e60a2aafe7
7 changed files with 266 additions and 103 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 på 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.
|
||||||
|
|
|
||||||
|
|
@ -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 MÅ ha match_participant_id; foursome/greensome/scramble MÅ
|
singles/fourball MÅ ha match_participant_id; foursome/greensome/scramble MÅ
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,46 @@
|
||||||
"""
|
"""
|
||||||
Delt autorisasjonssjekk: kan brukeren handle på 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 å få lov til å LEGGE TIL deltakere).
|
||||||
|
|
||||||
To uavhengige veier inn:
|
2026-07-19 (Brukerroller-runden): byttet fra "hvem som helst rostret på
|
||||||
1. Rostret på laget -- spiller med `player.user_id` koblet på nettopp DENNE
|
laget" til kaptein (`team_roster.is_captain = true`) ELLER org-eier/admin
|
||||||
team_roster-raden (den opprinnelige, fortsatt gjeldende regelen).
|
-- kapteinen er nå 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 på
|
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 nå "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 på 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 på 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)
|
||||||
|
|
|
||||||
|
|
@ -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 på hull {session.start_hole}</span>
|
<span>Starter på 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" &&
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue