Update Todos

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).
This commit is contained in:
Erol Haagenrud 2026-07-22 05:46:47 +02:00
parent 8e0d696859
commit df08339654
11 changed files with 374 additions and 103 deletions

View file

@ -334,7 +334,13 @@
"Bash(curl -s -o /dev/null -w \"verify-email: %{http_code}\\\\n\" https://teecup.teeoff.no/verify-email)", "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(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/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": [ "additionalDirectories": [
"/opt/teeoff/deploy", "/opt/teeoff/deploy",

View file

@ -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å — Brukeren påpekte 2026-07-20 at dagens tomme-tilstand ("Du har ingen
opprett en") er organisator-vridd og ikke stemmer med hva en fersk bruker organisasjon ennå — opprett en") er organisator-vridd og ikke stemmer med
faktisk trenger å se/gjøre. Bedt om en refleksjon FØR bygging. 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 **2026-07-21 — premisset er endret, ikke bare forslaget:** brukeren stilte
med kode (ADR-020) går allerede utenom (login-siden sender dem rett til selv spørsmålet om organisasjon fortsatt bør være "det som meldes først" —
turneringen). En spiller som er ROSTRET på et lag dukker nå opp i "Mine gitt ADR-031 (Mine runder), ADR-032 (e-post/mobil som personlig identitet)
runder" (ADR-031). Men en spiller som kun er PÅMELDT (`tournament_ og det nye ønsket om frittstående rundeføring med statistikk (se egen
registration`, ADR-017) og ikke ennå rostret, dukker IKKE opp — "Mine seksjon rett under), er en vanlig bruker først og fremst en GOLFSPILLER,
runder" v1 ser kun på `team_roster` (kjent, allerede notert begrensning, og det å arrangere turneringer er én av flere ting en spiller *kan* gjøre —
se ADR-031 sitt "naturlig neste steg"-punkt over). Denne gruppen — trolig ikke forutsetningen for å bruke appen i det hele tatt. **Vurdering: ja,
en av de vanligste måtene en helt fersk bruker faktisk møter TeeCup på — organisasjon bør slutte å være default/første-handling**, og bli ett
faller derfor rett igjennom til "opprett organisasjon"-skjermen i dag. 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 1. Utvid "Mine runder" til også å vise rene påmeldinger (ikke bare
rostrede lag) — tetter selve hullet, reduserer hvor ofte noen i det rostrede lag).
hele tatt havner på tom-skjermen.
2. Gjør selve tom-skjermen nøytral: to likestilte valg side ved side — 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 "Har du en kode?" og "Skal du arrangere selv? Opprett organisasjon".
`JoinByCode`) og "Skal du arrangere selv? Opprett organisasjon" — i
stedet for at organisator-veien er det eneste synlige alternativet.
**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.
--- ---

View file

@ -559,7 +559,15 @@ class MyOrg(BaseModel):
class MyTournament(BaseModel): class MyTournament(BaseModel):
"""Én rad = ett lag brukeren er ROSTRET på (ADR-031, 'Mine runder'). """Én rad = ett lag brukeren er ROSTRET på (ADR-031, 'Mine runder').
Bevisst utenfor omfang v1: turneringer der brukeren kun er PÅMELDT Bevisst utenfor omfang v1: turneringer der brukeren kun er PÅMELDT
(`tournament_registration`) men ikke ennå rostret et lag.""" (`tournament_registration`) men ikke ennå rostret 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 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_id: str
organization_name: str organization_name: str
@ -570,6 +578,8 @@ class MyTournament(BaseModel):
team_name: str team_name: str
team_color: str | None team_color: str | None
next_session_at: datetime | None next_session_at: datetime | None
my_session_id: str | None
my_match_id: str | None
class Me(BaseModel): class Me(BaseModel):
@ -652,11 +662,20 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
t.status::text AS status, t.status::text AS status,
tm.id::text AS team_id, tm.name AS team_name, tm.color AS team_color, tm.id::text AS team_id, tm.name AS team_name, tm.color AS team_color,
(SELECT min(s.scheduled_at) FROM session s (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 FROM player pl
JOIN team_roster tr ON tr.player_id = pl.id JOIN team_roster tr ON tr.player_id = pl.id
JOIN team tm ON tm.id = tr.team_id JOIN team tm ON tm.id = tr.team_id
JOIN tournament t ON t.id = tm.tournament_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 WHERE pl.user_id = $1
ORDER BY t.created_at DESC ORDER BY t.created_at DESC
""", """,

View file

@ -14,9 +14,10 @@ bruker foreløpig kun course_id som fremmednøkkel, ikke banens innhold).
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from .. import teeoff_client 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 ..db import org_connection
from ..errors import app_error, translate_db_errors from ..errors import app_error, translate_db_errors
from ..team_authz import is_org_member, user_is_org_player
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
router = APIRouter() router = APIRouter()
@ -214,9 +215,19 @@ class Hole(BaseModel):
@router.get("/orgs/{organization_id}/courses/{course_id}/holes", response_model=list[Hole]) @router.get("/orgs/{organization_id}/courses/{course_id}/holes", response_model=list[Hole])
async def list_holes( async def list_holes(
course_id: str, course_id: str,
organization_id: str = Depends(get_authorized_org), organization_id: str,
user: CurrentUser = Depends(get_current_user),
) -> list[Hole]: ) -> 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: 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( rows = await conn.fetch(
"SELECT hole_number, par, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number", "SELECT hole_number, par, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number",
course_id, course_id,

View file

@ -30,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_is_team_captain from ..team_authz import is_org_member, user_is_team_captain, user_is_tournament_participant
router = APIRouter() router = APIRouter()
@ -223,10 +223,21 @@ async def fetch_matches(
) )
async def list_matches( async def list_matches(
session_id: str, session_id: str,
organization_id: str = Depends(get_authorized_org), organization_id: str,
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
) -> list[MatchOut]: ) -> 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: 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) return await fetch_matches(conn, organization_id, session_id, user.user_id)
@ -244,7 +255,7 @@ class ParticipantCreate(BaseModel):
async def add_participant( async def add_participant(
match_id: str, match_id: str,
body: ParticipantCreate, body: ParticipantCreate,
organization_id: str = Depends(get_authorized_org), organization_id: str,
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
) -> MatchParticipantOut: ) -> MatchParticipantOut:
async with org_connection(organization_id) as conn, translate_db_errors(): async with org_connection(organization_id) as conn, translate_db_errors():
@ -393,7 +404,7 @@ async def add_participant(
async def remove_participant( async def remove_participant(
match_id: str, match_id: str,
participant_id: str, participant_id: str,
organization_id: str = Depends(get_authorized_org), organization_id: str,
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
) -> None: ) -> None:
"""Fjern et deltaker-valg FØR laget har låst -- lar en kaptein endre seg """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( async def lock_lineup(
session_id: str, session_id: str,
body: LockCreate, body: LockCreate,
organization_id: str = Depends(get_authorized_org), organization_id: str,
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
) -> LockOut: ) -> LockOut:
async with org_connection(organization_id) as conn, translate_db_errors(): async with org_connection(organization_id) as conn, translate_db_errors():

View file

@ -27,7 +27,7 @@ from collections import defaultdict
from fastapi import APIRouter, Depends, File, Form, UploadFile, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Depends, File, Form, UploadFile, WebSocket, WebSocketDisconnect
from pydantic import BaseModel 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 ..auth import get_current_user_from_websocket
from ..db import org_connection, plain_connection from ..db import org_connection, plain_connection
from ..errors import app_error, translate_db_errors 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]) @router.get("/orgs/{organization_id}/teams/{team_id}/messages", response_model=list[MessageOut])
async def list_team_messages( async def list_team_messages(
team_id: str, team_id: str,
organization_id: str = Depends(get_authorized_org), organization_id: str,
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
) -> list[MessageOut]: ) -> list[MessageOut]:
async with org_connection(organization_id) as conn: async with org_connection(organization_id) as conn:
@ -156,9 +156,9 @@ async def list_team_messages(
) )
async def send_team_message( async def send_team_message(
team_id: str, team_id: str,
organization_id: str,
body: str | None = Form(default=None), body: str | None = Form(default=None),
image: UploadFile | None = File(default=None), image: UploadFile | None = File(default=None),
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
) -> MessageOut: ) -> MessageOut:
if not body and image is None: if not body and image is None:
@ -209,7 +209,7 @@ async def send_team_message(
async def delete_team_message( async def delete_team_message(
team_id: str, team_id: str,
message_id: str, message_id: str,
organization_id: str = Depends(get_authorized_org), organization_id: str,
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
) -> None: ) -> None:
"""Kun forfatteren selv -- INGEN org-admin-unntak (samme begrunnelse som """Kun forfatteren selv -- INGEN org-admin-unntak (samme begrunnelse som

View file

@ -27,6 +27,7 @@ from ..blind_draw import locked_team_ids
from ..db import org_connection, plain_connection from ..db import org_connection, plain_connection
from ..errors import app_error, translate_db_errors from ..errors import app_error, translate_db_errors
from .. import storage from .. import storage
from ..team_authz import user_is_tournament_participant
from .matches import MatchOut, fetch_matches from .matches import MatchOut, fetch_matches
from .scoring import Scorecard, fetch_scorecard from .scoring import Scorecard, fetch_scorecard
from .tournaments import Leaderboard, SessionOut, _fetch_sessions, fetch_leaderboard 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: async def is_participant(conn, user_id: str, organization_id: str, tournament_id: str) -> bool:
"""Har brukerens koblede spillerprofil (ADR-017 Beslutning B) en """Tynn wrapper -- selve sjekken flyttet til team_authz.py 2026-07-21
registrering ELLER en rostret plass i NØYAKTIG denne turneringen?""" (`user_is_tournament_participant`) slik at org-scopede endepunkter
return await conn.fetchval( (matches.py/scoring.py/tournaments.py) kan gjenbruke den uten sirkulær
""" import. Beholder denne funksjonens navn/argumentrekkefølge uendret her
SELECT EXISTS ( for å unngå å røre alle kallsteder i denne filen."""
SELECT 1 FROM player p return await user_is_tournament_participant(conn, organization_id, tournament_id, user_id)
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,
)
def code_matches(actual_join_code: str, provided_code: str | None) -> bool: def code_matches(actual_join_code: str, provided_code: str | None) -> bool:

View file

@ -46,12 +46,17 @@ from pydantic import BaseModel, Field
from handicap_engine import HoleResult, allocate_over_played_holes, compute_match_state 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 ..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 ..realtime import broadcast_live_update 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() router = APIRouter()
@ -275,7 +280,7 @@ class ConcedeRequest(BaseModel):
async def concede_match( async def concede_match(
match_id: str, match_id: str,
body: ConcedeRequest, body: ConcedeRequest,
organization_id: str = Depends(get_authorized_org), organization_id: str,
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
) -> ConcedeOut: ) -> ConcedeOut:
"""Walkover/konsesjon (ADR-024): kun kaptein for det TAPENDE laget (laget """Walkover/konsesjon (ADR-024): kun kaptein for det TAPENDE laget (laget
@ -344,7 +349,7 @@ class HoleScoreOut(BaseModel):
async def submit_hole_score( async def submit_hole_score(
match_id: str, match_id: str,
body: HoleScoreCreate, body: HoleScoreCreate,
organization_id: str = Depends(get_authorized_org), organization_id: str,
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
) -> HoleScoreOut: ) -> HoleScoreOut:
async with org_connection(organization_id) as conn, translate_db_errors(): async with org_connection(organization_id) as conn, translate_db_errors():
@ -469,7 +474,7 @@ class HoleResultOut(BaseModel):
async def submit_hole_result( async def submit_hole_result(
match_id: str, match_id: str,
body: HoleResultCreate, body: HoleResultCreate,
organization_id: str = Depends(get_authorized_org), organization_id: str,
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
) -> HoleResultOut: ) -> HoleResultOut:
async with org_connection(organization_id) as conn, translate_db_errors(): 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( async def get_scorecard(
match_id: str, match_id: str,
organization_id: str = Depends(get_authorized_org), organization_id: str,
user: CurrentUser = Depends(get_current_user),
) -> Scorecard: ) -> 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: 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) return await fetch_scorecard(conn, match_id)

View file

@ -20,7 +20,7 @@ from ..blind_draw import locked_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_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 .scoring import apply_concession, recompute_and_cache_match_state
from .. import storage from .. import storage
@ -248,9 +248,21 @@ class Team(BaseModel):
@router.get("/orgs/{organization_id}/tournaments/{tournament_id}/teams", response_model=list[Team]) @router.get("/orgs/{organization_id}/tournaments/{tournament_id}/teams", response_model=list[Team])
async def list_teams( async def list_teams(
tournament_id: str, tournament_id: str,
organization_id: str = Depends(get_authorized_org), organization_id: str,
user: CurrentUser = Depends(get_current_user),
) -> list[Team]: ) -> 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: 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( rows = await conn.fetch(
"SELECT id::text, name, color FROM team WHERE tournament_id = $1 ORDER BY created_at", "SELECT id::text, name, color FROM team WHERE tournament_id = $1 ORDER BY created_at",
tournament_id, tournament_id,
@ -329,7 +341,7 @@ class ConcedeTournamentOut(BaseModel):
async def concede_tournament( async def concede_tournament(
tournament_id: str, tournament_id: str,
body: ConcedeTournamentRequest, body: ConcedeTournamentRequest,
organization_id: str = Depends(get_authorized_org), organization_id: str,
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
) -> ConcedeTournamentOut: ) -> ConcedeTournamentOut:
async with org_connection(organization_id) as conn, translate_db_errors(): 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( async def list_sessions(
tournament_id: str, tournament_id: str,
organization_id: str = Depends(get_authorized_org), organization_id: str,
user: CurrentUser = Depends(get_current_user),
) -> list[SessionOut]: ) -> 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,
dette utvider ikke hva som er synlig, kun HVEM som kan det via
denne org-scopede stien."""
async with org_connection(organization_id) as conn: 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) return await _fetch_sessions(conn, tournament_id)

View file

@ -119,6 +119,76 @@ async def user_is_match_participant(
return await is_org_admin(conn, organization_id, user_id) 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
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
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: 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- """Kun for lag-chat (ADR-025 Beslutning C) -- BEVISST INGEN org-admin-
fallback, ulikt de to funksjonene over. «Det hemmelige rommet» er ekte fallback, ulikt de to funksjonene over. «Det hemmelige rommet» er ekte

View file

@ -4,7 +4,21 @@ import type React from "react"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import Link from "next/link" 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 { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
@ -33,6 +47,10 @@ type MyTournament = {
team_name: string team_name: string
team_color: string | null team_color: string | null
next_session_at: 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 = { type Me = {
@ -254,22 +272,28 @@ export function Dashboard() {
// --- "Mine runder" (ADR-031) ----------------------------------------------- // --- "Mine runder" (ADR-031) -----------------------------------------------
// Turneringer brukeren er SPILLER i, uavhengig av organisasjonsmedlemskap. // Turneringer brukeren er SPILLER i, uavhengig av organisasjonsmedlemskap.
// Lenker til den offentlige turnering-siden (fungerer for enhver deltaker, // Kortets hoveddel lenker til den offentlige turnering-siden. Fra
// se ADR-031-utvidelsen av check_visibility) -- IKKE til lag-chat/ // 2026-07-21 (deltaker-tilgang-runden) har kortet i tillegg en
// scorekort ennå, som fortsatt krever organisasjonsmedlemskap (kjent, // handlingsrad med lenker RETT til lagets private chat og (hvis brukeren
// notert begrensning, se FEATURE_BACKLOG.md). // 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[] }) { function MyToursSection({ tournaments }: { tournaments: MyTournament[] }) {
return ( return (
<section className="flex flex-col gap-3" aria-label="Mine runder"> <section className="flex flex-col gap-3" aria-label="Mine runder">
<h2 className="text-sm font-bold uppercase tracking-wide text-muted-foreground">Mine runder</h2> <h2 className="text-sm font-bold uppercase tracking-wide text-muted-foreground">Mine runder</h2>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{tournaments.map((t) => ( {tournaments.map((t) => {
<Link const orgParam = `org=${t.organization_id}&name=${encodeURIComponent(t.tournament_name)}`
return (
<div
key={`${t.organization_id}-${t.tournament_id}`} key={`${t.organization_id}-${t.tournament_id}`}
href={`/t/${t.tournament_id}`} className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5 transition-colors hover:border-primary/50 sm:p-5"
className="group flex items-center gap-4 rounded-2xl border border-border bg-card p-4 text-left shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/50 sm:p-5"
> >
<Link href={`/t/${t.tournament_id}`} className="group flex items-center gap-4 text-left">
<span <span
aria-hidden="true" aria-hidden="true"
className="size-3 shrink-0 rounded-full" className="size-3 shrink-0 rounded-full"
@ -295,7 +319,28 @@ function MyToursSection({ tournaments }: { tournaments: MyTournament[] }) {
className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground" className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground"
/> />
</Link> </Link>
))}
<div className="flex flex-wrap gap-2 border-t border-border pt-3">
<Link
href={`/tournaments/${t.tournament_id}/teams/${t.team_id}/chat?${orgParam}`}
className="inline-flex h-9 items-center gap-1.5 rounded-xl border border-border bg-background px-3 text-sm font-semibold text-foreground transition-colors hover:bg-accent/50"
>
<MessageCircle aria-hidden="true" className="size-4" />
Lag-chat
</Link>
{t.my_match_id && t.my_session_id && (
<Link
href={`/tournaments/${t.tournament_id}/sessions/${t.my_session_id}/matches/${t.my_match_id}?${orgParam}`}
className="inline-flex h-9 items-center gap-1.5 rounded-xl border border-border bg-background px-3 text-sm font-semibold text-foreground transition-colors hover:bg-accent/50"
>
<ClipboardList aria-hidden="true" className="size-4" />
Scorekort
</Link>
)}
</div>
</div>
)
})}
</div> </div>
</section> </section>
) )