teecup/app/account_merge.py

568 lines
26 KiB
Python
Raw Permalink Normal View History

"""
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 behandles i en EGEN
`org_connection(org_id)`-transaksjon PER berørte org, det finnes ingen
atomisk vei 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 en GJENBRUKT tilkobling (Postgres
sin current_setting('app.current_org', true) kan returnere tom streng,
ikke NULL, 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) -- 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, 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,
)