teecup/ARCHITECTURE_DECISIONS.md
Erol Haagenrud 29a6b90127 Update Todos
Diagnostisere de to feilmeldingene fra skjermbildene

Flytte frontend-sidene fra /rounds/* til /my-rounds/* (kollisjon med API-proxyen)

Oppdatere alle interne lenker/router-kall til nye stier

Oppdatere next.config.mjs-kommentaren med denne lærdommen

Typesjekket produksjonsbuild

Rulle ut live etter bekreftelse

Oppdatere statusdokumentasjon
Ferdig og live. Oppsummert:

Begge feilmeldingene skyldtes samme rot-årsak: /rounds var både frontend-sidens sti og backend-APIets ressursprefiks. Next.js sin rewrite-presedens ga to motsatte feil samtidig — listesiden/opprett-runde-kallet nådde aldri backend (fikk Next sin egen HTML tilbake i stedet for JSON), mens selve rundedetalj-siden var fullstendig uoppnåelig i motsatt retning. Fikset ved å flytte alle tre frontend-rutene til /my-rounds/*, uendret API på /rounds. Bekreftet med curl mot ekte produksjon både før og etter — nå fungerer alt som det skal. Det andre skjermbildets «55/50/44/32» var forøvrig ikke en bug, bare Tjøme Golfklubb sine ekte utslagsnavn i teeoff.

Alt rullet ut, teeoff.no upåvirket, status dokumentert i CLAUDE.md/FEATURE_BACKLOG.md/ADR-033.
2026-07-23 12:19:31 +02:00

2164 lines
122 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.

# TeeCup — Arkitektur-beslutningslogg (ADR)
> Dette dokumentet er den autoritative kilden til *hva* som er bestemt og *hvorfor*.
> Det leses av mennesker og av AI-modeller (Claude, Gemini) i starten av hver økt.
> Endre aldri en beslutning uten å legge til en ny ADR som erstatter den — historikken skal bevares.
**Status:** Levende dokument
**Sist oppdatert:** 2026-07-22
---
## Kontekst
TeeCup er en kommersiell (SaaS) webapplikasjon for å opprette, administrere og
gjennomføre golfturneringer i «Ryder Cup»-format. Den driftes på samme VPS som
`teeoff.no`, men skal fungere som et isolert økosystem med eget subdomene
(`teecup.teeoff.no`) og egen database (`teecup_db`).
Målgruppe: klubber, bedrifter og vennegjenger som arrangerer turneringer over tid.
---
## ADR-001 — Tenant = Organisasjon
**Beslutning:** Isolasjonsenheten («tenant») er en **organisasjon**, ikke en turnering.
En organisasjon er et bevisst nøytralt begrep som dekker klubb, bedrift *og*
vennegjeng. Ved å ikke kalle den «klubb» i datamodellen unngår vi refaktorering
den dagen første bedriftskunde kommer.
**Hierarki:**
```
Organisasjon (tenant — det som isoleres og faktureres)
├── Medlemmer/spillere (gjenbrukbare på tvers av turneringer)
├── Egendefinerte baner
└── Turneringer
├── Lag
└── Matcher
└── Scores
```
**Begrunnelse:** En turnering er en hendelse med start og slutt, ikke en kunde.
Gjenbrukbare ting (spillere, baner, historikk) må leve over turneringens levetid.
Turnering er derfor en entitet *inne i* en organisasjon.
**Konsekvens:** `tenant_id` i alle skjemaer heter `organization_id`.
---
## ADR-002 — Bruker og organisasjonsmedlemskap er adskilt
**Beslutning:** Identitet (innlogging) og medlemskap i en organisasjon er to
forskjellige ting. Én `user` kan ha flere `organization_memberships`.
**Begrunnelse:** Samme person spiller ofte både klubbturneringen og
jobbturneringen. En bruker må kunne krysse organisasjoner med samme innlogging.
Dette er lett å bygge inn fra start og smertefullt å legge til senere.
**Konsekvens:** Utelukker «database-per-tenant» (se ADR-003), fordi en bruker som
krysser organisasjoner da måtte eksistere i flere databaser samtidig.
---
## ADR-003 — Isolasjonsstrategi: Shared schema + Row-Level Security
**Beslutning:** Én database (`teecup_db`), delte tabeller, isolert på
`organization_id`-kolonne, håndhevet av PostgreSQL **Row-Level Security (RLS)**.
**Vurderte alternativer:**
| Strategi | For | Mot | Valgt |
|---|---|---|---|
| Shared schema + `organization_id` + RLS | Enkel drift, billig, skalerer til mange org. | Krever disiplin; RLS må settes riktig | **Ja** |
| Database/schema per tenant | Sterk isolasjon | Tung migrering; bryter med ADR-002 | Nei |
**Begrunnelse:** RLS flytter isolasjonen fra applikasjonskoden (der én glemt
`WHERE organization_id = ...` lekker data mellom kunder) ned til databasen, som
håndhever den uansett hva koden gjør. Kombinert med ADR-002 er dette det eneste
praktiske valget.
**Konsekvens / oppgave:** Hver økt/tilkobling må sette `SET app.current_org` (eller
tilsvarende) slik at RLS-policyen kan filtrere. Dette må inn i tilkoblingslaget
tidlig, ikke ettermonteres.
---
## ADR-004 — Banedata fra `teeoff_db` via enveis, lesende API-kontrakt
**Beslutning:** TeeCup henter offisielle banedata fra hovedplattformen gjennom et
veldefinert, lesende API — **ikke** via direkte databasekobling på tvers.
**Begrunnelse:** En direkte kobling ville låst TeeCup til hovedplattformens
skjemaendringer, og et brudd ett sted ville tatt ned begge produktene.
`teeoff_db` er «master» for offisielle banedata; alt brukergenerert innhold
(egendefinerte baner, turneringer, brukere) forblir strengt i `teecup_db`.
**Konsekvens:** API-kontrakten mot TeeOff må versjoneres og behandles som en
ekstern avhengighet, selv om den kjører på samme server.
---
## ADR-005 — Handicap-motoren er et frittstående, testet bibliotek
**Beslutning:** All handicap- og score-logikk bygges som en ren Python-modul uten
avhengigheter til database, API eller web-rammeverk. Den testes grundig i
isolasjon.
**Begrunnelse:** Dette er produktets hjerte og den mest risikofylte biten. Feil
her koster mest troverdighet. Ved å isolere den kan reglene enhetstestes mot kjente
fasitverdier uavhengig av resten av systemet.
**Konsekvens / viktig presisering:** Prosentbaserte «allowances» (f.eks. 75 %,
90 %, 3/4) er **konfigurasjon, ikke hardkodet logikk**. Motoren vet ikke at
«fourball = 90 %»; den mottar tildelingen som parameter. De konkrete
standardprosentene per format må verifiseres mot gjeldende WHS/lokale regler før
produksjon — de skal ikke antas fra hukommelse.
---
## ADR-006 — Teknologistack
**Beslutning:**
- **Backend:** Python + FastAPI (gjenbruker stacken fra TeeOff → enklere vedlikehold).
- **Database:** PostgreSQL (+ PostGIS der banegeometri trengs).
- **Frontend:** React (Vite) som PWA, offline-first (Service Workers + IndexedDB).
**Begrunnelse:** Offline-first er *kjernefunksjonalitet*, ikke luksus — golfbaner har
ofte dårlig mobildekning, og score må kunne registreres uten nett og synkes senere.
---
## ADR-007 — Miksede formater via økter, og spiller-pool
**Beslutning:** En turnering er en **ordnet sekvens av økter** (sessions), ikke ett
enkelt format. Hver økt bærer format, hullomfang og allowance. Ryder Cup =
foursome/18 → fourball/18 → singles/18 er tre økter.
Hvert lag har en **pool** (`team_roster`) som kan være større enn antall
matchplasser. Deltakelse avgjøres per match via `match_participant`. En reserve
er en spiller i poolen uten deltaker-rad i en gitt økt; en spiller kan spille
kun enkelte økter (f.eks. bare singelen).
**Begrunnelse:** Dette er selve Ryder Cup-strukturen. Å modellere format på
turneringsnivå ville gjort miksede formater umulig; å modellere deltakelse på
turneringsnivå ville gjort reserver og delvis deltakelse umulig.
**Konsekvens:** Handicap-snapshot fryses per turnering i `team_roster`
(`handicap_index_snapshot`) for reproduserbare resultater.
---
## ADR-008 — 9-hulls-slag: "slagene som faller på 18-hulls-kortet"
**Beslutning:** For 9-hulls-økter (front/back) brukes spillerens fulle
18-hulls-tildeling, og slagene fordeles over **hele** 18-hulls stroke index —
deretter tas kun de spilte hullene ut. Spilleren mottar slag på de spilte hullene
der SI ≤ mottatte slag.
**Vurdert alternativ:** WHS' formelle 9-hulls course handicap (eget 9-hulls
rating, grovt sagt halvparten). Kan gi et litt annet totaltall. Ikke valgt som
standard fordi ikke alle baner publiserer 9-hulls-ratinger, og den valgte
metoden er den vanlige i vennegjeng-/klubbmatch-spill.
**Kritisk implementasjonsdetalj:** Man må IKKE sende bare de ni spilte hullene inn
i slagfordelingen med et 18-hulls slagtall — det gir feil ved høye slagtall
(12 slag på back-9 blir da 10 i stedet for riktige 6). Motoren har derfor
`allocate_over_played_holes(...)` som fordeler over alle 18 og så tar ut de spilte.
Dekket av test `test_nine_hole_twelve_strokes_is_six_not_ten`.
**Konsekvens:** `tee_rating` beholder likevel front/back-omfang, slik at WHS'
alternativ kan tilbys senere som konfig per turnering uten skjemaendring.
---
## ADR-009 — Egen innlogging, uavhengig av teeoff
**Beslutning:** TeeCup har egne brukerkontoer (`app_user`), uavhengig av teeoffs
innlogging.
**Begrunnelse:** TeeCup selges kommersielt til klubber/bedrifter som kanskje aldri
har hørt om teeoff.no. Delt innlogging ville vært forvirrende for dem og koblet de
to systemenes auth tettere sammen enn ellers ønskelig. Styrker isolasjonen fra
ADR-004.
**Konsekvens:** Egen auth (registrering, Google/magic-link e.l.), egne secrets
(jf. åpent spørsmål 1), egen sesjonshåndtering. Kaptein- og tilskuer-roller
defineres innenfor TeeCups eget auth-lag.
---
## ADR-010 — Feed krever innlogging (ingen anonym lesesti i v1)
**Beslutning:** Den «offentlige» runde-feeden er felles for begge lag i
turneringen, men bak innlogging. Ingen verdenssynlig lenke i v1.
**Begrunnelse:** «Offentlig» betyr her «alle innloggede deltakere i turneringen»,
ikke «hvem som helst med en lenke». Dette fjerner den uautentiserte lesestien og
den tunge modereringen (som ellers kreves når innhold er synlig for verden) fra
v1.
**Konsekvens:** Synlighet modelleres som kanal-scope (lag / turnering). En ekte
verdenssynlig delingslenke kan legges til senere som egen bryter, med moderering,
uten å bygge om.
---
## ADR-011 — Turneringsstruktur: lagformat i v1, bracket som egen fremtidig type
**Beslutning:** v1 bygges for Ryder Cup-lagformatet (økter → uavhengige matcher →
summerte poeng), og låses til **nøyaktig to lag**.
**Begrunnelse:** En match/flight er alltid to-sidig. Tre lag ville krevd
kryss-oppgjør (AB, AC, BC), nok spillere til å møte to motstandere samtidig,
balansert oppsett, og tre-veis blind draw — mye kompleksitet for lite gevinst. To
lag er både enklere og riktigere for formatet. Et knockout-bracket (f.eks. 128
spillere, utslagsmatcher, finale + bronsefinale) er en helt egen turneringstype:
rundene er *avhengige* (vinner av match A møter vinner av match B), og krever
progresjon mellom matcher, seeding, fripass og bronsegren — noe dagens modell ikke
har.
**Konsekvens:** To-lags-grensen håndheves i app-laget (validering ved
turneringsoppsett), ikke med DB-trigger. Match-modellen holdes generell (to sider,
vilkårlige lag-referanser), så flere lag eller en knockout-type kan komme senere
uten dataomskriving — kjernen (spillere, motor, hull-score, RLS) er typeuavhengig.
Knockout er fanget som egen type i FEATURE_BACKLOG.md.
**Merk (2026-07-19):** brukeren har reist ønske om flere turneringsformater
UTOVER Ryder Cup-lagformatet (f.eks. «Københavner» — se
FEATURE_BACKLOG.md sitt eget punkt for alle fire eksemplene). Minst ett av
disse («Københavner»: alle-mot-alle poengfordeling i et felt av spillere,
ikke to lag i det hele tatt) passer IKKE inn i denne ADR-ens to-lags-modell —
en fremtidig, egen ADR trengs når/hvis dette tas fatt på, samme mønster som
knockout-punktet over. Kun notert her ennå, ikke designet eller bygget.
---
## ADR-012 — To scoring-moduser per økt
**Beslutning:** Hver økt har en `scoring_mode`: `stroke` (spillere taster slag per
hull) eller `hole_result` (registrer bare hvem som vant hullet / delt).
**Begrunnelse:** Ønsket fra start: kunne føre score enten detaljert (slag) eller
raskt (tapp «lag rød vant hullet»). De to modusene har ulik datainngang.
**Konsekvens:** `stroke` skriver til `hole_score` (brutto, motor utleder netto og
hull-resultat). `hole_result` skriver til ny tabell `match_hole_result`
(vinnende side per hull, NULL = delt). Begge mater samme
`compute_match_state` i motoren, så matchstatus beregnes likt uansett modus.
---
## ADR-013 — Blind draw via lås per lag per økt
**Beslutning:** Lagoppstilling settes skjult; matchene avsløres når BEGGE lag har
låst. Modellert med tabell `lineup_lock` (én rad per lag per økt).
**Begrunnelse:** Kjerneønske i Ryder Cup-formatet — kapteinene låser i blinde, og
oppgjørene avsløres samtidig.
**Konsekvens:** `match_participant`-radene (oppstillingen) opprettes per lag, men
motstanderens side skjules i app-laget til det finnes en `lineup_lock` for begge
lag. Etter lås kan egen oppstilling ikke endres. Dette er finkornet synlighet
(lag-nivå) og håndheves i app-/spørrelaget, ikke RLS — begge lag ligger i samme
organisasjon (jf. ADR-010).
---
## ADR-014 — Konfigurerbar handicap-pipeline: fire uavhengige brytere
**Beslutning:** Handicap-anvendelsen i en økt er FIRE uavhengige, valgfrie steg —
ikke bare én allowance-prosent:
1. **Bruk handicap** (av/på) — helt av gir scratch-spill.
2. **Bruk course handicap** (av/på) — om slope/rating-justeringen
(`course_handicap_raw`) påføres, eller om rå `handicap_index` brukes direkte.
3. **HCP-prosent** (allowance-strategien, allerede dekket av ADR-005 /
`session.allowance_override`).
4. **Bruk matchplay-handicap** (av/på) — om resultatet konverteres til relative
slag (`match_play_strokes`: beste enhet spiller «av 0», resten får
differansen), eller brukes som absolutt Playing Handicap.
**Begrunnelse:** Skjermbilder fra Golf GameBook (referanseprodukt, delt
2026-07-16) viser nøyaktig denne firedelte bryter-strukturen per runde/format i
deres oppsettsdialog. Deres standard-prosenter (foursome 50 %, better ball/
fourball 90 %, singles 100 %) stemmer eksakt med `DEFAULT_MATCHPLAY_ALLOWANCES`
i `handicap_engine.py` — uavhengig bekreftelse på at defaultene er riktige,
slik ADR-005 krever verifisert. Motoren støtter allerede alle fire som atskilte,
komponerbare funksjonskall (`course_handicap_raw`, en `AllowanceStrategy`,
`match_play_strokes`) — hver kan hoppes over uavhengig av de andre — men
API-et/skjemaet eksponerer dem ikke som brytere ennå.
**Konsekvens:** `session.allowance_override` (jsonb) skal utvides til å bære
alle fire bryterne, ikke bare prosent, når motor-/scoring-integrasjonen bygges
(se FEATURE_BACKLOG.md). Ingen skjemaendring nødvendig (feltet er allerede
jsonb), men API-lagets validering og motor-kall må håndtere: `bruk_handicap =
false` → hopp over hele kjeden (brutto = netto), `bruk_course_handicap = false`
→ bruk `handicap_index`/`handicap_index_snapshot` direkte uten
slope/rating-justering, `bruk_matchplay_handicap = false` → bruk absolutt
Playing Handicap i stedet for å kalle `match_play_strokes`.
---
## ADR-015 — Utledet tee-time og maskinlesbar feilkode-kontrakt
**Beslutning A — klokkeslett er utledet, ikke lagret per match:** `session`
får `scheduled_at` (starttid for FØRSTE match) + `tee_interval_minutes`
(minutter mellom hver "flight"). En matchs `tee_time` er
`scheduled_at + (sequence-1) * tee_interval_minutes`, beregnet i Python ved
lesing (`app/routers/matches.py`), ALDRI skrevet til `match`-raden. Unntak:
`match.tee_time_override` (nullable `timestamptz`) for enkeltmatcher som må
justeres uavhengig (forsinkelser) — vinner alltid over den utledede verdien
når satt. `session.start_hole` er eksplisitt lagret (ikke utledet av
`hole_config`).
**Begrunnelse:** Brukeren beskrev selv mønsteret rett fra domenet: "man sier
at førstematchen starter på hull X klokka Y, og det er Z minutter mellom hver
flight" — det er slik tee-tider faktisk fungerer i golf, og å lagre ett
tidspunkt per match ville vært duplisert, avledet data som kunne komme ut av
synk med intervallet. Et lite unntak (override) dekker det ene tilfellet der
den avledede regelen ikke holder.
**Konsekvens:** Alle tre nye tidsfelter er nullable/additive (migrasjon
`006_scheduling_and_locale.sql`) — en økt uten planlagt klokkeslett gir
`tee_time: null` i API-et, ikke en feil. Det finnes ennå intet
PATCH-endepunkt for `tee_time_override` (satt direkte i databasen ved
verifisering); bygges når frontend faktisk trenger å justere enkeltmatcher.
**Beslutning B — én uniform feilrespons på tvers av HELE API-et:**
`{"detail": {"code": "...", "message": "..."}}` på ALLE stedene som kaster
`HTTPException` (39 steder ved innføring), via en liten factory
`app_error(status_code, code, message)` i `app/errors.py` — ingen egen
exception-klasse, ingen global exception handler. Kodene er en liten,
gjenbrukt taksonomi (~15 koder, IKKE én unik kode per kastested) —
f.eks. `NOT_FOUND`, `DUPLICATE`, `LIMIT_REACHED`, `NOT_ROSTERED_ON_TEAM`,
`NOT_AUTHENTICATED`. Pydantic sine egne 422-valideringsfeil er bevisst
UTENFOR denne kontrakten og beholder FastAPI sin standard `{"detail": [...]}`.
**Begrunnelse:** Hardkodet norsk prosa i `detail` (slik det var før denne
runden) kan ikke brukes til noe annet enn "vis strengen" av en frontend —
den kan ikke skille en 409 pga. dupliserte rader fra en 409 pga. et
forretningstak, og kan ikke oversettes (se i18n under). En liten, gjenbrukt
kode-taksonomi lar frontend bygge stabil logikk (f.eks. "vis en spesifikk
inline-feil for `LIMIT_REACHED`, en generisk toast for alt annet") uten å
måtte parse norsk tekst. Skulle vært gjort FØR frontend startet — dyrt å
ettermontere når klientkode allerede har begynt å parse tekststrenger.
**Konsekvens:** Enhver ny `HTTPException` i fremtidig API-kode SKAL bruke
`app_error()`, aldri en rå `HTTPException(status_code, detail="...")` — se
`app/errors.py` sin docstring. Nye forretningsbetydninger som ikke passer
noen eksisterende kode får en ny kode i taksonomien, ikke gjenbruk av en
semantisk feil kode.
**Beslutning C — locale er klient-oppgitt, ikke server-gjettet:**
`app_user.preferred_locale` og `magic_link_token.locale` (kun `nb`/`en`
foreløpig, håndhevet med `CHECK`) settes fra en `locale`-verdi klienten
sender eksplisitt ved `POST /auth/request-link` — ALDRI gjettet server-side
(f.eks. fra `Accept-Language`). En NY bruker får `preferred_locale` satt fra
forespørselens locale ved førstegangsopprettelse; en EKSISTERENDE bruker som
ber om en ny lenke på et annet språk får IKKE sin lagrede preferanse
overskrevet — kun selve e-posten sendes på forespørselens språk. Ingen
endepunkt for å ENDRE `preferred_locale` på en eksisterende bruker ennå
(egen, senere sak — profilinnstillinger).
**Begrunnelse:** Frontend vet sitt eget gjeldende visningsspråk (brukerens
valg i UI-et) — det er en bedre kilde enn å gjette fra headere eller IP.
At en påfølgende innlogging på et annet språk (f.eks. en gjest som låner en
enhet) IKKE skal endre den lagrede preferansen er bevisst: en
innloggingshandling bør ikke ha den overraskende bivirkningen å endre
brukerens varige profilinnstilling.
---
## ADR-016 — Frontend som eneste offentlige overflate, API-et server-side proxyet
**Beslutning:** `teecup.teeoff.no` peker (Caddy) på Next.js-frontenden
(`teecup_frontend`), IKKE direkte på FastAPI-et. Frontenden proxyer selv
kjente API-sti-prefikser (`/auth/*`, `/orgs/*`, `/health` — som til sammen
dekker HELE dagens API-overflate, verifisert med et grep av samtlige
rutedefinisjoner) videre til `teecup_api:8000` server-side, via Next.js sin
egen `rewrites()`-mekanisme (`frontend/next.config.mjs`). API-et er ikke
lenger separat Caddy-rutet eller offentlig eksponert under eget navn.
**Begrunnelse:** Alt kjører dermed under samme opprinnelse (origin) sett fra
nettleseren — ingen CORS-konfigurasjon trengs, og HttpOnly-sesjonscookien
(ADR-009) fungerer helt uendret enten kallet "egentlig" går til frontend
eller API. Alternativet (eget subdomene for API-et, `SameSite=None`-cookie
eller CORS-hull) ville svekket cookie-sikkerhetsmodellen som allerede var
bevisst bygget stram. Samme mønster generaliserer til alle fremtidige
skjermer uten videre Caddy-endringer — nye API-ruter under `/auth`, `/orgs`
trenger ingen ny proxy-regel, kun nye Next.js-sider som kaller dem med
relative URL-er.
**Reell fallgruve funnet og fikset ved bygging (2026-07-17):** Next.js sin
`rewrites()` løses ved BUILD-tid for `output: "standalone"` (bakes inn i
server-bunten), ikke ved container-oppstart. En `docker run -e
TEECUP_API_ORIGIN=...` ved kjøretid ble derfor stille ignorert (falt tilbake
til default `localhost:8000`, som ikke fantes i containeren — proxy-kall
feilet med `ECONNREFUSED`). Løst med en Docker build-time `ARG
TEECUP_API_ORIGIN` (default `http://teecup_api:8000`, matcher alltid det
delte nettverkets tjenestenavn) i `frontend/Dockerfile`, satt via
`docker-compose.yml` sin `build.args`. Generell lærdom for fremtidige
Next.js/Docker-oppsett i dette prosjektet: alt som brukes inne i
`next.config.mjs` er en BUILD-tids verdi, ikke en runtime-verdi, med mindre
det eksplisitt leses på nytt et sted som faktisk kjører per request (en
route handler, ikke selve config-filen).
**Konsekvens:** Enhver fremtidig ny API-sti-prefiks (utenfor `/auth`,
`/orgs`, `/health`) MÅ legges til i `frontend/next.config.mjs` sin
`rewrites()`-liste, ellers blir den utilgjengelig fra nettleseren selv om
API-et selv fungerer (kun nåbar internt på Docker-nettverket). `teecup_api`
sin port er ikke lenger tenkt nåbar direkte utenfra i prod.
---
## ADR-017 — Selvregistrering og utvidet spillerprofil
**Kontekst:** Reist av brukeren rett etter at lag/roster-skjermen var live.
Dagens modell antar at organisator kjenner og legger inn alle spillere selv
— i praksis vet organisator ofte ikke hvem som faktisk blir med før de
melder seg på selv.
**Beslutning A — Påmelding er offentlig, krever IKKE innlogging.** En
delbar lenke (`/register/{tournament_id}` — turneringens UUID er allerede
uforutsigelig nok, ingen ny token-mekanisme) viser et minimalt skjema. Ingen
magic-link, ingen konto kreves for å melde seg på.
**Begrunnelse:** Å kreve innlogging FØR man kan melde seg på er unødvendig
friksjon for "jeg blir med lørdag"-bruksmønsteret. Kontosammenkobling skjer
gratis senere (Beslutning B), ikke som et eget steg i selve påmeldingen.
**Beslutning B — E-post er sammenkoblingsnøkkelen** mellom en organisator-
forhåndsopprettet `player`-rad og en spiller som senere melder seg selv på
eller logger inn. Finnes det en `player`-rad i org-en med samme e-post ved
påmelding, fylles manglende felt inn på DEN raden i stedet for å opprette en
duplikat. `player.user_id` kobles først når noen med matchende e-post
faktisk logger inn via magic-link — `verify_magic_link` utvides til også å
slå opp org-scopede `player`-rader på e-post, ikke bare `app_user`.
**Konsekvens:** `player.email` er bevisst IKKE en `UNIQUE`-constraint —
familier deler av og til e-post (forelder melder på barn), en hard unik-
regel ville krasje akkurat den vanlige situasjonen. Matching er et mykt,
applikasjonslags-oppslag.
**Beslutning C — Påmelding er et eget, lettvekts steg, atskilt fra
`team_roster`**, med konfigurerbar godkjenning, kapasitet og samtykke. Ny
tabell `tournament_registration` (status: `pending`/`confirmed`/
`waitlisted`/`declined`/`withdrawn`). Tre nye felt på `tournament`:
`registration_capacity` (nullable — organisators valg om det i det hele
tatt skal være en grense), `registration_overflow_policy`
(`waitlist`/`closed`, kun relevant når kapasitet er satt),
`registration_requires_approval` (boolean). Rekkefølge ved en ny
påmelding: (1) er fristen passert → avvis; (2) er kapasitet nådd →
`waitlisted` eller avvis, avhengig av policy; (3) ellers `pending` eller
`confirmed`, avhengig av godkjenningsbryteren.
**Begrunnelse:** `team_roster` betyr i dag "committed til et bestemt lag".
Å blande "vil kanskje spille" med "spiller garantert" i samme tabell ville
gjort det umulig å skille en påmeldt-men-ikke-plukket spiller fra en som
aldri var interessert. Kapasitet og godkjenning er to reelle, uavhengige
organisator-beslutninger — å låse ett svar for alle turneringer ville vært
feil for minst noen av dem (brukeren bekreftet eksplisitt: begge skal være
konfigurerbare valg, ikke faste regler).
**Beslutning D — Utvidet spillerprofil + samtykke.** Nye felt på `player`:
`mobile`, `email`, `birth_date` (IKKE alder — alder blir feil neste år,
fødselsdato er ikke det), `nickname`, `country`, `club`,
`club_member_number`. Samtykke er obligatorisk ved påmelding — API-et
avviser innsending (400) uten `consent: true`, og
`tournament_registration.consent_given_at` er beviset på at det faktisk ble
gitt, ikke bare antatt. Samtykket lever på REGISTRERINGEN, ikke på
`player`, fordi det er selve påmeldingshandlingen for DENNE turneringen
samtykket knytter seg til.
**Ikke et nytt felt:** "utslagssted for anledningen" er sannsynligvis
allerede dekket av `match_participant.tee_id` (per match, ikke per
spillerprofil, siden det kan variere fra runde til runde).
**Beslutning E — `public_tournament_org()`: den eneste broen fra en
uautentisert forespørsel til riktig RLS-kontekst.** Et offentlig
påmeldingskall kjenner en turnering-id, men ikke organisasjonen den hører
til — og uten `app.current_org` satt slipper RLS ingen rader gjennom
(heller ikke selve oppslaget for å FINNE riktig org). Løst med en snever
`SECURITY DEFINER`-SQL-funksjon (`p_tournament_id -> organization_id`, kjørt
med skaperens BYPASSRLS-rettigheter, `SET search_path = public` mot
kapring) — samme "løs kontekst-problemet FØR RLS kan håndheve noe"-mønster
som den selvrefererende org-bootstrapen (`app/routers/organizations.py`),
nå for et lese-oppslag i stedet for en innsetting.
**Konsekvens:** App-laget slår opp org-id via denne funksjonen FØRST, åpner
deretter en vanlig `org_connection(org_id)` og fortsetter med normal
RLS-håndhevelse for alt det faktiske arbeidet. Funksjonen eksponerer KUN en
uuid->uuid-kobling, ingenting annet fra `tournament`-raden — et bevisst
smalt unntak, ikke en generell RLS-omgåelse. Ethvert fremtidig offentlig
(uautentisert) endepunkt som trenger å slå opp org-kontekst fra en kjent
ressurs-id bør gjenbruke akkurat dette mønsteret, ikke finne opp et nytt.
**Migrasjon:** `007_registration_and_player_fields.sql`. Kontosammenkoblingen
i Beslutning B (e-post → `player.user_id` ved innlogging) fikk sin egen
`SECURITY DEFINER`-bro (`link_player_by_email()`, samme mønster som
Beslutning E) i en oppfølgende migrasjon, `008_link_player_by_email.sql`.
---
## ADR-018 — Landingssider: synlighet, org-profil, delbart innhold
**Kontekst:** Reist av brukeren rett etter at registrerings-API-et
(ADR-017) var live — turneringer og organisasjoner bør ha egne, delbare
landingssider (hero, tekst, program, sponsorer, påmelding for turnering;
klubbprofil + turneringsliste for organisasjon). Brukeren krevde eksplisitt
at synlighet må være et VALG: offentlig, kun org-medlemmer, eller kun
turnering-deltakere.
**Beslutning A — Trenivås synlighet, ett felt per nivå.**
`tournament.visibility` (`'public'`/`'org'`/`'participants'`, default
`'org'`) og `organization.public_profile` (boolean, default `false`).
Trygg standard: ingen eksisterende eller nyopprettet turnering/org blir
offentlig av seg selv. "Kun deltakere" gir ikke mening på org-nivå (en
organisasjon har ikke "deltakere"), derfor en enklere bryter der, ikke
samme trenivå-enum som turnering.
**Beslutning B — RLS beskytter TENANT-grenser, ikke INNHOLDS-synlighet.**
`org_isolation`-policyen håndhever kun at org A aldri ser org B sin data —
den sier ingenting om hvem INNENFOR riktig org-kontekst som får se hva.
Det viste seg at det eksisterende `GET /public/tournaments/{id}` (ADR-017)
allerede leser fullt turneringsinnhold uten noen synlighetssjekk i det
hele tatt, fordi RLS er fornøyd så snart org-konteksten er riktig satt —
uansett hvem (eller om noen) som spør.
**Konsekvens:** `visibility` MÅ håndheves eksplisitt i applikasjonslaget på
hvert offentlig lese-endepunkt (og på registrering, se Beslutning D), ikke
forventes løst av RLS.
**Beslutning C — Ny autorisasjonsvei: "deltaker".** For
`visibility='participants'` må leseren enten være org-medlem, ELLER
innlogget med en `player`-rad (koblet via `player.user_id`, ADR-017
Beslutning B) som har en `tournament_registration`- eller
`team_roster`-rad for NØYAKTIG denne turneringen. Ny
`get_current_user_optional`-avhengighet i `app/auth.py` — som
`get_current_user`, men returnerer `None` i stedet for å kaste 401, siden
et offentlig endepunkt skal fungere for en anonym leser også (bare med
`visibility='public'`-tilgang).
**Beslutning D — Registrering følger SAMME synlighetsgrense som
landingssiden.** Kan du ikke se turneringen, kan du heller ikke melde deg
på den — ingen særbehandling av `/register`-endepunktet.
**Beslutning E — Turnering-landingsside: innhold nå, bilder som inerte
felt.** Nye felt: `tournament.description` (presenterende tekst),
`tournament.hero_image_key` (inert til MinIO-runden — kun kolonnen, ingen
opplastingslogikk, sparer en fremtidig migrasjon for null kostnad nå). Ny
tabell `tournament_sponsor` (navn+lenke aktivt, `logo_key` inert av samme
grunn). Program vises via `GET /public/tournaments/{id}/sessions`, som
gjenbruker `_fetch_sessions()` (nytt uttrekk fra `tournaments.py` sin
`list_sessions`, delt mellom den innloggede og den offentlige ruten) —
blind draw-hemmelighold (ADR-013) arves automatisk, ikke reimplementert.
**Bevisst utenfor omfang denne runden:** lag-/roster-visning på
landingssiden (må også respektere blind draw-lås) — egen, senere sak.
**Beslutning F — Org-landingsside: slug + tredje SECURITY DEFINER-bro.**
`organization.slug` (unik når satt, `CHECK (NOT public_profile OR slug IS
NOT NULL)`). `GET /public/orgs/{slug}` løser org via
`public_org_by_slug(slug)` — samme mønster som `public_tournament_org()`/
`link_player_by_email()` (007/008), nå tredje instans. Returnerer `NULL`
for BÅDE "finnes ikke" og "finnes, men er privat" — samme
anti-enumerering som magic-link (ADR-009), ikke en tilfeldighet at
mønsteret gjentas.
**Bevisst utsatt til egen, senere runde:** hero-/sponsorbilder (krever
MinIO — arkitektur-invarianten finnes fra før, se CLAUDE.md, men er aldri
satt opp i praksis).
**Migrasjon:** `009_landing_pages_and_visibility.sql`.
---
## ADR-019 — Offisiell banedata: import (kopi), ikke live oppslag
**Kontekst:** ADR-004 vedtok prinsippet (lesende API, ikke delt database) helt
i starten av prosjektet, men ble aldri bygget — kun `source='custom'`-baner
fantes (organisator taster inn banen selv). Brukeren påpekte dette rett etter
program-skjerm-runden (2026-07-18): det er en reell mangel at en organisator
må taste inn en bane manuelt når banen faktisk allerede finnes i teeoff.
**Kartlagt før noe ble besluttet** (lest `/opt/teeoff/backend/main.py`, kun
lesing): `GET /api/facilities/{slug}` er offentlig, uten auth/API-nøkkel, og
returnerer allerede `courses[]` med `holes[]` (`hole_number`, `par`,
`hcp_index`) og `tees[]` (`name`, `cr_men`/`slope_men`, `cr_women`/
`slope_women`) — nøyaktig det `hole`/`tee`/`tee_rating`-tabellene i
`teecup_db` (migrasjon 001) allerede modellerer. `GET /api/facilities?
view=search` gir en lettvekts liste (`id`, `slug`, `name`, `city`, `county`,
…) egnet for søk. Par/hcp_index kan være NULL i teeoffs skjema (ufullstendig
baneregistrering) — teecups `hole`-tabell krever begge NOT NULL.
**Beslutning A — Import ved organisators eksplisitte valg, ikke live
oppslag ved hver bruk.** Organisator søker blant teeoff sine baner, velger
én, og teecup kopierer da bane+hull+tee+tee_rating INN i `teecup_db` som en
vanlig `course`-rad med `source='official'` og
`external_course_ref = '{facility_slug}:{teeoff_course_id}'`. Etter import
er raden en helt vanlig lokal `course`-rad — økt-/match-/handicap-koden
(som allerede kun kjenner `course_id` som fremmednøkkel) trenger INGEN
endring.
**Begrunnelse:** Handicap-motoren joiner `tee_rating`/`hole` direkte i SQL
midt i en database-transaksjon (`app/handicap.py`) — et live HTTP-kall til
teeoff derfra ville krevd at hver matchberegning avhenger av teeoffs
oppetid, og ville ikke vært transaksjonssikkert. Import fryser dataene på
importtidspunktet — samme reproduserbarhets-prinsipp som handicap-
snapshotten i ADR-007 (`team_roster.handicap_index_snapshot`): endrer
teeoff en rating i etterkant, skal ikke en allerede opprettet turnering
plutselig regne annerledes. Dette er også det som gjør ADR-004s opprinnelige
"et brudd ett sted skal ikke ta ned begge produktene"-begrunnelse reell: en
teeoff-nedetid blokkerer kun NYE importer, ikke bruk av allerede importerte
baner.
**Beslutning B — Server-til-server, internt Docker-nettverk, ingen ny
hemmelighet.** `teecup_api` kaller `http://teeoff_api:8000` direkte (samme
`teeoff_default`-nettverk begge allerede deler) — ikke via Caddy/det
offentlige domenet. Teeoff sitt API har ingen auth-mekanisme på disse
endepunktene i det hele tatt, så ingen ny credential trengs. CORS-listen på
teeoff-siden (ekskluderer teecups origin) er irrelevant — CORS gjelder kun
nettleser-fetch, ikke et backend-til-backend-kall. Ny avhengighet: `httpx`
(async HTTP-klient), lagt til i `app/requirements.txt`.
**Beslutning C — Ufullstendige teeoff-data feiler importen tydelig, importen
skjer aldri delvis.** Mangler et hull `par`/`hcp_index`, eller mangler en
tee både `cr_men` OG `cr_women`, avvises HELE importen med en klar feil
(`code: EXTERNAL_DATA_INCOMPLETE`) FØR noe skrives — ikke en delvis
importert bane med hull som senere feiler i handicap-beregning. Hele
importen kjører i én DB-transaksjon (`org_connection` sin eksisterende
`conn.transaction()`), så en feil midtveis ruller automatisk tilbake.
**Beslutning D — Kun `full_18`-rating importeres.** Teeoff har ingen egen
front9/back9-rating i sitt skjema (kun én CR/slope per tee, kjønnsdelt).
Samme valg som ADR-008 allerede gjorde bevisst for egendefinerte baner: den
formelle WHS 9-hulls-metoden er ikke i bruk, 18-hulls-tildelingen med uttak
av spilte hull dekker front/back-økter.
**Beslutning E — Reimport av samme teeoff-bane er en feil, ikke en
duplikat-rad.** Ny partiell unik indeks `(organization_id,
external_course_ref) WHERE external_course_ref IS NOT NULL` (migrasjon
`010`) — importerer en organisator samme teeoff-bane to ganger, avvises det
med den eksisterende `DUPLICATE`-koden (409), ikke en ny rad med samme
banedata.
**Konsekvens:** `app/teeoff_client.py` (ny, ren HTTP-klient, ingen
db/RLS-avhengighet — samme isolasjonsprinsipp som `handicap_engine.py`).
`app/routers/courses.py` utvides med `GET .../courses/official-search` og
`POST .../courses/official-import`. Migrasjon `010_official_course_unique_
ref.sql`.
---
## ADR-020 — Invitasjonskode (oppdagelse), matchledelse og projisert stilling
**Kontekst:** Reist av brukeren 2026-07-19, rett etter at "bygg i rekkefølgen
ting brukes"-serien var ferdig. Tre relaterte, men separate mangler:
1. Den eneste veien inn til en turnering er en direkte lenke (`/t/[id]`) eller
org-ens klubbside (kun `public`-synlige turneringer). En spiller som bare
har fått muntlig beskjed ("du spiller lørdag") har i dag ingen vei inn i
det hele tatt — dette var reelt ikke gjennomtenkt tidligere.
2. Leaderboardet viser kun FAKTISK opptjente poeng. Ingen visning av hva
stillingen ville blitt om pågående, ikke-avgjorte matcher holder seg som
de står nå (vanlig i profesjonell golf-TV-dekning av Ryder Cup).
3. Ingen visuell indikasjon i matchlister på hvem som leder en pågående
match — brukeren viste et skjermbilde av referanseproduktets fargekodede
matchrader (rød/beige etter ledende side) som ønsket retning.
**Beslutning A — Kort, menneske-skrivbar invitasjonskode per turnering, som
OVERSTYRER `tournament.visibility`.** Ny `tournament.join_code` (6 tegn, fra
et alfabet uten forvekslingsbare tegn — `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`,
altså uten `0/O/1/I`), generert automatisk ved opprettelse, globalt unik
(kodeoppslag skjer FØR org-kontekst er kjent, samme problem som
`public_tournament_org()` løste for ADR-017). Login-skjermet får et eget
kode-felt (fungerer FØR innlogging) som løser koden til riktig turnering og
sender brukeren til `/t/{id}?code=...`.
**Bevisst valgt fremfor å la koden respektere synlighet:** en kode gitt
muntlig eller på en lapp ER selve invitasjonen — å likevel kreve org-
medlemskap eller deltakerstatus for en `org`/`participants`-synlig turnering
ville gjort koden verdiløs for akkurat den situasjonen den er ment å løse.
Koden er ikke hemmelig i sikkerhetsforstand (den er MENT å deles), men
6 tegn fra et 33-tegns alfabet (≈1,3 milliarder kombinasjoner) gjør blind
gjetting upraktisk uten separat rate-limiting — akseptabelt for et
tillitsbasert klubb-/vennegjeng-verktøy (samme trusselmodell-resonnement som
`team_authz.py`, se «Brukerroller» i FEATURE_BACKLOG.md).
**Konsekvens:** `GET /public/tournaments/{id}` og
`POST /public/tournaments/{id}/register` godtar en valgfri `code`-parameter;
matcher den turneringens `join_code` (case-insensitivt), hoppes den vanlige
`_check_visibility()`-sjekken helt over. Ny SECURITY DEFINER-bro
`public_tournament_by_code(code) RETURNS uuid` (tournament_id) — fjerde
instans av samme mønster som `public_tournament_org()` (007),
`link_player_by_email()` (008), `public_org_by_slug()` (009). Ingen
kode-regenerering bygget denne runden (organisator kan i dag ikke bytte ut
en lekket kode) — egen, senere sak i FEATURE_BACKLOG.md om det blir
etterspurt.
**Beslutning B — Projisert stilling: pågående matchers nåværende leder får
full poengsum, uavgjort/ikke-startet splittes likt.** For hver IKKE avgjort
match brukes `match.leading_side` (se Beslutning C) til å tildele hele
øktens `points_per_match` til den ledende siden i den PROJISERTE summen —
"AS" (all square) eller en match som ennå ikke har noen registrerte hull
splittes 0,5/0,5, samme regel som en faktisk halvert match. Avgjorte
matcher bidrar likt til både faktisk og projisert sum (de er jo allerede
det de blir). Speiler hvordan TV-dekning av Ryder Cup vanligvis viser
"hvis det sluttet nå"-tavler.
**Beslutning C — Ny cachet kolonne `match.leading_side`, samme mønster som
`status_text`/`points_side_a/b`.** `recompute_and_cache_match_state()`
(`app/routers/scoring.py`) beregner den allerede tilgjengelige
`MatchState.lead`-verdien (fortegn = ledende side) ved HVER hull-innsending
uansett om matchen er avgjort ennå — lagres nå også i en egen kolonne i
stedet for kun å ligge innbakt i den menneskelesbare `status_text`-strengen
("2 UP (A)"), slik at frontend kan style etter et strukturert felt
(`"a" | "b" | null`) i stedet for å parse norsk/engelsk tekst. Brukes til
BÅDE projisert stilling (Beslutning B) og fargekoding av matchlister
(Beslutning D).
**Beslutning D — Fargekoding er ren frontend-presentasjon, ingen ny
backend-modell.** Matchlister (blind draw sin avslørte visning, ev. flere
steder senere) farger den ledende sidens kant/bakgrunn med lagets EKSISTERENDE
`team.color` når `leading_side` er satt og matchen ikke er avgjort — samme
fargekilde som resten av appen (leaderboard, roster) allerede bruker, ingen
ny fargemodell innført.
**Migrasjon:** `011_join_code_and_leading_side.sql`.
---
## ADR-021 — Passord (valgfritt tillegg) + valgfri 2FA (TOTP eller e-post)
**Kontekst:** Reist av brukeren 2026-07-19, sammen med et konkret sesjons-
problem: dagens magic-link-cookie (ADR-009, 30 dager) fungerte visstnok ikke
som tiltenkt — brukeren måtte be om ny innloggingskode ved HVERT besøk til
`teecup.teeoff.no`. **Diagnostisert og FIKSET samme dag** (ikke en del av
ADR-021s videre omfang, men verdt å nevne her siden det var starten på denne
tråden): kodegjennomgangen av `app/auth.py`/`app/routers/auth.py` fant ingen
feil i selve cookie-settingen, og brukerens ekte, ferske cookie (hentet fra
nettleseren på forespørsel) bekreftet 30 dagers levetid, `Secure`/`HttpOnly`/
`SameSite=Lax` alt korrekt. Rot-årsaken var derfor IKKE en cookie-/backend-
bug, men en manglende sjekk i frontend: `app/page.tsx` (rot-siden) viste
ALLTID innloggingsskjemaet uten noensinne å sjekke om en gyldig sesjon
allerede fantes — `Dashboard` sjekket `/auth/me` og sendte til `/` ved
MANGLENDE sesjon, men ingenting gjorde det motsatte. Fikset med en
server-side sesjonssjekk (leser cookien via `next/headers`, kaller
`/auth/me` direkte mot `TEECUP_API_ORIGIN` server-til-server, samme mønster
som `generateMetadata` i `app/t/[id]/page.tsx`) som sender en allerede
innlogget bruker rett til `/dashboard`. Verifisert med brukerens ekte cookie:
uten cookie → 200 (skjema), med gyldig cookie → 307 til `/dashboard`. Rullet
ut live, kun `teecup_frontend`, ingen migrasjon, `teeoff.no` upåvirket.
Utover selve bugen ønsket brukeren eksplisitt: e-post/brukernavn+passord
(må håndtere spesialtegn og mellomrom korrekt) SOM ET TILLEGG til
(ikke erstatning for) den passordløse innloggingen, samt topartsautentisering.
**Beslutning A — Passord er et valgfritt, sidestilt alternativ, ikke
påkrevd.** Login-skjermet får en tredje modus (ved siden av e-post-magic-link
og invitasjonskode, ADR-020) for e-post+passord. En bruker setter selv et
passord når hen ønsker det (egen "sett passord"-handling, krever en allerede
gyldig sesjon — samme "du må bevise identitet FØRST" som andre sensitive
endringer) — INGEN eksisterende eller ny bruker tvinges til å sette passord.
Magic-link fortsetter å fungere uendret for alle, uavhengig av om et passord
i tillegg er satt.
**Beslutning B — Passord-hashing: Argon2id, ikke bcrypt.** Bcrypt trunkerer
stille ved 72 BYTES (et kjent fallgruve-mønster — to ulike passord som deler
de første 72 bytene hasher likt) og har historiske NUL-byte-kvirker i enkelte
implementasjoner. Siden brukeren eksplisitt ber om korrekt håndtering av
spesialtegn/mellomrom (dvs. lengre, mer varierte passord/passfraser er
forventet brukt), velges Argon2id (`argon2-cffi`) — minnehardt, OWASPs
anbefalte standard i dag, ingen lengde-fallgruve. Lagres i ny
`app_user.password_hash` (nullable — NULL betyr "ikke satt", faller da
tilbake til kun magic-link).
**Beslutning C — 2FA: brukeren velger metode selv, TOTP ELLER e-post-
engangskode.** `app_user.two_factor_method` (nullable, `'totp'`/`'email'`).
- **TOTP:** `app_user.totp_secret` (bare satt når metoden er `'totp'`),
`pyotp` for generering/verifisering (RFC 6238-standard, virker med Google
Authenticator/Authy/1Password etc. uten videre). QR-kode for oppsett
generert server-side (`qrcode`-biblioteket + eksisterende Pillow-
avhengighet, samme mønster som AVIF-konverteringen) — ingen ny ekstern
tjeneste, ingen løpende kostnad.
- **E-post-engangskode:** gjenbruker eksisterende SMTP-oppsett (`app/email.py`,
ADR-009) — ny, kort 6-sifret kode, samme hash-og-utløp-mønster som
`magic_link_token` (ny tabell `two_factor_code`, 5 minutters gyldighet).
Svakere som eneste faktor (e-post-kontoen blir da reelt sett den ENESTE
hemmeligheten), men krever ingen ny infrastruktur og er brukerens eget,
informerte valg mellom de to metodene.
- **Bevisst UTENFOR omfang:** SMS-2FA — krever en betalt tredjeparts
SMS-leverandør (f.eks. Twilio), løpende kostnad per melding, ny ekstern
avhengighet. Ikke bygget denne runden; kan legges til som en tredje metode
senere uten å røre TOTP/e-post-sporene, siden `two_factor_method` allerede
er et åpent tekstfelt, ikke en hardkodet to-verdi-enum i skjemaet.
**Beslutning D — 2FA er PÅKREVD for org-eier/admin, valgfritt ellers.**
Ved innlogging (uansett om via magic-link eller passord): har brukeren
`organization_membership.role IN ('owner','admin')` i MINST én organisasjon
OG `two_factor_method IS NULL`, gis IKKE en full sesjon — brukeren tvinges
inn i et "sett opp 2FA nå"-steg først. En vanlig `member`-rolle (eller en
bruker uten noe org-medlemskap ennå) kan fortsette å bruke appen helt uten
2FA om hen ønsker det. **Begrunnelse:** organisator-roller har skrivetilgang
til andre spilleres personopplysninger (ADR-017) og kontroll over hele
turneringer — et kompromittert organisator-passord/magic-link er en langt
alvorligere hendelse enn en kompromittert spillerkonto. Håndheves ved HVER
innlogging (ikke bare første gang), siden en bruker kan BLI eier av en ny
org (`POST /orgs`, ADR ingen restriksjon — se avklaringen under) etter at
kontoen allerede eksisterer uten 2FA.
**Beslutning E — To-stegs innlogging via en `stage`-claim i sesjons-JWT-en,
ikke en egen tabell for "pågående innlogging".** Når 2FA kreves (enten fordi
brukeren selv har slått det på, eller fordi Beslutning D tvinger det),
utsteder primær-autentisering (magic-link-verifisering ELLER passord-innlogging)
et KORTLEVD (5 min) JWT med `stage: "pending_2fa"` i stedet for en full
30-dagers sesjon. En ny avhengighet (`get_pending_2fa_user`, speiler
`get_current_user`) godtar KUN denne mellomtilstanden, og eksponeres bare på
2FA-verifiserings-/oppsett-endepunktene — `get_current_user` (brukt av ALLE
andre endepunkter) avviser eksplisitt en `pending_2fa`-claim som ugyldig,
slik at en ufullstendig innlogging ALDRI gir reell tilgang til noe. Først når
riktig TOTP-/e-post-kode verifiseres, byttes denne inn mot den vanlige fulle
sesjonscookien (samme 30-dagers levetid som i dag).
**Avklaring underveis, ikke en ny beslutning:** brukeren spurte samtidig om
«kan en bruker eie flere organisasjoner» er tenkt gjennom. Bekreftet: JA,
dette var alltid en del av modellen (ADR-002 — bruker og org-medlemskap er
bevisst atskilt nettopp for at én bruker skal kunne krysse flere
organisasjoner). `POST /orgs` (`app/routers/organizations.py`) har INGEN
begrensning på hvor mange organisasjoner én bruker kan opprette/eie —
hver ny org gir automatisk `role='owner'` for oppretteren, uavhengig av
eksisterende medlemskap. Allerede testet i praksis: dashbordets org-bytter,
kryss-org-isolasjonstestene, og `/auth/me` sin N+1-oppslagsstrategi
(«N = antall organisasjoner brukeren tilhører») forutsetter alle nettopp
dette. Ingen kodeendring nødvendig — kun bekreftelse.
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Migrasjon `012_password_2fa_and_
org_invitations.sql` kjørt mot ekte `teecup_db`. Se CLAUDE.md-status for
full byggerunde (backend/frontend-detaljer, tre reelle bugs funnet og
fikset under scratch-testing).
---
## ADR-022 — Organisasjonseierskap: dele, invitere, frasi seg, superadmin
**Kontekst:** Reist av brukeren 2026-07-19, rett etter ADR-021. Et reelt,
mer FUNDAMENTALT hull ble synlig under gjennomgangen: `organization_
membership` har i dag INGEN vei til å legge til et nytt medlem i det hele
tatt etter at organisasjonen er opprettet — den ENESTE raden som noensinne
settes inn er grunnleggerens egen `owner`-rad (`POST /orgs`,
`app/routers/organizations.py`). Det finnes ingen invitasjon, ingen
rollestyring, ingen måte å fjerne noen på. «Del eierskap»-ønsket er derfor
bare den mest synlige kanten av et bredere manglende felt: hele
medlemskaps-livssyklusen etter opprettelse.
**Beslutning A — Flere eiere er allerede støttet av SKJEMAET, kun API-et
mangler.** `organization_membership.role` har ingen unikhetsbegrensning som
hindrer flere `owner`-rader for samme organisasjon — dette var aldri en
sperre, bare et ubrukt hull. Ingen skjemaendring nødvendig for selve
flereeiere-støtten, kun nye endepunkter.
**Beslutning B — E-post-basert invitasjon, samme mønster som spiller-
sammenkobling (ADR-017 Beslutning B), ikke et helt nytt konsept.** Ny
`POST /orgs/{id}/invitations {email, role}` oppretter en
`organization_invitation`-rad (e-post, rolle, token-hash, utløper, hvem som
inviterte) og sender en e-post via eksisterende SMTP-infrastruktur
(`app/email.py`). Den inviterte trenger IKKE ha en konto fra før — lenken
tar dem til innlogging (magic-link, evt. passord etter ADR-021), og
`verify_magic_link` utvides til å sjekke ventende invitasjoner på e-posten
sin (samme "kjør på hver innlogging, idempotent" mønster som
`link_player_by_email`) og sette inn `organization_membership`-raden da.
**Rolle-grense ved invitasjon:** en `owner` kan invitere til ENHVER rolle
(owner/admin/member); en `admin` kan KUN invitere til `member` — å la en
admin invitere en ny eier ville vært en reell privilegie-eskaleringsvei
(en admin gir seg selv/en alliert eierskap). Dette er en bevisst, ikke
åpen, avgrensning — minste-privilegium-prinsippet, samme resonnement som
`team_authz.py` sin eksisterende owner/admin-splitt.
**Beslutning C — Rollestyring og fjerning, med et «siste eier»-vern.**
Ny `PATCH /orgs/{id}/memberships/{membership_id} {role}` (kun `owner`,
UNNTATT at en bruker alltid kan senke SIN EGEN rolle selv — «frasi seg
eierskapet» er en selvbetjent handling, ikke noe som må be en annen eier om
lov) og `DELETE /orgs/{id}/memberships/{membership_id}` (kun `owner`, eller
selv for å forlate organisasjonen). Begge avviser handlingen med en klar
409 (`LAST_OWNER`) hvis den ville latt organisasjonen stå igjen med NULL
eiere — samme «TOCTOU-trygg med `FOR UPDATE`»-mønster som ADR-011s
to-lags-grense. En enslig eier må altså enten forfremme noen andre til
eier FØRST, eller be en superadmin om hjelp (Beslutning D) hvis
organisasjonen skal forlates helt.
**Beslutning D — Superadmin er et manuelt tildelt, ikke selvbetjent, flagg.**
Ny `app_user.is_super_admin` (boolean, default `false`). INGEN API-endepunkt
lar noen sette dette flagget på seg selv ELLER andre — det settes kun
direkte i databasen av en driftsansvarlig (samme tillitsnivå som å kjøre en
migrasjon), bevisst utenfor appens eget autorisasjonssystem. Årsak: et
selvbetjent «bli superadmin»-endepunkt ville vært selve
sikkerhetshullet det er ment å ikke være. Med flagget satt får brukeren en
NY autorisasjonssti (`get_current_user_or_superadmin`, parallell til
`get_authorized_org` — sjekker `is_super_admin` FØR det vanlige
org-medlemskaps-kravet) som lar dem kalle de samme rolle-/medlemskaps-
endepunktene på ENHVER organisasjon, ikke bare de de selv er medlem av.
**Bevisst avgrenset:** superadmin-stien dekker KUN medlemskap/rolle-
styring i denne runden (nøyaktig det brukeren spurte om — «sette hvem som
helst som eiere av hvilken som helst organisasjon»), ikke generell
skriveadgang til turnering-/spiller-data i andres organisasjoner. En
bredere «support/drift kan se alt»-rolle er en egen, senere beslutning om
den blir etterspurt.
**Beslutning E — Fjernet/frasigende eiers roster-/spillerdata forblir
URØRT.** Bekreftet av bruker 2026-07-19. Kun `organization_membership`-raden
endres/fjernes ved rollestyring eller fjerning — `team_roster`/`player`-rader
(deltakelse-historikk) røres aldri av disse handlingene. Organisasjons-
STYRING og DELTAKELSE er allerede modellert som separate ting, og forblir
det.
**Status: ✅ BYGGET OG LIVE 2026-07-19**, samme runde som ADR-021 (samme
migrasjon `012`). Se CLAUDE.md-status for full byggerunde.
---
## ADR-023: Brukerroller — kaptein som reell autorisasjon, deltaker-avgrenset scoring
Reist 2026-07-19, direkte oppfølging av det lenge åpne «Brukerroller»-punktet
i FEATURE_BACKLOG.md (der siden prosjektets start beskrevet som turnerings-
admin/lagkaptein/spiller/tilskuer, men aldri fullt ut avgjort). Fire
delspørsmål, alle avklart eksplisitt med bruker før bygging.
**Beslutning A — Kaptein gir eksklusive rettigheter til å sette opp/låse
laget, ikke lenger «hvem som helst rostret».** `app/team_authz.py` sin
`user_is_team_captain` (erstatter `user_may_act_for_team`) krever
`team_roster.is_captain = true` for brukeren (eller org-eier/admin, uendret
fra 2026-07-18-runden) — brukt av `matches.py` sin `add_participant`/
`remove_participant`/`lock_lineup`. Ny feilkode `NOT_TEAM_CAPTAIN` (403,
erstatter `NOT_ROSTERED_ON_TEAM` for disse tre stedene).
**Bevisst unntak, funnet ved å faktisk sjekke ekte produksjonsdata FØR
utrulling, ikke antatt:** har et lag INGEN utpekt kaptein i det hele tatt,
godtas enhver rostret spiller i stedet — uten dette ville en kaptein-only-
regel umiddelbart LÅST ute et helt lag fra å sette opp seg selv. Sjekket mot
ekte `teecup_db`: laget «De Unge» i «De Gamle er Eldst» har i dag 0 av 2
roster-rader merket kaptein — dette er altså ikke et hypotetisk
kantscenario, det ville rammet en reell, allerede opprettet turnering med
en gang. Har laget FØRST fått en kaptein, gjelder utelukkende den.
**Beslutning B — «Kun én kaptein per lag» håndheves nå.** `PATCH .../
roster/{id}` og `POST .../roster` (`app/routers/tournaments.py`) fjerner
automatisk kapteinmerket fra enhver annen roster-rad på SAMME lag når en ny
kaptein settes, i samme transaksjon. Nødvendig konsekvens av Beslutning A —
uten dette ville det vært uklart hvem som faktisk har myndighet hvis flere
er merket. Sjekket mot ekte data: ingen eksisterende lag hadde flere
kapteiner (kun opprydningsbehovet «0 kapteiner» fantes reelt), så ingen
data-migrering var nødvendig.
**Beslutning C — Score-føring/-korrigering begrenses til matchens faktiske
deltakere.** `app/team_authz.py` sin nye `user_is_match_participant` krever
en `match_participant`-rad for brukeren i AKKURAT den matchen (valgfritt
begrenset til én side via `team_side` — brukt for `stroke`-modus sin
side-spesifikke innsending, `None`/hvilken som helst side for `hole_result`-
modus siden begge sider kan rapportere). Erstatter den brede
«rostret på laget»-sjekken i `app/routers/scoring.py` sin
`submit_hole_score`/`submit_hole_result`. UAVHENGIG av kapteinmerket — å
være kaptein gir ikke i seg selv rett til å føre score for en match man
selv ikke spiller; org-eier/admin har samme unntak som før. Ny feilkode
`NOT_MATCH_PARTICIPANT` (403, erstatter `NOT_ROSTERED_ON_TEAM` her).
**Beslutning D — «Tilskuer»-rollen utsettes bevisst.** Ingen kode denne
runden. Offentlig/deltaker-lesetilgang finnes allerede
(`tournament.visibility` + `get_current_user_optional`, ADR-018) — et
formelt tilskuer-begrep bør defineres sammen med det ennå ubesluttede
synlighetsspørsmålet for «Banter Board»-feeden (FEATURE_BACKLOG), ikke
isolert her, for å unngå å bygge to overlappende synlighetsmodeller.
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Ingen migrasjon (ren
autorisasjonslogikk, ingen skjemaendring). Se CLAUDE.md-status for
scratch-verifisering og utrulling.
---
## ADR-024: Walkover/konsesjon
Reist 2026-07-19, direkte oppfølging av det tidligere åpne punktet i
FEATURE_BACKLOG.md: en side som aldri stiller nok spillere får ALDRI
beregnet handicap, og matchen kan derfor ALDRI avgjøres — den henger uendelig.
Ventet tidligere på Brukerroller (ADR-023), som nå er avgjort. Fire
delspørsmål, alle avklart eksplisitt med bruker før bygging.
**Beslutning A — Kun den TAPENDE siden (eller org-eier/admin) kan erklære,
til fordel for motstanderen.** Speiler ekte golf-etikette: du gir bort DITT
EGET tap, du krever ikke seier på motstanderens vegne. Bruker
`app/team_authz.py` sin eksisterende `user_is_team_captain` (ADR-023) på det
KONSEDERENDE laget spesifikt — ingen ny autorisasjonsfunksjon nødvendig. Rene
no-show-tilfeller (den tapende siden har ingen innlogget/rostret spiller i
det hele tatt) dekkes av org-admin-fallbacken som allerede finnes i
`user_is_team_captain`.
**Beslutning B — Ensidig erklæring, ingen bekreftelse fra motparten.**
Samme tillitsnivå som all annen scoring i appen (allerede en upsert uten
godkjenning). Ingen ny "venter på bekreftelse"-tilstand eller
varslingsmekanisme bygget.
**Beslutning C — Både match- og turnering-nivå, i samme runde.**
`POST /orgs/{id}/matches/{id}/concede` (`app/routers/scoring.py`) for én
match. `POST /orgs/{id}/tournaments/{id}/concede`
(`app/routers/tournaments.py`) for å gi opp ALLE ikke-avgjorte matcher laget
har i turneringen, i én operasjon — v1 er låst til nøyaktig to lag
(ADR-011), så det finnes bare ÉN motstander uansett hvor mange
sesjoner/matcher turneringen har, og turnering-nivå-konsesjon er derfor
bare match-nivå-logikken (`apply_concession`) kjørt per ikke-avgjorte match.
Hull-nivå konsesjon er bevisst IKKE en egen mekanisme — `hole_result`-modus
dekker det allerede (rapporter bare hvem som vant hullet).
**Beslutning D — Kan erklæres uansett hvor mange hull som allerede er
registrert.** I match-play teller kun seier/tap/delt for poeng, ikke
marginen — det er derfor ingen reell forskjell, poengmessig, på "ga opp
etter 5 hull" og "ga opp før start". `apply_concession` overstyrer status/
poeng direkte, uavhengig av `_compute_hole_results`; allerede registrerte
hull forblir urørt i `hole_score`/`match_hole_result` (kun matchens
avgjørelses-felt endres), så scorekortet fortsatt viser nøyaktig hva som ble
spilt før konsesjonen.
**Gjenbruk, ikke duplisering:** `apply_concession` skriver til nøyaktig de
samme fire kolonnene (`status_text`/`points_side_a/b`/`leading_side`) som
`recompute_and_cache_match_state` — samme "matchen er avgjort"-signal
(`points_side_a IS NOT NULL`) som allerede stopper videre hull-innsending
(`submit_hole_score`/`submit_hole_result`), ingen ny låsemekanisme
nødvendig. Status-teksten "Walkover (A)"/"Walkover (B)" følger samme
"(bokstav)"-visningskonvensjon som `compute_match_state.describe()` sine
egne strenger (f.eks. "9&7 (A)").
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Ingen migrasjon (ren applogikk,
ingen skjemaendring). Se CLAUDE.md-status for scratch-verifisering og
utrulling.
---
## ADR-025: Kommunikasjon — lag-chat + offentlig runde-feed
Reist 2026-07-19, rett etter turnering-status-runden. Dekker to ganske ulike
ting under samme paraply: lag-intern chat («det hemmelige rommet») og en
offentlig runde-feed («Banter Board»). Fire beslutninger avklart eksplisitt
med bruker (AskUserQuestion) før bygging.
**Beslutning A — Begge bygges i samme runde**, ikke lag-chat først som egen
runde. Deler mye infrastruktur (meldingsmodell, sanntid-levering), så
designes/bygges sammen selv om feeden isolert sett hadde flere åpne
spørsmål.
**Beslutning B — Sanntid via WebSockets**, ikke polling. Løser samtidig det
tidligere åpne "sanntid vs. polling"-spørsmålet i FEATURE_BACKLOG.md
generelt (samme mekanisme kan gjenbrukes for leaderboard/andre skjermer
senere, selv om denne runden kun kobler den til meldinger).
**Viktig driftsbegrensning, videreført fra et allerede kjent åpent
arkitekturspørsmål (se «Åpne spørsmål» punkt 2 i dette dokumentet):**
tilkoblingsregisteret er en in-memory Python-struktur i `teecup_api`-
prosessen. Med kun én container/prosess (dagens oppsett) er dette trygt;
skaleres API-et til flere prosesser/containere senere, må broadcast flyttes
til noe delt (Redis pub/sub e.l.) — samme klasse begrensning som den
allerede aksepterte in-memory-cachen.
**Beslutning C — Lag-chat er EKTE privat: kun rostrede spillere på laget,
INGEN unntak for org-eier/admin.** Et bevisst avvik fra appens ellers
gjennomgående mønster (kaptein-/deltaker-sjekkene i `team_authz.py` har
alltid en org-admin-fallback, se ADR-023). Ny, egen autorisasjonsfunksjon
`user_is_rostered_on_team` (uten fallback) brukt KUN her — de eksisterende
funksjonene med org-admin-unntak røres ikke, siden de fortsatt er riktige
for sine egne bruksområder (oppsett/scoring, der en organisator uten
dette ville stått fast tidlig i en turnering).
**Beslutning D — Bilder med fra start**, ikke utsatt. Gjenbruker
`app/storage.py` sin allerede byggede og bevist MinIO+AVIF-konverterings-
pipeline (samme mønster som turnering-hero-bilder/sponsorlogoer, ADR-018) —
ingen ny opplastingsinfrastruktur trengs, kun en ny `prefix="messages"`.
**Datamodell:** delt `message`-tabell (migrasjon `013_messaging.sql`) med
en `scope`-diskriminator (`team`/`tournament_feed`) i stedet for to separate
tabeller — meldingsformen (tekst + valgfritt bilde) er identisk, kun
synlighet/autorisasjon skiller dem. `author_display_name` FRYSES ved
skrivetidspunkt (samme prinsipp som `handicap_index_snapshot`, ADR-007) —
utledet server-side fra avsenderens `player`-rad i org-en (hvis den finnes),
ellers e-postens lokaldel som fallback (en org-ansatt uten egen spillerprofil
kan fortsatt poste i den offentlige feeden).
**Offentlig feed — synlighet og posterett, to atskilte spørsmål:**
LESING gjenbruker `registration.py` sitt eksisterende trenivå-mønster
(`tournament.visibility` + `get_current_user_optional` + deltaker-sjekk,
ADR-018) uendret — ingen ny synlighetsmekanisme. POSTING er derimot
STRENGERE enn lesing: en anonym leser på en `public`-synlig turnering kan
lese feeden, men må logge inn OG være enten org-medlem eller faktisk
deltaker/registrert i NØYAKTIG denne turneringen for å få poste — hindrer at
en helt urelatert innlogget bruker (konto et helt annet sted i systemet) kan
poste på en fremmed offentlig turnering-side bare fordi den er synlig.
Moderering: forfatteren selv, ELLER org-eier/admin, kan slette et
feed-innlegg. Lag-chat har INGEN moderering utover forfatteren selv (rommet
er privat, org-admin har uansett ikke lesetilgang og kan derfor ikke
moderere det).
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Se CLAUDE.md-status for scratch-
verifisering og utrulling, inkl. en egen Caddy-rute (`/ws/*`) for
WebSocket-trafikk direkte til `teecup_api` (Next.js sin `rewrites()`
proxyer ikke WebSocket-oppgraderinger pålitelig — samme klasse
infrastrukturvalg som media-ruten i MinIO-runden, ADR-018).
---
## ADR-026: Tilskuer-rolle — offentlig leaderboard, matcher, scorekort
Reist 2026-07-19, rett etter Kommunikasjon (ADR-025), som gjorde det mulig å
definere "tilskuer" skikkelig (samme begrunnelse ble notert allerede i
FEATURE_BACKLOG.md fra starten av: bør avgjøres sammen med feed-synligheten,
som nå finnes).
**Kjernebeslutning: ingen ny rolle, ingen ny mekanisme.** "Tilskuer" er
IKKE en egen kontotype eller databasetabell — det er ganske enkelt: enhver
som kan SE en turnering (per `tournament.visibility`, ADR-018) kan nå også
følge den LIVE, ikke bare lese info-siden og programtidene. Samme
trenivå-visibility + `get_current_user_optional`-mønster som all annen
offentlig lesing, gjenbrukt helt uendret.
**Hva var det egentlige hullet:** `GET /orgs/.../leaderboard`,
`GET /orgs/.../sessions/{id}/matches` og `GET /orgs/.../matches/{id}/
scorecard` fantes allerede (bygget for organisatorer/spillere), men var
KUN tilgjengelige med org-medlemskap — en spectator med kun `/t/[id]`-
lenken (eller anonym på en `public`-synlig turnering) kunne aldri se dem.
Løst ved å ekstrahere den delte kjernelogikken til gjenbrukbare funksjoner
(`fetch_leaderboard` i tournaments.py, `fetch_matches` i matches.py,
`fetch_scorecard` i scoring.py — samme "gjort delt for gjenbruk"-mønster
som `recompute_and_cache_match_state`/`apply_concession` tidligere), og la
tre nye offentlige endepunkter i `registration.py` kalle dem, etter egen
visibility-sjekk.
**Omfang, valgt av bruker utover anbefalingen:** BÅDE leaderboard+
matchliste OG fullt hull-for-hull-scorekort per match, i samme runde (ikke
kun leaderboard+matchliste som opprinnelig anbefalt).
**`own_team_ids()` (blind_draw.py) gjort null-sikker:** tar nå
`user_id: str | None` — en anonym/ikke-tilknyttet leser har per definisjon
ingen egne lag, og skal derfor (korrekt, ikke en feil) kun se AVSLØRTE
matcher, akkurat som en tilfeldig org-medlem uten roster. Ingen ny
synlighetslogikk, bare eksisterende logikk gjort tilgjengelig for en
`None`-bruker.
**To NYE sikkerhetssjekker lagt til, funnet under design, ikke i etterkant:**
1. `session_id`/`match_id` i URL-en må eksplisitt verifiseres å høre til
NØYAKTIG `tournament_id` i samme URL — `org_connection()` setter kun
TENANT-grensen (RLS), ikke at stiens id-er faktisk henger sammen. Uten
denne sjekken kunne noen med tilgang til én offentlig turnering i en
organisasjon lest en HVILKEN SOM HELST økt/match i SAMME organisasjon
(inkl. en helt privat en) ved å gjette/prøve seg frem på id-er — nøyaktig
samme klasse hull som ADR-018 Beslutning B advarte om generelt
("RLS beskytter kun tenant-grenser, ikke innholds-synlighet").
2. Det offentlige scorekort-endepunktet krever eksplisitt at BEGGE lag har
låst oppstillingen for økten (blind draw, ADR-013) — leaderboard/
matchliste arver reveal-skjuling automatisk via `own_team_ids()`, men
scorekortet har ingen tilsvarende innebygd sjekk (kan i prinsippet
inneholde registrerte hull før reveal, selv om det ikke er normal flyt).
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Ingen migrasjon (kun nye
endepunkter + refaktorering av eksisterende spørringer til delte
funksjoner). Se CLAUDE.md-status for scratch-verifisering og utrulling.
---
## ADR-027: Sanntid for "Følg live"-siden
Reist 2026-07-19, rett etter ADR-026 (tilskuer-rolle) — brukeren valgte min
egen anbefaling: `/t/[id]/live` krevde omlasting for å se nye resultater,
litt selvmotsigende for en side som heter "Følg live". Løser samtidig det
tidligere åpne "koble leaderboard til sanntid"-punktet i FEATURE_BACKLOG.md.
**Gjenbruker WebSocket-mekanismen fra ADR-025 (meldinger), men "noe endret
seg, hent på nytt"-signal i stedet for å sende selve dataene** — å bygge/
sende hele leaderboard+matchliste+scorekort-formen over WS ville duplisert
betydelig beregningslogikk (leaderboardets projeksjons-regnestykke, blind
draw-filtrering osv.). Klienten reagerer på signalet ved å kalle de samme
REST-endepunktene på nytt (ADR-026), akkurat som ved førstegangslasting —
kun for det som faktisk er synlig/åpent på skjermen (leaderboard alltid,
en økts matcher kun hvis økten er utvidet, et scorekort kun hvis det er
åpnet).
**Ny, RUTEFRI modul `app/realtime.py`** for selve tilkoblingsregisteret og
kringkastingsfunksjonen — verken i `messaging.py`, `scoring.py` eller
`tournaments.py`. Årsak: `registration.py` (som eier selve
`/public/...`-endepunktene) importerer allerede fra `scoring.py`/
`tournaments.py`/`matches.py`, og `messaging.py` importerer fra
`registration.py` — å plassere kringkastingsfunksjonen i noen av routerne
ville skapt en sirkulær import. `app/realtime.py` ligger bevisst BAK alle
routere i importgrafen.
**Kringkastingen er lagt INN I de delte funksjonene selv**
(`recompute_and_cache_match_state`, `apply_concession`), ikke som noe
kallerne må huske å gjøre etterpå — samme selv-ansvarlig-mønster som andre
sentrale funksjoner i prosjektet. `apply_concession` fikk en ny påkrevd
`tournament_id`-parameter kun for dette formålet.
**Samme kjente in-memory-per-prosess-begrensning som ADR-025** (se «Åpne
spørsmål» under) — trygt med dagens ene `teecup_api`-container.
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Ingen migrasjon, ingen ny
Caddy-rute (gjenbruker `/ws/*`-ruten fra ADR-025 uendret). Se CLAUDE.md-
status for scratch-verifisering og utrulling.
---
## ADR-028: PWA — installasjon + offline scoreregistrering
Reist 2026-07-19, brukeren valgte å ta fatt på PWA (stod som "neste steg" i
CLAUDE.md siden ADR-006 vedtok prinsippet helt i starten av prosjektet — aldri
bygget). To beslutninger avklart eksplisitt med bruker (AskUserQuestion) før
bygging.
**Beslutning A — Full offline scoreregistrering i v1, ikke bare
installasjon.** Bruker valgte det mest ambisiøse alternativet: BÅDE
manifest/ikoner/service worker (installerbar app) OG at scorekort-skjermen
skal fungere uten nett — skriv til en lokal IndexedDB-kø, synk automatisk når
nettet er tilbake. Matcher ADR-006s opprinnelige formulering ordrett
("score må kunne registreres uten nett og synkes senere").
**Beslutning B — Omfang av selve offline-skrivingen: kun `hole-scores`/
`hole-results`, ingenting annet.** De to eksisterende scoreregistrerings-
endepunktene (ADR-012) er de eneste som køes. Bevisst UTENFOR omfang, ikke
glemt:
- Walkover/konsesjon (ADR-024) — sjeldnere handling, kan kreve nett.
- Chat/feed-posting (ADR-025) — bilder gjør en offline-kø vesentlig mer
komplisert (blob-lagring i IndexedDB), og er ikke "score"-handlingen
ADR-006 faktisk siktet til.
- Alle andre skrive-endepunkter (oppsett, roster, økter osv.) — forutsetter
nett i v1, uendret.
**Beslutning C — Køen lever i klientkoden (IndexedDB, `lib/offline-
queue.ts`), IKKE i service workeren, og bruker `window` sitt `online`-
event, IKKE Background Sync API.** To bevisste forenklinger:
1. Å gi umiddelbar, presis UI-tilbakemelding ("lagret lokalt, venter på
synk", pending-antall) er enklere og mer testbart fra selve
React-komponenten enn fra en service worker sin `fetch`-handler.
2. Background Sync API (som ville gitt synk selv om appen er lukket) støttes
IKKE av iOS Safari i det hele tatt — en stor andel av klubb-/
vennegjeng-brukerne er trolig på iPhone, og et rent
Background-Sync-avhengig design ville derfor vært brutt for dem. Et
`window.addEventListener("online", ...)`-mønster (pluss en manuell
"Synkroniser nå"-knapp i UI-et som reserve) fungerer overalt, på
bekostning av at synk krever at appen faktisk er åpen når nettet kommer
tilbake — akseptabelt for v1.
`submitStroke`/`submitHoleResult` (`components/session-scorecard.tsx`)
sjekker `navigator.onLine` FØRST (unngår en unødvendig ventetid på et
nettverkskall som uansett vil feile), og fanger ellers en EKTE nettverksfeil
i fetch-kallet separat fra et avvist HTTP-svar (`res.ok === false`, f.eks.
409 "matchen er avgjort") — kun den førstnevnte køordner, sistnevnte viser
fortsatt den vanlige feilteksten uendret. Et lokalt overlay
(`pendingStrokes`/`pendingResults`, ikke persistert i selve
`scorecard`-staten) viser køede verdier umiddelbart i UI-et, merket
"Lagret lokalt · venter på synk", inkludert i hull-navigasjonens
registrert-markering.
**Konfliktmodell: samme tillitsnivå som resten av appen, ingen ny
mekanisme.** Server-siden er allerede en upsert per hull (`ON CONFLICT ...
DO UPDATE`) — siste innsending vinner, ingen audit-trail (kjent, tidligere
dokumentert åpent spørsmål, se «Scoring-autorisasjon» i FEATURE_BACKLOG.md).
Køen sender i rekkefølge (aldri parallelt) for å respektere lokal
innsendingsrekkefølge ved flere endringer av samme hull offline. Et
definitivt HTTP-avvist forsøk ved synk (typisk: matchen ble avgjort på en
ANNEN enhet mens denne var offline) fjernes fra køen og vises som en
feilmelding — blir ALDRI hengende for alltid.
**Service worker-strategi: nettverk først, cache som fallback — bevisst
IKKE stale-while-revalidate.** `public/sw.js` cacher (a) side-navigasjon og
(b) GET-kall under `/orgs/*`. Begge prøver ekte nettverk FØRST og faller
kun tilbake til cache ved reell feil. En stale-while-revalidate-strategi ble
vurdert og avvist: scorekortet gjør et `refetchScorecard()`-kall RETT ETTER
hver innsending, og må da alltid få fersk data — en umiddelbart utdatert
cache ville vist feil matchstatus/hull-tall rett etter en vellykket
innsending. `/auth/*` og `/public/*` caches bevisst ikke — omfanget er
begrenset til akkurat det scoreregistrerings-flyten trenger.
**Kjent, akseptert begrensning (ikke løst i v1):** Cache Storage er nøklet
på URL, ikke på innlogget bruker — deler flere kontoer samme enhet/
nettleser, kan en offline-fallback teoretisk vise data cachet av en
TIDLIGERE innlogget bruker på samme enhet. Ingen cache-tømming ved
utlogging bygget. Samme tillitsnivå/trusselmodell som appen ellers opererer
med (tillitsbasert klubb-/vennegjeng-verktøy, jf. `team_authz.py` sin
begrunnelse i ADR-023/025).
**Ikoner: enkelt, midlertidig sett generert programmatisk (grønt
golf-flagg), ikke endelig design.** Bruker valgte å generere nå fremfor å
vente på ekte design, MED eksplisitt beskjed om at disse skal erstattes
senere — notert i FEATURE_BACKLOG.md. Erstattet samtidig den gamle
`public/apple-icon.png` (v0.app sin generiske plassholder-logo, ikke
TeeCup-merkevare i det hele tatt) med samme nye ikon, av konsistens.
**Ikke testet i ekte nettleser (viktig, ikke bare en formalitet):**
verifisert med typesjekket produksjonsbuild (samme `Dockerfile` som
deployes) og en kort container-boot med `curl` (manifest/service worker/
ikoner/offline.html svarer riktig), men INGEN faktisk browser-basert
offline-test (DevTools "Offline"-modus, "Legg til på hjemskjerm") er
gjennomført denne runden — ingen nettleserverktøy tilgjengelig i denne
økten. Anbefales sterkt at brukeren selv tester scorekort-siden med Chrome
DevTools sin Offline-bryter før tillit legges til flyten i skarp bruk.
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Bruker bekreftet eksplisitt, ingen
migrasjon, kun `teecup_frontend` (og en ren, uendret gjenoppbygging av
`teecup_api` som en bivirkning av `docker compose up --build` sin
avhengighets-oppløsning — ingen backend-kode rørt denne runden). Verifisert:
`/health`/`dashboard` → 200, `/manifest.webmanifest`/`sw.js`/ikoner alle
200 over ekte https, `teeoff.no` upåvirket. Se CLAUDE.md-status for full
byggerunde.
**Gjenstående, IKKE en del av "ferdig"-vurderingen over:** faktisk
nettleser-basert offline-test (Chrome DevTools Offline-modus, ekte "Legg
til på hjemskjerm") er fortsatt ikke gjort — ingen nettleserverktøy
tilgjengelig i byggeøkten. Brukeren har bedt om at dette noteres eksplisitt
for oppfølging, se FEATURE_BACKLOG.md sin PWA-tabell og CLAUDE.md sin
"Neste steg"-liste.
---
## ADR-029: Kjønn hører til tee-RATINGEN, ikke selve utslaget
Reist av brukeren 2026-07-19, som del av en runde med fire rapporterte
UX-/korrekthetshull fra faktisk testing av blind draw og scorekort. Startet
som en antatt frontend-fiks ("tee-valget bør følge spillerens kjønn
automatisk"), men brukeren presiserte at premisset mitt var feil: en
golfbane har IKKE fysisk kjønnsdelte utslag -- begge kjønn kan som regel
spille fra ethvert utslag. Det eneste som faktisk varierer per kjønn er om
klubben har VALGT å slope (rate) et gitt utslag for det respektive kjønnet
(noen klubber sloper bevisst ikke det lengste utslaget for damer).
**Bekreftet problem, ikke antatt:** `tee.gender` (migrasjon 001) la kjønn på
selve utslaget, ikke ratingen. Sjekket mot ekte, importert produksjonsdata
(Tjøme Golfklubb, ADR-019): alle fire utslagene lå som RENE navnepar --
"32"/m + "32"/f, "44"/m + "44"/f, osv. -- to separate `tee`-rader for
akkurat samme fysiske utslag. Blind draw-skjermen viste dette som "velg
Dame- eller Herre-tee", som om det var to ulike steder å slå fra.
**Beslutning A -- `gender` flyttes fra `tee` til `tee_rating`.** Ett fysisk
utslag (`tee`, identifisert kun ved navn) kan ha 0, 1 eller 2
kjønnsspesifikke ratinger. `tee_rating` sin unikhet endres fra
`(tee_id, scope)` til `(tee_id, scope, gender)`. `gender``tee_rating`
er `NOT NULL CHECK IN ('m','f')` -- 'x' (gyldig på PLAYER-nivå) gir ikke
mening for en WHS-rating, som alltid er for ett bestemt kjønn.
**Beslutning B -- tee-valget blir helt automatisk, ingen manuell
kjønnsvelger (bekreftet med bruker, valgte det anbefalte alternativet).**
Organisator/kaptein velger KUN fysisk utslag i blind draw
(`session-blind-draw.tsx`, ingen "H"/"D"-suffiks lenger). Riktig
kjønnsspesifikk rating løses AUTOMATISK server-side fra spillerens
registrerte `player.gender` -- `app/handicap.py` sin
`compute_and_store_side_handicaps` joiner nå `tee_rating`
`(tee_id, scope='full_18', gender = player.gender)` i tillegg til den
tidligere ADR-028-fiksen (alltid full_18, uavhengig av hole_config).
**Beslutning C -- manglende rating/kjønn feiler tydelig FØR innsetting,
ingen stille fallback (bekreftet med bruker, valgte det anbefalte
alternativet).** `matches.py` sin `add_participant` validerer, når
`config.use_handicap` er sann: (1) spilleren har et registrert kjønn --
avvist med `VALIDATION_FAILED` hvis ikke ("sett kjønn på spilleren
først"), (2) valgt utslag har faktisk en `tee_rating` for NØYAKTIG det
kjønnet -- avvist med `VALIDATION_FAILED` hvis ikke ("dette utslaget har
ingen dame/herre-rating -- velg et annet utslag"). Samme "fail loudly"-
prinsipp som ADR-019 sin importvalidering og den eksisterende
handicap_index-sjekken (2026-07-18) rett ved siden av. `_remap_course`
(bane-bytte midt i en økt) fikk samme presisering: matcher fortsatt på
tee-navn, men sjekker nå at den NYE tee-en har en rating for hver berørte
spillers kjønn, ikke bare at navnet finnes.
**Konsekvens for import/opprettelse:** ADR-019 sin teeoff-import
(`import_official_course`) lager nå ÉN `tee`-rad per fysisk teeoff-utslag
(tidligere: to, én per kjønn), med inntil to `tee_rating`-rader under.
Manuell tee-opprettelse (`POST .../courses/{id}/tees`) redesignet
tilsvarende: `TeeCreate.ratings` er nå en liste (1-2 elementer, distinkte
kjønn) i stedet for ett flatt kjønn+rating-sett -- ingen reell
bruker-/produksjonsdata brukte dette endepunktet fra før (kun Tjøme, som
er offisielt importert), så ingen bakoverkompatibilitet var nødvendig.
**Migrasjon `014_tee_gender_to_rating.sql`:** flytter `gender` til
`tee_rating`, slår deretter sammen eksisterende kjønns-par-tee-rader til
én fysisk tee-rad per (bane, navn) -- velger laveste id som "beholder",
flytter alle `tee_rating`- og `match_participant.tee_id`-referanser dit,
sletter duplikatene. Bekreftet TRYGT å kjøre mot ekte data FØR skriving
(read-only sjekk): alle 8 eksisterende tee-rader (Tjøme) er rene m/f-par,
ingen `gender='x'`-rader, ingen gruppe med mer enn to rader.
**Scratch-verifisert grundig, flere runder:** (1) selve
fletting-migrasjonen kjørt mot syntetisk data som gjenskaper Tjøme-
mønsteret nøyaktig, inkl. én `match_participant`-rad som pekte til
DUPLIKATEN (ikke beholderen) -- bekreftet korrekt reparert til beholderens
id etterpå, og et utslag med KUN én rating (ingen duplikat) forblir
urørt. (2) Manuell tee-opprettelse: to ratinger på ett utslag, kun én
rating (simulerer "klubben har ikke slopet dette kjønnet"), duplikat
kjønn i samme innsending avvist. (3) `GET tees` viser riktig sammenslått
struktur (2 fysiske utslag, ikke 3 rader). (4) `add_participant`: kvinne
+ utslag med dame-rating lykkes; mann + utslag som KUN har dame-rating
avvist tydelig; spiller uten registrert kjønn avvist tydelig; en full
kjønnsblandet singel-match scoret korrekt end-to-end. (5) Offisiell
import kjørt mot EKTE `teeoff_api` (Borregaard Golfklubb) -- bekreftet
4 fysiske utslag importert med begge kjønnsratinger hver, ikke 8 doble
rader. (6) `_remap_course`: bane-bytte til en bane UTEN matchende
kjønnsrating avvist tydelig, bane-bytte til en bane MED matchende
rating lykket.
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Migrasjon 014 kjørt mot ekte
`teecup_db`, bruker bekreftet eksplisitt: Tjømes 8 tee-rader slått sammen
til 4 fysiske utslag, alle 8 `tee_rating`-rader fikk riktig `gender`,
`match_participant`-referansene forble gyldige (0 brutte fremmednøkler
etter migrasjonen, bekreftet med en direkte spørring). Begge containere
(`teecup_api`, `teecup_frontend`) bygget og redeployet, `/health`/
`/dashboard` → 200, `teeoff.no` upåvirket.
---
## ADR-030: Dashbord-datoer utledes fra øktene, ikke et separat felt
Reist av brukeren 2026-07-19/20, direkte oppfølging av det tidligere
dokumenterte UI-hullet ("Dashboard-turneringskortet viser 'Ingen datoer
satt'", notert 2026-07-19). Brukeren viste et faktisk skjermbilde: en
turnering med en økt tydelig planlagt til "lør. 11. juli, 10:50" på
Program-fanen, mens dashbord-kortet fortsatt viste "Ingen datoer satt".
**Root cause, bekreftet ved kodegjennomgang (samme konklusjon som forrige
runde, nå faktisk fikset):** dashbord-kortet leser `tournament.start_date`/
`end_date` — et eget felt på selve turneringen (ADR-015) — som INGEN UI
noensinne har hatt en vei til å sette. Fullstendig atskilt fra
`session.scheduled_at` (som Program-fanen korrekt viser).
**Beslutning: utled datospennet fra øktenes `scheduled_at` i
`list_tournaments`, ikke bygg et manuelt datofelt-skjema.** To kilder til
sannhet (et manuelt "turnering-dato"-felt OG faktiske økt-tidspunkter) ville
uunngåelig kommet ut av synk — turneringens reelle datoer ER ganske enkelt
når rundene faktisk er satt til å spilles. `GET /orgs/{id}/tournaments`
gjør nå `COALESCE(t.start_date, MIN(økt.scheduled_at))` /
`COALESCE(t.end_date, MAX(økt.scheduled_at))` — et eksplisitt satt
`start_date`/`end_date` (om det noensinne blir gitt en skrivevei senere)
vinner fortsatt over det utledede spennet, men i praksis er det alltid det
utledede spennet som vises i dag. En turnering uten noen tidsplanlagte
økter viser fortsatt riktig "Ingen datoer satt" (ikke en feil).
**Konsekvens:** kun `list_tournaments` sin spørring endret (ikke
`_TOURNAMENT_COLUMNS`, som fortsatt brukes uendret av opprett-/
PATCH-endepunktene sine `RETURNING`-klausuler — de trenger ikke
sesjons-utledningen rett etter en skriveoperasjon). Ingen migrasjon.
**Scratch-verifisert:** turnering uten økter → `null`/`null` (ikke feil);
turnering med to økter (11./12. juli) → riktig utledet spenn; en turnering
med et EKSPLISITT satt `start_date`/`end_date` ved opprettelse beholder
fortsatt sin egen verdi selv med økter senere lagt til (bekrefter
COALESCE-prioriteringen).
**Status: ✅ BYGGET OG LIVE 2026-07-20.** Bruker bekreftet eksplisitt, ingen
migrasjon. Verifisert mot ekte data: "De Gamle er Eldst" viser nå korrekt
11. juli 2026 (utledet fra dens økt) i stedet for "Ingen datoer satt".
---
## ADR-031: Personlig landingsside for enhver registrert bruker + personlig profil
Reist av brukeren 2026-07-20, eksplisitt som en "tenk igjennom og foreslå"-
instruks, deretter et klart "gjør det" med et utvidet omfang (personlig
profil-CRUD: profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP,
hjemmeklubb).
**Bekreftet, reelt hull:** `app/page.tsx` sendte enhver innlogget bruker til
`/dashboard`, som viste "opprett organisasjon" så snart brukeren ikke var
org-medlem — også for en bruker som KUN er spiller (koblet via
`player.user_id`, ADR-017 B), aldri organisator.
**Beslutning A — ETT samlet dashboard, ikke to atskilte ruter.** `/dashboard`
viser nå en ny "Mine runder"-seksjon øverst (turneringer brukeren er ROSTRET
i, på tvers av organisasjoner) når den finnes, med organisasjonsseksjonen
uendret under. En bruker som er BÅDE organisator og spiller ser begge deler
— ingen tvungen valg mellom to identiteter.
**Beslutning B — personlig profil er ETT sett PER KONTO (`app_user`), atskilt
fra org-scopede `player`-rader.** Ny migrasjon `015_user_profile.sql`:
`app_user` får `first_name`/`last_name`/`birth_date`/`gender`/
`handicap_index`/`home_club`/`avatar_key`. Bevisst IKKE forsøkt slått sammen
med `player`-radene (som fortsatt er per-organisasjon, eid av organisator,
brukt til roster/handicap-snapshot) — en person kan ha flere `player`-rader
i ulike klubber (ulikt hjemmeklubb-medlemsnummer, potensielt ulik registrert
HCP per klubb i den virkelige golfverdenen), mens KONTOENS egen profil er
brukerens EGEN, selvstyrte fremstilling av seg selv — to bevisst atskilte
konsepter, ikke ett duplisert.
**Konsekvens:** `PATCH /auth/profile` (vanlig `exclude_unset`-PATCH-mønster
— et felt sendt eksplisitt som `null` sletter det, et utelatt felt endres
ikke), `POST`/`DELETE /auth/profile/avatar` (samme ekte multipart→AVIF-
opplastingsmønster som `tournament.hero_image_key`, ADR-018 MinIO-runden —
se `app/storage.py`). Ny seksjon i `/account` (`account-settings.tsx`).
**Beslutning C — "Mine runder" krever et NYTT tverr-org-oppslag, samme
mønster som fire tidligere bygde bruksområder.** `player` er RLS-beskyttet
per organisasjon; å finne "hvilke org-er har jeg en spiller-rad i" krever
samme smale `SECURITY DEFINER`-bro som `public_tournament_org()` (007)/
`link_player_by_email()` (008)/`public_org_by_slug()` (009)/
`public_tournament_by_code()` (011) — ny `player_organizations_for_user()`
(migrasjon 015), eksponerer KUN en uuid-liste. `/auth/me` utvidet med
`my_tournaments` (samme N+1-over-`org_connection()`-mønster som allerede
brukes for `organizations`).
**Beslutning D — ny sikkerhetsutvidelse funnet UNDER design, ikke antatt på
forhånd: en faktisk deltaker skal aldri stenges ute av sin EGEN turnering,
uansett synlighetsnivå.** Under bygging av "Mine runder" ble det klart at
`check_visibility()` (ADR-018 Beslutning C) kun ga deltaker-tilgang for
`visibility='participants'` — IKKE for `'org'` (som er DEFAULT for enhver
NY turnering). En ren spiller (rostret, men uten organisasjonsmedlemskap)
ville dermed vært stengt ute fra sin egen, helt vanlige turnering (default
`'org'`-synlighet) — nøyaktig den brukergruppen "Mine runder" er bygget
for. Utvidet: deltaker-sjekken gjelder nå for BEGGE ikke-offentlige tiere,
ikke bare `'participants'`. Begrunnelse: `visibility` styrer eksponering
mot UTENFORSTÅENDE, aldri mot folk som faktisk spiller i turneringen — det
finnes intet scenario der en organisator ønsker å skjule en turnering for
sin EGEN spiller. Verifisert presist: en faktisk deltaker (ikke org-medlem)
FÅR nå tilgang til en `'org'`-synlig turnering, mens en helt ubeslektet
FREMMED (innlogget, men ikke deltaker) og en ANONYM leser fortsatt begge
avvises som før (403 `NOT_VISIBLE`) — ren utvidelse, ingen innstramming.
**Bevisst UTENFOR omfang denne runden, kjent gjenstående begrensning:**
"Mine runder" lenker til den offentlige turnering-siden (`/t/{id}`), IKKE
til lagets private chat eller det organisator-vendte scorekortet — disse
krever fortsatt `get_authorized_org` (ekte organisasjonsmedlemskap), en
strengere sperre enn deltaker-status alene, brukt av dusinvis av
endepunkter på tvers av hele appen. Å utvide DENNE sperren trygt til også å
godta "faktisk deltaker" er en egen, større og mer risikofylt endring
(påvirker autorisasjonsarkitekturen bredt) — bevisst IKKE gjort i denne
runden, notert i FEATURE_BACKLOG.md som naturlig neste steg.
**Scratch-verifisert, 15 sjekker:** profil-CRUD (sett alle felt, delvis
PATCH lar andre felt stå urørt, eksplisitt `null` sletter et felt, tomt
PATCH avvist, avatar lastet opp med ekte AVIF-URL, avatar slettet, ugyldig
filtype avvist), "Mine runder" for en EKTE ren spiller (null organisasjons-
medlemskap, men `my_tournaments` viser riktig turnering+lag), OG den
kritiske sikkerhetssjekken: samme rene spiller FÅR nå se sin `'org'`-
synlige turnering, mens en fremmed innlogget bruker og en anonym begge
fortsatt avvises. Ekte typesjekket produksjonsbuild kjørt og bekreftet.
**Status: ✅ BYGGET OG LIVE 2026-07-20.** Migrasjon 015 kjørt mot ekte
`teecup_db`, bruker bekreftet eksplisitt. Begge containere redeployet,
`/health`/`/dashboard`/`/account` → 200, `teeoff.no` upåvirket. Se
CLAUDE.md-status for full byggerunde.
---
## ADR-032: Verifisert e-postbytte + mobil på personlig profil
Reist av brukeren 2026-07-20, samme dag og rett etter ADR-031: "identifikatoren"
(e-post) manglet i den nye personlige profilen, og mobil (med landsnummer)
burde være en opsjon.
**Beslutning A — mobil er et rent, enkelt tillegg til `ProfileUpdate`
(samme PATCH som resten av profilen), splittet i to felt.**
`mobile_country_code` (f.eks. `"+47"`) og `mobile_number` er separate
kolonner på `app_user` (migrasjon `016_profile_contact.sql`) — ikke én
sammensatt streng — slik at frontend kan tilby en egen landsnummer-
velger uten å måtte parse en fritekststreng i etterkant.
**Beslutning B — e-post er IKKE en del av den vanlige profil-PATCH-en, og
kan det aldri bli.** E-post er innloggings-identifikatoren (magic-link-
mål) — en enkel PATCH (som de andre feltene) ville latt en skrivefeil
ELLER en kapret sesjon stjele kontoen for godt, ingen verifisering av at
den NYE adressen faktisk eies av noen. Løst med et eget, to-stegs
bekreftelsesløp, samme `token_hash`+`expires_at`+`consumed_at`-mønster
som `magic_link_token` (004), gjenbrukt for et nytt formål: ny tabell
`email_change_token` (bruker_id, ny e-post, token-hash, utløp).
`POST /auth/profile/email` (krever gyldig sesjon — du må bevise at du
ER kontoen i dag) genererer tokenet og sender en bekreftelseslenke til
DEN NYE adressen (ikke den gamle — beviser eierskap av MÅLET, ikke bare
at avsenderen fortsatt er innlogget). `POST /auth/profile/email/confirm`
(ingen sesjon påkrevd — samme mønster som selve magic-link-verifiseringen,
siden lenken kan åpnes på en annen enhet/nettleser enn den som ba om
byttet) forbruker tokenet atomisk og gjennomfører selve byttet. E-posten
endres IKKE før lenken faktisk åpnes.
**Konsekvens:** duplikat-sjekk (er den ønskede adressen allerede en annen
kontos?) gjøres TO ganger — én gang ved forespørsel (rask
tilbakemelding), én gang igjen rett før selve `UPDATE`-en ved bekreftelse
(kan ha blitt tatt av noen andre i mellomtiden) — pluss den eksisterende
unike indeksen (`app_user_email_unique`, migrasjon 004) som siste
bakstopper via `translate_db_errors()`.
**Scratch-verifisert, 10 sjekker:** mobil satt via vanlig PATCH; vanlig
profil-PATCH endrer aldri e-post; bytte til en allerede brukt adresse
avvist (409); e-post FORBLIR uendret helt til lenken bekreftes; ugyldig
bekreftelseskode avvist; gyldig kode fullfører byttet; SAMME kode kan
ikke brukes to ganger; en helt ny innlogging med den GAMLE adressen
oppretter nå en fersk, tom konto (beviser byttet er reelt og fullstendig,
ikke kosmetisk). Ekte typesjekket produksjonsbuild kjørt og bekreftet
(ny `/verify-email`-rute listet).
**Status: ✅ BYGGET OG LIVE 2026-07-20.** Migrasjon 016 kjørt mot ekte
`teecup_db`, bruker bekreftet eksplisitt. Begge containere redeployet,
`/health`/`/dashboard`/`/account`/`/verify-email` → 200, `teeoff.no`
upåvirket.
---
## ADR-033: Frittstående rundeføring med detaljert statistikk
Reist av brukeren 2026-07-21 (se FEATURE_BACKLOG.md), utdypet 2026-07-22 med
konkrete statistikk-felt og en eksplisitt ambisjon: **dette skal bli
appens hovedfokus** — når rundeføring med detaljert statistikk er på
plass, skal turneringsoppsett deretter gjøres ekstremt enkelt. Dette er
den største enkeltbeslutningen i prosjektet siden ADR-001, fordi den
direkte utfordrer tenant-invarianten ("organisasjon er isolasjonsenheten")
CLAUDE.md hittil har krevd en ny ADR for å bryte.
Fire load-bærende delbeslutninger ble avklart eksplisitt med bruker
(AskUserQuestion) FØR resten av denne ADR-en ble skrevet. De tre
HCP-PDF-ene (se referanse i CLAUDE.md) ble lest i sin helhet som
forberedelse til Beslutning F/G, i tråd med prosjektets stående instruks
om å ikke anta HCP-regler fra hukommelse.
### Beslutning A — Eierskapsmønster: nytt, parallelt, IKKE en skjult organisasjon
En frittstående runde eies av en BRUKER (`app_user.id`), ikke en
organisasjon. Ingen ny RLS-policy-familie trengs for dette — prosjektet
har ALLEREDE et etablert, bevist mønster for nøyaktig denne typen data:
personlig profil (migrasjon 015), sekundær e-post (017) og
HCP-historikk (018) bruker alle `plain_connection()` (ingen
`app.current_org` satt) med eksplisitt `WHERE user_id = $1`-filtrering i
hver spørring, i stedet for RLS. Frittstående runder gjenbruker dette
mønsteret uendret — nye tabeller (`round`, `round_participant`,
`round_hole_stat`, se Beslutning B) har en `owner_user_id`-kolonne, INGEN
`organization_id`, og INGEN RLS-policy — autorisasjon håndheves i
app-laget (eier ser/redigerer egne runder; en deltaker uten konto har
ingen egen tilgang, se Beslutning D).
**Begrunnelse for å avvise "usynlig personlig organisasjon"-alternativet:**
en skjult organisasjon måtte for alltid filtreres bort fra ENHVER
org-listing/-bytter/-medlemsside/fremtidig fakturering — en varig
lekkasjerisiko som vokser for hver ny org-scopet skjerm som bygges
fremover. Det parallelle mønsteret er ikke bare konseptuelt riktigere,
det er også ALLEREDE bygget og bevist for personlig data — dette er
mindre nytt arbeid enn først antatt i brainstorm-runden.
### Beslutning B — Statistikk-datamodell: fast sett navngitte felt, ikke fri slag-for-slag-logg
Per hull, i tillegg til slagtall (som i dag): kølle brukt ved utslag,
utslags-resultat (fairway/høyre/venstre — kun relevant på par 4/5),
innspill-resultat (traff/lang/kort/høyre/venstre), antall putter, lengde
på første putt, antall chip, antall bunkerslag, antall straffeslag.
**Presis definisjon av "innspillsslaget"** (nødvendig for at GIR skal
kunne beregnes automatisk, uavhengig av hullets par): SISTE slag før
første putt. På en par 3 er dette utslaget selv; på en par 4 normalt
2. slag; på en par 5 kan det være 2. ELLER 3. slag (f.eks. ved en
layup) — modellen trenger ikke vite hvilket slagnummer det var, kun at
det faktisk er det siste FØR putting startet.
**GIR er DERIVERT, ikke tastet inn direkte:** Green In Regulation = sant
hvis innspill-resultat er "traff" OG antall slag brukt til da er ≤
(hullets par 2). Utslag-resultat og innspill-resultat er derimot
OBSERVERTE felt spilleren selv taster inn — appen har ingen GPS og kan
ikke oppdage dette selv.
**UX-prinsipp, ikke bare et skjema-valg:** ALLE detalj-felt er valgfrie
per hull. Rask "bare slagtall"-registrering skal alltid fungere uendret
— dette er bevisst for å unngå at "hovedfokus" i praksis blir for
tungvint til daglig bruk (samme klasse avveining som gjorde at
detaljerte felt ble utsatt fra scorekort-rundene tidligere i prosjektet).
**Avvist:** fri slag-for-slag-logging (hvert slag = egen rad). Ville
dekket samme behov, men med vesentlig tyngre registrering og et mer
komplekst skjema fra dag én, uten at brukerens beskrevne behov faktisk
krever det.
### Beslutning C — Banedata for frittstående runder: LIVE oppslag mot teeoff, bekreftet av bruker
Custom-baner er i dag org-scopet (`course.organization_id`) — dette
fungerer ikke uten organisasjon. **Bekreftet med bruker 2026-07-22:**
offisielle baner slås opp LIVE mot teeoff sitt API ved behov (samme
lesende API som ADR-019, IKKE en importert/kopiert kopi som i
turnering-flyten). ADR-019 sin "importer, ikke slå opp live"-beslutning
var begrunnet i turneringers behov for reproduserbarhet (et resultat skal
ikke endre seg retroaktivt hvis banedata oppdateres) — en frittstående
statistikk-runde har ikke samme behov, og live oppslag unngår i tillegg
dagens duplisering (hver org som importerer "Tjøme Golfklubb" i dag lager
sin egen kopi). Egendefinerte baner som ikke finnes i teeoff: global
banekatalog uten organisasjonstilknytning, søk-før-opprett for å begrense
duplikater (denne delen — selve den globale katalogen for CUSTOM baner —
er fortsatt kun min anbefaling, ikke eksplisitt bekreftet punkt for
punkt, men følger naturlig av at live-oppslag-beslutningen er tatt).
### Beslutning D — Deltakere i flighten uten TeeCup-konto: gjenbruk eksisterende mønster
`round_participant` får en nullable `user_id` (ekte TeeCup-bruker som
spiller med) og et `guest_name`-fritekstfelt (ingen konto). Samme
konsept som `player` uten `user_id` i org-sammenheng. E-post-basert
kobling i etterkant (samme mønster som `link_player_by_email`,
migrasjon 008) er en naturlig, men IKKE besluttet, senere utvidelse —
notert som åpent punkt under, ikke bygget i første omgang.
### Beslutning E — Hullantall og starthull: ingen tvang, kun standardvalg
18/første ni/siste ni er standardvalg i UI-et, ikke en database-
begrensning. Spilleren velger starthull fritt (gjenbruk av samme
`start_hole`-konsept som `session.start_hole`, ADR-015). En runde kan
avsluttes etter et hvilket som helst antall hull uten et forhånds-
deklarert mål — statistikk og par-sum regnes alltid fra hullene FAKTISK
spilt.
### Beslutning F — HCP-tellende runde: presist kildebelagt fra WHS Rules of Handicapping 2024
Brukeren lastet opp den offisielle kilden 2026-07-22 ("WHS Rules of
Handicapping 2024", USGA/R&A — IKKE en tredjeparts-blogg som de to andre
PDF-ene). Dette erstatter den tidligere, mer omtrentlige "minst 9
hull"-antakelsen med presise regler (**Rule 2.2**):
- **18-hulls-type runde:** minimum **10 av 18 hull** må spilles for at
scoren skal være akseptabel. De resterende (inntil 8) uspilte hullene
fylles med en "expected score" (se under), IKKE net par (net par-
metoden ble erstattet av expected-score-metoden i 2024-revisjonen —
et prinsipielt skifte fra tidligere WHS-versjoner, verdt å merke seg
siden eldre kilder/hukommelse fortsatt kan referere net par).
- **9-hulls-type runde:** ALLE 9 hull i det spesifikke, ratede 9-hulls-
settet (front ELLER back, de eneste to som normalt har egen Course/
Slope Rating) må spilles. Færre enn 9 hull totalt → scoren er IKKE
akseptabel for HCP-formål i det hele tatt, uansett grunn.
- **Konsekvens for Beslutning E (fritt valgt starthull):** den frie
starthull-friheten gjelder fullt ut for CASUAL, ikke-HCP-tellende
logging. For at en runde skal telle mot HCP, må de spilte hullene
derimot samsvare med enten (a) et sammenhengende 10-18-hulls-utsnitt av
banens 18-hulls-rating, eller (b) nøyaktig banens ratede front-9 eller
back-9 — en vilkårlig 9-hulls-strekning (f.eks. hull 5-13) har ingen
egen Course/Slope Rating og kan derfor aldri bli HCP-tellende. Dette må
kommuniseres tydelig i UI-et, ikke bare håndheves stille i motoren.
### Beslutning G — HCP-indeksberegning bygges NÅ, presist kildebelagt (WHS Rules of Handicapping 2024)
Full kjede, alle tall/formler hentet direkte fra kilden (Rule 3/5/6),
ikke hukommelse:
1. **Net Double Bogey** (maks hull-score for HCP-formål) = hullets par +
2 + spillerens handicapslag på det hullet (Rule 3.1b) — allerede
dekket av eksisterende `allocate_strokes_by_index`/
`allocate_over_played_holes`, ingen endring.
2. **Uspilte hull — LØST 2026-07-22 med et bevisst, kildebelagt avvik
fra "Expected Score":** WHS sin offisielle "Expected Score"-mekanisme
(Rule 3.2b) er eksplisitt beskrevet som automatisk beregnet av
sertifisert WHS-programvare, UTEN at selve formelen er publisert i
regelboken (samme mønster som PCC) — kan derfor ikke bygges presist.
**Brukeren instruerte eksplisitt** å bruke WHS sin egen, presist
DEFINERTE "Net Par"-term i stedet (Rule 3.2b/2 — normalt reservert for
spesielle godkjente tilfeller, men her vedtatt som TeeCups generelle
policy): for hvert uspilt hull antas spilleren å ha skåret sin Net Par
= hullets par + mottatte handicapslag på det hullet (samme formel som
Net Double Bogey, uten +2-leddet) — tilsvarer 2 Stableford-poeng per
uspilt hull. Summeres inn i Adjusted Gross Score FØR standard 18-hulls
Score Differential-formelen brukes. **Viktig konsekvens:** dette gjør
at en 9-hulls-runde nå KAN telle fullt mot HCP-indeksen (de resterende
9 hullene fylles med Net Par, hele runden går gjennom SAMME 18-hulls-
formel) — det tidligere spørsmålet om en egen 9-hulls-differensial-
formel (Rule 5.1b) er dermed ikke lenger nødvendig å bygge separat.
Fortsatt gyldig kun når minimumsantallet (Beslutning F) er oppfylt.
3. **Ikke fullført hull (spilleren plukker opp)** (Rule 3.3): laveste av
"most likely score" (allerede tatte slag + sannsynlig antall til
fullføring, tabell basert på ballens avstand fra hullet + eventuelle
straffeslag) eller net double bogey.
4. **18-hulls Score Differential** (Rule 5.1a) = `(113 ÷ Slope Rating) ×
(Adjusted Gross Score Course Rating PCC-justering)`, avrundet til
nærmeste tidel (,5 rundes opp).
5. **(Rule 5.1b, WHS sin egen 9-hulls-differensial-formel) — IKKE brukt.**
Erstattet av Net-Par-tilnærmingen i punkt 2: en 9-hulls-runde regnes nå
som en 18-hulls-runde med 9 Net-Par-fylte hull, gjennom SAMME formel
som punkt 4. Nevnt her kun for å dokumentere at det bevisst er valgt
bort, ikke oversett.
6. **Handicap Index** (Rule 5.2) = gjennomsnitt av de beste 8 av de siste
20 Score Differentials, avrundet til nærmeste tidel. For færre enn 20
runder i historikken brukes en egen opptrappingstabell (f.eks. 3
runder → laveste 1 med justering 2,0; 9-11 runder → snitt av laveste
3, ingen justering; osv. — full tabell i Rule 5.2a, IKKE en enkel
"gjennomsnitt av alt"-tilnærming for nye spillere).
7. **Low Handicap Index** (Rule 5.7): laveste indeks siste 365 dager —
nødvendig referansepunkt for cap-mekanismen under, må lagres/spores
per bruker.
8. **Soft cap / hard cap** (Rule 5.8): øker en oppdatert indeks mer enn
3,0 slag over Low Handicap Index, begrenses overskytende beløp til
50 %; mer enn 5,0 slag over Low Handicap Index er et absolutt tak.
Ingen nedre grense på hvor mye indeksen kan SYNKE.
9. **Maksimal indeks** (Rule 5.3) = 54,0 — samsvarer med det allerede
satte `le=54`-taket i `ProfileUpdate` fra profil-fullførings-runden
(2026-07-22), god konsistens-bekreftelse.
10. **9-hulls Course Handicap** (Rule 6.1b) — **NY, presis detalj,
AVVIKER fra 18-hulls-formelen:** `(Index ÷ 2, avrundet til nærmeste
tidel) × (9-hulls Slope ÷ 113) + (9-hulls Course Rating 9-hulls
Par)`. Dette er IKKE det samme som å bruke full indeks mot en
9-hulls rating — indeksen halveres først. **Denne formelen gjelder
frittstående 9-hulls-RUNDER (denne ADR-en), IKKE det eksisterende
front_9/back_9-øktoppsettet i turnering-flyten** (ADR-008/2026-07-19-
fiksen, som bevisst bruker full_18-rating for HELT ANDRE grunner —
slagfordeling innad i en turneringsmatch, ikke offisiell HCP-
runde-innsending. De to må IKKE forveksles eller slås sammen uten en
egen vurdering.)
11. **18-hulls Course Handicap** (Rule 6.1a) = `Index × (Slope ÷ 113) +
(Course Rating Par)` — allerede korrekt implementert
(`course_handicap_raw`, verifisert ved grep), ingen endring.
12. **Playing Handicap** (Rule 6.2) — uendret, allerede korrekt dekket av
eksisterende allowance-strategier (ADR-014).
**Dette er i hovedsak en HELT NY komponent i `handicap_engine.py`**
(punktene 4-9), ikke en utvidelse av det eksisterende — dagens motor tar
alltid indeksen som et KJENT input; den har aldri regnet UT en indeks fra
en historie av runder. Punkt 10 er en presisering/utvidelse av
eksisterende kode for 9-hulls-tilfellet. Holdes ren og testet isolert,
som resten av `handicap_engine.py` (ADR-005).
**Bevisst UTENFOR omfang i første byggerunde, til tross for at kilden nå
finnes** (for å holde v1 håndterbar — presist avgrenset, ikke bare
utsatt i vage vendinger):
- **Playing Conditions Calculation (PCC)** (Rule 5.6) — full prosedyre
funnet og forstått (statistisk sammenligning av dagens faktiske scorer
mot forventet, justering 1,0 til +3,0, krever minst 8 aksepterte
scorer på banen samme dag blant spillere med indeks ≤36,0). Ikke
bygget nå — krever et helt annet datagrunnlag (ALLE spilte runder på
en bane en gitt dag på tvers av ALLE brukere) enn det en enkelt
frittstående runde naturlig gir. Runder telles inn UTEN PCC-justering
til dette tas som egen, senere runde.
- **Exceptional Score-reduksjon** (Rule 5.9) — funnet og forstått (en
differensial 7,0-9,9 slag bedre enn gjeldende indeks gir automatisk
1,0 på de siste 20 differensialene, 10,0+ gir 2,0). Ikke bygget i
v1, notert for senere presisjon.
- **Initial indeks fra færre enn 3 runder, Handicap Committee-skjønn**
(Rule 5.2a) — TeeCup har ingen "Handicap Committee"-rolle; en
forenklet, automatisk variant av opptrappingstabellen brukes i stedet,
uten menneskelig overstyring i v1.
**Åpent, ikke besluttet:** når en reell WHS-indeks kan beregnes fra
runder, skal manuell redigering av `app_user.handicap_index`
(eksisterende `PATCH /auth/profile`, ADR-031) fortsatt tillates ved
siden av (f.eks. for en spiller uten noen TeeCup-runder ennå), eller skal
feltet bli read-only/auto-beregnet så snart minst én HCP-tellende runde
finnes? Påvirker om `handicap_history` (018) skal gjenbrukes uendret
eller trenger en ny kolonne som skiller "manuelt satt" fra "beregnet fra
runde".
**Skjemaet (migrasjon `020_personal_rounds.sql`) er ✅ SKREVET OG
SCRATCH-VERIFISERT 2026-07-22,** som andre byggesteg (etter motoren).
Sju nye tabeller: `round` (header, `owner_user_id`-eid, ingen RLS),
`round_participant` (deltakere — lenket bruker ELLER gjestenavn, XOR-
håndhevet via CHECK; maks én markert eier per runde via partiell unik
indeks), `round_hole` (rating-SNAPSHOT + statistikk per deltaker per
hull — GIR er bevisst IKKE en egen kolonne, kun deriverbar ved lesing:
`approach_result='hit' AND (score-putts) <= par-2`, verifisert eksakt
mot ekte testdata), pluss fire tabeller for den globale banekatalogen
(`personal_course`/`_hole`/`_tee`/`_tee_rating` — sistnevnte BEVISST uten
`scope`-kolonne, ulikt org-tabellen, siden Net-Par-tilnærmingen gjør
9-hulls-spesifikk rating overflødig). `round`/`personal_course_*` har
INGEN RLS (Beslutning A) — autorisasjon i app-laget.
**Scratch-verifisert, 9 sjekker** (kjørt som `teecup_app_scratch`, ikke
superbruker): duplikat kjønn på samme utslag avvist, teeoff+custom-felt
samtidig avvist (CHECK), verken/begge user_id+guest_name avvist (XOR-
CHECK), to markerte eiere på samme runde avvist, duplikat hullnummer per
deltaker avvist, ugyldig approach_result-verdi avvist, kaskade-sletting
av en runde fjerner alle dens deltakere+hull men lar ANDRE runder stå
urørt. `test_isolation.sql` fortsatt 12/12 (ingen RLS-regresjon på
eksisterende tabeller). **Rullet ut mot ekte `teecup_db` 2026-07-22,**
bruker bekreftet eksplisitt: alle sju tabeller bekreftet opprettet,
`test_isolation.sql` fortsatt 12/12 mot ekte database. **Gjenstår:**
API-lag og frontend — ingen av disse er startet.
**API-laget er ✅ BYGGET, SCRATCH-VERIFISERT OG RULLET UT LIVE 2026-07-22,**
som tredje byggesteg. Ny `app/routers/rounds.py` (registrert i `main.py`):
`POST/GET /rounds` (opprett/list egne runder), `GET/DELETE /rounds/{id}`,
`POST/DELETE /rounds/{id}/participants` (kun gjester i v1, se moduldoc),
`PATCH /rounds/{id}/participants/{pid}/holes/{n}` (hull-for-hull-
registrering), `POST /rounds/{id}/complete` (kjører hele motor-kjeden:
Adjusted Gross Score → Score Differential → `counts_for_handicap` via
`round_counts_for_handicap`), pluss `GET/POST /personal-courses` for den
globale banekatalogen. Ny motor-funksjon lagt til underveis:
`round_counts_for_handicap(played_holes_count, holes_planned)` — to
distinkte terskler (Rule 2.2a: min 10/18 ved 18-hulls-intensjon; Rule
2.2b: ALLE 9 ved 9-hulls-intensjon, ikke "minst 9"), testet (2 nye
tester, 43/43 totalt i `handicap_engine.py`).
**Reelt hull funnet OG fikset FØR API-et kunne fullføres:**
`round_participant` manglet kolonner for selve rating-tallene (Course
Rating/Slope Rating/Par) brukt til å beregne Course Handicap — kun
`round.tee_name_snapshot` (navn) fantes, ikke tallene. Ny migrasjon
`021_round_participant_rating_snapshot.sql` (tre nye nullable kolonner)
skrevet, scratch-verifisert sammen med resten, og rullet ut.
**Scratch-verifisert grundig, 26 sjekker** (isolert scratch-rolle+MinIO+
engangs API-container): full livssyklus for en custom-bane-runde
(course_handicap_snapshot regnet riktig — Index 15/Slope 128/Rating
71.5/Par 72 → 16, verifisert for hånd), en gjest UTEN HCP (ingen
snapshot/differensial, teller aldri), 18/18 spilt → tellende med korrekt
differensial (16.3, verifisert for hånd), 9 av 18 spilt ved 18-hulls-
intensjon → IKKE tellende, 9 av 9 spilt ved 9-hulls-intensjon → TELLENDE
(Net Par fyller resten av de 18, se Beslutning G punkt 2), full
autorisasjons-isolasjon (en fremmed bruker avvist 403 fra både lesing og
hull-oppdatering, egen runde-liste tom), kan ikke fjerne eieren, slett-
runde-kaskade, ufullstendig profil avvist fra å opprette runde. **Egen,
separat verifisering av teeoff-LIVE-oppslaget** (Beslutning C) mot den
ekte kjørende `teeoff_api`-containeren (Borregaard Golfklubb, samme
anlegg som ADR-019s opprinnelige verifisering) — bekreftet at INGEN
`course`/`hole`/`tee`-rad skrives noe sted, kun et navn-snapshot
("Borregaard Golfklubb Hovedbanen") og et rating-snapshot; en andre
deltaker lagt til samme runde utløste et FERSK, uavhengig live-oppslag
mot teeoff (ikke gjenbruk av cachet data). `test_isolation.sql` fortsatt
12/12.
**Rullet ut mot ekte systemer 2026-07-22:** migrasjon 021 kjørt mot ekte
`teecup_db` (kolonner bekreftet, `test_isolation.sql` fortsatt 12/12),
`docker compose up -d --build teecup_api` (kun backend — ingen frontend-
skjerm bygget for dette ennå), boot-et rent, `/health`/`/dashboard` → 200,
`teeoff.no` upåvirket. `frontend/next.config.mjs` sin `rewrites()` fikk
`/rounds/*` og `/personal-courses/*` lagt til proaktivt (samme lærdom som
ADR-016/medlemsside-hendelsen — enhver ny API-prefiks MÅ inn her FØR en
frontend-side bygges) — denne ENDRINGEN ligger IKKE deployet ennå (ingen
frontend-kode bruker den), tas med i neste frontend-runde.
**Frontend BYGGET OG RULLET UT 2026-07-23** — fjerde og siste lag
(engine → skjema → API → frontend). Tre nødvendige tillegg til
`app/routers/rounds.py` funnet og bygget UNDER frontend-designet, ikke
antatt på forhånd: `GET /rounds/official-search`/`{slug}` (samme
teeoff-søkemønster som `courses.py`, men uten org-kontekst — frittstående
runder har ingen), `GET /personal-courses/{id}` (detalj med
utslag+kjønn — søk-endepunktet returnerte kun navn), og
`GET .../participants/{id}/holes` (et reelt hull: `RoundOut` bar aldri
hull-nivå-data, så ingen skjerm kunne vise gjeldende tilstand ved
gjenlasting). Hull-PATCH endret til å returnere hele den oppdaterte raden
i stedet for `{"ok": true}`.
**Reelt kontraktsfunn, bekreftet i scratch FØR frontend stolte på det:**
hull-PATCH-endepunktet er IKKE et ekte delvis-PATCH — det skriver ALLE
felt ved hvert kall (arvet fra hvordan `HoleUpdate`-modellen alltid har
defaultverdier for utelatte felt). Et PATCH som kun sender `score` ville
derfor stille NULLSTILT `putts` og alle andre allerede lagrede felt.
Løst ved at `round-detail.tsx` alltid slår sammen med gjeldende
hull-data før hver PATCH, aldri sender et isolert feltnavn alene —
verifisert eksplisitt i en egen scratch-test som FØRST beviste
nullstillings-oppførselen uten merge, DERETTER beviste at
merge-mønsteret unngår den.
**Nye sider:** `/rounds` (liste over egne runder), `/rounds/new`
(bane-kilde teeoff vs. egen — for egen bane: søk-før-opprett, samme idé
som org-banenes gjenbrukbare katalog; utslag filtrert til kun de som har
rating for brukerens registrerte kjønn, ADR-029s automatikk-prinsipp
gjenbrukt her selv om HCP-motoren er en helt annen), `/rounds/[id]`
(deltaker-faner — eier + gjester, ingen ekte kontokobling i v1 per
Beslutning D; hull-navigasjon fra runde-ens starthull; slag/putt-
tallvelgere i samme visuelle stil som `session-scorecard.tsx` sin
`StrokePicker`; kølle/retning/innspill/chip/bunker/straffeslag/
putt-avstand bak en «flere detaljer»-utvidelse; GIR utledet og vist
KLIENTSIDE ved lesing, aldri lagret — nøyaktig Beslutning B sitt prinsipp;
fullfør-runde med HCP-differensial-sammendrag, låser videre redigering).
Lenket fra dashbordet som «Egne runder» (header-lenke + en egen kort på
forsiden) — bevisst adskilt navn fra det eksisterende «Mine runder»
(ADR-031, turnering-deltakelse) for å unngå at de to konseptene blandes
sammen i UI-et, selv om begge bokstavelig talt handler om "runder".
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
isolert scratch-MinIO + engangs API-container, samme mønster som resten
av ADR-033): 22 automatiserte sjekker (egendefinert-bane-opprettelse med
to utslag/to kjønn, søk, detalj, PATCH-kontraktsbeviset over,
GIR-derivering for et konstruert par4/score3/putt1-tilfelle, gjest-
fjerning, kryss-bruker-autorisasjon 403, full fullføring med differensial)
PLUSS en separat, egen test av HELE teeoff-baserte opprettelsesløpet mot
den ekte kjørende `teeoff_api`-containeren (Borregaard Golfklubb, samme
anlegg som tidligere ADR-019/033-verifiseringer) som bekreftet
`course_handicap_snapshot` ble beregnet riktig fra live-hentet
rating. Ekte typesjekket PRODUKSJONSBUILD kjørt via
`docker build --target builder` (nøyaktig samme steg `Dockerfile` bruker
i prod, ikke `next dev`) — kompilerte rent, alle nye ruter listet.
**Rullet ut live 2026-07-23**, bruker bekreftet eksplisitt: ingen
migrasjon i denne del-runden, `docker compose up -d --build teecup_api
teecup_frontend`, begge containere boot-et rent, `/health`/`/dashboard`/
`/rounds` → 200 over ekte https, `teeoff.no` upåvirket.
**Bevisst utenfor omfang, ikke bygget denne runden:** ekte kontokobling
for gjeste-deltakere (Beslutning D), automatisk oppdatering av
`app_user.handicap_index`/`handicap_history` ved fullført tellende runde
(åpent spørsmål i Beslutning G, fortsatt ubesvart), shotgun-start
(egen, separat ADR-034), GPS/avstandsmåling (se eget notat i
FEATURE_BACKLOG.md, krever data ingen kilde har i dag).
**Frontend ERSTATTET med V0-designet versjon 2026-07-23, samme dag som
den hånd-bygde frontend-en over ble rullet ut:** brukeren påpekte
(berettiget) at de tre nye skjermene var hånd-kodet av meg direkte i
stedet for designet i V0 -- et avvik fra prosjektets etablerte mønster
gjennom HELE resten av appen. Bekreftet med bruker at arbeidsmåten
fortsatt skal være: JEG skriver V0-prompten, BRUKEREN kjører den i
v0.app og sender koden tilbake, JEG integrerer (samme flyt som alltid,
ikke endret). Skrev tre detaljerte prompter (liste/opprett/hull-
registrering, inkl. eksplisitt tilgjengelighetskrav i hver) -- bruker
lastet opp tre zip-eksporter (`tee-cup-login-screen (10/11/12).zip`).
**Samme "full re-eksport hver gang"-mønster som ALLE tidligere V0-runder:**
diffet mot levende tre FØR noe ble tatt inn -- kun fire filer var reelt
nye/relevante (`round-card.tsx`, `own-rounds.tsx`, `new-round.tsx`,
`round-detail.tsx` + tre `app/rounds*/page.tsx`-ruter), resten
(login-form, dashboard, config, ui/*) var forventede full-reverts og ble
IKKE tatt inn. **Samme kjente V0-feil dukket opp igjen, hoppet bevisst
over:** `app/clubs/[id]/page.tsx` med feilnavngitt `[id]`-parameter
(egentlig en slug) -- identisk feil som ble rettet i klubbside-runden
under ADR-018, V0 gjenskaper den tydeligvis når prosjektet re-genereres.
**Mine tre hånd-bygde komponenter (`personal-rounds.tsx`, `round-new.tsx`,
og min opprinnelige `round-detail.tsx`) er ERSTATTET, ikke supplert** --
V0s presentasjon beholdt, datalaget skrevet om fra mock til ekte fetch
(samme "V0 leverte mock, jeg kabler ekte data"-mønster som enhver annen
skjerm i appen). Reelle tilpasninger utover ren om-kabling:
1. V0s teeoff-søkemodell antok hele bane+utslag-lista lå ferdig i
søkeresultatet -- det ekte API-et er et to-stegs oppslag (søk →
facility-detalj). Løst med en `resolving`-tilstand i
`OfficialSearchStep` (eneste strukturelle tillegg til V0s JSX).
2. La til et tredje kjønnsvalg "Annet" i gjeste-skjemaets `ChoiceRow`
(V0 hadde kun Mann/Kvinne) -- matcher appens ellers etablerte
Herre/Dame/Annet-konvensjon (`account-settings.tsx` m.fl.), og
API-ets `GuestParticipantCreate.gender` støtter allerede `x`.
3. Beholdt merge-før-PATCH-sikringen fra forrige rundes kontraktsfunn
(hull-PATCH skriver alle felt, ikke bare det endrede) -- V0 kjente
naturligvis ikke til denne kontraktsdetaljen, lagt inn i
`updateStat()` uendret i prinsipp fra min opprinnelige versjon.
4. Fjernet V0s dev-only forhåndsvisningskontroller (`ReviewToggle` i
`own-rounds.tsx`, den nederste "Forhåndsvis: Pågår/Fullført"-linjen i
`round-detail.tsx`) -- samme opprydningsmønster som ALLE tidligere
V0-runder i prosjektet.
Ingen backend-endring i denne del-runden (samme API-kontrakt som allerede
var scratch-verifisert). **Verifisert:** ekte typesjekket
produksjonsbuild (`docker build --target builder`) kompilerte rent, alle
ruter listet. Ingen ny interaktiv nettleser-test utført (intet slikt
verktøy tilgjengelig i denne økten) -- kun kodegjennomgang + typesjekk,
flagget eksplisitt til bruker.
**Rullet ut live 2026-07-23**, bruker bekreftet eksplisitt: `docker
compose up -d --build teecup_frontend` (gjenskapte også `teecup_api` som
vanlig compose-bivirkning, ingen backend-kode rørt), begge containere
boot-et rent, `/health`/`/dashboard`/`/rounds`/`/rounds/new` → 200 over
ekte https, `teeoff.no` upåvirket. De tre V0-zip-ene slettet fra
prosjektroten etter fullført integrering.
**Reell produksjonsbug funnet OG FIKSET 2026-07-23, rapportert av bruker
med to skjermbilder (`/rounds` ga "Klarte ikke å hente rundene dine",
`/rounds/new` sitt siste steg ga "Klarte ikke å opprette runden"):**
rot-årsak var en EKSAKT navnekollisjon mellom frontend-sidens toppnivå-
prefiks (`/rounds`) og backend-APIets ressursprefiks (samme `/rounds`,
`app/routers/rounds.py`) -- en verre variant av den allerede dokumenterte
"afterFiles"-fellen fra ADR-016s medlemsside-hendelse, denne gangen
rammende BEGGE retninger samtidig:
- `/rounds` (eksakt sti): en STATISK frontend-side. Statiske sider
sjekkes FØR rewrites, så siden vant presedens -- klientens
`fetch("/rounds")`/`POST /rounds` traff ALDRI backend, fikk Next sin
egen HTML tilbake i stedet for JSON (stille `res.json()`-parsefeil,
fanget av try/catch, viste den generiske feilteksten).
- `/rounds/[id]` (DYNAMISK side): her sjekkes rewrites FØR dynamiske
sider, så rewrite-regelen vant presedens i stedet -- selve
rundedetalj-SIDEN var dermed fullstendig UOPPNÅELIG (ville vist rå
backend-JSON i stedet for UI-et), bekreftet direkte med `curl` FØR
fiksen (anonymt `GET /rounds/00000000-...` ga ekte backend-JSON i
stedet for Next sin HTML).
Det andre skjermbildets utslagsnavn "55/50/44/32" ble UNDERSØKT OG
BEKREFTET Å IKKE VÆRE EN BUG -- lest direkte fra ekte `teeoff_api` (`GET
tjome-golfklubb`): Tjøme Golfklubb sine faktiske utslagsnavn i teeoff ER
bokstavelig talt disse tallene (lengde i hundremeter, ikke fargenavn) --
data gjengitt korrekt, ingen kode-endring nødvendig for dette punktet.
**Fikset ved samme prinsipp som medlemsside-hendelsen: flytt siden, ikke
APIet.** Alle tre frontend-rutene flyttet til et helt nytt, ikke-
overlappende toppnivå-prefiks `/my-rounds/*` (`app/rounds/` →
`app/my-rounds/`), API-et (`/rounds/*`) uendret. Åtte interne
navigasjonsreferanser oppdatert på tvers av `round-card.tsx`/
`own-rounds.tsx`/`new-round.tsx`/`round-detail.tsx`/`dashboard.tsx` --
ekte `fetch()`-kall til API-et (samme filer) bevisst latt urørt, kun
`<Link href>`/`router.push`/`router.replace` endret. Utvidet
`next.config.mjs` sin allerede eksisterende advarselskommentar med denne
nye, verre varianten av samme fellesklasse, som en fremtidig påminnelse:
et rewrite-prefiks og en frontend-sides toppnivå-segment må ALDRI være
identisk streng.
**Verifisert presist FØR og ETTER utrulling** (ikke bare "bygget uten
feil"): et `curl` mot ekte produksjon FØR fiksen bekreftet nøyaktig
mekanismen i begge retninger (se over). Ekte typesjekket
produksjonsbuild etterpå viste selv at rutetreet nå lister `/my-rounds`,
`/my-rounds/[id]`, `/my-rounds/new` i stedet for de gamle `/rounds`-
rutene. **Rullet ut live 2026-07-23**, bruker bekreftet eksplisitt: kun
`teecup_frontend` (gjenskapte `teecup_api` som vanlig bivirkning, ingen
backend-kode rørt). Verifisert ETTERPÅ med et nytt sett `curl`-kall mot
ekte https: anonymt `GET /rounds` ga nå korrekt backend-JSON
(`NOT_AUTHENTICATED`, IKKE Next sin HTML som før), anonymt `GET
/rounds/<uuid>` fortsatt korrekt backend-JSON (uendret, som forventet),
og -- den avgjørende nye sjekken -- anonymt `GET /my-rounds/<uuid>` ga nå
faktisk `text/html` (selve React-siden, ikke lenger uoppnåelig).
`/dashboard`/`/my-rounds`/`/my-rounds/new` → 200, `teeoff.no` upåvirket.
**Motor-komponenten (punkt 1-11) er ✅ BYGGET OG TESTET 2026-07-22,**
som første, isolerte byggesteg (ren Python, ingen DB/API/frontend ennå —
matcher ADR-005s "test i isolasjon FØR resten"). Nye funksjoner i
`handicap_engine.py`: `net_par`, `max_hole_score_for_handicap`,
`adjusted_gross_score`, `round_half_up_decimal`, `score_differential`,
`handicap_index_from_differentials`, `low_handicap_index`,
`apply_index_caps`, `course_handicap_9_raw`/`course_handicap_9`.
**Bevisst avvik fra opprinnelig plan, instruert av bruker 2026-07-22:**
"Expected Score" (Rule 3.2b) sin upubliserte formel erstattes gjennomgående
av WHS sin egen, presist definerte "Net Par"-term (par + mottatte
handicapslag = 2 Stableford-poeng) for uspilte hull — gjelder BÅDE
ufullstendige 18-hulls-runder og konvertering av en 9-hulls-runde til
18-hulls-ekvivalent (Rule 5.1b sin egen separate 9-hulls-differensial-
formel er dermed bevisst IKKE implementert, se punkt 5 over).
**41/41 tester bestått** (`test_handicap_engine.py`, kjørt uten pytest —
ikke installert i miljøet, kun den innebygde selvsjekk-runneren), 17 nye
i tillegg til de 24 eksisterende. Flere verifisert mot regelbokens EGNE
tallregneeksempler, ikke bare intern konsistens: Rule 5.2a sine to
initial-indeks-eksempler (13,2 og 34,1, samt oppfølgingen til 37,4),
Rule 5.1c sine tre avrundingseksempler, Diagram 5.8 sin soft-/hard-cap-
oppførsel, og Diagram 3.1b sin Net-Double-Bogey-capping (der front-9 ble
verifisert eksakt mot diagrammet, mens back-9 sine åtte ikke-annoterte
scorer bevisst ble egenkomponert pga. usikker bilde-lesing av akkurat de
sifrene — se testens egen kommentar for full transparens om hva som er
kildebelagt og hva som ikke er det).
**Gjenstår:** migrasjon (nye tabeller for runde/deltaker/statistikk),
API-lag, frontend — ingen av disse er startet.
### Beslutning H — Shotgun- vs. fortløpende start: EGEN, separat ADR (ADR-034)
Bekreftet med bruker: dette er et turnering/økt-konsept (start_hole per
match i stedet for per økt, samme klokkeslett for alle grupper), uten
reell avhengighet til rundeførings-arkitekturen over. Ikke behandlet
videre her.
### Ikke besluttet, gjenstår før bygging kan starte
- Den globale banekatalogen for EGENDEFINERTE (ikke-teeoff) baner
(del av Beslutning C) — naturlig konsekvens av live-oppslag-
beslutningen, men ikke bekreftet punkt for punkt ennå.
- Gjest-e-post-kobling (Beslutning D) — notert, ikke designet i detalj.
- Manuell vs. auto-beregnet HCP-indeks etter at motoren finnes
(Beslutning G) — notert, ikke avgjort.
- Skal turnering-scoring til slutt bruke SAMME statistikk-modell (reist i
brainstorm-runden 2026-07-22, FEATURE_BACKLOG.md) — ikke avgjort, ingen
konsekvens for denne ADR-ens omfang uansett.
- Eksakte tabellnavn/skjema (`round`/`round_participant`/
`round_hole_stat` er arbeidsnavn i denne ADR-en, ikke endelig
fastlagt) — avgjøres ved migrasjonsskriving.
**Status: 🔨 ADR skrevet OG kildebelagt 2026-07-22, BYGGING PÅBEGYNT.**
Alle tre store åpne punktene fra første utkast (banedata, WHS 9-hulls-
regel, full HCP-indeksformel) er enten eksplisitt bekreftet med bruker
(Beslutning C) eller presist kildebelagt fra den offisielle WHS Rules of
Handicapping 2024 (Beslutning F/G) — ikke lenger antatt eller tilnærmet.
**Tre byggesteg ferdig samme dag, alle rullet ut mot ekte systemer:**
(1) hele HCP-indeks-motor-komponenten (Beslutning G, punkt 1-11) bygget
og testet i `handicap_engine.py` (43/43 tester), (2) full
databasemigrasjon (`020_personal_rounds.sql` + rettefiksen `021_round_
participant_rating_snapshot.sql`, åtte tabeller/utvidelser) kjørt mot
ekte `teecup_db`, (3) fullt API-lag (`app/routers/rounds.py`) bygget,
scratch-verifisert (26 sjekker + egen teeoff-live-oppslag-test) og
redeployet (`teecup_api`). **Gjenstår: HELE frontend-en** — ingenting
bygget ennå. Notat fra bruker 2026-07-22 (IKKE designet): planer om
slaglengde-måling + avstand-til-punkter-på-banen (golf-GPS/rangefinder),
krever geografiske data ingen kilde har i dag — se FEATURE_BACKLOG.md.
---
## Åpne spørsmål (ikke besluttet ennå)
Disse må avklares før eller under de relevante fasene:
1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på
`JWT_SECRET`. TeeCup bør bruke separate, uavhengige secrets. *(Sikkerhet)*
2. **Cache over flere prosesser:** In-memory `dict`-cache på `app.state` deles ikke
mellom flere workers/containere. Ved skalering trengs Redis. *(Skalering)*
3. **Prising:** Per organisasjon (abonnement) eller per turnering? Påvirker ikke
isolasjonsmodellen, men påvirker fakturerings-/kvotemodell.
4. **Scramble-grensesnitt:** Arkitekturen skal ta høyde for formatet; eksakt
UI-løsning spesifiseres senere.
5. **Individuell-vs-delt-ball i `hole_score`:** Håndheves i app-laget, ikke av
databasen (CHECK når ikke opp til `session.format`). Motoren/API-et må passe
på at f.eks. et foursome ikke får per-spiller-scorer.
---
## Utviklingsplan (rekkefølge)
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
2. ✅ Denne beslutningsloggen (dette dokumentet)
3. ✅ Handicap-motor som frittstående, testet bibliotek (ADR-005) — 24 tester, R&A-verifisert
4. ✅ Databaseskjema (`001_initial_schema.sql`) — RLS, miksede formater, pool
5. ⏩ Backend-API + regelmotor-integrasjon (neste)
6. ⬜ Frontend (admin + score-registrering)
7. ⬜ PWA & offline-synk