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(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",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 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_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
|
||||||
""",
|
""",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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, 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:
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 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:
|
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
|
||||||
|
|
|
||||||
|
|
@ -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,48 +272,75 @@ 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)}`
|
||||||
key={`${t.organization_id}-${t.tournament_id}`}
|
return (
|
||||||
href={`/t/${t.tournament_id}`}
|
<div
|
||||||
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"
|
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"
|
||||||
<span
|
>
|
||||||
aria-hidden="true"
|
<Link href={`/t/${t.tournament_id}`} className="group flex items-center gap-4 text-left">
|
||||||
className="size-3 shrink-0 rounded-full"
|
<span
|
||||||
style={{ backgroundColor: t.team_color ?? "#64748b" }}
|
aria-hidden="true"
|
||||||
/>
|
className="size-3 shrink-0 rounded-full"
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
style={{ backgroundColor: t.team_color ?? "#64748b" }}
|
||||||
<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>
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
<TournamentStatusBadge status={t.status} />
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
</div>
|
<h3 className="truncate text-base font-bold text-foreground">{t.tournament_name}</h3>
|
||||||
<span className="truncate text-sm text-muted-foreground">
|
<TournamentStatusBadge status={t.status} />
|
||||||
{t.organization_name} · {t.team_name}
|
</div>
|
||||||
</span>
|
<span className="truncate text-sm text-muted-foreground">
|
||||||
{t.next_session_at && (
|
{t.organization_name} · {t.team_name}
|
||||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
</span>
|
||||||
<Calendar aria-hidden="true" className="size-4 shrink-0" />
|
{t.next_session_at && (
|
||||||
<span className="tabular-nums">{formatNextSession(t.next_session_at)}</span>
|
<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>
|
</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>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue