From 418a778942b2845ac02992de41b3e22218a0759c Mon Sep 17 00:00:00 2001 From: lizerk Date: Thu, 6 Aug 2026 14:45:21 +0300 Subject: [PATCH] fix(messages): sync unread count after deletion --- backend/communications/api/viewsets.py | 58 ++++++++++++------- backend/communications/comments_helpers.py | 7 +-- backend/communications/consumers.py | 23 ++++++-- backend/communications/services/__init__.py | 2 + .../services/message_deletion.py | 46 +++++++++++++++ .../communications/test_communications_api.py | 35 ++++++++++- .../tests/realtime/test_websocket_events.py | 25 +++++--- frontend/src/hooks/useMessagesChatList.ts | 41 ++++++++++++- frontend/src/lib/chat-list-state.test.js | 26 ++++++++- frontend/src/lib/messages/chatListState.d.ts | 6 ++ frontend/src/lib/messages/chatListState.js | 15 +++++ 11 files changed, 240 insertions(+), 44 deletions(-) create mode 100644 backend/communications/services/message_deletion.py diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py index 9c885f05..dc60bcfa 100644 --- a/backend/communications/api/viewsets.py +++ b/backend/communications/api/viewsets.py @@ -53,6 +53,7 @@ from ..models import ( Poll, ) from ..polls import PollActionError, cast_poll_vote +from ..services import soft_delete_message from ..utils import _coerce_ts, user_can_access_chat from communications.serialization import ( serialize_message, @@ -64,6 +65,19 @@ logger = logging.getLogger(__name__) User = get_user_model() +def _broadcast_message_deleted(chat_id, message_id, author_id): + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + f'chat_{chat_id}', + { + 'type': 'chat.message_deleted', + 'chat_id': chat_id, + 'message_id': message_id, + 'author_id': author_id, + }, + ) + + def _parse_bool_query_param(value, default=True): if value is None: return default @@ -1485,20 +1499,12 @@ class MessageViewSet(viewsets.ModelViewSet): status=status.HTTP_403_FORBIDDEN ) - instance.is_deleted = True - instance.deleted_at = timezone.now() - instance.deleted_by = request.user - instance.save() + soft_delete_message(instance, deleted_by=request.user) - # WebSocket уведомление - channel_layer = get_channel_layer() - async_to_sync(channel_layer.group_send)( - f'chat_{instance.chat_id}', - { - 'type': 'chat.message_deleted', - 'chat_id': instance.chat_id, - 'message_id': instance.id - } + _broadcast_message_deleted( + instance.chat_id, + instance.id, + instance.author_id, ) return Response({'ok': True}, status=status.HTTP_204_NO_CONTENT) @@ -1683,15 +1689,25 @@ class MessageViewSet(viewsets.ModelViewSet): message_ids = serializer.validated_data['message_ids'] - # Удаляем только свои сообщения - deleted_count = Message.objects.filter( - id__in=message_ids, - author=request.user - ).update( - is_deleted=True, - deleted_at=timezone.now(), - deleted_by=request.user + # Удаляем только свои сообщения и корректируем денормализованные + # счетчики непрочитанных для каждого удаленного сообщения. + messages = list( + Message.objects.filter( + id__in=message_ids, + author=request.user, + is_deleted=False, + ) ) + deleted_count = 0 + for message in messages: + if not soft_delete_message(message, deleted_by=request.user): + continue + deleted_count += 1 + _broadcast_message_deleted( + message.chat_id, + message.id, + message.author_id, + ) return Response({ 'ok': True, diff --git a/backend/communications/comments_helpers.py b/backend/communications/comments_helpers.py index e9994b73..05629bbb 100644 --- a/backend/communications/comments_helpers.py +++ b/backend/communications/comments_helpers.py @@ -250,12 +250,9 @@ def delete_comment(message: Message, deleted_by, soft_delete: bool = True): ... ) # Жесткое """ if soft_delete: - from django.utils import timezone + from communications.services import soft_delete_message - message.is_deleted = True - message.deleted_by = deleted_by - message.deleted_at = timezone.now() - message.save(update_fields=["is_deleted", "deleted_by", "deleted_at"]) + soft_delete_message(message, deleted_by=deleted_by) else: message.delete() diff --git a/backend/communications/consumers.py b/backend/communications/consumers.py index bbf339b4..84890a43 100644 --- a/backend/communications/consumers.py +++ b/backend/communications/consumers.py @@ -26,6 +26,7 @@ from .models import ( ) from .polls import PollActionError, cast_poll_vote from .serialization import serialize_message, serialize_poll +from .services import soft_delete_message from .utils import user_can_access_chat logger = logging.getLogger(__name__) @@ -190,6 +191,7 @@ class ChatConsumerMixin: """Сообщение удалено""" chat_id = event.get("chat_id") message_id = event.get("message_id") + author_id = event.get("author_id") logger.info( "[ChatMixin] Received delete event: chat_id=%s, message_id=%s", @@ -211,11 +213,19 @@ class ChatConsumerMixin: "type": "message_deleted", "chat_id": chat_id, "message_id": message_id, + "author_id": author_id, } ) logger.info("[ChatMixin] Sent to client successfully") - else: - logger.info("[ChatMixin] Skipping - not active chat") + await self.send_json( + { + "type": "list_update", + "event": "message_deleted", + "chat_id": chat_id, + "message_id": message_id, + "author_id": author_id, + } + ) async def chat_reaction_added(self, event): """Реакция добавлена""" @@ -445,6 +455,7 @@ class ChatConsumerMixin: "type": "chat_message_deleted", "chat_id": chat_id, "message_id": message_id, + "author_id": msg.author_id, }, ) @@ -742,9 +753,11 @@ class ChatConsumerMixin: @database_sync_to_async def _soft_delete_message(self, msg): """Мягкое удаление сообщения""" - msg.is_deleted = True - msg.content = "[Сообщение удалено]" - msg.save(update_fields=["is_deleted", "content"]) + return soft_delete_message( + msg, + deleted_by=self.user, + replacement_content="[Сообщение удалено]", + ) @database_sync_to_async def _add_reaction(self, msg, user, emoji): diff --git a/backend/communications/services/__init__.py b/backend/communications/services/__init__.py index 37172d59..9871593a 100644 --- a/backend/communications/services/__init__.py +++ b/backend/communications/services/__init__.py @@ -1,6 +1,8 @@ from .chat_notifications import ChatNotificationReadResult, ChatNotificationReadService +from .message_deletion import soft_delete_message __all__ = [ 'ChatNotificationReadResult', 'ChatNotificationReadService', + 'soft_delete_message', ] diff --git a/backend/communications/services/message_deletion.py b/backend/communications/services/message_deletion.py new file mode 100644 index 00000000..6aa2d04e --- /dev/null +++ b/backend/communications/services/message_deletion.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from django.db import transaction +from django.db.models import F, Q +from django.utils import timezone + +from communications.models import ChatReadState, Message + + +def soft_delete_message( + message: Message, + *, + deleted_by, + replacement_content: str | None = None, +) -> bool: + """Soft-delete a message and remove it from cached unread counters.""" + with transaction.atomic(): + locked_message = Message.objects.select_for_update().get(pk=message.pk) + if locked_message.is_deleted: + return False + + unread_states = ChatReadState.objects.filter( + chat_id=locked_message.chat_id, + unread_count__gt=0, + ).exclude(user_id=locked_message.author_id) + unread_states = unread_states.filter( + Q(last_read_message_id__isnull=True) + | Q(last_read_message_id__lt=locked_message.id) + ) + unread_states.update(unread_count=F("unread_count") - 1) + + locked_message.is_deleted = True + locked_message.deleted_at = timezone.now() + locked_message.deleted_by = deleted_by + update_fields = ["is_deleted", "deleted_at", "deleted_by"] + if replacement_content is not None: + locked_message.content = replacement_content + update_fields.append("content") + locked_message.save(update_fields=update_fields) + + message.is_deleted = locked_message.is_deleted + message.deleted_at = locked_message.deleted_at + message.deleted_by = locked_message.deleted_by + if replacement_content is not None: + message.content = replacement_content + return True diff --git a/backend/tests/api/v1/communications/test_communications_api.py b/backend/tests/api/v1/communications/test_communications_api.py index 0fa9fee6..9a1b4184 100644 --- a/backend/tests/api/v1/communications/test_communications_api.py +++ b/backend/tests/api/v1/communications/test_communications_api.py @@ -561,11 +561,25 @@ class TestMessageViewSet: assert response.status_code == status.HTTP_403_FORBIDDEN - def test_delete_message(self, auth_client, private_chat, user1): + def test_delete_message( + self, + auth_client, + private_chat, + user1, + user2, + ): """Удаление своего сообщения""" message = Message.objects.create( chat=private_chat, author=user1, content="To delete" ) + remaining_message = Message.objects.create( + chat=private_chat, author=user1, content="Still unread" + ) + read_state, _ = ChatReadState.objects.update_or_create( + chat=private_chat, + user=user2, + defaults={"last_read_message": None, "unread_count": 2}, + ) url = f"/api/v1/communications/messages/{message.pk}/" response = auth_client.delete(url) @@ -574,7 +588,11 @@ class TestMessageViewSet: # Проверяем мягкое удаление message.refresh_from_db() + remaining_message.refresh_from_db() assert message.is_deleted is True + assert remaining_message.is_deleted is False + read_state.refresh_from_db() + assert read_state.unread_count == 1 def test_react_to_message(self, auth_client, private_chat, user1): """Добавление реакции на сообщение""" @@ -630,7 +648,13 @@ class TestMessageViewSet: assert response.data["ok"] is True assert response.data["forwarded_count"] == 2 - def test_bulk_delete_messages(self, auth_client, private_chat, user1): + def test_bulk_delete_messages( + self, + auth_client, + private_chat, + user1, + user2, + ): """Массовое удаление сообщений""" # Создаем несколько сообщений messages = [ @@ -638,6 +662,11 @@ class TestMessageViewSet: for i in range(3) ] message_ids = [m.id for m in messages] + read_state, _ = ChatReadState.objects.update_or_create( + chat=private_chat, + user=user2, + defaults={"last_read_message": None, "unread_count": 3}, + ) url = "/api/v1/communications/messages/bulk-delete/" data = {"message_ids": message_ids} @@ -646,6 +675,8 @@ class TestMessageViewSet: assert response.status_code == status.HTTP_200_OK assert response.data["ok"] is True assert response.data["deleted_count"] == 3 + read_state.refresh_from_db() + assert read_state.unread_count == 0 def test_bulk_delete_partial_ownership(self, user1, user2, private_chat): """Массовое удаление - пропускает чужие сообщения""" diff --git a/backend/tests/realtime/test_websocket_events.py b/backend/tests/realtime/test_websocket_events.py index 3f0f6ce0..6affba98 100644 --- a/backend/tests/realtime/test_websocket_events.py +++ b/backend/tests/realtime/test_websocket_events.py @@ -197,7 +197,8 @@ class TestMessageWebSocketEvents: { 'type': 'chat.message_deleted', 'chat_id': test_chat.id, - 'message_id': mock_message_id + 'message_id': mock_message_id, + 'author_id': user.id, } ) @@ -206,12 +207,18 @@ class TestMessageWebSocketEvents: assert response["type"] == "message_deleted" assert response["chat_id"] == test_chat.id assert response["message_id"] == mock_message_id + + list_response = await comm.receive_json_from(timeout=5) + assert list_response["type"] == "list_update" + assert list_response["event"] == "message_deleted" + assert list_response["chat_id"] == test_chat.id + assert list_response["author_id"] == user.id await comm.disconnect() @pytest.mark.asyncio async def test_message_deleted_inactive_chat(self, ws_communicator, user, test_chat): - """Если чат неактивен, событие удаления НЕ отправляется клиенту.""" + """Удаление в неактивном чате обновляет его строку в списке.""" comm = await ws_communicator(user=user) connected, _ = await comm.connect() assert connected @@ -227,15 +234,17 @@ class TestMessageWebSocketEvents: { 'type': 'chat.message_deleted', 'chat_id': test_chat.id, - 'message_id': mock_message_id + 'message_id': mock_message_id, + 'author_id': user.id, } ) - # НЕ должны получить событие (чат неактивен) - # Проверяем что не получим ничего в течение короткого таймаута - import asyncio - with pytest.raises(asyncio.TimeoutError): - await comm.receive_json_from(timeout=2) + response = await comm.receive_json_from(timeout=5) + assert response["type"] == "list_update" + assert response["event"] == "message_deleted" + assert response["chat_id"] == test_chat.id + assert response["message_id"] == mock_message_id + assert response["author_id"] == user.id class TestReactionWebSocketEvents: diff --git a/frontend/src/hooks/useMessagesChatList.ts b/frontend/src/hooks/useMessagesChatList.ts index e4f8211d..caf80f1f 100644 --- a/frontend/src/hooks/useMessagesChatList.ts +++ b/frontend/src/hooks/useMessagesChatList.ts @@ -3,7 +3,10 @@ import { useCallback, useEffect, useSyncExternalStore } from "react"; import { apiClient } from "@/lib/api"; -import { applyChatReadWatermark } from "@/lib/messages/chatListState.js"; +import { + applyChatReadWatermark, + applyDeletedUnreadMessage, +} from "@/lib/messages/chatListState.js"; import wsManager from "@/lib/websocketManager"; import type { Chat } from "@/types/api"; @@ -19,6 +22,9 @@ type RealtimeChatEvent = { chat_id?: number; last_read_message_id?: number; reader_user_id?: number; + event?: string; + message_id?: number; + author_id?: number; notification?: { verb?: string } | null; }; @@ -129,7 +135,38 @@ function startRealtime() { const unsubscribeMessages = wsManager.subscribe((event) => { const data = event as RealtimeChatEvent; - if ((data.type === "list_update" || data.type === "message_edited") && typeof data.chat_id === "number") { + if ( + (data.type === "list_update" || data.type === "message_deleted") + && data.event === "message_deleted" + && typeof data.chat_id === "number" + && typeof data.message_id === "number" + && typeof data.author_id === "number" + && typeof activeUserId === "number" + ) { + const chatId = data.chat_id; + const messageId = data.message_id; + const authorId = data.author_id; + const currentUserId = activeUserId; + const chats = snapshot.chats.map((chat) => ( + chat.id === chatId + ? applyDeletedUnreadMessage( + chat, + messageId, + authorId, + currentUserId, + ) + : chat + )); + updateSnapshot({ ...snapshot, chats }); + void refreshChat(chatId); + return; + } + if ( + (data.type === "list_update" + || data.type === "message_edited" + || data.type === "message_deleted") + && typeof data.chat_id === "number" + ) { void refreshChat(data.chat_id); return; } diff --git a/frontend/src/lib/chat-list-state.test.js b/frontend/src/lib/chat-list-state.test.js index 14baa5b1..330a10c0 100644 --- a/frontend/src/lib/chat-list-state.test.js +++ b/frontend/src/lib/chat-list-state.test.js @@ -1,7 +1,10 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { applyChatReadWatermark } from "./messages/chatListState.js"; +import { + applyChatReadWatermark, + applyDeletedUnreadMessage, +} from "./messages/chatListState.js"; test("confirmed chat reads clear a stale unread counter", () => { const chat = { @@ -28,3 +31,24 @@ test("messages newer than the local read watermark remain unread", () => { assert.equal(applyChatReadWatermark(chat, 43), chat); }); + +test("deleting an unread message decrements the local chat counter", () => { + const chat = { + id: 7, + unread_count: 2, + last_read_message_id: 40, + last_message: { id: 44 }, + }; + + assert.deepEqual(applyDeletedUnreadMessage(chat, 43, 8, 9), { + ...chat, + unread_count: 1, + }); +}); + +test("deleting own or already read messages does not change the counter", () => { + const chat = { id: 7, unread_count: 2, last_read_message_id: 43 }; + + assert.equal(applyDeletedUnreadMessage(chat, 44, 9, 9), chat); + assert.equal(applyDeletedUnreadMessage(chat, 42, 8, 9), chat); +}); diff --git a/frontend/src/lib/messages/chatListState.d.ts b/frontend/src/lib/messages/chatListState.d.ts index 0aa3d1bd..6f5bb6dc 100644 --- a/frontend/src/lib/messages/chatListState.d.ts +++ b/frontend/src/lib/messages/chatListState.d.ts @@ -1,3 +1,9 @@ import type { Chat } from "@/types/api"; export function applyChatReadWatermark(chat: Chat, watermark: number): Chat; +export function applyDeletedUnreadMessage( + chat: Chat, + messageId: number, + authorId: number, + currentUserId: number, +): Chat; diff --git a/frontend/src/lib/messages/chatListState.js b/frontend/src/lib/messages/chatListState.js index 983b45b2..500a97a9 100644 --- a/frontend/src/lib/messages/chatListState.js +++ b/frontend/src/lib/messages/chatListState.js @@ -8,3 +8,18 @@ export function applyChatReadWatermark(chat, watermark) { last_read_message_id: Math.max(chat.last_read_message_id ?? 0, watermark), }; } + +export function applyDeletedUnreadMessage( + chat, + messageId, + authorId, + currentUserId, +) { + if (![messageId, authorId, currentUserId].every(Number.isFinite)) return chat; + if (authorId === currentUserId) return chat; + if (messageId <= (chat.last_read_message_id ?? 0)) return chat; + + const unreadCount = chat.unread_count ?? 0; + if (unreadCount <= 0) return chat; + return { ...chat, unread_count: unreadCount - 1 }; +}