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:
parent
5e66ce63f5
commit
d395f86753
17 changed files with 7326 additions and 6477 deletions
24
052_guest_name_split.sql
Normal file
24
052_guest_name_split.sql
Normal 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;
|
||||||
|
|
@ -845,7 +845,7 @@ kryss-org-isolasjonstestene, og `/auth/me` sin N+1-oppslagsstrategi
|
||||||
dette. Ingen kodeendring nødvendig — kun bekreftelse.
|
dette. Ingen kodeendring nødvendig — kun bekreftelse.
|
||||||
|
|
||||||
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Migrasjon `012_password_2fa_and_
|
**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
|
full byggerunde (backend/frontend-detaljer, tre reelle bugs funnet og
|
||||||
fikset under scratch-testing).
|
fikset under scratch-testing).
|
||||||
|
|
||||||
|
|
@ -924,7 +924,7 @@ STYRING og DELTAKELSE er allerede modellert som separate ting, og forblir
|
||||||
det.
|
det.
|
||||||
|
|
||||||
**Status: ✅ BYGGET OG LIVE 2026-07-19**, samme runde som ADR-021 (samme
|
**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.
|
isolert her, for å unngå å bygge to overlappende synlighetsmodeller.
|
||||||
|
|
||||||
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Ingen migrasjon (ren
|
**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.
|
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)").
|
egne strenger (f.eks. "9&7 (A)").
|
||||||
|
|
||||||
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Ingen migrasjon (ren applogikk,
|
**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.
|
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
|
er privat, org-admin har uansett ikke lesetilgang og kan derfor ikke
|
||||||
moderere det).
|
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
|
verifisering og utrulling, inkl. en egen Caddy-rute (`/ws/*`) for
|
||||||
WebSocket-trafikk direkte til `teecup_api` (Next.js sin `rewrites()`
|
WebSocket-trafikk direkte til `teecup_api` (Next.js sin `rewrites()`
|
||||||
proxyer ikke WebSocket-oppgraderinger pålitelig — samme klasse
|
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
|
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Ingen migrasjon (kun nye
|
||||||
endepunkter + refaktorering av eksisterende spørringer til delte
|
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
|
`teecup_api` som en bivirkning av `docker compose up --build` sin
|
||||||
avhengighets-oppløsning — ingen backend-kode rørt denne runden). Verifisert:
|
avhengighets-oppløsning — ingen backend-kode rørt denne runden). Verifisert:
|
||||||
`/health`/`dashboard` → 200, `/manifest.webmanifest`/`sw.js`/ikoner alle
|
`/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.
|
byggerunde.
|
||||||
|
|
||||||
**Gjenstående, IKKE en del av "ferdig"-vurderingen over:** faktisk
|
**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
|
**Status: ✅ BYGGET OG LIVE 2026-07-20.** Migrasjon 015 kjørt mot ekte
|
||||||
`teecup_db`, bruker bekreftet eksplisitt. Begge containere redeployet,
|
`teecup_db`, bruker bekreftet eksplisitt. Begge containere redeployet,
|
||||||
`/health`/`/dashboard`/`/account` → 200, `teeoff.no` upåvirket. Se
|
`/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
|
domenetabeller, håndhevet av RLS` er en ufravikelig invariant i
|
||||||
CLAUDE.md — en betinget/nullbar variant ville vært det FØRSTE
|
CLAUDE.md — en betinget/nullbar variant ville vært det FØRSTE
|
||||||
unntaket). Dette prosjektet har allerede én dokumentert hendelse
|
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
|
nettopp denne typen RLS-finesse — vi unngår bevisst å introdusere en
|
||||||
ny variant av samme risikoklasse. Noe skjema dupliseres (hull-for-hull-
|
ny variant av samme risikoklasse. Noe skjema dupliseres (hull-for-hull-
|
||||||
registrering ligner mye på `round_hole`), men den delte regnelogikken
|
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.
|
statistikk over utslag brukt per spiller") for full detalj.
|
||||||
**Fullført 2026-07-29:** samme funksjon bygget for org-scopede
|
**Fullført 2026-07-29:** samme funksjon bygget for org-scopede
|
||||||
turnering-scramble/-greensome også (`hole_score.selected_
|
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,
|
2026-07-29 for full detalj (validering, delt `submitStroke`-utvidelse,
|
||||||
ny `SelectedDriverSummary` i `session-scorecard.tsx`). Begge domener
|
ny `SelectedDriverSummary` i `session-scorecard.tsx`). Begge domener
|
||||||
dekket, ingen kjent gjenstående forskjell mellom scramble og greensome.
|
dekket, ingen kjent gjenstående forskjell mellom scramble og greensome.
|
||||||
|
|
|
||||||
6609
CHANGELOG.md
Normal file
6609
CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -19,7 +19,7 @@
|
||||||
> "alternativ designinstruks.md" (brukerens eget forslag, forsøkt først på
|
> "alternativ designinstruks.md" (brukerens eget forslag, forsøkt først på
|
||||||
> dashbordet, deretter godkjent som ny fasit). Innholdet under ER derfor nå
|
> dashbordet, deretter godkjent som ny fasit). Innholdet under ER derfor nå
|
||||||
> den gjeldende regelen — men er ikke nødvendigvis rullet ut på ALLE
|
> 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
|
> kommet). Rett opportunistisk opp eldre skjermer mot dette dokumentet når
|
||||||
> de likevel røres, samme mønster som tilgjengelighetsregelen over.
|
> 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 |
|
| 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 |
|
| `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 |
|
| `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 |
|
| `secondary` / `secondary-foreground` | lys grå | sekundær vekt, sjeldent brukt alene |
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@
|
||||||
multi-stage, Next.js `output: "standalone"`; ny `teecup_frontend`-tjeneste
|
multi-stage, Next.js `output: "standalone"`; ny `teecup_frontend`-tjeneste
|
||||||
i `docker-compose.yml`). Caddy (`teecup.teeoff.no`, i det SEPARATE
|
i `docker-compose.yml`). Caddy (`teecup.teeoff.no`, i det SEPARATE
|
||||||
`teeoff`-repoet) peker nå på `teecup_frontend` i stedet for `teecup_api`
|
`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
|
driftsdetaljene (samme stale-Caddy-inode-hendelse som
|
||||||
containeriseringsrunden, løst likt).
|
containeriseringsrunden, løst likt).
|
||||||
- **Verifisert med FAKTISK e-postlevering, ikke bare curl:** ekte
|
- **Verifisert med FAKTISK e-postlevering, ikke bare curl:** ekte
|
||||||
|
|
@ -144,7 +144,7 @@
|
||||||
**Rettet:** `.env` bygget om til fem separate, rent navngitte felt
|
**Rettet:** `.env` bygget om til fem separate, rent navngitte felt
|
||||||
(`TEECUP_DB_HOST/PORT/NAME/USER/PASS`), `app/config.py`+`app/db.py` bygger
|
(`TEECUP_DB_HOST/PORT/NAME/USER/PASS`), `app/config.py`+`app/db.py` bygger
|
||||||
nå `asyncpg`-poolen fra disse i stedet for én DSN-streng,
|
nå `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
|
driftsdetalj, inkl. at dette krevde et fullt
|
||||||
`docker compose up -d --build --force-recreate` (ikke bare
|
`docker compose up -d --build --force-recreate` (ikke bare
|
||||||
`--force-recreate` — imaget bygger inn `app/` ved build-tid). Verifisert
|
`--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
|
lenket fra hovedsiden. Bruker valgte å bygge fullt scorekort med i denne
|
||||||
runden (utover opprinnelig anbefaling om kun leaderboard+matchliste).
|
runden (utover opprinnelig anbefaling om kun leaderboard+matchliste).
|
||||||
- Se ARCHITECTURE_DECISIONS.md ADR-023 (kaptein/deltaker) og ADR-026
|
- 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).
|
scratch-verifiseringen (15 + 16 automatiserte sjekker).
|
||||||
|
|
||||||
### Scoring-autorisasjon: hvem fører, hvem korrigerer, hvem lukker (rejst 2026-07-16)
|
### 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
|
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
|
`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
|
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.
|
(manglende handicap-indeks) funnet og fikset samtidig.
|
||||||
- Kapteinene låser oppstillingen skjult; matchene avsløres samtidig når begge er
|
- Kapteinene låser oppstillingen skjult; matchene avsløres samtidig når begge er
|
||||||
ferdige.
|
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
|
deltakeren selv eller org-admin kan nå skrive score for en gitt
|
||||||
rundedeltaker. Runde-/deltaker-oppsett forblir bevisst på org-medlemsnivå
|
rundedeltaker. Runde-/deltaker-oppsett forblir bevisst på org-medlemsnivå
|
||||||
(samme presedens som `session`/`team_roster` i tournaments.py). 13 nye
|
(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.)
|
2026-07-30 for full detalj. De fem konkrete formatene (Københavner m.fl.)
|
||||||
og Order of Merit fortsatt ikke designet.
|
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å
|
turneringer. Rettet. Et layout-hull i headeren (navn ble avkuttet på
|
||||||
smal mobil) og et manglende form-språk i scorekort-cellene (kun farge,
|
smal mobil) og et manglende form-språk i scorekort-cellene (kun farge,
|
||||||
ikke sirkel/firkant som `round-scorecard.tsx` sin `ScoreMark`) ble også
|
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
|
verifiseringsdetalj (håndregnet HCP-kryssjekk, databasebekreftet
|
||||||
persistens, full opprett-ny-turnering-flyt testet fra bunnen).
|
persistens, full opprett-ny-turnering-flyt testet fra bunnen).
|
||||||
**IKKE rullet ut mot ekte systemer ennå** — venter på brukerens
|
**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å
|
triggerpunkter (venneforespørsel sendt/akseptert, medspiller lagt til på
|
||||||
en runde, en venn ser en synlig runde, en tilkoblet runde fullført) OG en
|
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
|
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
|
telefonens eget OS-varslingssystem (under) gjenstår av det opprinnelige
|
||||||
forslaget.
|
forslaget.
|
||||||
|
|
||||||
|
|
@ -1065,7 +1065,7 @@ utsendingen ligger ETT sted (inni `create_notification()` selv,
|
||||||
OG fremtidige varseltyper (`friend`/`round`/`result`/`tournament`) uten
|
OG fremtidige varseltyper (`friend`/`round`/`result`/`tournament`) uten
|
||||||
at hvert kallsted må huske det selv. Ny `send_notification_email()` i
|
at hvert kallsted må huske det selv. Ny `send_notification_email()` i
|
||||||
`app/email.py`, ny seksjon "Varsler på e-post" i `/account`. Se
|
`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
|
Opprinnelig forslag, for historikkens skyld: brukeren foreslo at TeeCup i
|
||||||
tillegg sender en e-post til mottakeren av en venneforespørsel ("du har
|
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,
|
utløses i en automatisert nettleserøkt -- ærlig, forventet begrensning,
|
||||||
samme klasse som all annen "ekte OS-installasjon"-testing i prosjektet).
|
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,
|
**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
|
RETT under det fremhevede brutto-hullmerket (inspirert av et
|
||||||
referansebilde fra en konkurrentapp bruker delte, bevisst IKKE en kopi av
|
referansebilde fra en konkurrentapp bruker delte, bevisst IKKE en kopi av
|
||||||
fargevalg/layout).
|
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.
|
(2026-07-26) -- ikke gjentatt her for å unngå duplisering.
|
||||||
|
|
||||||
### Oppdatering 2026-07-28: turnering-halvparten av spørsmålet AVKLART OG BYGGET
|
### 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-/
|
(`session-individual-leaderboard.tsx`, gjenbruker rangerings-/
|
||||||
mode-toggle-mønsteret fra `round-leaderboard.tsx` i en enklere,
|
mode-toggle-mønsteret fra `round-leaderboard.tsx` i en enklere,
|
||||||
sesjonsscopet variant), lenket fra blind draw-skjermen for kvalifiserende
|
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
|
en cache-feil, en ekte plasseringsfeil). Rettet ved å flytte boksen til
|
||||||
under scoringsseksjonen. Full beslutnings-/begrunnelsesdetalj i
|
under scoringsseksjonen. Full beslutnings-/begrunnelsesdetalj i
|
||||||
ARCHITECTURE_DECISIONS.md (ADR-033-oppdatering 2026-07-26), full
|
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.
|
duplisert her.
|
||||||
|
|
||||||
**Oppfølging 2026-07-27 — `teecup-scorekort-og-entry-spec.md` (nytt,
|
**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
|
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).
|
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
|
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`.
|
gjenbruk-fremfor-ny-nyanse-logikk) til egne kjernetoken i `globals.css`.
|
||||||
Konkret førstebruk: `--info` på et nytt "Hcp spilt til X"-merke på
|
Konkret førstebruk: `--info` på et nytt "Hcp spilt til X"-merke på
|
||||||
rundekort, `--gold` på et nytt "Personlig rekord"-merke (laveste til-par
|
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.
|
(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"-
|
nullbar rad). Ny `SelectedDriverSummary` i "Vis full oversikt"-
|
||||||
seksjonen. 51/51 scratch-sjekker (full org-turnering-scaffold bygget fra
|
seksjonen. 51/51 scratch-sjekker (full org-turnering-scaffold bygget fra
|
||||||
bunnen) + full nettleser-gjennomgang (ekte klikk, persistens bekreftet
|
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
|
for full detalj. **Begge domener (frittstående runder og org-scopede
|
||||||
turneringer) dekker nå samme funksjon** -- ingen kjent gjenstående
|
turneringer) dekker nå samme funksjon** -- ingen kjent gjenstående
|
||||||
forskjell mellom scramble og greensome i noen av domenene.
|
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). |
|
| 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
|
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,
|
verifisering med 20 automatiserte sjekker inkl. reell WebSocket-sanntid,
|
||||||
og utrulling).
|
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. |
|
| "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. |
|
| 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). |
|
| 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. |
|
| 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. |
|
| 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()` på `/t/[id]`+`/clubs/[slug]`, ekte data fra API-et, verifisert mot produksjonsimaget. `og:image` gjenstår (MinIO). |
|
| Del-metadata (Open Graph: og:title/og:description) | ✅ | `generateMetadata()` på `/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
|
forsiktig gren for bane-bytte (finner/flytter tilsvarende tee per allerede
|
||||||
tillagt deltaker, regner om handicap+matchstatus for hele økten etterpå —
|
tillagt deltaker, regner om handicap+matchstatus for hele økten etterpå —
|
||||||
også for allerede AVGJORTE matcher, bekreftet eksplisitt av bruker). Se
|
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` +
|
avgjort-match-eksempel) og et urelatert funn (`front_9`/`back_9` +
|
||||||
`stroke`-modus kan aldri få handicap i dag, siden `tee_rating` alltid kun
|
`stroke`-modus kan aldri få handicap i dag, siden `tee_rating` alltid kun
|
||||||
lages med `full_18`-omfang).
|
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
|
Program-skjermen (ingen ny rute). Fanget en reell regresjon i selve
|
||||||
V0-eksporten før den ble tatt inn: samme runde hadde utilsiktet fjernet
|
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
|
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.
|
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
|
"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
|
vasket bort `?org=...`-parameteren fra URL-en. Fikset ved å fjerne det
|
||||||
indre `<form>`-elementet (vanlig `<div>` + Enter-tast/knapp-klikk i
|
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
|
**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
|
`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
|
reimport, sjekket FØR teeoff-kallet), og navnet lagres nå som
|
||||||
"{anlegg} – {bane}". Ekte produksjonsdata for "De Gamle er Eldst" ryddet
|
"{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
|
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
|
**Åpent, ikke løst i denne runden:** de to bane-søkeflatene (øverste felt
|
||||||
= organisasjonens egne baner + "opprett ny"-snarvei, "Hent bane fra
|
= organisasjonens egne baner + "opprett ny"-snarvei, "Hent bane fra
|
||||||
teeoff"-knappen lenger ned = offisielt søk) er lette å forveksle — det var
|
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. |
|
| 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
|
**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.
|
reell (og transparent håndtert) passord-eksponeringshendelse underveis.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -2131,13 +2131,13 @@ reell (og transparent håndtert) passord-eksponeringshendelse underveis.
|
||||||
| Del | Status | Notat |
|
| 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. |
|
| 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. |
|
| 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: 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. |
|
| 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`. |
|
| 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.
|
inkl. tre reelle bugs funnet og fikset under scratch-testing.
|
||||||
|
|
||||||
### Organisasjonseierskap: dele, invitere, frasi seg, superadmin — ADR-022
|
### 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
|
(med alle 18 hull) fortsatt scorer helt uendret (begge sider, full
|
||||||
scorekort-henting). `test_isolation.sql` 12/12 uendret (ren
|
scorekort-henting). `test_isolation.sql` 12/12 uendret (ren
|
||||||
Python-logikk-fiks, ingen migrasjon). Rullet ut sammen med
|
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
|
**Designspørsmålene for punkt 2 er avklart** (bruker valgte det anbefalte
|
||||||
alternativet på begge, se ADR-029 Beslutning B/C): helautomatisk tee-valg
|
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
|
en ekte innlogget nettleser (Chrome DevTools) — knappen viste korrekt
|
||||||
«1 invitasjon sendt, 1 mangler registrert e-post» første gang, «0
|
«1 invitasjon sendt, 1 mangler registrert e-post» første gang, «0
|
||||||
invitasjoner sendt, 1 allerede sendt tidligere, …» andre gang. Se
|
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
|
ikke ved dypere direktelenker til andre autentiserte sider (f.eks. en
|
||||||
bokmerket turnering-URL) — samme skope-disiplin som tidligere runder.
|
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
|
scratch-backend-sjekker, `test_isolation.sql` 12/12, ekte typesjekket
|
||||||
produksjonsbuild, og et ekte HTTP-nivå-bevis mot en kjørende
|
produksjonsbuild, og et ekte HTTP-nivå-bevis mot en kjørende
|
||||||
produksjonscontainer (anonym → 200 innloggingsskjema, ekte innlogget-men-
|
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:
|
**Bygget som beskrevet i "2026-07-25"-forslaget under** (de sju blokkene:
|
||||||
Hurtighandlinger/Kommende runder/Kommende turneringer/Statistikk/Spilte
|
Hurtighandlinger/Kommende runder/Kommende turneringer/Statistikk/Spilte
|
||||||
baner/Venner/Organisasjoner) — se ADR-035 i ARCHITECTURE_DECISIONS.md og
|
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å
|
Organisasjon er ikke lenger første/eneste synlige vei inn; opprettes nå
|
||||||
usynlig i bakgrunnen når en bruker trykker "Ny turnering".
|
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
|
det ALLER første en innlogget bruker med en ufullstendig personlig profil
|
||||||
skal se, er en obligatorisk «Fullfør profilen din»-visning (fornavn/
|
skal se, er en obligatorisk «Fullfør profilen din»-visning (fornavn/
|
||||||
etternavn/fødselsdato/kjønn/HCP/hjemmeklubb/land — alt utenom bilde og
|
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
|
"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
|
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/
|
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
|
bygget, scratch-/browserverifisert og live 2026-07-28
|
||||||
(`round.visibility_mode`, migrasjon 032, `_can_view_round`/nye
|
(`round.visibility_mode`, migrasjon 032, `_can_view_round`/nye
|
||||||
`/public/rounds/*`-endepunkter, ny `/watch/[id]`-side og
|
`/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
|
full detalj (191 automatiserte sjekker + full nettleser-gjennomgang
|
||||||
med tre reelle nettleserkontekster).
|
med tre reelle nettleserkontekster).
|
||||||
3. **✅ Ekte medspillere** (ikke bare gjester) — utvider
|
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
|
Scratch-verifisert (happy path, kaptein-nullstilling, 409/400/404 alle
|
||||||
bekreftet) OG bekreftet direkte i en ekte innlogget nettleser (Chrome
|
bekreftet) OG bekreftet direkte i en ekte innlogget nettleser (Chrome
|
||||||
DevTools): flytting oppdaterte begge lags roster-lister og -antall
|
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.
|
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
|
delte samme `flight_group_id`. Kombinert leaderboard-siden bekreftet
|
||||||
visuelt (skjermbilde) med korrekt rangering/farger, ingen konsollfeil
|
visuelt (skjermbilde) med korrekt rangering/farger, ingen konsollfeil
|
||||||
noe sted i hele flyten.
|
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.
|
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
|
en manuell eksklusjons-toggel per deltaker (kontrollert av HVER
|
||||||
innlogget deltaker for sin egen rad), og selvdeklarert spilleform
|
innlogget deltaker for sin egen rad), og selvdeklarert spilleform
|
||||||
(slagspill/matchspill) med en anbefalt-men-overstyrbar eksklusjon for
|
(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).
|
`030`, backend/frontend-endringer, verifisering).
|
||||||
**Fortsatt IKKE tettet, notert samme runde:** offline-kø kun for
|
**Fortsatt IKKE tettet, notert samme runde:** offline-kø kun for
|
||||||
turnering-scorekortet (ikke frittstående runder), ingen Stableford,
|
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,
|
"plukket opp" lagres som en cap på (netto) score -- Net Double Bogey,
|
||||||
samme prinsipp som WHS allerede bruker, via den eksisterende
|
samme prinsipp som WHS allerede bruker, via den eksisterende
|
||||||
`max_hole_score_for_handicap()` i `handicap_engine.py` (ingen ny
|
`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
|
reelle frontend-bugs funnet og fikset under browserverifisering
|
||||||
(`play_format === "stroke"`-spesialsjekker som ikke ekskluderte det
|
(`play_format === "stroke"`-spesialsjekker som ikke ekskluderte det
|
||||||
nye "stableford"-formatet, samme mønster funnet og rettet fem steder
|
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
|
**Oppdatering 2026-07-28 (ADR-039):** design + backend bygget samme dag
|
||||||
som punktet ble reist, frontend (sideoppsett/skins-konfig/matchstatus-
|
som punktet ble reist, frontend (sideoppsett/skins-konfig/matchstatus-
|
||||||
visning/delt-ball-scorekort) og selve utrullingen mot ekte `teecup_db`/
|
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
|
2026-07-28 for full detalj (migrasjon `031`, nye endepunkter, 96+26
|
||||||
backend-scratch-sjekker, full nettleser-verifisering av alle seks
|
backend-scratch-sjekker, full nettleser-verifisering av alle seks
|
||||||
formater). Kort: alle fire load-bærende spørsmål (sider/gruppering,
|
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. |
|
| 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). |
|
| 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. |
|
| 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
|
**Rullet ut live 2026-07-19**, bruker bekreftet eksplisitt: `docker compose
|
||||||
up -d --build teecup_frontend` (ingen migrasjon). Verifisert:
|
up -d --build teecup_frontend` (ingen migrasjon). Verifisert:
|
||||||
|
|
|
||||||
129
app/email.py
129
app/email.py
|
|
@ -14,17 +14,26 @@ vanlig tilkobling (typisk port 587/25).
|
||||||
|
|
||||||
import smtplib
|
import smtplib
|
||||||
from asyncio import to_thread
|
from asyncio import to_thread
|
||||||
|
from dataclasses import dataclass
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
|
from html import escape as _esc
|
||||||
|
|
||||||
from .config import settings
|
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 = EmailMessage()
|
||||||
msg["Subject"] = subject
|
msg["Subject"] = subject
|
||||||
msg["From"] = settings.FROM_EMAIL
|
msg["From"] = settings.FROM_EMAIL
|
||||||
msg["To"] = to_email
|
msg["To"] = to_email
|
||||||
msg.set_content(body)
|
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:
|
if settings.SMTP_PORT == 465:
|
||||||
with smtplib.SMTP_SSL(settings.SMTP_SERVER, settings.SMTP_PORT) as smtp:
|
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"
|
f"Navn: {display_name}\n"
|
||||||
)
|
)
|
||||||
await to_thread(_send_sync, to_email, subject, body)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,48 @@ def _build_qr_data_uri(data: str) -> str:
|
||||||
return f"data:image/png;base64,{b64}"
|
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 nå-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()), så 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 på nykoblede
|
||||||
|
rader, uansett hva raden opprinnelig hadde -- personen må selv slå den
|
||||||
|
PÅ (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 på
|
||||||
|
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 på 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 så 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):
|
class MagicLinkRequest(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
locale: Literal["nb", "en"] = "nb"
|
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.
|
# 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 link_player_by_email($1, $2)", user_id, email)
|
||||||
await conn.execute("SELECT accept_pending_invitations_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) --
|
# Driftsvarsel til appens eier ved en HELT NY konto (2026-07-30) --
|
||||||
# BEVISST etter at connection-blokken er lukket, samme "e-post skal
|
# 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 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 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"])
|
return await _issue_login_result(response, request, user_row["id"])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,10 @@ from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
|
import secrets
|
||||||
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||||
|
|
@ -43,10 +45,13 @@ from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
from .. import teeoff_client
|
from .. import teeoff_client
|
||||||
from ..auth import CurrentUser, get_current_user, get_current_user_from_websocket, get_current_user_optional
|
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 ..db import plain_connection
|
||||||
|
from ..email import RoundSummaryHole, send_round_summary_email
|
||||||
from ..errors import app_error, translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
from ..handicap import SIDE_IS_UNIT, parse_allowance_config
|
from ..handicap import SIDE_IS_UNIT, parse_allowance_config
|
||||||
from ..realtime import broadcast_round_update, live_sockets_for_round
|
from ..realtime import broadcast_round_update, live_sockets_for_round
|
||||||
|
from .auth import _hash_secret
|
||||||
from .notifications import create_notification
|
from .notifications import create_notification
|
||||||
from handicap_engine import (
|
from handicap_engine import (
|
||||||
HoleResult,
|
HoleResult,
|
||||||
|
|
@ -767,6 +772,12 @@ class RoundParticipantOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
user_id: str | None
|
user_id: str | None
|
||||||
guest_name: 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
|
# Valgfritt kontaktfelt, KUN for gjester (2026-07-26) -- ingen betydning
|
||||||
# for en lenket bruker, som allerede har sin egen kontos e-post.
|
# for en lenket bruker, som allerede har sin egen kontos e-post.
|
||||||
guest_email: str | None
|
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(
|
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,
|
COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name,
|
||||||
rp.is_owner, rp.gender, rp.tee_name_snapshot,
|
rp.is_owner, rp.gender, rp.tee_name_snapshot,
|
||||||
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||||
|
|
@ -1008,6 +1020,8 @@ async def _create_participant(
|
||||||
*,
|
*,
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
guest_name: str | None,
|
guest_name: str | None,
|
||||||
|
guest_first_name: str | None = None,
|
||||||
|
guest_last_name: str | None = None,
|
||||||
is_owner: bool,
|
is_owner: bool,
|
||||||
gender: str,
|
gender: str,
|
||||||
handicap_index: float | None,
|
handicap_index: float | None,
|
||||||
|
|
@ -1028,15 +1042,18 @@ async def _create_participant(
|
||||||
participant_row = await conn.fetchrow(
|
participant_row = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
INSERT INTO round_participant
|
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,
|
course_rating_snapshot, slope_rating_snapshot, tee_par_snapshot, course_handicap_snapshot,
|
||||||
stat_level, exclude_from_handicap, round_side_id)
|
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
|
RETURNING id::text AS id
|
||||||
""",
|
""",
|
||||||
round_id,
|
round_id,
|
||||||
user_id,
|
user_id,
|
||||||
guest_name,
|
guest_name,
|
||||||
|
guest_first_name,
|
||||||
|
guest_last_name,
|
||||||
guest_email,
|
guest_email,
|
||||||
is_owner,
|
is_owner,
|
||||||
gender,
|
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 på
|
||||||
|
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):
|
async def _get_owned_round_or_404(conn, round_id: str, user_id: str):
|
||||||
"""Strengt eier-only -- for runde-forvaltning (rediger/slett metadata,
|
"""Strengt eier-only -- for runde-forvaltning (rediger/slett metadata,
|
||||||
legge til/fjerne deltakere). IKKE for lesing/scoreregistrering, se
|
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):
|
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
|
user_id: str | None = None
|
||||||
guest_name: str | None = Field(default=None, min_length=1, max_length=100)
|
# guest_last_name er bevisst valgfritt (en gjest kan legges til med kun
|
||||||
# Kun brukt for guest_name-varianten -- for user_id hentes kjønn/HCP fra
|
# 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).
|
# personens egen profil (samme kilde som eierens egen deltaker-rad).
|
||||||
gender: Literal["m", "f", "x"] | None = None
|
gender: Literal["m", "f", "x"] | None = None
|
||||||
handicap_index: float | None = Field(default=None, ge=-10, le=54)
|
handicap_index: float | None = Field(default=None, ge=-10, le=54)
|
||||||
|
|
@ -2098,8 +2156,8 @@ async def add_participant(
|
||||||
body: ParticipantCreate,
|
body: ParticipantCreate,
|
||||||
user: CurrentUser = Depends(get_current_user),
|
user: CurrentUser = Depends(get_current_user),
|
||||||
) -> RoundParticipantOut:
|
) -> RoundParticipantOut:
|
||||||
if (body.user_id is not None) == (body.guest_name is not None):
|
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_name, ikke begge/ingen.")
|
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:
|
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.")
|
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"]
|
gender = target["gender"]
|
||||||
handicap_index = target["handicap_index"]
|
handicap_index = target["handicap_index"]
|
||||||
guest_name = None
|
guest_name = None
|
||||||
|
guest_first_name = None
|
||||||
|
guest_last_name = None
|
||||||
linked_user_id = body.user_id
|
linked_user_id = body.user_id
|
||||||
else:
|
else:
|
||||||
gender = body.gender
|
gender = body.gender
|
||||||
handicap_index = body.handicap_index
|
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
|
linked_user_id = None
|
||||||
if gender is None:
|
if gender is None:
|
||||||
raise app_error(400, "VALIDATION_FAILED", "gender er påkrevd for en gjest uten konto.")
|
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():
|
async with conn.transaction(), translate_db_errors():
|
||||||
participant_id = await _create_participant(
|
participant_id = await _create_participant(
|
||||||
conn, round_id, resolved, tee_name,
|
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,
|
gender=gender, handicap_index=handicap_index,
|
||||||
stat_level=body.stat_level,
|
stat_level=body.stat_level,
|
||||||
guest_email=body.guest_email,
|
guest_email=body.guest_email,
|
||||||
|
|
@ -2194,7 +2257,8 @@ async def add_participant(
|
||||||
)
|
)
|
||||||
row = await conn.fetchrow(
|
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,
|
COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name,
|
||||||
rp.is_owner, rp.gender, rp.tee_name_snapshot,
|
rp.is_owner, rp.gender, rp.tee_name_snapshot,
|
||||||
rp.handicap_index_snapshot::float AS handicap_index_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
|
# 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.
|
# 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-
|
# `gender` er en del av rating-bunten under (påvirker hvilken utslags-
|
||||||
# rating som er gyldig), `guest_name`/`guest_email` er rene metadata.
|
# rating som er gyldig), `guest_first_name`/`guest_last_name`/
|
||||||
guest_name: str | None = Field(default=None, min_length=1, max_length=100)
|
# `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
|
gender: Literal["m", "f", "x"] | None = None
|
||||||
guest_email: EmailStr | None = None
|
guest_email: EmailStr | None = None
|
||||||
# ADR-038 Beslutning C -- eneste feltet en IKKE-eier (en lenket
|
# 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 --
|
# Felt en ikke-eier ALDRI kan sende her, uansett hvilken rad det gjelder --
|
||||||
# eier-only, se update_participant sin autorisasjonssjekk.
|
# eier-only, se update_participant sin autorisasjonssjekk.
|
||||||
_OWNER_ONLY_PARTICIPANT_FIELDS = {
|
_OWNER_ONLY_PARTICIPANT_FIELDS = {
|
||||||
"stat_level", "tee_name", "handicap_index", "guest_name", "gender", "guest_email", "round_side_id",
|
"stat_level", "tee_name", "handicap_index", "guest_first_name", "guest_last_name", "gender", "guest_email",
|
||||||
"lineup_order",
|
"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,
|
SELECT rp.user_id::text AS user_id, rp.gender, rp.tee_name_snapshot,
|
||||||
rp.handicap_index_snapshot::float AS handicap_index_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.owner_user_id::text AS round_owner_user_id,
|
||||||
r.completed_at, r.course_source, r.teeoff_facility_slug,
|
r.completed_at, r.course_source, r.teeoff_facility_slug,
|
||||||
r.teeoff_course_id, r.personal_course_id, r.play_format
|
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:
|
if current["user_id"] != user.user_id:
|
||||||
raise app_error(403, "NOT_AUTHORIZED", "Du kan kun endre dette for din egen deltakelse.")
|
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():
|
if current["user_id"] is not None and guest_only_fields & updates.keys():
|
||||||
raise app_error(
|
raise app_error(
|
||||||
400, "VALIDATION_FAILED",
|
400, "VALIDATION_FAILED",
|
||||||
|
|
@ -2409,8 +2477,22 @@ async def update_participant(
|
||||||
if "stat_level" in updates:
|
if "stat_level" in updates:
|
||||||
values.append(updates["stat_level"])
|
values.append(updates["stat_level"])
|
||||||
set_clauses.append(f"stat_level = ${len(values)}")
|
set_clauses.append(f"stat_level = ${len(values)}")
|
||||||
if "guest_name" in updates:
|
if "guest_first_name" in updates or "guest_last_name" in updates:
|
||||||
values.append(updates["guest_name"].strip())
|
# 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)}")
|
set_clauses.append(f"guest_name = ${len(values)}")
|
||||||
if "guest_email" in updates:
|
if "guest_email" in updates:
|
||||||
values.append(updates["guest_email"])
|
values.append(updates["guest_email"])
|
||||||
|
|
@ -2511,7 +2593,8 @@ async def update_participant(
|
||||||
|
|
||||||
row = await conn.fetchrow(
|
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,
|
COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name,
|
||||||
rp.is_owner, rp.gender, rp.tee_name_snapshot,
|
rp.is_owner, rp.gender, rp.tee_name_snapshot,
|
||||||
rp.handicap_index_snapshot::float AS handicap_index_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}",
|
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)
|
result = await _load_round_out(conn, round_id, user.user_id)
|
||||||
|
|
||||||
await broadcast_round_update(round_id)
|
await broadcast_round_update(round_id)
|
||||||
return result
|
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:
|
def _course_handicap_from_row(p) -> int:
|
||||||
return course_handicap(
|
return course_handicap(
|
||||||
p["handicap_index_snapshot"], p["slope_rating_snapshot"], p["course_rating_snapshot"], p["tee_par_snapshot"]
|
p["handicap_index_snapshot"], p["slope_rating_snapshot"], p["course_rating_snapshot"], p["tee_par_snapshot"]
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,10 @@
|
||||||
disse en svak grønn hue (130-145) iblandet, som ga et vedvarende
|
disse en svak grønn hue (130-145) iblandet, som ga et vedvarende
|
||||||
grønt skjær på nesten hver bakgrunn/hover/kant i hele appen. `primary`
|
grønt skjær på nesten hver bakgrunn/hover/kant i hele appen. `primary`
|
||||||
er fortsatt den bevisste, mettede merkevare-grønnen -- kun den skal
|
er fortsatt den bevisste, mettede merkevare-grønnen -- kun den skal
|
||||||
lese som grønn. */
|
lese som grønn. `--background` er ren hvit (0.985 var fortsatt en
|
||||||
--background: oklch(0.985 0 0);
|
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);
|
--foreground: oklch(0.24 0 0);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.24 0 0);
|
--card-foreground: oklch(0.24 0 0);
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,6 @@ import { cn } from "@/lib/utils"
|
||||||
// hvit tekst -- feiler AA for vanlig tekst) -- egen kontrastkorrigert
|
// hvit tekst -- feiler AA for vanlig tekst) -- egen kontrastkorrigert
|
||||||
// variant (#1f6b08, ~6,6:1) i samme fargefamilie.
|
// variant (#1f6b08, ~6,6:1) i samme fargefamilie.
|
||||||
const C = {
|
const C = {
|
||||||
bg: "#f7faf8",
|
|
||||||
card: "#ffffff",
|
card: "#ffffff",
|
||||||
glass: "rgba(255, 255, 255, 0.78)",
|
glass: "rgba(255, 255, 255, 0.78)",
|
||||||
ink: "#012c11", // primærtekst (nesten-svart grønn), IKKE lenger knappe-bakgrunn
|
ink: "#012c11", // primærtekst (nesten-svart grønn), IKKE lenger knappe-bakgrunn
|
||||||
|
|
@ -433,7 +432,7 @@ export function Dashboard() {
|
||||||
|
|
||||||
if (loadingMe) {
|
if (loadingMe) {
|
||||||
return (
|
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 aria-hidden="true" className="size-10 animate-spin rounded-full border-4" style={{ borderColor: `${C.accent}33`, borderTopColor: C.accent }} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -470,10 +469,10 @@ export function Dashboard() {
|
||||||
const playedCourses = [...coursesMap.values()].sort((a, b) => (a.lastPlayed < b.lastPlayed ? 1 : -1))
|
const playedCourses = [...coursesMap.values()].sort((a, b) => (a.lastPlayed < b.lastPlayed ? 1 : -1))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[100dvh] flex-col" style={{ backgroundColor: C.bg }}>
|
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||||
<header
|
<header
|
||||||
className="sticky top-0 z-10 border-b pt-[env(safe-area-inset-top)]"
|
className="sticky top-0 z-10 border-b bg-background pt-[env(safe-area-inset-top)]"
|
||||||
style={{ backgroundColor: C.bg, borderColor: C.border }}
|
style={{ borderColor: C.border }}
|
||||||
>
|
>
|
||||||
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-4">
|
<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">
|
<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
|
<span
|
||||||
aria-hidden="true"
|
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"
|
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}
|
{badgeText}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import { Bell, Building2, ChevronRight, LogOut, UserCircle, UserPlus } from "luc
|
||||||
import { BottomTabBar } from "@/components/dashboard"
|
import { BottomTabBar } from "@/components/dashboard"
|
||||||
|
|
||||||
const C = {
|
const C = {
|
||||||
bg: "#f7faf8",
|
|
||||||
card: "#ffffff",
|
card: "#ffffff",
|
||||||
ink: "#012c11",
|
ink: "#012c11",
|
||||||
muted: "#424941",
|
muted: "#424941",
|
||||||
|
|
@ -44,7 +43,7 @@ export function MoreMenu() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 }}>
|
<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">
|
<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 }}>
|
<h1 className="text-xl font-extrabold tracking-tight" style={{ color: C.ink }}>
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,11 @@ const TWO_SIDED = new Set<FormatId>([
|
||||||
// avgjør om HCP-prosenten bygges som "combined" (delt enhet) eller
|
// avgjør om HCP-prosenten bygges som "combined" (delt enhet) eller
|
||||||
// "per_player" i allowance_override.strategy.
|
// "per_player" i allowance_override.strategy.
|
||||||
const SIDE_IS_UNIT_FORMAT = new Set<FormatId>(["foursome", "greensome", "scramble2", "scramble4", "chapman"])
|
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) {
|
function usesStep4(f: FormatId) {
|
||||||
return TWO_SIDED.has(f) || f === "moneyball"
|
return TWO_SIDED.has(f) || f === "moneyball"
|
||||||
|
|
@ -312,6 +317,14 @@ export function NewRound() {
|
||||||
const [useMatchplayHcp, setUseMatchplayHcp] = useState(true)
|
const [useMatchplayHcp, setUseMatchplayHcp] = useState(true)
|
||||||
const [hcpPercent, setHcpPercent] = useState("")
|
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
|
// Step 3
|
||||||
const [players, setPlayers] = useState<Player[]>([])
|
const [players, setPlayers] = useState<Player[]>([])
|
||||||
|
|
||||||
|
|
@ -506,7 +519,14 @@ export function NewRound() {
|
||||||
if (p.userId) {
|
if (p.userId) {
|
||||||
participantBody.user_id = p.userId
|
participantBody.user_id = p.userId
|
||||||
} else {
|
} 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.gender = apiGenderOf(p.gender)
|
||||||
participantBody.handicap_index = parseHcp(p.hcp)
|
participantBody.handicap_index = parseHcp(p.hcp)
|
||||||
if (p.email && p.email.trim()) participantBody.guest_email = p.email.trim()
|
if (p.email && p.email.trim()) participantBody.guest_email = p.email.trim()
|
||||||
|
|
@ -632,7 +652,14 @@ export function NewRound() {
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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, så en sm:py-8 ville slått
|
||||||
|
padding-bottom tilbake til 32px på 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" && (
|
{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">
|
<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
|
Du setter opp enda en flight i samme oppsett -- dato, starthull, hullantall og spilleform er
|
||||||
|
|
@ -1812,8 +1839,10 @@ function Step2(props: {
|
||||||
{advOpen && (
|
{advOpen && (
|
||||||
<div className="flex flex-col gap-5 border-t border-border px-4 py-5">
|
<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 handicap" checked={useHcp} onChange={setUseHcp} />
|
||||||
<ToggleRow label="Bruk course handicap-justering" checked={useCourseHcp} onChange={setUseCourseHcp} />
|
{useHcp && !ORDINARY_FORMATS.has(format) && (
|
||||||
{twoSided && (
|
<ToggleRow label="Bruk course handicap-justering" checked={useCourseHcp} onChange={setUseCourseHcp} />
|
||||||
|
)}
|
||||||
|
{useHcp && twoSided && (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<ToggleRow label="Bruk matchplay-handicap" checked={useMatchplayHcp} onChange={setUseMatchplayHcp} />
|
<ToggleRow label="Bruk matchplay-handicap" checked={useMatchplayHcp} onChange={setUseMatchplayHcp} />
|
||||||
<p className="px-1 text-sm leading-relaxed text-muted-foreground text-pretty">
|
<p className="px-1 text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
|
@ -1822,12 +1851,14 @@ function Step2(props: {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
{useHcp && (
|
||||||
<Label htmlFor="hcp-percent" className="text-base font-semibold">
|
<div className="flex flex-col gap-2">
|
||||||
HCP-prosent (valgfritt)
|
<Label htmlFor="hcp-percent" className="text-base font-semibold">
|
||||||
</Label>
|
HCP-prosent (valgfritt)
|
||||||
<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" />
|
</Label>
|
||||||
</div>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
// aldri kun det ene feltet som ble endret.
|
// aldri kun det ene feltet som ble endret.
|
||||||
|
|
||||||
import type React from "react"
|
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 Link from "next/link"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { RoundPageShell } from "@/components/round-page-shell"
|
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).
|
// "Deg Deg"-duplikat der en egen Badge også viste "Deg" ved siden av).
|
||||||
name: string
|
name: string
|
||||||
userId: string | null
|
userId: string | null
|
||||||
|
guestFirstName: string | null
|
||||||
|
guestLastName: string | null
|
||||||
guestEmail: string | null
|
guestEmail: string | null
|
||||||
isOwner: boolean
|
isOwner: boolean
|
||||||
gender: Gender
|
gender: Gender
|
||||||
|
|
@ -158,6 +160,8 @@ type ApiParticipant = {
|
||||||
id: string
|
id: string
|
||||||
user_id: string | null
|
user_id: string | null
|
||||||
guest_name: 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).
|
// Valgfritt kontaktfelt, KUN meningsfullt for en gjest (2026-07-26).
|
||||||
guest_email: string | null
|
guest_email: string | null
|
||||||
// Alltid utfylt av API-et -- gjestens navn, eller en levende oppslått
|
// 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,
|
id: p.id,
|
||||||
name: playerLabel(p),
|
name: playerLabel(p),
|
||||||
userId: p.user_id,
|
userId: p.user_id,
|
||||||
|
guestFirstName: p.guest_first_name,
|
||||||
|
guestLastName: p.guest_last_name,
|
||||||
guestEmail: p.guest_email,
|
guestEmail: p.guest_email,
|
||||||
isOwner: p.is_owner,
|
isOwner: p.is_owner,
|
||||||
gender: apiGenderToUi(p.gender),
|
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`, {
|
const res = await fetch(`/rounds/${roundId}/participants`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify({
|
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),
|
gender: uiGenderToApi(gender),
|
||||||
handicap_index: hcp,
|
handicap_index: hcp,
|
||||||
stat_level: statLevel,
|
stat_level: statLevel,
|
||||||
|
|
@ -3313,7 +3328,7 @@ function PlayerList({
|
||||||
editingPlayerId: string | null
|
editingPlayerId: string | null
|
||||||
onToggleEdit: (id: string) => void
|
onToggleEdit: (id: string) => void
|
||||||
onPatchParticipant: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
|
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>
|
onAddSearched: (userId: string, statLevel: StatLevel) => Promise<string | null>
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -3462,7 +3477,7 @@ function AddParticipantForm({
|
||||||
onAddSearched,
|
onAddSearched,
|
||||||
onCancel,
|
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>
|
onAddSearched: (userId: string, statLevel: StatLevel) => Promise<string | null>
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -3500,7 +3515,7 @@ function AddParticipantForm({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "guest") {
|
if (mode === "guest") {
|
||||||
return <AddGuestForm onAdd={onAddGuest} onCancel={onCancel} onBack={() => setMode("search")} />
|
return <AddGuestForm initialName={query} onAdd={onAddGuest} onCancel={onCancel} onBack={() => setMode("search")} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -3585,27 +3600,88 @@ function AddParticipantForm({
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Add guest form (fallback for spillere uten TeeCup-konto) --------------
|
// --- 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({
|
function AddGuestForm({
|
||||||
|
initialName,
|
||||||
onAdd,
|
onAdd,
|
||||||
onCancel,
|
onCancel,
|
||||||
onBack,
|
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
|
onCancel: () => void
|
||||||
onBack?: () => 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 [gender, setGender] = useState<Gender>("male")
|
||||||
const [hcp, setHcp] = useState("")
|
const [hcp, setHcp] = useState("")
|
||||||
const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only")
|
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) {
|
function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const trimmed = name.trim()
|
const trimmedFirst = firstName.trim()
|
||||||
if (!trimmed) return
|
if (!trimmedFirst) return
|
||||||
const parsedHcp = hcp.trim() === "" ? null : Number(hcp.replace(",", "."))
|
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 (
|
return (
|
||||||
|
|
@ -3619,11 +3695,57 @@ function AddGuestForm({
|
||||||
← Søk i stedet
|
← Søk i stedet
|
||||||
</button>
|
</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">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="guest-name" className="text-base font-semibold">
|
<Label htmlFor="guest-email" className="text-base font-semibold">
|
||||||
Navn
|
E-post <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||||||
</Label>
|
</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>
|
</div>
|
||||||
|
|
||||||
<ChoiceRow
|
<ChoiceRow
|
||||||
|
|
@ -4014,7 +4136,8 @@ function EditParticipantPanel({
|
||||||
const [teeName, setTeeName] = useState(player.teeName)
|
const [teeName, setTeeName] = useState(player.teeName)
|
||||||
const [hcp, setHcp] = useState(player.hcp !== null ? String(player.hcp).replace(".", ",") : "")
|
const [hcp, setHcp] = useState(player.hcp !== null ? String(player.hcp).replace(".", ",") : "")
|
||||||
const [statLevel, setStatLevel] = useState<StatLevel>(player.statLevel)
|
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 [gender, setGender] = useState<Gender>(player.gender)
|
||||||
const [guestEmail, setGuestEmail] = useState(player.guestEmail ?? "")
|
const [guestEmail, setGuestEmail] = useState(player.guestEmail ?? "")
|
||||||
const [excludeFromHandicap, setExcludeFromHandicap] = useState(player.excludeFromHandicap)
|
const [excludeFromHandicap, setExcludeFromHandicap] = useState(player.excludeFromHandicap)
|
||||||
|
|
@ -4054,8 +4177,11 @@ function EditParticipantPanel({
|
||||||
if (normalizedHcp !== player.hcp) body.handicap_index = normalizedHcp
|
if (normalizedHcp !== player.hcp) body.handicap_index = normalizedHcp
|
||||||
if (statLevel !== player.statLevel) body.stat_level = statLevel
|
if (statLevel !== player.statLevel) body.stat_level = statLevel
|
||||||
if (isGuest) {
|
if (isGuest) {
|
||||||
const trimmedName = guestName.trim()
|
const trimmedFirst = guestFirstName.trim()
|
||||||
if (trimmedName && trimmedName !== player.name) body.guest_name = trimmedName
|
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
|
if (gender !== player.gender) body.gender = apiGender
|
||||||
const trimmedEmail = guestEmail.trim()
|
const trimmedEmail = guestEmail.trim()
|
||||||
const normalizedEmail = trimmedEmail === "" ? null : trimmedEmail
|
const normalizedEmail = trimmedEmail === "" ? null : trimmedEmail
|
||||||
|
|
@ -4095,16 +4221,29 @@ function EditParticipantPanel({
|
||||||
<>
|
<>
|
||||||
{isGuest && (
|
{isGuest && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<Label htmlFor={`edit-guest-name-${player.id}`} className="text-sm font-semibold">
|
<div className="flex flex-col gap-2">
|
||||||
Navn
|
<Label htmlFor={`edit-guest-first-name-${player.id}`} className="text-sm font-semibold">
|
||||||
</Label>
|
Fornavn
|
||||||
<Input
|
</Label>
|
||||||
id={`edit-guest-name-${player.id}`}
|
<Input
|
||||||
value={guestName}
|
id={`edit-guest-first-name-${player.id}`}
|
||||||
onChange={(e) => setGuestName(e.target.value)}
|
value={guestFirstName}
|
||||||
className="h-11 rounded-xl text-base"
|
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>
|
</div>
|
||||||
|
|
||||||
<ChoiceRow
|
<ChoiceRow
|
||||||
|
|
|
||||||
|
|
@ -1306,29 +1306,33 @@ function CreateSessionCard({
|
||||||
{advancedOpen && (
|
{advancedOpen && (
|
||||||
<div className="flex flex-col gap-1 border-t border-border px-4 py-2">
|
<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 handicap" checked={useHandicap} onCheckedChange={setUseHandicap} />
|
||||||
<ToggleRow
|
{useHandicap && (
|
||||||
label="Bruk course handicap-justering"
|
<>
|
||||||
checked={useCourseHandicap}
|
<ToggleRow
|
||||||
onCheckedChange={setUseCourseHandicap}
|
label="Bruk course handicap-justering"
|
||||||
/>
|
checked={useCourseHandicap}
|
||||||
<ToggleRow
|
onCheckedChange={setUseCourseHandicap}
|
||||||
label="Bruk matchplay-handicap"
|
/>
|
||||||
checked={useMatchplayHandicap}
|
<ToggleRow
|
||||||
onCheckedChange={setUseMatchplayHandicap}
|
label="Bruk matchplay-handicap"
|
||||||
/>
|
checked={useMatchplayHandicap}
|
||||||
<div className="flex flex-col gap-2 py-3">
|
onCheckedChange={setUseMatchplayHandicap}
|
||||||
<Label htmlFor="hcp-percent" className="text-sm font-semibold">
|
/>
|
||||||
HCP-prosent <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
<div className="flex flex-col gap-2 py-3">
|
||||||
</Label>
|
<Label htmlFor="hcp-percent" className="text-sm font-semibold">
|
||||||
<Input
|
HCP-prosent <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||||||
id="hcp-percent"
|
</Label>
|
||||||
inputMode="decimal"
|
<Input
|
||||||
placeholder="Standard for format"
|
id="hcp-percent"
|
||||||
value={hcpPercent}
|
inputMode="decimal"
|
||||||
onChange={(e) => setHcpPercent(e.target.value)}
|
placeholder="Standard for format"
|
||||||
className="h-11 rounded-xl text-base"
|
value={hcpPercent}
|
||||||
/>
|
onChange={(e) => setHcpPercent(e.target.value)}
|
||||||
</div>
|
className="h-11 rounded-xl text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,13 @@
|
||||||
// appen, se team_authz.py sin trusselmodell-begrunnelse), ingen
|
// appen, se team_authz.py sin trusselmodell-begrunnelse), ingen
|
||||||
// cache-tømming ved utlogging bygget ennå.
|
// 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"
|
const OFFLINE_URL = "/offline.html"
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
|
|
@ -51,7 +57,7 @@ self.addEventListener("fetch", (event) => {
|
||||||
|
|
||||||
if (req.mode === "navigate") {
|
if (req.mode === "navigate") {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
fetch(req)
|
fetch(req, { cache: "reload" })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const copy = res.clone()
|
const copy = res.clone()
|
||||||
caches.open(RUNTIME_CACHE).then((cache) => cache.put(req, copy))
|
caches.open(RUNTIME_CACHE).then((cache) => cache.put(req, copy))
|
||||||
|
|
@ -65,7 +71,7 @@ self.addEventListener("fetch", (event) => {
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
if (url.origin === self.location.origin && url.pathname.startsWith("/orgs/")) {
|
if (url.origin === self.location.origin && url.pathname.startsWith("/orgs/")) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
fetch(req)
|
fetch(req, { cache: "reload" })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.ok) caches.open(RUNTIME_CACHE).then((cache) => cache.put(req, res.clone()))
|
if (res.ok) caches.open(RUNTIME_CACHE).then((cache) => cache.put(req, res.clone()))
|
||||||
return res
|
return res
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue