From df08339654b2d3dacf975f72ac381b9536db3c78 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Wed, 22 Jul 2026 05:46:47 +0200 Subject: [PATCH] Update Todos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flytte is_participant-logikk til team_authz.py (unngå sirkulær import) Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup Widen tournaments.py: list_sessions/list_teams/concede_tournament Widen courses.py: list_holes Widen messaging.py: team chat REST-endepunkter (list/send/delete) Legge til my_session_id/my_match_id i /auth/me sin my_tournaments Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder» Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller) Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag Presentere plan og be om bekreftelse før utrulling mot ekte teecup_db/containere Bygge sekundær e-postadresse (enkelt tilfelle, ADR-032-mønster) Bygge HCP-historikk over tid Deltaker-tilgang til lag-chat/scorekort er bygget og grundig scratch-verifisert (43/43 sjekker, test_isolation.sql fortsatt 12/12). Kort oppsummert hva som er gjort: Backend — den blanke org-medlemskaps-sperren (get_authorized_org) er fjernet fra ni endepunkter (lag-chat lese/skrive/slette, scorekort-lesing, slag/hull-resultat-innsending, walkover på match- og turneringsnivå, match-/lag-/økt-listing, bane-hull) og erstattet med de faktiske, allerede eksisterende autorisasjonssjekkene (user_is_rostered_on_team, user_is_match_participant, user_is_team_captain — alle uendret) — pluss et nytt eksplisitt org-medlem ELLER turnering-deltaker-OR på de endepunktene som tidligere ikke hadde noen finkornet sjekk i det hele tatt (der en ren fjerning ville åpnet dem for enhver innlogget bruker). /auth/me viser nå også my_session_id/my_match_id per rad i "Mine runder". Frontend — "Mine runder"-kortet har fått to nye handlingslenker: "Lag-chat" (alltid) og "Scorekort" (når spilleren har en aktiv match). Verifisert grundig: en rostret spiller UTEN organisasjonsmedlemskap får nå riktig tilgang til alt dette; en helt fremmed bruker avvises fortsatt overalt; org-eier beholder full tilgang som før; lag-chatten er fortsatt ekte privat (org-eier fortsatt avvist der, med vilje); kryss-org-isolasjon holder; og en rostret-men-ikke-kaptein spiller ble korrekt avvist fra walkover (fant samtidig en eksisterende, dokumentert fallback-oppførsel — et lag uten utpekt kaptein godtar enhver rostret spiller — som viste seg å fungere akkurat som tiltenkt). --- .claude/settings.local.json | 8 ++- FEATURE_BACKLOG.md | 112 +++++++++++++++++++++++------ app/routers/auth.py | 23 +++++- app/routers/courses.py | 15 +++- app/routers/matches.py | 21 ++++-- app/routers/messaging.py | 8 +-- app/routers/registration.py | 31 ++------ app/routers/scoring.py | 40 +++++++++-- app/routers/tournaments.py | 34 +++++++-- app/team_authz.py | 70 ++++++++++++++++++ frontend/components/dashboard.tsx | 115 +++++++++++++++++++++--------- 11 files changed, 374 insertions(+), 103 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 81737f7..cc4d655 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -334,7 +334,13 @@ "Bash(curl -s -o /dev/null -w \"verify-email: %{http_code}\\\\n\" https://teecup.teeoff.no/verify-email)", "Bash(grep -rln \"orgs/\\\\[id\\\\]/members\\\\|orgs/\\\\${.*}/members\\\\|/orgs/\\\\\" .*members\" /opt/teecup/frontend --include=\"*.tsx\" --include=\"*.ts\")", "Bash(curl -s http://localhost:18734/organizations/00000000-0000-0000-0000-000000000000/members?name=Test)", - "Bash(curl -s http://localhost:18734/orgs/00000000-0000-0000-0000-000000000000/members)" + "Bash(curl -s http://localhost:18734/orgs/00000000-0000-0000-0000-000000000000/members)", + "Bash(grep -n \"fetch\\(\\\\`/orgs\" frontend/components/session-scorecard.tsx)", + "Bash(python3 -m py_compile app/routers/scoring.py app/routers/matches.py app/routers/tournaments.py app/routers/courses.py app/routers/messaging.py app/routers/registration.py app/team_authz.py)", + "Bash(python3 -m py_compile app/routers/auth.py)", + "Bash(python3 -m py_compile app/routers/auth.py app/routers/scoring.py app/routers/matches.py app/routers/tournaments.py app/routers/courses.py app/routers/messaging.py app/routers/registration.py app/team_authz.py)", + "Bash(rm -f /opt/requirements.txt)", + "Bash(python3 -m py_compile app/routers/*.py app/*.py)" ], "additionalDirectories": [ "/opt/teeoff/deploy", diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 107562d..3fadd63 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -1142,32 +1142,104 @@ koblingen skal skje). --- -## Dashboard: tom-tilstand ved første innlogging — 📋 FORESLÅTT 2026-07-20, IKKE bygget +## Dashboard: tom-tilstand ved første innlogging — 📋 UNDER REVURDERING (2026-07-21), IKKE bygget -Brukeren påpekte at dagens tomme-tilstand ("Du har ingen organisasjon ennå — -opprett en") er organisator-vridd og ikke stemmer med hva en fersk bruker -faktisk trenger å se/gjøre. Bedt om en refleksjon FØR bygging. +Brukeren påpekte 2026-07-20 at dagens tomme-tilstand ("Du har ingen +organisasjon ennå — opprett en") er organisator-vridd og ikke stemmer med +hva en fersk bruker faktisk trenger å se/gjøre. Et opprinnelig forslag +(utvid "Mine runder" til påmeldinger + nøytral to-valgs tom-skjerm, se +historikk under) ble lagt frem 2026-07-20 — brukeren ba 2026-07-21 +eksplisitt om å justere retningen i lys av en dypere refleksjon, se under. -**Analyse av hvem som faktisk lander på tom-skjermen:** en spiller invitert -med kode (ADR-020) går allerede utenom (login-siden sender dem rett til -turneringen). En spiller som er ROSTRET på et lag dukker nå opp i "Mine -runder" (ADR-031). Men en spiller som kun er PÅMELDT (`tournament_ -registration`, ADR-017) og ikke ennå rostret, dukker IKKE opp — "Mine -runder" v1 ser kun på `team_roster` (kjent, allerede notert begrensning, -se ADR-031 sitt "naturlig neste steg"-punkt over). Denne gruppen — trolig -en av de vanligste måtene en helt fersk bruker faktisk møter TeeCup på — -faller derfor rett igjennom til "opprett organisasjon"-skjermen i dag. +**2026-07-21 — premisset er endret, ikke bare forslaget:** brukeren stilte +selv spørsmålet om organisasjon fortsatt bør være "det som meldes først" — +gitt ADR-031 (Mine runder), ADR-032 (e-post/mobil som personlig identitet) +og det nye ønsket om frittstående rundeføring med statistikk (se egen +seksjon rett under), er en vanlig bruker først og fremst en GOLFSPILLER, +og det å arrangere turneringer er én av flere ting en spiller *kan* gjøre — +ikke forutsetningen for å bruke appen i det hele tatt. **Vurdering: ja, +organisasjon bør slutte å være default/første-handling**, og bli ett +likestilt valg blant flere fremtidige "første ting du kan gjøre" +(bli med i en turnering via kode, registrere en runde selv, ELLER +arrangere/opprette organisasjon) — ikke lenger den ENESTE synlige veien +inn. -**Foreslått, IKKE bygget:** +**Konsekvens for byggerekkefølgen:** selve tom-skjerm-redesignet er satt +PÅ VENT til frittstående runder (under) er avklart nok til å vite hvilken +tredje kortform den skal ha på tom-skjermen — å bygge en to-valgs versjon +nå og redesigne den på nytt om kort tid ville vært dobbeltarbeid. Punktet +"utvid Mine runder til rene påmeldinger" (fra 2026-07-20-forslaget) +henger IKKE sammen med denne avhengigheten og kan bygges uavhengig når som +helst — fortsatt et åpent, godt avgrenset TODO. + +**Opprinnelig forslag (2026-07-20), for historikkens skyld:** 1. Utvid "Mine runder" til også å vise rene påmeldinger (ikke bare - rostrede lag) — tetter selve hullet, reduserer hvor ofte noen i det - hele tatt havner på tom-skjermen. + rostrede lag). 2. Gjør selve tom-skjermen nøytral: to likestilte valg side ved side — - "Har du en kode?" (gjenbruker samme oppslag som login-siden sin - `JoinByCode`) og "Skal du arrangere selv? Opprett organisasjon" — i - stedet for at organisator-veien er det eneste synlige alternativet. + "Har du en kode?" og "Skal du arrangere selv? Opprett organisasjon". -**Venter på brukerens bekreftelse på retning** før bygging. +**Venter på:** en videre avklaring av frittstående runder (under) før +tom-skjermens endelige form kan bestemmes. + +--- + +## Frittstående rundeføring + detaljert statistikk (uten turnering/organisasjon) — 📋 NOTERT 2026-07-21, IKKE designet/bygget + +Reist av brukeren 2026-07-21, eksplisitt begrunnet som relevant for +hvordan dashbordet skal se ut fremover (se punktet over) — derfor fanget +grundig her selv om ingenting bygges i denne runden. + +**Brukerens beskrevne behov, fanget presist:** en bruker skal kunne +registrere en golfrunde HELT UAVHENGIG av enhver turnering eller +organisasjon — verken tilhørighet til et lag, en turnering, eller en +organisasjon skal være en forutsetning. Kan føres kun for seg selv, ELLER +for andre man spiller sammen med i flighten (ikke nødvendigvis +TeeCup-brukere). Statistikk utover selve slagtallet: +- Antall slag på hullet (allerede dekket av eksisterende `hole_score`-form) +- Antall putter +- Antall chip +- Antall bunkerslag +- Antall straffeslag +- Lengde på første putt +- "og lignende" — trolig flere finkornede stats brukeren vil spesifisere + nærmere når dette faktisk designes + +**Dette er IKKE en liten dashboard-finpuss — det utfordrer en av +arkitektur-invariantene i CLAUDE.md direkte:** "Tenant = organisasjon. +`organization_id` på alle domenetabeller, håndhevet av RLS." En +frittstående runde har per definisjon INGEN organisasjon å henge +`organization_id` på — dagens RLS-modell (org_isolation-policyer som +alle stoler på `app.current_org`) dekker rett og slett ikke dette +tilfellet. Dette krever en ny, egen beslutning (sannsynligvis en helt ny +ADR) om et PARALLELT eierskaps-/isolasjonsmønster keyet på `user_id` +(`app_user.id`) i stedet for `organization_id` — ikke en utvidelse av et +eksisterende mønster, men en ny gren i tenant-modellen. Presist hvilke +tabeller som trengs (egen `personal_round`? egen +`personal_round_hole_stat`? gjenbruk av eksisterende `hole_score`-form med +en nullable `organization_id` og en NY RLS-policy for "eier = current +user"?) er IKKE avklart — bevisst ikke gjettet på her. + +**Andre åpne spørsmål som trengs FØR design/bygging, ikke besvart av +brukerens beskrivelse ennå:** +- Hvordan identifiseres "andre man spiller med i flighten" når de ikke + nødvendigvis er TeeCup-brukere — frittstående "midlertidige" spiller- + rader (ala `player`, men uten organisasjonstilhørighet), eller rene + navn uten noen kobling i det hele tatt? +- Skal disse rundene noensinne telle inn i HCP-beregning/-historikk (se + eget punkt under), eller er de rent loggførende (som en digital + scorekort-dagbok)? +- Skal banedata (hull/par/stroke index/tee-rating) hentes fra samme + `course`-modell som i dag (org-scopet), eller trengs en egen, + org-uavhengig banekatalog for dette bruksmønsteret (en spiller uten + noen organisasjon i det hele tatt må fortsatt kunne velge en bane)? +- Skal frittstående runder vises i "Mine runder" på dashbordet sammen med + turnering-rundene, eller i en egen seksjon? + +**Bevisst IKKE startet i denne runden** — dette bør bli sin egen, +dedikerte ADR-runde (arkitektur-invariant-nivå beslutning, ikke et +tillegg til en dashboard/konto-poleringsrunde), men er tatt med i +vurderingen av tom-tilstand-redesignet over siden det direkte påvirker +hvilke "første handling"-alternativer dashbordet bør vise i fremtiden. --- diff --git a/app/routers/auth.py b/app/routers/auth.py index a058615..7263622 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -559,7 +559,15 @@ class MyOrg(BaseModel): class MyTournament(BaseModel): """Én rad = ett lag brukeren er ROSTRET på (ADR-031, 'Mine runder'). Bevisst utenfor omfang v1: turneringer der brukeren kun er PÅMELDT - (`tournament_registration`) men ikke ennå rostret på et lag.""" + (`tournament_registration`) men ikke ennå rostret på et lag. + + `my_session_id`/`my_match_id` (2026-07-21, deltaker-tilgang-runden): + én av brukerens egne matcher (foretrekker en ikke-avgjort fremfor en + avgjort, ellers vilkårlig) -- lar frontend lenke direkte til lagets + chat/scorekort uten at brukeren selv må navigere seg dit via + program-/blind draw-skjermene (som fortsatt krever org-medlemskap for + andre formål enn nettopp dette). `None` hvis brukeren ikke er lagt til + i noen match ennå.""" organization_id: str organization_name: str @@ -570,6 +578,8 @@ class MyTournament(BaseModel): team_name: str team_color: str | None next_session_at: datetime | None + my_session_id: str | None + my_match_id: str | None class Me(BaseModel): @@ -652,11 +662,20 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me: t.status::text AS status, tm.id::text AS team_id, tm.name AS team_name, tm.color AS team_color, (SELECT min(s.scheduled_at) FROM session s - WHERE s.tournament_id = t.id AND s.scheduled_at > now()) AS next_session_at + WHERE s.tournament_id = t.id AND s.scheduled_at > now()) AS next_session_at, + mymatch.session_id AS my_session_id, mymatch.match_id AS my_match_id FROM player pl JOIN team_roster tr ON tr.player_id = pl.id JOIN team tm ON tm.id = tr.team_id JOIN tournament t ON t.id = tm.tournament_id + LEFT JOIN LATERAL ( + SELECT m.id::text AS match_id, m.session_id::text AS session_id + FROM match_participant mp + JOIN match m ON m.id = mp.match_id + WHERE mp.team_roster_id = tr.id + ORDER BY (m.points_side_a IS NULL) DESC, m.created_at DESC + LIMIT 1 + ) mymatch ON true WHERE pl.user_id = $1 ORDER BY t.created_at DESC """, diff --git a/app/routers/courses.py b/app/routers/courses.py index 07094aa..11983ee 100644 --- a/app/routers/courses.py +++ b/app/routers/courses.py @@ -14,9 +14,10 @@ bruker foreløpig kun course_id som fremmednøkkel, ikke banens innhold). from fastapi import APIRouter, Depends, Query from .. import teeoff_client -from ..auth import get_authorized_org +from ..auth import CurrentUser, get_authorized_org, get_current_user from ..db import org_connection from ..errors import app_error, translate_db_errors +from ..team_authz import is_org_member, user_is_org_player from pydantic import BaseModel, Field router = APIRouter() @@ -214,9 +215,19 @@ class Hole(BaseModel): @router.get("/orgs/{organization_id}/courses/{course_id}/holes", response_model=list[Hole]) async def list_holes( course_id: str, - organization_id: str = Depends(get_authorized_org), + organization_id: str, + user: CurrentUser = Depends(get_current_user), ) -> list[Hole]: + """2026-07-21 (deltaker-tilgang-runden): org-medlem ELLER en bruker + koblet til NOEN spillerprofil i organisasjonen (`user_is_org_player`, + bevisst løsere enn en full turnering-deltaker-sjekk -- par/stroke-index + er lavsensitiv banedata, ikke spillerdata, og en bane er ikke bundet til + én bestemt turnering).""" async with org_connection(organization_id) as conn: + if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_org_player( + conn, organization_id, user.user_id + ): + raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne organisasjonen.") rows = await conn.fetch( "SELECT hole_number, par, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number", course_id, diff --git a/app/routers/matches.py b/app/routers/matches.py index 6442b30..c70dbab 100644 --- a/app/routers/matches.py +++ b/app/routers/matches.py @@ -30,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_is_team_captain +from ..team_authz import is_org_member, user_is_team_captain, user_is_tournament_participant router = APIRouter() @@ -223,10 +223,21 @@ async def fetch_matches( ) async def list_matches( session_id: str, - organization_id: str = Depends(get_authorized_org), + organization_id: str, user: CurrentUser = Depends(get_current_user), ) -> list[MatchOut]: + """2026-07-21 (deltaker-tilgang-runden): fjernet get_authorized_org sin + blanke org-medlemskapssperre -- erstattet med et eksplisitt OR + (org-medlem ELLER deltaker i turneringen økten hører til), samme + begrunnelse som scoring.py sin get_scorecard.""" async with org_connection(organization_id) as conn: + tournament_id = await conn.fetchval("SELECT tournament_id::text FROM session WHERE id = $1", session_id) + if tournament_id is None: + raise app_error(404, "NOT_FOUND", "Økten finnes ikke.") + if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant( + conn, organization_id, tournament_id, user.user_id + ): + raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.") return await fetch_matches(conn, organization_id, session_id, user.user_id) @@ -244,7 +255,7 @@ class ParticipantCreate(BaseModel): async def add_participant( match_id: str, body: ParticipantCreate, - organization_id: str = Depends(get_authorized_org), + organization_id: str, user: CurrentUser = Depends(get_current_user), ) -> MatchParticipantOut: async with org_connection(organization_id) as conn, translate_db_errors(): @@ -393,7 +404,7 @@ async def add_participant( async def remove_participant( match_id: str, participant_id: str, - organization_id: str = Depends(get_authorized_org), + organization_id: str, user: CurrentUser = Depends(get_current_user), ) -> None: """Fjern et deltaker-valg FØR laget har låst -- lar en kaptein endre seg @@ -450,7 +461,7 @@ class LockOut(BaseModel): async def lock_lineup( session_id: str, body: LockCreate, - organization_id: str = Depends(get_authorized_org), + organization_id: str, user: CurrentUser = Depends(get_current_user), ) -> LockOut: async with org_connection(organization_id) as conn, translate_db_errors(): diff --git a/app/routers/messaging.py b/app/routers/messaging.py index 7465f80..f0c892d 100644 --- a/app/routers/messaging.py +++ b/app/routers/messaging.py @@ -27,7 +27,7 @@ from collections import defaultdict from fastapi import APIRouter, Depends, File, Form, UploadFile, WebSocket, WebSocketDisconnect from pydantic import BaseModel -from ..auth import CurrentUser, get_authorized_org, get_current_user, get_current_user_optional +from ..auth import CurrentUser, get_current_user, get_current_user_optional from ..auth import get_current_user_from_websocket from ..db import org_connection, plain_connection from ..errors import app_error, translate_db_errors @@ -131,7 +131,7 @@ async def _is_org_member(user_id: str, organization_id: str) -> bool: @router.get("/orgs/{organization_id}/teams/{team_id}/messages", response_model=list[MessageOut]) async def list_team_messages( team_id: str, - organization_id: str = Depends(get_authorized_org), + organization_id: str, user: CurrentUser = Depends(get_current_user), ) -> list[MessageOut]: async with org_connection(organization_id) as conn: @@ -156,9 +156,9 @@ async def list_team_messages( ) async def send_team_message( team_id: str, + organization_id: str, body: str | None = Form(default=None), image: UploadFile | None = File(default=None), - organization_id: str = Depends(get_authorized_org), user: CurrentUser = Depends(get_current_user), ) -> MessageOut: if not body and image is None: @@ -209,7 +209,7 @@ async def send_team_message( async def delete_team_message( team_id: str, message_id: str, - organization_id: str = Depends(get_authorized_org), + organization_id: str, user: CurrentUser = Depends(get_current_user), ) -> None: """Kun forfatteren selv -- INGEN org-admin-unntak (samme begrunnelse som diff --git a/app/routers/registration.py b/app/routers/registration.py index 3485f9c..5b0e8f4 100644 --- a/app/routers/registration.py +++ b/app/routers/registration.py @@ -27,6 +27,7 @@ from ..blind_draw import locked_team_ids from ..db import org_connection, plain_connection from ..errors import app_error, translate_db_errors from .. import storage +from ..team_authz import user_is_tournament_participant from .matches import MatchOut, fetch_matches from .scoring import Scorecard, fetch_scorecard from .tournaments import Leaderboard, SessionOut, _fetch_sessions, fetch_leaderboard @@ -44,30 +45,12 @@ async def resolve_org(tournament_id: str) -> str: async def is_participant(conn, user_id: str, organization_id: str, tournament_id: str) -> bool: - """Har brukerens koblede spillerprofil (ADR-017 Beslutning B) en - registrering ELLER en rostret plass i NØYAKTIG denne turneringen?""" - return await conn.fetchval( - """ - SELECT EXISTS ( - SELECT 1 FROM player p - WHERE p.organization_id = $1 AND p.user_id = $2 - AND ( - EXISTS ( - SELECT 1 FROM tournament_registration tr - WHERE tr.player_id = p.id AND tr.tournament_id = $3 - ) - OR EXISTS ( - SELECT 1 FROM team_roster tro - JOIN team t ON t.id = tro.team_id - WHERE tro.player_id = p.id AND t.tournament_id = $3 - ) - ) - ) - """, - organization_id, - user_id, - tournament_id, - ) + """Tynn wrapper -- selve sjekken flyttet til team_authz.py 2026-07-21 + (`user_is_tournament_participant`) slik at org-scopede endepunkter + (matches.py/scoring.py/tournaments.py) kan gjenbruke den uten sirkulær + import. Beholder denne funksjonens navn/argumentrekkefølge uendret her + for å unngå å røre alle kallsteder i denne filen.""" + return await user_is_tournament_participant(conn, organization_id, tournament_id, user_id) def code_matches(actual_join_code: str, provided_code: str | None) -> bool: diff --git a/app/routers/scoring.py b/app/routers/scoring.py index 9363a77..b31a6e4 100644 --- a/app/routers/scoring.py +++ b/app/routers/scoring.py @@ -46,12 +46,17 @@ from pydantic import BaseModel, Field from handicap_engine import HoleResult, allocate_over_played_holes, compute_match_state -from ..auth import CurrentUser, get_authorized_org, get_current_user +from ..auth import CurrentUser, 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 ..realtime import broadcast_live_update -from ..team_authz import user_is_match_participant, user_is_team_captain +from ..team_authz import ( + is_org_member, + user_is_match_participant, + user_is_team_captain, + user_is_tournament_participant, +) router = APIRouter() @@ -275,7 +280,7 @@ class ConcedeRequest(BaseModel): async def concede_match( match_id: str, body: ConcedeRequest, - organization_id: str = Depends(get_authorized_org), + organization_id: str, user: CurrentUser = Depends(get_current_user), ) -> ConcedeOut: """Walkover/konsesjon (ADR-024): kun kaptein for det TAPENDE laget (laget @@ -344,7 +349,7 @@ class HoleScoreOut(BaseModel): async def submit_hole_score( match_id: str, body: HoleScoreCreate, - organization_id: str = Depends(get_authorized_org), + organization_id: str, user: CurrentUser = Depends(get_current_user), ) -> HoleScoreOut: async with org_connection(organization_id) as conn, translate_db_errors(): @@ -469,7 +474,7 @@ class HoleResultOut(BaseModel): async def submit_hole_result( match_id: str, body: HoleResultCreate, - organization_id: str = Depends(get_authorized_org), + organization_id: str, user: CurrentUser = Depends(get_current_user), ) -> HoleResultOut: async with org_connection(organization_id) as conn, translate_db_errors(): @@ -611,7 +616,30 @@ async def fetch_scorecard(conn, match_id: str) -> Scorecard: ) async def get_scorecard( match_id: str, - organization_id: str = Depends(get_authorized_org), + organization_id: str, + user: CurrentUser = Depends(get_current_user), ) -> Scorecard: + """2026-07-21 (deltaker-tilgang-runden): denne hadde tidligere INGEN + egen sjekk utover get_authorized_org (org-medlemskap) -- å bare fjerne + den ville latt EN HVILKEN SOM HELST autentisert bruker se ethvert + scorekort i organisasjonen. Erstattet med et eksplisitt OR: org-medlem + (bevarer eksisterende oppførsel for dagens brukere uendret) ELLER + deltaker i turneringen matchen hører til (ny -- gir en rostret/påmeldt + spiller uten org-medlemskap lesetilgang til scorekort i EGEN turnering, + samme brede "kan se andres matcher i egen turnering"-nivå som + tilskuer-siden ADR-026 allerede gir enhver som kan se turneringen).""" async with org_connection(organization_id) as conn: + tournament_id = await conn.fetchval( + """ + SELECT s.tournament_id::text FROM match m JOIN session s ON s.id = m.session_id + WHERE m.id = $1 + """, + match_id, + ) + if tournament_id is None: + raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.") + if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant( + conn, organization_id, tournament_id, user.user_id + ): + raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.") return await fetch_scorecard(conn, match_id) diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py index 22e473c..a993625 100644 --- a/app/routers/tournaments.py +++ b/app/routers/tournaments.py @@ -20,7 +20,7 @@ from ..blind_draw import locked_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_is_team_captain +from ..team_authz import is_org_member, user_is_team_captain, user_is_tournament_participant from .scoring import apply_concession, recompute_and_cache_match_state from .. import storage @@ -248,9 +248,21 @@ class Team(BaseModel): @router.get("/orgs/{organization_id}/tournaments/{tournament_id}/teams", response_model=list[Team]) async def list_teams( tournament_id: str, - organization_id: str = Depends(get_authorized_org), + organization_id: str, + user: CurrentUser = Depends(get_current_user), ) -> list[Team]: + """2026-07-21 (deltaker-tilgang-runden): org-medlem ELLER deltaker i + denne turneringen, samme mønster som scoring.py sin get_scorecard -- + laste team-navn/-farge er en forutsetning for at scorekort-siden i + det hele tatt kan rendres for en deltaker uten org-medlemskap.""" async with org_connection(organization_id) as conn: + exists = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id) + if exists is None: + raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") + if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant( + conn, organization_id, tournament_id, user.user_id + ): + raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.") rows = await conn.fetch( "SELECT id::text, name, color FROM team WHERE tournament_id = $1 ORDER BY created_at", tournament_id, @@ -329,7 +341,7 @@ class ConcedeTournamentOut(BaseModel): async def concede_tournament( tournament_id: str, body: ConcedeTournamentRequest, - organization_id: str = Depends(get_authorized_org), + organization_id: str, user: CurrentUser = Depends(get_current_user), ) -> ConcedeTournamentOut: async with org_connection(organization_id) as conn, translate_db_errors(): @@ -641,9 +653,23 @@ async def _fetch_sessions(conn, tournament_id: str) -> list[SessionOut]: ) async def list_sessions( tournament_id: str, - organization_id: str = Depends(get_authorized_org), + organization_id: str, + user: CurrentUser = Depends(get_current_user), ) -> list[SessionOut]: + """2026-07-21 (deltaker-tilgang-runden): org-medlem ELLER deltaker i + denne turneringen, samme mønster som list_teams over -- innholdet er + for øvrig identisk med det offentlige `/public/.../sessions`- + endepunktet (ADR-018) for enhver som allerede kan se turneringen, så + dette utvider ikke hva som er synlig, kun HVEM som kan nå det via + denne org-scopede stien.""" async with org_connection(organization_id) as conn: + exists = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id) + if exists is None: + raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") + if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant( + conn, organization_id, tournament_id, user.user_id + ): + raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.") return await _fetch_sessions(conn, tournament_id) diff --git a/app/team_authz.py b/app/team_authz.py index f582381..4835406 100644 --- a/app/team_authz.py +++ b/app/team_authz.py @@ -119,6 +119,76 @@ async def user_is_match_participant( return await is_org_admin(conn, organization_id, user_id) +async def is_org_member(conn: Connection, organization_id: str, user_id: str) -> bool: + """ETHVERT medlemskap (ikke bare owner/admin, se is_org_admin over) -- + brukt som den ene halvparten av OR-et som (2026-07-21, deltaker-tilgang- + runden) erstattet den tidligere blanke get_authorized_org-sperren på + read-endepunkter som selv ikke har noen mer finkornet sjekk (list_teams, + list_sessions, get_scorecard) -- bevarer eksisterende org-medlemmers + tilgang uendret, samtidig som en ikke-medlem deltaker slipper gjennom + via user_is_tournament_participant under.""" + return bool( + await conn.fetchval( + "SELECT EXISTS (SELECT 1 FROM organization_membership WHERE organization_id = $1 AND user_id = $2)", + organization_id, + user_id, + ) + ) + + +async def user_is_org_player(conn: Connection, organization_id: str, user_id: str) -> bool: + """Er brukeren koblet til NOEN spillerprofil i denne organisasjonen (ikke + nødvendigvis en gitt turnering) -- brukt for lavsensitiv data som + bane-/hull-info (courses.py sin list_holes), der en full + turnering-deltaker-sjekk ville vært unødvendig presis for hva som + faktisk beskyttes (par/stroke-index, ikke spillerdata).""" + return bool( + await conn.fetchval( + "SELECT EXISTS (SELECT 1 FROM player WHERE organization_id = $1 AND user_id = $2)", + organization_id, + user_id, + ) + ) + + +async def user_is_tournament_participant( + conn: Connection, organization_id: str, tournament_id: str, user_id: str +) -> bool: + """Har brukerens koblede spillerprofil (ADR-017 Beslutning B) en + registrering ELLER en rostret plass i NØYAKTIG denne turneringen? + + Flyttet hit fra registration.py 2026-07-21 (het `is_participant` der) -- + org-scopede endepunkter (matches.py/scoring.py/tournaments.py) kan ikke + importere fra registration.py uten sirkulær import (registration.py + importerer FRA disse tre for øvrig), men alle tre importerer allerede + fritt fra denne (dependency-frie) modulen. registration.py importerer nå + denne i stedet for sin egen kopi.""" + return bool( + await conn.fetchval( + """ + SELECT EXISTS ( + SELECT 1 FROM player p + WHERE p.organization_id = $1 AND p.user_id = $2 + AND ( + EXISTS ( + SELECT 1 FROM tournament_registration tr + WHERE tr.player_id = p.id AND tr.tournament_id = $3 + ) + OR EXISTS ( + SELECT 1 FROM team_roster tro + JOIN team t ON t.id = tro.team_id + WHERE tro.player_id = p.id AND t.tournament_id = $3 + ) + ) + ) + """, + organization_id, + user_id, + tournament_id, + ) + ) + + async def user_is_rostered_on_team(conn: Connection, team_id: str, user_id: str) -> bool: """Kun for lag-chat (ADR-025 Beslutning C) -- BEVISST INGEN org-admin- fallback, ulikt de to funksjonene over. «Det hemmelige rommet» er ekte diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx index 838cd62..d30935b 100644 --- a/frontend/components/dashboard.tsx +++ b/frontend/components/dashboard.tsx @@ -4,7 +4,21 @@ import type React from "react" import { useEffect, useState } from "react" import { useRouter } from "next/navigation" import Link from "next/link" -import { Building2, Calendar, ChevronRight, ChevronsUpDown, LogOut, Plus, Trophy, Users, UserCircle, X, Check } from "lucide-react" +import { + Building2, + Calendar, + ChevronRight, + ChevronsUpDown, + ClipboardList, + LogOut, + MessageCircle, + Plus, + Trophy, + Users, + UserCircle, + X, + Check, +} from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" @@ -33,6 +47,10 @@ type MyTournament = { team_name: string team_color: string | null next_session_at: string | null + // 2026-07-21 (deltaker-tilgang-runden): en av brukerens egne matcher, hvis + // noen finnes -- lar kortet lenke direkte til lagets chat/scorekort. + my_session_id: string | null + my_match_id: string | null } type Me = { @@ -254,48 +272,75 @@ export function Dashboard() { // --- "Mine runder" (ADR-031) ----------------------------------------------- // Turneringer brukeren er SPILLER i, uavhengig av organisasjonsmedlemskap. -// Lenker til den offentlige turnering-siden (fungerer for enhver deltaker, -// se ADR-031-utvidelsen av check_visibility) -- IKKE til lag-chat/ -// scorekort ennå, som fortsatt krever organisasjonsmedlemskap (kjent, -// notert begrensning, se FEATURE_BACKLOG.md). +// Kortets hoveddel lenker til den offentlige turnering-siden. Fra +// 2026-07-21 (deltaker-tilgang-runden) har kortet i tillegg en +// handlingsrad med lenker RETT til lagets private chat og (hvis brukeren +// har en match) scorekortet -- begge de org-scopede sidene, som nå +// fungerer for en rostret/påmeldt spiller UTEN organisasjonsmedlemskap +// (se team_authz.py sin user_is_rostered_on_team/user_is_match_participant, +// og den nye is_org_member-ELLER-user_is_tournament_participant-unionen på +// de øvrige org-scopede GET-endepunktene siden bruker). function MyToursSection({ tournaments }: { tournaments: MyTournament[] }) { return (

Mine runder

- {tournaments.map((t) => ( - -
)