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:
parent
8e0d696859
commit
df08339654
11 changed files with 374 additions and 103 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
""",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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
|
||||
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"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-3 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: t.team_color ?? "#64748b" }}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<h3 className="truncate text-base font-bold text-foreground">{t.tournament_name}</h3>
|
||||
<TournamentStatusBadge status={t.status} />
|
||||
</div>
|
||||
<span className="truncate text-sm text-muted-foreground">
|
||||
{t.organization_name} · {t.team_name}
|
||||
</span>
|
||||
{t.next_session_at && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Calendar aria-hidden="true" className="size-4 shrink-0" />
|
||||
<span className="tabular-nums">{formatNextSession(t.next_session_at)}</span>
|
||||
{tournaments.map((t) => {
|
||||
const orgParam = `org=${t.organization_id}&name=${encodeURIComponent(t.tournament_name)}`
|
||||
return (
|
||||
<div
|
||||
key={`${t.organization_id}-${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"
|
||||
>
|
||||
<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"
|
||||
style={{ backgroundColor: t.team_color ?? "#64748b" }}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<h3 className="truncate text-base font-bold text-foreground">{t.tournament_name}</h3>
|
||||
<TournamentStatusBadge status={t.status} />
|
||||
</div>
|
||||
<span className="truncate text-sm text-muted-foreground">
|
||||
{t.organization_name} · {t.team_name}
|
||||
</span>
|
||||
{t.next_session_at && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Calendar aria-hidden="true" className="size-4 shrink-0" />
|
||||
<span className="tabular-nums">{formatNextSession(t.next_session_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
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>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground"
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue