# CLAUDE.md — arbeidsinstruks for TeeCup 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…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. ## Sikkerhetsregler (ufravikelige) - Rør ALDRI `teeoff`-databasen eller den ekte `teecup_db` uten at brukeren eksplisitt har bekreftet det i samme økt. Test alltid migrasjoner mot en egen scratch-database først, og rydd opp etterpå. - Vis planen (hvilke kommandoer, mot hvilken database) FØR du kjører noe som skriver, migrerer eller sletter. Vent på bekreftelse. - Hemmeligheter (passord, secrets) bor i `.env` (filrettigheter 600), dekkes av `.gitignore`, committes aldri, og skrives aldri i klartekst i chatten eller i SQL-filer. Generer dem på serveren (`openssl rand -base64 32`). - Kjør appen som databaserollen `teecup_app` (NOSUPERUSER, NOBYPASSRLS) — aldri som `teeoff_admin`/superuser i runtime. ## Arkitektur-invarianter (ikke bryt uten en ny ADR) - Tenant = organisasjon. `organization_id` på alle domenetabeller, håndhevet av RLS. App-koden setter `app.current_org` med `SET LOCAL` per transaksjon. - Verifiser at brukeren er medlem av organisasjonen FØR org-konteksten settes. RLS stoler blindt på `app.current_org`. - Egen innlogging (uavhengig av teeoff). Banedata hentes fra teeoff via lesende API, ikke delt database. - v1 = nøyaktig to lag (Ryder Cup-format), håndhevet i app-laget. Match-modellen holdes generell (to sider) så knockout/flere lag kan komme senere. - Handicap-/matchlogikk skal ligge i `handicap_engine.py` (rent, testet, uten db/API-avhengigheter). Allowances er konfig, ikke hardkodet. - Media (bilder/video) skal i objektlagring (MinIO), ikke i Postgres. Postgres holder bare metadata + nøkkel. ## Arbeidsmåte - Inkrementelt. Ingenting tas for gitt før det er testet. Bekreft hvert steg før du går videre. - Bruk git (remote: brukerens Forgejo). Commit i logiske steg med tydelige meldinger. - Er du usikker på omfang eller en beslutning: spør heller enn å gjette. ## Status (oppdater denne når ting endres) Ferdig og verifisert: - Handicap-motor + tester (24/24, R&A-verifisert). - Skjema `001` + roller `002` + scoring/blind draw `003`. Isolasjon bevist med `test_isolation.sql` (RLS-oppførsel, ikke bare at skjemaet kjører). - 002 hadde en reell bug (psql interpolerer ikke `:'var'` inne i `DO $$...$$`) — permanent fikset, verifisert mot scratch to ganger. - API-et kjørt for ekte (ikke bare syntaks-sjekket) i en engangs Docker- container mot en scratch-database, RLS bevist gjennom hele asyncpg-pool-stacken (ikke bare i rå SQL). - Oppsett-endepunktene er bygget og verifisert: `app/routers/players.py` (spillerpool), `tournaments.py` (turnering/lag/roster/økter, ADR-011 to-lags-grense håndhevet med `FOR UPDATE`-lås), `matches.py` (matcher/ deltakere/blind draw-lås, ADR-013-synlighet push-down i SQL). Delt feiloversettelse i `app/errors.py`, delte synlighetsspørringer i `app/blind_draw.py`. `main.py` er nå bare app-factory + `include_router`. - Scoring-runden er bygget og verifisert for ekte mot scratch-db (18-hulls bane med `tee_rating`, 4 spillere for fourball-testing): `app/handicap.py` (ADR-014 fire brytere via `parse_allowance_config`, handicap beregnes i `compute_and_store_side_handicaps` rett etter deltaker-innsetting — singles/fourball per spiller umiddelbart, foursome/greensome/scramble kun når siden er komplett), `app/routers/scoring.py` (`hole-scores`/ `hole-results`-upsert, `scorecard`-GET, matchstatus-recompute med `FOR UPDATE`-lås mot race og SAMMENHENGENDE-prefiks-regel for uferdige hull). `app/team_authz.py` skilt ut fra `matches.py` (delt med `scoring.py`). Alle 10 planlagte tester bestått, inkl. fourball better-ball-aggregering (MIN av to nettoer, venter til begge partnere har registrert), poeng-caching ved tidlig avgjort match, og ADR-014-bryteren `use_handicap=false`. **Fant og fikset underveis:** `tournaments.py` sin `SessionCreate` manglet `scoring_mode` helt (økter kunne aldri opprettes i `hole_result`-modus via API-et) — lagt til. **Bevisst utelatt/kjente begrensninger:** en side som aldri når forventet deltakerantall (no-show) får aldri beregnet handicap og matchen kan da aldri avgjøres — ingen manuell overstyring bygget. Score-skriving er upsert (ingen avvisning ved duplikat) — ingen audit-trail på rettelser. Kapteins-only autorisasjon fortsatt ikke bygget (FEATURE_BACKLOG ❓); bar er «rostret på laget». - **Match-lås ved avgjørelse (2026-07-16):** `submit_hole_score`/ `submit_hole_result` avviser nå 409 hvis `match.points_side_a IS NOT NULL` (matchen er avgjort) — FØR upserten kjøres, både for nye hull og korrigering av allerede talte hull. Tetter en reell bug: uten dette kunne «spøkelses-hull» lagt inn etter avgjørelse endre en allerede cachet margin ved neste omregning. Automatisk, ingen ny autorisasjon involvert. - **Ekte autentisering bygget og verifisert (2026-07-16):** `X-Debug-User-Id`- stubben er HELT fjernet (ingen fallback). Magic-link + JWT-sesjon i `app/routers/auth.py` + `app/auth.py` (`request-link`/`verify-link`/ `logout`/`me`), ny migrasjon `004_auth.sql` (`magic_link_token`-tabell + unik e-post-indeks på `app_user`). Token = `secrets.token_urlsafe(32)`, kun SHA-256-hash lagres, atomisk forbruk (`UPDATE ... RETURNING`, ikke les-sjekk-skriv), generisk respons uansett om e-posten finnes (unngår enumerering), gamle uforbrukte lenker ugyldiggjøres når en ny utstedes, `app_user` opprettes FØRST ved vellykket verifisering (ikke ved forespørsel). Sesjons-JWT (PyJWT, `algorithms=["HS256"]` eksplisitt) i HttpOnly/SameSite=Lax/dynamisk-Secure-cookie, 30 dager, med et ekte eksistens-oppslag mot `app_user` på hver forespørsel (faktisk tilbakekalling — en slettet bruker kan ikke ri ut sesjonen). Alle 12 planlagte tester bestått. **Fant og fikset underveis:** `ON CONFLICT (email)` matchet ikke den nye PARTIELLE unike indeksen uten eksplisitt `WHERE email IS NOT NULL` (samme klasse feil som `hole_score`s partielle indekser i scoring-runden). **Fant, IKKE fikset i denne runden (egen runde rett etterpå — se under):** `organization`-tabellens RLS-policy kastet en 500 i stedet for skjemaets lovede "trygg standard: se ingenting" ved tomstreng-GUC. - **RLS-tomstreng-bug FIKSET (2026-07-16):** ny migrasjon `005_rls_null_guard.sql` — delt `STABLE` SQL-funksjon `app_current_org()` gjør `NULLIF(current_setting('app.current_org', true), '')::uuid` i stedet for det rå uttrykket, brukt av alle 15 RLS-policyer (`ALTER POLICY`, 14 `org_isolation` + `org_self`). Verifisert med 3 nye regresjonstester i `test_isolation.sql` (Test 10-12) OG ved faktisk å gjenskape original- buggen mot en ekte container (pool-størrelse 1, varm opp med `org_connection()`, deretter `/auth/me` på samme gjenbrukte tilkobling — gikk fra 500 til 200). **Viktig presisering fra denne runden:** fiksen gjør IKKE at `/auth/me` kan joine `organization` direkte via `plain_connection()` — det var en feilaktig antakelse i forrige runde. `org_self` krever fortsatt en MATCHENDE `app.current_org` for å vise en rad (riktig RLS-design, ikke noe fiksen skulle endre), og en bruker kan tilhøre flere organisasjoner samtidig, så det finnes ingen ÉN kontekst å sette for en tverr-org-spørring. `/auth/me` slår derfor opp hvert org-navn ett om gangen via `org_connection()` (N+1, N = antall org-er brukeren tilhører) — dette er riktig løsning, ikke en omvei. - **Organisasjon-bootstrap bygget og verifisert (2026-07-16):** nytt `POST /orgs` (`app/routers/organizations.py`) — det ENESTE stedet i API-et som setter inn en `organization`-rad. Fant under statusgjennomgang at dette manglet helt (alle tidligere org-er var seedet med superbruker-SQL). Ingen ny migrasjon. Selvrefererende RLS-bootstrap bekreftet å fungere: generer org-ens uuid i Python, sett `app.current_org` til nøyaktig den via eksisterende `org_connection()`, sett inn `organization`-raden med samme id — `org_self`s implisitte `WITH CHECK` blir da trivielt sann, ingen privilegert tilkobling nødvendig (i motsetning til hva 002s kommentar antydet). Verifisert med 5 tester inkl. en negativ kontroll (mismatchende id avvist med `insufficient_privilege`) og full kryss-org-isolasjon mellom to uavhengig opprettede organisasjoner. - **Ekte SMTP-utsending bygget og verifisert (2026-07-16):** ny `app/email.py` (`send_magic_link_email`, `smtplib` via `asyncio.to_thread`, håndterer både implisitt TLS/port 465 og STARTTLS dynamisk). Brukeren la egne SMTP-credentials i `.env` (`TEECUP_SMTP_*`, `TEECUP_FROM_EMAIL` — ADR-009, ikke delt med teeoff); jeg leste kun nøkkelnavnene for å bekrefte de fantes, aldri verdiene. `app/config.py` sin `SMTP_CONFIGURED` er valgfri (ikke `_required`) — dev-only logging (`TEECUP_DEV_LOG_MAGIC_LINKS`) fortsatt fungerer uendret når SMTP ikke er satt opp. Driftsfeil i utsendingen lekker aldri til klientresponsen (bevarer anti-enumerering). **Verifisert med faktisk levering:** sendte én ekte test-e-post til en adresse brukeren oppga — brukeren bekreftet mottak. Første gang noe i prosjektet er bevist ved ekte levering, ikke bare curl/scratch. - **Containerisert og LIVE på `teecup.teeoff.no` (2026-07-16):** ekte `teecup_db` opprettet (migrasjoner 001→005 kjørt permanent, `test_isolation.sql` består), `Dockerfile` + `docker-compose.yml` (tjeneste `teecup_api`, joiner det eksisterende `teeoff_default`-nettverket), Caddy-blokk lagt til i `/opt/teeoff/deploy/Caddyfile`. Ekte innlogging (magic-link → e-post → JWT-sesjon med `Secure`-cookie) verifisert ende-til-ende mot den live stacken. `teeoff.no` upåvirket gjennom hele prosessen. **To reelle hendelser underveis, begge løst:** 1. **Caddy plukket ikke opp filendringen** — `teeoff_caddy` sin `Caddyfile`-mount er en ENKELTFIL-bind-mount, låst til inoden som fantes da containeren sist startet. Min fil-redigering (atomisk rename) laget en ny inode på samme sti, så containeren fortsatte å lese den GAMLE filen uansett hvor mange ganger `caddy validate`/`caddy reload`/admin-API `/load` ble kjørt (alle validerte/lastet den uendrede gamle filen, derav ingen feilmelding). Løst med en full `docker restart teeoff_caddy` (brukeren bekreftet — avvek fra planens "kun graceful reload, ingen omstart"-løfte, noen sekunders nedetid for `teeoff.no`). 2. **Alvorlig nettverksalias-kollisjon** (funnet RETT ETTER omstarten, da ekte teeoff-trafikk som `/api/facilities?...` med ekte klubb-slugs dukket opp i `teecup_api` sin logg): `docker-compose.yml` sin service-nøkkel var `api:` — SAMME nøkkel som teeoffs eget `api`-servicenavn (`docker-compose.prod.yml`). Docker Compose registrerer nettverksalias basert på service-NAVNET (ikke bare `container_name`) på delte nettverk, så BEGGE containerne fikk alias `api` på `teeoff_default` — Caddys `reverse_proxy api:8000` i teeoff sin egen config kunne da tilfeldig treffe enten ekte `teeoff_api` eller `teecup_api`. **Rettet umiddelbart** (stoppet `teecup_api` først for å hindre videre feilruting av ekte teeoff-trafikk, ga service-nøkkelen navnet `teecup_api` i stedet, gjenopprettet — bekreftet med `docker network inspect` at alias `api` nå KUN peker på ekte `teeoff_api`). **Mindre driftslærdom:** (a) jeg eksponerte ved et uhell `TEECUP_SMTP_PASS`/`TEECUP_FROM_EMAIL` i eget debug-output mens jeg feilsøkte en `.env`-korrupsjon (manglende linjeskift fra min egen `>>`-tilføyelse) — brukeren roterte passordet som forsiktighetsregel; (b) Docker leser IKKE `.env` på nytt for en allerede kjørende container — `docker compose up -d --force-recreate` kreves etter enhver `.env`-endring som skal tas i bruk; (c) et `#`-tegn i et upassordet `.env`-passord kuttes som en kommentar av Compose sin parser — anførselstegn (fortrinnsvis enkle) løser dette. - **Dato/klokkeslett + feilkode-kontrakt + i18n-forberedelse (2026-07-17, ADR-015):** reist av brukeren rett før frontend-arbeidet. Ny migrasjon `006_scheduling_and_locale.sql` (alt additivt): `tournament.end_date`, `session.scheduled_at`/`tee_interval_minutes`/`start_hole`, `match.tee_time_override`, `app_user.preferred_locale`, `magic_link_token.locale`. `match.tee_time` er UTLEDET i Python (`scheduled_at + (sequence-1)*tee_interval_minutes`, override vinner hvis satt) — aldri lagret per match. Full retrofit av ALLE 39 daværende `HTTPException(..., detail="norsk streng")`-steder på tvers av 6 filer til en delt `app_error(status_code, code, message)`-factory (`app/errors.py`) — responsformen er nå konsekvent `{"detail":{"code":...,"message":...}}` i hele API-et, verifisert med et siste `grep -rn 'detail="' app/` som ga NULL treff. i18n: `locale` (`nb`/`en`) sendes av klienten ved `request-link`, styrer e-postmalen (ekte engelsk mal lagt inn i `app/email.py`, ikke bare rørlegging) OG settes som en HELT NY brukers `preferred_locale` — en eksisterende bruker som logger inn på et annet språk får IKKE sin lagrede preferanse overskrevet. **Fant og fikset underveis:** `tournament.start_date` har ligget i skjemaet siden migrasjon 001, men var ALDRI koblet til `TournamentCreate`/`Tournament`-modellene — funnet som en naturlig bivirkning av å legge til `end_date`. **Verifisert grundig mot fersk `teecup_scratch`** (001→006, `test_isolation.sql` fortsatt 12/12): feilkode-form bekreftet på tvers av 5 filer (`NOT_FOUND`/`LIMIT_REACHED` tournaments.py, `DUPLICATE` roster, `NOT_AUTHENTICATED` uten cookie, `NOT_ROSTERED_ON_TEAM` matches.py), tee_time-beregning bekreftet (10 min intervall → 08:00/08:10/08:20, override vinner, `scheduled_at=null` gir `tee_time:null` ikke feil), full i18n-runde bekreftet (ny bruker `locale:"en"` → `preferred_locale:"en"`; påfølgende `request-link` for samme bruker med `locale:"nb"` skiftet e-postmalen men IKKE den lagrede preferansen, bekreftet via `/auth/me`). **Kjørt mot ekte `teecup_db` 2026-07-17** (bruker bekreftet eksplisitt i samme økt): migrasjonen kjørte rent, `test_isolation.sql` fortsatt 12/12 (ruller alltid tilbake, ingen domenerader berørt). **Mindre driftslærdom, funnet OG rettet samme runde:** et `.env`-filter (`grep -v -i 'pass|secret|key'`) jeg brukte for å lese ikke-sensitive nøkler fanget ikke opp `TEECUP_DATABASE_URL`, som bar `teecup_app`- passordet innebygd i selve URL-en (i tillegg til at det SAMME passordet allerede lå rent i `TEECUP_APP_PASSWORD` — duplisert, ikke bare skjult) — passordet ble dermed synlig i et verktøyresultat. Flagget til brukeren umiddelbart (samme mønster som SMTP-passord-hendelsen over). - **`.env`/tilkobling ryddet opp (2026-07-17), samme runde:** roten til hendelsen over var at `TEECUP_DATABASE_URL` var én sammensatt DSN-streng med passordet URL-kodet inni — usynlig for navnebaserte secret-filtre. Erstattet med fem separate, rent navngitte felt: `TEECUP_DB_HOST`/`TEECUP_DB_PORT`/`TEECUP_DB_NAME`/`TEECUP_DB_USER`/ `TEECUP_DB_PASS` (sistnevnte omdøpt fra `TEECUP_APP_PASSWORD`, kun nøkkelnavnet — verdien aldri lest eller skrevet av meg). `app/config.py` og `app/db.py` bygger nå `asyncpg`-poolen fra disse fem separate feltene (`host=`/`port=`/`user=`/`password=`/`database=`) i stedet for én DSN — fjerner også URL-prosentkoding-problemet fra SMTP-passord-hendelsen sin klasse av feil. `docker-compose.yml` sin `environment:`-liste oppdatert tilsvarende. **Krevde et fullt image-rebuild, ikke bare `--force-recreate`:** `Dockerfile` sin `COPY app/ app/` bakes inn i imaget ved build-tid (ingen bind-mount i prod, i motsetning til scratch-verifiseringens engangscontainere) — en ren `--force-recreate` gjenbrukte det GAMLE imaget og krasjet umiddelbart på den nå fjernede `TEECUP_DATABASE_URL`. Rettet med `docker compose up -d --build --force-recreate`. **Verifisert ende-til-ende mot den live stacken:** containeren boot-et rent (`Application startup complete` i loggen, som krever en vellykket `init_pool()` — asyncpg ville kastet og forhindret akkurat den logglinjen ved feil tilkoblingsparametre), `/auth/me` over ekte https ga et rent `401 NOT_AUTHENTICATED` (ikke 500/502), `teeoff.no` upåvirket (`200` gjennom hele omstarten, kun `teecup_api` restartet — ikke delt Caddy-instans, ikke samme risikoklasse som Caddy-hendelsen fra containeriseringsrunden). - **Frontend startet, innlogging LIVE (2026-07-17, ADR-016):** første frontend-skjerm i prosjektet. Designet i V0 (Next.js + Tailwind + shadcn/ui), hentet inn som `frontend/` — merkevare-form/farge fra Teeoff-logoen, IKKE navn/logo (egne, separate produkter, se ADR-009). **Kvalitetsrunde før bruk:** V0s fargetokens var OKLCH-TILNÆRMINGER, ikke eksakte — regnet ut presise verdier fra `#8bc24a`/`#ff5722` og rettet alle 6 forekomster i `globals.css`. Fjernet `@vercel/analytics` (unødvendig på egen-hostet infra), fjernet `typescript: { ignoreBuildErrors: true }` (ekte typesjekk kjører nå), fjernet dødt `pnpm.overrides`-felt. `frontend/.gitignore` manglet `.pnpm-store/` — årsaken til at brukerens VSCode viste 10 000 "ukjente" filer ved første commit-forsøk; rettet. **Kablet mot ekte API:** `next.config.mjs` sin `rewrites()` proxyer `/auth/*`/`/orgs/*`/`/health` server-side til `teecup_api` — same-origin, ingen CORS, cookie uendret (full begrunnelse i ADR-016). Login-skjermet sender ekte `POST /auth/request-link`; ny `/verify`-side mottar `?token=...` fra e-postlenken (auto-verifiserer) eller viser et manuelt "lim inn koden"-felt. `app/email.py` fikk en ny `PUBLIC_BASE_URL`- innstilling og sender nå en EKTE klikkbar lenke (koden beholdes som fallback). **Reell fallgruve funnet og fikset ved containerisering:** Next.js sin `rewrites()` løses ved BUILD-tid for `output: "standalone"`, ikke ved container-oppstart — en runtime `-e TEECUP_API_ORIGIN=...` ble stille ignorert (proxy-kall feilet med `ECONNREFUSED` mot `localhost:8000`). Løst med en Docker build-time `ARG TEECUP_API_ORIGIN` i `frontend/Dockerfile`, satt via `docker-compose.yml` sin `build.args`. **Rullet ut live:** ny `teecup_frontend`-tjeneste i `docker-compose.yml`. Caddy (`teecup.teeoff.no`, i det SEPARATE `teeoff`-repoet, `/opt/teeoff/deploy/Caddyfile`) endret fra å peke direkte på `teecup_api` til å peke på `teecup_frontend` — samme stale-inode-oppførsel som containeriseringsrunden (graceful `reload` plukket IKKE opp endringen, `/` fortsatte å gi `teecup_api` sin egen 404 i stedet for innloggingssiden til reload faktisk skjedde). Løst likt: full `docker restart teeoff_caddy`, brukeren bekreftet eksplisitt på forhånd. `teeoff.no` upåvirket gjennom hele omstarten. **Verifisert med FAKTISK e-postlevering:** ekte magic-link sendt til brukerens egen adresse over `https://teecup.teeoff.no`, ekte e-post mottatt med en ekte klikkbar lenke, åpnet i nettleser, landet på en fungerende `/verify`-side, sesjon opprettet — brukeren bekreftet innlogget status. Første gang en hel bruker-vendt flyt er bevist ende-til-ende i produksjon, ikke bare API-et isolert. **Merk for neste økt:** `deploy/Caddyfile`-endringen ligger uncommitted i det SEPARATE `/opt/teeoff`-repoet, ikke i `teecup`-repoet — lett å glemme siden denne økten ellers kun har jobbet i `/opt/teecup`. - **Dashboard-skjerm LIVE (2026-07-18):** andre V0-skjerm — organisasjon- bytter/-opprettelse + turneringsliste (`/dashboard`), samme mønster som login-runden. **Reell integrasjonsfelle unngått:** V0s eksport denne gangen var en FULL re-eksport av hele prosjektet (inkl. `login-form.tsx`, `next.config.mjs`, `package.json`), ikke bare de nye filene — en naiv utpakking ville stille reversert `rewrites()`-proxyen, `output: "standalone"`, den ekte fetch-kablingen i login-skjemaet, og alle V0-uavhengige opprydninger fra forrige runde. Løst ved å pakke ut til et scratch-område FØRST, diffe mot live-treet fil for fil, og kun ta inn det som faktisk var nytt (`dashboard.tsx`, `tournament-card.tsx`, `tournament-status-badge.tsx`, `wordmark.tsx`, `badge.tsx`, `dropdown-menu.tsx`, `app/dashboard/page.tsx`) — `next.config.mjs`, `package.json`, `globals.css`, Docker-filene ble bevisst IKKE overskrevet. `login-form.tsx` fikk en kirurgisk patch (kun Wordmark flyttet til egen fil, som V0 selv hadde gjort — all egen fetch-/feilhåndteringslogikk urørt). **`dashboard.tsx` sitt datalag skrevet om fra bunnen** (V0 leverte kun mock `useState`): henter `/auth/me` for organisasjonsmedlemskap, `/orgs/{id}/tournaments` per valgt org, `POST /orgs`/`POST /orgs/{id}/tournaments` for opprettelse, `POST /auth/logout` for utlogging — presentasjonskomponentene (kort, bytter, tomme tilstander) beholdt uendret fra V0. `/verify`-siden oppdatert til å sende brukeren videre til `/dashboard` etter vellykket innlogging (fantes ingen dit å gå før nå). **Verifisert:** ekte typesjekket build, redeploy av kun `teecup_frontend` (ingen Caddy-endring nødvendig denne gangen — ADR-016s mønster holder), `teecup.teeoff.no/dashboard` → 200, `teeoff.no` upåvirket. **Skrive-flyten bekreftet med EKTE data samme dag (brukeren testet selv, ikke meg):** organisasjon "Tjøme Gents" og turnering "De Gamle er Eldst" opprettet via UI-et mot den ekte `teecup_db` — statusmerket viste riktig "Utkast", "Ingen datoer satt" håndtert korrekt (ingen krasj på manglende dato), ett-org-visningen viste riktig uten unødvendig bytter-UI. Første gang en HEL skrive-flyt (ikke bare lesing) er bevist ende-til-ende fra frontend mot ekte produksjonsdata. - **Lag/roster-skjerm LIVE (2026-07-18):** tredje V0-skjerm (`/tournaments/[id]`), samme re-eksport-mønster som dashboard-runden — diffet mot live-treet, tok kun inn `tournament-detail.tsx` og et `Link`-basert `tournament-card.tsx` (navigasjon fra dashbordet). **URL-design bevisst avvikende fra V0s forslag:** V0s genererte side leste aldri `params.id` og hadde ingen organization_id i det hele tatt — holdt derfor V0s flate `/tournaments/[id]`-struktur (i stedet for en nøstet `/orgs/[orgId]/tournaments/[id]`, som ville krevd manuell ombygging ved HVER fremtidig V0-reeksport) og la `org`+`name` til som søkeparametre i `tournament-card.tsx` sin lenke — API-et krever organization_id på alle team-/roster-kall (RLS). **Reelt hull funnet FØR integrering, ikke etter:** V0-skjermen bygger inn "fjern spiller"/"gjør til kaptein"-handlinger, men backend hadde KUN GET/POST på `team_roster` — ingen DELETE eller PATCH. Spurte bruker eksplisitt (samme mønster som andre scope-avklaringer denne økten) — svar: bygg de to endepunktene nå. Lagt til i `app/routers/tournaments.py`: `PATCH .../roster/{roster_id}` (bevisst enkel — setter/fjerner `is_captain` på NØYAKTIG denne raden, håndhever IKKE "kun én kaptein per lag", siden kaptein fortsatt bare er et merke, ikke en egen autorisasjonsrolle) og `DELETE .../roster/{roster_id}` (204, idempotent `NOT_FOUND` ved dobbel sletting — ikke krasj). **`tournament-detail.tsx` sitt datalag skrevet om fra V0s mock:** henter lag + roster (roster-radene bærer allerede `display_name`/ `handicap_index_snapshot` fra APIet, så V0s separate `poolById`-oppslag ble fjernet som overflødig) og organisasjonens spillerpool (`GET /orgs/{id}/players`, brukt til type-ahead ved "legg til spiller"). Alle fem mutasjonene (opprett lag, legg til eksisterende spiller, opprett ny spiller inline + rostre, endre kaptein, fjern fra roster) kablet mot ekte endepunkter — presentasjonskomponentene (kort, type-ahead, fargevelger, bekreft-fjerning) beholdt uendret fra V0. **Verifisert:** de to nye endepunktene testet mot en fersk `teecup_scratch` (PATCH setter kaptein + riktig `NOT_FOUND` på ugyldig id, DELETE gir 204 + idempotent `NOT_FOUND` ved gjentak, `test_isolation.sql` fortsatt 12/12), ekte typesjekket frontend-build (5 ruter), redeploy av BEGGE containere (backend-endepunktene er nye), `teecup.teeoff.no/dashboard` → 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. **Kjørt mot ekte `teecup_db` 2026-07-18** (bruker bekreftet eksplisitt i samme økt): migrasjonen kjørte rent, `test_isolation.sql` fortsatt 12/12. - **Registrerings-API LIVE (2026-07-18), samme dag:** ny `app/routers/registration.py` — `GET /public/tournaments/{id}` og `POST /public/tournaments/{id}/register`, begge UTEN `get_current_user` eller `get_authorized_org` (helt uautentisert, egen `/public`-prefiks, bevisst atskilt fra `/orgs/...` i koden). Bruker `public_tournament_org()` (migrasjon 007) til å slå opp org-kontekst FØR RLS kan håndheve noe. `app/routers/players.py` utvidet med alle sju nye ADR-017-feltene (`mobile`/`email`/`birth_date`/`nickname`/`country`/`club`/ `club_member_number`). `frontend/next.config.mjs` sin `rewrites()` utvidet med `/public/*` (ADR-016s konsekvens: enhver ny API-prefiks MÅ inn her). **Ny brukers-oppdaget notat fanget FØR bygging, ikke etter:** brukeren krevde eksplisitt at synlighet (offentlig/kun org/kun turnering- deltakere) må være et VALG for fremtidige landingssider — notert grundig i `FEATURE_BACKLOG.md` (koblet til samme åpne spørsmål for "Banter Board"-feeden) FØR dette registrerings-API-et ble bygget, akkurat for at det ikke skal gå i glemmeboken til landingsside-runden. **Verifisert grundig mot fersk `teecup_scratch`** (001→007, `test_isolation.sql` 12/12): hele registreringsløpet testet reelt — samtykke-avvisning (400), duplikat-avvisning (409 `DUPLICATE`), e-post-matching mot en organisator-forhåndsopprettet spiller (BEKREFTET: ingen duplikatrad, mobil fylt inn via `COALESCE`, `display_name` IKKE overskrevet), kapasitet+`waitlist`-policy (→ `waitlisted`), kapasitet+`closed`-policy (→ 409 `LIMIT_REACHED`), `registration_ requires_approval` (→ `pending`), utløpt frist (→ 409 `REGISTRATION_CLOSED`), og `confirmed_count` i `GET`-responsen talt riktig (kun `confirmed`+`pending`, ikke `waitlisted`/avviste). **Rullet ut live:** begge containere redeployet, `teecup.teeoff.no/ dashboard` og `/health` fortsatt 200, `teeoff.no` upåvirket, det offentlige endepunktet bekreftet nåbart over ekte https (ukjent turnering-id ga korrekt `404`/`NOT_FOUND`, ikke-destruktiv sjekk — selve påmeldingsflyten med ekte data ikke testet mot prod i denne runden). **E-post-basert kontosammenkobling LIVE, samme dag:** ny migrasjon `008_link_player_by_email.sql` — `link_player_by_email(user_id, email)`, samme `SECURITY DEFINER`-mønster som `public_tournament_org()` (007), denne gangen for en tverr-org UPDATE i stedet for et lese-oppslag. `verify_magic_link` (`app/routers/auth.py`) kaller den på HVER innlogging (idempotent — funksjonens `WHERE user_id IS NULL` gjør gjentatte kall til en no-op), ikke bare ved førstegangsopprettelse. **Verifisert presist:** to separate org-er, hver med sin egen organisator-opprettede "Kari"-rad (samme e-post, ulik store/små bokstaver for å teste case-insensitivitet også) — ved Karis FØRSTE innlogging ble BEGGE radene koblet til kontoen hennes, i to org-er hun aldri har vært medlem av. Eksplisitt bekreftet: `organization_membership` har NULL rader for henne etterpå — ren identitetskobling, ingen privilegie-eskalering (å ha `player.user_id` satt gir ingen ny tilgang gjennom `get_authorized_org`, som fortsatt krever ekte org-medlemskap uavhengig av dette). Andre innlogging idempotent, ingen feil. `test_isolation.sql` fortsatt 12/12. **Kjørt mot ekte `teecup_db` 2026-07-18**, bruker bekreftet eksplisitt, backend redeployet, live sjekker OK, `teeoff.no` upåvirket. **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. - **Offentlig turnering-landingsside LIVE (2026-07-18), samme dag:** fjerde V0-skjerm (`components/public-tournament.tsx`, ny rute `/t/[id]`) — banner (bevisst permanent farge-/gradient-utseende, ikke en "bilde kommer"-plassholder), presenterende tekst, status (datoer + "X av Y plasser"), program, sponsorer (navn+lenke), og et påmeldingsskjema med navn+e-post synlig først og resten bak en "flere detaljer"-utvidelse — tre distinkte bekreftelsestilstander (bekreftet/venteliste/godkjenning venter), ikke én generisk "takk". **Ingen ny reeksport-kollisjon denne gangen** — kun étt genuint nytt filnavn (`public-tournament.tsx`), resten var kjent V0-revert av allerede-tilpassede filer, samme mønster som før. **V0 opprettet komponenten, men ingen rute** — la selv til `app/t/[id]/ page.tsx` (bevisst en FLAT `/t/[id]`-sti, ikke nøstet under `/orgs/...` som den innloggede turnering-detalj-siden, siden det offentlige API-et kun trenger turnering-id, ikke org-id). **Datalaget skrevet om fra V0s mock:** ekte `fetch` mot `GET /public/tournaments/{id}` + `/sessions`, ekte `POST .../register`. Håndterer 403 (`NOT_VISIBLE`, ADR-018) og 404 med en egen tilgang-avvist- tilstand V0 ikke hadde bedt om (fantes ikke i prompten, men trengs for at siden faktisk skal fungere for `org`/`participants`-synlige turneringer). Mappet `status:"waitlisted"` fra API-et til komponentens `"waitlist"`, og skjemaets norske kjønnsvalg ("kvinne"/"mann"/"annet") til API-ets `^[mfx]$`-mønster — to reelle navnekollisjoner mellom V0s UI-språk og API-kontrakten, ikke bare et rett-frem felt-for-felt-uttrekk. **Reell driftsfeil funnet OG rettet under scratch-test, ikke i produksjon:** `pnpm install` (uten `--ignore-scripts`) i dev-server- testoppsettet feilet stille med tom logg og exit 1 — corepack hadde hentet en NY pnpm-versjon (11.13.1 → 11.14.0) siden sist, som gjør "ignored builds"-varselet til en hard feil i stedet for bare en advarsel. Rettet ved å bruke samme `--ignore-scripts`-flagg som `frontend/Dockerfile` allerede bruker (upåvirket av denne — bekreftet ved at selve prod-buildet fortsatt gikk rent). **Lærdom:** `Dockerfile` sin pinning av *kode* er ikke det samme som å pinne *verktøyene rundt* (corepack henter alltid siste pnpm) — verdt å huske neste gang et scratch-dev-server-oppsett plutselig feiler uten åpenbar grunn. **Verifisert grundig mot fersk `teecup_scratch` + en ekte kjørende frontend-dev-server** (ikke bare `next build`): ekte turnering med beskrivelse, kapasitet, sponsor og økt opprettet via API-et, hentet gjennom frontend-proxyen (ikke direkte mot backend) og bekreftet byte-for-byte riktig — inkludert en ekte `POST`-registrering som økte `confirmed_count` fra 0 til 1. **Rullet ut live**, kun `teecup_frontend` (ingen backend-endring denne runden), `teeoff.no` upåvirket. **Gjenstår:** org-landingssiden (egen V0-prompt), Open Graph-metadata for deling, MinIO/bilder. - **Offentlig klubb-landingsside LIVE (2026-07-18), samme dag:** femte og siste V0-skjerm i ADR-018 (`components/public-club.tsx`, ny rute `/clubs/[slug]`). Samme banner-språk som turnering-siden, liste over klubbens turneringer (gjenbrukte eksisterende `TournamentCard`), egen tom- tilstand. **Reell delt-komponent-kollisjon løst, ikke duplisert bort:** `TournamentCard` var bygget for KUN den innloggede konteksten (krevde `orgId`, lenket til `/tournaments/{id}?org=...`). I stedet for en egen kopi av kortet for den offentlige siden, gjort `orgId` valgfri — satt (dashbordet) gir innlogget lenke, utelatt (klubbsiden) gir `/t/{id}` i stedet. Samme kort, to kontekster, ingen duplisering. Bekreftet dashbordets egen bruk uendret/upåvirket etterpå. **V0 opprettet denne gangen selv en rute** (`app/clubs/[id]/page.tsx`, uten noen props sendt inn i det hele tatt) — men navnga parameteren `[id]` selv om den faktisk er en SLUG (`public_org_by_slug()`, ADR-018). Skrev selv en ny, riktig `app/clubs/[slug]/page.tsx` i stedet for å bruke V0s (feilnavngitte og prop-løse) versjon. **Verifisert mot fersk `teecup_scratch` + ekte kjørende frontend-dev- server:** org med slug+`public_profile`, én turnering satt `public`, én latt stå på default `org` — klubbsiden viste GJENNOM frontend-proxyen kun den ene offentlige turneringen, den org-private var korrekt usynlig (samme filtermønster som API-et selv, bekreftet fra klientsiden også). Ukjent slug ga korrekt 404. **Rullet ut live**, kun `teecup_frontend`, `teeoff.no` upåvirket. **ADR-018s planlagte skjermer er dermed komplette.** Gjenstår: Open Graph-metadata for deling, MinIO/bilder — begge bevisst egne, senere runder. Neste steg: 1. Open Graph-metadata (og:title/og:image/og:description) på `/t/[id]`/`/clubs/[slug]` for delingsforhåndsvisning, deretter MinIO/bildeopplasting som egen runde — de to gjenværende punktene fra ADR-018. 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). 3. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet. 4. Kommunikasjon (chat/feed) — ikke startet.