fix(messages): sync unread count after deletion
This commit is contained in:
parent
96d4a188b7
commit
418a778942
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
from .chat_notifications import ChatNotificationReadResult, ChatNotificationReadService
|
||||
from .message_deletion import soft_delete_message
|
||||
|
||||
__all__ = [
|
||||
'ChatNotificationReadResult',
|
||||
'ChatNotificationReadService',
|
||||
'soft_delete_message',
|
||||
]
|
||||
|
||||
46
backend/communications/services/message_deletion.py
Normal file
46
backend/communications/services/message_deletion.py
Normal 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
|
||||
@ -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):
|
||||
"""Массовое удаление - пропускает чужие сообщения"""
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
6
frontend/src/lib/messages/chatListState.d.ts
vendored
6
frontend/src/lib/messages/chatListState.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user