""" 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, )