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.
|
||||
|
||||
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Migrasjon `012_password_2fa_and_
|
||||
org_invitations.sql` kjørt mot ekte `teecup_db`. Se CLAUDE.md-status for
|
||||
org_invitations.sql` kjørt mot ekte `teecup_db`. Se CHANGELOG.md for
|
||||
full byggerunde (backend/frontend-detaljer, tre reelle bugs funnet og
|
||||
fikset under scratch-testing).
|
||||
|
||||
|
|
@ -924,7 +924,7 @@ STYRING og DELTAKELSE er allerede modellert som separate ting, og forblir
|
|||
det.
|
||||
|
||||
**Status: ✅ BYGGET OG LIVE 2026-07-19**, samme runde som ADR-021 (samme
|
||||
migrasjon `012`). Se CLAUDE.md-status for full byggerunde.
|
||||
migrasjon `012`). Se CHANGELOG.md for full byggerunde.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -981,7 +981,7 @@ synlighetsspørsmålet for «Banter Board»-feeden (FEATURE_BACKLOG), ikke
|
|||
isolert her, for å unngå å bygge to overlappende synlighetsmodeller.
|
||||
|
||||
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Ingen migrasjon (ren
|
||||
autorisasjonslogikk, ingen skjemaendring). Se CLAUDE.md-status for
|
||||
autorisasjonslogikk, ingen skjemaendring). Se CHANGELOG.md for
|
||||
scratch-verifisering og utrulling.
|
||||
|
||||
---
|
||||
|
|
@ -1038,7 +1038,7 @@ nødvendig. Status-teksten "Walkover (A)"/"Walkover (B)" følger samme
|
|||
egne strenger (f.eks. "9&7 (A)").
|
||||
|
||||
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Ingen migrasjon (ren applogikk,
|
||||
ingen skjemaendring). Se CLAUDE.md-status for scratch-verifisering og
|
||||
ingen skjemaendring). Se CHANGELOG.md for scratch-verifisering og
|
||||
utrulling.
|
||||
|
||||
---
|
||||
|
|
@ -1104,7 +1104,7 @@ feed-innlegg. Lag-chat har INGEN moderering utover forfatteren selv (rommet
|
|||
er privat, org-admin har uansett ikke lesetilgang og kan derfor ikke
|
||||
moderere det).
|
||||
|
||||
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Se CLAUDE.md-status for scratch-
|
||||
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Se CHANGELOG.md for scratch-
|
||||
verifisering og utrulling, inkl. en egen Caddy-rute (`/ws/*`) for
|
||||
WebSocket-trafikk direkte til `teecup_api` (Next.js sin `rewrites()`
|
||||
proxyer ikke WebSocket-oppgraderinger pålitelig — samme klasse
|
||||
|
|
@ -1166,7 +1166,7 @@ synlighetslogikk, bare eksisterende logikk gjort tilgjengelig for en
|
|||
|
||||
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Ingen migrasjon (kun nye
|
||||
endepunkter + refaktorering av eksisterende spørringer til delte
|
||||
funksjoner). Se CLAUDE.md-status for scratch-verifisering og utrulling.
|
||||
funksjoner). Se CHANGELOG.md for scratch-verifisering og utrulling.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1311,7 +1311,7 @@ migrasjon, kun `teecup_frontend` (og en ren, uendret gjenoppbygging av
|
|||
`teecup_api` som en bivirkning av `docker compose up --build` sin
|
||||
avhengighets-oppløsning — ingen backend-kode rørt denne runden). Verifisert:
|
||||
`/health`/`dashboard` → 200, `/manifest.webmanifest`/`sw.js`/ikoner alle
|
||||
200 over ekte https, `teeoff.no` upåvirket. Se CLAUDE.md-status for full
|
||||
200 over ekte https, `teeoff.no` upåvirket. Se CHANGELOG.md for full
|
||||
byggerunde.
|
||||
|
||||
**Gjenstående, IKKE en del av "ferdig"-vurderingen over:** faktisk
|
||||
|
|
@ -1544,7 +1544,7 @@ fortsatt avvises. Ekte typesjekket produksjonsbuild kjørt og bekreftet.
|
|||
**Status: ✅ BYGGET OG LIVE 2026-07-20.** Migrasjon 015 kjørt mot ekte
|
||||
`teecup_db`, bruker bekreftet eksplisitt. Begge containere redeployet,
|
||||
`/health`/`/dashboard`/`/account` → 200, `teeoff.no` upåvirket. Se
|
||||
CLAUDE.md-status for full byggerunde.
|
||||
CHANGELOG.md for full byggerunde.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -3250,7 +3250,7 @@ IKKE finnes noe sted ellers i systemet i dag (`organization_id på ALLE
|
|||
domenetabeller, håndhevet av RLS` er en ufravikelig invariant i
|
||||
CLAUDE.md — en betinget/nullbar variant ville vært det FØRSTE
|
||||
unntaket). Dette prosjektet har allerede én dokumentert hendelse
|
||||
(RLS-tomstreng-bugen, se CLAUDE.md-status 2026-07-16) som kom av
|
||||
(RLS-tomstreng-bugen, se CHANGELOG.md 2026-07-16) som kom av
|
||||
nettopp denne typen RLS-finesse — vi unngår bevisst å introdusere en
|
||||
ny variant av samme risikoklasse. Noe skjema dupliseres (hull-for-hull-
|
||||
registrering ligner mye på `round_hole`), men den delte regnelogikken
|
||||
|
|
@ -3832,7 +3832,7 @@ Disse må avklares før eller under de relevante fasene:
|
|||
statistikk over utslag brukt per spiller") for full detalj.
|
||||
**Fullført 2026-07-29:** samme funksjon bygget for org-scopede
|
||||
turnering-scramble/-greensome også (`hole_score.selected_
|
||||
participant_id`, migrasjon 039) — samme mønster, se CLAUDE.md-status
|
||||
participant_id`, migrasjon 039) — samme mønster, se CHANGELOG.md
|
||||
2026-07-29 for full detalj (validering, delt `submitStroke`-utvidelse,
|
||||
ny `SelectedDriverSummary` i `session-scorecard.tsx`). Begge domener
|
||||
dekket, ingen kjent gjenstående forskjell mellom scramble og greensome.
|
||||
|
|
|
|||
6609
CHANGELOG.md
Normal file
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å
|
||||
> dashbordet, deretter godkjent som ny fasit). Innholdet under ER derfor nå
|
||||
> den gjeldende regelen — men er ikke nødvendigvis rullet ut på ALLE
|
||||
> skjermer ennå (se `CLAUDE.md`-status for hvor langt konsistens-sjekken har
|
||||
> skjermer ennå (se `CHANGELOG.md` for hvor langt konsistens-sjekken har
|
||||
> kommet). Rett opportunistisk opp eldre skjermer mot dette dokumentet når
|
||||
> de likevel røres, samme mønster som tilgjengelighetsregelen over.
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ ellers virker helt av seg selv så lenge token-klassene brukes.
|
|||
|
||||
| Token | Lys modus | Rolle |
|
||||
|---|---|---|
|
||||
| `background` / `foreground` | nesten hvit / mørk grå | sideflate / brødtekst |
|
||||
| `background` / `foreground` | ren hvit (identisk med `card`) / mørk grå | sideflate / brødtekst |
|
||||
| `card` / `card-foreground` | hvit / mørk grå | kort, paneler, seksjoner |
|
||||
| `primary` / `primary-foreground` | `oklch(0.7512 0.1613 130.33)` (grønn) / mørk | hovedhandling, valgt tilstand, "under par"/positiv |
|
||||
| `secondary` / `secondary-foreground` | lys grå | sekundær vekt, sjeldent brukt alene |
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@
|
|||
multi-stage, Next.js `output: "standalone"`; ny `teecup_frontend`-tjeneste
|
||||
i `docker-compose.yml`). Caddy (`teecup.teeoff.no`, i det SEPARATE
|
||||
`teeoff`-repoet) peker nå på `teecup_frontend` i stedet for `teecup_api`
|
||||
direkte — se ADR-016 for hvorfor, og CLAUDE.md-status for
|
||||
direkte — se ADR-016 for hvorfor, og CHANGELOG.md for
|
||||
driftsdetaljene (samme stale-Caddy-inode-hendelse som
|
||||
containeriseringsrunden, løst likt).
|
||||
- **Verifisert med FAKTISK e-postlevering, ikke bare curl:** ekte
|
||||
|
|
@ -144,7 +144,7 @@
|
|||
**Rettet:** `.env` bygget om til fem separate, rent navngitte felt
|
||||
(`TEECUP_DB_HOST/PORT/NAME/USER/PASS`), `app/config.py`+`app/db.py` bygger
|
||||
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
|
||||
`docker compose up -d --build --force-recreate` (ikke bare
|
||||
`--force-recreate` — imaget bygger inn `app/` ved build-tid). Verifisert
|
||||
|
|
@ -339,7 +339,7 @@
|
|||
lenket fra hovedsiden. Bruker valgte å bygge fullt scorekort med i denne
|
||||
runden (utover opprinnelig anbefaling om kun leaderboard+matchliste).
|
||||
- Se ARCHITECTURE_DECISIONS.md ADR-023 (kaptein/deltaker) og ADR-026
|
||||
(tilskuer) for alle delbeslutningene, og CLAUDE.md-status for
|
||||
(tilskuer) for alle delbeslutningene, og CHANGELOG.md for
|
||||
scratch-verifiseringen (15 + 16 automatiserte sjekker).
|
||||
|
||||
### Scoring-autorisasjon: hvem fører, hvem korrigerer, hvem lukker (rejst 2026-07-16)
|
||||
|
|
@ -409,7 +409,7 @@
|
|||
et lag de ikke er rostret på (forrige rundes utvidelse), men
|
||||
`own_team_ids()` i `app/blind_draw.py` viste dem aldri tilbake før
|
||||
reveal — utvidet til samme owner/admin-regel som skrive-siden. Se
|
||||
CLAUDE.md-status for full runde, inkl. en tredje, urelatert 500-bug
|
||||
CHANGELOG.md for full runde, inkl. en tredje, urelatert 500-bug
|
||||
(manglende handicap-indeks) funnet og fikset samtidig.
|
||||
- Kapteinene låser oppstillingen skjult; matchene avsløres samtidig når begge er
|
||||
ferdige.
|
||||
|
|
@ -867,7 +867,7 @@ som ADR-023s `user_is_match_participant`) brukt av `update_hole` — kun
|
|||
deltakeren selv eller org-admin kan nå skrive score for en gitt
|
||||
rundedeltaker. Runde-/deltaker-oppsett forblir bevisst på org-medlemsnivå
|
||||
(samme presedens som `session`/`team_roster` i tournaments.py). 13 nye
|
||||
scratch-sjekker + full 52-punkts regresjon, se CLAUDE.md-status
|
||||
scratch-sjekker + full 52-punkts regresjon, se CHANGELOG.md
|
||||
2026-07-30 for full detalj. De fem konkrete formatene (Københavner m.fl.)
|
||||
og Order of Merit fortsatt ikke designet.
|
||||
|
||||
|
|
@ -891,7 +891,7 @@ egen SELECT (ADR-030s utledede datospenn) som aldri ble utvidet med
|
|||
turneringer. Rettet. Et layout-hull i headeren (navn ble avkuttet på
|
||||
smal mobil) og et manglende form-språk i scorekort-cellene (kun farge,
|
||||
ikke sirkel/firkant som `round-scorecard.tsx` sin `ScoreMark`) ble også
|
||||
funnet og rettet samme runde. Se CLAUDE.md-status 2026-07-30 for full
|
||||
funnet og rettet samme runde. Se CHANGELOG.md 2026-07-30 for full
|
||||
verifiseringsdetalj (håndregnet HCP-kryssjekk, databasebekreftet
|
||||
persistens, full opprett-ny-turnering-flyt testet fra bunnen).
|
||||
**IKKE rullet ut mot ekte systemer ennå** — venter på brukerens
|
||||
|
|
@ -910,7 +910,7 @@ CLAUDE.md sin ufravikelige regel.
|
|||
triggerpunkter (venneforespørsel sendt/akseptert, medspiller lagt til på
|
||||
en runde, en venn ser en synlig runde, en tilkoblet runde fullført) OG en
|
||||
e-post-fallback PER TYPE (`user_notification_email_pref`, trygg standard
|
||||
= ingen e-post). Se CLAUDE.md-status for full detalj. Kun push til
|
||||
= ingen e-post). Se CHANGELOG.md for full detalj. Kun push til
|
||||
telefonens eget OS-varslingssystem (under) gjenstår av det opprinnelige
|
||||
forslaget.
|
||||
|
||||
|
|
@ -1065,7 +1065,7 @@ utsendingen ligger ETT sted (inni `create_notification()` selv,
|
|||
OG fremtidige varseltyper (`friend`/`round`/`result`/`tournament`) uten
|
||||
at hvert kallsted må huske det selv. Ny `send_notification_email()` i
|
||||
`app/email.py`, ny seksjon "Varsler på e-post" i `/account`. Se
|
||||
CLAUDE.md-status 2026-07-28 for full detalj (19/19 scratch-sjekker).
|
||||
CHANGELOG.md 2026-07-28 for full detalj (19/19 scratch-sjekker).
|
||||
|
||||
Opprinnelig forslag, for historikkens skyld: brukeren foreslo at TeeCup i
|
||||
tillegg sender en e-post til mottakeren av en venneforespørsel ("du har
|
||||
|
|
@ -1173,7 +1173,7 @@ ble kalt uten konsollfeil (selve den native installasjonsdialogen kan ikke
|
|||
utløses i en automatisert nettleserøkt -- ærlig, forventet begrensning,
|
||||
samme klasse som all annen "ekte OS-installasjon"-testing i prosjektet).
|
||||
**Rullet ut live 2026-07-28** sammen med de tre andre punktene samme dag,
|
||||
se CLAUDE.md-status for felles utrullingsdetalj.
|
||||
se CHANGELOG.md for felles utrullingsdetalj.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1374,7 +1374,7 @@ utvidelsesmønster). Netto/poeng vises nå også som en rolig sekundærlinje
|
|||
RETT under det fremhevede brutto-hullmerket (inspirert av et
|
||||
referansebilde fra en konkurrentapp bruker delte, bevisst IKKE en kopi av
|
||||
fargevalg/layout).
|
||||
Full verifiserings- og utrullingsdetalj i CLAUDE.md sin statuslogg
|
||||
Full verifiserings- og utrullingsdetalj i CHANGELOG.md
|
||||
(2026-07-26) -- ikke gjentatt her for å unngå duplisering.
|
||||
|
||||
### Oppdatering 2026-07-28: turnering-halvparten av spørsmålet AVKLART OG BYGGET
|
||||
|
|
@ -1398,7 +1398,7 @@ matchlisten. Frontend: ny side
|
|||
(`session-individual-leaderboard.tsx`, gjenbruker rangerings-/
|
||||
mode-toggle-mønsteret fra `round-leaderboard.tsx` i en enklere,
|
||||
sesjonsscopet variant), lenket fra blind draw-skjermen for kvalifiserende
|
||||
økter. Scratch- og browserverifisert 2026-07-28, se CLAUDE.md-status.
|
||||
økter. Scratch- og browserverifisert 2026-07-28, se CHANGELOG.md.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1651,7 +1651,7 @@ rapportere "ingen synlig endring i det hele tatt" (bekreftet presist ved
|
|||
en cache-feil, en ekte plasseringsfeil). Rettet ved å flytte boksen til
|
||||
under scoringsseksjonen. Full beslutnings-/begrunnelsesdetalj i
|
||||
ARCHITECTURE_DECISIONS.md (ADR-033-oppdatering 2026-07-26), full
|
||||
verifiserings-/utrullingsdetalj i CLAUDE.md sin statuslogg — ikke
|
||||
verifiserings-/utrullingsdetalj i CHANGELOG.md — ikke
|
||||
duplisert her.
|
||||
|
||||
**Oppfølging 2026-07-27 — `teecup-scorekort-og-entry-spec.md` (nytt,
|
||||
|
|
@ -1687,7 +1687,7 @@ ikke-browsersjekkede ble sjekket). Fant OG fikset en ny, ekte "til par"-
|
|||
bug i `round-scorecard.tsx` (regnet mot hele rundens par i stedet for
|
||||
kun spilte hulls par — ga en absurd "−53 til par" midt i en runde).
|
||||
Resten av de 22 skjermene bekreftet uten krasj/konsoll-feil. Full detalj
|
||||
i CLAUDE.md sin statuslogg (2026-07-27) — ikke duplisert her.
|
||||
i CHANGELOG.md (2026-07-27) — ikke duplisert her.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1825,7 +1825,7 @@ bare den anbefalte ene). Løftet BEGGE `--chart-3` (blå → `--info`) OG
|
|||
gjenbruk-fremfor-ny-nyanse-logikk) til egne kjernetoken i `globals.css`.
|
||||
Konkret førstebruk: `--info` på et nytt "Hcp spilt til X"-merke på
|
||||
rundekort, `--gold` på et nytt "Personlig rekord"-merke (laveste til-par
|
||||
blant minst to fullførte runder). Full detalj i CLAUDE.md sin statuslogg
|
||||
blant minst to fullførte runder). Full detalj i CHANGELOG.md
|
||||
(2026-07-28) — ikke duplisert her.
|
||||
|
||||
---
|
||||
|
|
@ -1903,7 +1903,7 @@ strukturell forskjell fra `round_hole`, som alltid forhåndsoppretter en
|
|||
nullbar rad). Ny `SelectedDriverSummary` i "Vis full oversikt"-
|
||||
seksjonen. 51/51 scratch-sjekker (full org-turnering-scaffold bygget fra
|
||||
bunnen) + full nettleser-gjennomgang (ekte klikk, persistens bekreftet
|
||||
direkte i databasen, riktig opptelling). Se CLAUDE.md-status 2026-07-29
|
||||
direkte i databasen, riktig opptelling). Se CHANGELOG.md 2026-07-29
|
||||
for full detalj. **Begge domener (frittstående runder og org-scopede
|
||||
turneringer) dekker nå samme funksjon** -- ingen kjent gjenstående
|
||||
forskjell mellom scramble og greensome i noen av domenene.
|
||||
|
|
@ -1923,7 +1923,7 @@ forskjell mellom scramble og greensome i noen av domenene.
|
|||
| Moderering (offentlig feed) | ✅ LIVE | Forfatteren selv, ELLER org-eier/admin, kan slette et innlegg. Lag-chatten har ingen moderering utover forfatteren (rommet er privat, org-admin har uansett ikke lesetilgang). |
|
||||
|
||||
Se ARCHITECTURE_DECISIONS.md ADR-025 for alle fire hovedbeslutningene og
|
||||
CLAUDE.md-status for full byggerunde (datamodell, autorisasjon, scratch-
|
||||
CHANGELOG.md for full byggerunde (datamodell, autorisasjon, scratch-
|
||||
verifisering med 20 automatiserte sjekker inkl. reell WebSocket-sanntid,
|
||||
og utrulling).
|
||||
|
||||
|
|
@ -1943,7 +1943,7 @@ dra-og-slipp-opplastingsskjermen i frontend (V0) gjenstår.
|
|||
| "Deltaker"-tilgang (ikke org-medlem, men rostret/registrert) | ✅ | Ny `get_current_user_optional` + `_is_participant()`. Testet: rostret spiller som logget inn fikk tilgang, tilfeldig fremmed ble avvist. |
|
||||
| Turnering-landingsside: tekst, program, sponsorer, påmelding (API) | ✅ | `description`-felt, `tournament_sponsor`-tabell (navn+lenke), `GET /public/tournaments/{id}/sessions` (gjenbruker blind draw-lås fra ADR-013). Selve SKJERMEN i frontend gjenstår. |
|
||||
| Org-landingsside: klubbprofil, liste over turneringer (API) | ✅ | `GET /public/orgs/{slug}` — kun `public`-synlige turneringer. Bekreftet: klubb-profil KAN være offentlig (brukerens valg). |
|
||||
| Lesbar URL (slug) for organisasjon | ✅ | Fantes faktisk allerede i skjemaet siden migrasjon 001 (oversett, funnet da migrasjon 009 feilet mot scratch — se CLAUDE.md-status). Kun `CHECK`-constraints lagt til i 009. |
|
||||
| Lesbar URL (slug) for organisasjon | ✅ | Fantes faktisk allerede i skjemaet siden migrasjon 001 (oversett, funnet da migrasjon 009 feilet mot scratch — se CHANGELOG.md). Kun `CHECK`-constraints lagt til i 009. |
|
||||
| Organisator kan faktisk SETTE disse feltene | ✅ | Implisitt hull fylt under bygging: `PATCH /orgs/{id}/tournaments/{id}` (visibility/description/registrering), `PATCH /orgs/{id}` (slug/public_profile), full sponsor-CRUD. |
|
||||
| Bilder (hero, sponsorlogoer), backend | ✅ | Ny `teecup-minio`-tjeneste, ekte multipart-opplasting → AVIF-konvertering (Pillow) → lagring, live på `teecup.teeoff.no/teecup-media/*`. Selve opplastingsskjermen i frontend (V0) gjenstår. |
|
||||
| Del-metadata (Open Graph: og:title/og:description) | ✅ | `generateMetadata()` 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
|
||||
tillagt deltaker, regner om handicap+matchstatus for hele økten etterpå —
|
||||
også for allerede AVGJORTE matcher, bekreftet eksplisitt av bruker). Se
|
||||
CLAUDE.md-status for det fulle scenarioet (verifisert med et 10-hulls
|
||||
CHANGELOG.md for det fulle scenarioet (verifisert med et 10-hulls
|
||||
avgjort-match-eksempel) og et urelatert funn (`front_9`/`back_9` +
|
||||
`stroke`-modus kan aldri få handicap i dag, siden `tee_rating` alltid kun
|
||||
lages med `full_18`-omfang).
|
||||
|
|
@ -2037,7 +2037,7 @@ lages med `full_18`-omfang).
|
|||
Program-skjermen (ingen ny rute). Fanget en reell regresjon i selve
|
||||
V0-eksporten før den ble tatt inn: samme runde hadde utilsiktet fjernet
|
||||
bane-feltet fra "Legg til økt"-skjemaet — kun de nye rediger/slett-delene
|
||||
ble hentet inn, det ekte opprett-skjemaet urørt. Se CLAUDE.md-status for
|
||||
ble hentet inn, det ekte opprett-skjemaet urørt. Se CHANGELOG.md for
|
||||
detaljer.
|
||||
|
||||
---
|
||||
|
|
@ -2084,7 +2084,7 @@ rendret INNI det ytre økt-opprett-skjemaet — ugyldig, nestet HTML. Å klikke
|
|||
"Søk" submittet i praksis det ytre skjemaet som en ekte side-navigasjon og
|
||||
vasket bort `?org=...`-parameteren fra URL-en. Fikset ved å fjerne det
|
||||
indre `<form>`-elementet (vanlig `<div>` + Enter-tast/knapp-klikk i
|
||||
stedet). Se CLAUDE.md-status for full root cause.
|
||||
stedet). Se CHANGELOG.md for full root cause.
|
||||
|
||||
**Nok en reell bug funnet og fikset samme uke, rapportert fra ekte bruk mot
|
||||
`teecup.teeoff.no`:** import av samme teeoff-bane til flere økter (helt
|
||||
|
|
@ -2095,7 +2095,7 @@ official-import` er nå idempotent (gir tilbake eksisterende rad ved
|
|||
reimport, sjekket FØR teeoff-kallet), og navnet lagres nå som
|
||||
"{anlegg} – {bane}". Ekte produksjonsdata for "De Gamle er Eldst" ryddet
|
||||
opp (en økt hadde ved et uhell fått en tom søppel-`custom`-bane — se
|
||||
CLAUDE.md-status for full hendelse og rotårsak).
|
||||
CHANGELOG.md for full hendelse og rotårsak).
|
||||
**Åpent, ikke løst i denne runden:** de to bane-søkeflatene (øverste felt
|
||||
= organisasjonens egne baner + "opprett ny"-snarvei, "Hent bane fra
|
||||
teeoff"-knappen lenger ned = offisielt søk) er lette å forveksle — det var
|
||||
|
|
@ -2121,7 +2121,7 @@ Full design i ARCHITECTURE_DECISIONS.md ADR-020.
|
|||
| Kode-regenerering (ved lekket kode) | 💤 bevisst utsatt | Ikke bygget denne runden — ingen organisator-vei til å bytte ut en kode ennå. Egen sak hvis etterspurt. |
|
||||
|
||||
**ADR-020 er dermed helt ferdig** — backend + frontend, alle fire
|
||||
del-ønsker levert samme dag. Se CLAUDE.md-status for full runde inkl. en
|
||||
del-ønsker levert samme dag. Se CHANGELOG.md for full runde inkl. en
|
||||
reell (og transparent håndtert) passord-eksponeringshendelse underveis.
|
||||
|
||||
---
|
||||
|
|
@ -2131,13 +2131,13 @@ reell (og transparent håndtert) passord-eksponeringshendelse underveis.
|
|||
| Del | Status | Notat |
|
||||
|---|---|---|
|
||||
| Flere organisasjoner per bruker | ✅ bekreftet allerede dekket | ADR-002 fra dag én. `POST /orgs` har ingen begrensning på antall org-er samme bruker kan eie. Ingen kodeendring — kun bekreftet ved gjennomgang 2026-07-19. |
|
||||
| Sesjon holder seg ikke — ny magic-link-kode kreves ved hvert besøk | ✅ FIKSET og LIVE 2026-07-19 | Ikke en cookie-bug (brukerens ekte cookie var korrekt satt, 30 dager, `Secure`/`HttpOnly`). Root cause: `app/page.tsx` sjekket aldri om en gyldig sesjon allerede fantes før den viste innloggingsskjemaet. Fikset med server-side sesjonssjekk + redirect til `/dashboard`. Se CLAUDE.md-status for full diagnose og verifisering. |
|
||||
| Sesjon holder seg ikke — ny magic-link-kode kreves ved hvert besøk | ✅ FIKSET og LIVE 2026-07-19 | Ikke en cookie-bug (brukerens ekte cookie var korrekt satt, 30 dager, `Secure`/`HttpOnly`). Root cause: `app/page.tsx` sjekket aldri om en gyldig sesjon allerede fantes før den viste innloggingsskjemaet. Fikset med server-side sesjonssjekk + redirect til `/dashboard`. Se CHANGELOG.md for full diagnose og verifisering. |
|
||||
| Passord som valgfritt tillegg til magic-link | ✅ LIVE 2026-07-19 | Argon2id-hashing (ikke bcrypt — unngår 72-byte-trunkering). Verifisert med et ekte passord med mellomrom+æøå+spesialtegn. `POST /auth/login-password`, `/auth/set-password`, `/auth/remove-password`. Passord er ALDRI påkrevd. |
|
||||
| 2FA: TOTP eller e-post-engangskode, brukerens eget valg | ✅ LIVE 2026-07-19 | SMS bevisst utenfor omfang (krever betalt leverandør). `POST /auth/2fa/setup/start`+`/confirm`, `/auth/2fa/verify`, `/auth/2fa/disable`. |
|
||||
| 2FA påkrevd for org-eier/admin, valgfritt for medlemmer | ✅ LIVE 2026-07-19 | Håndheves ved hver innlogging via en `stage: "pending_2fa"`/`"must_enroll_2fa"`-mellomtilstand i sesjons-JWT-en. Verifisert: en fersk org-eier uten 2FA ble korrekt tvunget inn i oppsett ved neste innlogging. |
|
||||
| Frontend: passord-innlogging, 2FA-verifisering/-oppsett, kontoinnstillinger | ✅ LIVE 2026-07-19 | `login-form.tsx` (passord-modus), `two-factor-flow.tsx` (delt mellom login/verify), `/account`. |
|
||||
|
||||
**ADR-021 er dermed helt ferdig.** Se CLAUDE.md-status for full byggerunde,
|
||||
**ADR-021 er dermed helt ferdig.** Se CHANGELOG.md for full byggerunde,
|
||||
inkl. tre reelle bugs funnet og fikset under scratch-testing.
|
||||
|
||||
### Organisasjonseierskap: dele, invitere, frasi seg, superadmin — ADR-022
|
||||
|
|
@ -2252,7 +2252,7 @@ en reell skjemamigrasjon (`014_tee_gender_to_rating.sql`).
|
|||
(med alle 18 hull) fortsatt scorer helt uendret (begge sider, full
|
||||
scorekort-henting). `test_isolation.sql` 12/12 uendret (ren
|
||||
Python-logikk-fiks, ingen migrasjon). Rullet ut sammen med
|
||||
dashbord-hilsen-fiksen under, se CLAUDE.md-status.
|
||||
dashbord-hilsen-fiksen under, se CHANGELOG.md.
|
||||
|
||||
**Designspørsmålene for punkt 2 er avklart** (bruker valgte det anbefalte
|
||||
alternativet på begge, se ADR-029 Beslutning B/C): helautomatisk tee-valg
|
||||
|
|
@ -2514,7 +2514,7 @@ koblingen skal skje).
|
|||
en ekte innlogget nettleser (Chrome DevTools) — knappen viste korrekt
|
||||
«1 invitasjon sendt, 1 mangler registrert e-post» første gang, «0
|
||||
invitasjoner sendt, 1 allerede sendt tidligere, …» andre gang. Se
|
||||
CLAUDE.md-status 2026-07-28 for full detalj.
|
||||
CHANGELOG.md 2026-07-28 for full detalj.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -2675,7 +2675,7 @@ obligatorisk**, før noe annet i appen (inkl. dashbordet) er tilgjengelig.
|
|||
ikke ved dypere direktelenker til andre autentiserte sider (f.eks. en
|
||||
bokmerket turnering-URL) — samme skope-disiplin som tidligere runder.
|
||||
|
||||
**Verifisert:** se full detalj i CLAUDE.md-status 2026-07-22 — 16/16
|
||||
**Verifisert:** se full detalj i CHANGELOG.md 2026-07-22 — 16/16
|
||||
scratch-backend-sjekker, `test_isolation.sql` 12/12, ekte typesjekket
|
||||
produksjonsbuild, og et ekte HTTP-nivå-bevis mot en kjørende
|
||||
produksjonscontainer (anonym → 200 innloggingsskjema, ekte innlogget-men-
|
||||
|
|
@ -2693,7 +2693,7 @@ og vil derfor begge se profil-fullførings-skjemaet ved neste innlogging.
|
|||
**Bygget som beskrevet i "2026-07-25"-forslaget under** (de sju blokkene:
|
||||
Hurtighandlinger/Kommende runder/Kommende turneringer/Statistikk/Spilte
|
||||
baner/Venner/Organisasjoner) — se ADR-035 i ARCHITECTURE_DECISIONS.md og
|
||||
CLAUDE.md-status 2026-07-25 for full bygge-/verifiseringsdetalj.
|
||||
CHANGELOG.md 2026-07-25 for full bygge-/verifiseringsdetalj.
|
||||
Organisasjon er ikke lenger første/eneste synlige vei inn; opprettes nå
|
||||
usynlig i bakgrunnen når en bruker trykker "Ny turnering".
|
||||
|
||||
|
|
@ -2721,7 +2721,7 @@ inn.
|
|||
det ALLER første en innlogget bruker med en ufullstendig personlig profil
|
||||
skal se, er en obligatorisk «Fullfør profilen din»-visning (fornavn/
|
||||
etternavn/fødselsdato/kjønn/HCP/hjemmeklubb/land — alt utenom bilde og
|
||||
beskrivelse) — se CLAUDE.md-status, ✅ BYGGET OG LIVE. Dette svarer på
|
||||
beskrivelse) — se CHANGELOG.md, ✅ BYGGET OG LIVE. Dette svarer på
|
||||
"hva møter en fersk bruker aller først", men IKKE på det opprinnelige
|
||||
spørsmålet i denne seksjonen: hva skal dashbordets tom-tilstand vise for
|
||||
en bruker som HAR fullført profilen, men ennå ikke har noen organisasjon/
|
||||
|
|
@ -2883,7 +2883,7 @@ fritekst-versjonen.
|
|||
bygget, scratch-/browserverifisert og live 2026-07-28
|
||||
(`round.visibility_mode`, migrasjon 032, `_can_view_round`/nye
|
||||
`/public/rounds/*`-endepunkter, ny `/watch/[id]`-side og
|
||||
`/my-friends/[id]`-profilside). Se CLAUDE.md-status 2026-07-28 for
|
||||
`/my-friends/[id]`-profilside). Se CHANGELOG.md 2026-07-28 for
|
||||
full detalj (191 automatiserte sjekker + full nettleser-gjennomgang
|
||||
med tre reelle nettleserkontekster).
|
||||
3. **✅ Ekte medspillere** (ikke bare gjester) — utvider
|
||||
|
|
@ -3060,7 +3060,7 @@ entydig, ingen dropdown nødvendig.
|
|||
Scratch-verifisert (happy path, kaptein-nullstilling, 409/400/404 alle
|
||||
bekreftet) OG bekreftet direkte i en ekte innlogget nettleser (Chrome
|
||||
DevTools): flytting oppdaterte begge lags roster-lister og -antall
|
||||
umiddelbart, ingen konsollfeil. Ingen migrasjon. Se CLAUDE.md-status
|
||||
umiddelbart, ingen konsollfeil. Ingen migrasjon. Se CHANGELOG.md
|
||||
2026-07-28.
|
||||
|
||||
---
|
||||
|
|
@ -3187,7 +3187,7 @@ fullførte oppretting, bekreftet DIREKTE I DATABASEN at begge rundene nå
|
|||
delte samme `flight_group_id`. Kombinert leaderboard-siden bekreftet
|
||||
visuelt (skjermbilde) med korrekt rangering/farger, ingen konsollfeil
|
||||
noe sted i hele flyten.
|
||||
**Rullet ut live 2026-07-28**, se CLAUDE.md-status for felles
|
||||
**Rullet ut live 2026-07-28**, se CHANGELOG.md for felles
|
||||
utrullingsdetalj med de tre andre punktene samme dag.
|
||||
|
||||
---
|
||||
|
|
@ -3204,7 +3204,7 @@ vs. faktisk/beregnet), en eksplisitt "overfør til manuelt HCP"-handling,
|
|||
en manuell eksklusjons-toggel per deltaker (kontrollert av HVER
|
||||
innlogget deltaker for sin egen rad), og selvdeklarert spilleform
|
||||
(slagspill/matchspill) med en anbefalt-men-overstyrbar eksklusjon for
|
||||
matchspill. Se CLAUDE.md-status 2026-07-28 for full detalj (migrasjon
|
||||
matchspill. Se CHANGELOG.md 2026-07-28 for full detalj (migrasjon
|
||||
`030`, backend/frontend-endringer, verifisering).
|
||||
**Fortsatt IKKE tettet, notert samme runde:** offline-kø kun for
|
||||
turnering-scorekortet (ikke frittstående runder), ingen Stableford,
|
||||
|
|
@ -3275,7 +3275,7 @@ Løst nøyaktig som antatt her: `'stableford'` lagt til som eget
|
|||
"plukket opp" lagres som en cap på (netto) score -- Net Double Bogey,
|
||||
samme prinsipp som WHS allerede bruker, via den eksisterende
|
||||
`max_hole_score_for_handicap()` i `handicap_engine.py` (ingen ny
|
||||
motorlogikk). Full detalj i CLAUDE.md-status 2026-07-29, inkl. to
|
||||
motorlogikk). Full detalj i CHANGELOG.md 2026-07-29, inkl. to
|
||||
reelle frontend-bugs funnet og fikset under browserverifisering
|
||||
(`play_format === "stroke"`-spesialsjekker som ikke ekskluderte det
|
||||
nye "stableford"-formatet, samme mønster funnet og rettet fem steder
|
||||
|
|
@ -3465,7 +3465,7 @@ hvilke "første handling"-alternativer dashbordet bør vise i fremtiden.
|
|||
**Oppdatering 2026-07-28 (ADR-039):** design + backend bygget samme dag
|
||||
som punktet ble reist, frontend (sideoppsett/skins-konfig/matchstatus-
|
||||
visning/delt-ball-scorekort) og selve utrullingen mot ekte `teecup_db`/
|
||||
`teecup_api` fulgte rett etter, samme dag — se CLAUDE.md-status
|
||||
`teecup_api` fulgte rett etter, samme dag — se CHANGELOG.md
|
||||
2026-07-28 for full detalj (migrasjon `031`, nye endepunkter, 96+26
|
||||
backend-scratch-sjekker, full nettleser-verifisering av alle seks
|
||||
formater). Kort: alle fire load-bærende spørsmål (sider/gruppering,
|
||||
|
|
@ -3721,7 +3721,7 @@ Full design i ARCHITECTURE_DECISIONS.md ADR-028. Kort:
|
|||
| Service worker: cache app-navigasjon + `/orgs/*`-GET-er | ✅ bygget | `public/sw.js`, nettverk-først/cache-fallback (bevisst IKKE stale-while-revalidate, se ADR-028). `public/offline.html` som siste utvei. |
|
||||
| Offline scoreregistrering (hole-scores/hole-results) | ✅ bygget | `lib/offline-queue.ts` (IndexedDB-kø) + `components/session-scorecard.tsx`. Synker automatisk ved `window`s `online`-event, pluss manuell "Synkroniser nå"-knapp. Bevisst IKKE Background Sync API (iOS Safari støtter den ikke). |
|
||||
| Andre skrivehandlinger offline (walkover, chat/feed, oppsett) | 💤 bevisst utenfor omfang | Kun de to scoreregistrerings-endepunktene er køet — se ADR-028 Beslutning B for begrunnelse per type. |
|
||||
| Faktisk browser-testet (DevTools Offline-modus) | ✅ **FERDIG 2026-07-28** | Både turnering-scorekortet (den opprinnelige ADR-028-flyten) og den senere utvidelsen til frittstående runder er nå bevist med ekte Chrome DevTools-nettverksemulering (offline → registrer slag → tilbake online → automatisk synk bekreftet server-side), ikke bare kodegjennomgang. Se CLAUDE.md-status 2026-07-28. |
|
||||
| Faktisk browser-testet (DevTools Offline-modus) | ✅ **FERDIG 2026-07-28** | Både turnering-scorekortet (den opprinnelige ADR-028-flyten) og den senere utvidelsen til frittstående runder er nå bevist med ekte Chrome DevTools-nettverksemulering (offline → registrer slag → tilbake online → automatisk synk bekreftet server-side), ikke bare kodegjennomgang. Se CHANGELOG.md 2026-07-28. |
|
||||
|
||||
**Rullet ut live 2026-07-19**, bruker bekreftet eksplisitt: `docker compose
|
||||
up -d --build teecup_frontend` (ingen migrasjon). Verifisert:
|
||||
|
|
|
|||
129
app/email.py
129
app/email.py
|
|
@ -14,17 +14,26 @@ vanlig tilkobling (typisk port 587/25).
|
|||
|
||||
import smtplib
|
||||
from asyncio import to_thread
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
from html import escape as _esc
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
def _send_sync(to_email: str, subject: str, body: str) -> None:
|
||||
def _send_sync(to_email: str, subject: str, body: str, html_body: str | None = None) -> None:
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = settings.FROM_EMAIL
|
||||
msg["To"] = to_email
|
||||
msg.set_content(body)
|
||||
# html_body er valgfritt (2026-08-03, rundeoppsummering til midlertidige
|
||||
# spillere -- FØRSTE HTML-e-post i appen). add_alternative() gjør
|
||||
# meldingen multipart/alternative: body over forblir en ekte tekst-
|
||||
# fallback for klienter uten HTML-støtte, html_body er det klienter
|
||||
# faktisk viser når de kan.
|
||||
if html_body is not None:
|
||||
msg.add_alternative(html_body, subtype="html")
|
||||
|
||||
if settings.SMTP_PORT == 465:
|
||||
with smtplib.SMTP_SSL(settings.SMTP_SERVER, settings.SMTP_PORT) as smtp:
|
||||
|
|
@ -336,3 +345,121 @@ async def send_new_account_alert_email(to_email: str, new_user_email: str, displ
|
|||
f"Navn: {display_name}\n"
|
||||
)
|
||||
await to_thread(_send_sync, to_email, subject, body)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoundSummaryHole:
|
||||
hole_number: int
|
||||
par: int
|
||||
score: int | None
|
||||
net: int | None
|
||||
|
||||
|
||||
async def send_round_summary_email(
|
||||
to_email: str,
|
||||
guest_first_name: str,
|
||||
round_label: str,
|
||||
holes: list[RoundSummaryHole],
|
||||
stat_lines: list[tuple[str, str]],
|
||||
raw_token: str,
|
||||
) -> None:
|
||||
"""Rundeoppsummering til en midlertidig spiller (gjest) med registrert
|
||||
e-post, sendt ved fullføring (app/routers/rounds.py sin complete_round,
|
||||
2026-08-03). FØRSTE HTML-e-post i appen -- multipart/alternative via
|
||||
_send_sync sin nye html_body-parameter, med en ekte tekst-fallback.
|
||||
|
||||
Navneformat (CLAUDE.md, ufravikelig): dette ER direkte adressering til
|
||||
mottakeren -- KUN fornavn i hilsenen, aldri fullt navn.
|
||||
|
||||
`stat_lines` er allerede ferdig utledet og formatert av kalleren (samme
|
||||
"bygg teksten i routeren, ikke i e-post-modulen"-mønster som
|
||||
send_scorecard_invitations/send_session_result_email) -- graderer seg
|
||||
naturlig etter hva som faktisk ble spilt (stat_level, individuell-ball
|
||||
vs. delt-ball) siden kalleren rett og slett utelater linjer det ikke
|
||||
finnes data for.
|
||||
|
||||
Alltid norsk, samme presedens som send_scorecard_invitations (gjesten
|
||||
har ingen lagret språkpreferanse -- ingen konto ennå)."""
|
||||
link = f"{settings.PUBLIC_BASE_URL}/verify?token={raw_token}"
|
||||
subject = f"Scorekortet ditt fra {round_label}"
|
||||
|
||||
text_lines = [
|
||||
f"Hei {guest_first_name},",
|
||||
"",
|
||||
f"Her er scorekortet ditt fra {round_label}:",
|
||||
"",
|
||||
]
|
||||
for h in holes:
|
||||
score_txt = str(h.score) if h.score is not None else "–"
|
||||
net_txt = str(h.net) if h.net is not None else "–"
|
||||
text_lines.append(f"Hull {h.hole_number} (par {h.par}): {score_txt} slag (netto {net_txt})")
|
||||
text_lines.append("")
|
||||
for label, value in stat_lines:
|
||||
text_lines.append(f"{label}: {value}")
|
||||
text_lines += [
|
||||
"",
|
||||
"Noen registrerte deg som spiller i TeeCup med denne e-postadressen -- "
|
||||
"du trenger ingen konto for å se dette scorekortet, men med én kan du "
|
||||
"føre score selv, følge egen statistikk og handicap-utvikling over tid.",
|
||||
"",
|
||||
f"Logg inn og se hele runden ({settings.MAGIC_LINK_MAX_AGE_MINUTES} minutter):",
|
||||
link,
|
||||
"",
|
||||
"Ikke interessert? Ignorer denne e-posten -- den brukes ikke til noe annet.",
|
||||
]
|
||||
body = "\n".join(text_lines)
|
||||
|
||||
hole_rows_html = "\n".join(
|
||||
f'<tr><td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.hole_number}</td>'
|
||||
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.par}</td>'
|
||||
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.score if h.score is not None else "–"}</td>'
|
||||
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.net if h.net is not None else "–"}</td></tr>'
|
||||
for h in holes
|
||||
)
|
||||
stat_rows_html = "\n".join(
|
||||
f'<tr><td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;color:#424941;">{_esc(label)}</td>'
|
||||
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;font-weight:700;">{_esc(value)}</td></tr>'
|
||||
for label, value in stat_lines
|
||||
)
|
||||
html_body = f"""\
|
||||
<!doctype html>
|
||||
<html><body style="margin:0;padding:0;background-color:#f3f6f2;font-family:Arial,Helvetica,sans-serif;color:#012c11;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f3f6f2;padding:24px 0;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="100%" style="max-width:480px;background-color:#ffffff;border-radius:16px;overflow:hidden;">
|
||||
<tr><td style="background-color:#1a4325;padding:20px 24px;">
|
||||
<span style="color:#ffffff;font-size:20px;font-weight:800;">TeeCup</span>
|
||||
</td></tr>
|
||||
<tr><td style="padding:24px;">
|
||||
<p style="margin:0 0 16px;font-size:16px;">Hei {_esc(guest_first_name)},</p>
|
||||
<p style="margin:0 0 20px;font-size:16px;">Her er scorekortet ditt fra <strong>{_esc(round_label)}</strong>:</p>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-size:14px;margin-bottom:20px;">
|
||||
<tr style="background-color:#edf3ef;font-weight:700;">
|
||||
<td style="padding:6px 10px;">Hull</td><td style="padding:6px 10px;">Par</td>
|
||||
<td style="padding:6px 10px;">Slag</td><td style="padding:6px 10px;">Netto</td>
|
||||
</tr>
|
||||
{hole_rows_html}
|
||||
</table>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-size:14px;margin-bottom:24px;">
|
||||
{stat_rows_html}
|
||||
</table>
|
||||
<p style="margin:0 0 20px;font-size:14px;color:#424941;">
|
||||
Noen registrerte deg som spiller i TeeCup med denne e-postadressen -- du trenger ingen
|
||||
konto for å se dette scorekortet, men med én kan du føre score selv, følge egen
|
||||
statistikk og handicap-utvikling over tid.
|
||||
</p>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="border-radius:12px;background-color:#1f6b08;">
|
||||
<a href="{link}" style="display:inline-block;padding:14px 28px;color:#ffffff;font-size:16px;
|
||||
font-weight:700;text-decoration:none;">Logg inn og se hele runden</a>
|
||||
</td></tr></table>
|
||||
<p style="margin:20px 0 0;font-size:12px;color:#767f75;">
|
||||
Lenken er gyldig i {settings.MAGIC_LINK_MAX_AGE_MINUTES} minutter. Ikke interessert? Ignorer denne
|
||||
e-posten -- den brukes ikke til noe annet.
|
||||
</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>
|
||||
"""
|
||||
await to_thread(_send_sync, to_email, subject, body, html_body)
|
||||
|
|
|
|||
|
|
@ -110,6 +110,48 @@ def _build_qr_data_uri(data: str) -> str:
|
|||
return f"data:image/png;base64,{b64}"
|
||||
|
||||
|
||||
async def _link_round_participants_by_email(conn, user_id: str, email: str) -> None:
|
||||
"""Kobler frittstående-runde-gjester (round_participant.guest_email) til
|
||||
denne 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):
|
||||
email: EmailStr
|
||||
locale: Literal["nb", "en"] = "nb"
|
||||
|
|
@ -297,6 +339,7 @@ async def verify_magic_link(body: MagicLinkVerify, response: Response, request:
|
|||
# er idempotente, berører kun rader som ennå ikke er koblet/forbrukt.
|
||||
await conn.execute("SELECT link_player_by_email($1, $2)", user_id, email)
|
||||
await conn.execute("SELECT accept_pending_invitations_by_email($1, $2)", user_id, email)
|
||||
await _link_round_participants_by_email(conn, user_id, email)
|
||||
|
||||
# Driftsvarsel til appens eier ved en HELT NY konto (2026-07-30) --
|
||||
# BEVISST etter at connection-blokken er lukket, samme "e-post skal
|
||||
|
|
@ -354,6 +397,7 @@ async def login_with_password(body: PasswordLoginRequest, response: Response, re
|
|||
|
||||
await conn.execute("SELECT link_player_by_email($1, $2)", user_row["id"], email)
|
||||
await conn.execute("SELECT accept_pending_invitations_by_email($1, $2)", user_row["id"], email)
|
||||
await _link_round_participants_by_email(conn, user_row["id"], email)
|
||||
|
||||
return await _issue_login_result(response, request, user_row["id"])
|
||||
|
||||
|
|
|
|||
|
|
@ -34,8 +34,10 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import math
|
||||
import secrets
|
||||
import traceback
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||
|
|
@ -43,10 +45,13 @@ from pydantic import BaseModel, EmailStr, Field
|
|||
|
||||
from .. import teeoff_client
|
||||
from ..auth import CurrentUser, get_current_user, get_current_user_from_websocket, get_current_user_optional
|
||||
from ..config import settings
|
||||
from ..db import plain_connection
|
||||
from ..email import RoundSummaryHole, send_round_summary_email
|
||||
from ..errors import app_error, translate_db_errors
|
||||
from ..handicap import SIDE_IS_UNIT, parse_allowance_config
|
||||
from ..realtime import broadcast_round_update, live_sockets_for_round
|
||||
from .auth import _hash_secret
|
||||
from .notifications import create_notification
|
||||
from handicap_engine import (
|
||||
HoleResult,
|
||||
|
|
@ -767,6 +772,12 @@ class RoundParticipantOut(BaseModel):
|
|||
id: str
|
||||
user_id: str | None
|
||||
guest_name: str | None
|
||||
# Kilden til sannhet for redigering (2026-08-03) -- guest_name over
|
||||
# forblir det auto-synkroniserte, VISTE fulle navnet (samme mønster som
|
||||
# app_user.display_name synkes fra first_name/last_name). guest_last_name
|
||||
# er valgfritt (en gjest kan legges til med kun fornavn).
|
||||
guest_first_name: str | None
|
||||
guest_last_name: str | None
|
||||
# Valgfritt kontaktfelt, KUN for gjester (2026-07-26) -- ingen betydning
|
||||
# for en lenket bruker, som allerede har sin egen kontos e-post.
|
||||
guest_email: str | None
|
||||
|
|
@ -884,7 +895,8 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
|||
)
|
||||
participant_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name, rp.guest_email,
|
||||
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name,
|
||||
rp.guest_first_name, rp.guest_last_name, rp.guest_email,
|
||||
COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name,
|
||||
rp.is_owner, rp.gender, rp.tee_name_snapshot,
|
||||
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||
|
|
@ -1008,6 +1020,8 @@ async def _create_participant(
|
|||
*,
|
||||
user_id: str | None,
|
||||
guest_name: str | None,
|
||||
guest_first_name: str | None = None,
|
||||
guest_last_name: str | None = None,
|
||||
is_owner: bool,
|
||||
gender: str,
|
||||
handicap_index: float | None,
|
||||
|
|
@ -1028,15 +1042,18 @@ async def _create_participant(
|
|||
participant_row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO round_participant
|
||||
(round_id, user_id, guest_name, guest_email, is_owner, gender, tee_name_snapshot, handicap_index_snapshot,
|
||||
(round_id, user_id, guest_name, guest_first_name, guest_last_name, guest_email, is_owner, gender,
|
||||
tee_name_snapshot, handicap_index_snapshot,
|
||||
course_rating_snapshot, slope_rating_snapshot, tee_par_snapshot, course_handicap_snapshot,
|
||||
stat_level, exclude_from_handicap, round_side_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
RETURNING id::text AS id
|
||||
""",
|
||||
round_id,
|
||||
user_id,
|
||||
guest_name,
|
||||
guest_first_name,
|
||||
guest_last_name,
|
||||
guest_email,
|
||||
is_owner,
|
||||
gender,
|
||||
|
|
@ -1477,6 +1494,44 @@ async def get_rounds_stats_summary(
|
|||
)
|
||||
|
||||
|
||||
class KnownGuestOut(BaseModel):
|
||||
guest_first_name: str
|
||||
guest_last_name: str | None
|
||||
gender: Literal["m", "f", "x"] | None
|
||||
handicap_index: float | None
|
||||
|
||||
|
||||
@router.get("/rounds/guests/known", response_model=KnownGuestOut | None)
|
||||
async def find_known_guest(email: str, user: CurrentUser = Depends(get_current_user)) -> KnownGuestOut | None:
|
||||
""""Gjenkjenn denne e-posten"-oppslag (2026-08-03) -- bevisst scoped til
|
||||
DENNE brukerens EGNE tidligere registrerte gjester, ikke globalt 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):
|
||||
"""Strengt eier-only -- for runde-forvaltning (rediger/slett metadata,
|
||||
legge til/fjerne deltakere). IKKE for lesing/scoreregistrering, se
|
||||
|
|
@ -2067,10 +2122,13 @@ async def delete_round(round_id: str, user: CurrentUser = Depends(get_current_us
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ParticipantCreate(BaseModel):
|
||||
# Nøyaktig én av user_id/guest_name.
|
||||
# Nøyaktig én av user_id/guest_first_name.
|
||||
user_id: str | None = None
|
||||
guest_name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
# Kun brukt for guest_name-varianten -- for user_id hentes kjønn/HCP fra
|
||||
# guest_last_name er bevisst valgfritt (en gjest kan legges til med kun
|
||||
# fornavn) -- guest_first_name er den eneste PÅKREVDE gjeste-identiteten.
|
||||
guest_first_name: str | None = Field(default=None, min_length=1, max_length=60)
|
||||
guest_last_name: str | None = Field(default=None, min_length=1, max_length=60)
|
||||
# Kun brukt for gjest-varianten -- for user_id hentes kjønn/HCP fra
|
||||
# personens egen profil (samme kilde som eierens egen deltaker-rad).
|
||||
gender: Literal["m", "f", "x"] | None = None
|
||||
handicap_index: float | None = Field(default=None, ge=-10, le=54)
|
||||
|
|
@ -2098,8 +2156,8 @@ async def add_participant(
|
|||
body: ParticipantCreate,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> RoundParticipantOut:
|
||||
if (body.user_id is not None) == (body.guest_name is not None):
|
||||
raise app_error(400, "VALIDATION_FAILED", "Oppgi enten user_id (funnet via søk) eller guest_name, ikke begge/ingen.")
|
||||
if (body.user_id is not None) == (body.guest_first_name is not None):
|
||||
raise app_error(400, "VALIDATION_FAILED", "Oppgi enten user_id (funnet via søk) eller guest_first_name, ikke begge/ingen.")
|
||||
if body.user_id is not None and body.guest_email is not None:
|
||||
raise app_error(400, "VALIDATION_FAILED", "guest_email gir ikke mening sammen med user_id -- en lenket bruker har allerede sin egen konto-e-post.")
|
||||
|
||||
|
|
@ -2145,11 +2203,15 @@ async def add_participant(
|
|||
gender = target["gender"]
|
||||
handicap_index = target["handicap_index"]
|
||||
guest_name = None
|
||||
guest_first_name = None
|
||||
guest_last_name = None
|
||||
linked_user_id = body.user_id
|
||||
else:
|
||||
gender = body.gender
|
||||
handicap_index = body.handicap_index
|
||||
guest_name = body.guest_name.strip()
|
||||
guest_first_name = body.guest_first_name.strip()
|
||||
guest_last_name = body.guest_last_name.strip() if body.guest_last_name else None
|
||||
guest_name = f"{guest_first_name} {guest_last_name}".strip() if guest_last_name else guest_first_name
|
||||
linked_user_id = None
|
||||
if gender is None:
|
||||
raise app_error(400, "VALIDATION_FAILED", "gender er påkrevd for en gjest uten konto.")
|
||||
|
|
@ -2164,7 +2226,8 @@ async def add_participant(
|
|||
async with conn.transaction(), translate_db_errors():
|
||||
participant_id = await _create_participant(
|
||||
conn, round_id, resolved, tee_name,
|
||||
user_id=linked_user_id, guest_name=guest_name, is_owner=False,
|
||||
user_id=linked_user_id, guest_name=guest_name,
|
||||
guest_first_name=guest_first_name, guest_last_name=guest_last_name, is_owner=False,
|
||||
gender=gender, handicap_index=handicap_index,
|
||||
stat_level=body.stat_level,
|
||||
guest_email=body.guest_email,
|
||||
|
|
@ -2194,7 +2257,8 @@ async def add_participant(
|
|||
)
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name, rp.guest_email,
|
||||
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name,
|
||||
rp.guest_first_name, rp.guest_last_name, rp.guest_email,
|
||||
COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name,
|
||||
rp.is_owner, rp.gender, rp.tee_name_snapshot,
|
||||
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||
|
|
@ -2322,8 +2386,12 @@ class ParticipantUpdate(BaseModel):
|
|||
# KUN gyldig for en gjest (user_id IS NULL) -- gir ingen mening for en
|
||||
# lenket bruker, som allerede har sin egen kontos navn/kjønn/e-post.
|
||||
# `gender` er en del av rating-bunten under (påvirker hvilken utslags-
|
||||
# rating som er gyldig), `guest_name`/`guest_email` er rene metadata.
|
||||
guest_name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
# rating som er gyldig), `guest_first_name`/`guest_last_name`/
|
||||
# `guest_email` er rene metadata. `guest_name` (det VISTE fulle navnet)
|
||||
# synkes automatisk av update_participant når ett av disse to endres --
|
||||
# sendes aldri direkte av klienten.
|
||||
guest_first_name: str | None = Field(default=None, min_length=1, max_length=60)
|
||||
guest_last_name: str | None = Field(default=None, min_length=1, max_length=60)
|
||||
gender: Literal["m", "f", "x"] | None = None
|
||||
guest_email: EmailStr | None = None
|
||||
# ADR-038 Beslutning C -- eneste feltet en IKKE-eier (en lenket
|
||||
|
|
@ -2342,8 +2410,8 @@ class ParticipantUpdate(BaseModel):
|
|||
# Felt en ikke-eier ALDRI kan sende her, uansett hvilken rad det gjelder --
|
||||
# eier-only, se update_participant sin autorisasjonssjekk.
|
||||
_OWNER_ONLY_PARTICIPANT_FIELDS = {
|
||||
"stat_level", "tee_name", "handicap_index", "guest_name", "gender", "guest_email", "round_side_id",
|
||||
"lineup_order",
|
||||
"stat_level", "tee_name", "handicap_index", "guest_first_name", "guest_last_name", "gender", "guest_email",
|
||||
"round_side_id", "lineup_order",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -2366,7 +2434,7 @@ async def update_participant(
|
|||
"""
|
||||
SELECT rp.user_id::text AS user_id, rp.gender, rp.tee_name_snapshot,
|
||||
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||
rp.counts_for_handicap,
|
||||
rp.counts_for_handicap, rp.guest_first_name, rp.guest_last_name,
|
||||
r.owner_user_id::text AS round_owner_user_id,
|
||||
r.completed_at, r.course_source, r.teeoff_facility_slug,
|
||||
r.teeoff_course_id, r.personal_course_id, r.play_format
|
||||
|
|
@ -2389,7 +2457,7 @@ async def update_participant(
|
|||
if current["user_id"] != user.user_id:
|
||||
raise app_error(403, "NOT_AUTHORIZED", "Du kan kun endre dette for din egen deltakelse.")
|
||||
|
||||
guest_only_fields = {"guest_name", "gender", "guest_email"}
|
||||
guest_only_fields = {"guest_first_name", "guest_last_name", "gender", "guest_email"}
|
||||
if current["user_id"] is not None and guest_only_fields & updates.keys():
|
||||
raise app_error(
|
||||
400, "VALIDATION_FAILED",
|
||||
|
|
@ -2409,8 +2477,22 @@ async def update_participant(
|
|||
if "stat_level" in updates:
|
||||
values.append(updates["stat_level"])
|
||||
set_clauses.append(f"stat_level = ${len(values)}")
|
||||
if "guest_name" in updates:
|
||||
values.append(updates["guest_name"].strip())
|
||||
if "guest_first_name" in updates or "guest_last_name" in updates:
|
||||
# guest_name (det VISTE fulle navnet) synkes automatisk her --
|
||||
# samme mønster som app_user.display_name synkes fra first_name/
|
||||
# last_name (update_profile, routers/auth.py). Slår sammen med
|
||||
# det som IKKE ble sendt i dette kallet, slik at en PATCH med
|
||||
# kun ett av de to feltene ikke nullstiller det andre.
|
||||
new_first = (
|
||||
updates["guest_first_name"].strip() if "guest_first_name" in updates else current["guest_first_name"]
|
||||
)
|
||||
new_last_raw = updates["guest_last_name"] if "guest_last_name" in updates else current["guest_last_name"]
|
||||
new_last = new_last_raw.strip() if new_last_raw else None
|
||||
values.append(new_first)
|
||||
set_clauses.append(f"guest_first_name = ${len(values)}")
|
||||
values.append(new_last)
|
||||
set_clauses.append(f"guest_last_name = ${len(values)}")
|
||||
values.append(f"{new_first} {new_last}".strip() if new_last else new_first)
|
||||
set_clauses.append(f"guest_name = ${len(values)}")
|
||||
if "guest_email" in updates:
|
||||
values.append(updates["guest_email"])
|
||||
|
|
@ -2511,7 +2593,8 @@ async def update_participant(
|
|||
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name, rp.guest_email,
|
||||
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name,
|
||||
rp.guest_first_name, rp.guest_last_name, rp.guest_email,
|
||||
COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name,
|
||||
rp.is_owner, rp.gender, rp.tee_name_snapshot,
|
||||
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||
|
|
@ -4302,12 +4385,106 @@ async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_
|
|||
conn, user_id=uid, type="result", message=message, link_path=f"/watch/{round_id}",
|
||||
)
|
||||
|
||||
await _send_guest_round_summaries(conn, round_id, round_label)
|
||||
|
||||
result = await _load_round_out(conn, round_id, user.user_id)
|
||||
|
||||
await broadcast_round_update(round_id)
|
||||
return result
|
||||
|
||||
|
||||
async def _send_guest_round_summaries(conn, round_id: str, round_label: str) -> None:
|
||||
"""Rundeoppsummering til midlertidige spillere med registrert e-post
|
||||
(2026-08-03) -- ETT ekte magic-link-innloggingstoken per gjest, samme
|
||||
mønster som tournaments.py sin send_scorecard_invitations. Graderer seg
|
||||
naturlig etter individuell-ball (egne slag/putter) vs. delt-ball
|
||||
(kun sidens felles slag, ADR-039 Beslutning C) -- ingen egen
|
||||
format-sjekk trengs, feltene som ikke finnes er rett og slett fraværende
|
||||
i kildedataene."""
|
||||
guest_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id::text AS id, guest_email, guest_first_name, round_side_id::text AS round_side_id, stat_level
|
||||
FROM round_participant
|
||||
WHERE round_id = $1 AND guest_email IS NOT NULL
|
||||
""",
|
||||
round_id,
|
||||
)
|
||||
for g in guest_rows:
|
||||
stat_lines: list[tuple[str, str]] = []
|
||||
if g["round_side_id"] is not None:
|
||||
side_holes = await _build_side_holes(conn, round_id, g["round_side_id"])
|
||||
summary_holes = [
|
||||
RoundSummaryHole(
|
||||
hole_number=h.hole_number,
|
||||
par=h.par,
|
||||
score=h.score if h.played else None,
|
||||
net=h.score - h.strokes_received
|
||||
if h.played and h.score is not None and h.strokes_received is not None
|
||||
else None,
|
||||
)
|
||||
for h in side_holes
|
||||
]
|
||||
else:
|
||||
p_holes = await _build_participant_holes(conn, round_id, g["id"])
|
||||
summary_holes = [
|
||||
RoundSummaryHole(
|
||||
hole_number=h.hole_number,
|
||||
par=h.par,
|
||||
score=h.score if h.played else None,
|
||||
net=h.score - h.strokes_received
|
||||
if h.played and h.score is not None and h.strokes_received is not None
|
||||
else None,
|
||||
)
|
||||
for h in p_holes
|
||||
]
|
||||
if g["stat_level"] != "strokes_only":
|
||||
total_putts = sum(h.putts for h in p_holes if h.played and h.putts is not None)
|
||||
if total_putts:
|
||||
stat_lines.append(("Putter totalt", str(total_putts)))
|
||||
|
||||
played_holes = [h for h in summary_holes if h.score is not None]
|
||||
if played_holes:
|
||||
total_score = sum(h.score for h in played_holes)
|
||||
to_par = total_score - sum(h.par for h in played_holes)
|
||||
lead_lines = [("Slag totalt", str(total_score)), ("Til par", _signed(to_par))]
|
||||
net_holes = [h for h in played_holes if h.net is not None]
|
||||
if net_holes:
|
||||
net_to_par = sum(h.net for h in net_holes) - sum(h.par for h in net_holes)
|
||||
lead_lines.append(("Netto til par", _signed(net_to_par)))
|
||||
stat_lines = lead_lines + stat_lines
|
||||
|
||||
email = g["guest_email"].strip().lower()
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES)
|
||||
# Samme "ugyldiggjør eldre uforbrukte lenker"-mønster som
|
||||
# request_magic_link/send_scorecard_invitations.
|
||||
await conn.execute(
|
||||
"UPDATE magic_link_token SET consumed_at = now() WHERE email = $1 AND consumed_at IS NULL", email
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO magic_link_token (email, token_hash, expires_at, locale) VALUES ($1, $2, $3, 'nb')",
|
||||
email, _hash_secret(raw_token), expires_at,
|
||||
)
|
||||
if settings.SMTP_CONFIGURED:
|
||||
try:
|
||||
await send_round_summary_email(
|
||||
email, g["guest_first_name"] or "Gjest", round_label, summary_holes, stat_lines, raw_token,
|
||||
)
|
||||
except Exception:
|
||||
# Se app/email.py sitt mønster -- en driftsfeil i selve
|
||||
# utsendingen skal aldri hindre fullføringen av runden.
|
||||
traceback.print_exc()
|
||||
elif settings.DEV_LOG_MAGIC_LINKS:
|
||||
print(f"[DEV] Magic link for {email} (nb): {raw_token}", flush=True)
|
||||
print(f"[DEV] Rundeoppsummering til {email}: {round_label}, {len(played_holes)} hull spilt", flush=True)
|
||||
|
||||
|
||||
def _signed(value: int) -> str:
|
||||
if value == 0:
|
||||
return "E"
|
||||
return f"+{value}" if value > 0 else str(value)
|
||||
|
||||
|
||||
def _course_handicap_from_row(p) -> int:
|
||||
return course_handicap(
|
||||
p["handicap_index_snapshot"], p["slope_rating_snapshot"], p["course_rating_snapshot"], p["tee_par_snapshot"]
|
||||
|
|
|
|||
|
|
@ -59,8 +59,10 @@
|
|||
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`
|
||||
er fortsatt den bevisste, mettede merkevare-grønnen -- kun den skal
|
||||
lese som grønn. */
|
||||
--background: oklch(0.985 0 0);
|
||||
lese som grønn. `--background` er ren hvit (0.985 var fortsatt en
|
||||
synlig off-white, ikke "fjernet") -- identisk med `--card`, kort
|
||||
skilles fra siden via border/shadow, ikke en bakgrunnsfarge-forskjell. */
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.24 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.24 0 0);
|
||||
|
|
|
|||
|
|
@ -69,7 +69,6 @@ import { cn } from "@/lib/utils"
|
|||
// hvit tekst -- feiler AA for vanlig tekst) -- egen kontrastkorrigert
|
||||
// variant (#1f6b08, ~6,6:1) i samme fargefamilie.
|
||||
const C = {
|
||||
bg: "#f7faf8",
|
||||
card: "#ffffff",
|
||||
glass: "rgba(255, 255, 255, 0.78)",
|
||||
ink: "#012c11", // primærtekst (nesten-svart grønn), IKKE lenger knappe-bakgrunn
|
||||
|
|
@ -433,7 +432,7 @@ export function Dashboard() {
|
|||
|
||||
if (loadingMe) {
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4" style={{ backgroundColor: C.bg }}>
|
||||
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background">
|
||||
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4" style={{ borderColor: `${C.accent}33`, borderTopColor: C.accent }} />
|
||||
</div>
|
||||
)
|
||||
|
|
@ -470,10 +469,10 @@ export function Dashboard() {
|
|||
const playedCourses = [...coursesMap.values()].sort((a, b) => (a.lastPlayed < b.lastPlayed ? 1 : -1))
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] flex-col" style={{ backgroundColor: C.bg }}>
|
||||
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||
<header
|
||||
className="sticky top-0 z-10 border-b pt-[env(safe-area-inset-top)]"
|
||||
style={{ backgroundColor: C.bg, borderColor: C.border }}
|
||||
className="sticky top-0 z-10 border-b bg-background pt-[env(safe-area-inset-top)]"
|
||||
style={{ borderColor: C.border }}
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-4">
|
||||
<Link href="/dashboard" aria-label="TeeCup – til forsiden" className="flex items-center gap-2 rounded-xl">
|
||||
|
|
@ -624,7 +623,7 @@ function NotificationBell({ unreadCount }: { unreadCount: number }) {
|
|||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute right-1 top-1 flex min-w-[18px] items-center justify-center rounded-full px-1 text-[11px] font-bold leading-none"
|
||||
style={{ height: 18, backgroundColor: C.warmInk, color: "#ffffff", boxShadow: `0 0 0 2px ${C.bg}` }}
|
||||
style={{ height: 18, backgroundColor: C.warmInk, color: "#ffffff", boxShadow: `0 0 0 2px var(--background)` }}
|
||||
>
|
||||
{badgeText}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import { Bell, Building2, ChevronRight, LogOut, UserCircle, UserPlus } from "luc
|
|||
import { BottomTabBar } from "@/components/dashboard"
|
||||
|
||||
const C = {
|
||||
bg: "#f7faf8",
|
||||
card: "#ffffff",
|
||||
ink: "#012c11",
|
||||
muted: "#424941",
|
||||
|
|
@ -44,7 +43,7 @@ export function MoreMenu() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] flex-col" style={{ backgroundColor: C.bg }}>
|
||||
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||
<header className="border-b pt-[env(safe-area-inset-top)]" style={{ borderColor: C.border }}>
|
||||
<div className="mx-auto w-full max-w-2xl px-5 py-4">
|
||||
<h1 className="text-xl font-extrabold tracking-tight" style={{ color: C.ink }}>
|
||||
|
|
|
|||
|
|
@ -132,6 +132,11 @@ const TWO_SIDED = new Set<FormatId>([
|
|||
// avgjør om HCP-prosenten bygges som "combined" (delt enhet) eller
|
||||
// "per_player" i allowance_override.strategy.
|
||||
const SIDE_IS_UNIT_FORMAT = new Set<FormatId>(["foursome", "greensome", "scramble2", "scramble4", "chapman"])
|
||||
// "Bruk course handicap-justering" er uten reell nytteverdi for de to
|
||||
// enkleste, rene individuelle formatene (etterspurt av bruker 2026-08-02)
|
||||
// -- skjules for disse, men holdes funksjonelt PÅ (aldri slettet/fjernet
|
||||
// fra selve innsendingen, kun ikke eksponert som et valg i UI-et).
|
||||
const ORDINARY_FORMATS = new Set<FormatId>(["slagspill", "stableford"])
|
||||
|
||||
function usesStep4(f: FormatId) {
|
||||
return TWO_SIDED.has(f) || f === "moneyball"
|
||||
|
|
@ -312,6 +317,14 @@ export function NewRound() {
|
|||
const [useMatchplayHcp, setUseMatchplayHcp] = useState(true)
|
||||
const [hcpPercent, setHcpPercent] = useState("")
|
||||
|
||||
// "Bruk course handicap-justering" er skjult for de ordinære formatene
|
||||
// (ORDINARY_FORMATS) -- siden brukeren da aldri ser bryteren, må den
|
||||
// funksjonelt holdes PÅ uansett hva den måtte ha stått i tidligere (f.eks.
|
||||
// slått av på et annet format før man byttet tilbake til Slagspill).
|
||||
useEffect(() => {
|
||||
if (ORDINARY_FORMATS.has(format)) setUseCourseHcp(true)
|
||||
}, [format])
|
||||
|
||||
// Step 3
|
||||
const [players, setPlayers] = useState<Player[]>([])
|
||||
|
||||
|
|
@ -506,7 +519,14 @@ export function NewRound() {
|
|||
if (p.userId) {
|
||||
participantBody.user_id = p.userId
|
||||
} else {
|
||||
participantBody.guest_name = p.name
|
||||
// Samme "første ord = fornavn, resten = etternavn"-heuristikk som
|
||||
// round-detail.tsx sin splitName()/migrasjon 052 sin backfill
|
||||
// (2026-08-03 -- ParticipantCreate godtar ikke lenger guest_name
|
||||
// direkte, kun guest_first_name/guest_last_name).
|
||||
const trimmedName = p.name.trim()
|
||||
const spaceIdx = trimmedName.indexOf(" ")
|
||||
participantBody.guest_first_name = spaceIdx === -1 ? trimmedName : trimmedName.slice(0, spaceIdx)
|
||||
participantBody.guest_last_name = spaceIdx === -1 ? null : trimmedName.slice(spaceIdx + 1).trim()
|
||||
participantBody.gender = apiGenderOf(p.gender)
|
||||
participantBody.handicap_index = parseHcp(p.hcp)
|
||||
if (p.email && p.email.trim()) participantBody.guest_email = p.email.trim()
|
||||
|
|
@ -632,7 +652,14 @@ export function NewRound() {
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-6 pb-32 sm:py-8">
|
||||
{/* pb-32 er reservert klaring for den faste Tilbake/Neste-linjen under --
|
||||
ALDRI en py- eller sm:py-klasse her (Tailwind emitterer sm-varianter
|
||||
i en egen media-blokk etter grunnklassene, 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" && (
|
||||
<p className="mb-6 rounded-2xl border border-dashed border-primary/40 bg-primary/5 px-4 py-3 text-sm font-semibold text-foreground">
|
||||
Du setter opp enda en flight i samme oppsett -- dato, starthull, hullantall og spilleform er
|
||||
|
|
@ -1812,8 +1839,10 @@ function Step2(props: {
|
|||
{advOpen && (
|
||||
<div className="flex flex-col gap-5 border-t border-border px-4 py-5">
|
||||
<ToggleRow label="Bruk handicap" checked={useHcp} onChange={setUseHcp} />
|
||||
{useHcp && !ORDINARY_FORMATS.has(format) && (
|
||||
<ToggleRow label="Bruk course handicap-justering" checked={useCourseHcp} onChange={setUseCourseHcp} />
|
||||
{twoSided && (
|
||||
)}
|
||||
{useHcp && twoSided && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<ToggleRow label="Bruk matchplay-handicap" checked={useMatchplayHcp} onChange={setUseMatchplayHcp} />
|
||||
<p className="px-1 text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||
|
|
@ -1822,12 +1851,14 @@ function Step2(props: {
|
|||
</p>
|
||||
</div>
|
||||
)}
|
||||
{useHcp && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="hcp-percent" className="text-base font-semibold">
|
||||
HCP-prosent (valgfritt)
|
||||
</Label>
|
||||
<Input id="hcp-percent" inputMode="decimal" value={hcpPercent} onChange={(e) => setHcpPercent(e.target.value)} placeholder="Standard for format" className="h-14 rounded-2xl text-lg" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
// aldri kun det ene feltet som ble endret.
|
||||
|
||||
import type React from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { RoundPageShell } from "@/components/round-page-shell"
|
||||
|
|
@ -60,6 +60,8 @@ type Player = {
|
|||
// "Deg Deg"-duplikat der en egen Badge også viste "Deg" ved siden av).
|
||||
name: string
|
||||
userId: string | null
|
||||
guestFirstName: string | null
|
||||
guestLastName: string | null
|
||||
guestEmail: string | null
|
||||
isOwner: boolean
|
||||
gender: Gender
|
||||
|
|
@ -158,6 +160,8 @@ type ApiParticipant = {
|
|||
id: string
|
||||
user_id: string | null
|
||||
guest_name: string | null
|
||||
guest_first_name: string | null
|
||||
guest_last_name: string | null
|
||||
// Valgfritt kontaktfelt, KUN meningsfullt for en gjest (2026-07-26).
|
||||
guest_email: string | null
|
||||
// Alltid utfylt av API-et -- gjestens navn, eller en levende oppslått
|
||||
|
|
@ -779,6 +783,8 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
id: p.id,
|
||||
name: playerLabel(p),
|
||||
userId: p.user_id,
|
||||
guestFirstName: p.guest_first_name,
|
||||
guestLastName: p.guest_last_name,
|
||||
guestEmail: p.guest_email,
|
||||
isOwner: p.is_owner,
|
||||
gender: apiGenderToUi(p.gender),
|
||||
|
|
@ -981,13 +987,22 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
}
|
||||
}
|
||||
|
||||
async function addGuest(name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) {
|
||||
async function addGuest(
|
||||
firstName: string,
|
||||
lastName: string,
|
||||
email: string,
|
||||
gender: Gender,
|
||||
hcp: number | null,
|
||||
statLevel: StatLevel,
|
||||
) {
|
||||
const res = await fetch(`/rounds/${roundId}/participants`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
guest_name: name,
|
||||
guest_first_name: firstName,
|
||||
guest_last_name: lastName.trim() === "" ? null : lastName,
|
||||
guest_email: email.trim() === "" ? null : email.trim(),
|
||||
gender: uiGenderToApi(gender),
|
||||
handicap_index: hcp,
|
||||
stat_level: statLevel,
|
||||
|
|
@ -3313,7 +3328,7 @@ function PlayerList({
|
|||
editingPlayerId: string | null
|
||||
onToggleEdit: (id: string) => void
|
||||
onPatchParticipant: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
|
||||
onAddGuest: (name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
|
||||
onAddGuest: (firstName: string, lastName: string, email: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
|
||||
onAddSearched: (userId: string, statLevel: StatLevel) => Promise<string | null>
|
||||
}) {
|
||||
return (
|
||||
|
|
@ -3462,7 +3477,7 @@ function AddParticipantForm({
|
|||
onAddSearched,
|
||||
onCancel,
|
||||
}: {
|
||||
onAddGuest: (name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
|
||||
onAddGuest: (firstName: string, lastName: string, email: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
|
||||
onAddSearched: (userId: string, statLevel: StatLevel) => Promise<string | null>
|
||||
onCancel: () => void
|
||||
}) {
|
||||
|
|
@ -3500,7 +3515,7 @@ function AddParticipantForm({
|
|||
}
|
||||
|
||||
if (mode === "guest") {
|
||||
return <AddGuestForm onAdd={onAddGuest} onCancel={onCancel} onBack={() => setMode("search")} />
|
||||
return <AddGuestForm initialName={query} onAdd={onAddGuest} onCancel={onCancel} onBack={() => setMode("search")} />
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -3585,27 +3600,88 @@ function AddParticipantForm({
|
|||
}
|
||||
|
||||
// --- Add guest form (fallback for spillere uten TeeCup-konto) --------------
|
||||
// Navnesplitt/e-post/"gjenkjenn gjest"-utvidelse 2026-08-03 -- se
|
||||
// CHANGELOG.md for full bakgrunn (retroaktiv e-post-kobling, rundeoppsummer-
|
||||
// ing per e-post ved fullføring).
|
||||
|
||||
// Samme "første ord = fornavn, resten = etternavn"-heuristikk som
|
||||
// migrasjon 052 sin backfill -- brukt til å autofylle fra søkefeltet.
|
||||
function splitName(full: string): { first: string; last: string } {
|
||||
const trimmed = full.trim()
|
||||
const spaceIdx = trimmed.indexOf(" ")
|
||||
if (spaceIdx === -1) return { first: trimmed, last: "" }
|
||||
return { first: trimmed.slice(0, spaceIdx), last: trimmed.slice(spaceIdx + 1).trim() }
|
||||
}
|
||||
|
||||
type KnownGuest = {
|
||||
guest_first_name: string
|
||||
guest_last_name: string | null
|
||||
gender: ApiGender | null
|
||||
handicap_index: number | null
|
||||
}
|
||||
|
||||
function AddGuestForm({
|
||||
initialName,
|
||||
onAdd,
|
||||
onCancel,
|
||||
onBack,
|
||||
}: {
|
||||
onAdd: (name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
|
||||
initialName: string
|
||||
onAdd: (firstName: string, lastName: string, email: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
|
||||
onCancel: () => void
|
||||
onBack?: () => void
|
||||
}) {
|
||||
const [name, setName] = useState("")
|
||||
const initialSplit = useMemo(() => splitName(initialName), [initialName])
|
||||
const [firstName, setFirstName] = useState(initialSplit.first)
|
||||
const [lastName, setLastName] = useState(initialSplit.last)
|
||||
const [email, setEmail] = useState("")
|
||||
const [gender, setGender] = useState<Gender>("male")
|
||||
const [hcp, setHcp] = useState("")
|
||||
const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only")
|
||||
const [knownGuest, setKnownGuest] = useState<KnownGuest | null>(null)
|
||||
const [knownGuestDismissed, setKnownGuestDismissed] = useState(false)
|
||||
|
||||
// "Vi kjenner igjen denne e-posten" -- kun søk i EGNE tidligere
|
||||
// registrerte gjester (håndhevet server-side, se find_known_guest i
|
||||
// app/routers/rounds.py -- aldri et globalt oppslag).
|
||||
useEffect(() => {
|
||||
const trimmed = email.trim()
|
||||
setKnownGuestDismissed(false)
|
||||
if (trimmed.length < 5 || !trimmed.includes("@")) {
|
||||
setKnownGuest(null)
|
||||
return
|
||||
}
|
||||
const handle = setTimeout(() => {
|
||||
fetch(`/rounds/guests/known?email=${encodeURIComponent(trimmed)}`, { credentials: "include" })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data: KnownGuest | null) => setKnownGuest(data))
|
||||
.catch(() => setKnownGuest(null))
|
||||
}, 350)
|
||||
return () => clearTimeout(handle)
|
||||
}, [email])
|
||||
|
||||
function useKnownGuest() {
|
||||
if (!knownGuest) return
|
||||
setFirstName(knownGuest.guest_first_name)
|
||||
setLastName(knownGuest.guest_last_name ?? "")
|
||||
if (knownGuest.gender) setGender(apiGenderToUi(knownGuest.gender))
|
||||
if (knownGuest.handicap_index !== null) setHcp(String(knownGuest.handicap_index).replace(".", ","))
|
||||
setKnownGuestDismissed(true)
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
const trimmedFirst = firstName.trim()
|
||||
if (!trimmedFirst) return
|
||||
const parsedHcp = hcp.trim() === "" ? null : Number(hcp.replace(",", "."))
|
||||
onAdd(trimmed, gender, parsedHcp !== null && !Number.isNaN(parsedHcp) ? parsedHcp : null, statLevel)
|
||||
onAdd(
|
||||
trimmedFirst,
|
||||
lastName.trim(),
|
||||
email.trim(),
|
||||
gender,
|
||||
parsedHcp !== null && !Number.isNaN(parsedHcp) ? parsedHcp : null,
|
||||
statLevel,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -3619,11 +3695,57 @@ function AddGuestForm({
|
|||
← Søk i stedet
|
||||
</button>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="guest-name" className="text-base font-semibold">
|
||||
Navn
|
||||
<Label htmlFor="guest-first-name" className="text-base font-semibold">
|
||||
Fornavn
|
||||
</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-first-name"
|
||||
autoFocus
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
placeholder="Fornavn"
|
||||
className="h-12 rounded-2xl text-base"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="guest-last-name" className="text-base font-semibold">
|
||||
Etternavn <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="guest-last-name"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
placeholder="Etternavn"
|
||||
className="h-12 rounded-2xl text-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="guest-email" className="text-base font-semibold">
|
||||
E-post <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="guest-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="For scorekort og statistikk etter runden"
|
||||
className="h-12 rounded-2xl text-base"
|
||||
/>
|
||||
{knownGuest && !knownGuestDismissed && (
|
||||
<div className="flex flex-col gap-2 rounded-2xl border border-primary/40 bg-primary/5 p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-foreground">
|
||||
Vi fant <span className="font-bold">{knownGuest.guest_first_name} {knownGuest.guest_last_name}</span>{" "}
|
||||
fra en tidligere runde du satte opp.
|
||||
</p>
|
||||
<Button type="button" size="sm" onClick={useKnownGuest} className="h-10 shrink-0 rounded-xl px-4 text-sm font-bold">
|
||||
Bruk disse opplysningene
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChoiceRow
|
||||
|
|
@ -4014,7 +4136,8 @@ function EditParticipantPanel({
|
|||
const [teeName, setTeeName] = useState(player.teeName)
|
||||
const [hcp, setHcp] = useState(player.hcp !== null ? String(player.hcp).replace(".", ",") : "")
|
||||
const [statLevel, setStatLevel] = useState<StatLevel>(player.statLevel)
|
||||
const [guestName, setGuestName] = useState(player.name)
|
||||
const [guestFirstName, setGuestFirstName] = useState(player.guestFirstName ?? player.name)
|
||||
const [guestLastName, setGuestLastName] = useState(player.guestLastName ?? "")
|
||||
const [gender, setGender] = useState<Gender>(player.gender)
|
||||
const [guestEmail, setGuestEmail] = useState(player.guestEmail ?? "")
|
||||
const [excludeFromHandicap, setExcludeFromHandicap] = useState(player.excludeFromHandicap)
|
||||
|
|
@ -4054,8 +4177,11 @@ function EditParticipantPanel({
|
|||
if (normalizedHcp !== player.hcp) body.handicap_index = normalizedHcp
|
||||
if (statLevel !== player.statLevel) body.stat_level = statLevel
|
||||
if (isGuest) {
|
||||
const trimmedName = guestName.trim()
|
||||
if (trimmedName && trimmedName !== player.name) body.guest_name = trimmedName
|
||||
const trimmedFirst = guestFirstName.trim()
|
||||
const trimmedLast = guestLastName.trim()
|
||||
if (trimmedFirst && trimmedFirst !== (player.guestFirstName ?? "")) body.guest_first_name = trimmedFirst
|
||||
const normalizedLast = trimmedLast === "" ? null : trimmedLast
|
||||
if (normalizedLast !== (player.guestLastName ?? null)) body.guest_last_name = normalizedLast
|
||||
if (gender !== player.gender) body.gender = apiGender
|
||||
const trimmedEmail = guestEmail.trim()
|
||||
const normalizedEmail = trimmedEmail === "" ? null : trimmedEmail
|
||||
|
|
@ -4095,17 +4221,30 @@ function EditParticipantPanel({
|
|||
<>
|
||||
{isGuest && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={`edit-guest-name-${player.id}`} className="text-sm font-semibold">
|
||||
Navn
|
||||
<Label htmlFor={`edit-guest-first-name-${player.id}`} className="text-sm font-semibold">
|
||||
Fornavn
|
||||
</Label>
|
||||
<Input
|
||||
id={`edit-guest-name-${player.id}`}
|
||||
value={guestName}
|
||||
onChange={(e) => setGuestName(e.target.value)}
|
||||
id={`edit-guest-first-name-${player.id}`}
|
||||
value={guestFirstName}
|
||||
onChange={(e) => setGuestFirstName(e.target.value)}
|
||||
className="h-11 rounded-xl text-base"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={`edit-guest-last-name-${player.id}`} className="text-sm font-semibold">
|
||||
Etternavn <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id={`edit-guest-last-name-${player.id}`}
|
||||
value={guestLastName}
|
||||
onChange={(e) => setGuestLastName(e.target.value)}
|
||||
className="h-11 rounded-xl text-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChoiceRow
|
||||
label="Kjønn"
|
||||
|
|
|
|||
|
|
@ -1306,6 +1306,8 @@ function CreateSessionCard({
|
|||
{advancedOpen && (
|
||||
<div className="flex flex-col gap-1 border-t border-border px-4 py-2">
|
||||
<ToggleRow label="Bruk handicap" checked={useHandicap} onCheckedChange={setUseHandicap} />
|
||||
{useHandicap && (
|
||||
<>
|
||||
<ToggleRow
|
||||
label="Bruk course handicap-justering"
|
||||
checked={useCourseHandicap}
|
||||
|
|
@ -1329,6 +1331,8 @@ function CreateSessionCard({
|
|||
className="h-11 rounded-xl text-base"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,13 @@
|
|||
// appen, se team_authz.py sin trusselmodell-begrunnelse), ingen
|
||||
// cache-tømming ved utlogging bygget ennå.
|
||||
|
||||
const RUNTIME_CACHE = "teecup-runtime-v1"
|
||||
// v2 (2026-08-02): "nettverk først" var i praksis "nettleser-cache først" --
|
||||
// et rått fetch(req) her respekterer fortsatt HTTP Cache-Control på selve
|
||||
// requesten, så en side lastet før en ny utrulling kunne forbli synlig
|
||||
// lenge etter deploy uten at brukeren merket noe (ingen feilmelding, bare
|
||||
// stille utdatert innhold). explicit cache:"reload" tvinger et ekte
|
||||
// nettverksoppslag hver gang, samme prinsipp begge steder under.
|
||||
const RUNTIME_CACHE = "teecup-runtime-v2"
|
||||
const OFFLINE_URL = "/offline.html"
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
|
|
@ -51,7 +57,7 @@ self.addEventListener("fetch", (event) => {
|
|||
|
||||
if (req.mode === "navigate") {
|
||||
event.respondWith(
|
||||
fetch(req)
|
||||
fetch(req, { cache: "reload" })
|
||||
.then((res) => {
|
||||
const copy = res.clone()
|
||||
caches.open(RUNTIME_CACHE).then((cache) => cache.put(req, copy))
|
||||
|
|
@ -65,7 +71,7 @@ self.addEventListener("fetch", (event) => {
|
|||
const url = new URL(req.url)
|
||||
if (url.origin === self.location.origin && url.pathname.startsWith("/orgs/")) {
|
||||
event.respondWith(
|
||||
fetch(req)
|
||||
fetch(req, { cache: "reload" })
|
||||
.then((res) => {
|
||||
if (res.ok) caches.open(RUNTIME_CACHE).then((cache) => cache.put(req, res.clone()))
|
||||
return res
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue