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(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",

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å —
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.
---

View file

@ -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 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_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
""",

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 .. 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,

View file

@ -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():

View file

@ -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

View file

@ -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:

View file

@ -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)

View file

@ -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,
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:
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)

View file

@ -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
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:
"""Kun for lag-chat (ADR-025 Beslutning C) -- BEVISST INGEN org-admin-
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 { 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,22 +272,28 @@ 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 (
<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>
<div className="flex flex-col gap-3">
{tournaments.map((t) => (
<Link
{tournaments.map((t) => {
const orgParam = `org=${t.organization_id}&name=${encodeURIComponent(t.tournament_name)}`
return (
<div
key={`${t.organization_id}-${t.tournament_id}`}
href={`/t/${t.tournament_id}`}
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"
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"
>
<Link href={`/t/${t.tournament_id}`} className="group flex items-center gap-4 text-left">
<span
aria-hidden="true"
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"
/>
</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>
</section>
)