diff --git a/backend/communications/notifications/handlers.py b/backend/communications/notifications/handlers.py index dfc66ddc..25cf2db0 100644 --- a/backend/communications/notifications/handlers.py +++ b/backend/communications/notifications/handlers.py @@ -219,7 +219,8 @@ def _notify_comment(message): Уведомляет: 1. Автора объекта (Post/Document/Request) - 2. Других участников обсуждения (кто оставлял комментарии) + 2. Для Department: сотрудников отдела и role-only участников отдела + 3. Автора parent comment, если это reply Args: message: Экземпляр модели Message (в чате type='comments') @@ -298,17 +299,9 @@ def _notify_comment(message): if object_author and object_author.id != author.id: recipient_ids.add(object_author.id) - # 2. Уведомляем всех, кто участвовал в обсуждении - from ..models import Message - previous_commenters = Message.objects.filter( - chat=chat, - is_deleted=False - ).exclude( - author_id=author.id - ).values_list('author_id', flat=True).distinct() - - if previous_commenters: - recipient_ids.update(previous_commenters) + # 2. Reply notification for comments is opt-in by explicit reply target. + if message.reply_to and message.reply_to.author_id != author.id: + recipient_ids.add(message.reply_to.author_id) recipient_ids.discard(author.id) if not recipient_ids: diff --git a/backend/communications/services/chat_notifications.py b/backend/communications/services/chat_notifications.py index 4798c3e0..2d5c5d38 100644 --- a/backend/communications/services/chat_notifications.py +++ b/backend/communications/services/chat_notifications.py @@ -24,6 +24,7 @@ class ChatNotificationReadService: NotificationVerbs.MENTION, NotificationVerbs.REPLY, NotificationVerbs.ADDED_TO_CHAT, + NotificationVerbs.COMMENTED, ) def mark_chat_notifications_as_read( diff --git a/backend/tests/api/v1/employees/test_department_discussion.py b/backend/tests/api/v1/employees/test_department_discussion.py index 7ffa37aa..b398ce3a 100644 --- a/backend/tests/api/v1/employees/test_department_discussion.py +++ b/backend/tests/api/v1/employees/test_department_discussion.py @@ -181,7 +181,7 @@ def test_any_active_user_can_send_message_to_department_comments_chat( assert chat.flags.get("show_in_messages") is True -def test_department_comment_notifications_keep_chat_routing_and_include_role_only( +def test_department_comment_notifications_only_include_department_and_role_only( monkeypatch, ): department = Department.objects.create(name="Backoffice") @@ -229,7 +229,7 @@ def test_department_comment_notifications_keep_chat_routing_and_include_role_onl recipient_ids = {payload["recipient"].id for payload in sent_notifications} assert role_only.id in recipient_ids - assert previous_commenter.id in recipient_ids + assert previous_commenter.id not in recipient_ids assert member.id not in recipient_ids assert sent_notifications for payload in sent_notifications: @@ -237,6 +237,51 @@ def test_department_comment_notifications_keep_chat_routing_and_include_role_onl assert payload["data"]["chat_id"] == message.chat_id +def test_department_comment_reply_notifies_reply_target_outside_department( + monkeypatch, +): + department = Department.objects.create(name="Operations") + member = make_user("member@example.com") + outsider = make_user("outsider@example.com") + + EmployeeDepartment.objects.create( + employee=member, + department=department, + is_active=True, + ) + + first_comment = create_comment( + department, + author=outsider, + content="Вопрос от сотрудника вне отдела", + ) + + sent_notifications = [] + + def capture_notification(**kwargs): + sent_notifications.append(kwargs) + + monkeypatch.setattr( + "communications.notifications.handlers._send_notification", + capture_notification, + ) + + reply = create_comment( + department, + author=member, + content="Ответ сотруднику", + reply_to=first_comment, + ) + + recipient_ids = {payload["recipient"].id for payload in sent_notifications} + + assert outsider.id in recipient_ids + assert member.id not in recipient_ids + for payload in sent_notifications: + assert payload["action_url"] == f"/messages/{reply.chat_id}" + assert payload["data"]["chat_id"] == reply.chat_id + + def test_department_chat_visible_in_messages_list_but_generic_comments_hidden( api_client: APIClient, ): diff --git a/backend/tests/realtime/test_user_consumer.py b/backend/tests/realtime/test_user_consumer.py index a98cb97e..7a9ea2f4 100644 --- a/backend/tests/realtime/test_user_consumer.py +++ b/backend/tests/realtime/test_user_consumer.py @@ -150,6 +150,55 @@ class TestUserConsumerChatManagement: assert unrelated_notification.unread is True await communicator.disconnect() + + async def test_open_chat_marks_related_commented_notifications_as_read(self, ws_communicator, user, test_chat): + """Открытие comments-чата должно читать уведомления verb=commented этого чата.""" + related_notification = await database_sync_to_async(Notification.objects.create)( + recipient=user, + verb='commented', + description='Department chat comment notification', + action_url=f'/messages/{test_chat.id}', + data={'chat_id': test_chat.id, 'message_id': 12, 'object_type': 'Department'}, + ) + unrelated_notification = await database_sync_to_async(Notification.objects.create)( + recipient=user, + verb='commented', + description='Other comments chat notification', + action_url='/messages/999', + data={'chat_id': 999, 'message_id': 13, 'object_type': 'Department'}, + ) + + communicator = await ws_communicator(user=user) + + connected, _ = await communicator.connect() + assert connected + + await communicator.send_json_to({ + 'action': 'open_chat', + 'chat_id': test_chat.id, + 'load_history': False, + }) + + response = await communicator.receive_json_from(timeout=5) + assert response['type'] == 'chat_opened' + assert response['chat_id'] == test_chat.id + + read_all_event = await communicator.receive_json_from(timeout=5) + assert read_all_event['type'] == 'notifications_read_all' + assert related_notification.id in read_all_event['notification_ids'] + assert unrelated_notification.id not in read_all_event['notification_ids'] + + count_event = await communicator.receive_json_from(timeout=5) + assert count_event['type'] == 'unread_count' + assert count_event['count'] == 1 + + related_notification = await database_sync_to_async(Notification.objects.get)(id=related_notification.id) + unrelated_notification = await database_sync_to_async(Notification.objects.get)(id=unrelated_notification.id) + + assert related_notification.unread is False + assert unrelated_notification.unread is True + + await communicator.disconnect() async def test_open_nonexistent_chat(self, ws_communicator, user): """Попытка открыть несуществующий чат возвращает ошибку.""" diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 6f1386ba..8f642d57 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -378,6 +378,24 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator { color: #ffffff; } +.chat-list-unread-badge { + min-height: 1.35rem; + border: 2px solid var(--surface-primary); + box-shadow: 0 4px 10px color-mix(in srgb, #ef4444 22%, transparent); + letter-spacing: -0.01em; +} + +.chat-unread-divider { + color: var(--accent-primary-strong); +} + +.chat-unread-divider__label { + border: 1px solid color-mix(in srgb, var(--accent-primary) 20%, var(--border-subtle)); + background: color-mix(in srgb, var(--accent-primary) 10%, var(--surface-primary)); + color: var(--accent-primary-strong); + box-shadow: 0 6px 16px color-mix(in srgb, var(--accent-primary) 10%, transparent); +} + .app-action-primary { border: 1px solid transparent; background: var(--accent-primary); diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index 7802ec32..8166cdc8 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -135,6 +135,19 @@ function MessageDialogPageContent() { /* ── derived state ── */ const displayMessages = useMemo(() => mergeDisplayMessages(cm.messages, cm.pendingMessages), [cm.messages, cm.pendingMessages]); const messagesById = useMemo(() => new Map(displayMessages.map(m => [m.id, m])), [displayMessages]); + const unreadDividerMessageId = useMemo(() => { + if (cm.newMessagesBelowCount <= 0) { + return null; + } + + const unreadBelowIds = cm.unreadBelowIdsRef.current; + if (!unreadBelowIds || unreadBelowIds.size === 0) { + return null; + } + + const firstUnreadIncoming = displayMessages.find((message) => unreadBelowIds.has(message.id)); + return firstUnreadIncoming?.id ?? null; + }, [cm.newMessagesBelowCount, cm.unreadBelowIdsRef, displayMessages]); /** * Badge count: take the maximum of the server-provided unread_count @@ -1057,6 +1070,7 @@ function MessageDialogPageContent() { const prevDay = index > 0 ? getMessageDayKey(displayMessages[index - 1]) : null; const curDate = getMessageDate(message); const showDayDivider = Boolean(curDate && curDay !== prevDay); + const showUnreadDivider = unreadDividerMessageId === message.id; return ( @@ -1067,6 +1081,15 @@ function MessageDialogPageContent() { ) : null} + {showUnreadDivider ? ( +
+ + + Непрочитанные сообщения + + +
+ ) : null} +): boolean { + const chatType = chat.chat_type || chat.type; + + if (chatType !== "comments") { + return true; + } + + if (!isDepartmentCommentsChat(chat)) { + return false; + } + + return typeof chat.context_object_id === "number" + && visibleDepartmentIds.has(chat.context_object_id); +} + export default function MessagesPage() { const { user } = useUser(); const router = useRouter(); @@ -111,6 +129,15 @@ export default function MessagesPage() { }), [currentUserEmail, currentUserFirstName, currentUserLastName, currentUserPatronymic] ); + const visibleDepartmentIds = useMemo(() => { + const ids = new Set(); + (user?.departments || []).forEach((department) => { + if (typeof department.id === "number") { + ids.add(department.id); + } + }); + return ids; + }, [user?.departments]); const [chats, setChats] = useState([]); const [search, setSearch] = useState(""); const [chatTypeFilter, setChatTypeFilter] = useState("all"); @@ -147,7 +174,11 @@ export default function MessagesPage() { } setError(null); const items = await apiClient.getAllChats(); - setChats(items); + setChats( + items.filter((chat) => + isChatVisibleInMessagesList(chat, visibleDepartmentIds) + ) + ); hasCompletedInitialLoadRef.current = true; } catch (e: unknown) { console.error("Ошибка загрузки чатов:", e); @@ -158,7 +189,7 @@ export default function MessagesPage() { setLoading(false); } } - }, []); + }, [visibleDepartmentIds]); const refreshChat = useCallback(async (chatId: number) => { if (!Number.isFinite(chatId) || chatId <= 0) { @@ -174,6 +205,10 @@ export default function MessagesPage() { try { const chat = await apiClient.getChat(chatId); setChats((prev) => { + if (!isChatVisibleInMessagesList(chat, visibleDepartmentIds)) { + return prev.filter((item) => item.id !== chatId); + } + const existingIndex = prev.findIndex((item) => item.id === chatId); if (existingIndex === -1) { return [chat, ...prev]; @@ -192,7 +227,7 @@ export default function MessagesPage() { } finally { refreshChatInFlightRef.current.delete(chatId); } - }, [loadChats]); + }, [loadChats, visibleDepartmentIds]); useEffect(() => { void loadChats(); @@ -523,7 +558,7 @@ export default function MessagesPage() { }`} >
-
+
{chatAvatar ? ( {/* Счетчик непрочитанных */} {(chat.unread_count ?? 0) > 0 ? ( - + {chat.unread_count! > 99 ? '99+' : chat.unread_count} ) : null}