Merge branch 'develop'

This commit is contained in:
lizerk 2026-04-16 12:59:45 +03:00
commit ecd6c892f8
7 changed files with 183 additions and 19 deletions

View File

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

View File

@ -24,6 +24,7 @@ class ChatNotificationReadService:
NotificationVerbs.MENTION,
NotificationVerbs.REPLY,
NotificationVerbs.ADDED_TO_CHAT,
NotificationVerbs.COMMENTED,
)
def mark_chat_notifications_as_read(

View File

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

View File

@ -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):
"""Попытка открыть несуществующий чат возвращает ошибку."""

View File

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

View File

@ -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 (
<React.Fragment key={message.id}>
@ -1067,6 +1081,15 @@ function MessageDialogPageContent() {
</button>
</div>
) : null}
{showUnreadDivider ? (
<div className="chat-unread-divider mb-3 mt-1 flex items-center gap-3 px-2" role="separator" aria-label="Непрочитанные сообщения">
<span className="h-px flex-1 bg-[color:color-mix(in_srgb,var(--accent-primary)_18%,var(--border-subtle))]" />
<span className="chat-unread-divider__label rounded-full px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.08em]">
Непрочитанные сообщения
</span>
<span className="h-px flex-1 bg-[color:color-mix(in_srgb,var(--accent-primary)_18%,var(--border-subtle))]" />
</div>
) : null}
<ChatMessageItem
message={message}
currentUserId={user?.id}

View File

@ -93,6 +93,24 @@ function getChatSortTimestamp(chat: Chat): number {
return Number.isNaN(chatCreatedTs) ? 0 : chatCreatedTs;
}
function isChatVisibleInMessagesList(
chat: Chat,
visibleDepartmentIds: ReadonlySet<number>
): 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<number>();
(user?.departments || []).forEach((department) => {
if (typeof department.id === "number") {
ids.add(department.id);
}
});
return ids;
}, [user?.departments]);
const [chats, setChats] = useState<Chat[]>([]);
const [search, setSearch] = useState("");
const [chatTypeFilter, setChatTypeFilter] = useState<string>("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() {
}`}
>
<div className="flex items-start gap-3">
<div className="relative h-10 w-10">
<div className="relative h-10 w-10 overflow-visible">
<div className="app-avatar-fallback flex h-10 w-10 items-center justify-center overflow-hidden rounded-full text-xs font-semibold">
{chatAvatar ? (
<Image
@ -544,7 +579,7 @@ export default function MessagesPage() {
</span>
{/* Счетчик непрочитанных */}
{(chat.unread_count ?? 0) > 0 ? (
<span className="app-counter-danger absolute -top-1 -right-1 z-10 flex h-5 min-w-[20px] items-center justify-center border-2 border-[var(--surface-primary)] px-1.5 text-[10px] font-bold">
<span className="chat-list-unread-badge app-counter-danger absolute right-0 top-0 z-20 flex min-w-[1.35rem] -translate-y-1/4 translate-x-1/4 items-center justify-center px-1.5 text-[10px] font-bold leading-none">
{chat.unread_count! > 99 ? '99+' : chat.unread_count}
</span>
) : null}