From fed079e02daf270defb79d578f37c65141d1f71a Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 17 Sep 2026 11:55:01 +0300 Subject: [PATCH] =?UTF-8?q?:sparkles:=20feat(notifications):=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D1=80=D1=8B=D1=82=D1=8B=D0=B9=20=D0=B4=D0=B8=D0=B0?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3=20=D0=B3=D0=B0=D1=81=D0=B8=D1=82=20=D1=81?= =?UTF-8?q?=D0=B2=D0=BE=D0=B8=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прочтением считалось только нажатие на строку в шторке. Сотрудник мог уже отвечать клиенту, а оклик «клиент ждёт» по этому же диалогу продолжал висеть непрочитанным и держать счётчик в шапке. Счётчик переставал что-либо значить: он звал туда, где человек уже был. Теперь при открытии диалога гасятся все уведомления о нём — не только те, что клиент успел загрузить в шторку: в неё помещается полсотни последних, и гасить по загруженному списку означало бы оставлять хвост. Отбор идёт по адресу уведомления, включая маршруты, которыми на диалог ссылались старые строки в базе. Отметка прочтения теперь шлёт сотруднику событие: счётчик живёт в шапке каждой открытой вкладки, и без события они разъезжались до следующего опроса. Проверено: тесты уведомлений (гасится только свой диалог, гасятся все его уведомления, старый маршрут узнаётся, нечисловой идентификатор отклоняется), рассылка событий, каталоги переводов, ruff, проверка типов рабочего места. Co-Authored-By: Claude Opus 5 --- apps/backend/chatballs/i18n/messages/en.py | 1 + apps/backend/chatballs/i18n/messages/ru.py | 1 + .../chatballs/notifications/services.py | 32 +++++++- apps/backend/chatballs/notifications/tests.py | 81 ++++++++++++++++++- apps/backend/chatballs/notifications/views.py | 6 ++ .../conversations/ConversationWorkspace.tsx | 3 + .../src/features/notifications/model.ts | 4 + .../useOpenedConversationRead.ts | 19 +++++ 8 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 apps/internal-ui/src/features/notifications/useOpenedConversationRead.ts diff --git a/apps/backend/chatballs/i18n/messages/en.py b/apps/backend/chatballs/i18n/messages/en.py index bbd0d3e..5adeaf8 100644 --- a/apps/backend/chatballs/i18n/messages/en.py +++ b/apps/backend/chatballs/i18n/messages/en.py @@ -357,6 +357,7 @@ MESSAGES: dict[str, object] = { "identity.role_conflict": "User already has a different role in this organization", "identity.token_required": "token is required", "identity.totp_challenge_inactive": "TOTP challenge is not active", + "notifications.conversation_id_number": "conversationId must be a number", "notifications.ids_list_or_all": "ids must be a list or use all=true", "notifications.push_types_list": "pushTypes must be a list", "notifications.enabled_bool": "enabled must be true or false", diff --git a/apps/backend/chatballs/i18n/messages/ru.py b/apps/backend/chatballs/i18n/messages/ru.py index e165fda..c1fc10c 100644 --- a/apps/backend/chatballs/i18n/messages/ru.py +++ b/apps/backend/chatballs/i18n/messages/ru.py @@ -361,6 +361,7 @@ MESSAGES: dict[str, object] = { "identity.role_conflict": "У пользователя уже другая роль в этой организации", "identity.token_required": "Нужен токен", "identity.totp_challenge_inactive": "Проверка кода уже неактуальна — войдите заново", + "notifications.conversation_id_number": "conversationId должен быть числом", "notifications.ids_list_or_all": "ids — список, либо all=true", "notifications.push_types_list": "pushTypes — список", "notifications.enabled_bool": "enabled — да или нет", diff --git a/apps/backend/chatballs/notifications/services.py b/apps/backend/chatballs/notifications/services.py index 04e3084..365fd23 100644 --- a/apps/backend/chatballs/notifications/services.py +++ b/apps/backend/chatballs/notifications/services.py @@ -12,6 +12,7 @@ from chatballs.notifications.models import ( NotificationRead, NotificationType, ) +from chatballs.notifications.realtime import notify_notifications_changed from chatballs.notifications.selectors import unread_for # Реестр типов: дефолтный уровень и маршрут диплинка. Новый тип события — @@ -131,9 +132,32 @@ def notify_management(*, context, dedup_key: str = "", **notification_data) -> i return created -def mark_read(*, context, ids: list[int] | None = None, all_unread: bool = False) -> int: +# Маршруты, которыми уведомление ссылается на диалог. Кроме нынешнего «chat» +# здесь старые значения: они лежат в уже созданных строках, и открытый диалог +# обязан гасить и их тоже. +CONVERSATION_ROUTES = ("chat", "salesDialogs", "conversations") + + +def mark_read( + *, + context, + ids: list[int] | None = None, + all_unread: bool = False, + conversation_id: int | None = None, +) -> int: + """Отметить уведомления прочитанными: перечисленные, все или про диалог. + + Про диалог — потому что открытый диалог и есть прочтение: оклик «клиент + ждёт» бессмысленно висеть непрочитанным, когда сотрудник уже в переписке. + Гасятся все уведомления об этом диалоге, а не только те, что клиент успел + загрузить в шторку. + """ queryset = unread_for(context) - if not all_unread: + if conversation_id is not None: + queryset = queryset.filter( + target_route__in=CONVERSATION_ROUTES, target_id=str(conversation_id) + ) + elif not all_unread: queryset = queryset.filter(id__in=ids or []) rows = [ NotificationRead( @@ -144,4 +168,8 @@ def mark_read(*, context, ids: list[int] | None = None, all_unread: bool = False for notification in queryset ] NotificationRead.objects.bulk_create(rows, ignore_conflicts=True) + if rows: + # Счётчик непрочитанных живёт в шапке каждой открытой вкладки: без + # события они разъезжаются до следующего опроса. + notify_notifications_changed([context.actor_user.id]) return len(rows) diff --git a/apps/backend/chatballs/notifications/tests.py b/apps/backend/chatballs/notifications/tests.py index 46d84fe..a306c33 100644 --- a/apps/backend/chatballs/notifications/tests.py +++ b/apps/backend/chatballs/notifications/tests.py @@ -13,10 +13,11 @@ from chatballs.notifications.delivery import NOTIFICATION_CREATED from chatballs.notifications.models import ( MessengerBinding, MessengerBindingCode, + Notification, NotificationAudience, NotificationType, ) -from chatballs.notifications.selectors import visible_for +from chatballs.notifications.selectors import unread_for, visible_for from chatballs.notifications.services import notify from chatballs.testing import TenantAPIClient as APIClient from chatballs.testing import tenant_context_for @@ -217,6 +218,84 @@ class PollerSelectionTests(NotifierTestBase): self.assertNotIn("inactive-channel-client", polled_names) +class OpenedConversationReadTests(NotifierTestBase): + """Открытый диалог гасит свои уведомления. + + Раньше оклик «клиент ждёт» оставался непрочитанным, пока по нему не нажали + в шторке: сотрудник мог отвечать в переписке, а счётчик в шапке продолжал + звать его туда же. + """ + + def setUp(self) -> None: + super().setUp() + self.client = APIClient() + self.client.login(username="owner@example.com", password="temporary-password") + + def _notify(self, *, target_id: str, type=NotificationType.OPERATOR_REQUESTED): + return notify( + context=self.context, + type=type, + audience=NotificationAudience.OPERATORS, + title="Клиент ждёт", + target_id=target_id, + ) + + def test_opened_conversation_marks_only_its_own_notifications(self) -> None: + opened = self._notify(target_id="17") + another = self._notify(target_id="18") + + response = self.client.post( + "/api/v1/notifications/read/", + data={"conversationId": 17}, + content_type="application/json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["unreadCount"], 1) + unread = list(unread_for(self.context).values_list("id", flat=True)) + self.assertEqual(unread, [another.id]) + self.assertNotIn(opened.id, unread) + + def test_every_notification_about_the_conversation_is_marked(self) -> None: + # В шторку помещается не всё: гасим по диалогу, а не по тому, что успел + # загрузить клиент. + waiting = self._notify(target_id="17") + message = self._notify(target_id="17", type=NotificationType.DIALOG_NEW_MESSAGE) + + self.client.post( + "/api/v1/notifications/read/", + data={"conversationId": 17}, + content_type="application/json", + ) + + read = set(unread_for(self.context).values_list("id", flat=True)) + self.assertNotIn(waiting.id, read) + self.assertNotIn(message.id, read) + + def test_legacy_route_of_old_notifications_is_recognized(self) -> None: + old = self._notify(target_id="17") + Notification.objects.filter(id=old.id).update(target_route="salesDialogs") + + self.client.post( + "/api/v1/notifications/read/", + data={"conversationId": 17}, + content_type="application/json", + ) + + self.assertEqual(unread_for(self.context).count(), 0) + + def test_broken_conversation_id_is_rejected(self) -> None: + self._notify(target_id="17") + + response = self.client.post( + "/api/v1/notifications/read/", + data={"conversationId": "17"}, + content_type="application/json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(unread_for(self.context).count(), 1) + class BindingApiTests(NotifierTestBase): def setUp(self) -> None: super().setUp() diff --git a/apps/backend/chatballs/notifications/views.py b/apps/backend/chatballs/notifications/views.py index 6cb4b19..eb99e3c 100644 --- a/apps/backend/chatballs/notifications/views.py +++ b/apps/backend/chatballs/notifications/views.py @@ -129,8 +129,14 @@ class NotificationReadView(APIView): permission_classes = [IsAuthenticated] def post(self, request: Request) -> Response: + conversation_id = request.data.get("conversationId") if request.data.get("all"): mark_read(context=request.tenant_context, all_unread=True) + elif conversation_id is not None: + # Сотрудник открыл диалог — окликать по нему больше нечем. + if not isinstance(conversation_id, int) or isinstance(conversation_id, bool): + return Response({"detail": t("notifications.conversation_id_number")}, status=400) + mark_read(context=request.tenant_context, conversation_id=conversation_id) else: ids = request.data.get("ids") if not isinstance(ids, list): diff --git a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx index 92ef56f..5a1e81b 100644 --- a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx +++ b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx @@ -46,6 +46,7 @@ import type { ConversationListItem, ListSort, ListTab } from "./types"; import { useConversationCall } from "./useConversationCall"; import { useConversationEvents } from "./useConversationEvents"; import { useDebounced } from "../../shared/useDebounced"; +import { useOpenedConversationRead } from "../notifications/useOpenedConversationRead"; import { useConversationHistory } from "./useConversationHistory"; import { useConversationList } from "./useConversationList"; import { useDialogKeyboardNav } from "./useDialogKeyboardNav"; @@ -148,6 +149,8 @@ export function ConversationWorkspace({ isOwner = false, canDelete = false, view setCtxOpen(false); }, [selectedId]); + useOpenedConversationRead(selectedId); + useEffect(() => { if (selectedId == null) return; setDetail(null); diff --git a/apps/internal-ui/src/features/notifications/model.ts b/apps/internal-ui/src/features/notifications/model.ts index d917858..54944b8 100644 --- a/apps/internal-ui/src/features/notifications/model.ts +++ b/apps/internal-ui/src/features/notifications/model.ts @@ -24,6 +24,10 @@ export const LEVEL_META: Record api<{ items: AppNotification[]; unreadCount: number }>("/api/v1/notifications/"); export const markRead = (ids: number[]) => api("/api/v1/notifications/read/", { method: "POST", body: JSON.stringify({ ids }) }); export const markAllRead = () => api("/api/v1/notifications/read/", { method: "POST", body: JSON.stringify({ all: true }) }); +// Открытый диалог — тоже прочтение: гасит все уведомления о нём, а не только +// те, что попали в шторку. +export const markConversationRead = (conversationId: number) => + api("/api/v1/notifications/read/", { method: "POST", body: JSON.stringify({ conversationId }) }); export type NotificationTransport = "BROWSER" | "MESSENGER"; diff --git a/apps/internal-ui/src/features/notifications/useOpenedConversationRead.ts b/apps/internal-ui/src/features/notifications/useOpenedConversationRead.ts new file mode 100644 index 0000000..6c92ae7 --- /dev/null +++ b/apps/internal-ui/src/features/notifications/useOpenedConversationRead.ts @@ -0,0 +1,19 @@ +import { useEffect } from "react"; + +import { markConversationRead } from "./model"; + +/** Открытый диалог гасит свои уведомления. + * + * Раньше оклик «клиент ждёт» оставался непрочитанным, даже когда сотрудник уже + * отвечал в этой переписке: прочтением считалось только нажатие на строку в + * шторке. Счётчик в шапке жил своей жизнью и переставал что-либо значить. + * + * Список уведомлений после этого перезапрашивает шапка — по событию, которое + * шлёт сервер тому же сотруднику: вкладок у него может быть несколько. + */ +export function useOpenedConversationRead(conversationId: number | null): void { + useEffect(() => { + if (conversationId == null) return; + void markConversationRead(conversationId).catch(() => undefined); + }, [conversationId]); +}