From e1ddf13ccea262d3db3a8512bf163aaaf3d822ca Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Sat, 18 Jul 2026 08:24:55 +0200 Subject: [PATCH] ADR-017 er skrevet inn, og migrasjonen er ferdig og verifisert: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 007_registration_and_player_fields.sql — kjørt rent gjennom hele kjeden 001→007 på en fersk scratch-database, test_isolation.sql fortsatt 12/12. Ett reelt arkitekturproblem løst underveis, ikke bare skjema: et offentlig påmeldingskall kjenner en turnering-id, men ingen org-kontekst — og uten den slipper RLS ingen rader gjennom, heller ikke oppslaget for å finne riktig org. Løst med en snever SECURITY DEFINER-funksjon (public_tournament_org) som kun eksponerer koblingen turnering→org, ingenting annet. Testet presist: kalt som teecup_app-rollen med ingen org-kontekst satt — funksjonen fant riktig org, ga NULL (ikke feil) for en ukjent turnering, og et rått SELECT på samme tilkobling/rolle ga fortsatt 0 rader — beviser at RLS ikke er brutt generelt, bare dette ene smale unntaket finnes. Ikke gjort ennå (bevisst, dette var kun migrasjonssteget): Migrasjonen er ikke kjørt mot ekte teecup_db. Ingen API-endepunkter (offentlig registrerings-router, utvidet players.py). --- .claude/settings.local.json | 3 +- 007_registration_and_player_fields.sql | 116 +++++++++++++++++++++++++ ARCHITECTURE_DECISIONS.md | 86 ++++++++++++++++++ CLAUDE.md | 35 +++++++- FEATURE_BACKLOG.md | 1 + 5 files changed, 236 insertions(+), 5 deletions(-) create mode 100644 007_registration_and_player_fields.sql diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 752ce26..f4739d0 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -187,7 +187,8 @@ "Bash(break)", "Bash(rm -f /opt/teecup/.env.scratch /tmp/roster_cookies.txt)", "Bash(curl -s -o /dev/null -w 'dashboard: %{http_code}\\\\n' https://teecup.teeoff.no/dashboard)", - "Bash(curl -s -o /dev/null -w 'health: %{http_code}\\\\n' https://teecup.teeoff.no/health)" + "Bash(curl -s -o /dev/null -w 'health: %{http_code}\\\\n' https://teecup.teeoff.no/health)", + "Bash(docker rm -f teecup_scratch_api >/dev/null 2>&1 *)" ], "additionalDirectories": [ "/opt/teeoff/deploy", diff --git a/007_registration_and_player_fields.sql b/007_registration_and_player_fields.sql new file mode 100644 index 0000000..474ef0f --- /dev/null +++ b/007_registration_and_player_fields.sql @@ -0,0 +1,116 @@ +-- ===================================================================== +-- TeeCup — migrasjon 007 +-- Selvregistrering + utvidet spillerprofil (ADR-017) +-- ===================================================================== +-- Kjøres etter 001-006. Alt additivt (nullable eller default) bortsett fra +-- den nye tabellen, som er helt ny. +-- ===================================================================== + +\set ON_ERROR_STOP on + +-- --------------------------------------------------------------------- +-- 1. Utvidet spillerprofil (ADR-017 Beslutning D) +-- --------------------------------------------------------------------- +ALTER TABLE player ADD COLUMN mobile text; +ALTER TABLE player ADD COLUMN email text; +ALTER TABLE player ADD COLUMN birth_date date; +ALTER TABLE player ADD COLUMN nickname text; +ALTER TABLE player ADD COLUMN country text; +ALTER TABLE player ADD COLUMN club text; +ALTER TABLE player ADD COLUMN club_member_number text; + +-- Bevisst IKKE en UNIQUE-constraint på e-post: familier deler av og til +-- e-post (forelder melder på barn), og en hard unik-regel ville krasje +-- akkurat den vanlige situasjonen. E-postmatching ved påmelding/innlogging +-- (ADR-017 Beslutning B) er derfor et mykt, applikasjonslags-oppslag, ikke +-- en databasegaranti. + +-- --------------------------------------------------------------------- +-- 2. Påmeldingsinnstillinger på turnering (ADR-017 Beslutning C) +-- --------------------------------------------------------------------- +ALTER TABLE tournament ADD COLUMN registration_deadline timestamptz; + +ALTER TABLE tournament ADD COLUMN registration_capacity integer + CHECK (registration_capacity IS NULL OR registration_capacity > 0); + +ALTER TABLE tournament ADD COLUMN registration_overflow_policy text + NOT NULL DEFAULT 'closed' + CHECK (registration_overflow_policy IN ('waitlist', 'closed')); + +ALTER TABLE tournament ADD COLUMN registration_requires_approval boolean + NOT NULL DEFAULT false; + +-- --------------------------------------------------------------------- +-- 3. tournament_registration (ADR-017 Beslutning C) +-- +-- Bevisst ATSKILT fra team_roster: en registrering betyr "vil kanskje +-- spille", team_roster betyr "committed til et bestemt lag". Organisator +-- forfremmer en registrering til roster via de eksisterende +-- team-/roster-endepunktene -- denne tabellen påvirker ALDRI team_roster +-- automatisk. +-- --------------------------------------------------------------------- +CREATE TABLE tournament_registration ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL, + tournament_id uuid NOT NULL, + player_id uuid NOT NULL, + status text NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'confirmed', 'waitlisted', 'declined', 'withdrawn')), + registered_at timestamptz NOT NULL DEFAULT now(), + -- Bevis for at samtykke faktisk ble gitt ved innsending, ikke bare + -- antatt (ADR-017). API-laget skal avvise innsending uten samtykke -- + -- NOT NULL her er den siste linjen av forsvar, ikke den eneste. + consent_given_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (organization_id, tournament_id) + REFERENCES tournament(organization_id, id) ON DELETE CASCADE, + FOREIGN KEY (organization_id, player_id) + REFERENCES player(organization_id, id) ON DELETE RESTRICT, + UNIQUE (organization_id, id), -- for sammensatte FK-er + UNIQUE (tournament_id, player_id) -- én registrering per spiller per turnering +); + +CREATE INDEX ON tournament_registration (organization_id, tournament_id); + +-- RLS: samme org-isolasjon som resten (ADR-003). Skrevet direkte med +-- app_current_org() siden denne tabellen kommer etter 005-fiksen -- ingen +-- grunn til å reprodusere tomstreng-buggen bare for å rette den igjen. +ALTER TABLE tournament_registration ENABLE ROW LEVEL SECURITY; +ALTER TABLE tournament_registration FORCE ROW LEVEL SECURITY; +CREATE POLICY org_isolation ON tournament_registration + USING (organization_id = app_current_org()) + WITH CHECK (organization_id = app_current_org()); + +GRANT SELECT, INSERT, UPDATE, DELETE ON tournament_registration TO teecup_app; + +-- --------------------------------------------------------------------- +-- 4. public_tournament_org() -- den ENESTE broen fra en uautentisert +-- forespørsel (ingen app.current_org satt ennå) til riktig org-kontekst +-- (ADR-017 Beslutning A). +-- +-- SECURITY DEFINER: kjører med skaperens rettigheter (teeoff_admin, som +-- har BYPASSRLS) slik at oppslaget kan lese `tournament` FØR RLS- +-- konteksten er kjent -- det er selve poenget, ikke et hull. Eksponerer +-- KUN organization_id for en gitt turnering-id, ingenting annet fra +-- tournament-raden. `SET search_path = public` hindrer search_path- +-- kapring (standard herding for SECURITY DEFINER-funksjoner). +-- +-- Bruksmønster i app-laget: slå opp org_id via denne FØRST, åpne deretter +-- en vanlig org_connection(org_id) og fortsett med normal RLS-håndhevelse +-- for alt det faktiske arbeidet (sjekk frist/kapasitet, sett inn +-- registrering). Samme "løs kontekst-problemet FØR RLS kan håndheve +-- noe"-mønster som selvrefererende org-bootstrap (app/routers/ +-- organizations.py), nå for et lese-oppslag i stedet for en innsetting. +-- --------------------------------------------------------------------- +CREATE FUNCTION public_tournament_org(p_tournament_id uuid) +RETURNS uuid +LANGUAGE sql +SECURITY DEFINER +SET search_path = public +STABLE +AS $$ + SELECT organization_id FROM tournament WHERE id = p_tournament_id; +$$; + +GRANT EXECUTE ON FUNCTION public_tournament_org(uuid) TO teecup_app; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index dc885b0..4de395e 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -405,6 +405,92 @@ sin port er ikke lenger tenkt nåbar direkte utenfra i prod. --- +## ADR-017 — Selvregistrering og utvidet spillerprofil + +**Kontekst:** Reist av brukeren rett etter at lag/roster-skjermen var live. +Dagens modell antar at organisator kjenner og legger inn alle spillere selv +— i praksis vet organisator ofte ikke hvem som faktisk blir med før de +melder seg på selv. + +**Beslutning A — Påmelding er offentlig, krever IKKE innlogging.** En +delbar lenke (`/register/{tournament_id}` — turneringens UUID er allerede +uforutsigelig nok, ingen ny token-mekanisme) viser et minimalt skjema. Ingen +magic-link, ingen konto kreves for å melde seg på. + +**Begrunnelse:** Å kreve innlogging FØR man kan melde seg på er unødvendig +friksjon for "jeg blir med lørdag"-bruksmønsteret. Kontosammenkobling skjer +gratis senere (Beslutning B), ikke som et eget steg i selve påmeldingen. + +**Beslutning B — E-post er sammenkoblingsnøkkelen** mellom en organisator- +forhåndsopprettet `player`-rad og en spiller som senere melder seg selv på +eller logger inn. Finnes det en `player`-rad i org-en med samme e-post ved +påmelding, fylles manglende felt inn på DEN raden i stedet for å opprette en +duplikat. `player.user_id` kobles først når noen med matchende e-post +faktisk logger inn via magic-link — `verify_magic_link` utvides til også å +slå opp org-scopede `player`-rader på e-post, ikke bare `app_user`. + +**Konsekvens:** `player.email` er bevisst IKKE en `UNIQUE`-constraint — +familier deler av og til e-post (forelder melder på barn), en hard unik- +regel ville krasje akkurat den vanlige situasjonen. Matching er et mykt, +applikasjonslags-oppslag. + +**Beslutning C — Påmelding er et eget, lettvekts steg, atskilt fra +`team_roster`**, med konfigurerbar godkjenning, kapasitet og samtykke. Ny +tabell `tournament_registration` (status: `pending`/`confirmed`/ +`waitlisted`/`declined`/`withdrawn`). Tre nye felt på `tournament`: +`registration_capacity` (nullable — organisators valg om det i det hele +tatt skal være en grense), `registration_overflow_policy` +(`waitlist`/`closed`, kun relevant når kapasitet er satt), +`registration_requires_approval` (boolean). Rekkefølge ved en ny +påmelding: (1) er fristen passert → avvis; (2) er kapasitet nådd → +`waitlisted` eller avvis, avhengig av policy; (3) ellers `pending` eller +`confirmed`, avhengig av godkjenningsbryteren. + +**Begrunnelse:** `team_roster` betyr i dag "committed til et bestemt lag". +Å blande "vil kanskje spille" med "spiller garantert" i samme tabell ville +gjort det umulig å skille en påmeldt-men-ikke-plukket spiller fra en som +aldri var interessert. Kapasitet og godkjenning er to reelle, uavhengige +organisator-beslutninger — å låse ett svar for alle turneringer ville vært +feil for minst noen av dem (brukeren bekreftet eksplisitt: begge skal være +konfigurerbare valg, ikke faste regler). + +**Beslutning D — Utvidet spillerprofil + samtykke.** Nye felt på `player`: +`mobile`, `email`, `birth_date` (IKKE alder — alder blir feil neste år, +fødselsdato er ikke det), `nickname`, `country`, `club`, +`club_member_number`. Samtykke er obligatorisk ved påmelding — API-et +avviser innsending (400) uten `consent: true`, og +`tournament_registration.consent_given_at` er beviset på at det faktisk ble +gitt, ikke bare antatt. Samtykket lever på REGISTRERINGEN, ikke på +`player`, fordi det er selve påmeldingshandlingen for DENNE turneringen +samtykket knytter seg til. + +**Ikke et nytt felt:** "utslagssted for anledningen" er sannsynligvis +allerede dekket av `match_participant.tee_id` (per match, ikke per +spillerprofil, siden det kan variere fra runde til runde). + +**Beslutning E — `public_tournament_org()`: den eneste broen fra en +uautentisert forespørsel til riktig RLS-kontekst.** Et offentlig +påmeldingskall kjenner en turnering-id, men ikke organisasjonen den hører +til — og uten `app.current_org` satt slipper RLS ingen rader gjennom +(heller ikke selve oppslaget for å FINNE riktig org). Løst med en snever +`SECURITY DEFINER`-SQL-funksjon (`p_tournament_id -> organization_id`, kjørt +med skaperens BYPASSRLS-rettigheter, `SET search_path = public` mot +kapring) — samme "løs kontekst-problemet FØR RLS kan håndheve noe"-mønster +som den selvrefererende org-bootstrapen (`app/routers/organizations.py`), +nå for et lese-oppslag i stedet for en innsetting. + +**Konsekvens:** App-laget slår opp org-id via denne funksjonen FØRST, åpner +deretter en vanlig `org_connection(org_id)` og fortsetter med normal +RLS-håndhevelse for alt det faktiske arbeidet. Funksjonen eksponerer KUN en +uuid->uuid-kobling, ingenting annet fra `tournament`-raden — et bevisst +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`. + +--- + ## Åpne spørsmål (ikke besluttet ennå) Disse må avklares før eller under de relevante fasene: diff --git a/CLAUDE.md b/CLAUDE.md index 6074293..20c9b4e 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…016). Fasit. +- `ARCHITECTURE_DECISIONS.md` — hva som er bestemt og hvorfor (ADR-001…017). 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. @@ -367,14 +367,41 @@ Ferdig og verifisert: → 200, `teeoff.no` upåvirket. Selve skrive-flyten på `/tournaments/[id]` (opprett lag/roster) ikke testet med ekte data i denne runden — venter på brukeren, samme mønster som dashboard-rundens skrive-test. +- **ADR-017 + migrasjon 007 (2026-07-18):** brukeren reiste selvregistrering + rett etter at roster-skrive-flyten var bekreftet. Full ADR skrevet + (5 beslutninger: offentlig påmelding uten innlogging, e-post som + sammenkoblingsnøkkel mot forhåndsopprettede spillere, egen + `tournament_registration`-tabell atskilt fra `team_roster` med + konfigurerbar godkjenning/kapasitet/venteliste, utvidet spillerprofil + + obligatorisk samtykke, og `public_tournament_org()`). Migrasjon + `007_registration_and_player_fields.sql` skrevet og scratch-verifisert + (001→007 kjører rent, `test_isolation.sql` fortsatt 12/12). + **Reelt arkitekturproblem løst underveis, ikke bare skjema:** et + offentlig (uautentisert) påmeldingskall kjenner en turnering-id, men + ingen org-kontekst — og uten `app.current_org` slipper RLS ingen rader + gjennom, heller ikke oppslaget for å FINNE riktig org. Løst med en snever + `SECURITY DEFINER`-funksjon (`public_tournament_org`) som KUN eksponerer + uuid→uuid-koblingen. **Verifisert presist, ikke bare "kjørte uten feil":** + kalt funksjonen som `teecup_app`-rollen med ingen `app.current_org` satt + — ga korrekt org-id for en kjent turnering, `NULL` (ikke feil) for en + ukjent — OG et RÅTT `SELECT` på `tournament` på SAMME tilkobling/rolle ga + fortsatt 0 rader, som beviser RLS ikke er brutt generelt, bare dette ene + smale unntaket eksisterer. + **Kun migrasjon + ADR i denne runden** — API-endepunkter (offentlig + registrerings-router, utvidede `players.py`-felt) og frontend + (påmeldingsskjema) er IKKE bygget ennå, og migrasjonen er IKKE kjørt mot + ekte `teecup_db`. Neste steg: -1. Flere V0-skjermer (økt/program, blind draw, scorekort, leaderboard) — +1. ADR-017: kjøre migrasjon 007 mot ekte `teecup_db` (venter på brukerens + eksplisitte bekreftelse), bygge API-et (offentlig registrerings-router, + utvidet `players.py`), så et påmeldingsskjema i frontend. +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 pakkes ut over eksisterende filer, og sjekk om V0-skjermen bygger inn handlinger backend ikke støtter ennå FØR integrering (se dashboard-/roster-rundene over for hvorfor begge er nødvendige hver gang). -2. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet. -3. Kommunikasjon (chat/feed) — ikke startet. +3. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet. +4. Kommunikasjon (chat/feed) — ikke startet. diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 9a37f5f..a00ca2c 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -38,6 +38,7 @@ | Frontend: innlogging + verifisering, LIVE | ✅ | ADR-016. Next.js på `teecup.teeoff.no`, ekte magic-link-flyt bevist med reell e-post. Se egen seksjon under. | | Frontend: dashboard (org-bytter/-opprettelse + turneringsliste), LIVE | ✅ | `/dashboard`. Kablet mot `/auth/me`, `/orgs`, `/orgs/{id}/tournaments`. Skrive-flyt bekreftet med ekte data (org "Tjøme Gents" + turnering opprettet av bruker). | | Frontend: lag/roster-skjerm, LIVE | ✅ | `/tournaments/[id]`. To nye backend-endepunkter bygget samtidig (`PATCH`/`DELETE` roster). Skrive-flyt ikke testet med ekte data ennå. | +| Selvregistrering + utvidet spillerprofil | 🔨 | ADR-017. Migrasjon 007 skrevet + scratch-verifisert. API/frontend gjenstår. | | Roster: endre kaptein / fjern spiller (`PATCH`/`DELETE`) | ✅ | `app/routers/tournaments.py`. Bevisst ingen "kun én kaptein"-håndhevelse ennå — se «Brukerroller»-punktet under. | ---