teecup/CLAUDE.md
Erol Haagenrud a216e3945a Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt

Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)

app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere

app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering

app/email.py: 2FA-kode og invitasjons-maler

app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti

Frontend: login-form passord-modus + 2FA-skjermer

Frontend: kontoinnstillinger + org-medlemsstyring-skjerm

Ekte typesjekket frontend-build

Scratch-verifisere hele auth-løpet grundig (backend)

Deploy mot ekte teecup_db/containere + oppdatere .md-filer
ADR-021 (passord/2FA) og ADR-022 (org-eierskap) er live. Kort oppsummert:

Nytt i innloggingen:

Passord (valgfritt tillegg til magic-link) — Argon2id, testet med ekte spesialtegn/mellomrom/æøå
2FA — TOTP eller e-post-engangskode, brukerens eget valg
Påkrevd 2FA for org-eiere/administratorer, valgfritt for andre
Ny /account-skjerm for å sette passord og styre 2FA
Nytt for organisasjoner:

Inviter andre på e-post til enhver rolle (eiere) eller kun medlem (administratorer)
Frasi deg eierskap selv, eller fjern andre medlemmer — med vern mot at en organisasjon står igjen uten eier
Superadmin-flagg (kun manuelt satt i databasen, aldri via API) som kan sette eierskap på hvilken som helst organisasjon
Ny /orgs/[id]/members-skjerm, lenket fra dashbordet
Fant og fikset tre reelle bugs underveis i scratch-testingen (ingen nådde produksjon) — en krasj i selve innloggingen og to tilfeller av en uendelig 2FA-løkke. Alt er nå verifisert grundig og rullet ut, eksisterende sesjoner er upåvirket.
2026-07-19 10:40:15 +02:00

1347 lines
89 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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``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`
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``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.
- **Open Graph-metadata LIVE (2026-07-18), samme dag — ADR-018 dermed
helt ferdig:** `generateMetadata()` lagt til på `/t/[id]` og
`/clubs/[slug]` (ekte tittel/beskrivelse fra API-et, `og:site_name`,
trygg fallback-tittel ved ukjent id/slug — feil her skal ALDRI hindre
selve siden i å laste).
**Reell driftsfeil funnet FØR den nådde produksjon, ikke etter:**
`generateMetadata()` kjører server-side ved REQUEST-tid, ikke i
nettleseren — går derfor IKKE gjennom `next.config.mjs` sin
`rewrites()` (som kun gjelder nettleser-trafikk inn til Next.js-
serveren). Måtte derfor lese `TEECUP_API_ORIGIN` direkte, men den
variabelen fantes KUN i `Dockerfile` sitt builder-steg — `ENV` satt i
ett `FROM`-steg arves ikke til et senere. Rettet ved å sette samme
`ARG`/`ENV` på nytt i runner-steget også.
**Verifisert presist at fiksen faktisk virker, ikke bare at bygget gikk
gjennom:** bygget det EKTE produksjonsimaget (ikke dev-server) pekt mot
en scratch-backend med en ekte offentlig turnering+org, hentet den
faktiske server-rendrede HTML-en og bekreftet ekte `<title>`/`og:title`/
`og:description` — ikke bare at TypeScript kompilerte. Ukjent
turnering-id ga korrekt trygg fallback-tittel.
**Bevisst utenfor omfang:** `og:image` — ingen ekte bilde finnes ennå
(MinIO-runden). La til `metadataBase` i `app/layout.tsx` nå likevel, som
forarbeid slik at et fremtidig relativt bilde-URL løses riktig uten en
egen fiks da.
**Rullet ut live**, kun `teecup_frontend`, `teeoff.no` upåvirket.
- **MinIO-runden LIVE (2026-07-18), samme dag — ADR-018 dermed HELT
ferdig, ingenting utsatt igjen:** ny `teecup-minio`-tjeneste (persistent
volum, genererte credentials i `.env`), backend-endepunkter for ekte
bildeopplasting til `tournament.hero_image_key`/`tournament_sponsor.
logo_key` (som lå inerte siden migrasjon 009), offentlige API-svar bygger
nå fulle URL-er, `og:image` koblet på i `/t/[id]` sin `generateMetadata`.
**Sent, men viktig presisert krav underveis:** brukeren avbrøt en
verktøyskall midtveis for å presisere at ALLE bilder skal konverteres
til AVIF for plassbesparelse. Snudde HELE opplastingsarkitekturen på
dette: droppet den opprinnelige planen om presignerte URL-er (nettleser
laster opp DIREKTE til MinIO) til fordel for ekte multipart-opplasting
GJENNOM API-et, som konverterer til AVIF (Pillow + pillow-avif-plugin)
FØR lagring. Dette forenklet arkitekturen betydelig: kun ÉN MinIO-klient
trengs nå (før: to separate klient-oppsett for henholdsvis internt
admin-arbeid og offentlig presignering), og Caddy sin nye rute trenger
ikke lenger bevare Host-headeren presist (relevant kun for SigV4-
signaturverifisering av presignerte URL-er, ikke for anonym public-read).
**To reelle feil funnet UNDER scratch-verifisering, aldri i produksjon:**
(1) `pillow-avif-plugin` krever ingen ekstra systempakker i
`python:3.12-slim` -- verifisert med et frittstående encode/decode-
rundtrip FØR det ble tatt i bruk i selve API-et. (2) MinIO validerer
`Host`-headeren STRENGT og avviser understrek som ugyldig vertsnavn --
et tjenestenavn med understrek (`teecup_minio`, konsistent med
`teecup_api`/`teecup_frontend`) feilet umiddelbart ved oppstart
("Invalid Request (invalid hostname)"). Bekreftet presist ved å teste
samme oppsett med bindestrek i stedet (`teecup-minio`) -- fungerte
umiddelbart. Alle referanser rettet til bindestrek FØR noe ble forsøkt
mot ekte infrastruktur.
**Caddy:** ny `/teecup-media/*`-rute på det EKSISTERENDE
`teecup.teeoff.no`-blokket (IKKE et nytt subdomene -- en tidlig sjekk
avdekket at `media.teecup.teeoff.no` fantes som en wildcard DNS-post,
men pekte til en IPv6-adresse denne serveren ikke har i det hele tatt;
path-prefiks på et allerede fungerende domene unngikk hele den DNS-
avhengigheten). Samme stale-inode-oppførsel som alle tidligere Caddy-
runder -- full `docker restart teeoff_caddy`, brukeren bekreftet
eksplisitt. `teeoff.no` upåvirket.
**Verifisert grundig, i flere lag:** frittstående AVIF-encode/decode-
test, full scratch-kjede (fersk Postgres + en ISOLERT scratch-MinIO-
container) med et ekte opplastet bilde -- bekreftet konvertert til
gyldig AVIF (800×400, 406 bytes for et helfarget testbilde), bekreftet
lagret med riktig nøkkel, bekreftet lesbart ANONYMT direkte mot MinIO
(uten Caddy, isolerer bucket-policyen), bekreftet `hero_image_url`/
`logo_url` bygget riktig i det offentlige API-svaret. Alle tre
valideringsveier testet (ugyldig content-type, korrupt bildeinnhold,
for stor fil >8MB) -- alle ga korrekt `VALIDATION_FAILED`. Etter
Caddy-omstart: et ekte anonymt kall mot `/teecup-media/...`
produksjonsdomenet ga en ekte MinIO S3-XML-feilrespons (`NoSuchKey` for
en scratch-nøkkel som naturligvis ikke finnes i prod) -- beviser ruten
treffer MinIO selv, ikke frontend sin egen 404-side.
`test_isolation.sql` fortsatt 12/12 gjennom hele runden.
**Bevisst utenfor omfang:** ingen faktisk opplasting av et EKTE bilde
til en EKTE, live turnering i denne runden (ville krevd å skrive
test-data i brukerens ekte konto uten å bli spurt) -- tilbudt, ikke
utført. Selve dra-og-slipp-opplastingsskjermen i frontend (V0) er
fortsatt ikke bygget, som avtalt fra starten av runden.
- **Program-skjerm (økter/tidsplan), bygget og SCRATCH-verifisert, IKKE
live ennå (2026-07-18):** sjette V0-skjerm, første i "bygg i rekkefølgen
ting brukes"-serien (etter lag/roster: program → blind draw → scorekort →
leaderboard). `components/tournament-program.tsx`, ny rute
`/tournaments/[id]/program`. Tidslinje over økter + opprett-skjema
(format, hullomfang, scoringsmodus, poeng, klokkeslett+intervall,
starthull, kollapsbar "avansert"-seksjon med ADR-014s fire brytere).
**Reelt blokkerende hull funnet FØR integrering:** `SessionCreate.
course_id` er påkrevd, men INGEN endepunkt kunne noensinne produsere en —
ADR-004s teeoff-integrasjon er fortsatt bare vedtatt (ingen utgående
HTTP-klient finnes). Spurte bruker eksplisitt — svar: bygg enkel
course-CRUD nå. Ny `app/routers/courses.py` (`GET`/`POST /orgs/{id}/
courses`, kun `source='custom'`). Program-skjemaet fikk et
type-ahead-felt for bane (samme mønster som spiller-type-ahead i
roster-skjermen).
**Reell korrekthetsfeil rettet FØR integrering:** V0-promptet mitt ba om
ett generisk "Scramble"-valg, men skjemaets `CHECK`-constraint og
`handicap_engine.py` sin `Format`-enum krever `scramble_2`/`scramble_4`
som distinkte verdier -- ren `"scramble"` avvises med 400. Rettet i
frontend-mappingen til to segment-knapper.
**`allowance_override`-JSON-formen verifisert eksakt** mot
`app/handicap.py` sin `parse_allowance_config`/`_strategy_from_json`
(`{type: "combined"|"per_player", percentage: 0..1}`, ikke en flat
prosent) -- frontend velger riktig `type` ut fra om formatet er
side-enhet eller spiller-enhet, konverterer 0100-skjemafelt til 01.
**Scratch-infrastruktur denne runden, bevisst forskjellig fra tidligere:**
brukte en ISOLERT `teecup_app_scratch`-rolle (`GRANT teecup_app TO
teecup_app_scratch`) i stedet for den ekte `teecup_app`-rollen -- den er
nå cluster-global og produksjonskritisk (delt Postgres-instans med
`teecup_db`), så et eldre plandokuments "drop teecup_app-rolle"-
opprydning (skrevet FØR go-live) er utdatert og ble bevisst IKKE fulgt.
Egen isolert scratch-MinIO-container også (app-oppstart krever en
nåbar MinIO for `ensure_bucket()`).
**Verifisert:** courses opprettet+listet, kryss-org-isolasjon bekreftet,
økt med klokkeslett, økt med `scramble_4`+full `allowance_override`-
rundtur, gammel `"scramble"`-verdi avvist (400), `test_isolation.sql`
12/12, ekte typesjekket PRODUKSJONSBUILD (samme `Dockerfile` som faktisk
deployes) kjørt og bekreftet.
**Diffet V0-eksporten mot live-treet FØR noe ble tatt inn** (samme mønster
som alle tidligere runder): kun tre reelt nye filer, resten forventede
full-reverts, ikke rørt. Fjernet V0s dev-only forhåndsvisnings-toggle;
lagt til fanerad ("Lag og spillere" / "Program") i BEGGE skjermene siden
V0 ikke visste om den andre når den ble generert i egen prompt.
**Rullet ut live 2026-07-18**, bruker bekreftet eksplisitt: `docker
compose up -d --build teecup_api teecup_frontend` (kun disse to, `teecup-
minio` urørt). Verifisert: begge containere boot-et rent (`Application
startup complete`, Next.js `Ready`), `teecup.teeoff.no/health` og
`/dashboard` → 200, `teeoff.no` upåvirket (200).
- **ADR-004 (teeoff-banedata) kartlagt, IKKE bygget (2026-07-18):** brukeren
påpekte rett etter program-skjerm-rullingen at ADR-004s teeoff-
integrasjon fortsatt bare er vedtatt, ikke bygget (kun `source='custom'`
finnes). Kartlagt `/opt/teeoff/backend/main.py` (annet repo, kun lest):
`GET /api/facilities/{slug}` (offentlig, INGEN auth/API-nøkkel) returnerer
allerede alt teecup trenger — `courses[].holes[]` (`par`, `hcp_index`
= stroke index), `courses[].tees[]` (`name`, `cr_men`/`slope_men`,
`cr_women`/`slope_women` — kjønnsdelt WHS-rating). CORS-lista på teeoff-
siden ekskluderer teecups origin, men er IRRELEVANT for et server-til-
server-kall (kun nettleser-fetch rammes av CORS). Ingen dokumentasjon
(README/OpenAPI) finnes for dette API-et i teeoff-repoet — kartleggingen
over er basert på å lese koden direkte. Stabil identifikator: `facilities.
slug` (f.eks. `borregaard-golfklubb`) — selve banen har kun en intern
serial-id, ingen egen slug, så `course.external_course_ref` må bære
facility-slug + bane-id sammen.
**Oppdatering samme dag: brukeren ba meg ta fatt på dette NÅ** (bevisst
sidesprang fra "bygg i rekkefølgen ting brukes"-planen — blind draw-
skjermen er fortsatt neste steg i den planen ETTERPÅ, ikke droppet).
Design besluttet og skrevet som **ADR-019** (se ARCHITECTURE_DECISIONS.md):
import (kopi) ved organisators eksplisitte valg, ikke live oppslag ved
hver bruk — fryser data på importtidspunktet, samme reproduserbarhets-
prinsipp som handicap-snapshotten (ADR-007). Server-til-server-kall
(`teecup_api` → `http://teeoff_api:8000`, internt Docker-nettverk, ingen
auth trengs). Ny migrasjon `010` (unik `external_course_ref` per org,
hindrer dupliserte importer). Se ADR-019 for alle fem delbeslutningene.
**Bygget, scratch-verifisert MOT EKTE `teeoff_api`** (ikke en simulert
respons — ekte HTTP-kall til den kjørende produksjonscontaineren, kun
lesing): søkte opp "Borregaard" i teeoff sine 174 publiserte anlegg,
hentet Borregaard Golfklubb sin hovedbane (18 hull, 4 tee-farger ×
kjønn), importerte den til en scratch-org — alle 18 hull med riktig
par/stroke-index (1-18, unik), alle 8 tee/tee_rating-rader med riktig
full_18 course/slope-rating verifisert direkte i databasen, opprettet
deretter en ekte økt med den importerte banen som `course_id` (beviser
hele veien til handicap-motoren fungerer, ikke bare selve importen).
Reimport av samme bane korrekt avvist (409 DUPLICATE, migrasjon 010 sin
indeks). Kryss-org-isolasjon bekreftet, ukjent teeoff-slug ga korrekt 404,
`test_isolation.sql` 12/12. Ekte typesjekket produksjonsbuild av
frontend-utvidelsen (bane-søk i program-skjemaet: søk anlegg → velg bane →
importer, samme UI-mønster som spiller-/bane-type-ahead ellers i appen).
**Rullet ut live 2026-07-18**, bruker bekreftet eksplisitt: migrasjon 010
kjørt mot ekte `teecup_db` (kun én ny partiell unik-indeks, ingen
eksisterende rader rørt), begge containere bygget+redeployet, live
sjekker OK, `teeoff.no` upåvirket. **"Bygg i rekkefølgen ting brukes"-
planen gjenopptas nå** — blind draw-skjermen er neste steg.
**Reell produksjonsbug funnet OG fikset samme dag, rapportert av bruker
som faktisk brukte funksjonen:** brukeren klikket seg korrekt via
dashbord → turnering → Program-fane (bekreftet med skjermbilde + full
klikk-sti, ikke gjettet), åpnet "Hent bane fra teeoff", søkte "Tjøme", og
fikk feilmeldingen "Mangler organisasjon i lenken" — URL-en hadde da
MISTET `?org=...&name=...`. Root-cause: `OfficialCourseSearch` sitt eget
søke-`<form onSubmit={runSearch}>` var rendret INNI `CreateSessionCard`
sitt eksisterende `<form onSubmit={handleSubmit}>` -- nestede
`<form>`-elementer er ugyldig HTML. Nettleseren slår sammen de to
skjemaene i den faktiske DOM-en, så "Søk"-knappen submittet i praksis det
YTRE økt-skjemaet som en ekte native side-navigasjon (GET til gjeldende
sti, ingen navngitte felt => tom spørrestreng) -- dette vasket bort
org-parameteren og landet brukeren på siden sin egen org-guard. **Fikset**
ved å fjerne det indre `<form>`-elementet helt (vanlig `<div>` +
Enter-tast-håndtering på inputet + `type="button"` i stedet for
`type="submit"` på søkeknappen) -- gjør nestede skjemaer strukturelt
umulig fremover for denne komponenten. Ekte typesjekket
produksjonsbuild kjørt på nytt, kun `teecup_frontend` redeployet (ingen
backend-endring). **Lærdom for fremtidige skjermer:** en ny
søk-/underskjema-widget som skal plasseres INNI et eksisterende skjema
(slik som denne bane-søk-widgeten ligger inni økt-opprett-skjemaet) må
ALDRI være et eget `<form>` -- bruk `<div>` + eksplisitt
klikk-/Enter-håndtering.
- **Organisator-overstyring i `team_authz.py`, LIVE (2026-07-18):** reist av
brukeren rett før blind draw-skjermen skulle designes: `app/team_authz.py`
sin `user_may_act_for_team` krevde tidligere en `team_roster`-rad med
lenket bruker-konto — ingen vei for organisatoren til å låse/legge til
deltakere/føre score hvis ingen spiller hadde logget inn ennå (vanlig
tidlig i en turnering). Utvidet til også å godta org-eier/admin
(`organization_membership.role IN ('owner','admin')`), på ETHVERT lag,
ingen unntak for at organisatoren selv er rostret på motstanderlaget.
**Det unntaket ble bevisst vurdert og avvist** (brukeren spurte selv om
det, jeg anbefalte det opprinnelig, men vi kom sammen frem til at det ville
skapt en verre låsning: er organisatoren spillende på Lag A og
Lag B heller ikke har noen innlogget spiller, ville Lag B blitt helt
låst ute) — TeeCup er et tillitsbasert klubb-/vennegjeng-verktøy, ikke en
sikkerhetsgrense mot en fiendtlig organisator som uansett allerede ser
begge lags fulle troppe-liste (blind draw skjuler kun selve
kamp-paringen). Alle 5 kallsteder oppdatert (matches.py sin
add_participant/lock_lineup, scoring.py sin submit_hole_score/
submit_hole_result). **Verifisert grundig i scratch:** org-eier uten
roster kan nå låse BEGGE lag + føre score, en vanlig 'member'-rolle
fortsatt blokkert (uendret), en rostret spiller fungerer uendret
uavhengig av org-rolle. `test_isolation.sql` 12/12. Ingen migrasjon (ren
Python-endring) — kun `teecup_api` redeployet, `teeoff.no` upåvirket.
- **Tee-endepunkter bygget og LIVE (2026-07-18), rett før blind draw-skjermen:**
fant et hull som blokkerte selve blind draw-flyten: `match_participant.
tee_id` er påkrevd, men det fantes INGEN `GET`-vei for å liste en banes
tee-er (selv offisielt importerte baner har tee-rader, men ingenting
eksponerte dem) OG egendefinerte («custom») baner har ALDRI hatt noen
vei til å FÅ tee-er i det hele tatt — dette er ikke noe ADR-019 innførte,
det var et hull som fantes fra før custom-baner ble lagt til i første
omgang, bare usynlig til nå. Konsekvens før fiksen: en turnering satt opp
på en manuelt navngitt bane kunne ALDRI få en ekte deltaker lagt til på
noen match. **Presisering (brukeren spurte eksplisitt):** dette er IKKE
noe som må løses i teeoff.no sin kode — egendefinerte baner er per
definisjon baner teeoff ikke kjenner til, så dette er en ren
teecup-intern funksjon, uavhengig av ADR-019-integrasjonen.
Ny `GET/POST /orgs/{id}/courses/{id}/tees` i `app/routers/courses.py`.
`POST` avviser eksplisitt forsøk på offisielle baner (400
`VALIDATION_FAILED` — de får tee-ene sine fra teeoff-importen, ikke
manuelt). Kun full_18-rating dekket (samme begrunnelse som ADR-019
Beslutning D). **Bevisst UTENFOR omfang denne runden, egen senere sak:**
hull-/stroke-index-data for egendefinerte baner (`hole`-tabellen forblir
tom for custom-baner) — trengs for korrekt slagfordeling i SCORING-fasen
(ADR-008), ikke i blind draw, så det løses naturlig når scorekort-
skjermen bygges (samme "bygg i rekkefølgen ting brukes"-logikk).
**Verifisert grundig i scratch:** tom tee-liste på fersk custom-bane,
opprett+list tee på custom-bane, offisiell bane viser alle 8 importerte
tee-er, manuell tee-opprettelse avvist på offisiell bane, og — den
faktiske payoff-en — en deltaker lagt til en match på en custom-bane-økt
for FØRSTE gang noensinne (`POST .../matches/{id}/participants` lykkes
nå med en tee_id fra en nyopprettet custom-tee). `test_isolation.sql`
12/12. Ingen migrasjon, kun `teecup_api` redeployet, `teeoff.no`
upåvirket.
- **Blind draw-skjermen LIVE (2026-07-18):** syvende V0-skjerm,
`components/session-blind-draw.tsx`, ny rute
`/tournaments/[id]/sessions/[sessionId]`. To lag-kolonner, hver med sine
matcher ("flights"), legg til/fjern spiller+tee per plass (antall plasser
avhenger av format), lås-knapp med bekreftelse, avslørings-visning når
begge lag har låst. Program-skjermens økt-kort er nå klikkbare inn hit.
**Datamodell tilpasset fra V0s mock til API-ets faktiske sett-modell:**
V0 designet faste, indekserte "Slot"-arrays (kontrollert skjema-state);
API-et har verken PATCH på `match_participant` eller noen slot-indeks —
kun opprett/slett av en løs deltaker-mengde per side. Løst med en
"legg til spiller"-inline-form (samme mønster som spiller-/bane-type-ahead
ellers i appen) i stedet for faste dropdown-rader; funksjonelt likeverdig,
strukturelt riktigere for API-ets faktiske form.
**To reelle hull funnet og fikset FØR/UNDER integrering:**
1. Ingen `DELETE` fantes for `match_participant` — en kaptein kunne aldri
angre et valg før låsing uten å etterlate en foreldreløs rad. Ny
`DELETE /orgs/{id}/matches/{match_id}/participants/{participant_id}`
(samme ALREADY_LOCKED/NOT_ROSTERED_ON_TEAM-sjekker som opprett).
2. **"Skriv blindt"-hull, funnet under selve scratch-testingen (ikke bare
tenkt ut på forhånd):** forrige rundes org-admin-overstyring i
`team_authz.py` lot en organisator LEGGE TIL deltakere på et lag de
ikke selv er rostret på, men `app/blind_draw.py` sin `own_team_ids()`
sjekket KUN rostret spiller for SYNLIGHET — så organisatoren så aldri
sine egne tilføyelser igjen før begge lag hadde låst. Fikset ved å gi
`own_team_ids()` samme owner/admin-utvidelse som `user_may_act_for_
team()` — en org-admin ser nå BEGGE lag umiddelbart (konsistent med at
de uansett allerede har full tilgang, se forrige rundes resonnement),
mens en faktisk rostret kaptein fortsatt kun ser sitt eget lag før
reveal. Kun ett reelt kallsted (`matches.py` sin `list_matches`).
**Tredje, urelatert bug fanget under samme scratch-økt og fikset på
brukerens eksplisitte forespørsel:** `POST .../matches/{id}/participants`
krasjet med en rå 500 (`TypeError` i `handicap_engine.py` sin
`course_handicap_raw`) hvis spilleren manglet `handicap_index` OG
`use_handicap` var på (standard) — preeksisterende, ikke noe denne
runden introduserte. Fikset i `app/routers/matches.py` sin
`add_participant`: sjekker nå `handicap_index_snapshot IS NULL` EKSPLISITT
FØR innsetting når `use_handicap` er sann, avviser med en klar
`VALIDATION_FAILED` (400) i stedet for å krasje. Bekreftet at
`use_handicap=false` fortsatt tillater en spiller uten handicap
(scratch-spill), og at en spiller MED handicap fortsatt fungerer uendret.
**Verifisert grundig i scratch, flere runder:** DELETE-endepunktet
(fjern+idempotent 404 ved gjentak+409 etter lås), synlighetsfikset (org-
admin ser begge sider, rostret kaptein ser fortsatt kun eget lag),
null-handicap-fikset (avvist rent, scratch-modus upåvirket, normal
spiller upåvirket), full opprett-match→legg-til-deltaker→lås→avslør-
syklus ende-til-ende. `test_isolation.sql` 12/12 etter hver runde. Ekte
typesjekket produksjonsbuild.
**Rullet ut live**, bruker bekreftet eksplisitt: begge containere
bygget+redeployet (ingen migrasjon), `teeoff.no` upåvirket.
- **Backend-forarbeid for scorekort-skjermen, LIVE (2026-07-18):** to hull
funnet ved gjennomlesing av `scoring.py` FØR V0-prompten ble skrevet,
samme føre-var-mønster som resten av økten.
1. **Hull-/stroke-index-data for egendefinerte baner** (bevisst utsatt fra
tee-rundens status-notat) — `hole`-tabellen har ALDRI hatt noen vei inn
for custom-baner, som blokkerte `stroke`-scoringsmodus helt (`hole_
result`-modus trenger den ikke). Ny `GET/POST /orgs/{id}/courses/{id}/
holes` i `app/routers/courses.py` — engangs alle-18-på-en-gang
(avviser feil antall, avviser hvis banen allerede har hull, avviser på
offisielle baner som får hullene sine fra teeoff-import).
2. **`GET scorecard` viste kun UTLEDET vinn/tap/delt per hull, aldri de
faktiske tallene som var registrert** — umulig å bygge en skjerm som
viser gjeldende tilstand ved gjenlasting. Utvidet `Scorecard`-modellen
i `app/routers/scoring.py` med `stroke_entries`/`hole_result_entries`
(rå `hole_score`/`match_hole_result`-rader, nøyaktig ett av de to fylt
ut avhengig av øktens scoring_mode).
**Verifisert grundig i scratch:** hull-validering (feil antall avvist,
gjentatt oppsett avvist), og en FULL `stroke`-modus scoringsrunde med
ekte handicap-justert nettoberegning på en egendefinert bane for FØRSTE
gang noensinne (ulikt slagtall — 4 mot 5 — ga korrekt "halved" etter
handicap-utjevning), scorecard sin nye `stroke_entries` bekreftet å
returnere nøyaktig det som ble registrert. `test_isolation.sql` 12/12.
**Rullet ut live**, ingen migrasjon, kun `teecup_api` redeployet,
`teeoff.no` upåvirket.
- **Scorekort-skjermen LIVE (2026-07-18):** åttende V0-skjerm,
`components/session-scorecard.tsx`, ny rute `/tournaments/[id]/sessions/
[sessionId]/matches/[matchId]`. Ett hull i fokus om gangen (banebruk,
ikke et regneark) — store slag-steppere for `stroke`-modus, tre-valgs
vinner-knapper for `hole_result`-modus, hull-chip-navigasjon, kollapsbar
full oversikt, feiret "avgjort"-banner som låser alt til read-only.
Blind draw sin avslørte visning lenker nå til hver match sitt scorekort.
**Ingen nye backend-hull denne runden** — forrige rundes forarbeid
(hull-endepunkter + `stroke_entries`/`hole_result_entries` i scorecard)
dekket akkurat det skjermen trengte.
**Verifisert grundig i scratch, inkludert et fullt oppsett som speiler
NØYAKTIG frontend-ens egen last-sekvens** (sessions→teams→matches→
holes→scorecard, deretter en ekte `POST hole-scores` for begge spillere
på hull 1 og en refetch): status_text/derivert resultat oppdaterte seg
korrekt ("1 UP (A)", hull 1 → "a"), alle feltnavn stemte eksakt med
TypeScript-typene uten justering. `test_isolation.sql` 12/12, ekte
typesjekket build.
**Rullet ut live**, ren frontend-endring, `teeoff.no` upåvirket.
- **Leaderboard-backend LIVE (2026-07-18):** ny `GET /orgs/{id}/
tournaments/{id}/leaderboard` i `app/routers/tournaments.py` — summerer
poeng på tvers av ALLE økter/matcher i turneringen, både total og
per-økt-delsum.
**Reelt korrekthetshull funnet OG designet rundt FØR koden ble skrevet,
ikke oppdaget i ettertid:** `match.team_a_id`/`team_b_id` settes PER
MATCH ved opprettelse, ikke garantert konsistent på tvers av matcher —
en organisator kunne i prinsippet opprettet match 1 med team_a=Rød og
match 2 med team_a=Blå. En naiv summering av `points_side_a`/`points_
side_b` ville da blandet sammen poeng fra to ULIKE fysiske lag. Løst ved
å ALDRI summere på "a"/"b"-labelen — kun på det ekte lag-id-et
(`points_by_team: dict[team_id, float]`, både i totalen og per økt).
**Verifisert presist, ikke bare "kjørte uten feil":** bygget et scratch-
scenario med 3 matcher over 2 økter der match 2 sin team_a/team_b
BEVISST var byttet om i forhold til de to andre — satte poeng direkte
(Rød vinner alle tre, ett delt) og bekreftet at leaderboardet likevel ga
riktig total (Rød 2.5, Blå 0.5) og riktig per-økt-delsum, til tross for
swap-en. `test_isolation.sql` 12/12. Ingen migrasjon, kun `teecup_api`
redeployet, `teeoff.no` upåvirket. Frontend-skjermen gjenstår.
- **Leaderboard-skjermen LIVE (2026-07-18), samme dag:** niende og siste
V0-skjerm i "bygg i rekkefølgen ting brukes"-serien for kamp-play-flyten.
`components/tournament-leaderboard.tsx`, ny rute `/tournaments/[id]/
leaderboard`. Stort scoreboard-kort med de to lagenes totalpoeng
(lederen fremhevet), pluss en per-økt poeng-fordelingsliste med
fullført/ikke-startet-status. V0 la selv til et tredje faneelement
("Leaderboard") i BÅDE program- og roster-skjermens fanerad denne
runden — portert inn i de LIVE versjonene av begge (med riktig `org`-
parameter, som V0s eksport som vanlig manglet).
**Ingen nye backend-hull** — forrige rundes leaderboard-endepunkt dekket
akkurat det skjermen trengte.
**Verifisert i scratch:** full datamodell-runde (samme felt-for-felt
som backend-verifiseringen dagen før) OG et eget tomtilstand-scenario
(fersk turnering med 2 lag, 0 økter — `sessions: []`, `matches_total: 0`)
for å bekrefte at "ingen økter opprettet ennå"-meldingen vises riktig
i stedet for å krasje på et tomt array. `test_isolation.sql` 12/12, ekte
typesjekket build.
**Rullet ut live**, ren frontend-endring, `teeoff.no` upåvirket.
**Hele "bygg i rekkefølgen ting brukes"-serien for match-play-flyten er
dermed komplett:** oppsett (lag/roster) → program → blind draw →
scorekort → leaderboard.
- **Offisiell bane-import: idempotent + tydeligere navn, LIVE (2026-07-18),
rapportert av brukeren som faktisk brukte funksjonen på ekte
produksjonsdata:** to reelle problemer, begge funnet ved å lese (kun
lesing) de faktiske dataene for "De Gamle er Eldst" FØR noe ble antatt.
1. **`POST .../courses/official-import` var IKKE idempotent** — å
importere samme bane på nytt (helt vanlig: flere økter spilles ofte
på samme bane) ga en 409 `DUPLICATE`-feil (migrasjon 010 sin sperre)
i stedet for å bare gi tilbake den allerede importerte banen. Fikset:
sjekker nå `external_course_ref` FØR noe teeoff-kall gjøres — finnes
banen fra før, returneres den eksisterende raden direkte (også
raskere, og robust mot at teeoff er nede akkurat da).
2. **Lagret navn var kun selve banens navn ("Hovedbanen"), ikke hvilken
klubb** — ubrukelig til å skille baner fra hverandre, siden mange
klubber navngir hovedbanen sin identisk. Fikset: navnet kombineres nå
til "{anlegg} {bane}" (f.eks. "Tjøme Golfklubb Hovedbanen") ved
import.
**Reell konsekvens av hull #1 funnet i produksjonsdata:** brukeren hadde,
mens hen forsøkte å søke opp Tjøme via det ØVERSTE banefeltet (som søker
organisasjonens EGNE baner, ikke teeoff), ved et uhell trigget «Opprett
ny bane: «Tj»»-snarveien og fått en tom, søppel `custom`-bane hengende på
Foursome-økten -- en ekte forvekslingsfelle mellom de to adskilte
bane-søkeflatene (eget vs. teeoff), ikke en kodefeil i seg selv.
**Data ryddet opp i EKTE `teecup_db`, bruker bekreftet eksplisitt:**
Foursome-økten pekt om til den allerede importerte, ekte Tjøme-banen
(samme bane som Fourball-økten allerede brukte -- nøyaktig det
organisatoren egentlig ønsket), søppel-«Tj»-banen slettet (bekreftet
ingen matcher/tee-er/hull hang på den FØR sletting), og den ekte banens
navn oppdatert til "Tjøme Golfklubb Hovedbanen". Verifisert i etterkant
at begge økter nå peker til samme, korrekt navngitte bane.
**Verifisert mot ekte teeoff_api i scratch FØR utrulling:** importerte
Borregaard på nytt to ganger — andre kallet ga nøyaktig samme course-id
(ikke en duplikat-rad), navnet kom ut som "Borregaard Golfklubb
Hovedbanen". `test_isolation.sql` 12/12. Ingen migrasjon, kun `teecup_api`
redeployet, `teeoff.no` upåvirket.
- **`PATCH`/`DELETE` for økter, LIVE (2026-07-18), samme dag:** brukeren
spurte rett etter opprydningen om det i det hele tatt var mulig å
rette/slette en feiloppsatt økt — det var det ikke (kun `POST`/`GET`
fantes). Ny `PATCH /orgs/{id}/sessions/{id}` (`app/routers/
tournaments.py`) for enkle felt (navn, klokkeslett/intervall, starthull,
poeng, handicap-brytere) via vanlig `exclude_unset`-mønster, PLUSS en egen
gren for bane-bytte. Ny `DELETE /orgs/{id}/sessions/{id}` — kun tomme
økter (ingen matcher), avviser med 409 ellers (bruk PATCH til å korrigere
i stedet).
**Banebytte-scenarioet brukeren selv reiste** ("5 hull spilt, oppdager
feil bane — slagene er ekte, utregningen er trolig feil") krevde egen
design: `match_participant.tee_id` peker til en tee som HØRER til den
gamle banen. Løst med `_remap_course()` — finner en tee med samme
navn+kjønn på den nye banen for hver allerede tillagte deltaker, flytter
dem dit, og avviser HELE bane-byttet tydelig (400, ingenting skrevet,
bekreftet transaksjonell rollback) hvis den nye banen mangler en
tilsvarende tee. Etter et vellykket bytte: handicap regnes om for alle
berørte deltakere, og matchstatus/poeng regnes om for HVER match i
økten — **bevisst uavhengig av om matchen allerede er avgjort** (brukeren
bekreftet eksplisitt at en bane-korrigering skal kunne endre et allerede
cachet resultat). `recompute_and_cache_match_state` i `app/routers/
scoring.py` gjort delt (fjernet ledende understrek) for gjenbruk fra
tournaments.py.
**Reelt, urelatert funn underveis i scratch-testingen, IKKE fikset:**
`tee_rating` lages i dag ALLTID kun med `full_18`-omfang (både ved
teeoff-import og manuell tee-opprettelse) — en økt satt til `front_9`/
`back_9` i `stroke`-modus kan derfor ALDRI få handicap beregnet
(`compute_and_store_side_handicaps` sin `tee_rating`-join finner aldri
noen rad), og dermed aldri avgjøre noen hull. `hole_result`-modus
upåvirket. Flagget til bruker, bevisst latt urørt denne runden.
**Verifisert grundig i scratch:** enkelt feltbytte (kun navn), fullt
banebytte-scenario bygget nøyaktig som brukerens eksempel (10 hull spilt
under feil bane, matchen allerede avgjort 10&8, PATCH til riktig bane →
tee-er ombyttet korrekt, handicap endret fra 7/9 til 10/13 under den nye
banens rating, matchstatus regnet på nytt med UENDREDE rå slagtall),
avvist bane-bytte ved manglende tee-match (bekreftet full rollback,
også av det urelaterte navnefeltet i samme kall), DELETE avvist på økt
med match (409) og godtatt på tom økt (204). `test_isolation.sql` 12/12.
Ingen migrasjon, kun `teecup_api` redeployet, `teeoff.no` upåvirket.
Frontend (rediger-/slett-knapper i program-skjermen) ikke bygget ennå —
kun backend-kapasiteten denne runden.
- **Rediger/slett-UI for økter LIVE (2026-07-19):** V0-utvidelse av den
eksisterende Program-skjermen (ingen ny rute) — "..."-meny (samme
DropdownMenu-mønster som roster-skjermens per-spiller-handlinger) med
"Rediger"/"Slett" per øktkort. Rediger bytter kortet til et inline-skjema
(samme felt som PATCH støtter: navn, poeng, starthull, klokkeslett/
intervall, bane). Slett viser en bekreftelse; treffer den ekte 409-en
(økt har matcher), vises backend sin egen feiltekst i stedet for en
forhåndsberegnet klient-tilstand.
**Reell regresjon funnet OG UNNGÅTT i selve V0-eksporten, ikke i
etterkant:** denne rundens V0-prompt handlet kun om rediger/slett, men
eksporten hadde samtidig (utilsiktet) FJERNET bane-feltet fra "Legg til
økt"-skjemaet helt — nye økter ville stille blitt satt til en hardkodet
mock-bane. Fanget under diff-mot-live-treet FØR noe ble tatt inn (samme
rutine som alltid) — `CreateSessionCard` (med sitt ekte bane-søk/
teeoff-import) beholdt fullstendig urørt; kun de nye rediger/slett-
delene ble hentet inn.
**Bane-bytte i redigeringsskjemaet er bevisst ENKLERE enn opprett-
skjemaets bane-felt** — kun velg blant organisasjonens eksisterende
baner eller hent fra teeoff, INGEN "opprett ny bane: X"-snarvei her
(PATCH sin `course_id` må være en ekte, allerede eksisterende bane, ikke
en tekststreng) — unngår at samme "Tj"-forvekslingsfelle fra forrige
banebytte-hendelse kan gjenta seg via redigeringsveien.
**Verifisert:** ekte PATCH med nøyaktig skjemaets feltform, ekte DELETE
(204 tom økt, 409 med matcher — bekreftet at `{"detail":{"code",
"message"}}`-formen leses riktig av UI-et), `test_isolation.sql` 12/12,
ekte typesjekket build. Rullet ut live, ren frontend-endring, `teeoff.no`
upåvirket.
- **Invitasjonskode + ledende side + projisert stilling, BACKEND LIVE
(2026-07-19, ADR-020):** brukeren reiste tre relaterte hull rett etter at
"bygg i rekkefølgen ting brukes"-serien var ferdig: (1) ingen vei inn til
en turnering for en spiller som bare har fått muntlig beskjed, (2)
leaderboardet viser kun faktisk opptjente poeng, ikke hva stillingen ville
blitt om pågående matcher holder seg, (3) ingen fargekoding i matchlister
for hvem som leder. Full ADR-020 skrevet (4 delbeslutninger, se
ARCHITECTURE_DECISIONS.md) — nøkkelbeslutning bekreftet eksplisitt av
bruker FØR bygging: en invitasjonskode OVERSTYRER `tournament.visibility`
helt (koden ER selve invitasjonen, ikke en snarvei som fortsatt krever
eksisterende tilgang).
Ny migrasjon `011_join_code_and_leading_side.sql`: `tournament.join_code`
(6 tegn, alfabet uten 0/O/1/I, globalt unikt, backfylt for eksisterende
rader), `match.leading_side` (cachet fortegn av `MatchState.lead`, samme
mønster som `status_text`/`points_side_a/b`), fjerde
`SECURITY DEFINER`-bro `public_tournament_by_code()` (etter
`public_tournament_org` 007, `link_player_by_email` 008,
`public_org_by_slug` 009).
**Backend bygget:** `create_tournament` genererer koden (retry-løkke ved
kollisjon, astronomisk usannsynlig med 33^6 kombinasjoner). Ny
`GET /public/tournaments/by-code/{code}` (MÅ registreres FØR
`/{tournament_id}` i routeren, ellers tolkes "by-code" som en ugyldig
UUID). `GET /public/tournaments/{id}`, `GET .../sessions` og
`POST .../register` godtar alle en valgfri `code`-parameter som — når den
matcher — hopper `_check_visibility()` helt over.
`recompute_and_cache_match_state` (scoring.py) cacher nå `leading_side`
ved HVER hull-innsending, ikke bare ved avgjørelse. Leaderboard-
endepunktet fikk `projected_points`/`projected_points_by_team`:
avgjorte matcher bidrar likt til faktisk og projisert, ikke-avgjorte gir
hele `points_per_match` til `leading_side` (delt 0,5/0,5 ved "AS"/ikke
startet) — speiler hvordan Ryder Cup-TV-dekning viser "hvis det sluttet
nå".
**Reell hendelse underveis, håndtert transparent (ikke skjult):** en
feilformulert `docker exec teeoff_db env | grep -i POSTGRES`-kommando
(ment å liste variabelNAVN) fanget opp `POSTGRES_PASSWORD` sin VERDI også,
siden selve nøkkelnavnet matchet søkemønsteret — eksponerte `teeoff_db`
sitt superbruker-passord (`teeoff_admin`) i verktøyresultatet. Alvorligere
enn de to tidligere passord-hendelsene i prosjektet siden dette er
superbrukeren for HELE den delte Postgres-klyngen (teeoff OG teecup), ikke
en enkelt tjeneste-credential. Flagget til bruker umiddelbart, som valgte
å rotere. Rotert trygt UTEN å noensinne re-eksponere gammel ELLER ny verdi
i noe synlig kommandoresultat: `ALTER ROLE` kjørt via lokal
Unix-socket-`trust`-auth (bekreftet ved å lese `pg_hba.conf`, ingen
hemmelighet involvert i den sjekken) — krevde altså IKKE det gamle
passordet i det hele tatt. Nytt passord generert med `openssl rand -hex
32` (hex, ikke base64 — unngår SAMME klasse URL-enkodings-felle som
`TEECUP_DATABASE_URL`-hendelsen tidligere, siden verdien også ligger i en
`postgres://`-DSN). `/opt/teeoff/.env` sine TO forekomster
(`POSTGRES_PASSWORD` og `DATABASE_URL`) oppdatert med `sed`-mønstre som
ALDRI leser/skriver ut den gamle verdien. `teeoff_api`/`teeoff_worker`
service-nøklene i `docker-compose.prod.yml` viste seg å hete `api`/
`worker` (ikke `teeoff_api`/`teeoff_worker` — det er kun
`container_name`), samme "service-nøkkel ≠ container-navn"-fallgruve som
nettverksalias-hendelsen fra containeriseringsrunden. `docker compose up
-d --force-recreate api worker` gjenskapte OGSÅ `teeoff_db` selv (ikke
eksplisitt navngitt) — Compose oppdager konfigurasjonsendring
(`${POSTGRES_PASSWORD}` i `db`-tjenestens egen `environment:`) og
gjenskaper uansett hvilke tjenester som ble navngitt. Verifisert grundig
ETTERPÅ: `teeoff_db`-loggen viste "Skipping initialization" (datavolum
urørt, ikke reinitialisert) + ren oppstart, `teeoff_api`/`teeoff_worker`
ren oppstart uten en eneste feil-/auth-/passord-linje i hele loggen,
`teeoff.no` OG `teecup.teeoff.no/health` begge `200` etterpå.
**Scratch-verifisert grundig** (fersk `teecup_scratch` 001→011,
isolert `teecup_app_scratch`-rolle, isolert scratch-MinIO, engangs
API-container): `test_isolation.sql` 12/12 (måtte først rettes — tre
`INSERT INTO tournament`-steder i selve testfilen predaterte
`join_code` og traff den nye NOT NULL-constrainten, rettet med
dummy-koder). Full ende-til-ende-runde: to turneringer opprettet, ulike
koder bekreftet; `by-code`-oppslag bekreftet for kjent OG ukjent kode
(404); anonym lesing av en `org`-synlig turnering BLOKKERT uten kode
(403 NOT_VISIBLE), TILLATT med riktig kode (inkl. case-insensitivt),
FORTSATT blokkert med feil kode; samme mønster bekreftet for
`POST .../register`. Full hull-for-hull-simulering av en singel-match
(hole_result-modus): `leading_side`/`status_text` fulgte hverandre
eksakt gjennom "1 UP (A)" → "AS" (leading_side=null) → avgjort "9&7
(A)", leaderboardets `projected_points` traff nøyaktig 1.0/0.0 mens A
ledet, 0.5/0.5 ved "AS", og ble likt `points`/`projected_points` (begge
1.0/0.0) etter avgjørelse.
**Rullet ut mot ekte `teecup_db` 2026-07-19**, bruker bekreftet
eksplisitt: migrasjon 011 kjørt (eneste eksisterende turnering fikk
automatisk generert kode), `test_isolation.sql` fortsatt 12/12, kun
`teecup_api` redeployet (ingen frontend-endring i denne del-runden),
`teeoff.no` upåvirket.
**Frontend fullført samme dag, egen del-runde:** `login-form.tsx` fikk et
eget kode-modus (`JoinByCode`) — «Har du en invitasjonskode?»-lenke bytter
ut e-post-skjemaet, slår opp `/public/tournaments/by-code/{code}` og
navigerer til `/t/{id}?code=...` med Next sin `useRouter`. `code`
query-param tres gjennom hele veien: `app/t/[id]/page.tsx` leser
`searchParams`, `public-tournament.tsx` sender den med på BÅDE
info-/sessions-lesingen og selve `POST .../register` (ikke bare det
første oppslaget som tok deg dit).
`tournament-detail.tsx` viser koden i en egen kopier-chip i headeren —
ingen enkelt-turnering-`GET` fantes, så komponenten henter i stedet hele
org-ens turneringsliste (som allerede bærer `join_code`) og finner egen
rad, i stedet for å legge til et nytt endepunkt kun for dette.
`tournament-leaderboard.tsx` fikk en ny `SegmentedBar`-komponent — ETT
fargesegmentert rektangel per bar (ikke tall side om side), proporsjonalt
med hvert lags poeng, 50/50 nøytralt ved 0-0. To slike bares rett under
headeren: "Stilling nå" (faktisk) og "Projisert (hvis pågående matcher
holder seg)" — den EKSISTERENDE store tall-scoreboarden beholdt uendret
lenger ned som detaljvisning.
`session-blind-draw.tsx` sin `RevealedView`: matchkortet får nå en farget
toppkant (leaderens `team.color`) og en `status_text`-chip (fylt farge
ved avgjort match m/ konfetti-ikon, lys tone ved pågående) i stedet for
kun klokkeslettet; `RevealSide` for ledende side får en svak fargetonet
bakgrunn. `leading_side`/`status_text`/`points_side_a/b` lagt til
`ApiMatch`-typen (var der allerede i API-et, bare ikke konsumert
frontend-siden før nå).
**Verifisert:** ekte typesjekket produksjonsbuild (samme `Dockerfile`
som deployes, ikke dev-server) kompilerte rent, alle 10 ruter listet.
Rullet ut (kun `teecup_frontend`, ingen backend-endring i denne delen),
`teecup.teeoff.no/dashboard` og `/` → 200, `teeoff.no` upåvirket. Ekte
smoke-test i produksjon: `by-code`-oppslag for "De Gamle er Eldst" sin
faktiske kode ga riktig turnering-id, `/t/{id}?code=...` ga 200.
**ADR-020 er dermed helt ferdig** (backend + frontend, alle fire
del-ønsker: kode-basert oppdagelse, kode-felt på login, projisert
stilling, fargekoding av matcher) — bortsett fra kode-regenerering, som
er bevisst utsatt (se FEATURE_BACKLOG.md).
**Reell driftshendelse underveis** (mellom backend- og frontend-delen,
under scratch-oppsett): en feilformulert `grep -i POSTGRES`-kommando
eksponerte `teeoff_db` sitt superbruker-passord ved et uhell. Flagget
umiddelbart, brukeren valgte å rotere — se detaljene under
backend-avsnittet over for hele hendelsen og hvordan roteringen ble
gjennomført uten å noensinne re-eksponere gammel eller ny verdi.
- **Sesjons-bug diagnostisert og FIKSET, LIVE (2026-07-19):** brukeren
rapporterte at hen måtte be om ny magic-link-kode ved HVERT besøk til
`teecup.teeoff.no`, til tross for ADR-009s 30-dagers sesjonscookie. Bad
brukeren sjekke den EKTE cookien i nettleseren fremfor å gjette — kom
tilbake korrekt satt i alle henseender (`Expires` 30 dager frem,
`Secure`/`HttpOnly`/`SameSite=Lax`). Rot-årsaken var derfor IKKE cookien
eller backend-en: `frontend/app/page.tsx` (rot-siden) viste ALLTID
innloggingsskjemaet uten noensinne å sjekke om en gyldig sesjon allerede
fantes. `Dashboard`-komponenten sjekker `/auth/me` og sender til `/` ved
MANGLENDE sesjon, men ingen kode gjorde det motsatte — en bruker som
besøkte roten direkte (i stedet for å navigere til `/dashboard`) så
derfor alltid innloggingsskjemaet uansett sesjonsstatus.
**Fikset:** `page.tsx` gjort om til en async server-komponent som leser
sesjonscookien via `next/headers`, kaller `/auth/me` server-til-server
direkte mot `TEECUP_API_ORIGIN` (samme mønster som `generateMetadata` i
`app/t/[id]/page.tsx` — IKKE gjennom `next.config.mjs` sin `rewrites()`,
som kun gjelder nettleser-trafikk), og sender en allerede innlogget
bruker videre til `/dashboard` med `redirect()` FØR innloggingsskjemaet
når rendres.
**Verifisert presist mot den ekte, live stacken, med brukerens EGEN
ekte sesjonscookie** (ikke en syntetisk test): `curl` uten cookie mot
`https://teecup.teeoff.no/` ga `200` (skjemaet vises, riktig for en
anonym besøkende); samme kall MED den ekte cookien ga `307` til
`/dashboard` (riktig — sender en allerede innlogget bruker rett videre).
Ekte typesjekket produksjonsbuild kjørt FØR utrulling (build-outputet
viste selv at `/` nå er `ƒ` dynamisk i stedet for `○` statisk — bekrefter
at server-sjekken faktisk ble tatt i bruk). Rullet ut live, kun
`teecup_frontend`, ingen migrasjon, `teeoff.no` upåvirket.
**Samme runde:** brukeren stilte to oppfølgingsspørsmål om
autentisering/autorisasjon — «er flere-organisasjoner-eierskap tenkt
gjennom» (bekreftet: ja, ADR-002 fra dag én, ingen kodeendring nødvendig)
og et ønske om passord (valgfritt tillegg)+2FA. Fullt design skrevet som
**ADR-021** (passord/2FA) og **ADR-022** (dele/invitere/frasi seg
eierskap + superadmin) — se ARCHITECTURE_DECISIONS.md.
- **ADR-021 (passord/2FA) + ADR-022 (org-eierskap) BYGGET OG LIVE
(2026-07-19), samme dag:** brukeren ba om begge sammen («Bygg det»,
bekreftet eksplisitt at det gjaldt begge ADR-ene i samme runde). Ny
migrasjon `012_password_2fa_and_org_invitations.sql`: `app_user.
password_hash`/`two_factor_method`/`totp_secret`/`is_super_admin`, ny
tabell `two_factor_code` (samme hash-og-utløp-mønster som
`magic_link_token`), ny tabell `organization_invitation` (RLS
org-isolert, INGEN egen klikkbar aksept-lenke — godtas automatisk ved
neste innlogging med matchende e-post), femte
`SECURITY DEFINER`-bro `accept_pending_invitations_by_email()` (etter
`public_tournament_org` 007, `link_player_by_email` 008,
`public_org_by_slug` 009, `public_tournament_by_code` 011).
**Sesjons-STADIER innført i `app/auth.py`:** en sesjonscookie er ikke
nødvendigvis en full sesjon lenger — `create_session_token()` tar nå en
`stage`-parameter (`full`/`pending_2fa`/`must_enroll_2fa`), lagt inn som
et JWT-claim. `get_current_user` avviser eksplisitt alt annet enn `full`
ELLER en ELDRE token uten stage-claim i det hele tatt (utstedt før denne
runden — behandlet som `full` for bakoverkompatibilitet, ingen
eksisterende bruker logget brått ut). To nye avhengigheter:
`get_pending_user` (kun for 2FA-verifiseringsendepunktene) og
`get_current_or_enrolling_user` (godtar BÅDE en full sesjon — frivillig
2FA-oppsett fra kontoinnstillinger — OG `must_enroll_2fa` — tvunget
oppsett rett etter innlogging — samme oppsett-logikk dekker begge
veiene). `get_current_user_optional` fikk samme stage-sjekk for
konsistens.
**Passord (Argon2id, ikke bcrypt):** bevisst valg for å unngå bcrypt sin
stille 72-byte-trunkering, siden brukeren eksplisitt ba om korrekt
håndtering av spesialtegn/mellomrom. `POST /auth/set-password`/
`/remove-password`/`/login-password` — sistnevnte svarer med IDENTISK
401 uansett om e-posten finnes, mangler passord, eller passordet er
feil (samme anti-enumerering som magic-link).
**2FA (TOTP via `pyotp` ELLER e-post-engangskode via eksisterende SMTP,
brukerens eget valg):** `POST /auth/2fa/setup/start` genererer en
TOTP-secret UTEN å lagre den (rundturer til klienten, som ekkoer den
tilbake i `/setup/confirm` — unngår en halvferdig 2FA-tilstand i
databasen hvis brukeren forlater oppsettet). QR-kode generert
server-side (`qrcode`-biblioteket + eksisterende Pillow-avhengighet,
ingen ny ekstern tjeneste). `POST /auth/2fa/verify` fullfører en
PÅGÅENDE innlogging.
**Tvungen 2FA for org-eier/admin (ADR-021 Beslutning D):**
`user_requires_2fa_enrollment()` sjekket ved HVER innlogging (ikke bare
første gang, siden en bruker kan bli eier av en NY org etter at kontoen
allerede eksisterer uten 2FA) — verifisert eksplisitt: en fersk
org-eier uten 2FA ble korrekt blokkert fra all normal tilgang (401) og
tvunget inn i oppsett-flyten før noe annet ble tilgjengelig.
**Organisasjonseierskap (ADR-022):** `POST/GET/DELETE
/orgs/{id}/invitations` (owner→enhver rolle, admin→KUN member — ellers
en privilegie-eskaleringsvei), `PATCH/DELETE /orgs/{id}/memberships/
{id}` (owner kan endre/fjerne hvem som helst; en bruker kan ALLTID
SENKE egen rolle selv — aldri heve den, ville vært selv-forfremmelse —
og alltid forlate selv), «siste eier»-vern (409 `LAST_OWNER`, `FOR
UPDATE`-lås mot race på alle eier-rader, samme TOCTOU-mønster som
ADR-011s to-lags-grense). Superadmin (`app_user.is_super_admin`, KUN
manuelt DB-tildelt — bevisst INGEN API-vei til å gi seg selv eller
andre flagget) får en parallell autorisasjonssti
(`get_superadmin_user`) som kan sette medlemskap på ENHVER org,
uavhengig av eget medlemskap — bevisst avgrenset til nøyaktig dette,
ikke generell tilgang til andres turnering-/spillerdata.
**Tre reelle bugs funnet OG fikset UNDER scratch-testing, ingen nådde
produksjon:**
1. `verify_magic_link` sendte en rå asyncpg-`UUID` (ikke streng) videre
til sesjonsutstedelse — `jwt.encode()` sin JSON-serialisering
krasjet rått (500) på selve innloggingen. Fant umiddelbart ved første
reelle innloggingstest. Rettet med en eksplisitt `str()`.
2. OG 3. Både `2fa/setup/confirm` og `2fa/verify` kalte først den delte
`_issue_login_result()`-hjelpefunksjonen (ment for PRIMÆR
autentisering) EN GANG TIL etter at 2FA nettopp var bekreftet — som
så (korrekt, men feil kontekst) at `two_factor_method` nå var satt
og krevde EN NY runde med 2FA for akkurat den samme innloggingen,
en uendelig løkke. Fant ved å faktisk fullføre hele innloggings-
syklusen med ekte genererte TOTP-koder (`pyotp` i test-scriptet),
ikke bare ved å lese koden. Rettet ved at begge endepunktene nå
utsteder en full sesjon DIREKTE etter vellykket 2FA-bekreftelse,
ikke via gjenbruk av den generelle sjekken.
**Frontend:** `login-form.tsx` fikk en tredje modus (passord, ved siden
av magic-link og ADR-020s invitasjonskode-modus). Ny delt
`two-factor-flow.tsx` (`TwoFactorVerifyForm`/`TwoFactorSetupForm`),
brukt av BÅDE `login-form.tsx` og `verify-form.tsx` siden begge
primær-autentiseringsveiene kan returnere samme 2FA-mellomtilstand. Ny
`/account`-skjerm (sett/fjern passord, aktiver/deaktiver 2FA) og ny
`/orgs/[id]/members`-skjerm (invitere, endre rolle, fjerne/forlate),
begge lenket fra dashbordets header/org-visning. `next.config.mjs` sin
`rewrites()` utvidet med `/superadmin/:path*` (ADR-016s konsekvens for
enhver ny API-prefiks, selv om ingen frontend-UI faktisk bruker den
ennå).
**Verifisert grundig mot fersk `teecup_scratch`** (001→012,
`test_isolation.sql` 12/12): full magic-link-bakoverkompatibilitet
(eksisterende flyt uendret for brukere uten 2FA), passord med ekte
spesialtegn/mellomrom/æøå satt og brukt til pålogging, full TOTP-runde
(oppsett→bekreft→logg ut→logg inn→krev 2FA→verifiser med ekte
`pyotp`-generert kode), full e-post-2FA-runde (samme mønster, feil kode
avvist, kode ikke gjenbrukbar), tvungen 2FA-registrering for ny
org-eier, invitasjon→auto-aksept ved førstegangsinnlogging,
rolle-eskalering-forsøk avvist på tre distinkte måter (medlem kan ikke
invitere, admin kan ikke gi eierskap, bruker kan ikke forfremme seg
selv), siste-eier-vern (både PATCH og DELETE), duplikat-invitasjon
avvist, superadmin-sti fungerer/avvises riktig i begge retninger. Ekte
typesjekket produksjonsbuild av frontend (alle nye ruter listet).
**Rullet ut mot ekte systemer 2026-07-19**, bruker bekreftet eksplisitt:
migrasjon 012 kjørt mot ekte `teecup_db`, `test_isolation.sql` fortsatt
12/12, begge containere (`teecup_api`, `teecup_frontend`) redeployet og
bekreftet ren oppstart, `/health` og `/dashboard` fortsatt 200 (eksisterende
sesjoner uendret av stage-bakoverkompatibiliteten), `/auth/login-password`
bekreftet nåbart over ekte https, `teeoff.no` upåvirket.
**ADR-021 og ADR-022 er dermed begge helt ferdig** — backend + frontend.
Bevisst utenfor omfang: dedikert superadmin-UI (brukes via API av en
betrodd operatør), SMS som 2FA-metode.
Neste steg:
1. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet.
2. Kommunikasjon (chat/feed) — ikke startet.
3. Fortsatt åpne beslutninger fra FEATURE_BACKLOG.md (walkover/konsesjon,
sanntid/WebSockets vs. polling, tilskuer-rolle, kode-regenerering for
ADR-020) — bør avklares i egne, dedikerte runder når det er naturlig.
4. Ved fremtidige nye V0-skjermer/-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.