Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
1564 lines
85 KiB
Markdown
1564 lines
85 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 SCRATCH-VERIFISERT 2026-07-20, IKKE ENNÅ RULLET UT.**
|
||
Se CLAUDE.md-status for full byggerunde.
|
||
|
||
---
|
||
|
||
## Å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
|