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:
Erol Haagenrud 2026-07-18 09:45:11 +02:00
parent bb3b19560c
commit 2058d63db5
10 changed files with 645 additions and 56 deletions

View file

@ -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",

View 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;

View file

@ -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`.
--- ---

View file

@ -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

View file

@ -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. |
--- ---

View file

@ -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),

View file

@ -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")

View file

@ -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))

View file

@ -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, 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],
)

View file

@ -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.")