Update Todos

Skrive migrasjon 052 (guest_first_name/guest_last_name + backfill)

Bygge backend: splitt navn i ParticipantCreate/Update, auto-synk guest_name

Bygge backend: GET known-guest-oppslag (e-post-basert, scoped til egen bruker)

Bygge backend: retroaktiv e-post-kobling ved innlogging (auth.py)

Bygge backend: HTML-e-post-støtte i email.py (multipart/alternative)

Bygge backend: scorekort/statistikk-e-post ved complete_round (individuell + delt-ball)

Frontend: e-post-felt + navnesplitt + autofyll fra søk i AddGuestForm

Frontend: "vi kjenner igjen denne e-posten"-forslag i AddGuestForm

Scratch-verifisere hele kjeden (migrasjon+backend+frontend+e-post) end-to-end

Kjøre migrasjon mot ekte teecup_db og rulle ut (etter bekreftelse)
Alt er bygget og scratch-verifisert. Før jeg ruller ut mot ekte teecup_db, her er planen — trenger din bekreftelse per sikkerhetsregelen:

Migrasjon 052_guest_name_split.sql (rent additiv, ingen sletting):

ALTER TABLE round_participant ADD COLUMN guest_first_name text; + guest_last_name text
Backfiller eksisterende guest_name-rader ved samme "første ord/resten"-heuristikk som frontend bruker til autofyll
Deretter: docker compose up -d --build teecup_api teecup_frontend

Hva som er bygget:

E-post som valgfritt felt i "Legg til uten konto (gjest)" (fantes i backend fra før, manglet i selve skjemaet)
Navn splittet i for-/etternavn (etternavn valgfritt)
Retroaktiv kobling: spiller runder som gjest før du har konto → runden dukker opp i "Egne runder" når du logger inn med samme e-post, men teller ikke automatisk mot faktisk HCP (må bekreftes selv — unngår at en fremmed kan påvirke HCP-en din uten samtykke)
E-post med scorekort + statistikk + invitasjon sendes automatisk når runden fullføres, til enhver gjest med registrert e-post — første HTML-e-post i appen
Autofyll fra søkefeltet inn i gjesteskjemaet
"Vi kjenner igjen denne e-posten"-forslag, scoped til dine egne tidligere gjester (ikke globalt — unngår en personvernlekkasje)
This commit is contained in:
Erol Haagenrud 2026-08-03 06:24:57 +02:00
parent 5e66ce63f5
commit d395f86753
17 changed files with 7326 additions and 6477 deletions

24
052_guest_name_split.sql Normal file
View file

@ -0,0 +1,24 @@
-- Splitter round_participant sitt gjeste-navn i for-/etternavn (2026-08-03).
-- guest_name beholdes UENDRET som en auto-synkronisert, lagret "fullt navn"-
-- kolonne (samme mønster som app_user.display_name synkes fra first_name/
-- last_name) -- unngår å måtte røre de ti+ eksisterende SELECT-stedene som
-- allerede leser rp.guest_name direkte. guest_first_name/guest_last_name er
-- den nye kilden til sannhet ved REDIGERING; guest_name er fortsatt det som
-- faktisk VISES.
ALTER TABLE round_participant ADD COLUMN guest_first_name text;
ALTER TABLE round_participant ADD COLUMN guest_last_name text;
-- Backfill: første ord = fornavn, resten = etternavn (samme heuristikk som
-- frontend bruker ved autofyll fra søkefeltet -- se new-round.tsx sin
-- initials()/fullName()-familie for presedens på "første ord vs. resten").
UPDATE round_participant
SET guest_first_name = CASE
WHEN position(' ' IN guest_name) > 0 THEN split_part(guest_name, ' ', 1)
ELSE guest_name
END,
guest_last_name = CASE
WHEN position(' ' IN guest_name) > 0
THEN NULLIF(trim(substring(guest_name FROM position(' ' IN guest_name) + 1)), '')
ELSE NULL
END
WHERE guest_name IS NOT NULL;

View file

@ -845,7 +845,7 @@ kryss-org-isolasjonstestene, og `/auth/me` sin N+1-oppslagsstrategi
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
org_invitations.sql` kjørt mot ekte `teecup_db`. Se CHANGELOG.md for
full byggerunde (backend/frontend-detaljer, tre reelle bugs funnet og
fikset under scratch-testing).
@ -924,7 +924,7 @@ 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.
migrasjon `012`). Se CHANGELOG.md for full byggerunde.
---
@ -981,7 +981,7 @@ 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
autorisasjonslogikk, ingen skjemaendring). Se CHANGELOG.md for
scratch-verifisering og utrulling.
---
@ -1038,7 +1038,7 @@ nødvendig. Status-teksten "Walkover (A)"/"Walkover (B)" følger samme
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
ingen skjemaendring). Se CHANGELOG.md for scratch-verifisering og
utrulling.
---
@ -1104,7 +1104,7 @@ 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-
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Se CHANGELOG.md 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
@ -1166,7 +1166,7 @@ synlighetslogikk, bare eksisterende logikk gjort tilgjengelig for en
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Ingen migrasjon (kun nye
endepunkter + refaktorering av eksisterende spørringer til delte
funksjoner). Se CLAUDE.md-status for scratch-verifisering og utrulling.
funksjoner). Se CHANGELOG.md for scratch-verifisering og utrulling.
---
@ -1311,7 +1311,7 @@ migrasjon, kun `teecup_frontend` (og en ren, uendret gjenoppbygging av
`teecup_api` som en bivirkning av `docker compose up --build` sin
avhengighets-oppløsning — ingen backend-kode rørt denne runden). Verifisert:
`/health`/`dashboard` → 200, `/manifest.webmanifest`/`sw.js`/ikoner alle
200 over ekte https, `teeoff.no` upåvirket. Se CLAUDE.md-status for full
200 over ekte https, `teeoff.no` upåvirket. Se CHANGELOG.md for full
byggerunde.
**Gjenstående, IKKE en del av "ferdig"-vurderingen over:** faktisk
@ -1544,7 +1544,7 @@ fortsatt avvises. Ekte typesjekket produksjonsbuild kjørt og bekreftet.
**Status: ✅ BYGGET OG LIVE 2026-07-20.** Migrasjon 015 kjørt mot ekte
`teecup_db`, bruker bekreftet eksplisitt. Begge containere redeployet,
`/health`/`/dashboard`/`/account` → 200, `teeoff.no` upåvirket. Se
CLAUDE.md-status for full byggerunde.
CHANGELOG.md for full byggerunde.
---
@ -3250,7 +3250,7 @@ IKKE finnes noe sted ellers i systemet i dag (`organization_id på ALLE
domenetabeller, håndhevet av RLS` er en ufravikelig invariant i
CLAUDE.md — en betinget/nullbar variant ville vært det FØRSTE
unntaket). Dette prosjektet har allerede én dokumentert hendelse
(RLS-tomstreng-bugen, se CLAUDE.md-status 2026-07-16) som kom av
(RLS-tomstreng-bugen, se CHANGELOG.md 2026-07-16) som kom av
nettopp denne typen RLS-finesse — vi unngår bevisst å introdusere en
ny variant av samme risikoklasse. Noe skjema dupliseres (hull-for-hull-
registrering ligner mye på `round_hole`), men den delte regnelogikken
@ -3832,7 +3832,7 @@ Disse må avklares før eller under de relevante fasene:
statistikk over utslag brukt per spiller") for full detalj.
**Fullført 2026-07-29:** samme funksjon bygget for org-scopede
turnering-scramble/-greensome også (`hole_score.selected_
participant_id`, migrasjon 039) — samme mønster, se CLAUDE.md-status
participant_id`, migrasjon 039) — samme mønster, se CHANGELOG.md
2026-07-29 for full detalj (validering, delt `submitStroke`-utvidelse,
ny `SelectedDriverSummary` i `session-scorecard.tsx`). Begge domener
dekket, ingen kjent gjenstående forskjell mellom scramble og greensome.

6609
CHANGELOG.md Normal file

File diff suppressed because it is too large Load diff

6354
CLAUDE.md

File diff suppressed because it is too large Load diff

View file

@ -19,7 +19,7 @@
> "alternativ designinstruks.md" (brukerens eget forslag, forsøkt først på
> dashbordet, deretter godkjent som ny fasit). Innholdet under ER derfor nå
> den gjeldende regelen — men er ikke nødvendigvis rullet ut på ALLE
> skjermer ennå (se `CLAUDE.md`-status for hvor langt konsistens-sjekken har
> skjermer ennå (se `CHANGELOG.md` for hvor langt konsistens-sjekken har
> kommet). Rett opportunistisk opp eldre skjermer mot dette dokumentet når
> de likevel røres, samme mønster som tilgjengelighetsregelen over.
@ -50,7 +50,7 @@ ellers virker helt av seg selv så lenge token-klassene brukes.
| Token | Lys modus | Rolle |
|---|---|---|
| `background` / `foreground` | nesten hvit / mørk grå | sideflate / brødtekst |
| `background` / `foreground` | ren hvit (identisk med `card`) / mørk grå | sideflate / brødtekst |
| `card` / `card-foreground` | hvit / mørk grå | kort, paneler, seksjoner |
| `primary` / `primary-foreground` | `oklch(0.7512 0.1613 130.33)` (grønn) / mørk | hovedhandling, valgt tilstand, "under par"/positiv |
| `secondary` / `secondary-foreground` | lys grå | sekundær vekt, sjeldent brukt alene |

View file

@ -78,7 +78,7 @@
multi-stage, Next.js `output: "standalone"`; ny `teecup_frontend`-tjeneste
i `docker-compose.yml`). Caddy (`teecup.teeoff.no`, i det SEPARATE
`teeoff`-repoet) peker nå på `teecup_frontend` i stedet for `teecup_api`
direkte — se ADR-016 for hvorfor, og CLAUDE.md-status for
direkte — se ADR-016 for hvorfor, og CHANGELOG.md for
driftsdetaljene (samme stale-Caddy-inode-hendelse som
containeriseringsrunden, løst likt).
- **Verifisert med FAKTISK e-postlevering, ikke bare curl:** ekte
@ -144,7 +144,7 @@
**Rettet:** `.env` bygget om til fem separate, rent navngitte felt
(`TEECUP_DB_HOST/PORT/NAME/USER/PASS`), `app/config.py`+`app/db.py` bygger
`asyncpg`-poolen fra disse i stedet for én DSN-streng,
`docker-compose.yml` oppdatert tilsvarende. Se CLAUDE.md-status for full
`docker-compose.yml` oppdatert tilsvarende. Se CHANGELOG.md for full
driftsdetalj, inkl. at dette krevde et fullt
`docker compose up -d --build --force-recreate` (ikke bare
`--force-recreate` — imaget bygger inn `app/` ved build-tid). Verifisert
@ -339,7 +339,7 @@
lenket fra hovedsiden. Bruker valgte å bygge fullt scorekort med i denne
runden (utover opprinnelig anbefaling om kun leaderboard+matchliste).
- Se ARCHITECTURE_DECISIONS.md ADR-023 (kaptein/deltaker) og ADR-026
(tilskuer) for alle delbeslutningene, og CLAUDE.md-status for
(tilskuer) for alle delbeslutningene, og CHANGELOG.md for
scratch-verifiseringen (15 + 16 automatiserte sjekker).
### Scoring-autorisasjon: hvem fører, hvem korrigerer, hvem lukker (rejst 2026-07-16)
@ -409,7 +409,7 @@
et lag de ikke er rostret på (forrige rundes utvidelse), men
`own_team_ids()` i `app/blind_draw.py` viste dem aldri tilbake før
reveal — utvidet til samme owner/admin-regel som skrive-siden. Se
CLAUDE.md-status for full runde, inkl. en tredje, urelatert 500-bug
CHANGELOG.md for full runde, inkl. en tredje, urelatert 500-bug
(manglende handicap-indeks) funnet og fikset samtidig.
- Kapteinene låser oppstillingen skjult; matchene avsløres samtidig når begge er
ferdige.
@ -867,7 +867,7 @@ som ADR-023s `user_is_match_participant`) brukt av `update_hole` — kun
deltakeren selv eller org-admin kan nå skrive score for en gitt
rundedeltaker. Runde-/deltaker-oppsett forblir bevisst på org-medlemsnivå
(samme presedens som `session`/`team_roster` i tournaments.py). 13 nye
scratch-sjekker + full 52-punkts regresjon, se CLAUDE.md-status
scratch-sjekker + full 52-punkts regresjon, se CHANGELOG.md
2026-07-30 for full detalj. De fem konkrete formatene (Københavner m.fl.)
og Order of Merit fortsatt ikke designet.
@ -891,7 +891,7 @@ egen SELECT (ADR-030s utledede datospenn) som aldri ble utvidet med
turneringer. Rettet. Et layout-hull i headeren (navn ble avkuttet på
smal mobil) og et manglende form-språk i scorekort-cellene (kun farge,
ikke sirkel/firkant som `round-scorecard.tsx` sin `ScoreMark`) ble også
funnet og rettet samme runde. Se CLAUDE.md-status 2026-07-30 for full
funnet og rettet samme runde. Se CHANGELOG.md 2026-07-30 for full
verifiseringsdetalj (håndregnet HCP-kryssjekk, databasebekreftet
persistens, full opprett-ny-turnering-flyt testet fra bunnen).
**IKKE rullet ut mot ekte systemer ennå** — venter på brukerens
@ -910,7 +910,7 @@ CLAUDE.md sin ufravikelige regel.
triggerpunkter (venneforespørsel sendt/akseptert, medspiller lagt til på
en runde, en venn ser en synlig runde, en tilkoblet runde fullført) OG en
e-post-fallback PER TYPE (`user_notification_email_pref`, trygg standard
= ingen e-post). Se CLAUDE.md-status for full detalj. Kun push til
= ingen e-post). Se CHANGELOG.md for full detalj. Kun push til
telefonens eget OS-varslingssystem (under) gjenstår av det opprinnelige
forslaget.
@ -1065,7 +1065,7 @@ utsendingen ligger ETT sted (inni `create_notification()` selv,
OG fremtidige varseltyper (`friend`/`round`/`result`/`tournament`) uten
at hvert kallsted må huske det selv. Ny `send_notification_email()` i
`app/email.py`, ny seksjon "Varsler på e-post" i `/account`. Se
CLAUDE.md-status 2026-07-28 for full detalj (19/19 scratch-sjekker).
CHANGELOG.md 2026-07-28 for full detalj (19/19 scratch-sjekker).
Opprinnelig forslag, for historikkens skyld: brukeren foreslo at TeeCup i
tillegg sender en e-post til mottakeren av en venneforespørsel ("du har
@ -1173,7 +1173,7 @@ ble kalt uten konsollfeil (selve den native installasjonsdialogen kan ikke
utløses i en automatisert nettleserøkt -- ærlig, forventet begrensning,
samme klasse som all annen "ekte OS-installasjon"-testing i prosjektet).
**Rullet ut live 2026-07-28** sammen med de tre andre punktene samme dag,
se CLAUDE.md-status for felles utrullingsdetalj.
se CHANGELOG.md for felles utrullingsdetalj.
---
@ -1374,7 +1374,7 @@ utvidelsesmønster). Netto/poeng vises nå også som en rolig sekundærlinje
RETT under det fremhevede brutto-hullmerket (inspirert av et
referansebilde fra en konkurrentapp bruker delte, bevisst IKKE en kopi av
fargevalg/layout).
Full verifiserings- og utrullingsdetalj i CLAUDE.md sin statuslogg
Full verifiserings- og utrullingsdetalj i CHANGELOG.md
(2026-07-26) -- ikke gjentatt her for å unngå duplisering.
### Oppdatering 2026-07-28: turnering-halvparten av spørsmålet AVKLART OG BYGGET
@ -1398,7 +1398,7 @@ matchlisten. Frontend: ny side
(`session-individual-leaderboard.tsx`, gjenbruker rangerings-/
mode-toggle-mønsteret fra `round-leaderboard.tsx` i en enklere,
sesjonsscopet variant), lenket fra blind draw-skjermen for kvalifiserende
økter. Scratch- og browserverifisert 2026-07-28, se CLAUDE.md-status.
økter. Scratch- og browserverifisert 2026-07-28, se CHANGELOG.md.
---
@ -1651,7 +1651,7 @@ rapportere "ingen synlig endring i det hele tatt" (bekreftet presist ved
en cache-feil, en ekte plasseringsfeil). Rettet ved å flytte boksen til
under scoringsseksjonen. Full beslutnings-/begrunnelsesdetalj i
ARCHITECTURE_DECISIONS.md (ADR-033-oppdatering 2026-07-26), full
verifiserings-/utrullingsdetalj i CLAUDE.md sin statuslogg — ikke
verifiserings-/utrullingsdetalj i CHANGELOG.md — ikke
duplisert her.
**Oppfølging 2026-07-27 — `teecup-scorekort-og-entry-spec.md` (nytt,
@ -1687,7 +1687,7 @@ ikke-browsersjekkede ble sjekket). Fant OG fikset en ny, ekte "til par"-
bug i `round-scorecard.tsx` (regnet mot hele rundens par i stedet for
kun spilte hulls par — ga en absurd "53 til par" midt i en runde).
Resten av de 22 skjermene bekreftet uten krasj/konsoll-feil. Full detalj
i CLAUDE.md sin statuslogg (2026-07-27) — ikke duplisert her.
i CHANGELOG.md (2026-07-27) — ikke duplisert her.
---
@ -1825,7 +1825,7 @@ bare den anbefalte ene). Løftet BEGGE `--chart-3` (blå → `--info`) OG
gjenbruk-fremfor-ny-nyanse-logikk) til egne kjernetoken i `globals.css`.
Konkret førstebruk: `--info` på et nytt "Hcp spilt til X"-merke på
rundekort, `--gold` på et nytt "Personlig rekord"-merke (laveste til-par
blant minst to fullførte runder). Full detalj i CLAUDE.md sin statuslogg
blant minst to fullførte runder). Full detalj i CHANGELOG.md
(2026-07-28) — ikke duplisert her.
---
@ -1903,7 +1903,7 @@ strukturell forskjell fra `round_hole`, som alltid forhåndsoppretter en
nullbar rad). Ny `SelectedDriverSummary` i "Vis full oversikt"-
seksjonen. 51/51 scratch-sjekker (full org-turnering-scaffold bygget fra
bunnen) + full nettleser-gjennomgang (ekte klikk, persistens bekreftet
direkte i databasen, riktig opptelling). Se CLAUDE.md-status 2026-07-29
direkte i databasen, riktig opptelling). Se CHANGELOG.md 2026-07-29
for full detalj. **Begge domener (frittstående runder og org-scopede
turneringer) dekker nå samme funksjon** -- ingen kjent gjenstående
forskjell mellom scramble og greensome i noen av domenene.
@ -1923,7 +1923,7 @@ forskjell mellom scramble og greensome i noen av domenene.
| Moderering (offentlig feed) | ✅ LIVE | Forfatteren selv, ELLER org-eier/admin, kan slette et innlegg. Lag-chatten har ingen moderering utover forfatteren (rommet er privat, org-admin har uansett ikke lesetilgang). |
Se ARCHITECTURE_DECISIONS.md ADR-025 for alle fire hovedbeslutningene og
CLAUDE.md-status for full byggerunde (datamodell, autorisasjon, scratch-
CHANGELOG.md for full byggerunde (datamodell, autorisasjon, scratch-
verifisering med 20 automatiserte sjekker inkl. reell WebSocket-sanntid,
og utrulling).
@ -1943,7 +1943,7 @@ dra-og-slipp-opplastingsskjermen i frontend (V0) gjenstår.
| "Deltaker"-tilgang (ikke org-medlem, men rostret/registrert) | ✅ | Ny `get_current_user_optional` + `_is_participant()`. Testet: rostret spiller som logget inn fikk tilgang, tilfeldig fremmed ble avvist. |
| Turnering-landingsside: tekst, program, sponsorer, påmelding (API) | ✅ | `description`-felt, `tournament_sponsor`-tabell (navn+lenke), `GET /public/tournaments/{id}/sessions` (gjenbruker blind draw-lås fra ADR-013). Selve SKJERMEN i frontend gjenstår. |
| Org-landingsside: klubbprofil, liste over turneringer (API) | ✅ | `GET /public/orgs/{slug}` — kun `public`-synlige turneringer. Bekreftet: klubb-profil KAN være offentlig (brukerens valg). |
| Lesbar URL (slug) for organisasjon | ✅ | Fantes faktisk allerede i skjemaet siden migrasjon 001 (oversett, funnet da migrasjon 009 feilet mot scratch — se CLAUDE.md-status). Kun `CHECK`-constraints lagt til i 009. |
| Lesbar URL (slug) for organisasjon | ✅ | Fantes faktisk allerede i skjemaet siden migrasjon 001 (oversett, funnet da migrasjon 009 feilet mot scratch — se CHANGELOG.md). Kun `CHECK`-constraints lagt til i 009. |
| Organisator kan faktisk SETTE disse feltene | ✅ | Implisitt hull fylt under bygging: `PATCH /orgs/{id}/tournaments/{id}` (visibility/description/registrering), `PATCH /orgs/{id}` (slug/public_profile), full sponsor-CRUD. |
| Bilder (hero, sponsorlogoer), backend | ✅ | Ny `teecup-minio`-tjeneste, ekte multipart-opplasting → AVIF-konvertering (Pillow) → lagring, live på `teecup.teeoff.no/teecup-media/*`. Selve opplastingsskjermen i frontend (V0) gjenstår. |
| Del-metadata (Open Graph: og:title/og:description) | ✅ | `generateMetadata()``/t/[id]`+`/clubs/[slug]`, ekte data fra API-et, verifisert mot produksjonsimaget. `og:image` gjenstår (MinIO). |
@ -2029,7 +2029,7 @@ hvis den har matcher). `PATCH` dekker enkle felt fritt, pluss en egen,
forsiktig gren for bane-bytte (finner/flytter tilsvarende tee per allerede
tillagt deltaker, regner om handicap+matchstatus for hele økten etterpå —
også for allerede AVGJORTE matcher, bekreftet eksplisitt av bruker). Se
CLAUDE.md-status for det fulle scenarioet (verifisert med et 10-hulls
CHANGELOG.md for det fulle scenarioet (verifisert med et 10-hulls
avgjort-match-eksempel) og et urelatert funn (`front_9`/`back_9` +
`stroke`-modus kan aldri få handicap i dag, siden `tee_rating` alltid kun
lages med `full_18`-omfang).
@ -2037,7 +2037,7 @@ lages med `full_18`-omfang).
Program-skjermen (ingen ny rute). Fanget en reell regresjon i selve
V0-eksporten før den ble tatt inn: samme runde hadde utilsiktet fjernet
bane-feltet fra "Legg til økt"-skjemaet — kun de nye rediger/slett-delene
ble hentet inn, det ekte opprett-skjemaet urørt. Se CLAUDE.md-status for
ble hentet inn, det ekte opprett-skjemaet urørt. Se CHANGELOG.md for
detaljer.
---
@ -2084,7 +2084,7 @@ rendret INNI det ytre økt-opprett-skjemaet — ugyldig, nestet HTML. Å klikke
"Søk" submittet i praksis det ytre skjemaet som en ekte side-navigasjon og
vasket bort `?org=...`-parameteren fra URL-en. Fikset ved å fjerne det
indre `<form>`-elementet (vanlig `<div>` + Enter-tast/knapp-klikk i
stedet). Se CLAUDE.md-status for full root cause.
stedet). Se CHANGELOG.md for full root cause.
**Nok en reell bug funnet og fikset samme uke, rapportert fra ekte bruk mot
`teecup.teeoff.no`:** import av samme teeoff-bane til flere økter (helt
@ -2095,7 +2095,7 @@ official-import` er nå idempotent (gir tilbake eksisterende rad ved
reimport, sjekket FØR teeoff-kallet), og navnet lagres nå som
"{anlegg} {bane}". Ekte produksjonsdata for "De Gamle er Eldst" ryddet
opp (en økt hadde ved et uhell fått en tom søppel-`custom`-bane — se
CLAUDE.md-status for full hendelse og rotårsak).
CHANGELOG.md for full hendelse og rotårsak).
**Åpent, ikke løst i denne runden:** de to bane-søkeflatene (øverste felt
= organisasjonens egne baner + "opprett ny"-snarvei, "Hent bane fra
teeoff"-knappen lenger ned = offisielt søk) er lette å forveksle — det var
@ -2121,7 +2121,7 @@ Full design i ARCHITECTURE_DECISIONS.md ADR-020.
| Kode-regenerering (ved lekket kode) | 💤 bevisst utsatt | Ikke bygget denne runden — ingen organisator-vei til å bytte ut en kode ennå. Egen sak hvis etterspurt. |
**ADR-020 er dermed helt ferdig** — backend + frontend, alle fire
del-ønsker levert samme dag. Se CLAUDE.md-status for full runde inkl. en
del-ønsker levert samme dag. Se CHANGELOG.md for full runde inkl. en
reell (og transparent håndtert) passord-eksponeringshendelse underveis.
---
@ -2131,13 +2131,13 @@ reell (og transparent håndtert) passord-eksponeringshendelse underveis.
| Del | Status | Notat |
|---|---|---|
| Flere organisasjoner per bruker | ✅ bekreftet allerede dekket | ADR-002 fra dag én. `POST /orgs` har ingen begrensning på antall org-er samme bruker kan eie. Ingen kodeendring — kun bekreftet ved gjennomgang 2026-07-19. |
| Sesjon holder seg ikke — ny magic-link-kode kreves ved hvert besøk | ✅ FIKSET og LIVE 2026-07-19 | Ikke en cookie-bug (brukerens ekte cookie var korrekt satt, 30 dager, `Secure`/`HttpOnly`). Root cause: `app/page.tsx` sjekket aldri om en gyldig sesjon allerede fantes før den viste innloggingsskjemaet. Fikset med server-side sesjonssjekk + redirect til `/dashboard`. Se CLAUDE.md-status for full diagnose og verifisering. |
| Sesjon holder seg ikke — ny magic-link-kode kreves ved hvert besøk | ✅ FIKSET og LIVE 2026-07-19 | Ikke en cookie-bug (brukerens ekte cookie var korrekt satt, 30 dager, `Secure`/`HttpOnly`). Root cause: `app/page.tsx` sjekket aldri om en gyldig sesjon allerede fantes før den viste innloggingsskjemaet. Fikset med server-side sesjonssjekk + redirect til `/dashboard`. Se CHANGELOG.md for full diagnose og verifisering. |
| Passord som valgfritt tillegg til magic-link | ✅ LIVE 2026-07-19 | Argon2id-hashing (ikke bcrypt — unngår 72-byte-trunkering). Verifisert med et ekte passord med mellomrom+æøå+spesialtegn. `POST /auth/login-password`, `/auth/set-password`, `/auth/remove-password`. Passord er ALDRI påkrevd. |
| 2FA: TOTP eller e-post-engangskode, brukerens eget valg | ✅ LIVE 2026-07-19 | SMS bevisst utenfor omfang (krever betalt leverandør). `POST /auth/2fa/setup/start`+`/confirm`, `/auth/2fa/verify`, `/auth/2fa/disable`. |
| 2FA påkrevd for org-eier/admin, valgfritt for medlemmer | ✅ LIVE 2026-07-19 | Håndheves ved hver innlogging via en `stage: "pending_2fa"`/`"must_enroll_2fa"`-mellomtilstand i sesjons-JWT-en. Verifisert: en fersk org-eier uten 2FA ble korrekt tvunget inn i oppsett ved neste innlogging. |
| Frontend: passord-innlogging, 2FA-verifisering/-oppsett, kontoinnstillinger | ✅ LIVE 2026-07-19 | `login-form.tsx` (passord-modus), `two-factor-flow.tsx` (delt mellom login/verify), `/account`. |
**ADR-021 er dermed helt ferdig.** Se CLAUDE.md-status for full byggerunde,
**ADR-021 er dermed helt ferdig.** Se CHANGELOG.md for full byggerunde,
inkl. tre reelle bugs funnet og fikset under scratch-testing.
### Organisasjonseierskap: dele, invitere, frasi seg, superadmin — ADR-022
@ -2252,7 +2252,7 @@ en reell skjemamigrasjon (`014_tee_gender_to_rating.sql`).
(med alle 18 hull) fortsatt scorer helt uendret (begge sider, full
scorekort-henting). `test_isolation.sql` 12/12 uendret (ren
Python-logikk-fiks, ingen migrasjon). Rullet ut sammen med
dashbord-hilsen-fiksen under, se CLAUDE.md-status.
dashbord-hilsen-fiksen under, se CHANGELOG.md.
**Designspørsmålene for punkt 2 er avklart** (bruker valgte det anbefalte
alternativet på begge, se ADR-029 Beslutning B/C): helautomatisk tee-valg
@ -2514,7 +2514,7 @@ koblingen skal skje).
en ekte innlogget nettleser (Chrome DevTools) — knappen viste korrekt
«1 invitasjon sendt, 1 mangler registrert e-post» første gang, «0
invitasjoner sendt, 1 allerede sendt tidligere, …» andre gang. Se
CLAUDE.md-status 2026-07-28 for full detalj.
CHANGELOG.md 2026-07-28 for full detalj.
---
@ -2675,7 +2675,7 @@ obligatorisk**, før noe annet i appen (inkl. dashbordet) er tilgjengelig.
ikke ved dypere direktelenker til andre autentiserte sider (f.eks. en
bokmerket turnering-URL) — samme skope-disiplin som tidligere runder.
**Verifisert:** se full detalj i CLAUDE.md-status 2026-07-22 — 16/16
**Verifisert:** se full detalj i CHANGELOG.md 2026-07-22 — 16/16
scratch-backend-sjekker, `test_isolation.sql` 12/12, ekte typesjekket
produksjonsbuild, og et ekte HTTP-nivå-bevis mot en kjørende
produksjonscontainer (anonym → 200 innloggingsskjema, ekte innlogget-men-
@ -2693,7 +2693,7 @@ og vil derfor begge se profil-fullførings-skjemaet ved neste innlogging.
**Bygget som beskrevet i "2026-07-25"-forslaget under** (de sju blokkene:
Hurtighandlinger/Kommende runder/Kommende turneringer/Statistikk/Spilte
baner/Venner/Organisasjoner) — se ADR-035 i ARCHITECTURE_DECISIONS.md og
CLAUDE.md-status 2026-07-25 for full bygge-/verifiseringsdetalj.
CHANGELOG.md 2026-07-25 for full bygge-/verifiseringsdetalj.
Organisasjon er ikke lenger første/eneste synlige vei inn; opprettes nå
usynlig i bakgrunnen når en bruker trykker "Ny turnering".
@ -2721,7 +2721,7 @@ inn.
det ALLER første en innlogget bruker med en ufullstendig personlig profil
skal se, er en obligatorisk «Fullfør profilen din»-visning (fornavn/
etternavn/fødselsdato/kjønn/HCP/hjemmeklubb/land — alt utenom bilde og
beskrivelse) — se CLAUDE.md-status, ✅ BYGGET OG LIVE. Dette svarer på
beskrivelse) — se CHANGELOG.md, ✅ BYGGET OG LIVE. Dette svarer på
"hva møter en fersk bruker aller først", men IKKE på det opprinnelige
spørsmålet i denne seksjonen: hva skal dashbordets tom-tilstand vise for
en bruker som HAR fullført profilen, men ennå ikke har noen organisasjon/
@ -2883,7 +2883,7 @@ fritekst-versjonen.
bygget, scratch-/browserverifisert og live 2026-07-28
(`round.visibility_mode`, migrasjon 032, `_can_view_round`/nye
`/public/rounds/*`-endepunkter, ny `/watch/[id]`-side og
`/my-friends/[id]`-profilside). Se CLAUDE.md-status 2026-07-28 for
`/my-friends/[id]`-profilside). Se CHANGELOG.md 2026-07-28 for
full detalj (191 automatiserte sjekker + full nettleser-gjennomgang
med tre reelle nettleserkontekster).
3. **✅ Ekte medspillere** (ikke bare gjester) — utvider
@ -3060,7 +3060,7 @@ entydig, ingen dropdown nødvendig.
Scratch-verifisert (happy path, kaptein-nullstilling, 409/400/404 alle
bekreftet) OG bekreftet direkte i en ekte innlogget nettleser (Chrome
DevTools): flytting oppdaterte begge lags roster-lister og -antall
umiddelbart, ingen konsollfeil. Ingen migrasjon. Se CLAUDE.md-status
umiddelbart, ingen konsollfeil. Ingen migrasjon. Se CHANGELOG.md
2026-07-28.
---
@ -3187,7 +3187,7 @@ fullførte oppretting, bekreftet DIREKTE I DATABASEN at begge rundene nå
delte samme `flight_group_id`. Kombinert leaderboard-siden bekreftet
visuelt (skjermbilde) med korrekt rangering/farger, ingen konsollfeil
noe sted i hele flyten.
**Rullet ut live 2026-07-28**, se CLAUDE.md-status for felles
**Rullet ut live 2026-07-28**, se CHANGELOG.md for felles
utrullingsdetalj med de tre andre punktene samme dag.
---
@ -3204,7 +3204,7 @@ vs. faktisk/beregnet), en eksplisitt "overfør til manuelt HCP"-handling,
en manuell eksklusjons-toggel per deltaker (kontrollert av HVER
innlogget deltaker for sin egen rad), og selvdeklarert spilleform
(slagspill/matchspill) med en anbefalt-men-overstyrbar eksklusjon for
matchspill. Se CLAUDE.md-status 2026-07-28 for full detalj (migrasjon
matchspill. Se CHANGELOG.md 2026-07-28 for full detalj (migrasjon
`030`, backend/frontend-endringer, verifisering).
**Fortsatt IKKE tettet, notert samme runde:** offline-kø kun for
turnering-scorekortet (ikke frittstående runder), ingen Stableford,
@ -3275,7 +3275,7 @@ Løst nøyaktig som antatt her: `'stableford'` lagt til som eget
"plukket opp" lagres som en cap på (netto) score -- Net Double Bogey,
samme prinsipp som WHS allerede bruker, via den eksisterende
`max_hole_score_for_handicap()` i `handicap_engine.py` (ingen ny
motorlogikk). Full detalj i CLAUDE.md-status 2026-07-29, inkl. to
motorlogikk). Full detalj i CHANGELOG.md 2026-07-29, inkl. to
reelle frontend-bugs funnet og fikset under browserverifisering
(`play_format === "stroke"`-spesialsjekker som ikke ekskluderte det
nye "stableford"-formatet, samme mønster funnet og rettet fem steder
@ -3465,7 +3465,7 @@ hvilke "første handling"-alternativer dashbordet bør vise i fremtiden.
**Oppdatering 2026-07-28 (ADR-039):** design + backend bygget samme dag
som punktet ble reist, frontend (sideoppsett/skins-konfig/matchstatus-
visning/delt-ball-scorekort) og selve utrullingen mot ekte `teecup_db`/
`teecup_api` fulgte rett etter, samme dag — se CLAUDE.md-status
`teecup_api` fulgte rett etter, samme dag — se CHANGELOG.md
2026-07-28 for full detalj (migrasjon `031`, nye endepunkter, 96+26
backend-scratch-sjekker, full nettleser-verifisering av alle seks
formater). Kort: alle fire load-bærende spørsmål (sider/gruppering,
@ -3721,7 +3721,7 @@ Full design i ARCHITECTURE_DECISIONS.md ADR-028. Kort:
| Service worker: cache app-navigasjon + `/orgs/*`-GET-er | ✅ bygget | `public/sw.js`, nettverk-først/cache-fallback (bevisst IKKE stale-while-revalidate, se ADR-028). `public/offline.html` som siste utvei. |
| Offline scoreregistrering (hole-scores/hole-results) | ✅ bygget | `lib/offline-queue.ts` (IndexedDB-kø) + `components/session-scorecard.tsx`. Synker automatisk ved `window`s `online`-event, pluss manuell "Synkroniser nå"-knapp. Bevisst IKKE Background Sync API (iOS Safari støtter den ikke). |
| Andre skrivehandlinger offline (walkover, chat/feed, oppsett) | 💤 bevisst utenfor omfang | Kun de to scoreregistrerings-endepunktene er køet — se ADR-028 Beslutning B for begrunnelse per type. |
| Faktisk browser-testet (DevTools Offline-modus) | ✅ **FERDIG 2026-07-28** | Både turnering-scorekortet (den opprinnelige ADR-028-flyten) og den senere utvidelsen til frittstående runder er nå bevist med ekte Chrome DevTools-nettverksemulering (offline → registrer slag → tilbake online → automatisk synk bekreftet server-side), ikke bare kodegjennomgang. Se CLAUDE.md-status 2026-07-28. |
| Faktisk browser-testet (DevTools Offline-modus) | ✅ **FERDIG 2026-07-28** | Både turnering-scorekortet (den opprinnelige ADR-028-flyten) og den senere utvidelsen til frittstående runder er nå bevist med ekte Chrome DevTools-nettverksemulering (offline → registrer slag → tilbake online → automatisk synk bekreftet server-side), ikke bare kodegjennomgang. Se CHANGELOG.md 2026-07-28. |
**Rullet ut live 2026-07-19**, bruker bekreftet eksplisitt: `docker compose
up -d --build teecup_frontend` (ingen migrasjon). Verifisert:

View file

@ -14,17 +14,26 @@ vanlig tilkobling (typisk port 587/25).
import smtplib
from asyncio import to_thread
from dataclasses import dataclass
from email.message import EmailMessage
from html import escape as _esc
from .config import settings
def _send_sync(to_email: str, subject: str, body: str) -> None:
def _send_sync(to_email: str, subject: str, body: str, html_body: str | None = None) -> None:
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = settings.FROM_EMAIL
msg["To"] = to_email
msg.set_content(body)
# html_body er valgfritt (2026-08-03, rundeoppsummering til midlertidige
# spillere -- FØRSTE HTML-e-post i appen). add_alternative() gjør
# meldingen multipart/alternative: body over forblir en ekte tekst-
# fallback for klienter uten HTML-støtte, html_body er det klienter
# faktisk viser når de kan.
if html_body is not None:
msg.add_alternative(html_body, subtype="html")
if settings.SMTP_PORT == 465:
with smtplib.SMTP_SSL(settings.SMTP_SERVER, settings.SMTP_PORT) as smtp:
@ -336,3 +345,121 @@ async def send_new_account_alert_email(to_email: str, new_user_email: str, displ
f"Navn: {display_name}\n"
)
await to_thread(_send_sync, to_email, subject, body)
@dataclass(frozen=True)
class RoundSummaryHole:
hole_number: int
par: int
score: int | None
net: int | None
async def send_round_summary_email(
to_email: str,
guest_first_name: str,
round_label: str,
holes: list[RoundSummaryHole],
stat_lines: list[tuple[str, str]],
raw_token: str,
) -> None:
"""Rundeoppsummering til en midlertidig spiller (gjest) med registrert
e-post, sendt ved fullføring (app/routers/rounds.py sin complete_round,
2026-08-03). FØRSTE HTML-e-post i appen -- multipart/alternative via
_send_sync sin nye html_body-parameter, med en ekte tekst-fallback.
Navneformat (CLAUDE.md, ufravikelig): dette ER direkte adressering til
mottakeren -- KUN fornavn i hilsenen, aldri fullt navn.
`stat_lines` er allerede ferdig utledet og formatert av kalleren (samme
"bygg teksten i routeren, ikke i e-post-modulen"-mønster som
send_scorecard_invitations/send_session_result_email) -- graderer seg
naturlig etter hva som faktisk ble spilt (stat_level, individuell-ball
vs. delt-ball) siden kalleren rett og slett utelater linjer det ikke
finnes data for.
Alltid norsk, samme presedens som send_scorecard_invitations (gjesten
har ingen lagret språkpreferanse -- ingen konto ennå)."""
link = f"{settings.PUBLIC_BASE_URL}/verify?token={raw_token}"
subject = f"Scorekortet ditt fra {round_label}"
text_lines = [
f"Hei {guest_first_name},",
"",
f"Her er scorekortet ditt fra {round_label}:",
"",
]
for h in holes:
score_txt = str(h.score) if h.score is not None else ""
net_txt = str(h.net) if h.net is not None else ""
text_lines.append(f"Hull {h.hole_number} (par {h.par}): {score_txt} slag (netto {net_txt})")
text_lines.append("")
for label, value in stat_lines:
text_lines.append(f"{label}: {value}")
text_lines += [
"",
"Noen registrerte deg som spiller i TeeCup med denne e-postadressen -- "
"du trenger ingen konto for å se dette scorekortet, men med én kan du "
"føre score selv, følge egen statistikk og handicap-utvikling over tid.",
"",
f"Logg inn og se hele runden ({settings.MAGIC_LINK_MAX_AGE_MINUTES} minutter):",
link,
"",
"Ikke interessert? Ignorer denne e-posten -- den brukes ikke til noe annet.",
]
body = "\n".join(text_lines)
hole_rows_html = "\n".join(
f'<tr><td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.hole_number}</td>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.par}</td>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.score if h.score is not None else ""}</td>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.net if h.net is not None else ""}</td></tr>'
for h in holes
)
stat_rows_html = "\n".join(
f'<tr><td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;color:#424941;">{_esc(label)}</td>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;font-weight:700;">{_esc(value)}</td></tr>'
for label, value in stat_lines
)
html_body = f"""\
<!doctype html>
<html><body style="margin:0;padding:0;background-color:#f3f6f2;font-family:Arial,Helvetica,sans-serif;color:#012c11;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f3f6f2;padding:24px 0;">
<tr><td align="center">
<table role="presentation" width="100%" style="max-width:480px;background-color:#ffffff;border-radius:16px;overflow:hidden;">
<tr><td style="background-color:#1a4325;padding:20px 24px;">
<span style="color:#ffffff;font-size:20px;font-weight:800;">TeeCup</span>
</td></tr>
<tr><td style="padding:24px;">
<p style="margin:0 0 16px;font-size:16px;">Hei {_esc(guest_first_name)},</p>
<p style="margin:0 0 20px;font-size:16px;">Her er scorekortet ditt fra <strong>{_esc(round_label)}</strong>:</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-size:14px;margin-bottom:20px;">
<tr style="background-color:#edf3ef;font-weight:700;">
<td style="padding:6px 10px;">Hull</td><td style="padding:6px 10px;">Par</td>
<td style="padding:6px 10px;">Slag</td><td style="padding:6px 10px;">Netto</td>
</tr>
{hole_rows_html}
</table>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-size:14px;margin-bottom:24px;">
{stat_rows_html}
</table>
<p style="margin:0 0 20px;font-size:14px;color:#424941;">
Noen registrerte deg som spiller i TeeCup med denne e-postadressen -- du trenger ingen
konto for å se dette scorekortet, men med én kan du føre score selv, følge egen
statistikk og handicap-utvikling over tid.
</p>
<table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="border-radius:12px;background-color:#1f6b08;">
<a href="{link}" style="display:inline-block;padding:14px 28px;color:#ffffff;font-size:16px;
font-weight:700;text-decoration:none;">Logg inn og se hele runden</a>
</td></tr></table>
<p style="margin:20px 0 0;font-size:12px;color:#767f75;">
Lenken er gyldig i {settings.MAGIC_LINK_MAX_AGE_MINUTES} minutter. Ikke interessert? Ignorer denne
e-posten -- den brukes ikke til noe annet.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>
"""
await to_thread(_send_sync, to_email, subject, body, html_body)

View file

@ -110,6 +110,48 @@ def _build_qr_data_uri(data: str) -> str:
return f"data:image/png;base64,{b64}"
async def _link_round_participants_by_email(conn, user_id: str, email: str) -> None:
"""Kobler frittstående-runde-gjester (round_participant.guest_email) til
denne -innloggede kontoen (2026-08-03) -- samme "kjør trygt på HVER
innlogging"-idempotens som link_player_by_email (WHERE user_id IS NULL),
men INGEN SECURITY DEFINER-bro trengs: round/round_participant har ingen
RLS (ADR-033 Beslutning A, plain_connection()), en rett UPDATE er nok.
Bevisst IKKE en blind kobling -- se ADR-038-tillitsresonnementet: en
FREMMED kan i prinsippet ha registrert deg som gjest med feil/dårlig
score. `exclude_from_handicap = true` settes derfor alltid nykoblede
rader, uansett hva raden opprinnelig hadde -- personen selv slå den
(update_participant) for at runden skal telle mot faktisk HCP.
`NOT EXISTS`-vaktet mot en allerede eksisterende ekte deltaker-rad for
(round_id, user_id) -- ville ellers brutt UNIQUE-indeksen fra migrasjon
027 hvis personen tilfeldigvis også er lagt til som ekte spiller
SAMME runde. guest_name/guest_first_name/guest_last_name nullstilles
ved kobling (samme "nøyaktig ett av user_id/guest_first_name"-invariant
som CHECK-constrainten round_participant håndhever ved INSERT) --
`gender` derimot IKKE: den er NOT NULL og brukt til tee-rating-oppslag
for ALLE deltakere, ikke bare gjester (reelt funn under scratch-
testing -- en første versjon nullet den også, brøt NOT NULL-
constrainten). Beholder gjestens opprinnelig snapshottede kjønn
uendret; update_participant sin egen guest_only_fields-sjekk låser
uansett feltet mot videre endring snart user_id er satt."""
await conn.execute(
"""
UPDATE round_participant AS rp
SET user_id = $1,
exclude_from_handicap = true,
guest_name = NULL, guest_first_name = NULL, guest_last_name = NULL
WHERE rp.user_id IS NULL
AND lower(rp.guest_email) = lower($2)
AND NOT EXISTS (
SELECT 1 FROM round_participant rp2
WHERE rp2.round_id = rp.round_id AND rp2.user_id = $1
)
""",
user_id,
email,
)
class MagicLinkRequest(BaseModel):
email: EmailStr
locale: Literal["nb", "en"] = "nb"
@ -297,6 +339,7 @@ async def verify_magic_link(body: MagicLinkVerify, response: Response, request:
# er idempotente, berører kun rader som ennå ikke er koblet/forbrukt.
await conn.execute("SELECT link_player_by_email($1, $2)", user_id, email)
await conn.execute("SELECT accept_pending_invitations_by_email($1, $2)", user_id, email)
await _link_round_participants_by_email(conn, user_id, email)
# Driftsvarsel til appens eier ved en HELT NY konto (2026-07-30) --
# BEVISST etter at connection-blokken er lukket, samme "e-post skal
@ -354,6 +397,7 @@ async def login_with_password(body: PasswordLoginRequest, response: Response, re
await conn.execute("SELECT link_player_by_email($1, $2)", user_row["id"], email)
await conn.execute("SELECT accept_pending_invitations_by_email($1, $2)", user_row["id"], email)
await _link_round_participants_by_email(conn, user_row["id"], email)
return await _issue_login_result(response, request, user_row["id"])

View file

@ -34,8 +34,10 @@ from __future__ import annotations
import json
import math
import secrets
import traceback
import uuid
from datetime import date, datetime, timedelta
from datetime import date, datetime, timedelta, timezone
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
@ -43,10 +45,13 @@ from pydantic import BaseModel, EmailStr, Field
from .. import teeoff_client
from ..auth import CurrentUser, get_current_user, get_current_user_from_websocket, get_current_user_optional
from ..config import settings
from ..db import plain_connection
from ..email import RoundSummaryHole, send_round_summary_email
from ..errors import app_error, translate_db_errors
from ..handicap import SIDE_IS_UNIT, parse_allowance_config
from ..realtime import broadcast_round_update, live_sockets_for_round
from .auth import _hash_secret
from .notifications import create_notification
from handicap_engine import (
HoleResult,
@ -767,6 +772,12 @@ class RoundParticipantOut(BaseModel):
id: str
user_id: str | None
guest_name: str | None
# Kilden til sannhet for redigering (2026-08-03) -- guest_name over
# forblir det auto-synkroniserte, VISTE fulle navnet (samme mønster som
# app_user.display_name synkes fra first_name/last_name). guest_last_name
# er valgfritt (en gjest kan legges til med kun fornavn).
guest_first_name: str | None
guest_last_name: str | None
# Valgfritt kontaktfelt, KUN for gjester (2026-07-26) -- ingen betydning
# for en lenket bruker, som allerede har sin egen kontos e-post.
guest_email: str | None
@ -884,7 +895,8 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
)
participant_rows = await conn.fetch(
"""
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name, rp.guest_email,
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name,
rp.guest_first_name, rp.guest_last_name, rp.guest_email,
COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name,
rp.is_owner, rp.gender, rp.tee_name_snapshot,
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
@ -1008,6 +1020,8 @@ async def _create_participant(
*,
user_id: str | None,
guest_name: str | None,
guest_first_name: str | None = None,
guest_last_name: str | None = None,
is_owner: bool,
gender: str,
handicap_index: float | None,
@ -1028,15 +1042,18 @@ async def _create_participant(
participant_row = await conn.fetchrow(
"""
INSERT INTO round_participant
(round_id, user_id, guest_name, guest_email, is_owner, gender, tee_name_snapshot, handicap_index_snapshot,
(round_id, user_id, guest_name, guest_first_name, guest_last_name, guest_email, is_owner, gender,
tee_name_snapshot, handicap_index_snapshot,
course_rating_snapshot, slope_rating_snapshot, tee_par_snapshot, course_handicap_snapshot,
stat_level, exclude_from_handicap, round_side_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
RETURNING id::text AS id
""",
round_id,
user_id,
guest_name,
guest_first_name,
guest_last_name,
guest_email,
is_owner,
gender,
@ -1477,6 +1494,44 @@ async def get_rounds_stats_summary(
)
class KnownGuestOut(BaseModel):
guest_first_name: str
guest_last_name: str | None
gender: Literal["m", "f", "x"] | None
handicap_index: float | None
@router.get("/rounds/guests/known", response_model=KnownGuestOut | None)
async def find_known_guest(email: str, user: CurrentUser = Depends(get_current_user)) -> KnownGuestOut | None:
""""Gjenkjenn denne e-posten"-oppslag (2026-08-03) -- bevisst scoped til
DENNE brukerens EGNE tidligere registrerte gjester, ikke globalt
tvers av alle organisatorer. Et globalt oppslag ville latt hvem som
helst skrive inn en tilfeldig e-post og se navn/kjønn/HCP en HELT ANNEN
organisator har registrert -- en reell personvernlekkasje. Returnerer
den NYESTE treffende gjeste-raden (spillerens opplysninger kan ha
endret seg siden forrige runde)."""
email = email.strip()
if not email:
return None
async with plain_connection() as conn:
row = await conn.fetchrow(
"""
SELECT rp.guest_first_name, rp.guest_last_name, rp.gender,
rp.handicap_index_snapshot::float AS handicap_index
FROM round_participant rp
JOIN round r ON r.id = rp.round_id
WHERE r.owner_user_id = $1
AND rp.guest_first_name IS NOT NULL
AND lower(rp.guest_email) = lower($2)
ORDER BY r.played_at DESC
LIMIT 1
""",
user.user_id,
email,
)
return KnownGuestOut(**dict(row)) if row is not None else None
async def _get_owned_round_or_404(conn, round_id: str, user_id: str):
"""Strengt eier-only -- for runde-forvaltning (rediger/slett metadata,
legge til/fjerne deltakere). IKKE for lesing/scoreregistrering, se
@ -2067,10 +2122,13 @@ async def delete_round(round_id: str, user: CurrentUser = Depends(get_current_us
# ---------------------------------------------------------------------------
class ParticipantCreate(BaseModel):
# Nøyaktig én av user_id/guest_name.
# Nøyaktig én av user_id/guest_first_name.
user_id: str | None = None
guest_name: str | None = Field(default=None, min_length=1, max_length=100)
# Kun brukt for guest_name-varianten -- for user_id hentes kjønn/HCP fra
# guest_last_name er bevisst valgfritt (en gjest kan legges til med kun
# fornavn) -- guest_first_name er den eneste PÅKREVDE gjeste-identiteten.
guest_first_name: str | None = Field(default=None, min_length=1, max_length=60)
guest_last_name: str | None = Field(default=None, min_length=1, max_length=60)
# Kun brukt for gjest-varianten -- for user_id hentes kjønn/HCP fra
# personens egen profil (samme kilde som eierens egen deltaker-rad).
gender: Literal["m", "f", "x"] | None = None
handicap_index: float | None = Field(default=None, ge=-10, le=54)
@ -2098,8 +2156,8 @@ async def add_participant(
body: ParticipantCreate,
user: CurrentUser = Depends(get_current_user),
) -> RoundParticipantOut:
if (body.user_id is not None) == (body.guest_name is not None):
raise app_error(400, "VALIDATION_FAILED", "Oppgi enten user_id (funnet via søk) eller guest_name, ikke begge/ingen.")
if (body.user_id is not None) == (body.guest_first_name is not None):
raise app_error(400, "VALIDATION_FAILED", "Oppgi enten user_id (funnet via søk) eller guest_first_name, ikke begge/ingen.")
if body.user_id is not None and body.guest_email is not None:
raise app_error(400, "VALIDATION_FAILED", "guest_email gir ikke mening sammen med user_id -- en lenket bruker har allerede sin egen konto-e-post.")
@ -2145,11 +2203,15 @@ async def add_participant(
gender = target["gender"]
handicap_index = target["handicap_index"]
guest_name = None
guest_first_name = None
guest_last_name = None
linked_user_id = body.user_id
else:
gender = body.gender
handicap_index = body.handicap_index
guest_name = body.guest_name.strip()
guest_first_name = body.guest_first_name.strip()
guest_last_name = body.guest_last_name.strip() if body.guest_last_name else None
guest_name = f"{guest_first_name} {guest_last_name}".strip() if guest_last_name else guest_first_name
linked_user_id = None
if gender is None:
raise app_error(400, "VALIDATION_FAILED", "gender er påkrevd for en gjest uten konto.")
@ -2164,7 +2226,8 @@ async def add_participant(
async with conn.transaction(), translate_db_errors():
participant_id = await _create_participant(
conn, round_id, resolved, tee_name,
user_id=linked_user_id, guest_name=guest_name, is_owner=False,
user_id=linked_user_id, guest_name=guest_name,
guest_first_name=guest_first_name, guest_last_name=guest_last_name, is_owner=False,
gender=gender, handicap_index=handicap_index,
stat_level=body.stat_level,
guest_email=body.guest_email,
@ -2194,7 +2257,8 @@ async def add_participant(
)
row = await conn.fetchrow(
"""
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name, rp.guest_email,
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name,
rp.guest_first_name, rp.guest_last_name, rp.guest_email,
COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name,
rp.is_owner, rp.gender, rp.tee_name_snapshot,
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
@ -2322,8 +2386,12 @@ class ParticipantUpdate(BaseModel):
# KUN gyldig for en gjest (user_id IS NULL) -- gir ingen mening for en
# lenket bruker, som allerede har sin egen kontos navn/kjønn/e-post.
# `gender` er en del av rating-bunten under (påvirker hvilken utslags-
# rating som er gyldig), `guest_name`/`guest_email` er rene metadata.
guest_name: str | None = Field(default=None, min_length=1, max_length=100)
# rating som er gyldig), `guest_first_name`/`guest_last_name`/
# `guest_email` er rene metadata. `guest_name` (det VISTE fulle navnet)
# synkes automatisk av update_participant når ett av disse to endres --
# sendes aldri direkte av klienten.
guest_first_name: str | None = Field(default=None, min_length=1, max_length=60)
guest_last_name: str | None = Field(default=None, min_length=1, max_length=60)
gender: Literal["m", "f", "x"] | None = None
guest_email: EmailStr | None = None
# ADR-038 Beslutning C -- eneste feltet en IKKE-eier (en lenket
@ -2342,8 +2410,8 @@ class ParticipantUpdate(BaseModel):
# Felt en ikke-eier ALDRI kan sende her, uansett hvilken rad det gjelder --
# eier-only, se update_participant sin autorisasjonssjekk.
_OWNER_ONLY_PARTICIPANT_FIELDS = {
"stat_level", "tee_name", "handicap_index", "guest_name", "gender", "guest_email", "round_side_id",
"lineup_order",
"stat_level", "tee_name", "handicap_index", "guest_first_name", "guest_last_name", "gender", "guest_email",
"round_side_id", "lineup_order",
}
@ -2366,7 +2434,7 @@ async def update_participant(
"""
SELECT rp.user_id::text AS user_id, rp.gender, rp.tee_name_snapshot,
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
rp.counts_for_handicap,
rp.counts_for_handicap, rp.guest_first_name, rp.guest_last_name,
r.owner_user_id::text AS round_owner_user_id,
r.completed_at, r.course_source, r.teeoff_facility_slug,
r.teeoff_course_id, r.personal_course_id, r.play_format
@ -2389,7 +2457,7 @@ async def update_participant(
if current["user_id"] != user.user_id:
raise app_error(403, "NOT_AUTHORIZED", "Du kan kun endre dette for din egen deltakelse.")
guest_only_fields = {"guest_name", "gender", "guest_email"}
guest_only_fields = {"guest_first_name", "guest_last_name", "gender", "guest_email"}
if current["user_id"] is not None and guest_only_fields & updates.keys():
raise app_error(
400, "VALIDATION_FAILED",
@ -2409,8 +2477,22 @@ async def update_participant(
if "stat_level" in updates:
values.append(updates["stat_level"])
set_clauses.append(f"stat_level = ${len(values)}")
if "guest_name" in updates:
values.append(updates["guest_name"].strip())
if "guest_first_name" in updates or "guest_last_name" in updates:
# guest_name (det VISTE fulle navnet) synkes automatisk her --
# samme mønster som app_user.display_name synkes fra first_name/
# last_name (update_profile, routers/auth.py). Slår sammen med
# det som IKKE ble sendt i dette kallet, slik at en PATCH med
# kun ett av de to feltene ikke nullstiller det andre.
new_first = (
updates["guest_first_name"].strip() if "guest_first_name" in updates else current["guest_first_name"]
)
new_last_raw = updates["guest_last_name"] if "guest_last_name" in updates else current["guest_last_name"]
new_last = new_last_raw.strip() if new_last_raw else None
values.append(new_first)
set_clauses.append(f"guest_first_name = ${len(values)}")
values.append(new_last)
set_clauses.append(f"guest_last_name = ${len(values)}")
values.append(f"{new_first} {new_last}".strip() if new_last else new_first)
set_clauses.append(f"guest_name = ${len(values)}")
if "guest_email" in updates:
values.append(updates["guest_email"])
@ -2511,7 +2593,8 @@ async def update_participant(
row = await conn.fetchrow(
"""
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name, rp.guest_email,
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name,
rp.guest_first_name, rp.guest_last_name, rp.guest_email,
COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name,
rp.is_owner, rp.gender, rp.tee_name_snapshot,
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
@ -4302,12 +4385,106 @@ async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_
conn, user_id=uid, type="result", message=message, link_path=f"/watch/{round_id}",
)
await _send_guest_round_summaries(conn, round_id, round_label)
result = await _load_round_out(conn, round_id, user.user_id)
await broadcast_round_update(round_id)
return result
async def _send_guest_round_summaries(conn, round_id: str, round_label: str) -> None:
"""Rundeoppsummering til midlertidige spillere med registrert e-post
(2026-08-03) -- ETT ekte magic-link-innloggingstoken per gjest, samme
mønster som tournaments.py sin send_scorecard_invitations. Graderer seg
naturlig etter individuell-ball (egne slag/putter) vs. delt-ball
(kun sidens felles slag, ADR-039 Beslutning C) -- ingen egen
format-sjekk trengs, feltene som ikke finnes er rett og slett fraværende
i kildedataene."""
guest_rows = await conn.fetch(
"""
SELECT id::text AS id, guest_email, guest_first_name, round_side_id::text AS round_side_id, stat_level
FROM round_participant
WHERE round_id = $1 AND guest_email IS NOT NULL
""",
round_id,
)
for g in guest_rows:
stat_lines: list[tuple[str, str]] = []
if g["round_side_id"] is not None:
side_holes = await _build_side_holes(conn, round_id, g["round_side_id"])
summary_holes = [
RoundSummaryHole(
hole_number=h.hole_number,
par=h.par,
score=h.score if h.played else None,
net=h.score - h.strokes_received
if h.played and h.score is not None and h.strokes_received is not None
else None,
)
for h in side_holes
]
else:
p_holes = await _build_participant_holes(conn, round_id, g["id"])
summary_holes = [
RoundSummaryHole(
hole_number=h.hole_number,
par=h.par,
score=h.score if h.played else None,
net=h.score - h.strokes_received
if h.played and h.score is not None and h.strokes_received is not None
else None,
)
for h in p_holes
]
if g["stat_level"] != "strokes_only":
total_putts = sum(h.putts for h in p_holes if h.played and h.putts is not None)
if total_putts:
stat_lines.append(("Putter totalt", str(total_putts)))
played_holes = [h for h in summary_holes if h.score is not None]
if played_holes:
total_score = sum(h.score for h in played_holes)
to_par = total_score - sum(h.par for h in played_holes)
lead_lines = [("Slag totalt", str(total_score)), ("Til par", _signed(to_par))]
net_holes = [h for h in played_holes if h.net is not None]
if net_holes:
net_to_par = sum(h.net for h in net_holes) - sum(h.par for h in net_holes)
lead_lines.append(("Netto til par", _signed(net_to_par)))
stat_lines = lead_lines + stat_lines
email = g["guest_email"].strip().lower()
raw_token = secrets.token_urlsafe(32)
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES)
# Samme "ugyldiggjør eldre uforbrukte lenker"-mønster som
# request_magic_link/send_scorecard_invitations.
await conn.execute(
"UPDATE magic_link_token SET consumed_at = now() WHERE email = $1 AND consumed_at IS NULL", email
)
await conn.execute(
"INSERT INTO magic_link_token (email, token_hash, expires_at, locale) VALUES ($1, $2, $3, 'nb')",
email, _hash_secret(raw_token), expires_at,
)
if settings.SMTP_CONFIGURED:
try:
await send_round_summary_email(
email, g["guest_first_name"] or "Gjest", round_label, summary_holes, stat_lines, raw_token,
)
except Exception:
# Se app/email.py sitt mønster -- en driftsfeil i selve
# utsendingen skal aldri hindre fullføringen av runden.
traceback.print_exc()
elif settings.DEV_LOG_MAGIC_LINKS:
print(f"[DEV] Magic link for {email} (nb): {raw_token}", flush=True)
print(f"[DEV] Rundeoppsummering til {email}: {round_label}, {len(played_holes)} hull spilt", flush=True)
def _signed(value: int) -> str:
if value == 0:
return "E"
return f"+{value}" if value > 0 else str(value)
def _course_handicap_from_row(p) -> int:
return course_handicap(
p["handicap_index_snapshot"], p["slope_rating_snapshot"], p["course_rating_snapshot"], p["tee_par_snapshot"]

View file

@ -59,8 +59,10 @@
disse en svak grønn hue (130-145) iblandet, som ga et vedvarende
grønt skjær nesten hver bakgrunn/hover/kant i hele appen. `primary`
er fortsatt den bevisste, mettede merkevare-grønnen -- kun den skal
lese som grønn. */
--background: oklch(0.985 0 0);
lese som grønn. `--background` er ren hvit (0.985 var fortsatt en
synlig off-white, ikke "fjernet") -- identisk med `--card`, kort
skilles fra siden via border/shadow, ikke en bakgrunnsfarge-forskjell. */
--background: oklch(1 0 0);
--foreground: oklch(0.24 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.24 0 0);

View file

@ -69,7 +69,6 @@ import { cn } from "@/lib/utils"
// hvit tekst -- feiler AA for vanlig tekst) -- egen kontrastkorrigert
// variant (#1f6b08, ~6,6:1) i samme fargefamilie.
const C = {
bg: "#f7faf8",
card: "#ffffff",
glass: "rgba(255, 255, 255, 0.78)",
ink: "#012c11", // primærtekst (nesten-svart grønn), IKKE lenger knappe-bakgrunn
@ -433,7 +432,7 @@ export function Dashboard() {
if (loadingMe) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4" style={{ backgroundColor: C.bg }}>
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background">
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4" style={{ borderColor: `${C.accent}33`, borderTopColor: C.accent }} />
</div>
)
@ -470,10 +469,10 @@ export function Dashboard() {
const playedCourses = [...coursesMap.values()].sort((a, b) => (a.lastPlayed < b.lastPlayed ? 1 : -1))
return (
<div className="flex min-h-[100dvh] flex-col" style={{ backgroundColor: C.bg }}>
<div className="flex min-h-[100dvh] flex-col bg-background">
<header
className="sticky top-0 z-10 border-b pt-[env(safe-area-inset-top)]"
style={{ backgroundColor: C.bg, borderColor: C.border }}
className="sticky top-0 z-10 border-b bg-background pt-[env(safe-area-inset-top)]"
style={{ borderColor: C.border }}
>
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-4">
<Link href="/dashboard" aria-label="TeeCup til forsiden" className="flex items-center gap-2 rounded-xl">
@ -624,7 +623,7 @@ function NotificationBell({ unreadCount }: { unreadCount: number }) {
<span
aria-hidden="true"
className="absolute right-1 top-1 flex min-w-[18px] items-center justify-center rounded-full px-1 text-[11px] font-bold leading-none"
style={{ height: 18, backgroundColor: C.warmInk, color: "#ffffff", boxShadow: `0 0 0 2px ${C.bg}` }}
style={{ height: 18, backgroundColor: C.warmInk, color: "#ffffff", boxShadow: `0 0 0 2px var(--background)` }}
>
{badgeText}
</span>

View file

@ -13,7 +13,6 @@ import { Bell, Building2, ChevronRight, LogOut, UserCircle, UserPlus } from "luc
import { BottomTabBar } from "@/components/dashboard"
const C = {
bg: "#f7faf8",
card: "#ffffff",
ink: "#012c11",
muted: "#424941",
@ -44,7 +43,7 @@ export function MoreMenu() {
}
return (
<div className="flex min-h-[100dvh] flex-col" style={{ backgroundColor: C.bg }}>
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="border-b pt-[env(safe-area-inset-top)]" style={{ borderColor: C.border }}>
<div className="mx-auto w-full max-w-2xl px-5 py-4">
<h1 className="text-xl font-extrabold tracking-tight" style={{ color: C.ink }}>

View file

@ -132,6 +132,11 @@ const TWO_SIDED = new Set<FormatId>([
// avgjør om HCP-prosenten bygges som "combined" (delt enhet) eller
// "per_player" i allowance_override.strategy.
const SIDE_IS_UNIT_FORMAT = new Set<FormatId>(["foursome", "greensome", "scramble2", "scramble4", "chapman"])
// "Bruk course handicap-justering" er uten reell nytteverdi for de to
// enkleste, rene individuelle formatene (etterspurt av bruker 2026-08-02)
// -- skjules for disse, men holdes funksjonelt PÅ (aldri slettet/fjernet
// fra selve innsendingen, kun ikke eksponert som et valg i UI-et).
const ORDINARY_FORMATS = new Set<FormatId>(["slagspill", "stableford"])
function usesStep4(f: FormatId) {
return TWO_SIDED.has(f) || f === "moneyball"
@ -312,6 +317,14 @@ export function NewRound() {
const [useMatchplayHcp, setUseMatchplayHcp] = useState(true)
const [hcpPercent, setHcpPercent] = useState("")
// "Bruk course handicap-justering" er skjult for de ordinære formatene
// (ORDINARY_FORMATS) -- siden brukeren da aldri ser bryteren, må den
// funksjonelt holdes PÅ uansett hva den måtte ha stått i tidligere (f.eks.
// slått av på et annet format før man byttet tilbake til Slagspill).
useEffect(() => {
if (ORDINARY_FORMATS.has(format)) setUseCourseHcp(true)
}, [format])
// Step 3
const [players, setPlayers] = useState<Player[]>([])
@ -506,7 +519,14 @@ export function NewRound() {
if (p.userId) {
participantBody.user_id = p.userId
} else {
participantBody.guest_name = p.name
// Samme "første ord = fornavn, resten = etternavn"-heuristikk som
// round-detail.tsx sin splitName()/migrasjon 052 sin backfill
// (2026-08-03 -- ParticipantCreate godtar ikke lenger guest_name
// direkte, kun guest_first_name/guest_last_name).
const trimmedName = p.name.trim()
const spaceIdx = trimmedName.indexOf(" ")
participantBody.guest_first_name = spaceIdx === -1 ? trimmedName : trimmedName.slice(0, spaceIdx)
participantBody.guest_last_name = spaceIdx === -1 ? null : trimmedName.slice(spaceIdx + 1).trim()
participantBody.gender = apiGenderOf(p.gender)
participantBody.handicap_index = parseHcp(p.hcp)
if (p.email && p.email.trim()) participantBody.guest_email = p.email.trim()
@ -632,7 +652,14 @@ export function NewRound() {
</div>
</header>
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-6 pb-32 sm:py-8">
{/* pb-32 er reservert klaring for den faste Tilbake/Neste-linjen under --
ALDRI en py- eller sm:py-klasse her (Tailwind emitterer sm-varianter
i en egen media-blokk etter grunnklassene, en sm:py-8 ville slått
padding-bottom tilbake til 32px alle skjermer 640px og bredere,
uansett rekkefølge i selve className-strengen -- reell bug funnet
2026-08-02, se CHANGELOG.md). En pt-klasse er trygt, den påvirker
aldri padding-bottom. */}
<main className="mx-auto w-full max-w-3xl flex-1 px-5 pt-6 pb-32 sm:pt-8">
{isAdditionalFlight && stepN === 1 && s1Sub !== "fields" && (
<p className="mb-6 rounded-2xl border border-dashed border-primary/40 bg-primary/5 px-4 py-3 text-sm font-semibold text-foreground">
Du setter opp enda en flight i samme oppsett -- dato, starthull, hullantall og spilleform er
@ -1812,8 +1839,10 @@ function Step2(props: {
{advOpen && (
<div className="flex flex-col gap-5 border-t border-border px-4 py-5">
<ToggleRow label="Bruk handicap" checked={useHcp} onChange={setUseHcp} />
<ToggleRow label="Bruk course handicap-justering" checked={useCourseHcp} onChange={setUseCourseHcp} />
{twoSided && (
{useHcp && !ORDINARY_FORMATS.has(format) && (
<ToggleRow label="Bruk course handicap-justering" checked={useCourseHcp} onChange={setUseCourseHcp} />
)}
{useHcp && twoSided && (
<div className="flex flex-col gap-1.5">
<ToggleRow label="Bruk matchplay-handicap" checked={useMatchplayHcp} onChange={setUseMatchplayHcp} />
<p className="px-1 text-sm leading-relaxed text-muted-foreground text-pretty">
@ -1822,12 +1851,14 @@ function Step2(props: {
</p>
</div>
)}
<div className="flex flex-col gap-2">
<Label htmlFor="hcp-percent" className="text-base font-semibold">
HCP-prosent (valgfritt)
</Label>
<Input id="hcp-percent" inputMode="decimal" value={hcpPercent} onChange={(e) => setHcpPercent(e.target.value)} placeholder="Standard for format" className="h-14 rounded-2xl text-lg" />
</div>
{useHcp && (
<div className="flex flex-col gap-2">
<Label htmlFor="hcp-percent" className="text-base font-semibold">
HCP-prosent (valgfritt)
</Label>
<Input id="hcp-percent" inputMode="decimal" value={hcpPercent} onChange={(e) => setHcpPercent(e.target.value)} placeholder="Standard for format" className="h-14 rounded-2xl text-lg" />
</div>
)}
</div>
)}
</div>

View file

@ -9,7 +9,7 @@
// aldri kun det ene feltet som ble endret.
import type React from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { RoundPageShell } from "@/components/round-page-shell"
@ -60,6 +60,8 @@ type Player = {
// "Deg Deg"-duplikat der en egen Badge også viste "Deg" ved siden av).
name: string
userId: string | null
guestFirstName: string | null
guestLastName: string | null
guestEmail: string | null
isOwner: boolean
gender: Gender
@ -158,6 +160,8 @@ type ApiParticipant = {
id: string
user_id: string | null
guest_name: string | null
guest_first_name: string | null
guest_last_name: string | null
// Valgfritt kontaktfelt, KUN meningsfullt for en gjest (2026-07-26).
guest_email: string | null
// Alltid utfylt av API-et -- gjestens navn, eller en levende oppslått
@ -779,6 +783,8 @@ export function RoundDetail({ roundId }: { roundId: string }) {
id: p.id,
name: playerLabel(p),
userId: p.user_id,
guestFirstName: p.guest_first_name,
guestLastName: p.guest_last_name,
guestEmail: p.guest_email,
isOwner: p.is_owner,
gender: apiGenderToUi(p.gender),
@ -981,13 +987,22 @@ export function RoundDetail({ roundId }: { roundId: string }) {
}
}
async function addGuest(name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) {
async function addGuest(
firstName: string,
lastName: string,
email: string,
gender: Gender,
hcp: number | null,
statLevel: StatLevel,
) {
const res = await fetch(`/rounds/${roundId}/participants`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
guest_name: name,
guest_first_name: firstName,
guest_last_name: lastName.trim() === "" ? null : lastName,
guest_email: email.trim() === "" ? null : email.trim(),
gender: uiGenderToApi(gender),
handicap_index: hcp,
stat_level: statLevel,
@ -3313,7 +3328,7 @@ function PlayerList({
editingPlayerId: string | null
onToggleEdit: (id: string) => void
onPatchParticipant: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
onAddGuest: (name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
onAddGuest: (firstName: string, lastName: string, email: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
onAddSearched: (userId: string, statLevel: StatLevel) => Promise<string | null>
}) {
return (
@ -3462,7 +3477,7 @@ function AddParticipantForm({
onAddSearched,
onCancel,
}: {
onAddGuest: (name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
onAddGuest: (firstName: string, lastName: string, email: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
onAddSearched: (userId: string, statLevel: StatLevel) => Promise<string | null>
onCancel: () => void
}) {
@ -3500,7 +3515,7 @@ function AddParticipantForm({
}
if (mode === "guest") {
return <AddGuestForm onAdd={onAddGuest} onCancel={onCancel} onBack={() => setMode("search")} />
return <AddGuestForm initialName={query} onAdd={onAddGuest} onCancel={onCancel} onBack={() => setMode("search")} />
}
return (
@ -3585,27 +3600,88 @@ function AddParticipantForm({
}
// --- Add guest form (fallback for spillere uten TeeCup-konto) --------------
// Navnesplitt/e-post/"gjenkjenn gjest"-utvidelse 2026-08-03 -- se
// CHANGELOG.md for full bakgrunn (retroaktiv e-post-kobling, rundeoppsummer-
// ing per e-post ved fullføring).
// Samme "første ord = fornavn, resten = etternavn"-heuristikk som
// migrasjon 052 sin backfill -- brukt til å autofylle fra søkefeltet.
function splitName(full: string): { first: string; last: string } {
const trimmed = full.trim()
const spaceIdx = trimmed.indexOf(" ")
if (spaceIdx === -1) return { first: trimmed, last: "" }
return { first: trimmed.slice(0, spaceIdx), last: trimmed.slice(spaceIdx + 1).trim() }
}
type KnownGuest = {
guest_first_name: string
guest_last_name: string | null
gender: ApiGender | null
handicap_index: number | null
}
function AddGuestForm({
initialName,
onAdd,
onCancel,
onBack,
}: {
onAdd: (name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
initialName: string
onAdd: (firstName: string, lastName: string, email: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
onCancel: () => void
onBack?: () => void
}) {
const [name, setName] = useState("")
const initialSplit = useMemo(() => splitName(initialName), [initialName])
const [firstName, setFirstName] = useState(initialSplit.first)
const [lastName, setLastName] = useState(initialSplit.last)
const [email, setEmail] = useState("")
const [gender, setGender] = useState<Gender>("male")
const [hcp, setHcp] = useState("")
const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only")
const [knownGuest, setKnownGuest] = useState<KnownGuest | null>(null)
const [knownGuestDismissed, setKnownGuestDismissed] = useState(false)
// "Vi kjenner igjen denne e-posten" -- kun søk i EGNE tidligere
// registrerte gjester (håndhevet server-side, se find_known_guest i
// app/routers/rounds.py -- aldri et globalt oppslag).
useEffect(() => {
const trimmed = email.trim()
setKnownGuestDismissed(false)
if (trimmed.length < 5 || !trimmed.includes("@")) {
setKnownGuest(null)
return
}
const handle = setTimeout(() => {
fetch(`/rounds/guests/known?email=${encodeURIComponent(trimmed)}`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data: KnownGuest | null) => setKnownGuest(data))
.catch(() => setKnownGuest(null))
}, 350)
return () => clearTimeout(handle)
}, [email])
function useKnownGuest() {
if (!knownGuest) return
setFirstName(knownGuest.guest_first_name)
setLastName(knownGuest.guest_last_name ?? "")
if (knownGuest.gender) setGender(apiGenderToUi(knownGuest.gender))
if (knownGuest.handicap_index !== null) setHcp(String(knownGuest.handicap_index).replace(".", ","))
setKnownGuestDismissed(true)
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const trimmed = name.trim()
if (!trimmed) return
const trimmedFirst = firstName.trim()
if (!trimmedFirst) return
const parsedHcp = hcp.trim() === "" ? null : Number(hcp.replace(",", "."))
onAdd(trimmed, gender, parsedHcp !== null && !Number.isNaN(parsedHcp) ? parsedHcp : null, statLevel)
onAdd(
trimmedFirst,
lastName.trim(),
email.trim(),
gender,
parsedHcp !== null && !Number.isNaN(parsedHcp) ? parsedHcp : null,
statLevel,
)
}
return (
@ -3619,11 +3695,57 @@ function AddGuestForm({
Søk i stedet
</button>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="guest-first-name" className="text-base font-semibold">
Fornavn
</Label>
<Input
id="guest-first-name"
autoFocus
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Fornavn"
className="h-12 rounded-2xl text-base"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="guest-last-name" className="text-base font-semibold">
Etternavn <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
id="guest-last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Etternavn"
className="h-12 rounded-2xl text-base"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="guest-name" className="text-base font-semibold">
Navn
<Label htmlFor="guest-email" className="text-base font-semibold">
E-post <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input id="guest-name" autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="Gjestens navn" className="h-12 rounded-2xl text-base" />
<Input
id="guest-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="For scorekort og statistikk etter runden"
className="h-12 rounded-2xl text-base"
/>
{knownGuest && !knownGuestDismissed && (
<div className="flex flex-col gap-2 rounded-2xl border border-primary/40 bg-primary/5 p-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-foreground">
Vi fant <span className="font-bold">{knownGuest.guest_first_name} {knownGuest.guest_last_name}</span>{" "}
fra en tidligere runde du satte opp.
</p>
<Button type="button" size="sm" onClick={useKnownGuest} className="h-10 shrink-0 rounded-xl px-4 text-sm font-bold">
Bruk disse opplysningene
</Button>
</div>
)}
</div>
<ChoiceRow
@ -4014,7 +4136,8 @@ function EditParticipantPanel({
const [teeName, setTeeName] = useState(player.teeName)
const [hcp, setHcp] = useState(player.hcp !== null ? String(player.hcp).replace(".", ",") : "")
const [statLevel, setStatLevel] = useState<StatLevel>(player.statLevel)
const [guestName, setGuestName] = useState(player.name)
const [guestFirstName, setGuestFirstName] = useState(player.guestFirstName ?? player.name)
const [guestLastName, setGuestLastName] = useState(player.guestLastName ?? "")
const [gender, setGender] = useState<Gender>(player.gender)
const [guestEmail, setGuestEmail] = useState(player.guestEmail ?? "")
const [excludeFromHandicap, setExcludeFromHandicap] = useState(player.excludeFromHandicap)
@ -4054,8 +4177,11 @@ function EditParticipantPanel({
if (normalizedHcp !== player.hcp) body.handicap_index = normalizedHcp
if (statLevel !== player.statLevel) body.stat_level = statLevel
if (isGuest) {
const trimmedName = guestName.trim()
if (trimmedName && trimmedName !== player.name) body.guest_name = trimmedName
const trimmedFirst = guestFirstName.trim()
const trimmedLast = guestLastName.trim()
if (trimmedFirst && trimmedFirst !== (player.guestFirstName ?? "")) body.guest_first_name = trimmedFirst
const normalizedLast = trimmedLast === "" ? null : trimmedLast
if (normalizedLast !== (player.guestLastName ?? null)) body.guest_last_name = normalizedLast
if (gender !== player.gender) body.gender = apiGender
const trimmedEmail = guestEmail.trim()
const normalizedEmail = trimmedEmail === "" ? null : trimmedEmail
@ -4095,16 +4221,29 @@ function EditParticipantPanel({
<>
{isGuest && (
<>
<div className="flex flex-col gap-2">
<Label htmlFor={`edit-guest-name-${player.id}`} className="text-sm font-semibold">
Navn
</Label>
<Input
id={`edit-guest-name-${player.id}`}
value={guestName}
onChange={(e) => setGuestName(e.target.value)}
className="h-11 rounded-xl text-base"
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor={`edit-guest-first-name-${player.id}`} className="text-sm font-semibold">
Fornavn
</Label>
<Input
id={`edit-guest-first-name-${player.id}`}
value={guestFirstName}
onChange={(e) => setGuestFirstName(e.target.value)}
className="h-11 rounded-xl text-base"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`edit-guest-last-name-${player.id}`} className="text-sm font-semibold">
Etternavn <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
id={`edit-guest-last-name-${player.id}`}
value={guestLastName}
onChange={(e) => setGuestLastName(e.target.value)}
className="h-11 rounded-xl text-base"
/>
</div>
</div>
<ChoiceRow

View file

@ -1306,29 +1306,33 @@ function CreateSessionCard({
{advancedOpen && (
<div className="flex flex-col gap-1 border-t border-border px-4 py-2">
<ToggleRow label="Bruk handicap" checked={useHandicap} onCheckedChange={setUseHandicap} />
<ToggleRow
label="Bruk course handicap-justering"
checked={useCourseHandicap}
onCheckedChange={setUseCourseHandicap}
/>
<ToggleRow
label="Bruk matchplay-handicap"
checked={useMatchplayHandicap}
onCheckedChange={setUseMatchplayHandicap}
/>
<div className="flex flex-col gap-2 py-3">
<Label htmlFor="hcp-percent" className="text-sm font-semibold">
HCP-prosent <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
id="hcp-percent"
inputMode="decimal"
placeholder="Standard for format"
value={hcpPercent}
onChange={(e) => setHcpPercent(e.target.value)}
className="h-11 rounded-xl text-base"
/>
</div>
{useHandicap && (
<>
<ToggleRow
label="Bruk course handicap-justering"
checked={useCourseHandicap}
onCheckedChange={setUseCourseHandicap}
/>
<ToggleRow
label="Bruk matchplay-handicap"
checked={useMatchplayHandicap}
onCheckedChange={setUseMatchplayHandicap}
/>
<div className="flex flex-col gap-2 py-3">
<Label htmlFor="hcp-percent" className="text-sm font-semibold">
HCP-prosent <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
id="hcp-percent"
inputMode="decimal"
placeholder="Standard for format"
value={hcpPercent}
onChange={(e) => setHcpPercent(e.target.value)}
className="h-11 rounded-xl text-base"
/>
</div>
</>
)}
</div>
)}
</div>

View file

@ -24,7 +24,13 @@
// appen, se team_authz.py sin trusselmodell-begrunnelse), ingen
// cache-tømming ved utlogging bygget ennå.
const RUNTIME_CACHE = "teecup-runtime-v1"
// v2 (2026-08-02): "nettverk først" var i praksis "nettleser-cache først" --
// et rått fetch(req) her respekterer fortsatt HTTP Cache-Control på selve
// requesten, så en side lastet før en ny utrulling kunne forbli synlig
// lenge etter deploy uten at brukeren merket noe (ingen feilmelding, bare
// stille utdatert innhold). explicit cache:"reload" tvinger et ekte
// nettverksoppslag hver gang, samme prinsipp begge steder under.
const RUNTIME_CACHE = "teecup-runtime-v2"
const OFFLINE_URL = "/offline.html"
self.addEventListener("install", (event) => {
@ -51,7 +57,7 @@ self.addEventListener("fetch", (event) => {
if (req.mode === "navigate") {
event.respondWith(
fetch(req)
fetch(req, { cache: "reload" })
.then((res) => {
const copy = res.clone()
caches.open(RUNTIME_CACHE).then((cache) => cache.put(req, copy))
@ -65,7 +71,7 @@ self.addEventListener("fetch", (event) => {
const url = new URL(req.url)
if (url.origin === self.location.origin && url.pathname.startsWith("/orgs/")) {
event.respondWith(
fetch(req)
fetch(req, { cache: "reload" })
.then((res) => {
if (res.ok) caches.open(RUNTIME_CACHE).then((cache) => cache.put(req, res.clone()))
return res

File diff suppressed because one or more lines are too long