568 lines
26 KiB
Python
568 lines
26 KiB
Python
|
|
"""
|
||
|
|
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,
|
||
|
|
)
|