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å)
|
||||
|
||||
Disse må avklares før eller under de relevante fasene:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 på 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.
|
||||
|
|
|
|||
|
|
@ -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 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 ..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(
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
1. Rostret på laget -- spiller med `player.user_id` koblet på 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 nå en reell autorisasjonsrolle, ikke bare et visningsmerke.
|
||||
Se ARCHITECTURE_DECISIONS.md for hele begrunnelsen.
|
||||
|
||||
Bevisst INGEN unntak for at organisatoren selv er rostret på
|
||||
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 nå "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 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
|
||||
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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 på 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" &&
|
||||
|
|
|
|||
Loading…
Reference in a new issue