teecup/app/routers/registration.py
Erol Haagenrud 20ae4fe6ea Backend, frontend og Caddy-endringen er alle klare og verifisert (Caddy-syntaksen validert OK). Dette er en stor runde — her er full plan for utrulling:
1. Migrasjon — kjør 013_messaging.sql mot ekte teecup_db (ny message-tabell, RLS, ingen endring av eksisterende data).

2. Backend + frontend — docker compose up -d --build teecup_api teecup_frontend.

3. Caddy — legger til /ws/*-ruten i /opt/teeoff/deploy/Caddyfile (allerede skrevet og syntaks-validert). Som i alle tidligere runder som har rørt denne filen: en graceful reload plukker historisk IKKE opp endringen (stale bind-mount-inode), så det trengs en full docker restart teeoff_caddy — det gir noen sekunders nedetid for teeoff.no også, ikke bare teecup.teeoff.no.

Etter alt dette: sjekke /health + /dashboard → 200, en reell WebSocket-tilkobling fungerer over wss://teecup.teeoff.no/ws/..., og teeoff.no er tilbake på 200.
2026-07-19 22:26:45 +02:00

426 lines
16 KiB
Python

"""
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 .. import storage
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,
)
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
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 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
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.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"],
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)
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],
)