1619 lines
88 KiB
Markdown
1619 lines
88 KiB
Markdown
# 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-18
|
||
|
||
---
|
||
|
||
## 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 (A–B, A–C, B–C), 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` på `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` på
|
||
`(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.
|
||
|
||
---
|
||
|
||
## Å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
|