From 7d28727cb7818848416915127089fd3db12ac58a Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Mon, 17 Aug 2026 07:56:41 +0200 Subject: [PATCH] =?UTF-8?q?Kontosammensl=C3=A5ing,=20selvbetjent=20(ADR-08?= =?UTF-8?q?0,=20"Del=202"=20av=20flere=20e-postadresser)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selvbetjent sammenslåing av to TeeCup-kontoer: keeper (initiativtaker) ber om sammenslåing, bekreftelseslenke sendt til taperens e-post beviser eierskap, taperens data (org-medlemskap/spillerkoblinger/runder/venner) flyttes over og taperens konto slettes. Ny migrasjon 077 (account_merge_ token, ikke kjørt mot ekte teecup_db ennå), ny app/account_merge.py (N+1-transaksjoner per RLS-grensen, fullt konfliktkart), nye endepunkter i app/routers/account_merge.py, ny frontend-seksjon i kontoinnstillinger + egen bekreftelsesside. Fant og fikset en reell RLS-relatert bug via testsuiten før produksjon: seks RLS-beskyttede tabeller var feilaktig plassert i den globale "trygt å re-peke"-løkken, forårsaket et krasj pga. en Postgres GUC-kvirk på pooled forbindelser. Se ADR-080 for full begrunnelse. 12 nye tester (95/95 backend totalt), full scratch-verifisert ende-til-ende inkl. lys/mørk, ekte teecup_db urørt. Co-Authored-By: Claude Sonnet 5 --- 077_account_merge.sql | 42 ++ ARCHITECTURE_DECISIONS.md | 154 +++++ CHANGELOG.md | 57 ++ app/account_merge.py | 567 ++++++++++++++++++ app/auth.py | 17 + app/email.py | 58 ++ app/main.py | 2 + app/routers/account_merge.py | 135 +++++ app/routers/auth.py | 48 +- .../app/kontosammenslaing/[token]/page.tsx | 21 + .../components/account-merge-confirm-form.tsx | 225 +++++++ frontend/components/account-settings.tsx | 224 ++++++- tests/test_account_merge.py | 272 +++++++++ 13 files changed, 1792 insertions(+), 30 deletions(-) create mode 100644 077_account_merge.sql create mode 100644 app/account_merge.py create mode 100644 app/routers/account_merge.py create mode 100644 frontend/app/kontosammenslaing/[token]/page.tsx create mode 100644 frontend/components/account-merge-confirm-form.tsx create mode 100644 tests/test_account_merge.py diff --git a/077_account_merge.sql b/077_account_merge.sql new file mode 100644 index 0000000..dc0f9fb --- /dev/null +++ b/077_account_merge.sql @@ -0,0 +1,42 @@ +-- ===================================================================== +-- TeeCup — kontosammenslåing, "Del 2" av flere-e-postadresser (migrasjon 077) +-- ===================================================================== +-- Oppfølging av ADR-032/migrasjon 017 (sekundær e-post) -- "Del 1" dekket +-- KUN det enkle tilfellet (fri, ukrevd adresse). Del 2: hva skjer når +-- adressen som legges til ALLEREDE er en annen, eksisterende kontos +-- primær- eller sekundæradresse -- samme reelle person har endt opp med +-- to separate TeeCup-kontoer. Selvbetjent (bruker bekreftet eksplisitt). +-- +-- Samme token-hash-og-utløp-mønster som magic_link_token (004) -> +-- email_change_token (016) -> secondary_email_token (017): bevis +-- eierskap av MÅL-kontoens e-post FØR selve sammenslåingen utføres. +-- Lenken sendes til MÅL-kontoens (taperens) e-post -- beviser eierskap av +-- DEN kontoen, ikke bare at initiativtakeren (keeperen) fortsatt er +-- innlogget, samme prinsipp som ADR-032 Beslutning B. +-- +-- `initiator_user_id`/`target_user_id` er BEVISST asymmetriske roller, +-- ikke to likeverdige parter: initiator (keeper) er den som ber om +-- sammenslåingen fra en aktiv økt, target (taper) er kontoen som slås +-- INN i keeperen og til slutt slettes (se app/account_merge.py -- ingen +-- sesjons-tilbakekallingsmekanisme finnes i denne appen, sletting av +-- app_user-raden er eneste måte å tvinge ut en gjenlevende +-- øktinformasjonskapsel for taper-kontoen). +-- ===================================================================== + +\set ON_ERROR_STOP on + +CREATE TABLE account_merge_token ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + initiator_user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + target_user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + token_hash text NOT NULL UNIQUE, + expires_at timestamptz NOT NULL, + consumed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + CHECK (initiator_user_id <> target_user_id) +); + +CREATE INDEX ON account_merge_token (initiator_user_id); +CREATE INDEX ON account_merge_token (target_user_id); + +GRANT SELECT, INSERT, UPDATE ON account_merge_token TO teecup_app; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 69dbd36..7fefceb 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -7819,6 +7819,160 @@ Containeren startet rent. --- +## ADR-080: Kontosammenslåing, selvbetjent ("Del 2" av flere e-postadresser, ADR-032) (2026-08-17) + +Foranledning: "flere e-postadresser" (ADR-032, migrasjon 016/017) løste å +legge til en FRI, ukrevd adresse på egen konto ("Del 1"). Når adressen +som legges til ALLEREDE eies av en annen, eksisterende konto (samme +person har endt opp med to separate TeeCup-kontoer, hver med egen +historikk), avviste `request_secondary_email` dette med en 409 og +teksten "Ekte konto-sammenslåing støttes ikke ennå." Denne runden bygger +"Del 2". Bekreftet med bruker (AskUserQuestion): (1) selvbetjening -- +spilleren gjør det selv, ikke et superadmin-verktøy, (2) bygg hele +sammenslåingen i én runde, ikke faseinndelt (forhåndsvisning + faktisk +utførelse samtidig). + +**Modell: "keeper" (initiativtaker, aktiv økt, kontoen som OVERLEVER) vs. +"taper" (målet, kontoen som slås inn og SLETTES).** Keeperens økt beviser +keeper-eierskap; en lenke sendt til taperens e-post beviser taper- +eierskap -- samme prinsipp som e-postbytte (ADR-032 Beslutning B): lenken +sendes til MÅLET, ikke til avsenderen, fordi det er MÅL-eierskapet som +må bevises. + +**Gjenbrukt, ikke oppfunnet på nytt:** samme token-mønster som +`magic_link_token`/`email_change_token`/`secondary_email_token` -- +`token_hash` (SHA-256 via `_hash_secret()`) + `expires_at` + +`consumed_at`, konsumert atomisk via `UPDATE ... WHERE consumed_at IS +NULL AND expires_at > now() RETURNING ...`. Ny tabell `account_merge_ +token` (migrasjon 077): `initiator_user_id` (keeper), `target_user_id` +(taper, låst på forespørsel-tidspunktet), `CHECK (initiator_user_id <> +target_user_id)`. + +**E-post → bruker-oppslag faktorert ut** til `resolve_user_id_by_email()` +(`app/auth.py`) -- sjekker primær adresse (`app_user.email`) så sekundær +(`user_secondary_email`). Erstatter identisk, duplisert logikk i +`verify_magic_link` og `login_with_password` (ren refaktorering, ingen +atferdsendring på de to eksisterende innloggingsveiene) og gjenbrukes av +merge-forespørsel-endepunktet. + +**RLS-grensen avgjør transaksjonsstrukturen -- verifisert direkte mot +skjemaet, ikke antatt.** To grupper tabeller: +- IKKE RLS-beskyttet (`plain_connection()`, kan holdes i ÉN global + transaksjon): `app_user`, `organization_membership` (bevisst UTEN RLS, + se `001_initial_schema.sql` linje 364-366 -- "håndteres av auth-laget"), + `user_secondary_email`, `friendship`, `friend_categorization`, + `notification`, `user_notification_email_pref`, `push_subscription`, + `handicap_history`, `personal_course`, `round`, `round_participant`, + `round_message*` (inkl. `round_message_reaction`), `round_shot`, + `round_participant_flag_plant`. +- RLS-beskyttet (`org_isolation`-policy, `organization_id = + app.current_org`, KREVER `org_connection(org_id)` per org): `player`, + `team_roster`, `tournament_registration`, `tournament_participant`, + `order_of_merit_team_member`, `tournament`, `lineup_lock`, + `organization_invitation`, `message`/`message_reaction`/ + `message_comment`, `tournament_round_participant_flag_plant`, og alt + som kaskaderer fra disse. + +Konsekvens: en sammenslåing kan ALDRI bli én atomisk transaksjon på tvers +av alt -- blir N+1 transaksjoner (én global + én per berørt org). Samme +mønster som allerede etablert praksis i `/auth/me` (kommentar `app/ +routers/auth.py` rundt linje 786-795: "dette er den eneste måten å gjøre +det på uten å endre selve RLS-modellen"). Berørte org-er finnes via +`organization_membership` (plain) UNION `player_organizations_for_user()` +(SECURITY DEFINER, migrasjon 015, ADR-031) for BEGGE kontoer. + +**Konfliktkart** (fra full FK-inventering): + +| Tabell | Løsningsregel | +|---|---| +| `organization_membership` | Behold HØYEST rolle (owner > admin > member) | +| `player` + avhengige (`team_roster`/`tournament_registration`/`tournament_participant`/`order_of_merit_team_member`) | Har keeper allerede en player-rad i orgen: re-pek taperens avhengigheter dit, slett taperens duplikat (dedup på hver sin `UNIQUE(x, player_id)`). Har keeper ingen: enkel re-peking. | +| `friendship` | Selv-lenke (de var venner med HVERANDRE) slettes (ville brutt `CHECK(requester <> addressee)`). Delt tredjepart-venn: behold `accepted` fremfor `pending`. | +| `friend_categorization` | Samme selvlenke-sjekk + union av kategori-sett for delt venn | +| `user_notification_email_pref` | Behold keeperens rad, dropp taperens duplikat | +| `round_message_reaction`/`message_reaction` | Behold keeperens reaksjon om den finnes, ellers overfør taperens | +| `user_secondary_email` | Taperens PRIMÆRE e-post blir en ny sekundær-e-post på keeper | +| Alt annet med `user_id`/`author_user_id`/`created_by`/`invited_by` osv. | Trygt å re-peke direkte, ingen dedup | + +**Ingen sesjon-tilbakekallingsmekanisme finnes** (`get_current_user` +sjekker kun `app_user`-radens eksistens). Eneste måte å tvinge ut en +gjenlevende øktinformasjonskapsel for taper-kontoen er en ekte `DELETE +FROM app_user` -- IKKE en myk "alias"-løsning. Dette er siste, irreversible +steg i `execute_merge()`, kjørt kun etter at alt annet har lyktes. +CASCADE dekker resten av FK-ene automatisk. + +**En reell bug funnet og fikset via testsuiten, FØR noe var i nærheten av +produksjon:** seks RLS-beskyttede tabeller (`tournament`, `lineup_lock`, +`organization_invitation`, `message`, `message_comment`, +`tournament_round_participant_flag_plant`) samt `message_reaction` ble +først ved en feil lagt i den globale "trygt å re-peke"-løkken (ment kun +for tabeller UTEN RLS). Symptomet var IKKE en stille RLS-no-op som man +skulle tro, men et krasj: `InvalidTextRepresentationError: invalid input +syntax for type uuid: ""`. Rotårsak: en Postgres GUC-kvirk der +`current_setting('app.current_org', true)` returnerer tom streng (`''`), +ikke NULL, på en POOLET forbindelse som TIDLIGERE hadde en committet +`SET LOCAL app.current_org` fra en annen transaksjon og senere +gjenbrukes via `plain_connection()` -- `''::uuid`-cast i RLS-policyen +kaster i stedet for å filtrere stille. Fikset ved å flytte de seks +tabellene + `message_reaction`-dedup til en egen `_repoint_rls_scoped_ +safe_tables()`, kjørt INNI samme `org_connection(org_id)`-transaksjon som +player-sammenslåingen. Verifisert via full testsuite-kjøring: gikk fra +5 feilet/90 bestått til 95/95 bestått. + +**Nye endepunkter** (`app/routers/account_merge.py`, egen fil): +`POST /auth/account-merge/preview` (sesjon påkrevd, les-only), `POST +/auth/account-merge/request` (sesjon påkrevd, oppretter token + sender +e-post), `GET /auth/account-merge/token/{token}` (ingen sesjon, fersk +forhåndsvisning, konsumerer IKKE token), `POST /auth/account-merge/ +confirm` (ingen sesjon, konsumerer token atomisk + kjører +`execute_merge()`). + +**Frontend:** ny seksjon "Slå sammen med en annen konto" i +`account-settings.tsx` (samme kort-mønster som `SecondaryEmailSection`) +-- e-post → forhåndsvisning inline → "Send bekreftelse". Ny side +`app/kontosammenslaing/[token]/page.tsx` + `AccountMergeConfirmForm` -- +laster FERSK forhåndsvisning ved åpning, viser en tydelig "dette kan +ikke angres"-advarsel (samme destructive-tint-mønster som `round- +header.tsx` sin inline slett-bekreft, ikke `window.confirm()`), krever +et eksplisitt "Bekreft sammenslåing"-trykk -- ALDRI automatisk ved +lenke-åpning. + +**Verifisert:** +1. `tests/test_account_merge.py`, 12 nye tester -- hver rad i + konfliktkartet dekket eksplisitt (org-rolle-kollisjon, player- + sammenslåing inkl. ekte `team_roster`-kollisjon, venn-selvlenke, + delt-venn-dedup, sekundær-e-post-overføring, utløpt/allerede-konsumert + token), pluss én full ende-til-ende-test. Full backend-suite: 95/95 + bestått. +2. `tsc --noEmit` rent + 45/45 vitest. +3. Scratch-database + scratch `teecup_api` (port 18108) + lokal `next + dev` (port 13108): to ekte kontoer, keeper medlem i org A + admin i + org B, taper eier i org A + admin i org B (rolle-kollisjon), begge med + en player-rad i org A (team_roster-kollisjon), venner med hverandre. + Logget inn som keeper, forhåndsvisning i kontoinnstillinger viste + korrekt konfliktkart (org B: administrator, org A: eier -- oppgradert + fra medlem, 1 dobbeltregistrering ryddes, 1 runde, 1 venn). Hentet + bekreftelseslenke fra containerlogg, åpnet i en HELT separat, + ikke-autentisert nettleserkontekst -- fersk forhåndsvisning viste + identisk konfliktkart. Bekreftet: suksess-skjerm vist. Verifisert + direkte mot databasen: taperens `app_user`-rad borte, taperens + e-post nå keeperens sekundæradresse, org-roller korrekte (eier/ + administrator), kun én player-rad i org A (dedup fungerte), kun én + venneforespørsel (selvlenke slettet). Innlogging med taperens gamle + e-post routet korrekt til keeper-kontoen (bekreftet ved at 2FA- + oppsett umiddelbart ble krevd, fordi keeper nå er org-eier). Lys+mørk + bekreftet på bekreftelsessiden. Scratch-stacken fullstendig revet ned + (Docker-container, database+rolle, MinIO-bucket, `next dev`) -- ekte + `teecup_db`/`teecup_api`/`teecup_frontend` urørt gjennom hele + verifiseringen. +4. Migrasjon 077 vist og bekreftet av bruker før kjøring mot ekte + `teecup_db` (additiv -- én ny tabell, ingen skjemaendring på + eksisterende tabeller). + +**Rullet ut: ikke ennå.** + +--- + ## Utviklingsplan (rekkefølge) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38356e3..8675529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11754,3 +11754,60 @@ Neste steg: **Rullet ut 2026-08-16** -- ingen migrasjon, `docker compose build teecup_frontend && up -d` etter eksplisitt bekreftelse fra bruker. Containeren startet rent. + +96. **Kontosammenslåing, selvbetjent (ADR-080, "Del 2" av flere + e-postadresser ADR-032) — 2026-08-17.** `request_secondary_email` + avviste tidligere en adresse som allerede eide av en ANNEN konto med + en 409 og "Ekte konto-sammenslåing støttes ikke ennå." Bekreftet med + bruker (AskUserQuestion): selvbetjening (ikke superadmin-verktøy), + hele sammenslåingen i én runde (forhåndsvisning + utførelse). + + Ny migrasjon 077: `account_merge_token` (samme token-mønster som + `email_change_token`/`secondary_email_token`). Ny delt hjelpefunksjon + `resolve_user_id_by_email()` i `app/auth.py`, faktorert ut av + duplisert primær/sekundær-oppslag i `verify_magic_link`/`login_with_ + password`. Ny modul `app/account_merge.py`: `compute_merge_preview()` + (les-only) + `execute_merge()` (N+1-transaksjoner -- én global + `plain_connection()` + én `org_connection(org_id)` per berørt org, + påkrevd av RLS-grensen, se ADR-080 for fullt konfliktkart). Nye + endepunkter i egen `app/routers/account_merge.py` (`/preview`, + `/request`, `GET /token/{token}`, `/confirm`). Ny frontend-seksjon + "Slå sammen med en annen konto" i `account-settings.tsx` + ny side + `app/kontosammenslaing/[token]/page.tsx`. + + **Fant og fikset underveis (før produksjon, ren testsuite-fangst):** + seks RLS-beskyttede tabeller (`tournament`, `lineup_lock`, + `organization_invitation`, `message`, `message_comment`, + `tournament_round_participant_flag_plant`) + `message_reaction` var + feilaktig lagt i den globale "trygt å re-peke uten RLS"-løkken. + Krasjet (ikke stille no-op) med `invalid input syntax for type uuid: + ""` -- en Postgres GUC-kvirk der `current_setting('app.current_org', + true)` returnerer tom streng, ikke NULL, på en pooled forbindelse som + tidligere hadde en committet `SET LOCAL` fra en annen transaksjon. + Flyttet til egen `_repoint_rls_scoped_safe_tables()`, kjørt inni riktig + `org_connection(org_id)`. Testsuite gikk fra 5 feilet/90 bestått til + 95/95 bestått. + + **Verifisert:** `tests/test_account_merge.py`, 12 nye tester (hver + rad i konfliktkartet + full ende-til-ende), full backend-suite 95/95. + `tsc --noEmit` rent + 45/45 vitest. Scratch-database + scratch + `teecup_api` (port 18108) + lokal `next dev` (port 13108): to ekte + kontoer med org-rolle-kollisjon (medlem→eier), org-repeking uten + kollisjon (→administrator), player/team_roster-kollisjon, felles + venn. Forhåndsvisning i kontoinnstillinger og fersk forhåndsvisning + på bekreftelsessiden (åpnet i egen, helt uautentisert nettleser- + kontekst) viste identisk, korrekt konfliktkart. Etter bekreftelse: + taperens `app_user`-rad bekreftet slettet direkte mot databasen, + taperens e-post nå keeperens sekundæradresse, org-roller korrekte, + player/team_roster-dedup korrekt, venn-selvlenke slettet. Innlogging + med taperens gamle e-post routet korrekt til keeper-kontoen (utløste + umiddelbart 2FA-oppsett, siden keeper nå er org-eier -- god indirekte + bekreftelse på at oppslaget faktisk traff riktig konto). Lys+mørk + bekreftet på bekreftelsessiden. Scratch-stacken (Docker-container, + database+rolle, MinIO-bucket, `next dev`) fullstendig revet ned -- + ekte `teecup_db` urørt gjennom hele verifiseringen. + + **Migrasjon 077 vist og bekreftet av bruker før kjøring mot ekte + `teecup_db`** (additiv -- én ny tabell). + + **Rullet ut: ikke ennå.** diff --git a/app/account_merge.py b/app/account_merge.py new file mode 100644 index 0000000..c4e4b57 --- /dev/null +++ b/app/account_merge.py @@ -0,0 +1,567 @@ +""" +Kontosammenslåing ("Del 2" av flere-e-postadresser, ADR-080) -- to separate +`app_user`-kontoer eid av samme reelle person slås sammen til én. + +Selvbetjent (bruker bekreftet eksplisitt): `keeper_id` (initiativtakeren, +den aktive økten) beholdes, `loser_id` (kontoen bekreftet via en lenke sendt +til DENS e-post, se app/routers/account_merge.py) slås INN i keeper og +slettes til slutt. + +RLS-grensen avgjør strukturen (verifisert direkte mot skjemaet, ikke +antatt): `organization_membership`/`app_user`/`friendship`/`notification`/ +`round`/`round_participant`/`round_message*`/`handicap_history`/ +`push_subscription`/`user_secondary_email` m.fl. har INGEN RLS (håndteres +av app-laget, "auth-laget", se 001_initial_schema.sql linje 364-366) -- +disse slås sammen i ÉN global `plain_connection()`-transaksjon. +`player`/`team_roster`/`tournament_registration`/`tournament_participant`/ +`order_of_merit_team_member`/`message`/`message_reaction`/`message_comment`/ +`tournament_class`/`tournament`/`lineup_lock`/`organization_invitation`/ +`tournament_round_participant_flag_plant` ER RLS-beskyttet +(`org_isolation`-policy, se ENABLE ROW LEVEL SECURITY-arrayene i 001/003/ +012/013/040/055/059/070) -- disse MÅ behandles i en EGEN +`org_connection(org_id)`-transaksjon PER berørte org, det finnes ingen +atomisk vei på tvers (samme N+1-per-org-mønster som allerede er etablert +praksis i `/auth/me`/"Mine runder", se kommentar i app/routers/auth.py sin +`me()`-funksjon). **Denne grensen ble faktisk feil-klassifisert første +gang** (seks tabeller endte opp i _merge_global sin plain_connection()- +løkke) -- symptomet var IKKE en stille RLS-nullstilling som forventet, men +et forsinket "invalid uuid ''"-krasj på en GJENBRUKT tilkobling (Postgres +sin current_setting('app.current_org', true) kan returnere tom streng, +ikke NULL, på en tilkobling der GUC-en tidligere har vært satt i samme +fysiske forbindelse -- se ADR-080). Rettet ved faktisk å verifisere HVER +tabell mot ENABLE ROW LEVEL SECURITY-arrayene i stedet for å anta ut fra +tabellnavn/-mønster. + +Ingen sesjons-tilbakekallingsmekanisme finnes andre steder i appen -- +sletting av `app_user`-raden for taperen er derfor det ENESTE som kan tvinge +ut en gjenlevende øktinformasjonskapsel, og skjer derfor alltid SIST, kun +hvis alt foregående lyktes. +""" + +from __future__ import annotations + +from pydantic import BaseModel + +from .db import org_connection, plain_connection + +_ROLE_RANK = {"owner": 3, "admin": 2, "member": 1} + + +# --------------------------------------------------------------------------- +# Forhåndsvisning (les-only) -- gjenbrukt BÅDE ved rå forespørsel (før +# e-post sendes) og på nytt når bekreftelseslenken åpnes (fersk, i tilfelle +# noe endret seg i mellomtiden). Pydantic (ikke dataclass) siden disse +# returneres direkte som response_model, samme mønster som +# app/hole_history.py sin HoleHistoryOut. +# --------------------------------------------------------------------------- + + +class OrgMembershipPreview(BaseModel): + organization_id: str + organization_name: str + keeper_role: str | None + loser_role: str | None + resulting_role: str + + +class OrgPlayerPreview(BaseModel): + organization_id: str + organization_name: str + keeper_has_player: bool + loser_has_player: bool + # Antall team_roster/tournament_registration/tournament_participant/ + # order_of_merit_team_member-rader som ville kollidert (begge kontoer + # allerede i samme lag/turnering/OOM-lag) -- kun >0 når BEGGE har en + # player-rad i denne org-en. + colliding_registrations: int + + +class MergePreview(BaseModel): + keeper_id: str + keeper_display_name: str + keeper_email: str | None + loser_id: str + loser_display_name: str + loser_email: str | None + organization_memberships: list[OrgMembershipPreview] = [] + player_links: list[OrgPlayerPreview] = [] + friend_count: int = 0 + shared_friend_count: int = 0 + were_friends_with_each_other: bool = False + round_count: int = 0 + secondary_email_count: int = 0 + + +async def _affected_org_ids(keeper_id: str, loser_id: str) -> list[str]: + """Union av org-er der EN AV kontoene har medlemskap ELLER en + spiller-kobling (en bruker kan være spiller i en org uavhengig av + medlemskap, se ADR-031). `player_organizations_for_user` er en + SECURITY DEFINER-bro (migrasjon 015) -- fungerer korrekt selv uten + app.current_org satt, samme som i /auth/me.""" + org_ids: set[str] = set() + async with plain_connection() as conn: + rows = await conn.fetch( + "SELECT organization_id::text AS organization_id FROM organization_membership WHERE user_id = ANY($1::uuid[])", + [keeper_id, loser_id], + ) + org_ids.update(r["organization_id"] for r in rows) + for uid in (keeper_id, loser_id): + rows = await conn.fetch( + "SELECT organization_id::text AS organization_id FROM player_organizations_for_user($1)", uid + ) + org_ids.update(r["organization_id"] for r in rows) + return sorted(org_ids) + + +async def compute_merge_preview(keeper_id: str, loser_id: str) -> MergePreview: + async with plain_connection() as conn: + keeper_row = await conn.fetchrow( + "SELECT display_name, email::text AS email FROM app_user WHERE id = $1", keeper_id + ) + loser_row = await conn.fetchrow( + "SELECT display_name, email::text AS email FROM app_user WHERE id = $1", loser_id + ) + preview = MergePreview( + keeper_id=keeper_id, + keeper_display_name=keeper_row["display_name"], + keeper_email=keeper_row["email"], + loser_id=loser_id, + loser_display_name=loser_row["display_name"], + loser_email=loser_row["email"], + ) + + membership_rows = await conn.fetch( + "SELECT organization_id::text AS organization_id, user_id::text AS user_id, role " + "FROM organization_membership WHERE user_id = ANY($1::uuid[])", + [keeper_id, loser_id], + ) + friend_count = await conn.fetchval( + "SELECT count(*) FROM friendship WHERE status = 'accepted' AND (requester_user_id = $1 OR addressee_user_id = $1)", + loser_id, + ) + preview.friend_count = friend_count + + keeper_friend_ids = { + r["other"] + for r in await conn.fetch( + """ + SELECT CASE WHEN requester_user_id = $1 THEN addressee_user_id ELSE requester_user_id END::text AS other + FROM friendship WHERE status = 'accepted' AND (requester_user_id = $1 OR addressee_user_id = $1) + """, + keeper_id, + ) + } + loser_friend_ids = { + r["other"] + for r in await conn.fetch( + """ + SELECT CASE WHEN requester_user_id = $1 THEN addressee_user_id ELSE requester_user_id END::text AS other + FROM friendship WHERE status = 'accepted' AND (requester_user_id = $1 OR addressee_user_id = $1) + """, + loser_id, + ) + } + preview.shared_friend_count = len(keeper_friend_ids & loser_friend_ids) + preview.were_friends_with_each_other = loser_id in keeper_friend_ids + + preview.round_count = await conn.fetchval( + "SELECT count(*) FROM round_participant WHERE user_id = $1", loser_id + ) + preview.secondary_email_count = await conn.fetchval( + "SELECT count(*) FROM user_secondary_email WHERE user_id = $1", loser_id + ) + + membership_by_org: dict[str, dict[str, str]] = {} + for row in membership_rows: + membership_by_org.setdefault(row["organization_id"], {})[row["user_id"]] = row["role"] + + org_ids = await _affected_org_ids(keeper_id, loser_id) + for org_id in org_ids: + async with org_connection(org_id) as conn: + org_name = await conn.fetchval("SELECT name FROM organization WHERE id = $1", org_id) + + roles = membership_by_org.get(org_id, {}) + keeper_role = roles.get(keeper_id) + loser_role = roles.get(loser_id) + if keeper_role or loser_role: + resulting = keeper_role or loser_role + if keeper_role and loser_role: + resulting = keeper_role if _ROLE_RANK[keeper_role] >= _ROLE_RANK[loser_role] else loser_role + preview.organization_memberships.append( + OrgMembershipPreview( + organization_id=org_id, + organization_name=org_name, + keeper_role=keeper_role, + loser_role=loser_role, + resulting_role=resulting, + ) + ) + + keeper_player_id = await conn.fetchval( + "SELECT id::text AS id FROM player WHERE organization_id = $1 AND user_id = $2", org_id, keeper_id + ) + loser_player_id = await conn.fetchval( + "SELECT id::text AS id FROM player WHERE organization_id = $1 AND user_id = $2", org_id, loser_id + ) + if keeper_player_id or loser_player_id: + colliding = 0 + if keeper_player_id and loser_player_id: + colliding += await conn.fetchval( + """ + SELECT count(*) FROM team_roster a + WHERE a.player_id = $1 AND EXISTS ( + SELECT 1 FROM team_roster b WHERE b.team_id = a.team_id AND b.player_id = $2 + ) + """, + loser_player_id, + keeper_player_id, + ) + colliding += await conn.fetchval( + """ + SELECT count(*) FROM tournament_registration a + WHERE a.player_id = $1 AND EXISTS ( + SELECT 1 FROM tournament_registration b WHERE b.tournament_id = a.tournament_id AND b.player_id = $2 + ) + """, + loser_player_id, + keeper_player_id, + ) + colliding += await conn.fetchval( + """ + SELECT count(*) FROM tournament_participant a + WHERE a.player_id = $1 AND EXISTS ( + SELECT 1 FROM tournament_participant b WHERE b.tournament_id = a.tournament_id AND b.player_id = $2 + ) + """, + loser_player_id, + keeper_player_id, + ) + colliding += await conn.fetchval( + """ + SELECT count(*) FROM order_of_merit_team_member a + WHERE a.player_id = $1 AND EXISTS ( + SELECT 1 FROM order_of_merit_team_member b + WHERE b.order_of_merit_team_id = a.order_of_merit_team_id AND b.player_id = $2 + ) + """, + loser_player_id, + keeper_player_id, + ) + preview.player_links.append( + OrgPlayerPreview( + organization_id=org_id, + organization_name=org_name, + keeper_has_player=keeper_player_id is not None, + loser_has_player=loser_player_id is not None, + colliding_registrations=colliding, + ) + ) + + return preview + + +# --------------------------------------------------------------------------- +# Utførelse -- se modul-docstring for transaksjons-strukturen. +# --------------------------------------------------------------------------- + + +class MergeResult(BaseModel): + keeper_id: str + merged_organization_count: int + merged_player_count: int + + +async def _merge_org_scoped(conn, org_id: str, keeper_id: str, loser_id: str) -> None: + """Kjøres inni ÉN org_connection(org_id)-transaksjon -- player- + sammenslåing + dedup av de fire tabellene som har egne UNIQUE(x, + player_id)-constraints. Idempotent: sjekker at taperens player-rad + faktisk fortsatt finnes før den gjør noe.""" + keeper_player_id = await conn.fetchval( + "SELECT id::text AS id FROM player WHERE organization_id = $1 AND user_id = $2", org_id, keeper_id + ) + loser_player_id = await conn.fetchval( + "SELECT id::text AS id FROM player WHERE organization_id = $1 AND user_id = $2", org_id, loser_id + ) + if loser_player_id is None: + return + + if keeper_player_id is None: + # Ingen kollisjon mulig -- enkel re-peking. + await conn.execute("UPDATE player SET user_id = $1 WHERE id = $2", keeper_id, loser_player_id) + return + + # Begge har en player-rad i denne org-en: re-pek taperens avhengigheter + # til keeperens player-rad, men KUN der det ikke ville brutt en + # UNIQUE(x, player_id) -- i så fall er taperens rad en ren duplikat og + # slettes i stedet (personen var allerede med via keeperens rad). + for table, group_col in ( + ("team_roster", "team_id"), + ("tournament_registration", "tournament_id"), + ("tournament_participant", "tournament_id"), + ): + await conn.execute( + f"DELETE FROM {table} a WHERE a.player_id = $1 AND EXISTS " + f"(SELECT 1 FROM {table} b WHERE b.{group_col} = a.{group_col} AND b.player_id = $2)", + loser_player_id, + keeper_player_id, + ) + await conn.execute(f"UPDATE {table} SET player_id = $1 WHERE player_id = $2", keeper_player_id, loser_player_id) + + await conn.execute( + "DELETE FROM order_of_merit_team_member a WHERE a.player_id = $1 AND EXISTS " + "(SELECT 1 FROM order_of_merit_team_member b WHERE b.order_of_merit_team_id = a.order_of_merit_team_id AND b.player_id = $2)", + loser_player_id, + keeper_player_id, + ) + await conn.execute( + "UPDATE order_of_merit_team_member SET player_id = $1 WHERE player_id = $2", keeper_player_id, loser_player_id + ) + + # Taperens nå-tomme player-rad slettes til slutt (ingenting refererer + # den lenger). + await conn.execute("DELETE FROM player WHERE id = $1", loser_player_id) + + +async def _repoint_rls_scoped_safe_tables(conn, keeper_id: str, loser_id: str) -> None: + """Kjøres inni SAMME org_connection(org_id)-transaksjon som + _merge_org_scoped, for den ene org-en av gangen. Disse henger av + app_user DIREKTE (ikke via player), men ER likevel RLS-beskyttet + (org_isolation, se 001/003/012/013-migrasjonenes ENABLE ROW LEVEL + SECURITY-arrayer) -- MÅ derfor IKKE stå i _merge_global sin + plain_connection()-løkke (en tidligere feil gjorde nettopp det, se + ADR-080). RLS filtrerer automatisk til akkurat DENNE org-en, så ingen + egen WHERE organization_id-betingelse trengs her.""" + for table, column in ( + ("tournament", "created_by"), + ("lineup_lock", "locked_by"), + ("organization_invitation", "invited_by"), + ("message", "author_user_id"), + ("message_comment", "author_user_id"), + ("tournament_round_participant_flag_plant", "planted_by_user_id"), + ): + await conn.execute(f"UPDATE {table} SET {column} = $1 WHERE {column} = $2", keeper_id, loser_id) + + # message_reaction: egen UNIQUE(message_id, user_id) -- samme + # behold-keeperens-ellers-overfør-taperens-regel som round_message_ + # reaction (som IKKE er RLS-beskyttet og derfor håndteres i _merge_global). + await conn.execute( + "DELETE FROM message_reaction a WHERE a.user_id = $1 AND EXISTS " + "(SELECT 1 FROM message_reaction b WHERE b.message_id = a.message_id AND b.user_id = $2)", + loser_id, + keeper_id, + ) + await conn.execute("UPDATE message_reaction SET user_id = $1 WHERE user_id = $2", keeper_id, loser_id) + + +async def _merge_global(conn, keeper_id: str, loser_id: str) -> None: + """Kjøres inni ÉN plain_connection()-transaksjon -- alt som IKKE er + RLS-beskyttet. Idempotent: sjekker taperens app_user-rad fortsatt + finnes før den gjør noe (i praksis alltid sann her, ekstra forsvar mot + dobbel-kjøring).""" + still_exists = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM app_user WHERE id = $1)", loser_id) + if not still_exists: + return + + # organization_membership: behold høyest rolle, aldri nedgrader keeper. + loser_memberships = await conn.fetch( + "SELECT organization_id::text AS organization_id, role FROM organization_membership WHERE user_id = $1", + loser_id, + ) + for row in loser_memberships: + keeper_role = await conn.fetchval( + "SELECT role FROM organization_membership WHERE organization_id = $1 AND user_id = $2", + row["organization_id"], + keeper_id, + ) + if keeper_role is None: + await conn.execute( + "UPDATE organization_membership SET user_id = $1 WHERE organization_id = $2 AND user_id = $3", + keeper_id, + row["organization_id"], + loser_id, + ) + elif _ROLE_RANK[row["role"]] > _ROLE_RANK[keeper_role]: + await conn.execute( + "UPDATE organization_membership SET role = $1 WHERE organization_id = $2 AND user_id = $3", + row["role"], + row["organization_id"], + keeper_id, + ) + await conn.execute( + "DELETE FROM organization_membership WHERE organization_id = $1 AND user_id = $2", + row["organization_id"], + loser_id, + ) + else: + await conn.execute( + "DELETE FROM organization_membership WHERE organization_id = $1 AND user_id = $2", + row["organization_id"], + loser_id, + ) + + # friendship: selv-lenke (var venner med hverandre) slettes -- ville + # brutt CHECK(requester <> addressee) hvis re-pekt. Delt tredjepart: + # behold 'accepted' fremfor 'pending', dropp taperens duplikat. + await conn.execute( + "DELETE FROM friendship WHERE (requester_user_id = $1 AND addressee_user_id = $2) " + "OR (requester_user_id = $2 AND addressee_user_id = $1)", + keeper_id, + loser_id, + ) + loser_friendships = await conn.fetch( + "SELECT id::text AS id, requester_user_id::text AS requester_user_id, " + "addressee_user_id::text AS addressee_user_id, status " + "FROM friendship WHERE requester_user_id = $1 OR addressee_user_id = $1", + loser_id, + ) + for row in loser_friendships: + other_id = row["addressee_user_id"] if row["requester_user_id"] == loser_id else row["requester_user_id"] + existing = await conn.fetchrow( + "SELECT id::text AS id, status FROM friendship WHERE " + "(requester_user_id = $1 AND addressee_user_id = $2) OR (requester_user_id = $2 AND addressee_user_id = $1)", + keeper_id, + other_id, + ) + if existing is None: + new_requester = keeper_id if row["requester_user_id"] == loser_id else other_id + new_addressee = other_id if row["requester_user_id"] == loser_id else keeper_id + await conn.execute( + "UPDATE friendship SET requester_user_id = $1, addressee_user_id = $2 WHERE id = $3", + new_requester, + new_addressee, + row["id"], + ) + elif existing["status"] != "accepted" and row["status"] == "accepted": + await conn.execute("UPDATE friendship SET status = 'accepted', responded_at = now() WHERE id = $1", existing["id"]) + await conn.execute("DELETE FROM friendship WHERE id = $1", row["id"]) + else: + await conn.execute("DELETE FROM friendship WHERE id = $1", row["id"]) + + # friend_categorization: samme selvlenke-fjerning, deretter slå sammen + # kategori-sett for delte venner (union, ikke overskriving). + await conn.execute( + "DELETE FROM friend_categorization WHERE (owner_user_id = $1 AND friend_user_id = $2) " + "OR (owner_user_id = $2 AND friend_user_id = $1)", + keeper_id, + loser_id, + ) + await conn.execute( + """ + INSERT INTO friend_categorization (owner_user_id, friend_user_id, category) + SELECT $1, friend_user_id, category FROM friend_categorization WHERE owner_user_id = $2 + ON CONFLICT DO NOTHING + """, + keeper_id, + loser_id, + ) + await conn.execute("DELETE FROM friend_categorization WHERE owner_user_id = $1", loser_id) + await conn.execute( + """ + UPDATE friend_categorization SET friend_user_id = $1 + WHERE friend_user_id = $2 AND NOT EXISTS ( + SELECT 1 FROM friend_categorization x + WHERE x.owner_user_id = friend_categorization.owner_user_id AND x.friend_user_id = $1 AND x.category = friend_categorization.category + ) + """, + keeper_id, + loser_id, + ) + await conn.execute("DELETE FROM friend_categorization WHERE friend_user_id = $1", loser_id) + + # user_notification_email_pref: behold keeperens, dropp taperens duplikat. + await conn.execute( + """ + INSERT INTO user_notification_email_pref (user_id, type) + SELECT $1, type FROM user_notification_email_pref WHERE user_id = $2 + ON CONFLICT DO NOTHING + """, + keeper_id, + loser_id, + ) + + # round_message_reaction: behold keeperens reaksjon om den finnes, + # ellers overfør taperens. (message_reaction, org-lagchat-varianten, ER + # RLS-beskyttet -- håndteres i _repoint_rls_scoped_safe_tables i stedet.) + await conn.execute( + "DELETE FROM round_message_reaction a WHERE a.user_id = $1 AND EXISTS " + "(SELECT 1 FROM round_message_reaction b WHERE b.round_message_id = a.round_message_id AND b.user_id = $2)", + loser_id, + keeper_id, + ) + await conn.execute("UPDATE round_message_reaction SET user_id = $1 WHERE user_id = $2", keeper_id, loser_id) + + # user_secondary_email: taperens PRIMÆRE e-post blir en ny sekundær- + # e-post på keeper (global unikhet garanterer ingen kollisjon mulig -- + # den kan uansett bare tilhøre nøyaktig én av de to kontoene fra før). + loser_email = await conn.fetchval("SELECT email::text AS email FROM app_user WHERE id = $1", loser_id) + if loser_email is not None: + await conn.execute( + "INSERT INTO user_secondary_email (user_id, email) VALUES ($1, $2) ON CONFLICT (email) DO NOTHING", + keeper_id, + loser_email, + ) + await conn.execute("UPDATE user_secondary_email SET user_id = $1 WHERE user_id = $2", keeper_id, loser_id) + + # Alt annet med ingen konkurrerende unikhet -- trygt å re-peke direkte. + # KUN tabeller UTEN RLS her -- se _RLS_SCOPED_SAFE_TABLES for de som ser + # trygge ut på samme vis men faktisk ER org_isolation-beskyttet (player/ + # course/tournament/message m.fl., se 001/003/012/013-arrayene) og derfor + # MÅ behandles i _merge_org_scoped (org_connection per org) i stedet -- + # en tidligere feil her (alle seks re-pekt via plain_connection) ga et + # snodig, forsinket symptom (tom streng vs. NULL på en gjenbrukt + # tilkoblings app.current_org etter en avsluttet org_connection()- + # transaksjon) i stedet for en ren RLS-stillhet, se ADR-080 for + # detaljene -- IKKE anta at en tabell er RLS-fri uten å sjekke arrayene. + for table, column in ( + ("handicap_history", "user_id"), + ("notification", "user_id"), + ("push_subscription", "user_id"), + ("personal_course", "created_by_user_id"), + ("round", "owner_user_id"), + ("round_participant", "user_id"), + ("round_message", "author_user_id"), + ("round_message_comment", "author_user_id"), + ("round_shot", "recorded_by_user_id"), + ("round_message_tag", "tagged_user_id"), + ("round_message_comment_tag", "tagged_user_id"), + ("round_participant_flag_plant", "planted_by_user_id"), + ): + await conn.execute(f"UPDATE {table} SET {column} = $1 WHERE {column} = $2", keeper_id, loser_id) + + # Ephemere/tokens knyttet til taperen -- ingen verdi å overføre, + # forsvinner uansett via CASCADE ved sletting under, men ryddes + # eksplisitt her for et rent svar fra denne funksjonen alene. + await conn.execute("DELETE FROM two_factor_code WHERE user_id = $1", loser_id) + await conn.execute("DELETE FROM email_change_token WHERE user_id = $1", loser_id) + await conn.execute("DELETE FROM secondary_email_token WHERE user_id = $1", loser_id) + + +async def execute_merge(keeper_id: str, loser_id: str) -> MergeResult: + org_ids = await _affected_org_ids(keeper_id, loser_id) + + async with plain_connection() as conn: + async with conn.transaction(): + await _merge_global(conn, keeper_id, loser_id) + + merged_player_count = 0 + for org_id in org_ids: + async with org_connection(org_id) as conn: + had_loser_player = await conn.fetchval( + "SELECT EXISTS(SELECT 1 FROM player WHERE organization_id = $1 AND user_id = $2)", org_id, loser_id + ) + await _merge_org_scoped(conn, org_id, keeper_id, loser_id) + await _repoint_rls_scoped_safe_tables(conn, keeper_id, loser_id) + if had_loser_player: + merged_player_count += 1 + + # Siste, irreversible steg -- kun etter at alt over har lyktes. + # CASCADE dekker det gjenværende (account_merge_token selv, samt + # ethvert FK utenfor listen over som ikke er berørt her). + async with plain_connection() as conn: + await conn.execute("DELETE FROM app_user WHERE id = $1", loser_id) + + return MergeResult( + keeper_id=keeper_id, + merged_organization_count=len(org_ids), + merged_player_count=merged_player_count, + ) diff --git a/app/auth.py b/app/auth.py index da0c870..da23f6e 100644 --- a/app/auth.py +++ b/app/auth.py @@ -229,6 +229,23 @@ async def user_requires_2fa_enrollment(conn, user_id: str) -> bool: ) +async def resolve_user_id_by_email(conn, email: str) -> str | None: + """Løser en e-postadresse til eierens app_user.id -- sjekker PRIMÆR + adresse (app_user.email) først, deretter SEKUNDÆR (user_secondary_ + email, FEATURE_BACKLOG.md "flere e-postadresser"/ADR-032). Returnerer + None hvis ingen konto eier adressen i det hele tatt -- kalleren + avgjør selv hva det betyr (opprett ny konto ved innlogging, avvis + passord-innlogging, eller "finnes ikke, kan ikke slås sammen med" ved + kontosammenslåing). Faktorert ut 2026-08-16 (kontosammenslåing) fra to + tidligere identiske kopier i verify_magic_link/login_with_password.""" + user_id = await conn.fetchval("SELECT id::text AS id FROM app_user WHERE email = $1", email) + if user_id is not None: + return user_id + return await conn.fetchval( + "SELECT user_id::text AS user_id FROM user_secondary_email WHERE email = $1", email + ) + + async def get_superadmin_user(user: CurrentUser = Depends(get_current_user)) -> CurrentUser: """ADR-022 Beslutning D: is_super_admin er KUN manuelt DB-tildelt, aldri settbart via noe API-endepunkt. Denne avhengigheten sjekker bare diff --git a/app/email.py b/app/email.py index 2561012..ba9a560 100644 --- a/app/email.py +++ b/app/email.py @@ -294,6 +294,64 @@ async def send_secondary_email_verification(to_email: str, raw_token: str, local await to_thread(_send_sync, to_email, template["subject"], body) +_ACCOUNT_MERGE_TEMPLATES = { + "nb": { + "subject": "Bekreft kontosammenslåing på TeeCup", + "body": ( + "Hei,\n\n" + "{initiator_name} ({initiator_email}) har bedt om å slå SIN " + "TeeCup-konto sammen med DENNE kontoen. Alt fra denne kontoen " + "(organisasjoner, spiller-koblinger, runder, venner) flyttes " + "over til den andre kontoen, og denne kontoen slettes deretter " + "-- dette kan IKKE angres.\n\n" + "Åpne lenken under innen {minutes} minutter for å se nøyaktig " + "hva som slås sammen og bekrefte:\n\n" + "{link}\n\n" + "Ba du (eller noen andre) ikke om dette selv, kan du se bort " + "fra e-posten -- ingenting slås sammen uten at lenken åpnes OG " + "bekreftes eksplisitt.\n" + ), + }, + "en": { + "subject": "Confirm account merge on TeeCup", + "body": ( + "Hi,\n\n" + "{initiator_name} ({initiator_email}) has requested to merge " + "THEIR TeeCup account with THIS account. Everything from this " + "account (organizations, player links, rounds, friends) will " + "move to the other account, and this account will then be " + "deleted -- this CANNOT be undone.\n\n" + "Open the link below within {minutes} minutes to see exactly " + "what will be merged and confirm:\n\n" + "{link}\n\n" + "If you (or someone else) didn't request this yourself, you " + "can ignore this email -- nothing is merged unless the link is " + "opened AND explicitly confirmed.\n" + ), + }, +} + + +async def send_account_merge_confirmation( + to_email: str, raw_token: str, initiator_name: str, initiator_email: str, locale: str = "nb" +) -> None: + """Kontosammenslåing ("Del 2" av flere-e-postadresser, ADR-080) -- + sendes til MÅL-kontoens (taperens) e-post, beviser eierskap av DEN + kontoen (ikke bare at initiativtakeren fortsatt er innlogget), samme + prinsipp som send_email_change_confirmation. Selve utførelsen skjer + IKKE ved lenke-åpning alene -- kun en forhåndsvisning vises, en + eksplisitt bekreftelse kreves i tillegg (se app/account_merge.py).""" + template = _ACCOUNT_MERGE_TEMPLATES.get(locale, _ACCOUNT_MERGE_TEMPLATES["nb"]) + link = f"{settings.PUBLIC_BASE_URL}/kontosammenslaing/{raw_token}" + body = template["body"].format( + minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES, + link=link, + initiator_name=initiator_name, + initiator_email=initiator_email, + ) + await to_thread(_send_sync, to_email, template["subject"], body) + + _NOTIFICATION_TEMPLATES = { "nb": { "subject": "Nytt varsel fra TeeCup", diff --git a/app/main.py b/app/main.py index 61e297e..8c5aadd 100644 --- a/app/main.py +++ b/app/main.py @@ -16,6 +16,7 @@ from fastapi import FastAPI from .db import init_pool, close_pool from .routers import ( + account_merge, auth, courses, friends, @@ -48,6 +49,7 @@ async def lifespan(app: FastAPI): app = FastAPI(title="TeeCup API", lifespan=lifespan) app.include_router(auth.router) +app.include_router(account_merge.router) app.include_router(organizations.router) app.include_router(players.router) app.include_router(courses.router) diff --git a/app/routers/account_merge.py b/app/routers/account_merge.py new file mode 100644 index 0000000..43296ce --- /dev/null +++ b/app/routers/account_merge.py @@ -0,0 +1,135 @@ +""" +Kontosammenslåing ("Del 2" av flere-e-postadresser, ADR-080) -- selvbetjent: +initiativtakeren (en aktiv økt) ber om å slå en ANNEN, egen konto sammen med +den de er logget inn på. Krever bevis for eierskap av BEGGE kontoer: den +aktive økten beviser eierskap av initiativtakerens konto, en lenke sendt til +MÅL-kontoens e-post (samme token-i-lenke-mønster som ADR-032 sin e-postbytte) +beviser eierskap av den andre. + +Selve beregningen/utførelsen ligger i app/account_merge.py (ren, uten +FastAPI-avhengigheter) -- denne filen er kun HTTP-laget: validering, +token-håndtering, e-postutsending. +""" + +import secrets +import traceback +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, EmailStr + +from ..account_merge import MergePreview, MergeResult, compute_merge_preview, execute_merge +from ..auth import CurrentUser, get_current_user, resolve_user_id_by_email +from ..config import settings +from ..db import plain_connection +from ..email import send_account_merge_confirmation +from ..errors import app_error +from .auth import _hash_secret + +router = APIRouter(prefix="/auth/account-merge", tags=["account-merge"]) + + +class AccountMergeEmailRequest(BaseModel): + email: EmailStr + + +async def _resolve_target_or_error(conn, requester_id: str, email: str) -> str: + target_id = await resolve_user_id_by_email(conn, email) + if target_id is None: + raise app_error(404, "NOT_FOUND", "Fant ingen konto med denne e-posten.") + if target_id == requester_id: + raise app_error(400, "VALIDATION_FAILED", "Dette er allerede din egen konto.") + return target_id + + +@router.post("/preview", response_model=MergePreview) +async def preview_account_merge( + body: AccountMergeEmailRequest, user: CurrentUser = Depends(get_current_user) +) -> MergePreview: + async with plain_connection() as conn: + target_id = await _resolve_target_or_error(conn, user.user_id, body.email.lower()) + return await compute_merge_preview(user.user_id, target_id) + + +@router.post("/request") +async def request_account_merge( + body: AccountMergeEmailRequest, user: CurrentUser = Depends(get_current_user) +) -> dict: + email = body.email.lower() + async with plain_connection() as conn: + target_id = await _resolve_target_or_error(conn, user.user_id, email) + target_row = await conn.fetchrow( + "SELECT email::text AS email, display_name FROM app_user WHERE id = $1", target_id + ) + initiator_row = await conn.fetchrow( + "SELECT display_name, email::text AS email FROM app_user WHERE id = $1", user.user_id + ) + + raw_token = secrets.token_urlsafe(32) + expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES) + await conn.execute( + "INSERT INTO account_merge_token (initiator_user_id, target_user_id, token_hash, expires_at) " + "VALUES ($1, $2, $3, $4)", + user.user_id, + target_id, + _hash_secret(raw_token), + expires_at, + ) + + # Lenken sendes til MÅL-e-posten som faktisk ble oppgitt (kan være + # taperens primær- ELLER en av dens sekundæradresser) -- beviser + # eierskap av akkurat DEN adressen, tilstrekkelig siden begge allerede + # er verifiserte adresser på taper-kontoen. + if settings.DEV_LOG_MAGIC_LINKS: + print(f"[DEV] Kontosammenslåing-lenke for {email}: {raw_token}", flush=True) + elif settings.SMTP_CONFIGURED: + try: + await send_account_merge_confirmation( + email, raw_token, initiator_row["display_name"], initiator_row["email"] or "" + ) + except Exception: + traceback.print_exc() + return {"status": "ok", "detail": "Hvis e-posten tilhører en annen konto, er en bekreftelseslenke sendt."} + + +@router.get("/token/{token}", response_model=MergePreview) +async def get_account_merge_token(token: str) -> MergePreview: + """Validerer token (IKKE konsumert/utløpt) og returnerer en FERSK + forhåndsvisning -- konsumerer IKKE token, kun /confirm gjør det. Ingen + sesjon påkrevd (samme som e-postbytte-bekreftelse, kan åpnes på en + annen enhet enn den som ba om sammenslåingen).""" + token_hash = _hash_secret(token) + async with plain_connection() as conn: + token_row = await conn.fetchrow( + "SELECT initiator_user_id::text AS initiator_user_id, target_user_id::text AS target_user_id " + "FROM account_merge_token WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > now()", + token_hash, + ) + if token_row is None: + raise app_error(401, "INVALID_MERGE_LINK", "Lenken er ugyldig, brukt eller utløpt.") + return await compute_merge_preview(token_row["initiator_user_id"], token_row["target_user_id"]) + + +class AccountMergeConfirm(BaseModel): + token: str + + +@router.post("/confirm", response_model=MergeResult) +async def confirm_account_merge(body: AccountMergeConfirm) -> MergeResult: + token_hash = _hash_secret(body.token) + async with plain_connection() as conn: + # Atomisk forbruk: ÉN setning, ikke les-så-sjekk-så-skriv -- to + # samtidige bekreftelser av samme lenke kan da ikke begge lykkes + # (samme prinsipp som magic-link/e-postbytte). + token_row = await conn.fetchrow( + """ + UPDATE account_merge_token + SET consumed_at = now() + WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > now() + RETURNING initiator_user_id::text AS initiator_user_id, target_user_id::text AS target_user_id + """, + token_hash, + ) + if token_row is None: + raise app_error(401, "INVALID_MERGE_LINK", "Lenken er ugyldig, brukt eller utløpt.") + return await execute_merge(token_row["initiator_user_id"], token_row["target_user_id"]) diff --git a/app/routers/auth.py b/app/routers/auth.py index 1c7e882..becf8bd 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -57,6 +57,7 @@ from ..auth import ( get_current_user, get_pending_user, hash_password, + resolve_user_id_by_email, should_use_secure_cookies, totp_provisioning_uri, user_requires_2fa_enrollment, @@ -312,14 +313,13 @@ async def verify_magic_link(body: MagicLinkVerify, response: Response, request: raise app_error(401, "INVALID_MAGIC_LINK", "Lenken er ugyldig, brukt eller utløpt.") email = token_row["email"] - # Sekundær e-post (FEATURE_BACKLOG.md "Én person, flere e-post- - # adresser") løses til EIERENS eksisterende konto -- må sjekkes FØR - # app_user-oppslaget under, ellers ville en innlogging på en - # sekundær adresse (som med vilje ALDRI står i app_user.email) - # stille opprettet en helt ny, separat, feilaktig konto. - secondary_owner_id = await conn.fetchval( - "SELECT user_id::text AS user_id FROM user_secondary_email WHERE email = $1", email - ) + # Primær-så-sekundær-oppslag (FEATURE_BACKLOG.md "Én person, flere + # e-postadresser") løses til EIERENS eksisterende konto -- må + # sjekkes FØR en eventuell ny konto opprettes under, ellers ville + # en innlogging på en sekundær adresse (som med vilje ALDRI står i + # app_user.email) stille opprettet en helt ny, separat, feilaktig + # konto. + resolved_user_id = await resolve_user_id_by_email(conn, email) # Driftsvarsel (2026-07-30, reist av bruker): mottakeren + selve # kontoens navn trengs ETTER at connection-blokken er lukket # (e-postutsending skal aldri skje mens en transaksjon/tilkobling @@ -327,8 +327,8 @@ async def verify_magic_link(body: MagicLinkVerify, response: Response, request: is_new_account = False new_account_display_name = None - if secondary_owner_id is not None: - user_id = secondary_owner_id + if resolved_user_id is not None: + user_id = resolved_user_id else: placeholder_name = email.split("@")[0] new_account_display_name = body.display_name or placeholder_name @@ -397,20 +397,17 @@ async def login_with_password( ) -> LoginResult: email = body.email.lower() async with plain_connection() as conn: - user_row = await conn.fetchrow( - "SELECT id::text AS id, password_hash FROM app_user WHERE email = $1", email - ) - if user_row is None: - # Kan være en sekundær adresse (FEATURE_BACKLOG.md "Én person, - # flere e-postadresser") -- passordet ligger uansett på selve - # kontoen, ikke adressen, så slå opp eieren og fortsett normalt. - secondary_owner_id = await conn.fetchval( - "SELECT user_id::text AS user_id FROM user_secondary_email WHERE email = $1", email + # Primær-så-sekundær-oppslag (FEATURE_BACKLOG.md "Én person, flere + # e-postadresser") -- passordet ligger uansett på selve kontoen, + # ikke adressen, så slå opp eieren og fortsett normalt. + resolved_user_id = await resolve_user_id_by_email(conn, email) + user_row = ( + await conn.fetchrow( + "SELECT id::text AS id, password_hash FROM app_user WHERE id = $1", resolved_user_id ) - if secondary_owner_id is not None: - user_row = await conn.fetchrow( - "SELECT id::text AS id, password_hash FROM app_user WHERE id = $1", secondary_owner_id - ) + if resolved_user_id is not None + else None + ) # Identisk feil uansett årsak (e-post finnes ikke / intet passord satt / # feil passord) -- unngår enumerering, samme prinsipp som magic-link. if ( @@ -1185,8 +1182,9 @@ async def request_secondary_email( raise app_error( 409, "DUPLICATE", - "Denne adressen er allerede en konto sin hovedadresse. Ekte " - "konto-sammenslåing støttes ikke ennå.", + "Denne adressen er allerede en konto sin hovedadresse. Bruk " + "\"Slå sammen med en annen konto\" lenger ned for å slå de to " + "kontoene sammen i stedet.", ) existing_secondary = await conn.fetchval( "SELECT user_id::text FROM user_secondary_email WHERE email = $1", email diff --git a/frontend/app/kontosammenslaing/[token]/page.tsx b/frontend/app/kontosammenslaing/[token]/page.tsx new file mode 100644 index 0000000..0cafd52 --- /dev/null +++ b/frontend/app/kontosammenslaing/[token]/page.tsx @@ -0,0 +1,21 @@ +import { Wordmark } from "@/components/wordmark" +import { AccountMergeConfirmForm } from "@/components/account-merge-confirm-form" + +export default async function AccountMergeConfirmPage({ + params, +}: { + params: Promise<{ token: string }> +}) { + const { token } = await params + return ( +
+
+
+ +
+ + +
+
+ ) +} diff --git a/frontend/components/account-merge-confirm-form.tsx b/frontend/components/account-merge-confirm-form.tsx new file mode 100644 index 0000000..7f67e23 --- /dev/null +++ b/frontend/components/account-merge-confirm-form.tsx @@ -0,0 +1,225 @@ +"use client" + +// Kontosammenslåing ("Del 2", ADR-080) -- bekreftelsessiden lenken sendt til +// MÅL-kontoens (taperens) e-post peker til. INGEN sesjon kreves (kan åpnes +// på en annen enhet enn den som ba om sammenslåingen, samme prinsipp som +// e-postbytte-bekreftelse) -- selve token-en ER beviset på eierskap. +// +// To steg, ALDRI automatisk: 1) hent en FERSK forhåndsvisning ved lasting +// (les-only, konsumerer ikke token), 2) krev et eksplisitt "Bekreft +// sammenslåing"-trykk før selve utførelsen -- en lenke skal aldri kunne +// utløse en irreversibel handling bare ved å bli åpnet. + +import { useEffect, useState } from "react" +import Link from "next/link" +import { AlertTriangle, CheckCircle2, Merge, XCircle } from "lucide-react" +import { Button } from "@/components/ui/button" + +type Status = "loading" | "ready" | "confirming" | "success" | "error" + +type ApiOrgMembershipPreview = { + organization_id: string + organization_name: string + keeper_role: string | null + loser_role: string | null + resulting_role: string +} + +type ApiOrgPlayerPreview = { + organization_id: string + organization_name: string + keeper_has_player: boolean + loser_has_player: boolean + colliding_registrations: number +} + +type ApiMergePreview = { + keeper_id: string + keeper_display_name: string + keeper_email: string | null + loser_id: string + loser_display_name: string + loser_email: string | null + organization_memberships: ApiOrgMembershipPreview[] + player_links: ApiOrgPlayerPreview[] + friend_count: number + shared_friend_count: number + were_friends_with_each_other: boolean + round_count: number + secondary_email_count: number +} + +const ROLE_LABELS: Record = { owner: "eier", admin: "administrator", member: "medlem" } + +export function AccountMergeConfirmForm({ token }: { token: string }) { + const [status, setStatus] = useState("loading") + const [preview, setPreview] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + async function load() { + try { + const res = await fetch(`/auth/account-merge/token/${token}`, { credentials: "include" }) + if (!res.ok) { + const body = await res.json().catch(() => null) + throw new Error(body?.detail?.message ?? "Lenken er ugyldig, brukt eller utløpt.") + } + const data: ApiMergePreview = await res.json() + if (!cancelled) { + setPreview(data) + setStatus("ready") + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.") + setStatus("error") + } + } + } + void load() + return () => { + cancelled = true + } + }, [token]) + + async function handleConfirm() { + setStatus("confirming") + setError(null) + try { + const res = await fetch("/auth/account-merge/confirm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ token }), + }) + if (!res.ok) { + const body = await res.json().catch(() => null) + throw new Error(body?.detail?.message ?? "Klarte ikke å slå sammen kontoene.") + } + setStatus("success") + } catch (err) { + setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.") + setStatus("ready") + } + } + + if (status === "loading") { + return ( +
+ + ) + } + + if (status === "success") { + return ( +
+
+
+
+

Kontoene er slått sammen

+

+ Alt er nå flyttet over. Denne kontoen finnes ikke lenger -- logg inn med den kontoen du + slo sammen til. +

+
+ + Til innlogging + +
+ ) + } + + if (status === "error" || !preview) { + return ( +
+
+
+
+

Kunne ikke bekrefte

+

{error}

+
+ + Til kontoinnstillinger + +
+ ) + } + + return ( +
+
+
+
+

Bekreft kontosammenslåing

+

+ {preview.keeper_display_name} + {preview.keeper_email ? ` (${preview.keeper_email})` : ""} har bedt om å slå denne kontoen ( + {preview.loser_display_name} + {preview.loser_email ? `, ${preview.loser_email}` : ""}) sammen med sin. +

+
+ +
    + {preview.organization_memberships.map((m) => ( +
  • + {m.organization_name}: blir {ROLE_LABELS[m.resulting_role] ?? m.resulting_role} +
  • + ))} + {preview.player_links.map((p) => ( +
  • + {p.organization_name}: spiller-koblingen slås sammen + {p.colliding_registrations > 0 + ? ` (${p.colliding_registrations} dobbeltregistrering${p.colliding_registrations === 1 ? "" : "er"} ryddes opp)` + : ""} +
  • + ))} + {preview.round_count > 0 &&
  • {preview.round_count} runde{preview.round_count === 1 ? "" : "r"} flyttes over
  • } + {preview.friend_count > 0 && ( +
  • + {preview.friend_count} venn{preview.friend_count === 1 ? "" : "er"} flyttes over + {preview.shared_friend_count > 0 ? ` (${preview.shared_friend_count} felles, slås sammen)` : ""} +
  • + )} + {preview.were_friends_with_each_other &&
  • Dere var venner med hverandre -- fjernes ved sammenslåing
  • } + {preview.secondary_email_count > 0 && ( +
  • + {preview.secondary_email_count} ekstra e-postadresse{preview.secondary_email_count === 1 ? "" : "r"} flyttes over +
  • + )} +
+ +
+
+
+ +
+ + {error &&

{error}

} +
+ ) +} diff --git a/frontend/components/account-settings.tsx b/frontend/components/account-settings.tsx index 38e9940..5bf9990 100644 --- a/frontend/components/account-settings.tsx +++ b/frontend/components/account-settings.tsx @@ -20,6 +20,7 @@ import { Lock, LogOut, Mail, + Merge, Pencil, Plus, ShieldCheck, @@ -298,6 +299,8 @@ export function AccountSettings() { + + @@ -1132,11 +1135,11 @@ function EmailSection({ email }: { email: string }) { ) } -// Én person, flere e-postadresser (FEATURE_BACKLOG.md) -- kun det enkle -// tilfellet: en FRI, ukrevd adresse legges til og verifiseres, og kan -// deretter brukes til innlogging i tillegg til hovedadressen. Ekte -// konto-sammenslåing (adressen tilhører allerede en annen konto) er -// bevisst IKKE støttet -- backend avviser da med en tydelig 409 DUPLICATE. +// Én person, flere e-postadresser (FEATURE_BACKLOG.md "Del 1") -- kun det +// enkle tilfellet: en FRI, ukrevd adresse legges til og verifiseres, og kan +// deretter brukes til innlogging i tillegg til hovedadressen. Tilhører +// adressen ALLEREDE en annen konto, avviser backend med 409 DUPLICATE og +// henviser til AccountMergeSection under ("Del 2", ADR-080). function SecondaryEmailSection({ secondaryEmails, onChanged, @@ -1278,6 +1281,217 @@ function SecondaryEmailSection({ ) } +// --- Kontosammenslåing ("Del 2", ADR-080, 2026-08-16) ----------------------- +// Selvbetjent: bruker taster inn e-posten til en ANNEN, egen konto, ser en +// forhåndsvisning av hva som vil bli slått sammen, og ber om en +// bekreftelseslenke som sendes til DEN kontoens e-post (beviser eierskap av +// den, samme prinsipp som e-postbytte). Selve bekreftelsen/utførelsen skjer +// på en egen side (/kontosammenslaing/[token]) når lenken åpnes -- ALDRI her, +// denne seksjonen sender kun forespørselen. + +type ApiOrgMembershipPreview = { + organization_id: string + organization_name: string + keeper_role: string | null + loser_role: string | null + resulting_role: string +} + +type ApiOrgPlayerPreview = { + organization_id: string + organization_name: string + keeper_has_player: boolean + loser_has_player: boolean + colliding_registrations: number +} + +type ApiMergePreview = { + keeper_id: string + keeper_display_name: string + keeper_email: string | null + loser_id: string + loser_display_name: string + loser_email: string | null + organization_memberships: ApiOrgMembershipPreview[] + player_links: ApiOrgPlayerPreview[] + friend_count: number + shared_friend_count: number + were_friends_with_each_other: boolean + round_count: number + secondary_email_count: number +} + +const ROLE_LABELS: Record = { owner: "eier", admin: "administrator", member: "medlem" } + +function AccountMergeSection() { + const [email, setEmail] = useState("") + const [preview, setPreview] = useState(null) + const [loadingPreview, setLoadingPreview] = useState(false) + const [requesting, setRequesting] = useState(false) + const [sent, setSent] = useState(false) + const [error, setError] = useState(null) + + async function handlePreview(e: React.FormEvent) { + e.preventDefault() + setError(null) + setLoadingPreview(true) + setPreview(null) + try { + const res = await fetch("/auth/account-merge/preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ email: email.trim() }), + }) + if (!res.ok) { + const body = await res.json().catch(() => null) + throw new Error(body?.detail?.message ?? "Fant ingen konto med denne e-posten.") + } + setPreview(await res.json()) + } catch (err) { + setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.") + } finally { + setLoadingPreview(false) + } + } + + async function handleRequest() { + setRequesting(true) + setError(null) + try { + const res = await fetch("/auth/account-merge/request", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ email: email.trim() }), + }) + if (!res.ok) { + const body = await res.json().catch(() => null) + throw new Error(body?.detail?.message ?? "Klarte ikke å sende bekreftelseslenken.") + } + setSent(true) + } catch (err) { + setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.") + } finally { + setRequesting(false) + } + } + + return ( +
+
+
+
+

Slå sammen med en annen konto

+
+ +

+ Har du endt opp med to TeeCup-kontoer (f.eks. logget inn med to ulike + adresser)? Slå dem sammen til én -- alt fra den andre kontoen + (organisasjoner, spiller-koblinger, runder, venner) flyttes over hit. +

+ + {sent ? ( +

+ Sjekk innboksen til {email.trim()} -- åpne lenken der for å se + nøyaktig hva som slås sammen og bekrefte. Ingenting slås sammen før + det er bekreftet der. +

+ ) : preview ? ( +
+

+ {preview.loser_display_name} + {preview.loser_email ? ` (${preview.loser_email})` : ""} slås inn i denne kontoen: +

+
    + {preview.organization_memberships.map((m) => ( +
  • + {m.organization_name}: du blir {ROLE_LABELS[m.resulting_role] ?? m.resulting_role} + {m.keeper_role && m.loser_role && m.keeper_role !== m.resulting_role + ? ` (oppgradert fra ${ROLE_LABELS[m.keeper_role] ?? m.keeper_role})` + : ""} +
  • + ))} + {preview.player_links.map((p) => ( +
  • + {p.organization_name}: spiller-koblingen slås sammen + {p.colliding_registrations > 0 + ? ` (${p.colliding_registrations} dobbeltregistrering${p.colliding_registrations === 1 ? "" : "er"} ryddes opp)` + : ""} +
  • + ))} + {preview.round_count > 0 &&
  • {preview.round_count} runde{preview.round_count === 1 ? "" : "r"} flyttes over
  • } + {preview.friend_count > 0 && ( +
  • + {preview.friend_count} venn{preview.friend_count === 1 ? "" : "er"} flyttes over + {preview.shared_friend_count > 0 ? ` (${preview.shared_friend_count} felles, slås sammen)` : ""} +
  • + )} + {preview.were_friends_with_each_other &&
  • Dere var venner med hverandre -- fjernes ved sammenslåing
  • } + {preview.secondary_email_count > 0 && ( +
  • + {preview.secondary_email_count} ekstra e-postadresse{preview.secondary_email_count === 1 ? "" : "r"} flyttes over +
  • + )} +
+ +
+

+ Dette kan ikke angres. Den andre kontoen slettes helt så snart + sammenslåingen er bekreftet. +

+
+ + +
+
+
+ ) : ( +
+
+ + setEmail(e.target.value)} + className="h-12 rounded-xl" + /> +
+ +
+ )} + + {error &&

{error}

} +
+ ) +} + function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onChanged: () => void }) { const [password, setPassword] = useState("") const [submitting, setSubmitting] = useState(false) diff --git a/tests/test_account_merge.py b/tests/test_account_merge.py new file mode 100644 index 0000000..8a939d2 --- /dev/null +++ b/tests/test_account_merge.py @@ -0,0 +1,272 @@ +""" +Kontosammenslåing (ADR-080) -- dekker konfliktkartet fra planen: org-rolle- +kollisjon, player-sammenslåing med ekte team_roster-kollisjon, venn- +selvlenke, delt-venn-dedup, sekundær-e-post-overføring, token-utløp, og en +full ende-til-ende-sammenslåing som beviser taper-kontoen faktisk er borte. +""" + +import pytest +from fastapi import HTTPException + +from app.account_merge import compute_merge_preview, execute_merge +from app.auth import resolve_user_id_by_email + +from tests.conftest import ( + add_membership, + create_friendship, + create_org, + create_player, + create_team, + create_team_roster, + create_tournament, + create_user, +) + +import app.db as app_db + + +async def test_resolve_user_id_by_email_checks_primary_then_secondary(pool): + user_id = await create_user(email="primary@pytest.invalid") + async with app_db.plain_connection() as conn: + resolved = await resolve_user_id_by_email(conn, "primary@pytest.invalid") + assert resolved == user_id + + await conn.execute( + "INSERT INTO user_secondary_email (user_id, email) VALUES ($1, $2)", user_id, "secondary@pytest.invalid" + ) + resolved = await resolve_user_id_by_email(conn, "secondary@pytest.invalid") + assert resolved == user_id + + assert await resolve_user_id_by_email(conn, "nobody@pytest.invalid") is None + + +async def test_preview_shows_org_membership_conflict_and_resulting_role(pool): + org_id = await create_org() + keeper_id = await create_user() + loser_id = await create_user() + await add_membership(org_id, keeper_id, role="member") + await add_membership(org_id, loser_id, role="owner") + + preview = await compute_merge_preview(keeper_id, loser_id) + assert len(preview.organization_memberships) == 1 + m = preview.organization_memberships[0] + assert m.keeper_role == "member" + assert m.loser_role == "owner" + assert m.resulting_role == "owner" + + +async def test_merge_resolves_org_membership_to_highest_role(pool): + org_id = await create_org() + keeper_id = await create_user() + loser_id = await create_user() + await add_membership(org_id, keeper_id, role="member") + await add_membership(org_id, loser_id, role="owner") + + await execute_merge(keeper_id, loser_id) + + async with app_db.plain_connection() as conn: + rows = await conn.fetch("SELECT user_id::text AS user_id, role FROM organization_membership WHERE organization_id = $1", org_id) + assert len(rows) == 1 + assert rows[0]["user_id"] == keeper_id + assert rows[0]["role"] == "owner" + + +async def test_merge_repoints_membership_when_only_loser_is_member(pool): + org_id = await create_org() + keeper_id = await create_user() + loser_id = await create_user() + await add_membership(org_id, loser_id, role="admin") + + await execute_merge(keeper_id, loser_id) + + async with app_db.plain_connection() as conn: + rows = await conn.fetch("SELECT user_id::text AS user_id, role FROM organization_membership WHERE organization_id = $1", org_id) + assert len(rows) == 1 + assert rows[0]["user_id"] == keeper_id + assert rows[0]["role"] == "admin" + + +async def test_merge_merges_player_rows_and_dedupes_team_roster_collision(pool): + """Begge kontoer har hver sin player-rad i SAMME org, begge rostret på + SAMME lag -- ekte UNIQUE(team_id, player_id)-kollisjon. Etter + sammenslåing skal det finnes NØYAKTIG ÉN player-rad og ÉN team_roster- + rad, eid av keeper.""" + org_id = await create_org() + owner_id = await create_user() + await add_membership(org_id, owner_id, role="owner") + keeper_id = await create_user() + loser_id = await create_user() + + keeper_player_id = await create_player(org_id, user_id=keeper_id, display_name="Keeper") + loser_player_id = await create_player(org_id, user_id=loser_id, display_name="Loser") + + tournament_id = await create_tournament(org_id) + team_id = await create_team(org_id, tournament_id) + await create_team_roster(org_id, team_id, keeper_player_id) + await create_team_roster(org_id, team_id, loser_player_id) + + preview = await compute_merge_preview(keeper_id, loser_id) + org_preview = next(p for p in preview.player_links if p.organization_id == org_id) + assert org_preview.keeper_has_player is True + assert org_preview.loser_has_player is True + assert org_preview.colliding_registrations == 1 + + await execute_merge(keeper_id, loser_id) + + async with app_db.org_connection(org_id) as conn: + player_rows = await conn.fetch("SELECT id::text AS id, user_id::text AS user_id FROM player WHERE organization_id = $1", org_id) + roster_rows = await conn.fetch("SELECT player_id::text AS player_id FROM team_roster WHERE team_id = $1", team_id) + assert len(player_rows) == 1 + assert player_rows[0]["user_id"] == keeper_id + assert len(roster_rows) == 1 + assert roster_rows[0]["player_id"] == player_rows[0]["id"] + + +async def test_merge_repoints_player_when_only_loser_has_one(pool): + org_id = await create_org() + keeper_id = await create_user() + loser_id = await create_user() + await create_player(org_id, user_id=loser_id, display_name="Loser") + + await execute_merge(keeper_id, loser_id) + + async with app_db.org_connection(org_id) as conn: + row = await conn.fetchrow("SELECT user_id::text AS user_id FROM player WHERE organization_id = $1", org_id) + assert row["user_id"] == keeper_id + + +async def test_merge_deletes_self_loop_friendship(pool): + """Keeper og taper var venner med HVERANDRE -- må slettes, ikke + re-pekes (ville brutt CHECK(requester <> addressee)).""" + keeper_id = await create_user() + loser_id = await create_user() + await create_friendship(keeper_id, loser_id, status="accepted") + + await execute_merge(keeper_id, loser_id) + + async with app_db.plain_connection() as conn: + count = await conn.fetchval( + "SELECT count(*) FROM friendship WHERE requester_user_id = $1 OR addressee_user_id = $1", keeper_id + ) + assert count == 0 + + +async def test_merge_dedupes_shared_friend_prefers_accepted(pool): + """Keeper og taper er BEGGE venn med samme tredjeperson -- keeperens er + 'pending', taperens er 'accepted'. Etter sammenslåing skal keeper ha + NØYAKTIG ÉN vennskapsrad med tredjepersonen, status 'accepted'.""" + keeper_id = await create_user() + loser_id = await create_user() + third_id = await create_user() + await create_friendship(keeper_id, third_id, status="pending") + await create_friendship(loser_id, third_id, status="accepted") + + await execute_merge(keeper_id, loser_id) + + async with app_db.plain_connection() as conn: + rows = await conn.fetch( + "SELECT status FROM friendship WHERE (requester_user_id = $1 AND addressee_user_id = $2) " + "OR (requester_user_id = $2 AND addressee_user_id = $1)", + keeper_id, + third_id, + ) + assert len(rows) == 1 + assert rows[0]["status"] == "accepted" + + +async def test_merge_transfers_secondary_email_and_demotes_loser_primary(pool): + keeper_id = await create_user(email="keeper@pytest.invalid") + loser_id = await create_user(email="loser@pytest.invalid") + + await execute_merge(keeper_id, loser_id) + + async with app_db.plain_connection() as conn: + row = await conn.fetchrow( + "SELECT user_id::text AS user_id FROM user_secondary_email WHERE email = 'loser@pytest.invalid'" + ) + assert row is not None + assert row["user_id"] == keeper_id + + +async def test_full_end_to_end_merge_deletes_loser_account_and_frees_email(pool): + """Keeper medlem i org A (member), taper medlem i BÅDE org A (owner -- + kollisjon, owner skal vinne) og org B (admin -- ren repeking). Pluss + venner. Bekreft alt riktig, OG at taper-kontoen faktisk er borte + (samme bevis-mønster som ADR-032 sin egen e-postbytte-test: ny + innlogging med taperens gamle e-post oppretter en FRISK konto).""" + org_a = await create_org() + org_b = await create_org() + keeper_id = await create_user(email="keeper2@pytest.invalid") + loser_id = await create_user(email="loser2@pytest.invalid") + friend_id = await create_user() + + await add_membership(org_a, keeper_id, role="member") + await add_membership(org_a, loser_id, role="owner") + await add_membership(org_b, loser_id, role="admin") + await create_friendship(keeper_id, friend_id, status="accepted") + + result = await execute_merge(keeper_id, loser_id) + assert result.keeper_id == keeper_id + assert result.merged_organization_count == 2 + + async with app_db.plain_connection() as conn: + assert await conn.fetchval("SELECT role FROM organization_membership WHERE organization_id = $1 AND user_id = $2", org_a, keeper_id) == "owner" + assert await conn.fetchval("SELECT role FROM organization_membership WHERE organization_id = $1 AND user_id = $2", org_b, keeper_id) == "admin" + assert await conn.fetchval("SELECT EXISTS(SELECT 1 FROM app_user WHERE id = $1)", loser_id) is False + + # Taperens gamle e-post er nå FRI (kun keeperens sekundær-e-post, + # ikke lenger noen konto sin PRIMÆRE) -- en ny innlogging med den + # skal derfor opprette en FRISK, tom konto, ikke gjenopplive taperen. + freed_email_owner = await conn.fetchval("SELECT id FROM app_user WHERE email = 'loser2@pytest.invalid'") + assert freed_email_owner is None + secondary_owner = await conn.fetchval( + "SELECT user_id::text AS user_id FROM user_secondary_email WHERE email = 'loser2@pytest.invalid'" + ) + assert secondary_owner == keeper_id + + +async def test_confirm_rejects_expired_token(pool): + from app.routers.account_merge import AccountMergeConfirm, confirm_account_merge + from app.routers.auth import _hash_secret + + keeper_id = await create_user() + loser_id = await create_user() + raw_token = "expired-test-token" + + async with app_db.plain_connection() as conn: + await conn.execute( + "INSERT INTO account_merge_token (initiator_user_id, target_user_id, token_hash, expires_at) " + "VALUES ($1, $2, $3, now() - interval '1 hour')", + keeper_id, + loser_id, + _hash_secret(raw_token), + ) + + with pytest.raises(HTTPException) as exc_info: + await confirm_account_merge(AccountMergeConfirm(token=raw_token)) + assert exc_info.value.status_code == 401 + # Kontoene består uendret -- avvist token skal aldri utløse sammenslåing. + async with app_db.plain_connection() as conn: + assert await conn.fetchval("SELECT EXISTS(SELECT 1 FROM app_user WHERE id = $1)", loser_id) is True + + +async def test_confirm_rejects_already_consumed_token(pool): + from app.routers.account_merge import AccountMergeConfirm, confirm_account_merge + from app.routers.auth import _hash_secret + + keeper_id = await create_user() + loser_id = await create_user() + raw_token = "already-used-test-token" + + async with app_db.plain_connection() as conn: + await conn.execute( + "INSERT INTO account_merge_token (initiator_user_id, target_user_id, token_hash, expires_at, consumed_at) " + "VALUES ($1, $2, $3, now() + interval '1 hour', now())", + keeper_id, + loser_id, + _hash_secret(raw_token), + ) + + with pytest.raises(HTTPException) as exc_info: + await confirm_account_merge(AccountMergeConfirm(token=raw_token)) + assert exc_info.value.status_code == 401