""" Offentlig, uautentisert registrerings- og landingsside-API (ADR-017, ADR-018). Bevisst egen fil med prefiks /public/..., atskilt fra /orgs/... -- gjør sikkerhetsgrensen eksplisitt i koden: disse endepunktene krever VERKEN innlogging (get_current_user) ELLER org-medlemskap (get_authorized_org) for å svare i det hele tatt. To SECURITY DEFINER-broer (public_tournament_org, public_org_by_slug) er den ENESTE måten disse endepunktene får vite riktig RLS-kontekst FØR den er kjent -- se ADR-017 Beslutning E / ADR-018 Beslutning F. VIKTIG (ADR-018 Beslutning B): RLS (org_isolation) beskytter kun TENANT- grenser (org A ser aldri org B), ikke INNHOLDS-synlighet innenfor riktig org-kontekst. `tournament.visibility` håndheves derfor eksplisitt her i applikasjonslaget, på hvert lese- OG skrive-endepunkt (registrering følger samme grense som lesing, ADR-018 Beslutning D) -- RLS alene løser det ikke. """ from datetime import date, datetime, timezone from fastapi import APIRouter, Depends from pydantic import BaseModel, EmailStr, Field from ..auth import CurrentUser, get_current_user_optional 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 .individual_tournaments import LeaderboardEntry, RoundCellOut, _compute_individual_standings from .matches import MatchOut, fetch_matches from .scoring import Scorecard, fetch_scorecard from .tournaments import Leaderboard, SessionOut, _fetch_sessions, fetch_leaderboard router = APIRouter(prefix="/public/tournaments", tags=["public-registration"]) org_router = APIRouter(prefix="/public/orgs", tags=["public-registration"]) async def resolve_org(tournament_id: str) -> str: async with plain_connection() as conn: org_id = await conn.fetchval("SELECT public_tournament_org($1)", tournament_id) if org_id is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") return str(org_id) async def is_participant(conn, user_id: str, organization_id: str, tournament_id: str) -> bool: """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: return provided_code is not None and provided_code.strip().lower() == actual_join_code.lower() async def check_visibility( conn, visibility: str, organization_id: str, tournament_id: str, user: CurrentUser | None, ) -> None: if visibility == "public": return if user is not None: is_member = await conn.fetchval( "SELECT EXISTS (SELECT 1 FROM organization_membership WHERE user_id = $1 AND organization_id = $2)", user.user_id, organization_id, ) if is_member: return # ADR-031: en faktisk DELTAKER skal aldri stenges ute av øktens # egen turnering, uansett hvilken av de to ikke-offentlige tierene # som er valgt -- visibility styrer eksponering mot UTENFORSTÅENDE, # ikke mot spillere som faktisk er med. Utvidet fra kun # visibility == "participants" (opprinnelig ADR-018 Beslutning C) # da "Mine runder"-dashbordet gjorde det tydelig at 'org' (som er # DEFAULT for enhver ny turnering) ellers ville stengt ute enhver # spiller uten organisasjonsmedlemskap fra sin egen turnering. if await is_participant(conn, user.user_id, organization_id, tournament_id): return raise app_error(403, "NOT_VISIBLE", "Du har ikke tilgang til denne turneringen.") class JoinCodeResult(BaseModel): tournament_id: str organization_id: str tournament_name: str @router.get("/by-code/{code}", response_model=JoinCodeResult) async def resolve_join_code(code: str) -> JoinCodeResult: """Løser en invitasjonskode til riktig turnering (ADR-020 Beslutning A) -- MÅ registreres FØR /{tournament_id} under, ellers ville "by-code" blitt tolket som en tournament_id (ugyldig UUID -> 500, ikke 404). Bevisst UAVHENGIG av tournament.visibility -- ingen check_visibility()- kall her, i motsetning til alle andre endepunkter i denne filen. Koden ER selve invitasjonen; å likevel kreve org-medlemskap/deltakerstatus ville gjort den verdiløs nettopp for personer som mangler det.""" async with plain_connection() as conn: tournament_id = await conn.fetchval("SELECT public_tournament_by_code($1)", code) if tournament_id is None: raise app_error(404, "NOT_FOUND", "Fant ingen turnering med denne koden.") organization_id = await resolve_org(str(tournament_id)) async with org_connection(organization_id) as conn: name = await conn.fetchval("SELECT name FROM tournament WHERE id = $1", tournament_id) return JoinCodeResult( tournament_id=str(tournament_id), organization_id=organization_id, tournament_name=name ) class SponsorOut(BaseModel): id: str name: str url: str | None logo_url: str | None class PublicTournamentInfo(BaseModel): id: str name: str organization_name: str status: str # 2026-08-21: manglet her -- landingssiden/"Følg live"-lenken kunne # derfor ikke skille individuell fra Cup-format offentlig, og rutet # ALLTID til Cup-sidens /t/{id}/live (som feilet stille for en # individuell turnering, se ARCHITECTURE_DECISIONS.md/CHANGELOG.md # samme dag). format_type: str # 2026-08-21: for offentlig visning av HCP på leaderboardet -- bruker: # "når det er brutto som er hovedkonkurransen trenger vi ikke se hcp". # HCP er kun meningsfullt der den faktisk brukes til slagfordeling # (netto/stableford/Københavner/BBB), ikke ren bruttoslagspill/eclectic # gross. `None` for lagturneringer (scoring_method er individuell-kun). scoring_method: str | None visibility: str description: str | None start_date: date | None end_date: date | None registration_open: bool registration_deadline: datetime | None registration_capacity: int | None confirmed_count: int hero_image_url: str | None sponsors: list[SponsorOut] @router.get("/{tournament_id}", response_model=PublicTournamentInfo) async def get_public_tournament( tournament_id: str, code: str | None = None, user: CurrentUser | None = Depends(get_current_user_optional), ) -> PublicTournamentInfo: organization_id = await resolve_org(tournament_id) async with org_connection(organization_id) as conn: row = await conn.fetchrow( """ SELECT t.id::text, t.name, o.name AS organization_name, t.status::text, t.format_type::text, t.scoring_method, t.visibility, t.description, t.hero_image_key, t.join_code, t.start_date, t.end_date, t.registration_deadline, t.registration_capacity, (SELECT count(*)::int FROM tournament_registration tr WHERE tr.tournament_id = t.id AND tr.status IN ('confirmed', 'pending') ) AS confirmed_count FROM tournament t JOIN organization o ON o.id = t.organization_id WHERE t.id = $1 """, tournament_id, ) if row is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") # ADR-020 Beslutning A: en matchende kode overstyrer visibility helt. if not code_matches(row["join_code"], code): await check_visibility(conn, row["visibility"], organization_id, tournament_id, user) sponsor_rows = await conn.fetch( "SELECT id::text, name, url, logo_key FROM tournament_sponsor WHERE tournament_id = $1 ORDER BY created_at", tournament_id, ) deadline = row["registration_deadline"] deadline_passed = deadline is not None and deadline < datetime.now(timezone.utc) return PublicTournamentInfo( id=row["id"], name=row["name"], organization_name=row["organization_name"], status=row["status"], format_type=row["format_type"], scoring_method=row["scoring_method"], visibility=row["visibility"], description=row["description"], start_date=row["start_date"], end_date=row["end_date"], registration_open=not deadline_passed, registration_deadline=deadline, registration_capacity=row["registration_capacity"], confirmed_count=row["confirmed_count"], hero_image_url=storage.public_url(row["hero_image_key"]) if row["hero_image_key"] else None, sponsors=[ SponsorOut( id=r["id"], name=r["name"], url=r["url"], logo_url=storage.public_url(r["logo_key"]) if r["logo_key"] else None, ) for r in sponsor_rows ], ) @router.get("/{tournament_id}/sessions", response_model=list[SessionOut]) async def get_public_sessions( tournament_id: str, code: str | None = None, user: CurrentUser | None = Depends(get_current_user_optional), ) -> list[SessionOut]: organization_id = await resolve_org(tournament_id) async with org_connection(organization_id) as conn: row = await conn.fetchrow( "SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id ) if row is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") if not code_matches(row["join_code"], code): await check_visibility(conn, row["visibility"], organization_id, tournament_id, user) return await _fetch_sessions(conn, tournament_id) # --- Individuelle turneringer, offentlig "Følg live" (2026-08-21) --------- # # Cup-formatets tilskuer-rolle over har eksistert siden ADR-026 -- denne # siden manglet helt for individuelle turneringer (bevisst utsatt, # FEATURE_BACKLOG.md "Flaggturnering Del B", 2026-08-14: "egen, større # oppgave hvis/når etterspurt"). Samme synlighets-/kode-mønster som resten # av filen, ingen ny mekanisme. class PublicTournamentRound(BaseModel): id: str sequence: int name: str course_name: str scheduled_at: datetime | None # Utledet (ingen status-kolonne finnes på tournament_round selv) -- # samme "har noen påbegynt et hull ennå"-logikk som # _attach_stroke_play_columns sin today_sequence-utregning bruker # internt, generalisert til å gjelde HVER runde (ikke bare den seneste) # og lagt til en "alle ferdig"-gren for "completed". status: str # "not_started" | "in_progress" | "completed" _HOLES_BY_CONFIG = {"full_18": 18, "front_9": 9, "back_9": 9} @router.get("/{tournament_id}/rounds", response_model=list[PublicTournamentRound]) async def get_public_rounds( tournament_id: str, code: str | None = None, user: CurrentUser | None = Depends(get_current_user_optional), ) -> list[PublicTournamentRound]: organization_id = await resolve_org(tournament_id) async with org_connection(organization_id) as conn: row = await conn.fetchrow( "SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id ) if row is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") if not code_matches(row["join_code"], code): await check_visibility(conn, row["visibility"], organization_id, tournament_id, user) rounds = await conn.fetch( """ SELECT tr.id::text, tr.sequence, tr.name, tr.hole_config::text, c.name AS course_name, tr.scheduled_at FROM tournament_round tr JOIN course c ON c.id = tr.course_id WHERE tr.tournament_id = $1 ORDER BY tr.sequence """, tournament_id, ) progress = await conn.fetch( """ SELECT trs.tournament_round_id::text, count(*) FILTER (WHERE trs.holes_played > 0) AS started, count(*) FILTER (WHERE trs.holes_played >= h.hole_count) AS completed, count(*) AS total FROM tournament_round_score trs JOIN tournament_round tr ON tr.id = trs.tournament_round_id JOIN LATERAL (SELECT count(*) AS hole_count FROM hole WHERE hole.course_id = tr.course_id) h ON true WHERE tr.tournament_id = $1 GROUP BY trs.tournament_round_id """, tournament_id, ) progress_by_round = {r["tournament_round_id"]: r for r in progress} def status_for(round_id: str) -> str: p = progress_by_round.get(round_id) if p is None or p["started"] == 0: return "not_started" if p["completed"] == p["total"] and p["total"] > 0: return "completed" return "in_progress" return [ PublicTournamentRound( id=r["id"], sequence=r["sequence"], name=r["name"] or f"Runde {r['sequence']}", course_name=r["course_name"], scheduled_at=r["scheduled_at"], status=status_for(r["id"]), ) for r in rounds ] class PublicLeaderboardEntry(BaseModel): tournament_participant_id: str player_name: str rounds_played: int gross_total: int | None net_total: int | None stableford_total: int | None copenhagen_total: int | None bbb_total: int | None class_id: str | None class_name: str | None position: str | None is_leader: bool today_label: str | None thru_label: str | None total_label: str | None rank: int | None cut: bool status: str eclectic_total: int | None handicap_index: float | None rounds: list[RoundCellOut] @router.get( "/{tournament_id}/individual-leaderboard", response_model=list[PublicLeaderboardEntry] ) async def get_public_individual_leaderboard( tournament_id: str, code: str | None = None, user: CurrentUser | None = Depends(get_current_user_optional), ) -> list[PublicLeaderboardEntry]: organization_id = await resolve_org(tournament_id) async with org_connection(organization_id) as conn: row = await conn.fetchrow( "SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id ) if row is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") if not code_matches(row["join_code"], code): await check_visibility(conn, row["visibility"], organization_id, tournament_id, user) _, entries = await _compute_individual_standings(conn, tournament_id) # HCP-indeks hentes separat (bevisst valgt synlig her, jf. samme # presedens som frittstående runders offentlige leaderboard) -- # LeaderboardEntry selv har den ikke, kun tournament_participant. hcp_rows = await conn.fetch( "SELECT id::text, handicap_index_snapshot FROM tournament_participant WHERE tournament_id = $1", tournament_id, ) hcp_by_participant = {r["id"]: r["handicap_index_snapshot"] for r in hcp_rows} return [ PublicLeaderboardEntry( tournament_participant_id=e.tournament_participant_id, player_name=e.player_name, rounds_played=e.rounds_played, gross_total=e.gross_total, net_total=e.net_total, stableford_total=e.stableford_total, copenhagen_total=e.copenhagen_total, bbb_total=e.bbb_total, class_id=e.class_id, class_name=e.class_name, position=e.position, is_leader=e.is_leader, today_label=e.today_label, thru_label=e.thru_label, total_label=e.total_label, rank=e.rank, cut=e.cut, status=e.status, eclectic_total=e.eclectic_total, handicap_index=float(hcp_by_participant[e.tournament_participant_id]) if hcp_by_participant.get(e.tournament_participant_id) is not None else None, rounds=e.rounds, ) for e in entries ] class PublicRoundHole(BaseModel): hole_number: int par: int gross_strokes: int | None @router.get( "/{tournament_id}/rounds/{round_id}/participants/{tournament_participant_id}/holes", response_model=list[PublicRoundHole], ) async def get_public_round_participant_holes( tournament_id: str, round_id: str, tournament_participant_id: str, code: str | None = None, user: CurrentUser | None = Depends(get_current_user_optional), ) -> list[PublicRoundHole]: """Hull-for-hull for ÉN spiller i ÉN runde -- brukt av den offentlige leaderboard-radens utvidbare rutenett. Nøkkelen er bevisst tournament_participant_id (samme id som i PublicLeaderboardEntry over), IKKE tournament_round_participant_id (den er runde-spesifikk og aldri eksponert offentlig) -- slår opp riktig runde-deltaker-rad internt.""" organization_id = await resolve_org(tournament_id) async with org_connection(organization_id) as conn: row = await conn.fetchrow( "SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id ) if row is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") if not code_matches(row["join_code"], code): await check_visibility(conn, row["visibility"], organization_id, tournament_id, user) round_row = await conn.fetchrow( "SELECT course_id::text AS course_id FROM tournament_round WHERE id = $1 AND tournament_id = $2", round_id, tournament_id, ) if round_row is None: raise app_error(404, "NOT_FOUND", "Runden finnes ikke i denne turneringen.") round_participant_id = await conn.fetchval( "SELECT id FROM tournament_round_participant WHERE tournament_round_id = $1 AND tournament_participant_id = $2", round_id, tournament_participant_id, ) if round_participant_id is None: return [] holes = await conn.fetch( """ SELECT h.hole_number, h.par, trh.gross_strokes FROM hole h LEFT JOIN tournament_round_hole trh ON trh.tournament_round_participant_id = $1 AND trh.hole_number = h.hole_number WHERE h.course_id = $2 ORDER BY h.hole_number """, round_participant_id, round_row["course_id"], ) return [PublicRoundHole(**dict(h)) for h in holes] # --- Tilskuer-rolle (ADR-026): leaderboard, matcher, scorekort ------------- # # Samme synlighets-/kode-mønster som info-/sesjon-kallene over -- ingen ny # mekanisme. Leaderboard har ingen spillernivå-data i det hele tatt (kun # lag-id/-navn/-farge/poeng), så INGEN ekstra reveal-sjekk trengs der. # Match-listen arver blind draw-skjulingen (ADR-013) automatisk via # fetch_matches() sin egen own_team_ids()-håndtering (tom mengde for en # anonym/uinnloggede leser -- ser bare avslørte matcher, akkurat som en # tilfeldig org-medlem uten roster ville gjort). Scorekortet trenger en # EKSPLISITT reveal-sjekk her (matchen kan i prinsippet ha registrerte hull # før begge lag har låst -- ikke normal flyt, men ikke teknisk umulig). @router.get("/{tournament_id}/leaderboard", response_model=Leaderboard) async def get_public_leaderboard( tournament_id: str, code: str | None = None, user: CurrentUser | None = Depends(get_current_user_optional), ) -> Leaderboard: organization_id = await resolve_org(tournament_id) async with org_connection(organization_id) as conn: row = await conn.fetchrow( "SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id ) if row is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") if not code_matches(row["join_code"], code): await check_visibility(conn, row["visibility"], organization_id, tournament_id, user) return await fetch_leaderboard(conn, tournament_id) @router.get( "/{tournament_id}/sessions/{session_id}/matches", response_model=list[MatchOut] ) async def get_public_matches( tournament_id: str, session_id: str, code: str | None = None, user: CurrentUser | None = Depends(get_current_user_optional), ) -> list[MatchOut]: organization_id = await resolve_org(tournament_id) async with org_connection(organization_id) as conn: row = await conn.fetchrow( "SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id ) if row is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") if not code_matches(row["join_code"], code): await check_visibility(conn, row["visibility"], organization_id, tournament_id, user) # Sikkerhetskritisk: session_id må faktisk høre til DENNE turneringen # -- org_connection() setter kun tenant-grensen (RLS), ikke at stiens # to id-er faktisk henger sammen. Uten denne sjekken kunne noen med # kode/tilgang til én offentlig turnering lese en HVILKEN SOM HELST # økt i samme organisasjon (inkl. en privat en) ved å gjette id-en. session_tournament = await conn.fetchval( "SELECT tournament_id::text FROM session WHERE id = $1", session_id ) if session_tournament != tournament_id: raise app_error(404, "NOT_FOUND", "Økten finnes ikke i denne turneringen.") user_id = user.user_id if user is not None else None return await fetch_matches(conn, organization_id, session_id, user_id) @router.get("/{tournament_id}/matches/{match_id}/scorecard", response_model=Scorecard) async def get_public_scorecard( tournament_id: str, match_id: str, code: str | None = None, user: CurrentUser | None = Depends(get_current_user_optional), ) -> Scorecard: organization_id = await resolve_org(tournament_id) async with org_connection(organization_id) as conn: row = await conn.fetchrow( "SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id ) if row is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") if not code_matches(row["join_code"], code): await check_visibility(conn, row["visibility"], organization_id, tournament_id, user) # Samme tenant-vs-sti-konsistenssjekk som matchlisten over, PLUSS en # eksplisitt reveal-sjekk (blind draw, ADR-013) -- scorekortet skal # aldri kunne leses offentlig før laget selv har avslørt oppstillingen. match_row = await conn.fetchrow( """ SELECT s.tournament_id::text AS tournament_id, m.session_id::text AS session_id FROM match m JOIN session s ON s.id = m.session_id WHERE m.id = $1 """, match_id, ) if match_row is None or match_row["tournament_id"] != tournament_id: raise app_error(404, "NOT_FOUND", "Matchen finnes ikke i denne turneringen.") locked = await locked_team_ids(conn, match_row["session_id"]) if len(locked) < 2: raise app_error(403, "NOT_VISIBLE", "Denne matchen er ikke avslørt ennå.") return await fetch_scorecard(conn, match_id) class RegisterRequest(BaseModel): display_name: str = Field(min_length=1, max_length=200) email: EmailStr | None = None mobile: str | None = None birth_date: date | None = None nickname: str | None = None country: str | None = None club: str | None = None club_member_number: str | None = None gender: str | None = Field(default=None, pattern="^[mfx]$") handicap_index: float | None = None # API-et krever eksplisitt True -- ingen implisitt samtykke (ADR-017). consent: bool # ADR-020 Beslutning A: en matchende invitasjonskode overstyrer visibility. code: str | None = None class RegistrationResult(BaseModel): id: str status: str @router.post("/{tournament_id}/register", response_model=RegistrationResult, status_code=201) async def register_for_tournament( tournament_id: str, body: RegisterRequest, user: CurrentUser | None = Depends(get_current_user_optional), ) -> RegistrationResult: if not body.consent: raise app_error(400, "VALIDATION_FAILED", "Samtykke må gis for å melde seg på.") organization_id = await resolve_org(tournament_id) async with org_connection(organization_id) as conn, translate_db_errors(): # Lås turneringsraden: forhindrer at to samtidige påmeldinger begge # leser "under kapasitet" og begge kommer inn (TOCTOU) -- samme # mønster som to-lags-grensen i tournaments.py sin create_team. tournament = await conn.fetchrow( """ SELECT visibility, join_code, registration_deadline, registration_capacity, registration_overflow_policy, registration_requires_approval FROM tournament WHERE id = $1 FOR UPDATE """, tournament_id, ) if tournament is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") # ADR-018 Beslutning D: registrering følger samme synlighetsgrense # som selve landingssiden -- ingen særbehandling. ADR-020 Beslutning A: # unntaket er en matchende invitasjonskode, som overstyrer dette helt. if not code_matches(tournament["join_code"], body.code): await check_visibility(conn, tournament["visibility"], organization_id, tournament_id, user) deadline = tournament["registration_deadline"] if deadline is not None and deadline < datetime.now(timezone.utc): raise app_error(409, "REGISTRATION_CLOSED", "Påmeldingen er stengt.") # E-post-matching (ADR-017 Beslutning B): finn en allerede # organisator-opprettet spillerrad med samme e-post og fyll inn # manglende felt der, i stedet for å opprette en duplikat. player_id = None if body.email: existing = await conn.fetchrow( "SELECT id FROM player WHERE organization_id = $1 AND lower(email) = lower($2)", organization_id, body.email, ) if existing is not None: player_id = existing["id"] await conn.execute( """ UPDATE player SET mobile = COALESCE(mobile, $2), birth_date = COALESCE(birth_date, $3), nickname = COALESCE(nickname, $4), country = COALESCE(country, $5), club = COALESCE(club, $6), club_member_number = COALESCE(club_member_number, $7), gender = COALESCE(gender, $8), handicap_index = COALESCE(handicap_index, $9) WHERE id = $1 """, player_id, body.mobile, body.birth_date, body.nickname, body.country, body.club, body.club_member_number, body.gender, body.handicap_index, ) if player_id is None: player_row = await conn.fetchrow( """ INSERT INTO player (organization_id, display_name, handicap_index, gender, mobile, email, birth_date, nickname, country, club, club_member_number) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id """, organization_id, body.display_name, body.handicap_index, body.gender, body.mobile, body.email.lower() if body.email else None, body.birth_date, body.nickname, body.country, body.club, body.club_member_number, ) player_id = player_row["id"] already = await conn.fetchval( "SELECT id FROM tournament_registration WHERE tournament_id = $1 AND player_id = $2", tournament_id, player_id, ) if already is not None: raise app_error(409, "DUPLICATE", "Denne spilleren er allerede påmeldt.") # Rekkefølge fra ADR-017 Beslutning C: frist -> kapasitet -> godkjenning. status = "confirmed" if tournament["registration_capacity"] is not None: active_count = await conn.fetchval( """ SELECT count(*) FROM tournament_registration WHERE tournament_id = $1 AND status IN ('confirmed', 'pending') """, tournament_id, ) if active_count >= tournament["registration_capacity"]: if tournament["registration_overflow_policy"] == "waitlist": status = "waitlisted" else: raise app_error(409, "LIMIT_REACHED", "Turneringen er full.") if status != "waitlisted" and tournament["registration_requires_approval"]: status = "pending" row = await conn.fetchrow( """ INSERT INTO tournament_registration (organization_id, tournament_id, player_id, status, consent_given_at) VALUES ($1, $2, $3, $4, now()) RETURNING id::text, status """, organization_id, tournament_id, player_id, status, ) return RegistrationResult(id=row["id"], status=row["status"]) class PublicOrgTournament(BaseModel): id: str name: str status: str start_date: date | None end_date: date | None class PublicOrgInfo(BaseModel): name: str tournaments: list[PublicOrgTournament] @org_router.get("/{slug}", response_model=PublicOrgInfo) async def get_public_org(slug: str) -> PublicOrgInfo: async with plain_connection() as conn: organization_id = await conn.fetchval("SELECT public_org_by_slug($1)", slug) # NULL for BÅDE "finnes ikke" og "finnes, men er privat" -- samme # anti-enumerering som magic-link (ADR-009), se ADR-018 Beslutning F. if organization_id is None: raise app_error(404, "NOT_FOUND", "Organisasjonen finnes ikke.") async with org_connection(str(organization_id)) as conn: org_row = await conn.fetchrow("SELECT name FROM organization WHERE id = $1", organization_id) tournament_rows = await conn.fetch( """ SELECT id::text, name, status::text, start_date, end_date FROM tournament WHERE visibility = 'public' ORDER BY start_date DESC NULLS LAST, created_at DESC """, ) return PublicOrgInfo( name=org_row["name"], tournaments=[PublicOrgTournament(**dict(r)) for r in tournament_rows], )