diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index b5e6fe9..4629166 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -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: diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 4dc1069..afe988a 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -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 diff --git a/app/routers/matches.py b/app/routers/matches.py index 84687db..5136601 100644 --- a/app/routers/matches.py +++ b/app/routers/matches.py @@ -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. diff --git a/app/routers/scoring.py b/app/routers/scoring.py index 940744a..529e334 100644 --- a/app/routers/scoring.py +++ b/app/routers/scoring.py @@ -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( diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py index 27b59c5..259f283 100644 --- a/app/routers/tournaments.py +++ b/app/routers/tournaments.py @@ -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 ( diff --git a/app/team_authz.py b/app/team_authz.py index 8058336..ecc730c 100644 --- a/app/team_authz.py +++ b/app/team_authz.py @@ -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) diff --git a/frontend/components/tournament-program.tsx b/frontend/components/tournament-program.tsx index 3b1667f..b8ce1bc 100644 --- a/frontend/components/tournament-program.tsx +++ b/frontend/components/tournament-program.tsx @@ -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({ Starter på hull {session.start_hole} )} +
+
{mode === "confirmingDelete" &&