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:
parent
e1ddf13cce
commit
05a451ff75
7 changed files with 343 additions and 21 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
50
CLAUDE.md
50
CLAUDE.md
|
|
@ -387,15 +387,51 @@ Ferdig og verifisert:
|
||||||
ukjent — OG et RÅTT `SELECT` på `tournament` på SAMME tilkobling/rolle ga
|
ukjent — OG et RÅTT `SELECT` på `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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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 på et lag.
|
er organisator-arbeid, ikke noe som forutsetter rostring på et lag.
|
||||||
|
|
||||||
|
Utvidet profil (ADR-017): kontakt-/identitetsfelt brukt til å matche en
|
||||||
|
selvregistrerende spiller mot en allerede organisator-opprettet rad på
|
||||||
|
e-post (se app/routers/registration.py), og senere til å koble
|
||||||
|
app_user.id på (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
228
app/routers/registration.py
Normal 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"])
|
||||||
|
|
@ -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` },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue