Registrerings-API-et er live på https://teecup.teeoff.no. Oppsummert:

Bygget: GET /public/tournaments/{id} og POST /public/tournaments/{id}/register — helt uautentisert, egen /public-prefiks. players.py utvidet med alle sju nye feltene.

Grundig scratch-testet, ikke bare "kjørte uten feil": samtykke-avvisning, duplikat-avvisning, e-post-matching mot en organisator-forhåndsopprettet spiller (bekreftet ingen duplikat, mobil fylt inn, navn ikke overskrevet), kapasitet+venteliste, kapasitet+stengt, godkjenningskrav, utløpt frist — alle seks scenarioene fra ADR-en testet én etter én og ga riktig resultat.

Notatet ditt om synlighet er fanget i FEATURE_BACKLOG.md, koblet til det samme åpne spørsmålet for «Banter Board»-feeden — før dette API-et ble bygget, ikke etter, slik du ba om.

Gjenstår, bevisst utsatt:

E-post-basert kontosammenkobling ved innlogging (ADR-017 Beslutning B sin andre halvdel) — trenger en ny SECURITY DEFINER-funksjon på tvers av org-er, altså migrasjon 008 siden 007 alt er kjørt mot prod. Ikke gjort i denne runden.
Selve påmeldingsflyten er ikke testet med ekte data mot prod (kun ikke-destruktive sjekker: ukjent turnering ga korrekt 404).
Landingssider — egen ADR-runde, som avtalt.
This commit is contained in:
Erol Haagenrud 2026-07-18 08:50:03 +02:00
parent e1ddf13cce
commit 05a451ff75
7 changed files with 343 additions and 21 deletions

View file

@ -188,7 +188,12 @@
"Bash(rm -f /opt/teecup/.env.scratch /tmp/roster_cookies.txt)", "Bash(rm -f /opt/teecup/.env.scratch /tmp/roster_cookies.txt)",
"Bash(curl -s -o /dev/null -w 'dashboard: %{http_code}\\\\n' https://teecup.teeoff.no/dashboard)", "Bash(curl -s -o /dev/null -w 'dashboard: %{http_code}\\\\n' https://teecup.teeoff.no/dashboard)",
"Bash(curl -s -o /dev/null -w 'health: %{http_code}\\\\n' https://teecup.teeoff.no/health)", "Bash(curl -s -o /dev/null -w 'health: %{http_code}\\\\n' https://teecup.teeoff.no/health)",
"Bash(docker rm -f teecup_scratch_api >/dev/null 2>&1 *)" "Bash(docker rm -f teecup_scratch_api >/dev/null 2>&1 *)",
"Bash(python3 -m py_compile /opt/teecup/app/routers/players.py /opt/teecup/app/routers/registration.py /opt/teecup/app/main.py)",
"Bash(sed -i \"s#^TEECUP_DB_NAME=.*#TEECUP_DB_NAME=teecup_scratch#\" .env.scratch)",
"Bash(python3 -c \"import sys,json;print\\(json.load\\(sys.stdin\\)['confirmed_count']\\)\")",
"Bash(rm -f /opt/teecup/.env.scratch /tmp/owner_cookies.txt)",
"Bash(curl -s https://teecup.teeoff.no/public/tournaments/00000000-0000-0000-0000-000000000000)"
], ],
"additionalDirectories": [ "additionalDirectories": [
"/opt/teeoff/deploy", "/opt/teeoff/deploy",

View file

@ -387,15 +387,51 @@ Ferdig og verifisert:
ukjent — OG et RÅTT `SELECT``tournament` på SAMME tilkobling/rolle ga ukjent — OG et RÅTT `SELECT``tournament` på SAMME tilkobling/rolle ga
fortsatt 0 rader, som beviser RLS ikke er brutt generelt, bare dette ene fortsatt 0 rader, som beviser RLS ikke er brutt generelt, bare dette ene
smale unntaket eksisterer. smale unntaket eksisterer.
**Kun migrasjon + ADR i denne runden** — API-endepunkter (offentlig **Kjørt mot ekte `teecup_db` 2026-07-18** (bruker bekreftet eksplisitt i
registrerings-router, utvidede `players.py`-felt) og frontend samme økt): migrasjonen kjørte rent, `test_isolation.sql` fortsatt 12/12.
(påmeldingsskjema) er IKKE bygget ennå, og migrasjonen er IKKE kjørt mot - **Registrerings-API LIVE (2026-07-18), samme dag:** ny
ekte `teecup_db`. `app/routers/registration.py``GET /public/tournaments/{id}` og
`POST /public/tournaments/{id}/register`, begge UTEN `get_current_user`
eller `get_authorized_org` (helt uautentisert, egen `/public`-prefiks,
bevisst atskilt fra `/orgs/...` i koden). Bruker `public_tournament_org()`
(migrasjon 007) til å slå opp org-kontekst FØR RLS kan håndheve noe.
`app/routers/players.py` utvidet med alle sju nye ADR-017-feltene
(`mobile`/`email`/`birth_date`/`nickname`/`country`/`club`/
`club_member_number`). `frontend/next.config.mjs` sin `rewrites()`
utvidet med `/public/*` (ADR-016s konsekvens: enhver ny API-prefiks MÅ
inn her).
**Ny brukers-oppdaget notat fanget FØR bygging, ikke etter:** brukeren
krevde eksplisitt at synlighet (offentlig/kun org/kun turnering-
deltakere) må være et VALG for fremtidige landingssider — notert grundig
i `FEATURE_BACKLOG.md` (koblet til samme åpne spørsmål for "Banter
Board"-feeden) FØR dette registrerings-API-et ble bygget, akkurat for at
det ikke skal gå i glemmeboken til landingsside-runden.
**Verifisert grundig mot fersk `teecup_scratch`** (001→007,
`test_isolation.sql` 12/12): hele registreringsløpet testet reelt —
samtykke-avvisning (400), duplikat-avvisning (409 `DUPLICATE`),
e-post-matching mot en organisator-forhåndsopprettet spiller (BEKREFTET:
ingen duplikatrad, mobil fylt inn via `COALESCE`, `display_name` IKKE
overskrevet), kapasitet+`waitlist`-policy (→ `waitlisted`),
kapasitet+`closed`-policy (→ 409 `LIMIT_REACHED`), `registration_
requires_approval` (→ `pending`), utløpt frist (→ 409
`REGISTRATION_CLOSED`), og `confirmed_count` i `GET`-responsen talt
riktig (kun `confirmed`+`pending`, ikke `waitlisted`/avviste).
**Rullet ut live:** begge containere redeployet, `teecup.teeoff.no/
dashboard` og `/health` fortsatt 200, `teeoff.no` upåvirket, det
offentlige endepunktet bekreftet nåbart over ekte https (ukjent
turnering-id ga korrekt `404`/`NOT_FOUND`, ikke-destruktiv sjekk — selve
påmeldingsflyten med ekte data ikke testet mot prod i denne runden).
**Gjenstår:** e-post-basert `player.user_id`-kobling ved innlogging
(ADR-017 Beslutning B sin andre halvdel — krever en egen
`SECURITY DEFINER`-funksjon på tvers av org-er, altså en ny migrasjon
008, siden 007 allerede er kjørt mot prod). Frontend-påmeldingsskjema og
landingssider er egen, senere ADR-runde (se `FEATURE_BACKLOG.md`).
Neste steg: Neste steg:
1. ADR-017: kjøre migrasjon 007 mot ekte `teecup_db` (venter på brukerens 1. ADR-017: e-post-basert kontosammenkobling ved innlogging (migrasjon 008
eksplisitte bekreftelse), bygge API-et (offentlig registrerings-router, + `verify_magic_link`-utvidelse), så landingssider (turnering/org) med
utvidet `players.py`), så et påmeldingsskjema i frontend. påmeldingsskjema i
frontend — se brukerens landingsside-spørsmål, samme økt.
2. Flere V0-skjermer (økt/program, blind draw, scorekort, leaderboard) — 2. Flere V0-skjermer (økt/program, blind draw, scorekort, leaderboard) —
samme mønster: design i V0 (fortsett i samme prosjekt), FORVENT en full samme mønster: design i V0 (fortsett i samme prosjekt), FORVENT en full
re-eksport hver gang — diff mot live-treet i et scratch-område før noe re-eksport hver gang — diff mot live-treet i et scratch-område før noe

View file

@ -38,7 +38,7 @@
| Frontend: innlogging + verifisering, LIVE | ✅ | ADR-016. Next.js på `teecup.teeoff.no`, ekte magic-link-flyt bevist med reell e-post. Se egen seksjon under. | | Frontend: innlogging + verifisering, LIVE | ✅ | ADR-016. Next.js på `teecup.teeoff.no`, ekte magic-link-flyt bevist med reell e-post. Se egen seksjon under. |
| Frontend: dashboard (org-bytter/-opprettelse + turneringsliste), LIVE | ✅ | `/dashboard`. Kablet mot `/auth/me`, `/orgs`, `/orgs/{id}/tournaments`. Skrive-flyt bekreftet med ekte data (org "Tjøme Gents" + turnering opprettet av bruker). | | Frontend: dashboard (org-bytter/-opprettelse + turneringsliste), LIVE | ✅ | `/dashboard`. Kablet mot `/auth/me`, `/orgs`, `/orgs/{id}/tournaments`. Skrive-flyt bekreftet med ekte data (org "Tjøme Gents" + turnering opprettet av bruker). |
| Frontend: lag/roster-skjerm, LIVE | ✅ | `/tournaments/[id]`. To nye backend-endepunkter bygget samtidig (`PATCH`/`DELETE` roster). Skrive-flyt ikke testet med ekte data ennå. | | Frontend: lag/roster-skjerm, LIVE | ✅ | `/tournaments/[id]`. To nye backend-endepunkter bygget samtidig (`PATCH`/`DELETE` roster). Skrive-flyt ikke testet med ekte data ennå. |
| Selvregistrering + utvidet spillerprofil | 🔨 | ADR-017. Migrasjon 007 skrevet + scratch-verifisert. API/frontend gjenstår. | | Selvregistrering + utvidet spillerprofil (API) | ✅ | ADR-017. `app/routers/registration.py` (offentlig, uautentisert), utvidet `players.py`. Live. Frontend-påmeldingsskjema/landingssider gjenstår (egen ADR-runde). |
| Roster: endre kaptein / fjern spiller (`PATCH`/`DELETE`) | ✅ | `app/routers/tournaments.py`. Bevisst ingen "kun én kaptein"-håndhevelse ennå — se «Brukerroller»-punktet under. | | Roster: endre kaptein / fjern spiller (`PATCH`/`DELETE`) | ✅ | `app/routers/tournaments.py`. Bevisst ingen "kun én kaptein"-håndhevelse ennå — se «Brukerroller»-punktet under. |
--- ---
@ -397,7 +397,7 @@
| Del | Status | Notat | | Del | Status | Notat |
|---|---|---| |---|---|---|
| Lag-intern chat («det hemmelige rommet») | 🔨 | Bekreftet ønsket. Kanal m/ scope `team`. | | Lag-intern chat («det hemmelige rommet») | 🔨 | Bekreftet ønsket. Kanal m/ scope `team`. |
| Offentlig runde-feed («Banter Board») | ❓ | Synlighetsnivå ikke besluttet (deltakere/org/offentlig lenke). | | Offentlig runde-feed («Banter Board») | ❓ | Synlighetsnivå ikke besluttet (deltakere/org/offentlig lenke) — SAMME trenivå-modell som landingssider under, bør trolig løses med ÉN delt mekanisme, ikke to separate. |
| Bilder i feed/chat | 📋 | v1. Objektlagring (MinIO), presigned opplasting. | | Bilder i feed/chat | 📋 | v1. Objektlagring (MinIO), presigned opplasting. |
| Video | 💤 | Arkitekt for det, bygg senere (ADR-forslag). | | Video | 💤 | Arkitekt for det, bygg senere (ADR-forslag). |
| 1-til-1 direktemeldinger | 💤 | Gemini frarådet for v1; ikke etterspurt av deg. | | 1-til-1 direktemeldinger | 💤 | Gemini frarådet for v1; ikke etterspurt av deg. |
@ -405,6 +405,25 @@
--- ---
## Landingssider (turnering + organisasjon) — idé, ikke besluttet
Reist av brukeren 2026-07-18, rett etter registrerings-ADR-en (ADR-017).
Egen ADR-runde planlagt SENERE — for mange åpne spørsmål til å bygges
sammen med registrerings-API-et.
| Del | Status | Notat |
|---|---|---|
| Synlighetsnivå: offentlig / kun org-medlemmer / kun turnering-deltakere | ❓ | **Eksplisitt krav fra brukeren, må ikke glemmes.** Samme trenivå-modell som «Banter Board» over — bør trolig dele mekanisme, ikke løses to ganger. Sannsynligvis et felt på turnering (og org?) som styrer hvilken av de tre gruppene som får se siden. |
| Turnering-landingsside: hero, tekst, program, sponsorer, påmelding | 📋 | Innhold bør endre seg med turneringens livssyklus (meld deg på → følg live → se resultater) — samme URL, ulikt hovedbudskap avhengig av dato/status. |
| Org-landingsside: klubbprofil, liste over turneringer | ❓ | Uavklart om dette i det hele tatt skal være en offentlig, søkbar profil, eller bare en intern hub med delbare turneringslenker. |
| Lesbar URL (slug) for organisasjon | 📋 | Trengs for at en delt lenke skal se ut som noe («teecup.teeoff.no/tjome-gents»), ikke en UUID. Nytt felt på `organization`. |
| Bilder (hero, sponsorlogoer) | 📋 | Krever objektlagring (MinIO) — arkitektur-invarianten finnes alt (se CLAUDE.md), men MinIO er ALDRI satt opp i praksis. Første funksjon som faktisk trenger det. |
| Del-metadata (Open Graph: og:title/og:image/og:description) | 📋 | Lett å glemme, høy verdi — dette ER siden folk faktisk deler i WhatsApp/e-post. |
| Blind draw-skjuling på offentlig side | 📋 | Må respektere ADR-013 (skjult oppstilling til begge lag er låst) akkurat som resten av appen — en offentlig side må IKKE lekke dette ved et uhell. |
| Antall påmeldte / ledige plasser vist åpent | 📋 | Bruker `tournament.registration_capacity` fra ADR-017 direkte, ingen ny data nødvendig. |
---
## UX / frontend (senere fase) ## UX / frontend (senere fase)
- 📋 Høy kontrast, dark/light, store +/- knapper, stor «Neste hull»-knapp - 📋 Høy kontrast, dark/light, store +/- knapper, stor «Neste hull»-knapp

View file

@ -15,7 +15,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from .db import init_pool, close_pool from .db import init_pool, close_pool
from .routers import auth, matches, organizations, players, scoring, tournaments from .routers import auth, matches, organizations, players, registration, scoring, tournaments
@asynccontextmanager @asynccontextmanager
@ -35,6 +35,7 @@ app.include_router(players.router)
app.include_router(tournaments.router) app.include_router(tournaments.router)
app.include_router(matches.router) app.include_router(matches.router)
app.include_router(scoring.router) app.include_router(scoring.router)
app.include_router(registration.router)
@app.get("/health") @app.get("/health")

View file

@ -3,10 +3,17 @@ Spillerpool på organisasjonsnivå (ADR-001: gjenbrukbar på tvers av turneringe
Kun org-medlemskap kreves (get_authorized_org) å opprette en spiller i poolen Kun org-medlemskap kreves (get_authorized_org) å opprette en spiller i poolen
er organisator-arbeid, ikke noe som forutsetter rostring et lag. er organisator-arbeid, ikke noe som forutsetter rostring et lag.
Utvidet profil (ADR-017): kontakt-/identitetsfelt brukt til å matche en
selvregistrerende spiller mot en allerede organisator-opprettet rad
e-post (se app/routers/registration.py), og senere til å koble
app_user.id (app/routers/auth.py sin verify_magic_link).
""" """
from datetime import date
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field from pydantic import BaseModel, EmailStr, Field
from ..auth import get_authorized_org from ..auth import get_authorized_org
from ..db import org_connection from ..db import org_connection
@ -19,6 +26,13 @@ class PlayerCreate(BaseModel):
display_name: str = Field(min_length=1, max_length=200) display_name: str = Field(min_length=1, max_length=200)
handicap_index: float | None = None handicap_index: float | None = None
gender: str | None = Field(default=None, pattern="^[mfx]$") gender: str | None = Field(default=None, pattern="^[mfx]$")
mobile: str | None = None
email: EmailStr | 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
class Player(BaseModel): class Player(BaseModel):
@ -26,6 +40,19 @@ class Player(BaseModel):
display_name: str display_name: str
handicap_index: float | None handicap_index: float | None
gender: str | None gender: str | None
mobile: str | None
email: str | None
birth_date: date | None
nickname: str | None
country: str | None
club: str | None
club_member_number: str | None
_PLAYER_COLUMNS = """
id::text, display_name, handicap_index::float AS handicap_index, gender,
mobile, email, birth_date, nickname, country, club, club_member_number
"""
@router.get("/orgs/{organization_id}/players", response_model=list[Player]) @router.get("/orgs/{organization_id}/players", response_model=list[Player])
@ -34,11 +61,7 @@ async def list_players(
) -> list[Player]: ) -> list[Player]:
async with org_connection(organization_id) as conn: async with org_connection(organization_id) as conn:
rows = await conn.fetch( rows = await conn.fetch(
""" f"SELECT {_PLAYER_COLUMNS} FROM player ORDER BY display_name"
SELECT id::text, display_name, handicap_index::float AS handicap_index, gender
FROM player
ORDER BY display_name
"""
) )
return [Player(**dict(r)) for r in rows] return [Player(**dict(r)) for r in rows]
@ -50,14 +73,23 @@ async def create_player(
) -> Player: ) -> Player:
async with org_connection(organization_id) as conn, translate_db_errors(): async with org_connection(organization_id) as conn, translate_db_errors():
row = await conn.fetchrow( row = await conn.fetchrow(
""" f"""
INSERT INTO player (organization_id, display_name, handicap_index, gender) INSERT INTO player
VALUES ($1, $2, $3, $4) (organization_id, display_name, handicap_index, gender,
RETURNING id::text, display_name, handicap_index::float AS 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 {_PLAYER_COLUMNS}
""", """,
organization_id, organization_id,
body.display_name, body.display_name,
body.handicap_index, body.handicap_index,
body.gender, 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,
) )
return Player(**dict(row)) return Player(**dict(row))

228
app/routers/registration.py Normal file
View file

@ -0,0 +1,228 @@
"""
Offentlig, uautentisert registrerings-API (ADR-017).
Bevisst egen fil med prefiks /public/tournaments, atskilt fra /orgs/... --
gjør sikkerhetsgrensen eksplisitt i koden: disse to endepunktene krever
VERKEN innlogging (get_current_user) ELLER org-medlemskap
(get_authorized_org), i motsetning til absolutt alt annet i API-et.
`public_tournament_org()` (migrasjon 007, SECURITY DEFINER) er den ENESTE
broen fra en turnering-id til riktig RLS-kontekst FØR den er kjent -- se
ADR-017 Beslutning E for hvorfor dette er trygt (smalt unntak, ikke en
generell RLS-omgåelse).
"""
from datetime import date, datetime, timezone
from fastapi import APIRouter
from pydantic import BaseModel, EmailStr, Field
from ..db import org_connection, plain_connection
from ..errors import app_error, translate_db_errors
router = APIRouter(prefix="/public/tournaments", 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)
class PublicTournamentInfo(BaseModel):
id: str
name: str
organization_name: str
status: str
start_date: date | None
end_date: date | None
registration_open: bool
registration_deadline: datetime | None
registration_capacity: int | None
confirmed_count: int
@router.get("/{tournament_id}", response_model=PublicTournamentInfo)
async def get_public_tournament(tournament_id: str) -> 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.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.")
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"],
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"],
)
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) -> 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 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.")
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"])

View file

@ -13,6 +13,7 @@ const nextConfig = {
return [ return [
{ source: "/auth/:path*", destination: `${API_ORIGIN}/auth/:path*` }, { source: "/auth/:path*", destination: `${API_ORIGIN}/auth/:path*` },
{ source: "/orgs/:path*", destination: `${API_ORIGIN}/orgs/:path*` }, { source: "/orgs/:path*", destination: `${API_ORIGIN}/orgs/:path*` },
{ source: "/public/:path*", destination: `${API_ORIGIN}/public/:path*` },
{ source: "/health", destination: `${API_ORIGIN}/health` }, { source: "/health", destination: `${API_ORIGIN}/health` },
] ]
}, },