# 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. --- ## 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`. --- ## Å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