ADR-018 sin backend er ferdig, verifisert og live. Oppsummert:
Én reell feil funnet og rettet underveis, ikke antatt riktig: migrasjonen feilet først mot scratch — organization.slug har faktisk ligget i skjemaet siden migrasjon 001 ("f.eks. subdomene/URL-vennlig"), noe jeg hadde oversett og prøvde å legge til på nytt. Rettet, kjørte rent etterpå.
En viktig presisering oppdaget under bygging, ikke antatt på forhånd: RLS beskytter kun tenant-grenser (org A ser aldri org B), ikke innholds-synlighet innenfor riktig org-kontekst. Det gamle offentlige endepunktet fra forrige runde leste faktisk fullt innhold uten noen synlighetssjekk i det hele tatt — synlighet må håndheves eksplisitt i koden, noe jeg nå har gjort konsekvent på både lesing og registrering.
Fylte et implisitt hull: ADR-en beskrev synligheten, men ingen tidligere runde hadde bygget en vei for organisator til å faktisk sette disse feltene — lagt til PATCH-endepunkter for turnering og org, pluss full sponsor-CRUD.
Grundig testet: hele synlighetsmatrisen med ekte HTTP-kall — inkludert den interessante "kylling-og-egg"-konsekvensen av Beslutning D (ingen kan selv-registrere seg til en participants-synlig turnering, kun organisator kan legge til direkte — riktig, ikke en bug).
Live nå, teeoff.no upåvirket gjennom hele prosessen.
This commit is contained in:
parent
bb3b19560c
commit
2058d63db5
10 changed files with 645 additions and 56 deletions
|
|
@ -194,7 +194,9 @@
|
||||||
"Bash(python3 -c \"import sys,json;print\\(json.load\\(sys.stdin\\)['confirmed_count']\\)\")",
|
"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(rm -f /opt/teecup/.env.scratch /tmp/owner_cookies.txt)",
|
||||||
"Bash(curl -s https://teecup.teeoff.no/public/tournaments/00000000-0000-0000-0000-000000000000)",
|
"Bash(curl -s https://teecup.teeoff.no/public/tournaments/00000000-0000-0000-0000-000000000000)",
|
||||||
"Bash(docker exec -i teeoff_db psql -U teeoff_admin -d teecup_scratch -v ON_ERROR_STOP=1 < /opt/teecup/test_isolation.sql 2>&1 | tail -8 *)"
|
"Bash(docker exec -i teeoff_db psql -U teeoff_admin -d teecup_scratch -v ON_ERROR_STOP=1 < /opt/teecup/test_isolation.sql 2>&1 | tail -8 *)",
|
||||||
|
"Bash(python3 -m py_compile /opt/teecup/app/auth.py /opt/teecup/app/routers/tournaments.py /opt/teecup/app/routers/organizations.py /opt/teecup/app/routers/registration.py /opt/teecup/app/main.py)",
|
||||||
|
"Bash(curl -s https://teecup.teeoff.no/public/orgs/finnes-ikke-garantert)"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
|
|
|
||||||
86
009_landing_pages_and_visibility.sql
Normal file
86
009_landing_pages_and_visibility.sql
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- TeeCup — migrasjon 009
|
||||||
|
-- Landingssider: synlighet, org-profil, sponsorer (ADR-018)
|
||||||
|
-- =====================================================================
|
||||||
|
-- Kjøres etter 001-008. Alt additivt (nullable eller trygg default).
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- 1. Turnering: synlighet + presenterende tekst (ADR-018 Beslutning A/E)
|
||||||
|
--
|
||||||
|
-- Default 'org' -- ingen eksisterende eller nyopprettet turnering blir
|
||||||
|
-- offentlig av seg selv, organisator må aktivt velge å åpne opp.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
ALTER TABLE tournament ADD COLUMN visibility text NOT NULL DEFAULT 'org'
|
||||||
|
CHECK (visibility IN ('public', 'org', 'participants'));
|
||||||
|
|
||||||
|
ALTER TABLE tournament ADD COLUMN description text;
|
||||||
|
|
||||||
|
-- Inert til MinIO-runden (bevisst utsatt, se ADR-018) -- kun kolonnen,
|
||||||
|
-- ingen opplastingslogikk.
|
||||||
|
ALTER TABLE tournament ADD COLUMN hero_image_key text;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- 2. Organisasjon: offentlig profil (ADR-018 Beslutning F)
|
||||||
|
--
|
||||||
|
-- MERK: `organization.slug` finnes ALLEREDE, helt siden migrasjon 001
|
||||||
|
-- ("f.eks. subdomene/URL-vennlig", plain UNIQUE -- Postgres sin UNIQUE
|
||||||
|
-- behandler NULL som distinkt fra NULL, så "unik når satt" er allerede
|
||||||
|
-- garantert uten en egen partiell indeks). Oppdaget ved at denne
|
||||||
|
-- migrasjonen først feilet mot en fersk scratch-database ("column slug
|
||||||
|
-- already exists") -- IKKE lagt til på nytt her, kun brukt.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
ALTER TABLE organization ADD COLUMN public_profile boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
ALTER TABLE organization ADD CONSTRAINT organization_slug_format
|
||||||
|
CHECK (slug IS NULL OR slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$');
|
||||||
|
|
||||||
|
ALTER TABLE organization ADD CONSTRAINT organization_public_profile_requires_slug
|
||||||
|
CHECK (NOT public_profile OR slug IS NOT NULL);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- 3. Sponsorer (ADR-018 Beslutning E) -- navn+lenke aktivt, logo inert
|
||||||
|
-- til MinIO-runden.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
CREATE TABLE tournament_sponsor (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id uuid NOT NULL,
|
||||||
|
tournament_id uuid NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
url text,
|
||||||
|
logo_key text, -- inert til MinIO-runden
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
FOREIGN KEY (organization_id, tournament_id)
|
||||||
|
REFERENCES tournament(organization_id, id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ON tournament_sponsor (organization_id, tournament_id);
|
||||||
|
|
||||||
|
ALTER TABLE tournament_sponsor ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE tournament_sponsor FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY org_isolation ON tournament_sponsor
|
||||||
|
USING (organization_id = app_current_org())
|
||||||
|
WITH CHECK (organization_id = app_current_org());
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON tournament_sponsor TO teecup_app;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- 4. public_org_by_slug() -- tredje SECURITY DEFINER-bro i dette
|
||||||
|
-- prosjektet (etter public_tournament_org 007, link_player_by_email
|
||||||
|
-- 008), samme mønster: løs kontekst-problemet FØR RLS kan håndheve
|
||||||
|
-- noe. Returnerer NULL for BÅDE "finnes ikke" og "finnes, men er
|
||||||
|
-- privat" -- samme anti-enumerering som magic-link (ADR-009).
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
CREATE FUNCTION public_org_by_slug(p_slug text)
|
||||||
|
RETURNS uuid
|
||||||
|
LANGUAGE sql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT id FROM organization WHERE slug = p_slug AND public_profile = true;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public_org_by_slug(text) TO teecup_app;
|
||||||
|
|
@ -487,7 +487,82 @@ smalt unntak, ikke en generell RLS-omgåelse. Ethvert fremtidig offentlig
|
||||||
(uautentisert) endepunkt som trenger å slå opp org-kontekst fra en kjent
|
(uautentisert) endepunkt som trenger å slå opp org-kontekst fra en kjent
|
||||||
ressurs-id bør gjenbruke akkurat dette mønsteret, ikke finne opp et nytt.
|
ressurs-id bør gjenbruke akkurat dette mønsteret, ikke finne opp et nytt.
|
||||||
|
|
||||||
**Migrasjon:** `007_registration_and_player_fields.sql`.
|
**Migrasjon:** `007_registration_and_player_fields.sql`. Kontosammenkoblingen
|
||||||
|
i Beslutning B (e-post → `player.user_id` ved innlogging) fikk sin egen
|
||||||
|
`SECURITY DEFINER`-bro (`link_player_by_email()`, samme mønster som
|
||||||
|
Beslutning E) i en oppfølgende migrasjon, `008_link_player_by_email.sql`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-018 — Landingssider: synlighet, org-profil, delbart innhold
|
||||||
|
|
||||||
|
**Kontekst:** Reist av brukeren rett etter at registrerings-API-et
|
||||||
|
(ADR-017) var live — turneringer og organisasjoner bør ha egne, delbare
|
||||||
|
landingssider (hero, tekst, program, sponsorer, påmelding for turnering;
|
||||||
|
klubbprofil + turneringsliste for organisasjon). Brukeren krevde eksplisitt
|
||||||
|
at synlighet må være et VALG: offentlig, kun org-medlemmer, eller kun
|
||||||
|
turnering-deltakere.
|
||||||
|
|
||||||
|
**Beslutning A — Trenivås synlighet, ett felt per nivå.**
|
||||||
|
`tournament.visibility` (`'public'`/`'org'`/`'participants'`, default
|
||||||
|
`'org'`) og `organization.public_profile` (boolean, default `false`).
|
||||||
|
Trygg standard: ingen eksisterende eller nyopprettet turnering/org blir
|
||||||
|
offentlig av seg selv. "Kun deltakere" gir ikke mening på org-nivå (en
|
||||||
|
organisasjon har ikke "deltakere"), derfor en enklere bryter der, ikke
|
||||||
|
samme trenivå-enum som turnering.
|
||||||
|
|
||||||
|
**Beslutning B — RLS beskytter TENANT-grenser, ikke INNHOLDS-synlighet.**
|
||||||
|
`org_isolation`-policyen håndhever kun at org A aldri ser org B sin data —
|
||||||
|
den sier ingenting om hvem INNENFOR riktig org-kontekst som får se hva.
|
||||||
|
Det viste seg at det eksisterende `GET /public/tournaments/{id}` (ADR-017)
|
||||||
|
allerede leser fullt turneringsinnhold uten noen synlighetssjekk i det
|
||||||
|
hele tatt, fordi RLS er fornøyd så snart org-konteksten er riktig satt —
|
||||||
|
uansett hvem (eller om noen) som spør.
|
||||||
|
|
||||||
|
**Konsekvens:** `visibility` MÅ håndheves eksplisitt i applikasjonslaget på
|
||||||
|
hvert offentlig lese-endepunkt (og på registrering, se Beslutning D), ikke
|
||||||
|
forventes løst av RLS.
|
||||||
|
|
||||||
|
**Beslutning C — Ny autorisasjonsvei: "deltaker".** For
|
||||||
|
`visibility='participants'` må leseren enten være org-medlem, ELLER
|
||||||
|
innlogget med en `player`-rad (koblet via `player.user_id`, ADR-017
|
||||||
|
Beslutning B) som har en `tournament_registration`- eller
|
||||||
|
`team_roster`-rad for NØYAKTIG denne turneringen. Ny
|
||||||
|
`get_current_user_optional`-avhengighet i `app/auth.py` — som
|
||||||
|
`get_current_user`, men returnerer `None` i stedet for å kaste 401, siden
|
||||||
|
et offentlig endepunkt skal fungere for en anonym leser også (bare med
|
||||||
|
`visibility='public'`-tilgang).
|
||||||
|
|
||||||
|
**Beslutning D — Registrering følger SAMME synlighetsgrense som
|
||||||
|
landingssiden.** Kan du ikke se turneringen, kan du heller ikke melde deg
|
||||||
|
på den — ingen særbehandling av `/register`-endepunktet.
|
||||||
|
|
||||||
|
**Beslutning E — Turnering-landingsside: innhold nå, bilder som inerte
|
||||||
|
felt.** Nye felt: `tournament.description` (presenterende tekst),
|
||||||
|
`tournament.hero_image_key` (inert til MinIO-runden — kun kolonnen, ingen
|
||||||
|
opplastingslogikk, sparer en fremtidig migrasjon for null kostnad nå). Ny
|
||||||
|
tabell `tournament_sponsor` (navn+lenke aktivt, `logo_key` inert av samme
|
||||||
|
grunn). Program vises via `GET /public/tournaments/{id}/sessions`, som
|
||||||
|
gjenbruker `_fetch_sessions()` (nytt uttrekk fra `tournaments.py` sin
|
||||||
|
`list_sessions`, delt mellom den innloggede og den offentlige ruten) —
|
||||||
|
blind draw-hemmelighold (ADR-013) arves automatisk, ikke reimplementert.
|
||||||
|
**Bevisst utenfor omfang denne runden:** lag-/roster-visning på
|
||||||
|
landingssiden (må også respektere blind draw-lås) — egen, senere sak.
|
||||||
|
|
||||||
|
**Beslutning F — Org-landingsside: slug + tredje SECURITY DEFINER-bro.**
|
||||||
|
`organization.slug` (unik når satt, `CHECK (NOT public_profile OR slug IS
|
||||||
|
NOT NULL)`). `GET /public/orgs/{slug}` løser org via
|
||||||
|
`public_org_by_slug(slug)` — samme mønster som `public_tournament_org()`/
|
||||||
|
`link_player_by_email()` (007/008), nå tredje instans. Returnerer `NULL`
|
||||||
|
for BÅDE "finnes ikke" og "finnes, men er privat" — samme
|
||||||
|
anti-enumerering som magic-link (ADR-009), ikke en tilfeldighet at
|
||||||
|
mønsteret gjentas.
|
||||||
|
|
||||||
|
**Bevisst utsatt til egen, senere runde:** hero-/sponsorbilder (krever
|
||||||
|
MinIO — arkitektur-invarianten finnes fra før, se CLAUDE.md, men er aldri
|
||||||
|
satt opp i praksis).
|
||||||
|
|
||||||
|
**Migrasjon:** `009_landing_pages_and_visibility.sql`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
74
CLAUDE.md
74
CLAUDE.md
|
|
@ -3,7 +3,7 @@
|
||||||
Les dette først i hver økt. Det koder hva vi har bestemt og hvordan vi jobber.
|
Les dette først i hver økt. Det koder hva vi har bestemt og hvordan vi jobber.
|
||||||
|
|
||||||
## Autoritative kilder (les før du gjør noe)
|
## Autoritative kilder (les før du gjør noe)
|
||||||
- `ARCHITECTURE_DECISIONS.md` — hva som er bestemt og hvorfor (ADR-001…017). Fasit.
|
- `ARCHITECTURE_DECISIONS.md` — hva som er bestemt og hvorfor (ADR-001…018). Fasit.
|
||||||
- `FEATURE_BACKLOG.md` — hva som gjenstår, hva som er utsatt, hva som mangler.
|
- `FEATURE_BACKLOG.md` — hva som gjenstår, hva som er utsatt, hva som mangler.
|
||||||
- Endres en beslutning: legg til en ny ADR, ikke slett historikk. Hold begge
|
- Endres en beslutning: legg til en ny ADR, ikke slett historikk. Hold begge
|
||||||
filene oppdatert når noe avgjøres.
|
filene oppdatert når noe avgjøres.
|
||||||
|
|
@ -443,11 +443,77 @@ Ferdig og verifisert:
|
||||||
**ADR-017s backend er dermed komplett** (registrering + kontokobling).
|
**ADR-017s backend er dermed komplett** (registrering + kontokobling).
|
||||||
Gjenstående: frontend-påmeldingsskjema og landingssider — egen, senere
|
Gjenstående: frontend-påmeldingsskjema og landingssider — egen, senere
|
||||||
ADR-runde (se `FEATURE_BACKLOG.md`).
|
ADR-runde (se `FEATURE_BACKLOG.md`).
|
||||||
|
- **ADR-018 + migrasjon 009: landingssider, backend LIVE (2026-07-18),
|
||||||
|
samme dag:** trenivås synlighet (`tournament.visibility`:
|
||||||
|
`public`/`org`/`participants`, default `org` — trygg standard) +
|
||||||
|
`organization.public_profile`. **Reell presisering funnet underveis, ikke
|
||||||
|
antatt på forhånd:** RLS (`org_isolation`) beskytter kun TENANT-grenser
|
||||||
|
(org A ser aldri org B), IKKE innholds-synlighet innenfor riktig
|
||||||
|
org-kontekst — det eksisterende `GET /public/tournaments/{id}` (ADR-017)
|
||||||
|
leste allerede fullt innhold uten synlighetssjekk, fordi RLS er fornøyd
|
||||||
|
så snart org-konteksten er satt, uansett hvem som spør. `visibility`
|
||||||
|
håndheves derfor eksplisitt i `app/routers/registration.py`, på BÅDE
|
||||||
|
lesing og registrering (ADR-018 Beslutning D: kan du ikke se turneringen,
|
||||||
|
kan du heller ikke melde deg på den — bekreftet med bruker FØR bygging).
|
||||||
|
Ny `get_current_user_optional` i `app/auth.py` (som `get_current_user`,
|
||||||
|
men returnerer `None` i stedet for 401 — offentlige endepunkter skal
|
||||||
|
fungere for anonyme lesere også). Ny "deltaker"-autorisasjonsvei: en
|
||||||
|
innlogget bruker med `player.user_id` koblet (ADR-017) OG en
|
||||||
|
`tournament_registration`- eller `team_roster`-rad for NØYAKTIG den
|
||||||
|
turneringen får se `participants`-synlige turneringer, uansett
|
||||||
|
org-medlemskap.
|
||||||
|
**Ekte migrasjonsfeil funnet OG rettet UNDER scratch-verifisering, ikke
|
||||||
|
antatt riktig:** migrasjonen feilet først ("column slug already exists")
|
||||||
|
— `organization.slug` har ligget i skjemaet siden migrasjon 001
|
||||||
|
("f.eks. subdomene/URL-vennlig", allerede med en plain `UNIQUE`), noe jeg
|
||||||
|
hadde oversett fullstendig og forsøkt å legge til på nytt. Rettet ved å
|
||||||
|
fjerne den doble `ADD COLUMN` + den overflødige partielle unik-indeksen
|
||||||
|
(001 sin plain `UNIQUE` dekker "unik når satt" allerede, siden Postgres
|
||||||
|
behandler NULL som distinkt), beholde kun de nye `CHECK`-constraintene.
|
||||||
|
Kjørte rent på ny etter fiksen. **Lærdom:** grep alltid eksisterende
|
||||||
|
skjema for feltnavn FØR en ny migrasjon skrives, ikke bare stol på
|
||||||
|
hukommelsen om hva som "sikkert" ikke finnes fra før.
|
||||||
|
Ny tabell `tournament_sponsor` (navn+lenke aktivt, `logo_key` inert til
|
||||||
|
MinIO-runden — samme med `tournament.hero_image_key`). Tredje
|
||||||
|
`SECURITY DEFINER`-bro i prosjektet: `public_org_by_slug()` (etter
|
||||||
|
`public_tournament_org` 007, `link_player_by_email` 008) — returnerer
|
||||||
|
`NULL` for BÅDE "finnes ikke" og "finnes, men er privat", samme
|
||||||
|
anti-enumerering som magic-link.
|
||||||
|
**Fylte også et implisitt hull oppdaget underveis:** ADR-en beskrev
|
||||||
|
hvordan synlighet skulle håndheves, men ingen tidligere runde hadde bygget
|
||||||
|
noen vei for organisator til faktisk å SETTE disse feltene. Lagt til:
|
||||||
|
`PATCH /orgs/{id}/tournaments/{id}` (visibility/description/
|
||||||
|
registrerings-innstillinger, ekte PATCH-semantikk via Pydantic sin
|
||||||
|
`exclude_unset` — et utelatt felt nullstilles IKKE), `PATCH /orgs/{id}`
|
||||||
|
(slug/public_profile), full sponsor-CRUD. `_fetch_sessions()` trukket ut
|
||||||
|
som delt hjelpefunksjon i `tournaments.py` (delt mellom den innloggede
|
||||||
|
og den nye offentlige `GET /public/tournaments/{id}/sessions` — blind
|
||||||
|
draw-hemmelighold, ADR-013, arves automatisk, ikke reimplementert).
|
||||||
|
**Verifisert grundig mot fersk `teecup_scratch`** (001→009,
|
||||||
|
`test_isolation.sql` 12/12): hele synlighetsmatrisen testet med ekte
|
||||||
|
HTTP-kall — anonym avvist på `org`-synlig turnering (både lesing OG
|
||||||
|
registrering), `PATCH` til `public` + beskrivelse + sponsor fungerte,
|
||||||
|
anonym lesing fungerte deretter, `participants`-synlighet bekreftet
|
||||||
|
reell chicken-and-egg-konsekvens av Beslutning D (ingen kan selv-
|
||||||
|
registrere seg til en `participants`-synlig turnering, kun organisator
|
||||||
|
kan legge til direkte — korrekt, ikke en bug), en organisator-rostret
|
||||||
|
spiller som logget inn fikk tilgang, en tilfeldig innlogget FREMMED
|
||||||
|
(ikke deltaker) ble fortsatt avvist, org-landingsside viste KUN
|
||||||
|
`public`-synlige turneringer, `CHECK`-constraint (`public_profile`
|
||||||
|
krever `slug`) avvist korrekt, ugyldig slug-format avvist av Pydantic,
|
||||||
|
sponsor-sletting fungerte.
|
||||||
|
**Kjørt mot ekte `teecup_db` 2026-07-18**, bruker bekreftet eksplisitt,
|
||||||
|
backend redeployet, live sjekker OK, `teeoff.no` upåvirket. Ingen
|
||||||
|
frontend-endring nødvendig for selve API-tilgangen (det brede
|
||||||
|
`/public/:path*`-mønsteret fra ADR-016 dekker allerede `/public/orgs/*`).
|
||||||
|
**Gjenstår:** selve landingsside-SKJERMENE i frontend (V0), og
|
||||||
|
MinIO/bildeopplasting — bevisst utsatt, egen runde.
|
||||||
|
|
||||||
Neste steg:
|
Neste steg:
|
||||||
1. Landingssider (turnering/org) med påmeldingsskjema i frontend — egen
|
1. Landingssider (turnering/org): ADR-018 sin backend er ferdig og live
|
||||||
ADR-runde (synlighetsvalg offentlig/org/deltakere, slug-URL for org,
|
(synlighet, org-profil/slug, sponsorer, PATCH-endepunkter) — gjenstår er
|
||||||
MinIO for bilder — se `FEATURE_BACKLOG.md`).
|
selve skjermene i frontend (V0), pluss MinIO/bildeopplasting som egen,
|
||||||
|
senere runde.
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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) — SAMME trenivå-modell som landingssider under, bør trolig løses med ÉN delt mekanisme, ikke to separate. |
|
| Offentlig runde-feed («Banter Board») | ❓ | Synlighetsnivå fortsatt ikke besluttet for FEED-en spesifikt, men mekanismen finnes nå: `tournament.visibility` + `get_current_user_optional`/`_is_participant()` (ADR-018) er bygget og live — gjenbruk dette, ikke bygg en ny mekanisme. |
|
||||||
| 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,22 +405,26 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Landingssider (turnering + organisasjon) — idé, ikke besluttet
|
## Landingssider (turnering + organisasjon) — ADR-018, backend ✅ LIVE 2026-07-18
|
||||||
|
|
||||||
Reist av brukeren 2026-07-18, rett etter registrerings-ADR-en (ADR-017).
|
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
|
Backend bygget og live samme dag. Frontend-skjermene og MinIO/bilder
|
||||||
sammen med registrerings-API-et.
|
gjenstår, bevisst egne runder.
|
||||||
|
|
||||||
| Del | Status | Notat |
|
| 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. |
|
| Synlighetsnivå: offentlig / kun org-medlemmer / kun turnering-deltakere | ✅ | `tournament.visibility` (default `org`, trygg standard) + `organization.public_profile`. Håndheves eksplisitt i `registration.py` — RLS løser IKKE dette alene (se ADR-018 Beslutning B, reell presisering funnet under bygging). Samme trenivå-modell som «Banter Board» under bør gjenbruke dette. |
|
||||||
| 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. |
|
| Registrering følger samme synlighetsgrense | ✅ | ADR-018 Beslutning D, bekreftet med bruker FØR bygging. |
|
||||||
| 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. |
|
| "Deltaker"-tilgang (ikke org-medlem, men rostret/registrert) | ✅ | Ny `get_current_user_optional` + `_is_participant()`. Testet: rostret spiller som logget inn fikk tilgang, tilfeldig fremmed ble avvist. |
|
||||||
| 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`. |
|
| Turnering-landingsside: tekst, program, sponsorer, påmelding (API) | ✅ | `description`-felt, `tournament_sponsor`-tabell (navn+lenke), `GET /public/tournaments/{id}/sessions` (gjenbruker blind draw-lås fra ADR-013). Selve SKJERMEN i frontend gjenstår. |
|
||||||
| 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. |
|
| Org-landingsside: klubbprofil, liste over turneringer (API) | ✅ | `GET /public/orgs/{slug}` — kun `public`-synlige turneringer. Bekreftet: klubb-profil KAN være offentlig (brukerens valg). |
|
||||||
| 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. |
|
| Lesbar URL (slug) for organisasjon | ✅ | Fantes faktisk allerede i skjemaet siden migrasjon 001 (oversett, funnet da migrasjon 009 feilet mot scratch — se CLAUDE.md-status). Kun `CHECK`-constraints lagt til i 009. |
|
||||||
| 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. |
|
| Organisator kan faktisk SETTE disse feltene | ✅ | Implisitt hull fylt under bygging: `PATCH /orgs/{id}/tournaments/{id}` (visibility/description/registrering), `PATCH /orgs/{id}` (slug/public_profile), full sponsor-CRUD. |
|
||||||
| Antall påmeldte / ledige plasser vist åpent | 📋 | Bruker `tournament.registration_capacity` fra ADR-017 direkte, ingen ny data nødvendig. |
|
| Bilder (hero, sponsorlogoer) | 📋 | Bevisst utsatt (brukerens valg). `hero_image_key`/`logo_key`-kolonner finnes allerede (inerte), men krever MinIO — ikke satt opp. Egen, senere runde. |
|
||||||
|
| Del-metadata (Open Graph: og:title/og:image/og:description) | 📋 | Frontend-arbeid, ikke gjort ennå. |
|
||||||
|
| Blind draw-skjuling på offentlig side | ✅ | Arves automatisk via delt `_fetch_sessions()`-hjelpefunksjon (ADR-018 Beslutning E) — ikke reimplementert. |
|
||||||
|
| Antall påmeldte / ledige plasser vist åpent | ✅ | `confirmed_count` i `GET /public/tournaments/{id}`. |
|
||||||
|
| Frontend-skjermer (turnering-/org-landingsside, påmeldingsskjema) | 📋 | Ikke startet — neste steg. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
27
app/auth.py
27
app/auth.py
|
|
@ -81,6 +81,33 @@ async def get_current_user(request: Request) -> CurrentUser:
|
||||||
return CurrentUser(user_id=user_id)
|
return CurrentUser(user_id=user_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user_optional(request: Request) -> CurrentUser | None:
|
||||||
|
"""Som get_current_user, men returnerer None i stedet for å kaste 401.
|
||||||
|
|
||||||
|
Brukt av offentlige landingsside-endepunkter (ADR-018) som skal fungere
|
||||||
|
for en helt anonym leser også -- bare med redusert tilgang (kun
|
||||||
|
visibility='public'-turneringer), ikke en hard 401.
|
||||||
|
"""
|
||||||
|
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
claims = jwt.decode(token, settings.SESSION_SECRET, algorithms=[_JWT_ALGORITHM])
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
user_id = claims.get("sub")
|
||||||
|
if not user_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async with plain_connection() as conn:
|
||||||
|
exists = await conn.fetchval("SELECT EXISTS (SELECT 1 FROM app_user WHERE id = $1)", user_id)
|
||||||
|
if not exists:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return CurrentUser(user_id=user_id)
|
||||||
|
|
||||||
|
|
||||||
async def get_authorized_org(
|
async def get_authorized_org(
|
||||||
organization_id: str,
|
organization_id: str,
|
||||||
user: CurrentUser = Depends(get_current_user),
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ 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.include_router(registration.router)
|
||||||
|
app.include_router(registration.org_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,9 @@ import uuid
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ..auth import CurrentUser, get_current_user
|
from ..auth import CurrentUser, get_authorized_org, get_current_user
|
||||||
from ..db import org_connection
|
from ..db import org_connection
|
||||||
from ..errors import translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -59,3 +59,49 @@ async def create_organization(
|
||||||
)
|
)
|
||||||
|
|
||||||
return OrganizationOut(id=row["id"], name=row["name"], role="owner")
|
return OrganizationOut(id=row["id"], name=row["name"], role="owner")
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationUpdate(BaseModel):
|
||||||
|
"""Alle felt valgfrie -- kun de som faktisk sendes med blir endret
|
||||||
|
(exclude_unset, se update_organization). Foreløpig kun de to feltene
|
||||||
|
landingssiden trenger (ADR-018) -- ikke en generell org-innstillings-
|
||||||
|
endepunkt."""
|
||||||
|
|
||||||
|
public_profile: bool | None = None
|
||||||
|
slug: str | None = Field(default=None, pattern="^[a-z0-9]+(-[a-z0-9]+)*$")
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationSettings(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
public_profile: bool
|
||||||
|
slug: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/orgs/{organization_id}", response_model=OrganizationSettings)
|
||||||
|
async def update_organization(
|
||||||
|
body: OrganizationUpdate,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> OrganizationSettings:
|
||||||
|
updates = body.model_dump(exclude_unset=True)
|
||||||
|
if not updates:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
|
||||||
|
|
||||||
|
# Trygt mot injeksjon: nøklene kommer KUN fra OrganizationUpdate sine
|
||||||
|
# egne feltnavn (fast, kjent hviteliste).
|
||||||
|
set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)]
|
||||||
|
values = list(updates.values())
|
||||||
|
values.append(organization_id)
|
||||||
|
|
||||||
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f"""
|
||||||
|
UPDATE organization SET {', '.join(set_clauses)}
|
||||||
|
WHERE id = ${len(values)}
|
||||||
|
RETURNING id::text, name, public_profile, slug
|
||||||
|
""",
|
||||||
|
*values,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Organisasjonen finnes ikke.")
|
||||||
|
return OrganizationSettings(**dict(row))
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,34 @@
|
||||||
"""
|
"""
|
||||||
Offentlig, uautentisert registrerings-API (ADR-017).
|
Offentlig, uautentisert registrerings- og landingsside-API (ADR-017, ADR-018).
|
||||||
|
|
||||||
Bevisst egen fil med prefiks /public/tournaments, atskilt fra /orgs/... --
|
Bevisst egen fil med prefiks /public/..., atskilt fra /orgs/... -- gjør
|
||||||
gjør sikkerhetsgrensen eksplisitt i koden: disse to endepunktene krever
|
sikkerhetsgrensen eksplisitt i koden: disse endepunktene krever VERKEN
|
||||||
VERKEN innlogging (get_current_user) ELLER org-medlemskap
|
innlogging (get_current_user) ELLER org-medlemskap (get_authorized_org) for
|
||||||
(get_authorized_org), i motsetning til absolutt alt annet i API-et.
|
å svare i det hele tatt.
|
||||||
|
|
||||||
`public_tournament_org()` (migrasjon 007, SECURITY DEFINER) er den ENESTE
|
To SECURITY DEFINER-broer (public_tournament_org, public_org_by_slug) er den
|
||||||
broen fra en turnering-id til riktig RLS-kontekst FØR den er kjent -- se
|
ENESTE måten disse endepunktene får vite riktig RLS-kontekst FØR den er
|
||||||
ADR-017 Beslutning E for hvorfor dette er trygt (smalt unntak, ikke en
|
kjent -- se ADR-017 Beslutning E / ADR-018 Beslutning F.
|
||||||
generell RLS-omgåelse).
|
|
||||||
|
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 datetime import date, datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel, EmailStr, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
from ..auth import CurrentUser, get_current_user_optional
|
||||||
from ..db import org_connection, plain_connection
|
from ..db import org_connection, plain_connection
|
||||||
from ..errors import app_error, translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
|
from .tournaments import SessionOut, _fetch_sessions
|
||||||
|
|
||||||
router = APIRouter(prefix="/public/tournaments", tags=["public-registration"])
|
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 def _resolve_org(tournament_id: str) -> str:
|
||||||
|
|
@ -31,26 +39,90 @@ async def _resolve_org(tournament_id: str) -> str:
|
||||||
return str(org_id)
|
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):
|
class PublicTournamentInfo(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
organization_name: str
|
organization_name: str
|
||||||
status: str
|
status: str
|
||||||
|
visibility: str
|
||||||
|
description: str | None
|
||||||
start_date: date | None
|
start_date: date | None
|
||||||
end_date: date | None
|
end_date: date | None
|
||||||
registration_open: bool
|
registration_open: bool
|
||||||
registration_deadline: datetime | None
|
registration_deadline: datetime | None
|
||||||
registration_capacity: int | None
|
registration_capacity: int | None
|
||||||
confirmed_count: int
|
confirmed_count: int
|
||||||
|
sponsors: list[SponsorOut]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{tournament_id}", response_model=PublicTournamentInfo)
|
@router.get("/{tournament_id}", response_model=PublicTournamentInfo)
|
||||||
async def get_public_tournament(tournament_id: str) -> 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)
|
organization_id = await _resolve_org(tournament_id)
|
||||||
async with org_connection(organization_id) as conn:
|
async with org_connection(organization_id) as conn:
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT t.id::text, t.name, o.name AS organization_name, t.status::text,
|
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.start_date, t.end_date, t.registration_deadline,
|
||||||
t.registration_capacity,
|
t.registration_capacity,
|
||||||
(SELECT count(*)::int FROM tournament_registration tr
|
(SELECT count(*)::int FROM tournament_registration tr
|
||||||
|
|
@ -65,6 +137,13 @@ async def get_public_tournament(tournament_id: str) -> PublicTournamentInfo:
|
||||||
if row is None:
|
if row is None:
|
||||||
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
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 = row["registration_deadline"]
|
||||||
deadline_passed = deadline is not None and deadline < datetime.now(timezone.utc)
|
deadline_passed = deadline is not None and deadline < datetime.now(timezone.utc)
|
||||||
return PublicTournamentInfo(
|
return PublicTournamentInfo(
|
||||||
|
|
@ -72,15 +151,32 @@ async def get_public_tournament(tournament_id: str) -> PublicTournamentInfo:
|
||||||
name=row["name"],
|
name=row["name"],
|
||||||
organization_name=row["organization_name"],
|
organization_name=row["organization_name"],
|
||||||
status=row["status"],
|
status=row["status"],
|
||||||
|
visibility=row["visibility"],
|
||||||
|
description=row["description"],
|
||||||
start_date=row["start_date"],
|
start_date=row["start_date"],
|
||||||
end_date=row["end_date"],
|
end_date=row["end_date"],
|
||||||
registration_open=not deadline_passed,
|
registration_open=not deadline_passed,
|
||||||
registration_deadline=deadline,
|
registration_deadline=deadline,
|
||||||
registration_capacity=row["registration_capacity"],
|
registration_capacity=row["registration_capacity"],
|
||||||
confirmed_count=row["confirmed_count"],
|
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):
|
class RegisterRequest(BaseModel):
|
||||||
display_name: str = Field(min_length=1, max_length=200)
|
display_name: str = Field(min_length=1, max_length=200)
|
||||||
email: EmailStr | None = None
|
email: EmailStr | None = None
|
||||||
|
|
@ -102,7 +198,11 @@ class RegistrationResult(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{tournament_id}/register", response_model=RegistrationResult, status_code=201)
|
@router.post("/{tournament_id}/register", response_model=RegistrationResult, status_code=201)
|
||||||
async def register_for_tournament(tournament_id: str, body: RegisterRequest) -> RegistrationResult:
|
async def register_for_tournament(
|
||||||
|
tournament_id: str,
|
||||||
|
body: RegisterRequest,
|
||||||
|
user: CurrentUser | None = Depends(get_current_user_optional),
|
||||||
|
) -> RegistrationResult:
|
||||||
if not body.consent:
|
if not body.consent:
|
||||||
raise app_error(400, "VALIDATION_FAILED", "Samtykke må gis for å melde seg på.")
|
raise app_error(400, "VALIDATION_FAILED", "Samtykke må gis for å melde seg på.")
|
||||||
|
|
||||||
|
|
@ -114,7 +214,7 @@ async def register_for_tournament(tournament_id: str, body: RegisterRequest) ->
|
||||||
# mønster som to-lags-grensen i tournaments.py sin create_team.
|
# mønster som to-lags-grensen i tournaments.py sin create_team.
|
||||||
tournament = await conn.fetchrow(
|
tournament = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT registration_deadline, registration_capacity,
|
SELECT visibility, registration_deadline, registration_capacity,
|
||||||
registration_overflow_policy, registration_requires_approval
|
registration_overflow_policy, registration_requires_approval
|
||||||
FROM tournament WHERE id = $1 FOR UPDATE
|
FROM tournament WHERE id = $1 FOR UPDATE
|
||||||
""",
|
""",
|
||||||
|
|
@ -123,6 +223,10 @@ async def register_for_tournament(tournament_id: str, body: RegisterRequest) ->
|
||||||
if tournament is None:
|
if tournament is None:
|
||||||
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
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"]
|
deadline = tournament["registration_deadline"]
|
||||||
if deadline is not None and deadline < datetime.now(timezone.utc):
|
if deadline is not None and deadline < datetime.now(timezone.utc):
|
||||||
raise app_error(409, "REGISTRATION_CLOSED", "Påmeldingen er stengt.")
|
raise app_error(409, "REGISTRATION_CLOSED", "Påmeldingen er stengt.")
|
||||||
|
|
@ -226,3 +330,41 @@ async def register_for_tournament(tournament_id: str, body: RegisterRequest) ->
|
||||||
status,
|
status,
|
||||||
)
|
)
|
||||||
return RegistrationResult(id=row["id"], status=row["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],
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,25 @@ class TournamentCreate(BaseModel):
|
||||||
end_date: date | None = None
|
end_date: date | None = None
|
||||||
|
|
||||||
|
|
||||||
|
_TOURNAMENT_COLUMNS = """
|
||||||
|
id::text, name, status::text, start_date, end_date,
|
||||||
|
visibility, description, registration_deadline, registration_capacity,
|
||||||
|
registration_overflow_policy, registration_requires_approval
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class Tournament(BaseModel):
|
class Tournament(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
status: str
|
status: str
|
||||||
start_date: date | None
|
start_date: date | None
|
||||||
end_date: date | None
|
end_date: date | None
|
||||||
|
visibility: str
|
||||||
|
description: str | None
|
||||||
|
registration_deadline: datetime | None
|
||||||
|
registration_capacity: int | None
|
||||||
|
registration_overflow_policy: str
|
||||||
|
registration_requires_approval: bool
|
||||||
|
|
||||||
|
|
||||||
@router.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament])
|
@router.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament])
|
||||||
|
|
@ -45,10 +58,7 @@ async def list_tournaments(
|
||||||
) -> list[Tournament]:
|
) -> list[Tournament]:
|
||||||
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 {_TOURNAMENT_COLUMNS} FROM tournament ORDER BY created_at DESC"
|
||||||
SELECT id::text, name, status::text, start_date, end_date
|
|
||||||
FROM tournament ORDER BY created_at DESC
|
|
||||||
"""
|
|
||||||
)
|
)
|
||||||
return [Tournament(**dict(r)) for r in rows]
|
return [Tournament(**dict(r)) for r in rows]
|
||||||
|
|
||||||
|
|
@ -60,10 +70,10 @@ async def create_tournament(
|
||||||
) -> Tournament:
|
) -> Tournament:
|
||||||
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 tournament (organization_id, name, start_date, end_date)
|
INSERT INTO tournament (organization_id, name, start_date, end_date)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING id::text, name, status::text, start_date, end_date
|
RETURNING {_TOURNAMENT_COLUMNS}
|
||||||
""",
|
""",
|
||||||
organization_id,
|
organization_id,
|
||||||
body.name,
|
body.name,
|
||||||
|
|
@ -73,6 +83,51 @@ async def create_tournament(
|
||||||
return Tournament(**dict(row))
|
return Tournament(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
class TournamentUpdate(BaseModel):
|
||||||
|
"""Alle felt valgfrie -- kun de som faktisk sendes med blir endret
|
||||||
|
(PATCH-semantikk via Pydantic sin exclude_unset, se update_tournament)."""
|
||||||
|
|
||||||
|
visibility: str | None = Field(default=None, pattern="^(public|org|participants)$")
|
||||||
|
description: str | None = None
|
||||||
|
registration_deadline: datetime | None = None
|
||||||
|
registration_capacity: int | None = Field(default=None, gt=0)
|
||||||
|
registration_overflow_policy: str | None = Field(default=None, pattern="^(waitlist|closed)$")
|
||||||
|
registration_requires_approval: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/orgs/{organization_id}/tournaments/{tournament_id}", response_model=Tournament)
|
||||||
|
async def update_tournament(
|
||||||
|
tournament_id: str,
|
||||||
|
body: TournamentUpdate,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> Tournament:
|
||||||
|
# exclude_unset: kun felt klienten FAKTISK sendte skal skrives -- et
|
||||||
|
# utelatt felt skal IKKE nullstilles (ekte PATCH-semantikk, ikke PUT).
|
||||||
|
updates = body.model_dump(exclude_unset=True)
|
||||||
|
if not updates:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
|
||||||
|
|
||||||
|
# Trygt mot injeksjon: nøklene kommer KUN fra TournamentUpdate sine egne
|
||||||
|
# feltnavn (en fast, kjent hviteliste), aldri fra en rå streng brukeren
|
||||||
|
# sender inn.
|
||||||
|
set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)]
|
||||||
|
values = list(updates.values())
|
||||||
|
values.append(tournament_id)
|
||||||
|
|
||||||
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f"""
|
||||||
|
UPDATE tournament SET {', '.join(set_clauses)}
|
||||||
|
WHERE id = ${len(values)}
|
||||||
|
RETURNING {_TOURNAMENT_COLUMNS}
|
||||||
|
""",
|
||||||
|
*values,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
||||||
|
return Tournament(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Lag (ADR-011: maks to lag per turnering, håndhevet her — ikke i skjemaet —
|
# Lag (ADR-011: maks to lag per turnering, håndhevet her — ikke i skjemaet —
|
||||||
# slik at match-modellen kan holdes generell for fremtidige turneringstyper)
|
# slik at match-modellen kan holdes generell for fremtidige turneringstyper)
|
||||||
|
|
@ -369,15 +424,10 @@ def _session_out(row, locked: set[str]) -> SessionOut:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
async def _fetch_sessions(conn, tournament_id: str) -> list[SessionOut]:
|
||||||
"/orgs/{organization_id}/tournaments/{tournament_id}/sessions",
|
"""Delt mellom den innloggede org-ruten under og det offentlige
|
||||||
response_model=list[SessionOut],
|
landingsside-endepunktet (app/routers/registration.py, ADR-018) -- samme
|
||||||
)
|
spørring, samme blind draw-låsing, ikke duplisert to steder."""
|
||||||
async def list_sessions(
|
|
||||||
tournament_id: str,
|
|
||||||
organization_id: str = Depends(get_authorized_org),
|
|
||||||
) -> list[SessionOut]:
|
|
||||||
async with org_connection(organization_id) as conn:
|
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
|
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
|
||||||
|
|
@ -397,6 +447,18 @@ async def list_sessions(
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/orgs/{organization_id}/tournaments/{tournament_id}/sessions",
|
||||||
|
response_model=list[SessionOut],
|
||||||
|
)
|
||||||
|
async def list_sessions(
|
||||||
|
tournament_id: str,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> list[SessionOut]:
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
return await _fetch_sessions(conn, tournament_id)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/orgs/{organization_id}/tournaments/{tournament_id}/sessions",
|
"/orgs/{organization_id}/tournaments/{tournament_id}/sessions",
|
||||||
response_model=SessionOut,
|
response_model=SessionOut,
|
||||||
|
|
@ -436,3 +498,81 @@ async def create_session(
|
||||||
body.start_hole,
|
body.start_hole,
|
||||||
)
|
)
|
||||||
return _session_out(row, set())
|
return _session_out(row, set())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sponsorer (ADR-018) -- navn+lenke aktivt, logo inert til MinIO-runden.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class SponsorCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=200)
|
||||||
|
url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Sponsor(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
url: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/orgs/{organization_id}/tournaments/{tournament_id}/sponsors",
|
||||||
|
response_model=list[Sponsor],
|
||||||
|
)
|
||||||
|
async def list_sponsors(
|
||||||
|
tournament_id: str,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> list[Sponsor]:
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT id::text, name, url FROM tournament_sponsor WHERE tournament_id = $1 ORDER BY created_at",
|
||||||
|
tournament_id,
|
||||||
|
)
|
||||||
|
return [Sponsor(**dict(r)) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/orgs/{organization_id}/tournaments/{tournament_id}/sponsors",
|
||||||
|
response_model=Sponsor,
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
async def add_sponsor(
|
||||||
|
tournament_id: str,
|
||||||
|
body: SponsorCreate,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> Sponsor:
|
||||||
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
|
tournament = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id)
|
||||||
|
if tournament is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO tournament_sponsor (organization_id, tournament_id, name, url)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id::text, name, url
|
||||||
|
""",
|
||||||
|
organization_id,
|
||||||
|
tournament_id,
|
||||||
|
body.name,
|
||||||
|
body.url,
|
||||||
|
)
|
||||||
|
return Sponsor(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/orgs/{organization_id}/tournaments/{tournament_id}/sponsors/{sponsor_id}",
|
||||||
|
status_code=204,
|
||||||
|
)
|
||||||
|
async def delete_sponsor(
|
||||||
|
tournament_id: str,
|
||||||
|
sponsor_id: str,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> None:
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
deleted = await conn.fetchval(
|
||||||
|
"DELETE FROM tournament_sponsor WHERE id = $1 AND tournament_id = $2 RETURNING id",
|
||||||
|
sponsor_id,
|
||||||
|
tournament_id,
|
||||||
|
)
|
||||||
|
if deleted is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Sponsoren finnes ikke.")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue