""" 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 ..db import org_connection, plain_connection from ..errors import app_error, translate_db_errors from .tournaments import SessionOut, _fetch_sessions 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: """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, ) 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 if visibility == "participants" and 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 SponsorOut(BaseModel): id: str name: str url: str | None class PublicTournamentInfo(BaseModel): id: str name: str organization_name: str status: str 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 sponsors: list[SponsorOut] @router.get("/{tournament_id}", response_model=PublicTournamentInfo) async def get_public_tournament( tournament_id: str, 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.visibility, t.description, 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.") await _check_visibility(conn, row["visibility"], organization_id, tournament_id, user) sponsor_rows = await conn.fetch( "SELECT id::text, name, url 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"], 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"], sponsors=[SponsorOut(**dict(r)) for r in sponsor_rows], ) @router.get("/{tournament_id}/sessions", response_model=list[SessionOut]) async def get_public_sessions( tournament_id: str, 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: visibility = await conn.fetchval("SELECT visibility FROM tournament WHERE id = $1", tournament_id) if visibility is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") await _check_visibility(conn, visibility, organization_id, tournament_id, user) return await _fetch_sessions(conn, tournament_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 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, 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. 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], )