Refaktorere get_leaderboard til delt fetch_leaderboard() Gjøre own_team_ids null-sikker for anonyme/uinnloggede tilskuere Refaktorere list_matches og get_scorecard til delte funksjoner Legge til offentlige leaderboard/matches/scorecard-endepunkter i registration.py Skrive ADR-026 (tilskuer-rolle) Scratch-verifisere backend Bygge frontend: offentlig live-visning (leaderboard + matcher + scorekort) på /t/[id]/live Ekte typesjekket frontend-build Deploy etter bekreftelse + oppdatere .md-filer Backend og frontend er scratch-verifisert (16 automatiserte sjekker, inkl. en presis test av tenant-vs-sti-sikkerhetssjekken). Ingen migrasjon denne runden — ren backend-refaktorering + nye endepunkter + ny /t/[id]/live-side. Ingen Caddy-endring nødvendig (dekkes av eksisterende /public/*-proxy)
64 KiB
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:
- Bruk handicap (av/på) — helt av gir scratch-spill.
- Bruk course handicap (av/på) — om slope/rating-justeringen
(
course_handicap_raw) påføres, eller om råhandicap_indexbrukes direkte. - HCP-prosent (allowance-strategien, allerede dekket av ADR-005 /
session.allowance_override). - 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:
- Den eneste veien inn til en turnering er en direkte lenke (
/t/[id]) eller org-ens klubbside (kunpublic-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. - 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).
- 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'),pyotpfor 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 sommagic_link_token(ny tabelltwo_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_methodallerede 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:
session_id/match_idi URL-en må eksplisitt verifiseres å høre til NØYAKTIGtournament_idi 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").- 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 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.
Åpne spørsmål (ikke besluttet ennå)
Disse må avklares før eller under de relevante fasene:
- Sesjons-secret: TeeOff lar
PUBLIC_SESSION_SECRETfalle tilbake påJWT_SECRET. TeeCup bør bruke separate, uavhengige secrets. (Sikkerhet) - Cache over flere prosesser: In-memory
dict-cache påapp.statedeles ikke mellom flere workers/containere. Ved skalering trengs Redis. (Skalering) - Prising: Per organisasjon (abonnement) eller per turnering? Påvirker ikke isolasjonsmodellen, men påvirker fakturerings-/kvotemodell.
- Scramble-grensesnitt: Arkitekturen skal ta høyde for formatet; eksakt UI-løsning spesifiseres senere.
- Individuell-vs-delt-ball i
hole_score: Håndheves i app-laget, ikke av databasen (CHECK når ikke opp tilsession.format). Motoren/API-et må passe på at f.eks. et foursome ikke får per-spiller-scorer.
Utviklingsplan (rekkefølge)
- ✅ Land tenant-modell → Organisasjon (ADR-001/002/003)
- ✅ Denne beslutningsloggen (dette dokumentet)
- ✅ Handicap-motor som frittstående, testet bibliotek (ADR-005) — 24 tester, R&A-verifisert
- ✅ Databaseskjema (
001_initial_schema.sql) — RLS, miksede formater, pool - ⏩ Backend-API + regelmotor-integrasjon (neste)
- ⬜ Frontend (admin + score-registrering)
- ⬜ PWA & offline-synk