Update Todos

Flytte is_participant-logikk til team_authz.py (unngå sirkulær import)

Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match

Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup

Widen tournaments.py: list_sessions/list_teams/concede_tournament

Widen courses.py: list_holes

Widen messaging.py: team chat REST-endepunkter (list/send/delete)

Legge til my_session_id/my_match_id i /auth/me sin my_tournaments

Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder»

Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller)

Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag

Presentere plan, deploye og oppdatere .md-filer for deltaker-tilgang-runden

Bygge og scratch-verifisere sekundær e-postadresse (migrasjon 017)

Designe, bygge og scratch-verifisere HCP-historikk (migrasjon 018)
HCP-historikk er bygget og scratch-verifisert (18/18 sjekker, test_isolation.sql fortsatt 12/12).

Hva er bygget:

Migrasjon 018_handicap_history.sql — ny append-only-tabell handicap_history, kun for den personlige profilens HCP (ikke lagenes frosne handicap_index_snapshot, som er urørt, ADR-007).
Backend: PATCH /auth/profile logger nå automatisk en ny rad når HCP faktisk endres til en tallverdi — ikke ved ren nullstilling, og ikke ved gjentatt lagring av samme verdi (sjekket eksplisitt mot forrige verdi først). Ny GET /auth/profile/handicap-history.
Frontend: en «Vis HCP-historikk»-lenke i /account sin profilseksjon, viser dato + verdi-liste, oppdateres automatisk etter lagring.
Verifisert grundig: ingen duplikat ved gjentatt lagring av uendret verdi, korrekt logging ved faktisk endring, ingen logg ved nullstilling, ny logg ved gjeninnsetting etter nullstilling, kronologisk rekkefølge riktig, og full isolasjon mellom to brukeres historikk.
This commit is contained in:
Erol Haagenrud 2026-07-22 06:31:11 +02:00
parent b376f095be
commit 1a86be3f83
5 changed files with 234 additions and 19 deletions

28
018_handicap_history.sql Normal file
View file

@ -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;

View file

@ -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.

View file

@ -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.
---

View file

@ -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,

View file

@ -403,10 +403,80 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
{saving ? "Lagrer …" : "Lagre profil"}
</Button>
</form>
<HandicapHistorySection reloadKey={me.handicap_index} />
</section>
)
}
// 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 (
<div className="border-t border-border pt-4">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="text-sm font-semibold text-primary underline-offset-2 hover:underline"
>
{open ? "Skjul HCP-historikk" : "Vis HCP-historikk"}
</button>
{open && (
<div className="mt-3">
{entries === null ? (
<p className="text-sm text-muted-foreground">Laster </p>
) : entries.length === 0 ? (
<p className="text-sm text-muted-foreground text-pretty">
Ingen historikk ennå registreres automatisk neste gang du endrer HCP.
</p>
) : (
<ul className="flex flex-col gap-1.5">
{[...entries].reverse().map((entry, i) => (
<li
key={`${entry.recorded_at}-${i}`}
className="flex items-center justify-between gap-3 text-sm"
>
<span className="text-muted-foreground">
{new Date(entry.recorded_at).toLocaleDateString("no-NO", {
day: "numeric",
month: "short",
year: "numeric",
})}
</span>
<span className="font-semibold tabular-nums text-foreground">{entry.handicap_index}</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
)
}
// --- E-post (identifikatoren) -----------------------------------------------
// Bevisst IKKE en del av ProfileSection sin vanlige PATCH -- e-post er
// innloggings-identifikatoren, endring krever at den NYE adressen beviser