From 6b724e260f619ce2e299ad8bd345899b317ecc9c Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 21:15:48 +0300 Subject: [PATCH] fix(migration): merge duplicate saved chats per user --- .../versions/0026_deduplicate_saved_chats.py | 216 ++++++++++++++++++ docs/core-checklist-status.md | 2 +- 2 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/0026_deduplicate_saved_chats.py diff --git a/alembic/versions/0026_deduplicate_saved_chats.py b/alembic/versions/0026_deduplicate_saved_chats.py new file mode 100644 index 0000000..f454681 --- /dev/null +++ b/alembic/versions/0026_deduplicate_saved_chats.py @@ -0,0 +1,216 @@ +"""deduplicate saved chats per user + +Revision ID: 0026_deduplicate_saved_chats +Revises: 0025_user_twofa_recovery_codes +Create Date: 2026-03-10 00:25:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "0026_deduplicate_saved_chats" +down_revision: Union[str, Sequence[str], None] = "0025_user_twofa_recovery_codes" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + + duplicate_user_ids = [ + int(row[0]) + for row in bind.execute( + sa.text( + """ + SELECT cm.user_id + FROM chat_members cm + JOIN chats c ON c.id = cm.chat_id + WHERE c.is_saved IS TRUE + GROUP BY cm.user_id + HAVING COUNT(*) > 1 + """ + ) + ).fetchall() + ] + + for user_id in duplicate_user_ids: + saved_chat_ids = [ + int(row[0]) + for row in bind.execute( + sa.text( + """ + SELECT c.id + FROM chats c + JOIN chat_members cm ON cm.chat_id = c.id + WHERE c.is_saved IS TRUE + AND cm.user_id = :user_id + ORDER BY c.id ASC + """ + ), + {"user_id": user_id}, + ).fetchall() + ] + if len(saved_chat_ids) <= 1: + continue + + keep_chat_id = saved_chat_ids[0] + duplicate_chat_ids = saved_chat_ids[1:] + + for duplicate_chat_id in duplicate_chat_ids: + bind.execute( + sa.text( + """ + UPDATE chats keep + SET pinned_message_id = COALESCE( + keep.pinned_message_id, + (SELECT pinned_message_id FROM chats WHERE id = :dup_chat_id) + ) + WHERE keep.id = :keep_chat_id + """ + ), + {"keep_chat_id": keep_chat_id, "dup_chat_id": duplicate_chat_id}, + ) + + bind.execute( + sa.text( + """ + UPDATE messages + SET chat_id = :keep_chat_id + WHERE chat_id = :dup_chat_id + """ + ), + {"keep_chat_id": keep_chat_id, "dup_chat_id": duplicate_chat_id}, + ) + + bind.execute( + sa.text( + """ + INSERT INTO message_receipts (chat_id, user_id, last_delivered_message_id, last_read_message_id, updated_at) + SELECT :keep_chat_id, mr.user_id, mr.last_delivered_message_id, mr.last_read_message_id, mr.updated_at + FROM message_receipts mr + WHERE mr.chat_id = :dup_chat_id + ON CONFLICT (chat_id, user_id) DO UPDATE + SET last_delivered_message_id = GREATEST( + COALESCE(message_receipts.last_delivered_message_id, 0), + COALESCE(EXCLUDED.last_delivered_message_id, 0) + ), + last_read_message_id = GREATEST( + COALESCE(message_receipts.last_read_message_id, 0), + COALESCE(EXCLUDED.last_read_message_id, 0) + ), + updated_at = GREATEST(message_receipts.updated_at, EXCLUDED.updated_at) + """ + ), + {"keep_chat_id": keep_chat_id, "dup_chat_id": duplicate_chat_id}, + ) + bind.execute( + sa.text("DELETE FROM message_receipts WHERE chat_id = :dup_chat_id"), + {"dup_chat_id": duplicate_chat_id}, + ) + + bind.execute( + sa.text( + """ + INSERT INTO chat_notification_settings (chat_id, user_id, muted, updated_at) + SELECT :keep_chat_id, cns.user_id, cns.muted, cns.updated_at + FROM chat_notification_settings cns + WHERE cns.chat_id = :dup_chat_id + ON CONFLICT (chat_id, user_id) DO UPDATE + SET muted = chat_notification_settings.muted OR EXCLUDED.muted, + updated_at = GREATEST(chat_notification_settings.updated_at, EXCLUDED.updated_at) + """ + ), + {"keep_chat_id": keep_chat_id, "dup_chat_id": duplicate_chat_id}, + ) + bind.execute( + sa.text("DELETE FROM chat_notification_settings WHERE chat_id = :dup_chat_id"), + {"dup_chat_id": duplicate_chat_id}, + ) + + bind.execute( + sa.text( + """ + INSERT INTO chat_user_settings (chat_id, user_id, archived, pinned, pinned_at, updated_at) + SELECT :keep_chat_id, cus.user_id, cus.archived, cus.pinned, cus.pinned_at, cus.updated_at + FROM chat_user_settings cus + WHERE cus.chat_id = :dup_chat_id + ON CONFLICT (chat_id, user_id) DO UPDATE + SET archived = chat_user_settings.archived OR EXCLUDED.archived, + pinned = chat_user_settings.pinned OR EXCLUDED.pinned, + pinned_at = CASE + WHEN chat_user_settings.pinned_at IS NULL THEN EXCLUDED.pinned_at + WHEN EXCLUDED.pinned_at IS NULL THEN chat_user_settings.pinned_at + ELSE GREATEST(chat_user_settings.pinned_at, EXCLUDED.pinned_at) + END, + updated_at = GREATEST(chat_user_settings.updated_at, EXCLUDED.updated_at) + """ + ), + {"keep_chat_id": keep_chat_id, "dup_chat_id": duplicate_chat_id}, + ) + bind.execute( + sa.text("DELETE FROM chat_user_settings WHERE chat_id = :dup_chat_id"), + {"dup_chat_id": duplicate_chat_id}, + ) + + bind.execute( + sa.text( + """ + INSERT INTO message_idempotency_keys (chat_id, sender_id, client_message_id, message_id, created_at) + SELECT :keep_chat_id, mik.sender_id, mik.client_message_id, mik.message_id, mik.created_at + FROM message_idempotency_keys mik + WHERE mik.chat_id = :dup_chat_id + ON CONFLICT (chat_id, sender_id, client_message_id) DO NOTHING + """ + ), + {"keep_chat_id": keep_chat_id, "dup_chat_id": duplicate_chat_id}, + ) + bind.execute( + sa.text("DELETE FROM message_idempotency_keys WHERE chat_id = :dup_chat_id"), + {"dup_chat_id": duplicate_chat_id}, + ) + + bind.execute( + sa.text( + """ + INSERT INTO chat_members (chat_id, user_id, role, joined_at) + SELECT :keep_chat_id, cm.user_id, cm.role, cm.joined_at + FROM chat_members cm + WHERE cm.chat_id = :dup_chat_id + ON CONFLICT (chat_id, user_id) DO UPDATE + SET role = CASE + WHEN chat_members.role = 'OWNER' OR EXCLUDED.role = 'OWNER' THEN 'OWNER'::chatmemberrole + WHEN chat_members.role = 'ADMIN' OR EXCLUDED.role = 'ADMIN' THEN 'ADMIN'::chatmemberrole + ELSE 'MEMBER'::chatmemberrole + END, + joined_at = LEAST(chat_members.joined_at, EXCLUDED.joined_at) + """ + ), + {"keep_chat_id": keep_chat_id, "dup_chat_id": duplicate_chat_id}, + ) + bind.execute( + sa.text("DELETE FROM chat_members WHERE chat_id = :dup_chat_id"), + {"dup_chat_id": duplicate_chat_id}, + ) + + bind.execute( + sa.text("DELETE FROM chat_bans WHERE chat_id = :dup_chat_id"), + {"dup_chat_id": duplicate_chat_id}, + ) + bind.execute( + sa.text("DELETE FROM chat_invite_links WHERE chat_id = :dup_chat_id"), + {"dup_chat_id": duplicate_chat_id}, + ) + + bind.execute( + sa.text("DELETE FROM chats WHERE id = :dup_chat_id"), + {"dup_chat_id": duplicate_chat_id}, + ) + + +def downgrade() -> None: + # data-cleanup migration; no reversible schema changes + pass + diff --git a/docs/core-checklist-status.md b/docs/core-checklist-status.md index a0f87c5..1175a33 100644 --- a/docs/core-checklist-status.md +++ b/docs/core-checklist-status.md @@ -40,7 +40,7 @@ Legend: 31. Privacy - `PARTIAL` (avatar/last-seen/group-invites + PM policy `everyone|contacts|nobody`; group-invite `nobody` is available in API and web settings; integration tests cover PM policy matrix (`everyone/contacts/nobody`), group-invite policy matrix (`everyone/contacts/nobody`), private chat counterpart visibility for `nobody/contacts/everyone`, and avatar visibility matrix in search for `everyone/contacts/nobody`, remaining UX/matrix hardening) 32. Security - `PARTIAL` (sessions + revoke + 2FA base + access-session visibility; integration tests cover single-session revoke and revoke-all invalidation/force-disconnect; 2FA setup now blocked after enable to prevent secret re-issuance; one-time recovery codes added and covered for normalization/lifecycle (`remaining_codes` decrement + one-time usage); web auth panel supports recovery-code login; settings now warns when recovery codes are empty and provides copy/download actions for freshly generated codes) 33. Realtime Events - `DONE` (connect/disconnect/send/receive/typing/read/delivered/online/offline + chat/message updates + chat_deleted) -34. Sync - `PARTIAL` (cross-device via backend state + realtime; reconciliation improved for loaded chats/messages, chat-info panel hot-refreshes on `chat_updated`, delete/leave updates realtime subscriptions, full-chat delete emits `chat_deleted`, per-user chat state mutations (archive/unarchive/pin/unpin/mute) now emit `chat_updated`, and chat list now excludes duplicate `is_saved` rows from regular listing to keep a single Saved Messages entry) +34. Sync - `PARTIAL` (cross-device via backend state + realtime; reconciliation improved for loaded chats/messages, chat-info panel hot-refreshes on `chat_updated`, delete/leave updates realtime subscriptions, full-chat delete emits `chat_deleted`, per-user chat state mutations (archive/unarchive/pin/unpin/mute) now emit `chat_updated`, chat list excludes duplicate `is_saved` rows from regular listing, and migration `0026_deduplicate_saved_chats` merges historical duplicate Saved Messages data) 35. Additional - `PARTIAL` (drafts/link preview partial/autoload media basic) ## Current Focus to reach ~80%