diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c96e9a1..83423cc 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -327,7 +327,8 @@ "Bash(python3 /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_hcp_fix.py)", "Bash(python3 /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_tee_gender.py)", "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teeoff.no)", - "Bash(python3 /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_player_edit_and_dates.py)" + "Bash(python3 /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_player_edit_and_dates.py)", + "Bash(python3 /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_profile_and_myrounds.py)" ], "additionalDirectories": [ "/opt/teeoff/deploy", diff --git a/015_user_profile.sql b/015_user_profile.sql new file mode 100644 index 0000000..7cc5f6b --- /dev/null +++ b/015_user_profile.sql @@ -0,0 +1,37 @@ +-- ===================================================================== +-- TeeCup — personlig profil på app_user + tverr-org spiller-oppslag +-- (migrasjon 015) +-- ===================================================================== +-- ADR-031: personlig landingsside for enhver registrert bruker. Ett +-- canonical profil-sett PER KONTO (ikke per org-scopet `player`-rad -- +-- se ADR-031 for hvorfor de to bevisst holdes atskilt). Alt additivt/ +-- nullable -- trygt mot eksisterende kontoer. +-- ===================================================================== + +\set ON_ERROR_STOP on + +ALTER TABLE app_user ADD COLUMN first_name text; +ALTER TABLE app_user ADD COLUMN last_name text; +ALTER TABLE app_user ADD COLUMN birth_date date; +ALTER TABLE app_user ADD COLUMN gender text CHECK (gender IN ('m', 'f', 'x')); +ALTER TABLE app_user ADD COLUMN handicap_index numeric(4,1); +ALTER TABLE app_user ADD COLUMN home_club text; +ALTER TABLE app_user ADD COLUMN avatar_key text; + +-- Samme "smale SECURITY DEFINER-bro"-mønster som public_tournament_org() +-- (007) / link_player_by_email() (008) / public_org_by_slug() (009) / +-- public_tournament_by_code() (011): `player` er RLS-beskyttet per org, så +-- et tverr-org-oppslag ("hvilke organisasjoner har jeg en spiller-rad i") +-- kan ikke gjøres med en vanlig org-scopet tilkobling. Eksponerer KUN +-- uuid-listen, ingenting annet fra player-raden. +CREATE FUNCTION player_organizations_for_user(p_user_id uuid) +RETURNS TABLE(organization_id uuid) +LANGUAGE sql +SECURITY DEFINER +SET search_path = public +STABLE +AS $$ + SELECT DISTINCT organization_id FROM player WHERE user_id = p_user_id; +$$; + +GRANT EXECUTE ON FUNCTION player_organizations_for_user(uuid) TO teecup_app; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index e479f4d..e46806d 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -1449,6 +1449,92 @@ migrasjon. Verifisert mot ekte data: "De Gamle er Eldst" viser nå korrekt --- +## ADR-031: Personlig landingsside for enhver registrert bruker + personlig profil + +Reist av brukeren 2026-07-20, eksplisitt som en "tenk igjennom og foreslå"- +instruks, deretter et klart "gjør det" med et utvidet omfang (personlig +profil-CRUD: profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, +hjemmeklubb). + +**Bekreftet, reelt hull:** `app/page.tsx` sendte enhver innlogget bruker til +`/dashboard`, som viste "opprett organisasjon" så snart brukeren ikke var +org-medlem — også for en bruker som KUN er spiller (koblet via +`player.user_id`, ADR-017 B), aldri organisator. + +**Beslutning A — ETT samlet dashboard, ikke to atskilte ruter.** `/dashboard` +viser nå en ny "Mine runder"-seksjon øverst (turneringer brukeren er ROSTRET +i, på tvers av organisasjoner) når den finnes, med organisasjonsseksjonen +uendret under. En bruker som er BÅDE organisator og spiller ser begge deler +— ingen tvungen valg mellom to identiteter. + +**Beslutning B — personlig profil er ETT sett PER KONTO (`app_user`), atskilt +fra org-scopede `player`-rader.** Ny migrasjon `015_user_profile.sql`: +`app_user` får `first_name`/`last_name`/`birth_date`/`gender`/ +`handicap_index`/`home_club`/`avatar_key`. Bevisst IKKE forsøkt slått sammen +med `player`-radene (som fortsatt er per-organisasjon, eid av organisator, +brukt til roster/handicap-snapshot) — en person kan ha flere `player`-rader +i ulike klubber (ulikt hjemmeklubb-medlemsnummer, potensielt ulik registrert +HCP per klubb i den virkelige golfverdenen), mens KONTOENS egen profil er +brukerens EGEN, selvstyrte fremstilling av seg selv — to bevisst atskilte +konsepter, ikke ett duplisert. + +**Konsekvens:** `PATCH /auth/profile` (vanlig `exclude_unset`-PATCH-mønster +— et felt sendt eksplisitt som `null` sletter det, et utelatt felt endres +ikke), `POST`/`DELETE /auth/profile/avatar` (samme ekte multipart→AVIF- +opplastingsmønster som `tournament.hero_image_key`, ADR-018 MinIO-runden — +se `app/storage.py`). Ny seksjon i `/account` (`account-settings.tsx`). + +**Beslutning C — "Mine runder" krever et NYTT tverr-org-oppslag, samme +mønster som fire tidligere bygde bruksområder.** `player` er RLS-beskyttet +per organisasjon; å finne "hvilke org-er har jeg en spiller-rad i" krever +samme smale `SECURITY DEFINER`-bro som `public_tournament_org()` (007)/ +`link_player_by_email()` (008)/`public_org_by_slug()` (009)/ +`public_tournament_by_code()` (011) — ny `player_organizations_for_user()` +(migrasjon 015), eksponerer KUN en uuid-liste. `/auth/me` utvidet med +`my_tournaments` (samme N+1-over-`org_connection()`-mønster som allerede +brukes for `organizations`). + +**Beslutning D — ny sikkerhetsutvidelse funnet UNDER design, ikke antatt på +forhånd: en faktisk deltaker skal aldri stenges ute av sin EGEN turnering, +uansett synlighetsnivå.** Under bygging av "Mine runder" ble det klart at +`check_visibility()` (ADR-018 Beslutning C) kun ga deltaker-tilgang for +`visibility='participants'` — IKKE for `'org'` (som er DEFAULT for enhver +NY turnering). En ren spiller (rostret, men uten organisasjonsmedlemskap) +ville dermed vært stengt ute fra sin egen, helt vanlige turnering (default +`'org'`-synlighet) — nøyaktig den brukergruppen "Mine runder" er bygget +for. Utvidet: deltaker-sjekken gjelder nå for BEGGE ikke-offentlige tiere, +ikke bare `'participants'`. Begrunnelse: `visibility` styrer eksponering +mot UTENFORSTÅENDE, aldri mot folk som faktisk spiller i turneringen — det +finnes intet scenario der en organisator ønsker å skjule en turnering for +sin EGEN spiller. Verifisert presist: en faktisk deltaker (ikke org-medlem) +FÅR nå tilgang til en `'org'`-synlig turnering, mens en helt ubeslektet +FREMMED (innlogget, men ikke deltaker) og en ANONYM leser fortsatt begge +avvises som før (403 `NOT_VISIBLE`) — ren utvidelse, ingen innstramming. + +**Bevisst UTENFOR omfang denne runden, kjent gjenstående begrensning:** +"Mine runder" lenker til den offentlige turnering-siden (`/t/{id}`), IKKE +til lagets private chat eller det organisator-vendte scorekortet — disse +krever fortsatt `get_authorized_org` (ekte organisasjonsmedlemskap), en +strengere sperre enn deltaker-status alene, brukt av dusinvis av +endepunkter på tvers av hele appen. Å utvide DENNE sperren trygt til også å +godta "faktisk deltaker" er en egen, større og mer risikofylt endring +(påvirker autorisasjonsarkitekturen bredt) — bevisst IKKE gjort i denne +runden, notert i FEATURE_BACKLOG.md som naturlig neste steg. + +**Scratch-verifisert, 15 sjekker:** profil-CRUD (sett alle felt, delvis +PATCH lar andre felt stå urørt, eksplisitt `null` sletter et felt, tomt +PATCH avvist, avatar lastet opp med ekte AVIF-URL, avatar slettet, ugyldig +filtype avvist), "Mine runder" for en EKTE ren spiller (null organisasjons- +medlemskap, men `my_tournaments` viser riktig turnering+lag), OG den +kritiske sikkerhetssjekken: samme rene spiller FÅR nå se sin `'org'`- +synlige turnering, mens en fremmed innlogget bruker og en anonym begge +fortsatt avvises. Ekte typesjekket produksjonsbuild kjørt og bekreftet. + +**Status: ✅ BYGGET OG SCRATCH-VERIFISERT 2026-07-20, IKKE ENNÅ RULLET UT.** +Se CLAUDE.md-status for full byggerunde. + +--- + ## Åpne spørsmål (ikke besluttet ennå) Disse må avklares før eller under de relevante fasene: diff --git a/CLAUDE.md b/CLAUDE.md index 4cb1802..c7761f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1941,31 +1941,114 @@ Ferdig og verifisert: upåvirket. Verifisert mot EKTE data (ikke bare scratch): "De Gamle er Eldst" viser nå korrekt 11. juli 2026 i stedet for "Ingen datoer satt". +- **To nye punkter reist 2026-07-20, GJENNOMTENKT OG FORESLÅTT, IKKE + bygget:** brukeren ba eksplisitt om at punkt 1 tenkes grundig gjennom + og legges frem som et forslag FØR bygging (ikke kode med en gang), og + markerte det eksplisitt som prioritet over punkt 2. + 1. **Personlig landingsside for enhver registrert bruker (PRIORITERT).** + Bekreftet reelt hull ved kodegjennomgang: `app/page.tsx` sender + enhver innlogget bruker til `/dashboard`, som viser "opprett + organisasjon" så snart `organizations.length === 0` — også for en + bruker som KUN er spiller (koblet via `player.user_id`, + ADR-017 B), aldri organisator. Fullt forslag skrevet i + FEATURE_BACKLOG.md: ett samlet dashboard (ikke to atskilte ruter), + ny "Mine runder"-seksjon (tverr-org, krever en ny SECURITY + DEFINER-bro `player_organizations_for_user()` + utvidelse av + `/auth/me`, samme mønster som `public_tournament_org()` m.fl.), + organisasjonsseksjonen uendret under. Venter på brukerens + bekreftelse på retningen før bygging starter. + 2. **Midlertidige spillere + automatisk etter-runde-e-post (lavere + prioritet, likevel dokumentert grundig).** Presisert ved + kodegjennomgang: det meste av "midlertidig spiller"-behovet + dekkes ALLEREDE av eksisterende `POST /orgs/{id}/players` + (krever aldri en konto). Det som faktisk mangler er en PROAKTIV + e-post-utsending etter runden (scorekort + innloggingslenke) — + foreslått som en eksplisitt organisator-knapp per økt (ikke en + automatisk bakgrunnsjobb, for å unngå uventede e-poster fra en + gjettet "runden er ferdig"-deteksjon). Tre åpne spørsmål notert i + FEATURE_BACKLOG.md (økt- vs. turnering-nivå, dobbel-utsending- + sperre, locale). + **Ingen kode skrevet for noen av de to ennå** — dette var bevisst en + tenke-og-foreslå-runde, ikke en byggerunde. + +- **Punkt 1 BYGGET OG SCRATCH-VERIFISERT (2026-07-20), samme dag, rett etter + forslaget over:** bruker svarte "Gjør punkt 1" med et utvidet omfang — + inkluder også opprettelse/redigering/sletting av personlig informasjon + (profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb). + **Ny migrasjon `015_user_profile.sql`:** `app_user` får de sju nye + profilfeltene — ETT sett PER KONTO, bevisst IKKE slått sammen med de + org-scopede `player`-radene (se ADR-031 Beslutning B for full + begrunnelse — to reelt atskilte konsepter). Ny + `player_organizations_for_user()`-bro (femte instans av samme + SECURITY DEFINER-mønster som `public_tournament_org()` m.fl.). + **Backend:** `/auth/me` utvidet med profilfeltene + `avatar_url` + + `my_tournaments` (turneringer brukeren er ROSTRET i, tverr-org, samme + N+1-org_connection()-mønster som organisasjonslisten). Ny + `PATCH /auth/profile` (vanlig exclude_unset), `POST`/ + `DELETE /auth/profile/avatar` (samme ekte multipart→AVIF-mønster som + turnering-hero-bilder). + **Reelt sikkerhetshull funnet UNDER bygging, ikke antatt på forhånd:** + testet "Mine runder" mot en EKTE ren spiller (rostret, ingen + org-medlemskap) og oppdaget at `check_visibility()` (ADR-018) kun ga + deltaker-tilgang for `visibility='participants'` — IKKE for `'org'` + (DEFAULT for enhver ny turnering). En ren spiller ville altså vært + stengt ute fra sin EGEN, helt vanlige turnering — nøyaktig + brukergruppen "Mine runder" er bygget for. Fikset: deltaker-sjekken + gjelder nå begge ikke-offentlige tier, med eksplisitt begrunnelse om + at visibility styrer eksponering mot UTENFORSTÅENDE, aldri mot faktiske + deltakere. Verifisert presist at dette er en REN UTVIDELSE, ingen + innstramming: samme scratch-test bekreftet at en helt ubeslektet + FREMMED (innlogget, ikke deltaker) og en ANONYM leser fortsatt begge + avvises identisk som før (403 NOT_VISIBLE). + **Frontend:** `account-settings.tsx` fikk en ny "Personlig profil"- + seksjon (avatar-opplasting/fjerning, fornavn/etternavn/fødselsdato/ + kjønn/HCP/hjemmeklubb-skjema). `dashboard.tsx` fikk en ny + `MyToursSection` («Mine runder», øverst, lenker til den offentlige + turnering-siden) + en mykere, sekundær utgave av "opprett organisasjon"- + tomtilstanden når brukeren allerede har spiller-data å vise. + **Bevisst UTENFOR omfang, klart flagget, IKKE en del av denne rundens + leveranse:** "Mine runder" lenker IKKE til lag-chat/scorekort ennå — de + krever fortsatt ekte organisasjonsmedlemskap (`get_authorized_org`), en + strengere, bredt brukt sperre som ikke ble endret denne runden (egen, + større og mer risikofylt endring, se ADR-031). + **Scratch-verifisert, 15 sjekker:** full profil-CRUD (alle felt satt, + delvis PATCH lar andre felt stå urørt, eksplisitt `null` sletter et + felt, tomt PATCH avvist, avatar lastet opp med ekte AVIF-URL og + slettet igjen, ugyldig filtype avvist), "Mine runder" for en EKTE ren + spiller uten org-medlemskap, OG den kritiske sikkerhetssjekken over. + Ekte typesjekket produksjonsbuild kjørt og bekreftet. + **Ikke rullet ut ennå** — venter på utrullingsbekreftelse + (`015_user_profile.sql` mot ekte `teecup_db`, deretter begge + containere). + Neste steg: -1. **Oppfølgingspunkt fra PWA-runden, eksplisitt notert på brukerens - forespørsel:** offline-scoreregistrering er ALDRI browser-testet i +1. **Personlig landingsside + profil er BYGGET (ADR-031), venter på + utrullingsbekreftelse** — se over (migrasjon 015 + begge containere). + Naturlig oppfølging etterpå: deltaker-tilgang (uten org-medlemskap) til + lag-chat/scorekort — bevisst utenfor omfang denne runden, se + FEATURE_BACKLOG.md. +2. **Punkt 2 fra samme 2026-07-20-runde (lavere prioritet enn #1):** + midlertidige spillere + automatisk etter-runde-e-post — forslag klart + i FEATURE_BACKLOG.md, tre åpne spørsmål, ingen kode skrevet. +3. Oppfølgingspunkt fra PWA-runden, eksplisitt notert på brukerens + forespørsel: offline-scoreregistrering er ALDRI browser-testet i praksis (kun kodegjennomgang + build-verifisering, se ADR-028). Bruker bør selv åpne et scorekort, skru på Chrome DevTools sin Offline-bryter, registrere et par slag, skru nettet på igjen, og bekrefte at de faktisk synkes — først da er flyten reelt bevist. -2. Fire UI-/UX-hull notert 2026-07-19 (se over) — ingen fikset ennå. +4. Fire UI-/UX-hull notert 2026-07-19 (se over) — ingen fikset ennå. Rewrite/medlemsside-bugen (#2) er den mest presserende siden siden er helt utilgjengelig i dag. -3. **Alle fire nye hull (spillerliste, tee/kjønn-modell ADR-029, - tallvelger, hcp-scope-bug) er FIKSET OG LIVE** (se over, 2026-07-19, - migrasjon 014 kjørt mot ekte data). En liten, urelatert 500-krasj - (stroke-innsending på bane uten hull) ble også funnet under - verifiseringen, ikke fikset — egen, liten sak. -4. Fortsatt åpne beslutninger fra FEATURE_BACKLOG.md: kode-regenerering for +5. Fortsatt åpne beslutninger fra FEATURE_BACKLOG.md: kode-regenerering for ADR-020, korrigering-godkjenning fra motpart, video/1-til-1-meldinger (bevisst utsatt i ADR-025). -5. Flere turneringsformater utover Ryder Cup (Københavner/High-low-high/ +6. Flere turneringsformater utover Ryder Cup (Københavner/High-low-high/ Robbins/Try all, notert 2026-07-19) — ingen ADR-runde startet ennå. -6. **Merk for neste økt:** `deploy/Caddyfile`-endringen (ny `/ws/*`-rute, +7. **Merk for neste økt:** `deploy/Caddyfile`-endringen (ny `/ws/*`-rute, ADR-025) ligger uncommitted i det SEPARATE `/opt/teeoff`-repoet, ikke i `teecup`-repoet — samme fallgruve som ADR-016-runden sin Caddy-endring, lett å glemme siden denne økten ellers kun har jobbet i `/opt/teecup`. -7. Ved fremtidige nye V0-skjermer/-design i V0 (fortsett i samme prosjekt), +8. Ved fremtidige nye V0-skjermer/-design i V0 (fortsett i samme prosjekt), FORVENT en full re-eksport hver gang — diff mot live-treet i et scratch-område før noe pakkes ut over eksisterende filer, og sjekk om V0-skjermen bygger inn handlinger backend ikke støtter ennå FØR diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 6e57cc9..54133da 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -1011,6 +1011,103 @@ bekreftet. --- +## Personlig landingsside for ENHVER registrert bruker + personlig profil — ✅ BYGGET OG SCRATCH-VERIFISERT 2026-07-20 (ADR-031) + +Reist av brukeren 2026-07-20 som en "tenk igjennom og foreslå"-instruks, +deretter et "gjør det" med utvidet omfang (personlig profil-CRUD lagt til: +profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb). Full +detalj i ARCHITECTURE_DECISIONS.md ADR-031 — kort her: + +| Del | Status | Notat | +|---|---|---| +| Ett samlet dashboard (ikke to atskilte ruter) | ✅ | Ny "Mine runder"-seksjon øverst i `dashboard.tsx`, organisasjonsseksjonen uendret under, begge vises hvis begge finnes. | +| "Mine runder" — tverr-org spiller-oppslag | ✅ | Ny `player_organizations_for_user()`-bro (migrasjon 015, samme mønster som fire tidligere), `/auth/me` utvidet med `my_tournaments`. Kun rostrede lag i v1 (ikke rene påmeldinger uten roster). | +| Personlig profil (fornavn/etternavn/fødselsdato/kjønn/HCP/hjemmeklubb) | ✅ | Nye felt på `app_user` (migrasjon 015) — ETT sett per konto, BEVISST atskilt fra org-scopede `player`-rader (se ADR-031 Beslutning B for hvorfor). `PATCH /auth/profile`, ny seksjon i `/account`. | +| Profilbilde | ✅ | `POST`/`DELETE /auth/profile/avatar`, samme ekte multipart→AVIF-opplasting som turnering-hero-bilder (ADR-018). | +| **Sikkerhetsutvidelse funnet UNDER bygging:** deltaker-tilgang uansett synlighetsnivå | ✅ | `check_visibility()` ga tidligere kun deltaker-tilgang for `visibility='participants'` — IKKE for `'org'` (DEFAULT for enhver ny turnering), som ville stengt ute enhver spiller uten org-medlemskap fra sin EGEN turnering. Utvidet til å gjelde begge ikke-offentlige tier. Verifisert: deltaker FÅR nå tilgang, fremmed+anonym fortsatt avvist (ingen innstramming, ren utvidelse). | + +**Bevisst UTENFOR omfang, kjent gjenstående begrensning (se ADR-031 for full +begrunnelse):** "Mine runder" lenker til den offentlige turnering-siden +(`/t/{id}`), IKKE til lagets private chat eller scorekortet — disse krever +fortsatt ekte organisasjonsmedlemskap (`get_authorized_org`), en strengere +sperre enn deltaker-status alene, brukt av dusinvis av endepunkter på tvers +av appen. Å utvide DEN sperren til også å godta "faktisk deltaker" er en +egen, større og mer risikofylt endring (påvirker autorisasjonsarkitekturen +bredt, ikke ett enkelt endepunkt) — naturlig neste steg, men bevisst ikke +gjort i denne runden. Notifikasjons-/aktivitetsfeed og HCP-historikk over +tid også bevisst utenfor omfang v1 (samme begrunnelse som opprinnelig +forslag). + +**Scratch-verifisert, 15 sjekker** (profil-CRUD komplett, inkl. sletting av +enkeltfelt og avatar; en EKTE ren spiller uten org-medlemskap ser riktig +`my_tournaments`; den kritiske sikkerhetssjekken: samme spiller får nå se +sin `'org'`-synlige turnering, mens fremmed/anonym fortsatt avvises). Ekte +typesjekket produksjonsbuild kjørt og bekreftet. + +**Ikke rullet ut ennå** — se CLAUDE.md-status. + +### Naturlig neste steg (ikke bygget, notert for senere) +- **Deltaker-tilgang (uten org-medlemskap) til lag-chat og scorekort** — det + gjenstående hullet nevnt over. Krever en egen, forsiktig gjennomgang av + `get_authorized_org`-bruken (brukt bredt i hele appen) — ikke en rask fiks. +- Notifikasjons-/aktivitetsfeed på "Mine runder". +- HCP-historikk over tid (personlig profil sin `handicap_index` endres i + dag uten noen logg). +- "Mine runder" for RENE påmeldinger (`tournament_registration` uten + roster ennå) — v1 viser kun rostrede lag. + +--- + +## Midlertidige spillere + automatisk etter-runde-invitasjon — 📋 FORESLÅTT 2026-07-20, IKKE bygget ennå + +Reist samme runde som punktet over, uttalt som punkt 2 (ikke like prioritert +som "Mine runder"-dashbordet, men skal likevel dokumenteres grundig nå). + +**Viktig presisering, funnet ved kodegjennomgang FØR noe ble antatt:** det +meste av "midlertidig spiller"-behovet er allerede dekket av eksisterende +funksjonalitet, ikke et hull i seg selv — `POST /orgs/{id}/players` krever +ALDRI at spilleren har en konto (`player.user_id` er nullable, kobles først +automatisk når/hvis noen logger inn på matchende e-post, ADR-017 +Beslutning B). En organisator kan altså allerede legge til "Ola Nordmann, +ola@example.com" uten at Ola noensinne har hørt om TeeCup. Det som +FAKTISK mangler er den PROAKTIVE oppfølgingen brukeren ber om: et +automatisk e-post-utsendelse-steg etter runden, med scorekort + invitasjon +til å logge inn og "ta eierskap" over spiller-profilen sin — dette finnes +ikke i noen form i dag (en spiller må selv, uoppfordret, logge inn for at +koblingen skal skje). + +**Foreslått design, IKKE bygget:** +- **Utløses av en EKSPLISITT organisator-handling, ikke en automatisk + bakgrunnsjobb** ("Send scorekort og invitasjon til alle med e-post i + denne økten", en knapp på øktnivå når øktens matcher er avgjort). + Anbefalt fremfor helautomatisk utsendelse ved et gjettet + "runden er ferdig"-tidspunkt — unngår uventede e-poster fra en + feilaktig auto-deteksjon, og matcher prosjektets øvrige mønster (blind + draw krever eksplisitt lås, walkover er en eksplisitt handling — ingen + "magisk" auto-trigger noe annet sted i appen). +- **Ingen ny databasekolonne nødvendig for selve "midlertidig"-begrepet** + — enhver `player`-rad UTEN `user_id` ER allerede "midlertidig" i praksis. + Kun en NY, liten `sent_at`-lignende sporingskolonne kan trengs for å + unngå dobbel utsending ved gjentatt klikk (åpent spørsmål, se under). +- **E-posten gjenbruker eksisterende infrastruktur** (`app/email.py`, + samme SMTP-oppsett som magic-link/2FA) — innhold: spillerens + hull-for-hull-resultat for økten + en ekte innloggingslenke (vanlig + magic-link, ingen ny auth-mekanisme nødvendig siden `link_player_by_ + email()` allerede kobler kontoen automatisk ved første innlogging). + +**Åpne spørsmål, trengs FØR bygging:** +- Skal utsendingsknappen ligge på ØKT-nivå (send til alle i denne ene + runden) eller TURNERING-nivå (send til alle på tvers av alle økter, når + hele turneringen er ferdig)? Økt-nivå virker riktigst — en spiller kan + ha spilt kun én av flere økter. +- Skal systemet spore "allerede sendt til denne spilleren for denne økten" + for å hindre dobbel utsending ved et nytt klikk (sannsynligvis ja — én + liten ny tabell/kolonne)? +- Skal e-posten sendes på spillerens/organisasjonens foretrukne språk + (samme `locale`-mønster som magic-link-e-posten, ADR-015 Beslutning C)? + +--- + ## UX / frontend (senere fase) - 📋 Høy kontrast, dark/light, store +/- knapper, stor «Neste hull»-knapp diff --git a/app/routers/auth.py b/app/routers/auth.py index 47d74e9..1b165a1 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -38,14 +38,15 @@ import base64 import hashlib import secrets import traceback -from datetime import datetime, timedelta, timezone +from datetime import date, datetime, timedelta, timezone from io import BytesIO from typing import Literal import qrcode -from fastapi import APIRouter, Depends, Request, Response +from fastapi import APIRouter, Depends, Request, Response, UploadFile from pydantic import BaseModel, EmailStr, Field +from .. import storage from ..auth import ( CurrentUser, PendingUser, @@ -555,6 +556,22 @@ class MyOrg(BaseModel): role: str +class MyTournament(BaseModel): + """Én rad = ett lag brukeren er ROSTRET på (ADR-031, 'Mine runder'). + Bevisst utenfor omfang v1: turneringer der brukeren kun er PÅMELDT + (`tournament_registration`) men ikke ennå rostret på et lag.""" + + organization_id: str + organization_name: str + tournament_id: str + tournament_name: str + status: str + team_id: str + team_name: str + team_color: str | None + next_session_at: datetime | None + + class Me(BaseModel): id: str email: str @@ -563,6 +580,17 @@ class Me(BaseModel): has_password: bool two_factor_method: Literal["totp", "email"] | None organizations: list[MyOrg] + # Personlig profil (ADR-031) -- ETT sett per konto, atskilt fra de + # org-scopede `player`-radene (se ADR-031 for hvorfor de ikke slås + # sammen til én ting). + first_name: str | None + last_name: str | None + birth_date: date | None + gender: str | None + handicap_index: float | None + home_club: str | None + avatar_url: str | None + my_tournaments: list[MyTournament] @router.get("/me", response_model=Me) @@ -577,11 +605,18 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me: # (som setter riktig kontekst for akkurat den ene raden) -- N+1 spørringer, # men N er antall organisasjoner brukeren tilhører (typisk 1-3), og dette # er den eneste måten å gjøre det på uten å endre selve RLS-modellen. + # + # ADR-031: samme N+1-mønster brukt for "Mine runder", nå over org-er + # funnet via player_organizations_for_user() (migrasjon 015) i stedet + # for organization_membership -- en bruker kan være SPILLER i en org + # helt uavhengig av om de er MEDLEM der. async with plain_connection() as conn: user_row = await conn.fetchrow( """ SELECT id::text AS id, email::text AS email, display_name, preferred_locale, - (password_hash IS NOT NULL) AS has_password, two_factor_method + (password_hash IS NOT NULL) AS has_password, two_factor_method, + first_name, last_name, birth_date, gender, + handicap_index::float AS handicap_index, home_club, avatar_key FROM app_user WHERE id = $1 """, user.user_id, @@ -590,6 +625,10 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me: "SELECT organization_id::text AS organization_id, role FROM organization_membership WHERE user_id = $1", user.user_id, ) + player_org_rows = await conn.fetch( + "SELECT organization_id::text AS organization_id FROM player_organizations_for_user($1)", + user.user_id, + ) organizations = [] for m in membership_rows: @@ -599,6 +638,32 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me: ) organizations.append(MyOrg(organization_id=m["organization_id"], name=name, role=m["role"])) + my_tournaments: list[MyTournament] = [] + for p in player_org_rows: + org_id = p["organization_id"] + async with org_connection(org_id) as org_conn: + org_name = await org_conn.fetchval("SELECT name FROM organization WHERE id = $1", org_id) + rows = await org_conn.fetch( + """ + SELECT t.id::text AS tournament_id, t.name AS tournament_name, + t.status::text AS status, + tm.id::text AS team_id, tm.name AS team_name, tm.color AS team_color, + (SELECT min(s.scheduled_at) FROM session s + WHERE s.tournament_id = t.id AND s.scheduled_at > now()) AS next_session_at + FROM player pl + JOIN team_roster tr ON tr.player_id = pl.id + JOIN team tm ON tm.id = tr.team_id + JOIN tournament t ON t.id = tm.tournament_id + WHERE pl.user_id = $1 + ORDER BY t.created_at DESC + """, + user.user_id, + ) + for r in rows: + my_tournaments.append( + MyTournament(organization_id=org_id, organization_name=org_name, **dict(r)) + ) + return Me( id=user_row["id"], email=user_row["email"], @@ -607,4 +672,79 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me: has_password=user_row["has_password"], two_factor_method=user_row["two_factor_method"], organizations=organizations, + first_name=user_row["first_name"], + last_name=user_row["last_name"], + birth_date=user_row["birth_date"], + gender=user_row["gender"], + handicap_index=user_row["handicap_index"], + home_club=user_row["home_club"], + avatar_url=storage.public_url(user_row["avatar_key"]) if user_row["avatar_key"] else None, + my_tournaments=my_tournaments, ) + + +class ProfileUpdate(BaseModel): + """Personlig profil (ADR-031) -- PATCH-semantikk via exclude_unset, som + resten av appen. Et felt sendt eksplisitt som `null` NULLES (f.eks. + fjern fødselsdato), et UTELATT felt endres ikke -- vanlig + "sletting" av et enkeltfelt trenger derfor ingen egen DELETE-vei her, + kun avatar (binært innhold) har sin egen under.""" + + first_name: str | None = Field(default=None, max_length=100) + last_name: str | None = Field(default=None, max_length=100) + birth_date: date | None = None + gender: str | None = Field(default=None, pattern="^[mfx]$") + handicap_index: float | None = None + home_club: str | None = Field(default=None, max_length=200) + + +@router.patch("/profile", response_model=Me) +async def update_profile( + body: ProfileUpdate, + user: CurrentUser = Depends(get_current_user), +) -> Me: + updates = body.model_dump(exclude_unset=True) + if not updates: + raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.") + + set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)] + values = list(updates.values()) + values.append(user.user_id) + + async with plain_connection() as conn: + await conn.execute( + f"UPDATE app_user SET {', '.join(set_clauses)} WHERE id = ${len(values)}", + *values, + ) + return await me(user) + + +@router.post("/profile/avatar", response_model=Me) +async def upload_avatar( + file: UploadFile, + user: CurrentUser = Depends(get_current_user), +) -> Me: + """Samme ekte multipart->AVIF-mønster som tournament.hero_image_key + (ADR-018 MinIO-runden) -- se app/storage.py.""" + if file.content_type not in storage.ALLOWED_INPUT_CONTENT_TYPES: + raise app_error(400, "VALIDATION_FAILED", "Ustøttet bildeformat.") + + raw = await file.read(storage.MAX_UPLOAD_BYTES + 1) + if len(raw) > storage.MAX_UPLOAD_BYTES: + raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 8 MB).") + + try: + key = await storage.upload_image("avatars", user.user_id, raw) + except storage.InvalidImageError: + raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.") + + async with plain_connection() as conn: + await conn.execute("UPDATE app_user SET avatar_key = $1 WHERE id = $2", key, user.user_id) + return await me(user) + + +@router.delete("/profile/avatar", response_model=Me) +async def remove_avatar(user: CurrentUser = Depends(get_current_user)) -> Me: + async with plain_connection() as conn: + await conn.execute("UPDATE app_user SET avatar_key = NULL WHERE id = $1", user.user_id) + return await me(user) diff --git a/app/routers/registration.py b/app/routers/registration.py index 7c54044..3485f9c 100644 --- a/app/routers/registration.py +++ b/app/routers/registration.py @@ -91,9 +91,15 @@ async def check_visibility( ) if is_member: return - if visibility == "participants" and await is_participant( - conn, user.user_id, organization_id, tournament_id - ): + # ADR-031: en faktisk DELTAKER skal aldri stenges ute av øktens + # egen turnering, uansett hvilken av de to ikke-offentlige tierene + # som er valgt -- visibility styrer eksponering mot UTENFORSTÅENDE, + # ikke mot spillere som faktisk er med. Utvidet fra kun + # visibility == "participants" (opprinnelig ADR-018 Beslutning C) + # da "Mine runder"-dashbordet gjorde det tydelig at 'org' (som er + # DEFAULT for enhver ny turnering) ellers ville stengt ute enhver + # spiller uten organisasjonsmedlemskap fra sin egen turnering. + if await is_participant(conn, user.user_id, organization_id, tournament_id): return raise app_error(403, "NOT_VISIBLE", "Du har ikke tilgang til denne turneringen.") diff --git a/frontend/components/account-settings.tsx b/frontend/components/account-settings.tsx index 79bddb1..9299b68 100644 --- a/frontend/components/account-settings.tsx +++ b/frontend/components/account-settings.tsx @@ -6,20 +6,29 @@ // (forced=false her -- kun tekst-copyen skiller de to). import type React from "react" -import { useEffect, useState } from "react" +import { useEffect, useRef, useState } from "react" import Link from "next/link" -import { ArrowLeft, KeyRound, Lock, ShieldCheck, ShieldOff } from "lucide-react" +import { ArrowLeft, Camera, KeyRound, Lock, ShieldCheck, ShieldOff, User, X } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { TwoFactorSetupForm } from "@/components/two-factor-flow" +// ADR-031: personlig profil, ETT sett per konto (app_user), atskilt fra de +// org-scopede `player`-radene organisatorer administrerer. type Me = { id: string email: string display_name: string has_password: boolean two_factor_method: "totp" | "email" | null + first_name: string | null + last_name: string | null + birth_date: string | null + gender: "m" | "f" | "x" | null + handicap_index: number | null + home_club: string | null + avatar_url: string | null } export function AccountSettings() { @@ -90,6 +99,8 @@ export function AccountSettings() { ) : (
- En organisasjon er golfklubben eller bedriften som arrangerer turneringen. Opprett en for - å komme i gang. + {compact + ? "Skal du selv arrangere turneringer? En organisasjon er golfklubben eller bedriften som står bak." + : "En organisasjon er golfklubben eller bedriften som arrangerer turneringen. Opprett en for å komme i gang."}