diff --git a/018_handicap_history.sql b/018_handicap_history.sql new file mode 100644 index 0000000..998600f --- /dev/null +++ b/018_handicap_history.sql @@ -0,0 +1,28 @@ +-- ===================================================================== +-- TeeCup — HCP-historikk over tid for personlig profil (migrasjon 018) +-- ===================================================================== +-- Oppfølging av ADR-031 sitt "naturlig neste steg"-punkt: personlig +-- profil sin `app_user.handicap_index` (ADR-031, migrasjon 015) endres i +-- dag stille ved hver PATCH /auth/profile, uten noen logg over tidligere +-- verdier. Denne migrasjonen legger til en append-only historikk-tabell +-- -- IKKE noe erstatning for selve `app_user.handicap_index` (som +-- fortsatt er "gjeldende verdi", brukt overalt ellers uendret). +-- +-- Bevisst KUN for den PERSONLIGE profilens HCP -- IKKE de org-scopede +-- `player.handicap_index`-radene eller `team_roster.handicap_index_ +-- snapshot` (som allerede har sitt eget, separate reproduserbarhets- +-- prinsipp, ADR-007 -- ikke noe denne migrasjonen rører). +-- ===================================================================== + +\set ON_ERROR_STOP on + +CREATE TABLE handicap_history ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + handicap_index numeric(4,1) NOT NULL, + recorded_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ON handicap_history (user_id, recorded_at); + +GRANT SELECT, INSERT ON handicap_history TO teecup_app; diff --git a/CLAUDE.md b/CLAUDE.md index 4c0a542..59f783c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2150,10 +2150,39 @@ Ferdig og verifisert: begge containere boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no` upåvirket. +- **Sekundær e-postadresse (del 1, det enkle tilfellet) — ✅ BYGGET OG LIVE + 2026-07-21**, samme dag, rett etter deltaker-tilgang-runden. Ny + migrasjon `017_secondary_email.sql` (`secondary_email_token` + + `user_secondary_email`, samme token-hash-og-utløp-mønster som ADR-032s + `email_change_token`). Nye endepunkter `POST /auth/secondary-email`, + `POST /auth/secondary-email/confirm`, `DELETE /auth/secondary-email/{id}`. + **Kjernestykket:** `verify_magic_link`/`login_with_password` slår nå opp + `user_secondary_email` FØR sitt vanlige `app_user.email`-oppslag — en + innlogging på en verifisert sekundæradresse løses til EIERENS + eksisterende konto i stedet for å opprette en ny, separat en (nøyaktig + det hullet som gjorde funksjonen nødvendig i utgangspunktet). Lagt i + `/account` (ikke dashbordet som opprinnelig bedt om — bevisst avvik, + flagget eksplisitt: dette er kun del 1, dashbord-plassering er trolig + riktigere når/hvis del 2 (kontosammenslåing) bygges). + **Scratch-verifisert, 20 sjekker:** adresse ikke lagt til før bekreftet, + token ikke gjenbrukbart, dupliserte adresser (som andres primær- ELLER + sekundæradresse) avvist tydelig, innlogging via sekundæradresse (magic- + link OG passord) bekreftet å resolve til SAMME eksisterende konto, + fremmed kan ikke slette andres adresse, fjernet adresse oppretter en + genuint NY konto ved neste innlogging (beviser fjerning er reell). + `test_isolation.sql` 12/12. Ekte typesjekket produksjonsbuild kjørt og + bekreftet. + **Rullet ut live 2026-07-21**, bruker bekreftet eksplisitt: migrasjon + 017 kjørt mot ekte `teecup_db` (begge tabeller bekreftet, `test_ + isolation.sql` fortsatt 12/12), deretter `docker compose up -d --build + teecup_api teecup_frontend`. Begge containere boot-et rent, + `/health`/`/dashboard`/`/account`/`/verify-email` → 200, `teeoff.no` + upåvirket. Del 2 (ekte kontosammenslåing) fortsatt IKKE designet, egen + fremtidig runde, se FEATURE_BACKLOG.md. + Neste steg: -1. **Pågår, samme økt:** bygge de to gjenværende bekreftede punktene fra - dashboard/konto-runden (2026-07-21) — sekundær e-postadresse (enkelt - tilfelle, gjenbruker ADR-032s token-mønster) og HCP-historikk over tid. +1. **Pågår, samme økt:** HCP-historikk over tid — siste bekreftede punkt + fra dashboard/konto-runden (2026-07-21), gjenstår å bygge. 2. **Pauset, venter på retning:** dashbordets tom-tilstand ved første innlogging. Brukeren ba om å justere det opprinnelige 2026-07-20- forslaget i lys av et dypere spørsmål — bør organisasjon fortsatt være @@ -2167,16 +2196,13 @@ Neste steg: dedikert ADR-runde, IKKE noe som skal bygges i forlengelsen av en vanlig funksjonsrunde. Se full analyse og åpne spørsmål i FEATURE_BACKLOG.md. -4. **Notert, IKKE designet i detalj:** én person med flere e-post-adresser - — legge til en verifisert sekundær-adresse fra dashbordet, med - turneringer/data som "dukker opp"/slås sammen deretter, og fremtidige - innlogginger med enten adresse. Fanget grundig i FEATURE_BACKLOG.md, - inkl. et reelt uløst spørsmål (ekte konto-sammenslåing hvis adressen - allerede tilhører en annen eksisterende konto) som trenger egen, - separat designrunde. **Merk:** dette overlapper med punkt 1 sin - "sekundær e-postadresse" — punkt 1 er det ENKLE, avgrensede tilfellet - (fri adresse), dette punktet er det harde tilfellet (adressen tilhører - allerede en annen konto). +4. **Del 1 (fri, ukrevd sekundær-e-post) er nå BYGGET OG LIVE** (2026-07-21, + se status over). **Del 2 (ekte konto-sammenslåing) fortsatt IKKE + designet:** hva skjer hvis den ønskede adressen ALLEREDE tilhører en + annen, eksisterende konto (i dag avvist tydelig med 409 DUPLICATE i + stedet for gjettet på)? Trenger egen, separat designrunde — se + FEATURE_BACKLOG.md for full analyse av hvorfor dette er vesentlig + vanskeligere enn del 1. 5. **Deltaker-tilgang til lag-chat/scorekort er nå BYGGET OG LIVE** (2026-07-21, se status over) — ADR-031s tidligere notert begrensning er dermed tettet. diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index a4d1d09..db763d0 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -1315,7 +1315,7 @@ hvilke "første handling"-alternativer dashbordet bør vise i fremtiden. --- -## Én person, flere e-postadresser — 📋 NOTERT 2026-07-20, IKKE designet/bygget +## Én person, flere e-postadresser — DEL 1 (det enkle tilfellet) ✅ BYGGET OG LIVE 2026-07-21, DEL 2 (kontosammenslåing) fortsatt 📋 NOTERT Reist av brukeren rett etter ADR-032 (verifisert e-postbytte). Et beslektet, men DISTINKT behov: én person kan ha flere e-postadresser i omløp samtidig @@ -1367,11 +1367,67 @@ naturlig ville hørt hjemme) — trolig fordi selve GEVINSTEN (nye turneringer dukker opp) er noe som vises på dashbordet, så handlingen bør ligge der resultatet vises. -**Ingenting designet i detalj eller bygget ennå** — kun fanget grundig her -slik at det ikke går i glemmeboken. Bør trolig deles i to separate runder: -(1) legg til en frisk, ukrevd sekundær-e-post (rimelig godt avgrenset, -gjenbruker ADR-032 sitt mønster direkte), (2) ekte konto-sammenslåing for -det vanskelige tilfellet over (egen, større designrunde). +**Ble delt i to separate runder, som foreslått:** (1) legg til en frisk, +ukrevd sekundær-e-post — ✅ BYGGET, se under. (2) Ekte konto-sammenslåing +for det vanskelige tilfellet — fortsatt IKKE designet, egen fremtidig +runde. + +### Del 1 (det enkle tilfellet) — ✅ BYGGET OG LIVE 2026-07-21 + +Migrasjon `017_secondary_email.sql`: to nye tabeller +(`secondary_email_token` — midlertidig, samme token-hash-og-utløp-mønster +som `email_change_token`; `user_secondary_email` — den faktiske, +verifiserte adressen, globalt UNIQUE). Nye endepunkter i +`app/routers/auth.py`: `POST /auth/secondary-email` (send +bekreftelseslenke), `POST /auth/secondary-email/confirm` (ingen sesjon +påkrevd, samme mønster som selve magic-link-verifiseringen), +`DELETE /auth/secondary-email/{id}`. + +**Kjernestykket, ikke bare CRUD:** `verify_magic_link` og +`login_with_password` sjekker nå `user_secondary_email` FØR de gjør sitt +vanlige `app_user.email`-oppslag — finner de en match, løses innloggingen +til DEN EKSISTERENDE eierens konto i stedet for å (som før) stille +opprette en helt ny, separat konto. Dette er selve mekanismen som gjør +adressen nyttig, ikke bare en liste over "andre adresser". + +**Plassering, bevisst avvik fra brukerens opprinnelige "fra dashbordet"- +instruks:** lagt i `/account` (samme sted som ADR-032 sin e-postbytte), +IKKE dashbordet — begrunnet med at dette kun er del 1 (det enkle +tilfellet); når/hvis del 2 (kontosammenslåing, "data dukker opp") bygges, +er dashbordet trolig riktigere siden GEVINSTEN vises der. Flagget +eksplisitt til bruker, ikke stille besluttet. + +**Scratch-verifisert, 20 sjekker:** adresse legges IKKE til før bekreftet; +token ikke gjenbrukbart; adresse som allerede er en ANNEN kontos +hovedadresse ELLER sekundæradresse avvist tydelig (409 DUPLICATE) i begge +retninger; innlogging via sekundæradressen (BÅDE magic-link OG passord) +løses korrekt til samme, eksisterende konto (bekreftet: samme `id`, +`email` i responsen forblir hovedadressen); en fremmed kan ikke slette +andres sekundæradresse; og — den kritiske sjekken — en ny innlogging på +adressen ETTER at den er fjernet oppretter en genuint NY, separat konto +(beviser fjerningen er reell, ikke kosmetisk). `test_isolation.sql` +fortsatt 12/12 (additiv migrasjon). Ekte typesjekket produksjonsbuild av +frontend kjørt og bekreftet. + +**Rullet ut live 2026-07-21**, bruker bekreftet eksplisitt: migrasjon 017 +kjørt mot ekte `teecup_db` (bekreftet begge nye tabeller finnes, +`test_isolation.sql` fortsatt 12/12), deretter `docker compose up -d +--build teecup_api teecup_frontend`. Begge containere boot-et rent, +`/health`/`/dashboard`/`/account`/`/verify-email` → 200, `teeoff.no` +upåvirket. + +### Del 2 (ekte kontosammenslåing) — fortsatt 📋 NOTERT, IKKE designet + +Uendret fra den opprinnelige analysen: hva skjer hvis adressen som legges +til ALLEREDE er primær- eller sekundæradressen til en ANNEN, eksisterende +konto (spilleren har altså to helt separate kontoer med egen historikk — +ulike org-medlemskap, ulike spillerkoblinger, kanskje ulikt passord/2FA)? +Dagens del 1-løsning avviser dette tydelig (409 DUPLICATE) i stedet for å +gjette — en ekte sammenslåing (slå sammen org-medlemskap uten å bryte +"én rolle per bruker per org", deduplisere spillerkoblinger, avgjøre +hvilken konto som "vinner" for motstridende felt) er en betydelig større +og mer risikofylt operasjon, fortsatt bevisst utsatt til en egen, +dedikert designrunde. --- diff --git a/app/routers/auth.py b/app/routers/auth.py index 4325934..2edd491 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -780,13 +780,48 @@ async def update_profile( values.append(user.user_id) async with plain_connection() as conn: + # HCP-historikk (FEATURE_BACKLOG.md, ADR-031 sitt "naturlig neste + # steg"-punkt): les gjeldende verdi FØR den overskrives, slik at vi + # kan avgjøre om dette faktisk er en ENDRING (ikke bare et PATCH- + # kall som gjentar samme verdi) FØR vi logger en ny historikk-rad. + old_hcp = None + if "handicap_index" in updates: + old_hcp = await conn.fetchval("SELECT handicap_index FROM app_user WHERE id = $1", user.user_id) + await conn.execute( f"UPDATE app_user SET {', '.join(set_clauses)} WHERE id = ${len(values)}", *values, ) + + if "handicap_index" in updates: + new_hcp = updates["handicap_index"] + # Kun faktiske tallverdier logges (ikke nullstilling) -- en + # "HCP fjernet"-hendelse gir ingen mening i en verdi-over-tid- + # historikk. + changed = new_hcp is not None and (old_hcp is None or float(old_hcp) != float(new_hcp)) + if changed: + await conn.execute( + "INSERT INTO handicap_history (user_id, handicap_index) VALUES ($1, $2)", + user.user_id, + new_hcp, + ) return await me(user) +@router.get("/profile/handicap-history") +async def get_handicap_history(user: CurrentUser = Depends(get_current_user)) -> list[dict]: + async with plain_connection() as conn: + rows = await conn.fetch( + """ + SELECT handicap_index::float AS handicap_index, recorded_at + FROM handicap_history WHERE user_id = $1 + ORDER BY recorded_at + """, + user.user_id, + ) + return [{"handicap_index": r["handicap_index"], "recorded_at": r["recorded_at"].isoformat()} for r in rows] + + @router.post("/profile/avatar", response_model=Me) async def upload_avatar( file: UploadFile, diff --git a/frontend/components/account-settings.tsx b/frontend/components/account-settings.tsx index 75911d2..0f8020f 100644 --- a/frontend/components/account-settings.tsx +++ b/frontend/components/account-settings.tsx @@ -403,10 +403,80 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) { {saving ? "Lagrer …" : "Lagre profil"} + + ) } +// HCP-historikk (ADR-031 sitt "naturlig neste steg"-punkt): en append-only +// logg bygget opp av selve PATCH-endepunktet (app/routers/auth.py) hver +// gang HCP-feltet faktisk endres til en tallverdi. `reloadKey` (gjeldende +// HCP) sørger for at listen hentes på nytt rett etter en lagring, uten en +// egen refetch-prop å tre gjennom fra ProfileSection. +function HandicapHistorySection({ reloadKey }: { reloadKey: number | null }) { + const [open, setOpen] = useState(false) + const [entries, setEntries] = useState<{ handicap_index: number; recorded_at: string }[] | null>(null) + + useEffect(() => { + if (!open) return + let cancelled = false + fetch("/auth/profile/handicap-history", { credentials: "include" }) + .then((res) => (res.ok ? res.json() : [])) + .then((data) => { + if (!cancelled) setEntries(data) + }) + .catch(() => { + if (!cancelled) setEntries([]) + }) + return () => { + cancelled = true + } + }, [open, reloadKey]) + + return ( +
+ + + {open && ( +
+ {entries === null ? ( +

Laster …

+ ) : entries.length === 0 ? ( +

+ Ingen historikk ennå — registreres automatisk neste gang du endrer HCP. +

+ ) : ( +
    + {[...entries].reverse().map((entry, i) => ( +
  • + + {new Date(entry.recorded_at).toLocaleDateString("no-NO", { + day: "numeric", + month: "short", + year: "numeric", + })} + + {entry.handicap_index} +
  • + ))} +
+ )} +
+ )} +
+ ) +} + // --- E-post (identifikatoren) ----------------------------------------------- // Bevisst IKKE en del av ProfileSection sin vanlige PATCH -- e-post er // innloggings-identifikatoren, endring krever at den NYE adressen beviser