From 2058d63db5b3c5d8c85bd253e3aa6f4c167d27fe Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Sat, 18 Jul 2026 09:45:11 +0200 Subject: [PATCH] ADR-018 sin backend er ferdig, verifisert og live. Oppsummert: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit É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. --- .claude/settings.local.json | 4 +- 009_landing_pages_and_visibility.sql | 86 +++++++++++++ ARCHITECTURE_DECISIONS.md | 77 ++++++++++- CLAUDE.md | 74 ++++++++++- FEATURE_BACKLOG.md | 28 ++-- app/auth.py | 27 ++++ app/main.py | 1 + app/routers/organizations.py | 50 ++++++- app/routers/registration.py | 168 ++++++++++++++++++++++-- app/routers/tournaments.py | 186 +++++++++++++++++++++++---- 10 files changed, 645 insertions(+), 56 deletions(-) create mode 100644 009_landing_pages_and_visibility.sql diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f74955d..3df022f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -194,7 +194,9 @@ "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)", - "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": [ "/opt/teeoff/deploy", diff --git a/009_landing_pages_and_visibility.sql b/009_landing_pages_and_visibility.sql new file mode 100644 index 0000000..4b75baf --- /dev/null +++ b/009_landing_pages_and_visibility.sql @@ -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; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 4de395e..eb11179 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -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 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`. --- diff --git a/CLAUDE.md b/CLAUDE.md index 8473beb..00cfd16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ 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) -- `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. - Endres en beslutning: legg til en ny ADR, ikke slett historikk. Hold begge filene oppdatert når noe avgjøres. @@ -443,11 +443,77 @@ Ferdig og verifisert: **ADR-017s backend er dermed komplett** (registrering + kontokobling). Gjenstående: frontend-påmeldingsskjema og landingssider — egen, senere 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: -1. Landingssider (turnering/org) med påmeldingsskjema i frontend — egen - ADR-runde (synlighetsvalg offentlig/org/deltakere, slug-URL for org, - MinIO for bilder — se `FEATURE_BACKLOG.md`). +1. Landingssider (turnering/org): ADR-018 sin backend er ferdig og live + (synlighet, org-profil/slug, sponsorer, PATCH-endepunkter) — gjenstår er + selve skjermene i frontend (V0), pluss MinIO/bildeopplasting som egen, + senere runde. 2. Flere V0-skjermer (økt/program, blind draw, scorekort, leaderboard) — 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 diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index d64e908..1804cf3 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -397,7 +397,7 @@ | Del | Status | Notat | |---|---|---| | 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. | | Video | 💤 | Arkitekt for det, bygg senere (ADR-forslag). | | 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). -Egen ADR-runde planlagt SENERE — for mange åpne spørsmål til å bygges -sammen med registrerings-API-et. +Backend bygget og live samme dag. Frontend-skjermene og MinIO/bilder +gjenstår, bevisst egne runder. | 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. | +| 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. | +| Registrering følger samme synlighetsgrense | ✅ | ADR-018 Beslutning D, bekreftet med bruker FØR bygging. | +| "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. | +| 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. | +| Org-landingsside: klubbprofil, liste over turneringer (API) | ✅ | `GET /public/orgs/{slug}` — kun `public`-synlige turneringer. Bekreftet: klubb-profil KAN være offentlig (brukerens valg). | +| 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. | +| 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. | +| 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. | --- diff --git a/app/auth.py b/app/auth.py index f64733d..5fd857e 100644 --- a/app/auth.py +++ b/app/auth.py @@ -81,6 +81,33 @@ async def get_current_user(request: Request) -> CurrentUser: 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( organization_id: str, user: CurrentUser = Depends(get_current_user), diff --git a/app/main.py b/app/main.py index bb05736..b53ec7d 100644 --- a/app/main.py +++ b/app/main.py @@ -36,6 +36,7 @@ app.include_router(tournaments.router) app.include_router(matches.router) app.include_router(scoring.router) app.include_router(registration.router) +app.include_router(registration.org_router) @app.get("/health") diff --git a/app/routers/organizations.py b/app/routers/organizations.py index 627c8ea..a31eba4 100644 --- a/app/routers/organizations.py +++ b/app/routers/organizations.py @@ -22,9 +22,9 @@ import uuid from fastapi import APIRouter, Depends 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 ..errors import translate_db_errors +from ..errors import app_error, translate_db_errors router = APIRouter() @@ -59,3 +59,49 @@ async def create_organization( ) 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)) diff --git a/app/routers/registration.py b/app/routers/registration.py index c5ecad3..cfa7bb1 100644 --- a/app/routers/registration.py +++ b/app/routers/registration.py @@ -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/... -- -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. +Bevisst egen fil med prefiks /public/..., atskilt fra /orgs/... -- gjør +sikkerhetsgrensen eksplisitt i koden: disse endepunktene krever VERKEN +innlogging (get_current_user) ELLER org-medlemskap (get_authorized_org) for +å svare i det hele tatt. -`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). +To SECURITY DEFINER-broer (public_tournament_org, public_org_by_slug) er den +ENESTE måten disse endepunktene får vite riktig RLS-kontekst FØR den er +kjent -- se ADR-017 Beslutning E / ADR-018 Beslutning F. + +VIKTIG (ADR-018 Beslutning B): RLS (org_isolation) beskytter kun TENANT- +grenser (org A ser aldri org B), ikke INNHOLDS-synlighet innenfor riktig +org-kontekst. `tournament.visibility` håndheves derfor eksplisitt her i +applikasjonslaget, på hvert lese- OG skrive-endepunkt (registrering følger +samme grense som lesing, ADR-018 Beslutning D) -- RLS alene løser det ikke. """ from datetime import date, datetime, timezone -from fastapi import APIRouter +from fastapi import APIRouter, Depends from pydantic import BaseModel, EmailStr, Field +from ..auth import CurrentUser, get_current_user_optional from ..db import org_connection, plain_connection from ..errors import app_error, translate_db_errors +from .tournaments import SessionOut, _fetch_sessions router = APIRouter(prefix="/public/tournaments", tags=["public-registration"]) +org_router = APIRouter(prefix="/public/orgs", tags=["public-registration"]) async def _resolve_org(tournament_id: str) -> str: @@ -31,26 +39,90 @@ async def _resolve_org(tournament_id: str) -> str: return str(org_id) +async def _is_participant(conn, user_id: str, organization_id: str, tournament_id: str) -> bool: + """Har brukerens koblede spillerprofil (ADR-017 Beslutning B) en + registrering ELLER en rostret plass i NØYAKTIG denne turneringen?""" + return await conn.fetchval( + """ + SELECT EXISTS ( + SELECT 1 FROM player p + WHERE p.organization_id = $1 AND p.user_id = $2 + AND ( + EXISTS ( + SELECT 1 FROM tournament_registration tr + WHERE tr.player_id = p.id AND tr.tournament_id = $3 + ) + OR EXISTS ( + SELECT 1 FROM team_roster tro + JOIN team t ON t.id = tro.team_id + WHERE tro.player_id = p.id AND t.tournament_id = $3 + ) + ) + ) + """, + organization_id, + user_id, + tournament_id, + ) + + +async def _check_visibility( + conn, + visibility: str, + organization_id: str, + tournament_id: str, + user: CurrentUser | None, +) -> None: + if visibility == "public": + return + if user is not None: + is_member = await conn.fetchval( + "SELECT EXISTS (SELECT 1 FROM organization_membership WHERE user_id = $1 AND organization_id = $2)", + user.user_id, + organization_id, + ) + if is_member: + return + if visibility == "participants" and await _is_participant( + conn, user.user_id, organization_id, tournament_id + ): + return + raise app_error(403, "NOT_VISIBLE", "Du har ikke tilgang til denne turneringen.") + + +class SponsorOut(BaseModel): + id: str + name: str + url: str | None + + class PublicTournamentInfo(BaseModel): id: str name: str organization_name: str status: str + visibility: str + description: str | None start_date: date | None end_date: date | None registration_open: bool registration_deadline: datetime | None registration_capacity: int | None confirmed_count: int + sponsors: list[SponsorOut] @router.get("/{tournament_id}", response_model=PublicTournamentInfo) -async def get_public_tournament(tournament_id: str) -> PublicTournamentInfo: +async def get_public_tournament( + tournament_id: str, + user: CurrentUser | None = Depends(get_current_user_optional), +) -> PublicTournamentInfo: organization_id = await _resolve_org(tournament_id) async with org_connection(organization_id) as conn: row = await conn.fetchrow( """ SELECT t.id::text, t.name, o.name AS organization_name, t.status::text, + t.visibility, t.description, t.start_date, t.end_date, t.registration_deadline, t.registration_capacity, (SELECT count(*)::int FROM tournament_registration tr @@ -65,6 +137,13 @@ async def get_public_tournament(tournament_id: str) -> PublicTournamentInfo: if row is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") + await _check_visibility(conn, row["visibility"], organization_id, tournament_id, user) + + sponsor_rows = await conn.fetch( + "SELECT id::text, name, url FROM tournament_sponsor WHERE tournament_id = $1 ORDER BY created_at", + tournament_id, + ) + deadline = row["registration_deadline"] deadline_passed = deadline is not None and deadline < datetime.now(timezone.utc) return PublicTournamentInfo( @@ -72,15 +151,32 @@ async def get_public_tournament(tournament_id: str) -> PublicTournamentInfo: name=row["name"], organization_name=row["organization_name"], status=row["status"], + visibility=row["visibility"], + description=row["description"], start_date=row["start_date"], end_date=row["end_date"], registration_open=not deadline_passed, registration_deadline=deadline, registration_capacity=row["registration_capacity"], confirmed_count=row["confirmed_count"], + sponsors=[SponsorOut(**dict(r)) for r in sponsor_rows], ) +@router.get("/{tournament_id}/sessions", response_model=list[SessionOut]) +async def get_public_sessions( + tournament_id: str, + user: CurrentUser | None = Depends(get_current_user_optional), +) -> list[SessionOut]: + organization_id = await _resolve_org(tournament_id) + async with org_connection(organization_id) as conn: + visibility = await conn.fetchval("SELECT visibility FROM tournament WHERE id = $1", tournament_id) + if visibility is None: + raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") + await _check_visibility(conn, visibility, organization_id, tournament_id, user) + return await _fetch_sessions(conn, tournament_id) + + class RegisterRequest(BaseModel): display_name: str = Field(min_length=1, max_length=200) email: EmailStr | None = None @@ -102,7 +198,11 @@ class RegistrationResult(BaseModel): @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: 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. tournament = await conn.fetchrow( """ - SELECT registration_deadline, registration_capacity, + SELECT visibility, registration_deadline, registration_capacity, registration_overflow_policy, registration_requires_approval 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: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") + # ADR-018 Beslutning D: registrering følger samme synlighetsgrense + # som selve landingssiden -- ingen særbehandling. + await _check_visibility(conn, tournament["visibility"], organization_id, tournament_id, user) + deadline = tournament["registration_deadline"] if deadline is not None and deadline < datetime.now(timezone.utc): raise app_error(409, "REGISTRATION_CLOSED", "Påmeldingen er stengt.") @@ -226,3 +330,41 @@ async def register_for_tournament(tournament_id: str, body: RegisterRequest) -> 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], + ) diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py index 4fb6077..b6ff7b6 100644 --- a/app/routers/tournaments.py +++ b/app/routers/tournaments.py @@ -31,12 +31,25 @@ class TournamentCreate(BaseModel): 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): id: str name: str status: str start_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]) @@ -45,10 +58,7 @@ async def list_tournaments( ) -> list[Tournament]: async with org_connection(organization_id) as conn: rows = await conn.fetch( - """ - SELECT id::text, name, status::text, start_date, end_date - FROM tournament ORDER BY created_at DESC - """ + f"SELECT {_TOURNAMENT_COLUMNS} FROM tournament ORDER BY created_at DESC" ) return [Tournament(**dict(r)) for r in rows] @@ -60,10 +70,10 @@ async def create_tournament( ) -> Tournament: async with org_connection(organization_id) as conn, translate_db_errors(): row = await conn.fetchrow( - """ + f""" INSERT INTO tournament (organization_id, name, start_date, end_date) VALUES ($1, $2, $3, $4) - RETURNING id::text, name, status::text, start_date, end_date + RETURNING {_TOURNAMENT_COLUMNS} """, organization_id, body.name, @@ -73,6 +83,51 @@ async def create_tournament( 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 — # slik at match-modellen kan holdes generell for fremtidige turneringstyper) @@ -369,6 +424,29 @@ def _session_out(row, locked: set[str]) -> SessionOut: ) +async def _fetch_sessions(conn, tournament_id: str) -> list[SessionOut]: + """Delt mellom den innloggede org-ruten under og det offentlige + landingsside-endepunktet (app/routers/registration.py, ADR-018) -- samme + spørring, samme blind draw-låsing, ikke duplisert to steder.""" + rows = await conn.fetch( + """ + SELECT id::text, sequence, name, format, hole_config::text AS hole_config, + course_id::text, points_per_match::float AS points_per_match, + allowance_override::text AS allowance_override, scoring_mode, + scheduled_at, tee_interval_minutes, start_hole + FROM session + WHERE tournament_id = $1 + ORDER BY sequence + """, + tournament_id, + ) + result = [] + for r in rows: + locked = await locked_team_ids(conn, r["id"]) + result.append(_session_out(r, locked)) + return result + + @router.get( "/orgs/{organization_id}/tournaments/{tournament_id}/sessions", response_model=list[SessionOut], @@ -378,23 +456,7 @@ async def list_sessions( organization_id: str = Depends(get_authorized_org), ) -> list[SessionOut]: async with org_connection(organization_id) as conn: - rows = await conn.fetch( - """ - SELECT id::text, sequence, name, format, hole_config::text AS hole_config, - course_id::text, points_per_match::float AS points_per_match, - allowance_override::text AS allowance_override, scoring_mode, - scheduled_at, tee_interval_minutes, start_hole - FROM session - WHERE tournament_id = $1 - ORDER BY sequence - """, - tournament_id, - ) - result = [] - for r in rows: - locked = await locked_team_ids(conn, r["id"]) - result.append(_session_out(r, locked)) - return result + return await _fetch_sessions(conn, tournament_id) @router.post( @@ -436,3 +498,81 @@ async def create_session( body.start_hole, ) 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.")