fix(messages): sync unread count after deletion

This commit is contained in:
lizerk 2026-08-06 14:45:21 +03:00
parent 96d4a188b7
commit 418a778942
11 changed files with 240 additions and 44 deletions

View File

@ -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,

View File

@ -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()

View File

@ -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):

View File

@ -1,6 +1,8 @@
from .chat_notifications import ChatNotificationReadResult, ChatNotificationReadService
from .message_deletion import soft_delete_message
__all__ = [
'ChatNotificationReadResult',
'ChatNotificationReadService',
'soft_delete_message',
]

View File

@ -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

View File

@ -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):
"""Массовое удаление - пропускает чужие сообщения"""

View File

@ -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:

View File

@ -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;
}

View File

@ -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);
});

View File

@ -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;

View File

@ -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 };
}