diff --git a/AGENTS.md b/AGENTS.md index 08050fd..6f65256 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,9 @@ Если в окружении не доступен инструмент, например - Python, PHP, попробуй docker, если в проекте есть его файлы. +Тесты: гонять только те, что относятся к изменённому в текущем шаге. Полный +прогон — только по моему явному указанию, никогда по своей инициативе. Полный +набор идёт ~20 минут, и гонять его после каждой правки запрещено. + Без моего явного указания не меняй ничего! Если я задал вопросы, это не значит что ты можешь менять файлы! Любые правки только после моего явного указания, например - "делай". @@ -56,7 +60,11 @@ Engineering rules / обязательные практики: - Демо-набор ставится на языке организации: манифесты лежат в `demo_seed/data/<язык>/`, наборы ключей в них совпадают. Бинарные вложения (аватары, голосовые) общие, текстовые документы — свои на каждый язык. Definition of Done: -- Запускать только тесты, относящиеся к изменениям текущей итерации. Полный прогон всех тестов выполнять только по явному указанию владельца. +- Запускать только тесты, относящиеся к изменениям текущей итерации: изменил + presence — гоняешь тесты присутствия, изменил тексты — гоняешь каталог i18n. + Полный прогон всех тестов выполнять ТОЛЬКО по явному указанию владельца. + Использовать `--reuse-db`; `--create-db` — лишняя минута на переигрывание + миграций, она нужна только когда схема действительно поменялась. - Измененные файлы должны быть проверены на NO GOD violations. - Новый компонент не должен владеть несвязанными ответственностями. - Не должно быть придуманных текстов, иконок, layout-решений или состояний вне design/docs. diff --git a/VERSION b/VERSION index 2eda823..9dbb0c0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.1 \ No newline at end of file +1.7.0 \ No newline at end of file diff --git a/apps/backend/chatballs/conversations/chat_extras_views.py b/apps/backend/chatballs/conversations/chat_extras_views.py index 1453523..fd75501 100644 --- a/apps/backend/chatballs/conversations/chat_extras_views.py +++ b/apps/backend/chatballs/conversations/chat_extras_views.py @@ -22,6 +22,7 @@ from chatballs.conversations.models import ( LifecycleState, ReplyTemplate, ) +from chatballs.conversations.queue_models import policy_for from chatballs.conversations.selectors import apply_conversation_visibility from chatballs.conversations.serializers import conversation_payload from chatballs.conversations.view_base import ConversationViewBase @@ -30,6 +31,7 @@ from chatballs.identity.avatars import user_avatar_url from chatballs.identity.group_models import EmployeeGroup from chatballs.identity.models import HumanUser, OrganizationMembership from chatballs.identity.policy import can_administer_access +from chatballs.presence import ONLINE_WITHIN_SECONDS, last_seen def _label_payload(label: ConversationLabel) -> dict[str, object]: @@ -258,10 +260,21 @@ class ConversationCountersView(ConversationViewBase): } for assignee in assignees: assignee["avatarUrl"] = user_avatar_url(avatars.get(assignee["id"]), request.tenant_context.organization.public_id) + # Два разных ожидания (макет Q3): диалог ничей — взять может любой; + # диалог назначен лично на меня и ждёт, пока я его возьму. Смешивать их + # в одном счётчике значит прятать своё среди чужого. + waiting_qs = open_qs.filter(control_mode=ControlMode.PAUSED) return Response( { "all": open_qs.count(), - "waiting": open_qs.filter(control_mode=ControlMode.PAUSED).count(), + "waiting": waiting_qs.count(), + "queue": waiting_qs.filter(assigned_operator__isnull=True).count(), + "waitingOnMe": waiting_qs.filter(assigned_operator_id=request.user.id).count(), + # Срок личной очереди: по нему клиент считает, через сколько + # диалог вернётся всем. + "assignmentTimeoutMinutes": policy_for( + request.tenant_context.organization + ).assignment_timeout_minutes, "mine": base.filter(assigned_operator_id=request.user.id).count(), "ungrouped": ungrouped, "groups": groups, @@ -301,6 +314,22 @@ class ConversationDirectoryView(APIView): # Ответственного можно назначить и вне выдачи — по поиску, поэтому # оставшихся не прячем молча, а сообщаем признаком hasMore. rows = list(members[: DIRECTORY_LIMIT + 1]) + shown = rows[:DIRECTORY_LIMIT] + # Присутствие и загрузка — второй и третий признак при выборе + # ответственного (макет «Очередь и уведомления», кадр Q5). Назначить + # отсутствующего можно: признак приблизительный и ничего не запрещает. + user_ids = [member.user_id for member in shown] + seen = last_seen(organization_id, user_ids) + now = timezone.now() + load = dict( + Conversation.objects.filter( + organization_id=organization_id, + lifecycle=LifecycleState.OPEN, + assigned_operator_id__in=user_ids, + ) + .values_list("assigned_operator_id") + .annotate(total=Count("id")) + ) return Response( { "groups": [{"id": group.id, "name": group.name, "color": group.color} for group in groups], @@ -309,8 +338,15 @@ class ConversationDirectoryView(APIView): "id": member.user_id, "name": member.user.full_name or member.user.email, "avatarUrl": user_avatar_url(member.user, request.tenant_context.organization.public_id), + "role": member.role, + "online": member.user_id in seen + and (now - seen[member.user_id]).total_seconds() <= ONLINE_WITHIN_SECONDS, + "lastSeenAt": ( + seen[member.user_id].isoformat() if member.user_id in seen else None + ), + "openDialogs": load.get(member.user_id, 0), } - for member in rows[:DIRECTORY_LIMIT] + for member in shown ], "hasMoreEmployees": len(rows) > DIRECTORY_LIMIT, } diff --git a/apps/backend/chatballs/conversations/consumers.py b/apps/backend/chatballs/conversations/consumers.py index 648a2cf..27d578e 100644 --- a/apps/backend/chatballs/conversations/consumers.py +++ b/apps/backend/chatballs/conversations/consumers.py @@ -1,4 +1,9 @@ -"""WebSocket оповещений о диалогах (см. chatballs.conversations.realtime). +"""WebSocket оповещений рабочего места (см. chatballs.conversations.realtime и +chatballs.notifications.realtime). + +Сокет один на сессию: по нему идут и события диалогов, и события уведомлений. +Второй сокет ради второго источника означал бы второе переподключение, вторую +аутентификацию и вторую точку отказа на ровном месте. Правила: - аутентификация — сессией того же SPA (AuthMiddlewareStack), отдельного токена @@ -23,6 +28,8 @@ from chatballs.conversations.models import Conversation from chatballs.conversations.realtime import conversation_group, inbox_group from chatballs.conversations.selectors import conversation_is_visible from chatballs.identity.models import OrganizationMembership +from chatballs.notifications.realtime import user_group +from chatballs.presence import touch from chatballs.tenancy.database import tenant_atomic from chatballs.tenancy.lookup import organization_by_public_id @@ -36,6 +43,8 @@ class ConversationEventsConsumer(AsyncJsonWebsocketConsumer): self.organization_id: int | None = None self.membership_id: int | None = None self.watched: str | None = None + self.personal: str | None = None + self.user_id: int | None = None user = self.scope.get("user") if user is None or not user.is_authenticated: await self.close(code=NOT_A_MEMBER_CLOSE) @@ -46,20 +55,42 @@ class ConversationEventsConsumer(AsyncJsonWebsocketConsumer): await self.close(code=NOT_A_MEMBER_CLOSE) return self.organization_id, self.membership_id = resolved + self.user_id = user.id + # Открытый сокет и есть присутствие: ничего специально «включать» для + # этого сотрудник не должен (chatballs.presence). + await self._touch_presence() await self.channel_layer.group_add(inbox_group(self.organization_id), self.channel_name) + # Уведомления адресованы человеку, а не организации: у каждого своя группа. + self.personal = user_group(user.id) + await self.channel_layer.group_add(self.personal, self.channel_name) await self.accept() async def disconnect(self, code: int) -> None: + if self.organization_id is not None and self.user_id is not None: + # Не «его нет», а «здесь он был в последний раз». + await database_sync_to_async(touch)(self.organization_id, self.user_id) if self.organization_id is not None: await self.channel_layer.group_discard( inbox_group(self.organization_id), self.channel_name ) + if self.personal is not None: + await self.channel_layer.group_discard(self.personal, self.channel_name) if self.watched is not None: await self.channel_layer.group_discard(self.watched, self.channel_name) async def receive_json(self, content: dict, **kwargs) -> None: - """Клиент сообщает, какой диалог открыт: событий по нему он и ждёт.""" - if content.get("type") != "watch" or self.organization_id is None: + """Клиент сообщает, какой диалог открыт: событий по нему он и ждёт. + + Он же раз в минуту присылает heartbeat — по нему продлевается отметка + присутствия. Без неё ключ истекает сам, и оборванное соединение + перестаёт считаться живым без отдельного уборщика. + """ + if self.organization_id is None: + return + if content.get("type") == "ping": + await self._touch_presence() + return + if content.get("type") != "watch": return conversation_id = content.get("conversationId") if self.watched is not None: @@ -80,6 +111,11 @@ class ConversationEventsConsumer(AsyncJsonWebsocketConsumer): async def fanout(self, event: dict) -> None: await self.send_json(event["payload"]) + async def _touch_presence(self) -> None: + if self.organization_id is None or self.user_id is None: + return + await database_sync_to_async(touch)(self.organization_id, self.user_id) + @database_sync_to_async def _membership(self, user_id: int, raw_public_id: str) -> tuple[int, int] | None: try: diff --git a/apps/backend/chatballs/conversations/escalation.py b/apps/backend/chatballs/conversations/escalation.py new file mode 100644 index 0000000..2eb01ee --- /dev/null +++ b/apps/backend/chatballs/conversations/escalation.py @@ -0,0 +1,184 @@ +"""Что происходит, когда диалог ждёт слишком долго. + +Раньше не происходило ничего. Про ждущий диалог операторов окликали ровно один +раз, и дальше `dedup_key` на сутки гарантировал тишину: если в тот момент никто +не смотрел на экран, диалог мог провисеть до автозакрытия, и узнать об этом было +неоткуда. + +Уровень выбирается по времени ожидания, а не по счётчику попыток: состояние +хранить не нужно, потому что повтор гасит тот же `dedup_key` — свой у каждого +уровня. Свип идеемпотентен и может выполняться сколь угодно часто. + +Назначенный диалог не эскалируется: он не в общей очереди, а в личной, и у неё +свой срок — не взял, значит возвращаем всем. + +Присутствие сокращает ожидание, но не заменяет его. Если в группе диалога сейчас +никого нет за рабочим местом, ждать второго порога бессмысленно: напоминать +некому, и круг расширяется сразу. Обратного правила нет — присутствие никого не +задерживает и ничего не запрещает, потому что ошибается в обе стороны. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta + +from django.utils import timezone + +from chatballs.conversations.models import ( + ControlMode, + Conversation, + LifecycleState, + Message, + MessageAuthor, + SystemEvent, +) +from chatballs.conversations.queue_models import QueueEscalationPolicy, policy_for +from chatballs.conversations.services import operator_label +from chatballs.i18n import t +from chatballs.notifications.models import ( + NotificationAudience, + NotificationLevel, + NotificationType, +) +from chatballs.notifications.recipients import audience_user_ids +from chatballs.notifications.services import notify, notify_management +from chatballs.presence import online_user_ids +from chatballs.tenancy.context import TenantContext + +logger = logging.getLogger(__name__) + + +def _contact_name(conversation: Conversation) -> str: + return getattr(conversation.contact, "name", "") or t("conversations.guest") + + +def _waiting_conversations(context: TenantContext): + return Conversation.objects.filter( + organization=context.organization, + lifecycle=LifecycleState.OPEN, + control_mode=ControlMode.PAUSED, + waiting_since__isnull=False, + ).select_related("contact", "assigned_operator", "group") + + +def sweep_waiting_conversations(context: TenantContext) -> int: + """Оклики по ждущим диалогам организации. Возвращает число новых уведомлений.""" + policy = policy_for(context.organization) + now = timezone.now() + fired = 0 + for conversation in _waiting_conversations(context): + if conversation.assigned_operator_id: + fired += _expire_stale_assignment(context, conversation, policy, now) + else: + fired += _escalate(context, conversation, policy, now) + return fired + + +def _nobody_is_watching(context: TenantContext, conversation: Conversation) -> bool: + """В группе диалога никого нет за рабочим местом.""" + watchers = audience_user_ids( + organization_id=context.organization_id, + audience=NotificationAudience.OPERATORS, + group_id=conversation.group_id, + ) + return not online_user_ids(context.organization_id, watchers) + + +def _escalate( + context: TenantContext, + conversation: Conversation, + policy: QueueEscalationPolicy, + now: datetime, +) -> int: + waited = now - conversation.waiting_since + contact = _contact_name(conversation) + common = { + "context": context, + "type": NotificationType.DIALOG_WAITING_LONG, + "text_params": {"contact": contact}, + "target_id": conversation.id, + "source_type": "Conversation", + "source_id": conversation.id, + } + fired = 0 + if waited >= timedelta(minutes=policy.remind_after_minutes): + # Тот же круг, что и в первый раз: смена на месте, просто не заметила. + fired += notify( + audience=NotificationAudience.OPERATORS, + audience_group=conversation.group, + level=NotificationLevel.WARNING, + title=f"Диалог всё ещё ждёт · {contact}", + title_key="notifications.still_waiting", + body_key="notifications.still_waiting_body", + dedup_key=f"waiting:{conversation.id}:remind", + **common, + ) is not None + widen_after = policy.widen_after_minutes + if waited >= timedelta(minutes=policy.remind_after_minutes) and _nobody_is_watching( + context, conversation + ): + # Некому заметить напоминание — второй порог ждать незачем. + widen_after = min(widen_after, policy.remind_after_minutes) + if waited >= timedelta(minutes=widen_after): + # Круг шире группы: в своей группе ответить некому. + fired += notify( + audience=NotificationAudience.OPERATORS, + audience_group=None, + level=NotificationLevel.WARNING, + title=f"Диалог всё ещё ждёт · {contact}", + title_key="notifications.still_waiting", + body_key="notifications.still_waiting_body", + dedup_key=f"waiting:{conversation.id}:widen", + **common, + ) is not None + if waited >= timedelta(minutes=policy.escalate_after_minutes): + # Это уже не про сменщика, а про то, что смены нет. + fired += notify_management( + level=NotificationLevel.CRITICAL, + title=f"Диалог никто не берёт · {contact}", + title_key="notifications.waiting_unattended", + body_key="notifications.waiting_unattended_body", + dedup_key=f"waiting:{conversation.id}:management", + **common, + ) + return fired + + +def _expire_stale_assignment( + context: TenantContext, + conversation: Conversation, + policy: QueueEscalationPolicy, + now: datetime, +) -> int: + if conversation.assigned_at is None: + return 0 + if now - conversation.assigned_at < timedelta(minutes=policy.assignment_timeout_minutes): + return 0 + label = operator_label(conversation.assigned_operator) + conversation.assigned_operator = None + conversation.assigned_at = None + conversation.save(update_fields=["assigned_operator", "assigned_at"]) + Message.objects.create( + conversation=conversation, + author_type=MessageAuthor.SYSTEM, + system_event=SystemEvent.ASSIGNMENT_EXPIRED, + system_params={"operator": label}, + text=f"{label} не взял диалог — он вернулся в очередь", + ) + logger.info("Assignment on conversation %s expired", conversation.id) + contact = _contact_name(conversation) + return notify( + context=context, + type=NotificationType.OPERATOR_REQUESTED, + audience=NotificationAudience.OPERATORS, + audience_group=conversation.group, + level=NotificationLevel.WARNING, + title=f"Диалог снова ничей · {contact}", + title_key="notifications.assignment_expired", + text_params={"contact": contact, "operator": label}, + target_id=conversation.id, + source_type="Conversation", + source_id=conversation.id, + dedup_key=f"unassigned:{conversation.id}:{conversation.waiting_since.isoformat()}", + ) is not None diff --git a/apps/backend/chatballs/conversations/ingest.py b/apps/backend/chatballs/conversations/ingest.py index 424e10b..997c05d 100644 --- a/apps/backend/chatballs/conversations/ingest.py +++ b/apps/backend/chatballs/conversations/ingest.py @@ -30,6 +30,7 @@ from chatballs.conversations.models import ( SystemEvent, TranscriptStatus, ) +from chatballs.conversations.queue import QUEUE_FIELDS, enter_queue, is_waiting from chatballs.conversations.transports.base import InboundMessage from chatballs.events.models import EventOwnership, InboxEvent from chatballs.i18n import t @@ -249,6 +250,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: external_chat_id=inbound.chat_id, control_mode=ControlMode.AI if ai_available else ControlMode.PAUSED, expected_responder=ExpectedResponder.AI if ai_available else ExpectedResponder.OPERATOR, + waiting_since=None if ai_available else timezone.now(), previous_conversation=previous, ) elif inbound.chat_id and not conversation.external_chat_id: @@ -282,9 +284,8 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: conversation.last_activity_at = timezone.now() update_fields = ["external_chat_id", "last_activity_at"] if conversation.control_mode == ControlMode.AI and not ai_available: - conversation.control_mode = ControlMode.PAUSED - conversation.expected_responder = ExpectedResponder.OPERATOR - update_fields.extend(["control_mode", "expected_responder"]) + enter_queue(conversation) + update_fields.extend(QUEUE_FIELDS) if inbound.thread_meta: # Email: Message-ID последнего входящего — для ответа в тред; # тема диалога фиксируется по первому письму (ADR-CHATBALLS-0035). @@ -298,10 +299,18 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: conversation.save(update_fields=update_fields) if is_new: + # Диалог, которым занялся агент, — это «новый диалог» и больше ничего. + # Диалог, отвечать в котором некому, — уже просьба о человеке: событие + # одно, а смысл для смены разный, и подписки на них тоже разные. notify( context=context, - type=NotificationType.DIALOG_WAITING, + type=( + NotificationType.OPERATOR_REQUESTED + if is_waiting(conversation) + else NotificationType.NEW_DIALOG + ), audience=NotificationAudience.OPERATORS, + audience_group=conversation.group, title=f"Новый диалог · {channel.name}", body=f"{contact.name or 'Гость'} · {integration.provider}: {message_text[:80]}", title_key="notifications.new_dialog", @@ -324,6 +333,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: context=context, type=NotificationType.DIALOG_NEW_MESSAGE, audience=NotificationAudience.USER if operator else NotificationAudience.OPERATORS, + audience_group=conversation.group, recipient_user=operator, title=f"Новое сообщение · {contact.name or 'Гость'}", body=message_text[:120], @@ -353,15 +363,15 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: ai_input = transcribe_voice_message(channel, message) if (is_voice and not ai_input) or files_only: if conversation.control_mode == ControlMode.AI: - conversation.control_mode = ControlMode.PAUSED - conversation.expected_responder = ExpectedResponder.OPERATOR - conversation.save(update_fields=["control_mode", "expected_responder"]) + enter_queue(conversation) + conversation.save(update_fields=QUEUE_FIELDS) if is_new: return notify( context=context, - type=NotificationType.DIALOG_WAITING, + type=NotificationType.OPERATOR_REQUESTED, audience=NotificationAudience.OPERATORS, + audience_group=conversation.group, title=f"Нужен оператор · {contact.name or 'Гость'}", title_key="notifications.operator_needed", text_params={"contact": contact.name or t("conversations.guest")}, @@ -385,10 +395,9 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: # Сбой AI не должен «терять» сообщение: переводим диалог в очередь к # оператору, уведомляем и отвечаем клиенту понятным fallback. logger.warning("AI turn failed for conversation %s: %s", conversation.id, error) - conversation.control_mode = ControlMode.PAUSED - conversation.expected_responder = ExpectedResponder.OPERATOR + enter_queue(conversation) conversation.last_activity_at = timezone.now() - conversation.save(update_fields=["control_mode", "expected_responder", "last_activity_at"]) + conversation.save(update_fields=[*QUEUE_FIELDS, "last_activity_at"]) Message.objects.create( conversation=conversation, author_type=MessageAuthor.SYSTEM, @@ -399,8 +408,9 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: Message.objects.create(conversation=conversation, author_type=MessageAuthor.AI, text=fallback) notify( context=context, - type=NotificationType.DIALOG_WAITING, + type=NotificationType.OPERATOR_REQUESTED, audience=NotificationAudience.OPERATORS, + audience_group=conversation.group, title=f"Нужен оператор · {contact.name or 'Гость'}", title_key="notifications.operator_needed", text_params={"contact": contact.name or t("conversations.guest")}, @@ -413,7 +423,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: ) notify_management( context=context, - type=NotificationType.INTEGRATION_ERROR, + type=NotificationType.AI_STOPPED, title=f"Ошибка AI · {channel.name}", body="AI временно недоступен, диалог передан оператору", title_key="notifications.ai_error", @@ -435,11 +445,10 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: Message.objects.create(conversation=conversation, author_type=MessageAuthor.AI, text=reply) conversation.last_activity_at = timezone.now() if handoff: - conversation.control_mode = ControlMode.PAUSED - conversation.expected_responder = ExpectedResponder.OPERATOR + enter_queue(conversation) else: conversation.expected_responder = ExpectedResponder.CUSTOMER - conversation.save(update_fields=["control_mode", "last_activity_at", "expected_responder"]) + conversation.save(update_fields=[*QUEUE_FIELDS, "last_activity_at"]) if handoff: Message.objects.create( @@ -450,8 +459,9 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: ) notify( context=context, - type=NotificationType.DIALOG_WAITING, + type=NotificationType.OPERATOR_REQUESTED, audience=NotificationAudience.OPERATORS, + audience_group=conversation.group, title=f"AI передал диалог · {contact.name or 'Гость'}", title_key="notifications.ai_handed_over", text_params={"contact": contact.name or t("conversations.guest")}, diff --git a/apps/backend/chatballs/conversations/migrations/0022_conversation_waiting_since.py b/apps/backend/chatballs/conversations/migrations/0022_conversation_waiting_since.py new file mode 100644 index 0000000..05e6ae1 --- /dev/null +++ b/apps/backend/chatballs/conversations/migrations/0022_conversation_waiting_since.py @@ -0,0 +1,40 @@ +"""Время ожидания в очереди — отдельным полем. + +Раньше «дольше всех ждущий» вычислялся по времени последнего сообщения, и +очередь работала обратно смыслу: клиент, напомнивший о себе, двигал +last_message_at вперёд и падал в конец очереди. Поле ставится один раз при входе +в очередь (chatballs.conversations.queue) и снимается при выходе из неё. + +Backfill берёт last_message_at — единственное, что известно про уже ждущие +диалоги. Для них порядок не ухудшится: в старой сортировке ключ был тот же. +""" + +from django.db import migrations, models + +BACKFILL = """ +UPDATE conversations_conversation +SET waiting_since = last_message_at +WHERE lifecycle = 'OPEN' AND control_mode = 'PAUSED' AND waiting_since IS NULL +""" + + +class Migration(migrations.Migration): + + dependencies = [ + ('conversations', '0021_i18n_events'), + ] + + operations = [ + migrations.AddField( + model_name='conversation', + name='waiting_since', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.RunSQL(sql=BACKFILL, reverse_sql=migrations.RunSQL.noop), + migrations.AddIndex( + model_name='conversation', + index=models.Index( + fields=['organization', 'waiting_since'], name='conv_waiting_order' + ), + ), + ] diff --git a/apps/backend/chatballs/conversations/migrations/0023_queue_escalation.py b/apps/backend/chatballs/conversations/migrations/0023_queue_escalation.py new file mode 100644 index 0000000..7af0d3a --- /dev/null +++ b/apps/backend/chatballs/conversations/migrations/0023_queue_escalation.py @@ -0,0 +1,46 @@ +"""Личная очередь и пороги эскалации. + +assigned_at нужен, чтобы у назначения был срок: waiting_since для этого не +годится — назначить могут и через час после того, как диалог встал в очередь. +Пороги — строка на организацию с дефолтами: «долго» у круглосуточной +поддержки и у приёма по будням означает разное. +""" + + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('conversations', '0022_conversation_waiting_since'), + ('identity', '0039_remove_organization_currency'), + ] + + operations = [ + migrations.AddField( + model_name='conversation', + name='assigned_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='message', + name='system_event', + field=models.CharField(blank=True, choices=[('operator_took', 'Оператор перехватил диалог'), ('returned_to_ai', 'Диалог возвращён AI'), ('returned_to_queue', 'Диалог возвращён в очередь'), ('ai_unavailable', 'AI недоступен'), ('ai_handed_over', 'AI передал диалог оператору'), ('assigned_to', 'Диалог назначен сотруднику'), ('assignment_expired', 'Назначение истекло'), ('call_requested', 'Запрошен звонок'), ('call_accepted', 'Клиент принял приглашение'), ('call_declined', 'Клиент отклонил приглашение'), ('call_cancelled', 'Приглашение отменено'), ('call_missed', 'Звонок пропущен'), ('call_expired', 'Приглашение истекло'), ('call_started', 'Звонок начался'), ('call_ended', 'Звонок завершён'), ('call_failed', 'Звонок не состоялся')], default='', max_length=32), + ), + migrations.CreateModel( + name='QueueEscalationPolicy', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('remind_after_minutes', models.PositiveIntegerField(default=5)), + ('widen_after_minutes', models.PositiveIntegerField(default=15)), + ('escalate_after_minutes', models.PositiveIntegerField(default=30)), + ('assignment_timeout_minutes', models.PositiveIntegerField(default=10)), + ('organization', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='queue_policy', to='identity.organization')), + ], + options={ + 'db_table': 'conversations_queueescalationpolicy', + }, + ), + ] diff --git a/apps/backend/chatballs/conversations/migrations/0024_queue_policy_author.py b/apps/backend/chatballs/conversations/migrations/0024_queue_policy_author.py new file mode 100644 index 0000000..48889bc --- /dev/null +++ b/apps/backend/chatballs/conversations/migrations/0024_queue_policy_author.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.16 on 2026-09-13 19:15 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('conversations', '0023_queue_escalation'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='queueescalationpolicy', + name='updated_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='queueescalationpolicy', + name='updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/apps/backend/chatballs/conversations/models.py b/apps/backend/chatballs/conversations/models.py index 6478cdc..5f21e2f 100644 --- a/apps/backend/chatballs/conversations/models.py +++ b/apps/backend/chatballs/conversations/models.py @@ -166,6 +166,10 @@ class Conversation(models.Model): lifecycle = models.CharField(max_length=16, choices=LifecycleState.choices, default=LifecycleState.OPEN) control_mode = models.CharField(max_length=16, choices=ControlMode.choices, default=ControlMode.AI) expected_responder = models.CharField(max_length=16, choices=ExpectedResponder.choices, default=ExpectedResponder.AI) + # С какого момента диалог ждёт человека (chatballs.conversations.queue). + # Не «последнее сообщение»: клиент, написавший повторно, ждёт не меньше, а + # больше прежнего, и в очереди обязан оставаться выше, а не ниже. + waiting_since = models.DateTimeField(null=True, blank=True) # Группа видимости (ADR-CHATBALLS-0043): наследуется от group агента/канала при # создании, переносится вручную. NULL — диалог виден всем сотрудникам. group = models.ForeignKey( @@ -177,6 +181,9 @@ class Conversation(models.Model): ) # «Ответственный» (ADR-CHATBALLS-0043): видит диалог независимо от групп. assigned_operator = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_conversations") + # Когда назначили. С этого момента идёт срок личной очереди: не взял — + # диалог возвращается в общую (chatballs.conversations.escalation). + assigned_at = models.DateTimeField(null=True, blank=True) # Дизайн-базлайн v2: приоритет, метки и заметка оператора. priority = models.CharField( max_length=8, choices=ConversationPriority.choices, default=ConversationPriority.NONE @@ -219,6 +226,10 @@ class Conversation(models.Model): models.Index( fields=["contact", "-last_activity_at"], name="conv_contact_recent" ), + # Очередь к оператору: кто ждёт дольше всех и не дождался порога. + models.Index( + fields=["organization", "waiting_since"], name="conv_waiting_order" + ), ] constraints = [ # Диалог всегда принадлежит контакту. @@ -274,6 +285,8 @@ class SystemEvent(models.TextChoices): RETURNED_TO_QUEUE = "returned_to_queue", "Диалог возвращён в очередь" AI_UNAVAILABLE = "ai_unavailable", "AI недоступен" AI_HANDED_OVER = "ai_handed_over", "AI передал диалог оператору" + ASSIGNED_TO = "assigned_to", "Диалог назначен сотруднику" + ASSIGNMENT_EXPIRED = "assignment_expired", "Назначение истекло" CALL_REQUESTED = "call_requested", "Запрошен звонок" CALL_ACCEPTED = "call_accepted", "Клиент принял приглашение" CALL_DECLINED = "call_declined", "Клиент отклонил приглашение" @@ -400,3 +413,11 @@ class ReplyTemplate(models.Model): def __str__(self) -> str: return f"template:{self.organization_id}/{self.title}" + + +# Django импортирует только models.py: пороги очереди лежат рядом, чтобы не +# растить этот файл, и переэкспортируются здесь ради регистрации модели. +from chatballs.conversations.queue_models import ( # noqa: E402, F401 + QueueEscalationPolicy, + policy_for, +) diff --git a/apps/backend/chatballs/conversations/queue.py b/apps/backend/chatballs/conversations/queue.py new file mode 100644 index 0000000..d2478c3 --- /dev/null +++ b/apps/backend/chatballs/conversations/queue.py @@ -0,0 +1,69 @@ +"""Очередь к оператору: единственное место, где диалог в неё входит и выходит. + +Правило «диалог ждёт человека» — это три поля сразу: control_mode = PAUSED, +expected_responder = OPERATOR и момент, с которого пошло ожидание. Раньше первые +два выставлялись в шести местах подряд (создание диалога без доступного AI, +клиент написал в диалог без AI, голосовое без расшифровки, сбой провайдера, +хендофф агента, ручной возврат оператором), а третьего не было вовсе: «дольше +всех ждущий» считался по времени последнего сообщения. + +Из-за этого очередь вела себя обратно смыслу. Клиент, который писал повторно, +двигал last_message_at вперёд и падал в конец очереди: чем настойчивее человек, +тем позже до него доходили руки. Поэтому waiting_since ставится один раз — при +входе в очередь — и не обновляется, пока диалог из неё не вышел. +""" + +from __future__ import annotations + +from datetime import datetime + +from django.utils import timezone + +from chatballs.conversations.models import ( + ControlMode, + Conversation, + ExpectedResponder, + LifecycleState, +) + +# Что пишет enter_queue. Вызывающий кладёт это в update_fields своего save(): +# состояние диалога меняется вместе с остальными полями, одной записью. +QUEUE_FIELDS = ("control_mode", "expected_responder", "waiting_since") + + +def is_waiting(conversation: Conversation) -> bool: + """Диалог стоит в очереди к человеку. + + Закрытый и спам тоже лежат в PAUSED, но никого не ждут — отсюда проверка + жизненного цикла. + """ + return ( + conversation.lifecycle == LifecycleState.OPEN + and conversation.control_mode == ControlMode.PAUSED + ) + + +def enter_queue(conversation: Conversation, *, now: datetime | None = None) -> bool: + """Ставит диалог в очередь. True — если он в неё только что попал. + + Возврат нужен вызывающему, чтобы решить, звать ли операторов: повторное + сообщение клиента в уже ждущий диалог очередь не меняет и второго оклика не + заслуживает. + """ + entered = not is_waiting(conversation) + conversation.control_mode = ControlMode.PAUSED + conversation.expected_responder = ExpectedResponder.OPERATOR + if entered: + conversation.waiting_since = now or timezone.now() + return entered + + +def leave_queue(conversation: Conversation) -> None: + """Диалог больше никого не ждёт. + + Несимметрично enter_queue намеренно: вход в очередь — одно состояние, а + выходов несколько (оператор взял, диалог вернули AI, закрыли, пометили + спамом), и control_mode у каждого свой. Общее у них только одно — ожидание + закончилось, и его начало больше не имеет смысла. + """ + conversation.waiting_since = None diff --git a/apps/backend/chatballs/conversations/queue_models.py b/apps/backend/chatballs/conversations/queue_models.py new file mode 100644 index 0000000..82e891f --- /dev/null +++ b/apps/backend/chatballs/conversations/queue_models.py @@ -0,0 +1,53 @@ +"""Пороги очереди: через сколько напоминать, расширять круг и звать руководство. + +Числа разные у разных организаций — у круглосуточной поддержки хостинга и у +клиники с приёмом по будням «долго» означает не одно и то же, — поэтому они +настройка, а не константа в коде. Строка одна на организацию и заводится с +дефолтами при первом обращении. +""" + +from __future__ import annotations + +from django.conf import settings +from django.db import models + + +class QueueEscalationPolicy(models.Model): + organization = models.OneToOneField( + "identity.Organization", on_delete=models.CASCADE, related_name="queue_policy" + ) + # Диалог ждёт дольше этого — повторный оклик той же группе. + remind_after_minutes = models.PositiveIntegerField(default=5) + # Ждёт ещё дольше — круг расширяется за пределы группы диалога. + widen_after_minutes = models.PositiveIntegerField(default=15) + # Совсем долго — это уже не про сменщика, а про руководство. + escalate_after_minutes = models.PositiveIntegerField(default=30) + # Назначили ответственного, а он не взял — диалог возвращается в общую + # очередь. Без этого назначение работает как способ спрятать диалог: из + # общей очереди он ушёл, а отвечать некому. + assignment_timeout_minutes = models.PositiveIntegerField(default=10) + # Кто и когда менял: в разделе настроек это подпись под формой. Сроки — + # правило работы смены, и знать, чьё это решение, важнее, чем кажется. + updated_at = models.DateTimeField(null=True, blank=True) + updated_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+" + ) + + # Значения по умолчанию — они же «обычные сроки» в кнопке сброса. + DEFAULTS = { + "remind_after_minutes": 5, + "widen_after_minutes": 15, + "escalate_after_minutes": 30, + "assignment_timeout_minutes": 10, + } + + class Meta: + db_table = "conversations_queueescalationpolicy" + + def __str__(self) -> str: + return f"queue-policy:{self.organization_id}" + + +def policy_for(organization) -> QueueEscalationPolicy: + policy, _ = QueueEscalationPolicy.objects.get_or_create(organization=organization) + return policy diff --git a/apps/backend/chatballs/conversations/queue_policy_views.py b/apps/backend/chatballs/conversations/queue_policy_views.py new file mode 100644 index 0000000..c9dcf5e --- /dev/null +++ b/apps/backend/chatballs/conversations/queue_policy_views.py @@ -0,0 +1,62 @@ +"""Сроки очереди: раздел «Когда звать на помощь» (макет Q2). + +До этого пороги правились только в служебной админке — то есть де-факто никем. +Читает их тот, кто видит настройки; меняет — тот, кто ими управляет. +""" + +from __future__ import annotations + +from django.utils import timezone +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from chatballs.conversations.queue_models import QueueEscalationPolicy, policy_for +from chatballs.i18n import t +from chatballs.identity.policy import ResourceScope, authorize + +FIELDS = tuple(QueueEscalationPolicy.DEFAULTS) +# Сутки: всё, что дольше, — это не «позвать на помощь», а другая задача. +MAX_MINUTES = 24 * 60 + + +def _payload(policy: QueueEscalationPolicy) -> dict: + author = policy.updated_by + return { + **{field: getattr(policy, field) for field in FIELDS}, + "defaults": dict(QueueEscalationPolicy.DEFAULTS), + "updatedAt": policy.updated_at.isoformat() if policy.updated_at else None, + "updatedBy": (author.full_name or author.email) if author else "", + } + + +class QueuePolicyView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request: Request) -> Response: + context = request.tenant_context + if not authorize(context.membership, "settings.view", ResourceScope(context.organization_id)): + return Response({"detail": t("settings.queue_policy_forbidden")}, status=403) + return Response(_payload(policy_for(context.organization))) + + def patch(self, request: Request) -> Response: + context = request.tenant_context + if not authorize(context.membership, "settings.manage", ResourceScope(context.organization_id)): + return Response({"detail": t("settings.queue_policy_forbidden")}, status=403) + policy = policy_for(context.organization) + changed = [] + for field in FIELDS: + if field not in request.data: + continue + value = request.data[field] + if not isinstance(value, int) or isinstance(value, bool) or not 1 <= value <= MAX_MINUTES: + return Response({"detail": t("settings.queue_minutes_range")}, status=400) + if getattr(policy, field) != value: + setattr(policy, field, value) + changed.append(field) + if changed: + policy.updated_at = timezone.now() + policy.updated_by = context.actor_user + policy.save(update_fields=[*changed, "updated_at", "updated_by"]) + return Response(_payload(policy)) diff --git a/apps/backend/chatballs/conversations/realtime.py b/apps/backend/chatballs/conversations/realtime.py index f0b6c2d..978dfbc 100644 --- a/apps/backend/chatballs/conversations/realtime.py +++ b/apps/backend/chatballs/conversations/realtime.py @@ -13,12 +13,7 @@ from __future__ import annotations -import logging - -from asgiref.sync import async_to_sync -from channels.layers import get_channel_layer - -logger = logging.getLogger(__name__) +from chatballs.realtime import publish INBOX_EVENT = "inbox.changed" CONVERSATION_EVENT = "conversation.changed" @@ -32,27 +27,12 @@ def conversation_group(conversation_id: int) -> str: return f"conv.{conversation_id}" -def _publish(group: str, payload: dict[str, object]) -> None: - """Оповещение — вспомогательный путь: его сбой не должен ронять запись. - - Сообщение уже сохранено к моменту отправки; если канал недоступен, клиент - узнает об изменении следующим опросом — он остаётся как запасной путь. - """ - layer = get_channel_layer() - if layer is None: - return - try: - async_to_sync(layer.group_send)(group, {"type": "fanout", "payload": payload}) - except Exception: # noqa: BLE001 — канал не должен ломать сохранение - logger.warning("realtime fanout failed for %s", group, exc_info=True) - - def notify_inbox_changed(organization_id: int) -> None: - _publish(inbox_group(organization_id), {"type": INBOX_EVENT}) + publish(inbox_group(organization_id), {"type": INBOX_EVENT}) def notify_conversation_changed(conversation_id: int, *, organization_id: int) -> None: - _publish( + publish( conversation_group(conversation_id), {"type": CONVERSATION_EVENT, "conversationId": conversation_id}, ) diff --git a/apps/backend/chatballs/conversations/selectors.py b/apps/backend/chatballs/conversations/selectors.py index b078ef5..4d9c062 100644 --- a/apps/backend/chatballs/conversations/selectors.py +++ b/apps/backend/chatballs/conversations/selectors.py @@ -10,6 +10,7 @@ from django.db.models import ( Value, When, ) +from django.db.models.functions import Coalesce from chatballs.api.pagination import SortKey from chatballs.conversations.models import ( @@ -63,6 +64,11 @@ def order_conversations( Сортировка живёт на сервере вместе с окном: клиент видит не весь набор, и переставлять в браузере ему нечего. `waiting` — «ждущие человека первыми, дольше всех ждущий выше», остальные — по убыванию активности. + + «Дольше всех ждущий» считается от waiting_since — момента, когда диалог + встал в очередь. По времени последнего сообщения его считать нельзя: клиент, + написавший повторно, двигал бы себя в конец очереди, и чем настойчивее он + напоминал о себе, тем позже до него доходила очередь. """ if sort != "waiting": return queryset.order_by("-last_message_at", "-id"), ACTIVITY_KEYS @@ -72,7 +78,9 @@ def order_conversations( When(waits, then=Value(0)), default=Value(1), output_field=IntegerField() ), _wait_at=Case( - When(waits, then=F("last_message_at")), + # Coalesce — страховка для строк, встававших в очередь до появления + # waiting_since: без неё они ушли бы в конец очереди вместо начала. + When(waits, then=Coalesce(F("waiting_since"), F("last_message_at"))), default=Value(_NOT_WAITING_AT), output_field=DateTimeField(), ), diff --git a/apps/backend/chatballs/conversations/serializers.py b/apps/backend/chatballs/conversations/serializers.py index 8d54a20..afc3776 100644 --- a/apps/backend/chatballs/conversations/serializers.py +++ b/apps/backend/chatballs/conversations/serializers.py @@ -257,6 +257,17 @@ def conversation_payload( "isAssignedToViewer": bool( viewer_id and conversation.assigned_operator_id == viewer_id ), + # Ожидание (макет «Очередь и уведомления», кадры Q3 и Q4): с какого + # момента диалог ждёт человека и с какого — конкретного человека. + # Считать «сколько осталось» клиент должен сам: минута на сервере и + # минута на экране расходятся, и показывать замерший счётчик хуже, чем + # не показывать никакого. + "waitingSince": ( + conversation.waiting_since.isoformat() if conversation.waiting_since else None + ), + "assignedAt": ( + conversation.assigned_at.isoformat() if conversation.assigned_at else None + ), "group": ( {"id": conversation.group_id, "name": conversation.group.name, "color": conversation.group.color} if conversation.group_id diff --git a/apps/backend/chatballs/conversations/services.py b/apps/backend/chatballs/conversations/services.py index 7276888..47b2789 100644 --- a/apps/backend/chatballs/conversations/services.py +++ b/apps/backend/chatballs/conversations/services.py @@ -13,9 +13,12 @@ from chatballs.conversations.models import ( MessageKind, SystemEvent, ) +from chatballs.conversations.queue import QUEUE_FIELDS, enter_queue, leave_queue from chatballs.i18n import customer_language, t from chatballs.identity.models import EmployeeRole from chatballs.integrations.models import IntegrationProvider +from chatballs.notifications.models import NotificationAudience, NotificationType +from chatballs.notifications.services import notify from chatballs.tenancy.context import TenantContext @@ -37,7 +40,7 @@ def _require_open(conversation: Conversation) -> None: -def _operator_label(operator) -> str: +def operator_label(operator) -> str: return getattr(operator, "full_name", "") or operator.email @@ -101,7 +104,9 @@ def claim_locked_conversation(*, context: TenantContext, conversation: Conversat conversation.expected_responder = ExpectedResponder.OPERATOR - conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"]) + leave_queue(conversation) + + conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder", "waiting_since"]) Message.objects.create( @@ -111,9 +116,9 @@ def claim_locked_conversation(*, context: TenantContext, conversation: Conversat system_event=SystemEvent.OPERATOR_TOOK, - system_params={"operator": _operator_label(operator)}, + system_params={"operator": operator_label(operator)}, - text=f"Оператор {_operator_label(operator)} перехватил диалог", + text=f"Оператор {operator_label(operator)} перехватил диалог", ) @@ -147,7 +152,9 @@ def release_to_ai(*, context: TenantContext, conversation_id: int) -> Conversati conversation.expected_responder = ExpectedResponder.AI - conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"]) + leave_queue(conversation) + + conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder", "waiting_since"]) Message.objects.create( conversation=conversation, @@ -176,13 +183,12 @@ def return_to_queue(*, context: TenantContext, conversation_id: int) -> Conversa _require_open(conversation) - conversation.control_mode = ControlMode.PAUSED + enter_queue(conversation) conversation.assigned_operator = None - conversation.expected_responder = ExpectedResponder.OPERATOR - conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"]) + conversation.save(update_fields=[*QUEUE_FIELDS, "assigned_operator"]) Message.objects.create( conversation=conversation, @@ -337,6 +343,8 @@ def close_conversation(*, context: TenantContext, conversation_id: int) -> Conve conversation.expected_responder = ExpectedResponder.NOBODY + leave_queue(conversation) + conversation.save( update_fields=[ @@ -348,6 +356,7 @@ def close_conversation(*, context: TenantContext, conversation_id: int) -> Conve "assigned_operator", "expected_responder", + "waiting_since", ] @@ -383,6 +392,8 @@ def mark_conversation_as_spam( conversation.expected_responder = ExpectedResponder.NOBODY + leave_queue(conversation) + conversation.save( update_fields=[ @@ -394,6 +405,7 @@ def mark_conversation_as_spam( "assigned_operator", "expected_responder", + "waiting_since", ] @@ -401,3 +413,53 @@ def mark_conversation_as_spam( return conversation + + +@transaction.atomic +def assign_operator(*, context: TenantContext, conversation_id: int, assignee) -> Conversation: + """Назначить ответственного за диалог (или снять назначение). + + Назначение — не взятие: человек ещё не ответил и мог даже не увидеть + диалог. Но из общей очереди диалог уходит — отвечать в нём, кроме + назначенного и руководства, уже никто не может, — поэтому назначенного надо + позвать лично и поставить срок. Не успел — диалог возвращается в общую + очередь (chatballs.conversations.escalation). + + Раньше эта операция молча писала внешний ключ: назначенный не узнавал, + диалог продолжал числиться в общей очереди, а взять его оттуда было уже + нельзя. + """ + conversation = Conversation.objects.select_for_update().get( + id=conversation_id, organization=context.organization + ) + _require_open(conversation) + conversation.assigned_operator = assignee + conversation.assigned_at = timezone.now() if assignee is not None else None + conversation.save(update_fields=["assigned_operator", "assigned_at"]) + if assignee is None: + return conversation + Message.objects.create( + conversation=conversation, + author_type=MessageAuthor.SYSTEM, + system_event=SystemEvent.ASSIGNED_TO, + system_params={"operator": operator_label(assignee)}, + text=f"Диалог назначен на {operator_label(assignee)}", + ) + # Себе назначил — сам и знает. + if context.actor_user is not None and assignee.pk == context.actor_user.pk: + return conversation + contact_name = getattr(conversation.contact, "name", "") or t("conversations.guest") + notify( + context=context, + type=NotificationType.DIALOG_ASSIGNED, + audience=NotificationAudience.USER, + recipient_user=assignee, + title=f"Вам назначен диалог · {contact_name}", + title_key="notifications.assigned_to_you", + text_params={"contact": contact_name}, + target_id=conversation.id, + source_type="Conversation", + source_id=conversation.id, + dedup_key=f"assign:{conversation.id}:{assignee.pk}", + ) + return conversation diff --git a/apps/backend/chatballs/conversations/test_directory.py b/apps/backend/chatballs/conversations/test_directory.py index b05d59c..bd60ef5 100644 --- a/apps/backend/chatballs/conversations/test_directory.py +++ b/apps/backend/chatballs/conversations/test_directory.py @@ -1,8 +1,15 @@ -"""Справочник выбора ответственного: ограниченная выдача и поиск на сервере.""" +"""Справочник выбора ответственного: ограниченная выдача, поиск и присутствие.""" +from datetime import timedelta +from unittest import mock + +from django.core.cache import cache from django.test import TestCase +from django.utils import timezone +from chatballs.channels.models import Channel from chatballs.conversations.chat_extras_views import DIRECTORY_LIMIT +from chatballs.conversations.models import Contact, Conversation, LifecycleState from chatballs.identity.bootstrap import bootstrap_owner from chatballs.identity.models import ( EmployeeRole, @@ -10,6 +17,7 @@ from chatballs.identity.models import ( Organization, OrganizationMembership, ) +from chatballs.presence import touch from chatballs.testing import TenantAPIClient as APIClient @@ -54,3 +62,65 @@ class ConversationDirectoryTests(TestCase): def test_groups_are_returned_as_before(self) -> None: self.assertIn("groups", self._directory()) + + +class DirectoryPresenceTests(TestCase): + """Присутствие и загрузка в выборе ответственного (макет Q5). + + Назначить отсутствующего можно — признак ничего не запрещает; он лишь + отвечает на вопрос «кто сейчас за рабочим местом». + """ + + def setUp(self) -> None: + cache.clear() + self.organization = Organization.objects.create(name="Example", slug="directory-presence") + self.owner = self._employee("owner@dir.test", EmployeeRole.OWNER) + self.away = self._employee("away@dir.test", EmployeeRole.EMPLOYEE) + self.client = APIClient() + self.client.force_authenticate(self.owner.user) + + def _employee(self, email: str, role: str) -> OrganizationMembership: + user = HumanUser.objects.create_user(email=email, password="Password-123") + return OrganizationMembership.objects.create( + user=user, organization=self.organization, role=role, position_title="Specialist" + ) + + def _rows(self) -> dict[int, dict]: + response = self.client.get("/api/v1/conversations/directory/") + self.assertEqual(response.status_code, 200) + return {row["id"]: row for row in response.json()["employees"]} + + def test_presence_is_reported_for_whoever_is_in_the_app(self) -> None: + touch(self.organization.id, self.owner.user_id) + rows = self._rows() + self.assertTrue(rows[self.owner.user_id]["online"]) + self.assertIsNotNone(rows[self.owner.user_id]["lastSeenAt"]) + self.assertFalse(rows[self.away.user_id]["online"]) + self.assertIsNone(rows[self.away.user_id]["lastSeenAt"]) + + def test_long_gone_employee_is_not_online_but_remembered(self) -> None: + with mock.patch( + "chatballs.presence.timezone.now", + return_value=timezone.now() - timedelta(minutes=25), + ): + touch(self.organization.id, self.away.user_id) + row = self._rows()[self.away.user_id] + self.assertFalse(row["online"]) + self.assertIsNotNone(row["lastSeenAt"]) + + def test_load_counts_only_open_dialogs_of_that_person(self) -> None: + channel = Channel.objects.create( + organization=self.organization, code="dir-presence", name="Канал" + ) + contact = Contact.objects.create(organization=self.organization, name="Клиент") + for lifecycle in (LifecycleState.OPEN, LifecycleState.OPEN, LifecycleState.CLOSED): + Conversation.objects.create( + organization=self.organization, + channel=channel, + contact=contact, + lifecycle=lifecycle, + assigned_operator=self.owner.user, + ) + rows = self._rows() + self.assertEqual(rows[self.owner.user_id]["openDialogs"], 2) + self.assertEqual(rows[self.away.user_id]["openDialogs"], 0) diff --git a/apps/backend/chatballs/conversations/test_queue_escalation.py b/apps/backend/chatballs/conversations/test_queue_escalation.py new file mode 100644 index 0000000..b0eca2f --- /dev/null +++ b/apps/backend/chatballs/conversations/test_queue_escalation.py @@ -0,0 +1,246 @@ +"""Эскалация ждущих диалогов и личная очередь назначенного. + +Регрессия, ради которой всё это заведено: про ждущий диалог операторов окликали +ровно один раз, и дальше суточный dedup гарантировал тишину. Назначение при этом +молча писало внешний ключ — назначенный не узнавал, а из общей очереди диалог +уже ушёл. +""" + +from datetime import timedelta + +from django.core.cache import cache +from django.test import TestCase +from django.utils import timezone + +from chatballs.channels.models import Channel +from chatballs.conversations.escalation import sweep_waiting_conversations +from chatballs.conversations.models import ( + Contact, + ControlMode, + Conversation, + ExpectedResponder, + LifecycleState, + SystemEvent, +) +from chatballs.conversations.queue_models import policy_for +from chatballs.conversations.services import assign_operator +from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember +from chatballs.identity.models import ( + EmployeeRole, + HumanUser, + Organization, + OrganizationMembership, +) +from chatballs.notifications.models import Notification, NotificationAudience +from chatballs.presence import touch +from chatballs.testing import tenant_context_for + + +class QueueTestBase(TestCase): + def setUp(self) -> None: + self.organization = Organization.objects.create(name="Example", slug="queue-escalation") + self.support = EmployeeGroup.objects.create( + organization=self.organization, name="Поддержка" + ) + self.owner = self._employee("owner@queue.test", EmployeeRole.OWNER) + self.operator = self._employee("operator@queue.test", EmployeeRole.EMPLOYEE) + EmployeeGroupMember.objects.create( + organization=self.organization, group=self.support, employee=self.operator + ) + self.context = tenant_context_for(self.owner.user, self.organization) + self.policy = policy_for(self.organization) + self.channel = Channel.objects.create( + organization=self.organization, group=self.support, code="esc", name="Очередь" + ) + self.conversation = self._waiting_conversation() + # Обычный случай: смена на месте. Пустую смену проверяет + # PresenceEscalationTests, и она её задаёт явно. + cache.clear() + touch(self.organization.id, self.operator.user_id) + + def _employee(self, email: str, role: str) -> OrganizationMembership: + user = HumanUser.objects.create_user(email=email, password="Password-123") + return OrganizationMembership.objects.create( + user=user, organization=self.organization, role=role, position_title="Specialist" + ) + + def _waiting_conversation(self) -> Conversation: + contact = Contact.objects.create(organization=self.organization, name="Клиент") + return Conversation.objects.create( + organization=self.organization, + channel=self.channel, + group=self.support, + contact=contact, + lifecycle=LifecycleState.OPEN, + control_mode=ControlMode.PAUSED, + expected_responder=ExpectedResponder.OPERATOR, + waiting_since=timezone.now(), + ) + + def _wait_for(self, minutes: int) -> None: + Conversation.objects.filter(pk=self.conversation.pk).update( + waiting_since=timezone.now() - timedelta(minutes=minutes) + ) + + def _dedup_keys(self) -> set[str]: + return set( + Notification.objects.filter(organization=self.organization).values_list( + "dedup_key", flat=True + ) + ) + + + +class QueueEscalationTests(QueueTestBase): + def test_fresh_dialog_is_not_escalated(self) -> None: + self.assertEqual(sweep_waiting_conversations(self.context), 0) + self.assertEqual(self._dedup_keys(), set()) + + def test_reminder_goes_to_the_group_of_the_dialog(self) -> None: + self._wait_for(self.policy.remind_after_minutes + 1) + self.assertEqual(sweep_waiting_conversations(self.context), 1) + reminder = Notification.objects.get(dedup_key=f"waiting:{self.conversation.id}:remind") + self.assertEqual(reminder.audience, NotificationAudience.OPERATORS) + self.assertEqual(reminder.audience_group_id, self.support.id) + + def test_circle_widens_beyond_the_group(self) -> None: + self._wait_for(self.policy.widen_after_minutes + 1) + sweep_waiting_conversations(self.context) + widened = Notification.objects.get(dedup_key=f"waiting:{self.conversation.id}:widen") + self.assertIsNone(widened.audience_group_id) + + def test_management_is_called_last(self) -> None: + self._wait_for(self.policy.escalate_after_minutes + 1) + sweep_waiting_conversations(self.context) + management = Notification.objects.filter( + dedup_key__startswith=f"waiting:{self.conversation.id}:management" + ) + self.assertEqual( + {item.recipient_user_id for item in management}, {self.owner.user_id} + ) + + def test_second_sweep_does_not_repeat_itself(self) -> None: + self._wait_for(self.policy.escalate_after_minutes + 1) + first = sweep_waiting_conversations(self.context) + second = sweep_waiting_conversations(self.context) + self.assertGreater(first, 0) + self.assertEqual(second, 0) + + def test_assigned_dialog_is_not_escalated_to_everyone(self) -> None: + assign_operator( + context=self.context, + conversation_id=self.conversation.id, + assignee=self.operator.user, + ) + self._wait_for(self.policy.escalate_after_minutes + 1) + sweep_waiting_conversations(self.context) + self.assertEqual( + {key for key in self._dedup_keys() if key.startswith("waiting:")}, set() + ) + + +class AssignmentTests(QueueTestBase): + def test_assignment_notifies_the_assignee_and_writes_history(self) -> None: + assign_operator( + context=self.context, + conversation_id=self.conversation.id, + assignee=self.operator.user, + ) + notification = Notification.objects.get( + dedup_key=f"assign:{self.conversation.id}:{self.operator.user_id}" + ) + self.assertEqual(notification.audience, NotificationAudience.USER) + self.assertEqual(notification.recipient_user_id, self.operator.user_id) + self.assertEqual( + list(self.conversation.messages.values_list("system_event", flat=True)), + [SystemEvent.ASSIGNED_TO], + ) + + def test_assigning_to_yourself_does_not_ping_you(self) -> None: + assign_operator( + context=self.context, + conversation_id=self.conversation.id, + assignee=self.owner.user, + ) + self.assertFalse( + Notification.objects.filter( + dedup_key=f"assign:{self.conversation.id}:{self.owner.user_id}" + ).exists() + ) + + def test_unclaimed_assignment_returns_the_dialog_to_the_queue(self) -> None: + assign_operator( + context=self.context, + conversation_id=self.conversation.id, + assignee=self.operator.user, + ) + Conversation.objects.filter(pk=self.conversation.pk).update( + assigned_at=timezone.now() + - timedelta(minutes=self.policy.assignment_timeout_minutes + 1) + ) + sweep_waiting_conversations(self.context) + + self.conversation.refresh_from_db() + self.assertIsNone(self.conversation.assigned_operator_id) + self.assertIsNone(self.conversation.assigned_at) + # Ожидание не обнулилось: клиент ждёт с того же момента, что и ждал. + self.assertIsNotNone(self.conversation.waiting_since) + self.assertIn( + SystemEvent.ASSIGNMENT_EXPIRED, + set(self.conversation.messages.values_list("system_event", flat=True)), + ) + + +class PresenceEscalationTests(QueueTestBase): + """Присутствие сокращает ожидание, но ничего не запрещает.""" + + def setUp(self) -> None: + super().setUp() + # Начинаем с пустой смены: за рабочим местом нет никого. + cache.clear() + + def test_circle_widens_at_the_first_threshold_when_nobody_is_online(self) -> None: + self._wait_for(self.policy.remind_after_minutes + 1) + sweep_waiting_conversations(self.context) + self.assertTrue( + Notification.objects.filter( + dedup_key=f"waiting:{self.conversation.id}:widen" + ).exists() + ) + + def test_present_operator_keeps_the_second_threshold(self) -> None: + touch(self.organization.id, self.operator.user_id) + self._wait_for(self.policy.remind_after_minutes + 1) + sweep_waiting_conversations(self.context) + # Напомнили группе, но круг не расширили: в группе есть кому ответить. + self.assertTrue( + Notification.objects.filter( + dedup_key=f"waiting:{self.conversation.id}:remind" + ).exists() + ) + self.assertFalse( + Notification.objects.filter( + dedup_key=f"waiting:{self.conversation.id}:widen" + ).exists() + ) + + def test_presence_never_delays_the_wider_circle(self) -> None: + touch(self.organization.id, self.operator.user_id) + self._wait_for(self.policy.widen_after_minutes + 1) + sweep_waiting_conversations(self.context) + self.assertTrue( + Notification.objects.filter( + dedup_key=f"waiting:{self.conversation.id}:widen" + ).exists() + ) + + def test_operator_of_another_group_does_not_count_as_present(self) -> None: + outsider = self._employee("outsider@queue.test", EmployeeRole.EMPLOYEE) + touch(self.organization.id, outsider.user_id) + self._wait_for(self.policy.remind_after_minutes + 1) + sweep_waiting_conversations(self.context) + self.assertTrue( + Notification.objects.filter( + dedup_key=f"waiting:{self.conversation.id}:widen" + ).exists() + ) diff --git a/apps/backend/chatballs/conversations/test_queue_order.py b/apps/backend/chatballs/conversations/test_queue_order.py new file mode 100644 index 0000000..97e57d3 --- /dev/null +++ b/apps/backend/chatballs/conversations/test_queue_order.py @@ -0,0 +1,172 @@ +"""Очередь к оператору: с какого момента идёт ожидание и в каком порядке разбирают. + +Регрессия, ради которой заведён waiting_since: «дольше всех ждущий» считался по +времени последнего сообщения, поэтому клиент, напомнивший о себе, уезжал в конец +очереди. Чем настойчивее человек, тем позже до него доходили руки. +""" + +from datetime import timedelta +from unittest import mock + +from django.test import TestCase +from django.utils import timezone + +from chatballs.ai.models import AIAgent +from chatballs.channels.models import Channel +from chatballs.conversations.ingest import ingest_inbound +from chatballs.conversations.models import ControlMode, Conversation +from chatballs.conversations.selectors import order_conversations +from chatballs.conversations.services import ( + claim_conversation, + close_conversation, + return_to_queue, +) +from chatballs.conversations.transports.base import InboundMessage +from chatballs.identity.bootstrap import bootstrap_owner +from chatballs.identity.models import HumanUser, Organization +from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider +from chatballs.notifications.models import Notification, NotificationType +from chatballs.testing import tenant_context_for + + +class QueueTestBase(TestCase): + def setUp(self) -> None: + bootstrap_owner(email="owner@example.com", password="temporary-password") + self.organization = Organization.objects.get(slug="demo") + self.owner = HumanUser.objects.get(email="owner@example.com") + self.context = tenant_context_for(self.owner, self.organization) + # Канал без активного агента: каждый входящий сразу создаёт очередь. + self.channel = Channel.objects.create( + organization=self.organization, code="queue-order", name="Очередь" + ) + self.integration = Integration.objects.create( + organization=self.organization, + kind=IntegrationKind.MESSENGER, + provider=IntegrationProvider.TELEGRAM, + name="queue-bot", + channel=self.channel, + ) + + def _ingest(self, external_id: str, chat_id: str, text: str) -> None: + inbound = InboundMessage( + external_id=external_id, + user_id=chat_id, + chat_id=chat_id, + text=text, + display_name=chat_id, + ) + with ( + mock.patch("chatballs.conversations.ingest.run_channel_turn"), + mock.patch("chatballs.conversations.ingest.transports.send_reply"), + ): + ingest_inbound(self.integration, inbound) + + def _waiting_order(self) -> list[int]: + ordered, _ = order_conversations( + Conversation.objects.filter(organization=self.organization), "waiting" + ) + return list(ordered.values_list("id", flat=True)) + + + +class QueueOrderTests(QueueTestBase): + def test_new_dialog_without_agent_starts_waiting(self) -> None: + self._ingest("m-1", "chat-1", "Здравствуйте") + conversation = self.channel.conversations.get() + self.assertEqual(conversation.control_mode, ControlMode.PAUSED) + self.assertIsNotNone(conversation.waiting_since) + + def test_reminder_from_customer_does_not_restart_the_wait(self) -> None: + self._ingest("m-1", "chat-1", "Здравствуйте") + conversation = self.channel.conversations.get() + started = conversation.waiting_since + + self._ingest("m-2", "chat-1", "Ну что там?") + + conversation.refresh_from_db() + self.assertEqual(conversation.waiting_since, started) + # Свежесть диалога при этом обновилась — ожидание и активность разные вещи. + self.assertGreater(conversation.last_message_at, started) + + def test_longest_waiting_goes_first_even_after_a_reminder(self) -> None: + self._ingest("a-1", "chat-a", "Первый вопрос") + first = Conversation.objects.get(external_chat_id="chat-a") + # Первый диалог ждёт заметно дольше второго. + Conversation.objects.filter(pk=first.pk).update( + waiting_since=timezone.now() - timedelta(hours=2) + ) + self._ingest("b-1", "chat-b", "Второй вопрос") + second = Conversation.objects.get(external_chat_id="chat-b") + + # …и именно он напоминает о себе, двигая своё последнее сообщение вперёд. + self._ingest("a-2", "chat-a", "Всё ещё жду") + + self.assertEqual(self._waiting_order(), [first.id, second.id]) + + def test_claim_ends_the_wait_and_return_to_queue_starts_a_new_one(self) -> None: + self._ingest("m-1", "chat-1", "Здравствуйте") + conversation = self.channel.conversations.get() + first_wait = conversation.waiting_since + + claimed = claim_conversation(context=self.context, conversation_id=conversation.id) + self.assertEqual(claimed.control_mode, ControlMode.HUMAN) + self.assertIsNone(claimed.waiting_since) + + returned = return_to_queue(context=self.context, conversation_id=conversation.id) + self.assertEqual(returned.control_mode, ControlMode.PAUSED) + self.assertIsNotNone(returned.waiting_since) + # Ожидание началось заново: диалог успел побывать у оператора. + self.assertGreater(returned.waiting_since, first_wait) + + def test_closed_dialog_leaves_the_queue(self) -> None: + self._ingest("z-1", "chat-z", "Вопрос") + conversation = self.channel.conversations.get() + self.assertIsNotNone(conversation.waiting_since) + + closed = close_conversation(context=self.context, conversation_id=conversation.id) + self.assertIsNone(closed.waiting_since) + + +class NewDialogNotificationTests(QueueTestBase): + """«Новый диалог» и «клиент запросил оператора» — разные события. + + Диалог начинается и на канале с работающим агентом, где человека никто не + звал. Назвать такой оклик просьбой о человеке — соврать тому, кто на него + подписан. + """ + + def _last_type(self) -> str: + return ( + Notification.objects.filter(organization=self.organization) + .order_by("-id") + .values_list("type", flat=True) + .first() + ) + + def test_dialog_without_an_agent_asks_for_a_person(self) -> None: + self._ingest("n-1", "chat-n", "Здравствуйте") + self.assertEqual(self._last_type(), NotificationType.OPERATOR_REQUESTED) + + def test_dialog_handled_by_the_agent_is_just_a_new_dialog(self) -> None: + agent = AIAgent.objects.create( + organization=self.organization, + channel=self.channel, + name="Консультант", + is_active=True, + ) + self.assertTrue(agent.is_active) + with mock.patch( + "chatballs.conversations.ingest.run_channel_turn", + return_value=mock.Mock(text="Здравствуйте!"), + ), mock.patch("chatballs.conversations.ingest.transports.send_reply"): + ingest_inbound( + self.integration, + InboundMessage( + external_id="a-1", + user_id="chat-a", + chat_id="chat-a", + text="Здравствуйте", + display_name="chat-a", + ), + ) + self.assertEqual(self._last_type(), NotificationType.NEW_DIALOG) diff --git a/apps/backend/chatballs/conversations/test_queue_policy.py b/apps/backend/chatballs/conversations/test_queue_policy.py new file mode 100644 index 0000000..63d5c99 --- /dev/null +++ b/apps/backend/chatballs/conversations/test_queue_policy.py @@ -0,0 +1,64 @@ +"""Сроки очереди через API (макет Q2). + +До этого пороги правились только в служебной админке — то есть де-факто никем. +""" + +from django.test import TestCase + +from chatballs.conversations.queue_models import QueueEscalationPolicy +from chatballs.identity.models import ( + EmployeeRole, + HumanUser, + Organization, + OrganizationMembership, +) +from chatballs.testing import TenantAPIClient as APIClient + +URL = "/api/v1/conversations/queue-policy/" + + +class QueuePolicyApiTests(TestCase): + def setUp(self) -> None: + self.organization = Organization.objects.create(name="Example", slug="queue-policy") + self.owner = self._employee("owner@policy.test", EmployeeRole.OWNER) + self.operator = self._employee("operator@policy.test", EmployeeRole.EMPLOYEE) + self.client = APIClient() + self.client.force_authenticate(self.owner.user) + + def _employee(self, email: str, role: str) -> OrganizationMembership: + user = HumanUser.objects.create_user(email=email, password="Password-123") + return OrganizationMembership.objects.create( + user=user, organization=self.organization, role=role, position_title="Specialist" + ) + + def test_first_read_returns_usual_delays(self) -> None: + response = self.client.get(URL) + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["remind_after_minutes"], 5) + self.assertEqual(payload["assignment_timeout_minutes"], 10) + self.assertEqual(payload["defaults"], payload["defaults"] | {"widen_after_minutes": 15}) + self.assertIsNone(payload["updatedAt"]) + + def test_saving_remembers_who_and_when(self) -> None: + response = self.client.patch(URL, {"remind_after_minutes": 3}, format="json") + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["remind_after_minutes"], 3) + self.assertEqual(payload["updatedBy"], self.owner.user.full_name or self.owner.user.email) + self.assertIsNotNone(payload["updatedAt"]) + self.assertEqual(QueueEscalationPolicy.objects.get().remind_after_minutes, 3) + + def test_nonsense_values_are_refused(self) -> None: + for value in (0, -5, 10000, "быстро", True): + with self.subTest(value=value): + response = self.client.patch(URL, {"widen_after_minutes": value}, format="json") + self.assertEqual(response.status_code, 400) + self.assertEqual(QueueEscalationPolicy.objects.get().widen_after_minutes, 15) + + def test_operator_reads_but_does_not_change(self) -> None: + self.client.force_authenticate(self.operator.user) + self.assertEqual(self.client.get(URL).status_code, 403) + self.assertEqual( + self.client.patch(URL, {"remind_after_minutes": 1}, format="json").status_code, 403 + ) diff --git a/apps/backend/chatballs/conversations/tests.py b/apps/backend/chatballs/conversations/tests.py index a54fecc..5e97821 100644 --- a/apps/backend/chatballs/conversations/tests.py +++ b/apps/backend/chatballs/conversations/tests.py @@ -421,7 +421,7 @@ class WebchatContactTests(TestCase): ) self.assertTrue( Notification.objects.filter( - type=NotificationType.DIALOG_WAITING, + type=NotificationType.OPERATOR_REQUESTED, audience=NotificationAudience.OPERATORS, target_id=str(conversation.id), ).exists() @@ -429,7 +429,7 @@ class WebchatContactTests(TestCase): self.assertEqual( set( Notification.objects.filter( - type=NotificationType.INTEGRATION_ERROR, + type=NotificationType.AI_STOPPED, audience=NotificationAudience.USER, target_id=str(conversation.id), ).values_list("recipient_user__email", flat=True) diff --git a/apps/backend/chatballs/conversations/urls.py b/apps/backend/chatballs/conversations/urls.py index 66fcffa..7b09392 100644 --- a/apps/backend/chatballs/conversations/urls.py +++ b/apps/backend/chatballs/conversations/urls.py @@ -3,6 +3,7 @@ from django.urls import path from chatballs.conversations import ( attachment_views, chat_extras_views, + queue_policy_views, reporting_views, views, voice_views, @@ -30,6 +31,7 @@ urlpatterns = [ path("/labels/", chat_extras_views.ConversationLabelsView.as_view(), name="conversation-labels"), path("/archive/", chat_extras_views.ConversationArchiveView.as_view(), name="conversation-archive"), path("counters/", chat_extras_views.ConversationCountersView.as_view(), name="conversation-counters"), + path("queue-policy/", queue_policy_views.QueuePolicyView.as_view(), name="conversation-queue-policy"), path("directory/", chat_extras_views.ConversationDirectoryView.as_view(), name="conversation-directory"), path("labels/", chat_extras_views.LabelListView.as_view(), name="conversation-label-list"), path("labels//", chat_extras_views.LabelDetailView.as_view(), name="conversation-label-detail"), diff --git a/apps/backend/chatballs/conversations/views.py b/apps/backend/chatballs/conversations/views.py index df50e72..76ce43f 100644 --- a/apps/backend/chatballs/conversations/views.py +++ b/apps/backend/chatballs/conversations/views.py @@ -30,6 +30,7 @@ from chatballs.conversations.serializers import ( ) from chatballs.conversations.services import ( ClaimError, + assign_operator, claim_conversation, close_conversation, mark_conversation_as_spam, @@ -87,6 +88,21 @@ class ConversationListView(ConversationViewBase): items = items.filter( lifecycle=LifecycleState.OPEN, control_mode=ControlMode.PAUSED ) + # Две вкладки ожидания (макет «Очередь и уведомления», кадр Q3): общая + # очередь — диалоги без ответственного, их берёт кто угодно; «на мне» — + # назначенные лично и ждущие, пока их возьмут. + if params.get("queue") == "1": + items = items.filter( + lifecycle=LifecycleState.OPEN, + control_mode=ControlMode.PAUSED, + assigned_operator__isnull=True, + ) + if params.get("waitingOnMe") == "1": + items = items.filter( + lifecycle=LifecycleState.OPEN, + control_mode=ControlMode.PAUSED, + assigned_operator_id=request.user.id, + ) query = params.get("q", "").strip() if query: from django.contrib.postgres.search import SearchQuery, SearchVector @@ -469,8 +485,11 @@ class ConversationAssigneeView(ConversationViewBase): if membership is None: return Response({"detail": t("admin.employee_not_found")}, status=400) assignee = membership.user - conversation.assigned_operator = assignee - conversation.save(update_fields=["assigned_operator"]) + conversation = assign_operator( + context=request.tenant_context, + conversation_id=conversation.id, + assignee=assignee, + ) self._audit(request, "assignee_changed", conversation) return Response( { diff --git a/apps/backend/chatballs/events/management/commands/run_worker.py b/apps/backend/chatballs/events/management/commands/run_worker.py index 2808c87..07844e6 100644 --- a/apps/backend/chatballs/events/management/commands/run_worker.py +++ b/apps/backend/chatballs/events/management/commands/run_worker.py @@ -6,6 +6,7 @@ from django.core.management.base import BaseCommand from django.utils import timezone from chatballs.calls.maintenance import expire_stale_calls +from chatballs.conversations.escalation import sweep_waiting_conversations from chatballs.conversations.maintenance import close_stale_conversations from chatballs.conversations.poller import poll_all_messengers from chatballs.events.handlers import dispatch @@ -22,6 +23,9 @@ logger = logging.getLogger(__name__) MESSENGER_POLL_INTERVAL = 3.0 # seconds between messenger long-poll cycles MAINTENANCE_INTERVAL = 3600.0 # seconds between maintenance cycles (auto-close stale dialogs) CALL_SWEEP_INTERVAL = 10.0 # seconds between call timeout sweeps (invite expiry, stuck connect) +# Пороги очереди задаются в минутах, поэтому раз в полминуты — с запасом: +# проверка дешёвая, а повтор гасится dedup-ключом уровня. +QUEUE_SWEEP_INTERVAL = 30.0 # seconds between waiting-queue escalation sweeps class Command(BaseCommand): @@ -39,6 +43,7 @@ class Command(BaseCommand): last_poll = 0.0 last_maintenance = 0.0 last_call_sweep = 0.0 + last_queue_sweep = 0.0 while True: try: event = claim_next_outbox_event() @@ -91,6 +96,14 @@ class Command(BaseCommand): expire_stale_calls(context) except Exception: # pragma: no cover logger.exception("Call sweep cycle failed") + if now - last_queue_sweep >= QUEUE_SWEEP_INTERVAL: + last_queue_sweep = now + try: + for context in self._tenant_contexts(): + with tenant_atomic(context): + sweep_waiting_conversations(context) + except Exception: # pragma: no cover + logger.exception("Waiting queue sweep cycle failed") if now - last_maintenance >= MAINTENANCE_INTERVAL: last_maintenance = now # Канал релизов спрашивается не чаще раза в несколько часов: diff --git a/apps/backend/chatballs/i18n/messages/en.py b/apps/backend/chatballs/i18n/messages/en.py index a64ffa6..390b312 100644 --- a/apps/backend/chatballs/i18n/messages/en.py +++ b/apps/backend/chatballs/i18n/messages/en.py @@ -54,6 +54,8 @@ MESSAGES: dict[str, object] = { "conversations.not_found": "Conversation not found", "conversations.note_too_long": "The note is longer than 4000 characters", "conversations.restore_admin_only": "Only an administrator can restore a conversation", + "conversations.system.assigned_to": "Conversation assigned to {operator}", + "conversations.system.assignment_expired": "{operator} did not pick the conversation up — it is back in the queue", "conversations.system.ai_handed_over": "AI handed the conversation to an operator", "conversations.system.ai_unavailable": "AI is unavailable — the conversation was handed to an operator", "conversations.system.call_accepted": "The customer accepted the call invitation", @@ -96,6 +98,12 @@ MESSAGES: dict[str, object] = { "notifications.new_dialog_body": "{contact} · {provider}: {preview}", "notifications.new_message": "New message · {contact}", "notifications.operator_needed": "An operator is needed · {contact}", + "notifications.still_waiting": "Conversation is still waiting · {contact}", + "notifications.still_waiting_body": "Nobody has picked this conversation up. Open it if you can reply.", + "notifications.waiting_unattended": "Nobody is picking this conversation up · {contact}", + "notifications.waiting_unattended_body": "The customer has been waiting longer than usual and the queue is not being worked.", + "notifications.assigned_to_you": "A conversation was assigned to you · {contact}", + "notifications.assignment_expired": "Conversation is unassigned again · {contact}", "notifications.voice_without_transcript": "A voice message without a transcript", "onboarding.membership_required": "Onboarding is available to organization members", "onboarding.unknown_action": "Unknown onboarding action", @@ -103,6 +111,10 @@ MESSAGES: dict[str, object] = { "portals.file_field_required": "A file is required in the file field (multipart/form-data)", "portals.file_not_found": "File not found", "portals.helpful_must_be_boolean": "helpful must be a boolean", + "portals.installation_domain_missing": ( + "The installation address has no domain: set one in Settings, " + "otherwise there is nowhere to host the portal" + ), "portals.invalid_article": "Invalid article", "portals.invalid_article_data": "Invalid article data", "portals.invalid_category": "Invalid category", @@ -111,10 +123,6 @@ MESSAGES: dict[str, object] = { "portals.invalid_revision": "Invalid revision", "portals.invalid_section": "Invalid section", "portals.not_found": "Portal not found", - "portals.installation_domain_missing": ( - "The installation address has no domain: set one in Settings, " - "otherwise there is nowhere to host the portal" - ), "portals.section_not_found": "Section not found", "profile.accent_hex": "The accent is a HEX colour like #0f9b8e", "profile.choose_photo_file": "Choose a photo file", @@ -349,6 +357,10 @@ MESSAGES: dict[str, object] = { "identity.totp_challenge_inactive": "TOTP challenge is not active", "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", + "settings.queue_minutes_range": "The delay is set in minutes, from 1 to 1440", + "settings.queue_policy_forbidden": "Only someone who manages settings can change these delays", + "notifications.unknown_transport": "Unknown delivery channel", "platform.idempotency_key_required": "Idempotency-Key header is required", "portals.articles_non_empty": "articles must be a non-empty list", "profile.current_password_invalid": "Current password is invalid", @@ -481,10 +493,12 @@ MESSAGES: dict[str, object] = { "ai.unexpected_provider_response": "Unexpected provider response: {error}", "settings.base_url_rejected": "Base URL: {error}", "settings.proxy_url_rejected": "Proxy: {error}", - "notifications.type_dialog_waiting": "A dialog is waiting for an operator", - "notifications.type_dialog_new_message": "A new message in a dialog", - "notifications.type_release_published": "An agent release was published", - "notifications.type_integration_error": "Integration error", + "notifications.type_new_dialog": "A new conversation", + "notifications.type_operator_requested": "A customer asked for a person", + "notifications.type_dialog_new_message": "A new message in my conversation", + "notifications.type_dialog_assigned": "A conversation was assigned to me", + "notifications.type_dialog_waiting_long": "A conversation has been waiting a long time", + "notifications.type_ai_stopped": "AI stopped by an error or a limit", "integrations.check_no_connection": "No connection: {error}", "integrations.check_api_key_missing": "The API key is not set", "integrations.check_base_url_missing": "Base URL is not set", diff --git a/apps/backend/chatballs/i18n/messages/ru.py b/apps/backend/chatballs/i18n/messages/ru.py index bb0aecc..4bab94b 100644 --- a/apps/backend/chatballs/i18n/messages/ru.py +++ b/apps/backend/chatballs/i18n/messages/ru.py @@ -59,6 +59,8 @@ MESSAGES: dict[str, object] = { "conversations.note_too_long": "Заметка длиннее 4000 символов", "conversations.restore_admin_only": "Восстановить диалог может только администратор", "conversations.system.ai_handed_over": "AI передал диалог оператору", + "conversations.system.assigned_to": "Диалог назначен на {operator}", + "conversations.system.assignment_expired": "{operator} не взял диалог — он вернулся в очередь", "conversations.system.ai_unavailable": "AI недоступен — диалог передан оператору", "conversations.system.call_accepted": "Клиент принял приглашение на звонок", "conversations.system.call_cancelled": "Сотрудник отменил приглашение на звонок", @@ -101,12 +103,22 @@ MESSAGES: dict[str, object] = { "notifications.new_message": "Новое сообщение · {contact}", "notifications.operator_needed": "Нужен оператор · {contact}", "notifications.voice_without_transcript": "Голосовое без расшифровки", + "notifications.still_waiting": "Диалог всё ещё ждёт · {contact}", + "notifications.still_waiting_body": "Диалог никто не взял. Откройте его, если можете ответить.", + "notifications.waiting_unattended": "Диалог никто не берёт · {contact}", + "notifications.waiting_unattended_body": "Клиент ждёт ответа дольше обычного, и очередь не разбирают.", + "notifications.assigned_to_you": "Вам назначен диалог · {contact}", + "notifications.assignment_expired": "Диалог снова ничей · {contact}", "onboarding.membership_required": "Онбординг доступен участнику организации", "onboarding.unknown_action": "Неизвестное действие онбординга", "portals.article_not_found": "Статья не найдена", "portals.file_field_required": "Нужен файл в поле file (multipart/form-data)", "portals.file_not_found": "Файл не найден", "portals.helpful_must_be_boolean": "helpful должен быть boolean", + "portals.installation_domain_missing": ( + "Адрес установки задан без домена: укажите домен в «Настройках», " + "иначе портал негде разместить" + ), "portals.invalid_article": "Некорректная статья", "portals.invalid_article_data": "Некорректные данные статьи", "portals.invalid_category": "Некорректная категория", @@ -115,10 +127,6 @@ MESSAGES: dict[str, object] = { "portals.invalid_revision": "Некорректная версия", "portals.invalid_section": "Некорректный раздел", "portals.not_found": "Портал не найден", - "portals.installation_domain_missing": ( - "Адрес установки задан без домена: укажите домен в «Настройках», " - "иначе портал негде разместить" - ), "portals.section_not_found": "Раздел не найден", "profile.accent_hex": "Акцент — HEX-цвет вида #0f9b8e", "profile.choose_photo_file": "Выберите файл фото", @@ -353,6 +361,10 @@ MESSAGES: dict[str, object] = { "identity.totp_challenge_inactive": "Проверка кода уже неактуальна — войдите заново", "notifications.ids_list_or_all": "ids — список, либо all=true", "notifications.push_types_list": "pushTypes — список", + "notifications.enabled_bool": "enabled — да или нет", + "settings.queue_minutes_range": "Срок указывается в минутах: от 1 до 1440", + "settings.queue_policy_forbidden": "Менять сроки может тот, кто управляет настройками", + "notifications.unknown_transport": "Неизвестный способ доставки", "platform.idempotency_key_required": "Нужен заголовок Idempotency-Key", "portals.articles_non_empty": "articles — непустой список", "profile.current_password_invalid": "Текущий пароль неверен", @@ -485,10 +497,12 @@ MESSAGES: dict[str, object] = { "ai.unexpected_provider_response": "Неожиданный ответ провайдера: {error}", "settings.base_url_rejected": "Base URL: {error}", "settings.proxy_url_rejected": "Прокси: {error}", - "notifications.type_dialog_waiting": "Диалог ждёт оператора", - "notifications.type_dialog_new_message": "Новое сообщение в диалоге", - "notifications.type_release_published": "Опубликован релиз агента", - "notifications.type_integration_error": "Ошибка интеграции", + "notifications.type_new_dialog": "Новый диалог", + "notifications.type_operator_requested": "Клиент запросил оператора", + "notifications.type_dialog_new_message": "Новое сообщение в моём диалоге", + "notifications.type_dialog_assigned": "Диалог назначили на меня", + "notifications.type_dialog_waiting_long": "Диалог долго ждёт человека", + "notifications.type_ai_stopped": "AI остановлен ошибкой или лимитом", "integrations.check_no_connection": "Нет связи: {error}", "integrations.check_api_key_missing": "Не указан API-ключ", "integrations.check_base_url_missing": "Не указан Base URL", diff --git a/apps/backend/chatballs/identity/demo_seed/data/en/operations.json b/apps/backend/chatballs/identity/demo_seed/data/en/operations.json index a26fc4f..d030005 100644 --- a/apps/backend/chatballs/identity/demo_seed/data/en/operations.json +++ b/apps/backend/chatballs/identity/demo_seed/data/en/operations.json @@ -76,7 +76,7 @@ ], "notifications": [ { - "type": "DIALOG_WAITING", + "type": "OPERATOR_REQUESTED", "level": "WARNING", "title": "Dmitry Orlov is waiting for an operator", "body": "Web Chat: the client asked for a member of staff. Waiting 6 min.", @@ -107,7 +107,7 @@ "minutesAgo": 4 }, { - "type": "INTEGRATION_ERROR", + "type": "AI_STOPPED", "level": "CRITICAL", "title": "Telegram · delivery bot: connection error", "body": "401 Unauthorized: bot token was revoked in @BotFather. Check the token in the connection settings.", @@ -116,7 +116,7 @@ "minutesAgo": 4200 }, { - "type": "RELEASE_PUBLISHED", + "type": "DIALOG_ASSIGNED", "level": "SUCCESS", "title": "Chatballs update: voice messages", "body": "Operators can send voice messages to Telegram and MAX, and incoming ones are transcribed with a single button.", @@ -136,9 +136,9 @@ "connection": "tg", "externalChatId": "482900010", "pushTypes": [ - "DIALOG_WAITING", + "OPERATOR_REQUESTED", "DIALOG_NEW_MESSAGE", - "INTEGRATION_ERROR" + "AI_STOPPED" ] }, { @@ -146,7 +146,7 @@ "connection": "tg", "externalChatId": "482900011", "pushTypes": [ - "DIALOG_WAITING" + "OPERATOR_REQUESTED" ] } ], diff --git a/apps/backend/chatballs/identity/demo_seed/data/ru/operations.json b/apps/backend/chatballs/identity/demo_seed/data/ru/operations.json index 2ce580f..01351c0 100644 --- a/apps/backend/chatballs/identity/demo_seed/data/ru/operations.json +++ b/apps/backend/chatballs/identity/demo_seed/data/ru/operations.json @@ -76,7 +76,7 @@ ], "notifications": [ { - "type": "DIALOG_WAITING", + "type": "OPERATOR_REQUESTED", "level": "WARNING", "title": "Дмитрий Орлов ждёт оператора", "body": "Web Chat: клиент попросил сотрудника. Ждёт 6 мин.", @@ -107,7 +107,7 @@ "minutesAgo": 4 }, { - "type": "INTEGRATION_ERROR", + "type": "AI_STOPPED", "level": "CRITICAL", "title": "Telegram · бот доставки: ошибка подключения", "body": "401 Unauthorized: bot token was revoked in @BotFather. Проверьте токен в настройках подключения.", @@ -116,7 +116,7 @@ "minutesAgo": 4200 }, { - "type": "RELEASE_PUBLISHED", + "type": "DIALOG_ASSIGNED", "level": "SUCCESS", "title": "Обновление Chatballs: голосовые сообщения", "body": "Операторы могут отправлять голосовые в Telegram и MAX, входящие расшифровываются одной кнопкой.", @@ -136,9 +136,9 @@ "connection": "tg", "externalChatId": "482900010", "pushTypes": [ - "DIALOG_WAITING", + "OPERATOR_REQUESTED", "DIALOG_NEW_MESSAGE", - "INTEGRATION_ERROR" + "AI_STOPPED" ] }, { @@ -146,7 +146,7 @@ "connection": "tg", "externalChatId": "482900011", "pushTypes": [ - "DIALOG_WAITING" + "OPERATOR_REQUESTED" ] } ], diff --git a/apps/backend/chatballs/identity/demo_seed/loaders/operations.py b/apps/backend/chatballs/identity/demo_seed/loaders/operations.py index 5d70d00..7847cfb 100644 --- a/apps/backend/chatballs/identity/demo_seed/loaders/operations.py +++ b/apps/backend/chatballs/identity/demo_seed/loaders/operations.py @@ -18,6 +18,7 @@ from chatballs.calls.models import ( ParticipantConnectionState, ParticipantSide, ) +from chatballs.conversations.queue_models import policy_for from chatballs.identity.demo_seed import manifest from chatballs.identity.demo_seed.loaders.common import backdate, moment, now from chatballs.identity.demo_seed.refs import DemoRefs @@ -28,7 +29,9 @@ from chatballs.notifications.models import ( NotificationAudience, NotificationLevel, NotificationRead, + NotificationTransport, ) +from chatballs.notifications.preferences import update_preference from chatballs.tenancy.context import TenantContext FINISHED = { @@ -44,6 +47,9 @@ FINISHED = { def load(context: TenantContext, refs: DemoRefs) -> None: data = manifest.load("operations", refs.language) current = now() + # Пороги очереди — данные организации, а не установки: демо-организация + # заводит их со значениями по умолчанию, как завела бы живая. + policy_for(refs.organization) for item in data.get("calls", []): _ensure_call(refs, item, current) for item in data.get("notifications", []): @@ -55,9 +61,15 @@ def load(context: TenantContext, refs: DemoRefs) -> None: defaults={ "organization": refs.organization, "external_chat_id": item["externalChatId"], - "push_types": item.get("pushTypes", []), }, ) + update_preference( + organization_id=refs.organization.id, + user_id=refs.users[item["user"]].id, + transport=NotificationTransport.MESSENGER, + enabled=True, + types=item.get("pushTypes", []), + ) for item in data.get("messengerBindingCodes", []): MessengerBindingCode.objects.get_or_create( code=item["code"], diff --git a/apps/backend/chatballs/identity/policy.py b/apps/backend/chatballs/identity/policy.py index 12087dc..6649a4c 100644 --- a/apps/backend/chatballs/identity/policy.py +++ b/apps/backend/chatballs/identity/policy.py @@ -15,6 +15,12 @@ from chatballs.identity.models import EmployeeRole, OrganizationMembership # сохраняется; scope-модель и отделы упразднены. +# Руководство организации: видит всё независимо от групп. Набор один и тот же +# в проверке доступа, в видимости диалогов и в подборе получателей уведомлений, +# поэтому записан здесь один раз. +MANAGEMENT_ROLES = frozenset({EmployeeRole.OWNER, EmployeeRole.ADMIN}) + + @dataclass(frozen=True, slots=True) class ResourceScope: organization_id: int @@ -47,6 +53,18 @@ def authorize(actor, capability: str, resource_scope: ResourceScope) -> bool: return capability in _role_capabilities(profile.role) +def roles_with_capability(capability: str) -> frozenset[str]: + """Роли, которым право выдано. + + Нужна там, где получателей ищут пачкой: спрашивать authorize() по одному + сотруднику — это запрос в базу на каждого, то есть сотня запросов на сотню + рабочих мест. Право выводится из роли, значит и отбирать можно по роли. + """ + return frozenset( + role for role in EmployeeRole.values if capability in _role_capabilities(role) + ) + + def has_capability_any_scope(actor, capability: str) -> bool: profile = _active_membership(actor) if profile is None: @@ -56,7 +74,7 @@ def has_capability_any_scope(actor, capability: str) -> bool: def can_administer_access(actor) -> bool: profile = _active_membership(actor) - return profile is not None and profile.role in {EmployeeRole.OWNER, EmployeeRole.ADMIN} + return profile is not None and profile.role in MANAGEMENT_ROLES def conversation_visibility(actor) -> dict | None: @@ -69,7 +87,7 @@ def conversation_visibility(actor) -> dict | None: profile = _active_membership(actor) if profile is None: return {"none": True} - if profile.role in {EmployeeRole.OWNER, EmployeeRole.ADMIN}: + if profile.role in MANAGEMENT_ROLES: return None from chatballs.identity.group_models import member_group_ids diff --git a/apps/backend/chatballs/identity/test_membership_migration.py b/apps/backend/chatballs/identity/test_membership_migration.py index ce19118..7affa83 100644 --- a/apps/backend/chatballs/identity/test_membership_migration.py +++ b/apps/backend/chatballs/identity/test_membership_migration.py @@ -4,6 +4,14 @@ from django.test import TransactionTestCase class MembershipMigrationTests(TransactionTestCase): + """Перенос сотрудников на членства (identity/0012) не теряет идентификаторы. + + Тест уводит базу к состоянию до переноса, поэтому обязан вернуть её обратно + за собой. Иначе всё, что запускается после него в той же сессии, работает + на схеме годичной давности: откат по identity утягивает и зависимые + миграции других приложений, и чем их больше, тем разрушительнее последствия. + """ + migrate_from = [("identity", "0011_enforce_capability_registry")] migrate_to = [("identity", "0012_membership_identity")] diff --git a/apps/backend/chatballs/notifications/binding.py b/apps/backend/chatballs/notifications/binding.py index 205204f..33029c1 100644 --- a/apps/backend/chatballs/notifications/binding.py +++ b/apps/backend/chatballs/notifications/binding.py @@ -19,7 +19,12 @@ from chatballs.conversations import transports from chatballs.conversations.transports.base import InboundMessage from chatballs.i18n import customer_language, first_chosen, normalize_language, t from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider -from chatballs.notifications.models import MessengerBinding, MessengerBindingCode +from chatballs.notifications.models import ( + MessengerBinding, + MessengerBindingCode, + NotificationTransport, +) +from chatballs.notifications.preferences import update_preference logger = logging.getLogger(__name__) @@ -110,6 +115,13 @@ def handle_notifier_inbound(integration: Integration, inbound: InboundMessage) - integration=integration, defaults={"external_chat_id": inbound.chat_id or inbound.user_id}, ) + # Привязка — и есть согласие получать: до неё звать было просто некуда. + update_preference( + organization_id=integration.organization_id, + user_id=binding_code.user_id, + transport=NotificationTransport.MESSENGER, + enabled=True, + ) binding_code.delete() # Подтверждение читает конкретный сотрудник — язык берём из его профиля. done = t( diff --git a/apps/backend/chatballs/notifications/delivery.py b/apps/backend/chatballs/notifications/delivery.py index 8600511..1fff977 100644 --- a/apps/backend/chatballs/notifications/delivery.py +++ b/apps/backend/chatballs/notifications/delivery.py @@ -1,8 +1,10 @@ """Доставка уведомлений в мессенджеры через сервисных ботов. Событие notifications.notification_created кладётся в outbox из notify() и -обрабатывается worker'ом: аудитория разворачивается в получателей (зеркало -selectors.visible_for), их привязки получают сообщение через транспорт бота. +обрабатывается worker'ом: аудитория разворачивается в получателей +(chatballs.notifications.recipients), их привязки получают сообщение через +транспорт бота. + Отправка best-effort: сбой одной привязки логируется и не валит событие. """ @@ -12,15 +14,13 @@ import logging from chatballs.conversations import transports from chatballs.identity.instance_settings import public_base_url -from chatballs.identity.models import OrganizationMembership from chatballs.notifications.models import ( MessengerBinding, Notification, - NotificationAudience, NotificationLevel, ) -from chatballs.notifications.selectors import visible_for -from chatballs.tenancy.context import TenantContext +from chatballs.notifications.preferences import messenger_types +from chatballs.notifications.recipients import recipient_user_ids logger = logging.getLogger(__name__) @@ -34,21 +34,6 @@ _LEVEL_MARK = { } -def _recipient_user_ids(notification: Notification) -> list[int]: - if notification.audience == NotificationAudience.USER: - return [notification.recipient_user_id] if notification.recipient_user_id else [] - profiles = OrganizationMembership.objects.filter( - organization_id=notification.organization_id, - blocked_at__isnull=True, - user__is_active=True, - ).select_related("user") - return [ - profile.user_id - for profile in profiles - if visible_for(TenantContext.for_membership(profile)).filter(id=notification.id).exists() - ] - - def _message_text(notification: Notification) -> str: mark = _LEVEL_MARK.get(notification.level, "🔔") lines = [f"{mark} {notification.title}"] @@ -60,8 +45,14 @@ def _message_text(notification: Notification) -> str: return "\n".join(lines) -def deliver_notification(notification: Notification) -> int: - user_ids = _recipient_user_ids(notification) +def deliver_notification(notification: Notification, *, user_ids: list[int] | None = None) -> int: + """Рассылает уведомление в привязанные мессенджеры. + + Получателей можно передать готовыми: тот же список нужен и оповещению + открытого приложения, а считать его дважды на каждое уведомление незачем. + """ + if user_ids is None: + user_ids = recipient_user_ids(notification) if not user_ids: return 0 bindings = ( @@ -71,7 +62,10 @@ def deliver_notification(notification: Notification) -> int: text = _message_text(notification) sent = 0 for binding in bindings: - if notification.type not in (binding.push_types or []): + allowed = messenger_types( + organization_id=notification.organization_id, user_id=binding.user_id + ) + if notification.type not in allowed: continue try: if transports.send_reply(binding.integration, chat_id=binding.external_chat_id, user_id="", text=text): diff --git a/apps/backend/chatballs/notifications/event_handlers.py b/apps/backend/chatballs/notifications/event_handlers.py index 330d9cf..6874347 100644 --- a/apps/backend/chatballs/notifications/event_handlers.py +++ b/apps/backend/chatballs/notifications/event_handlers.py @@ -1,6 +1,8 @@ from chatballs.events.handlers import register from chatballs.notifications.delivery import NOTIFICATION_CREATED, deliver_notification from chatballs.notifications.models import Notification +from chatballs.notifications.realtime import notify_notifications_changed +from chatballs.notifications.recipients import recipient_user_ids from chatballs.tenancy.context import TenantContext @@ -11,5 +13,11 @@ def handle_notification_created(payload: dict, context: TenantContext | None) -> notification = Notification.objects.filter( pk=payload.get("notificationId"), organization=context.organization ).first() - if notification is not None: - deliver_notification(notification) + if notification is None: + return + # Получатели считаются один раз на оба пути доставки. + user_ids = recipient_user_ids(notification) + # Сначала те, кто сидит в приложении: это мгновенно и ничего не стоит. + # Мессенджер — следом, он медленнее и ходит наружу. + notify_notifications_changed(user_ids) + deliver_notification(notification, user_ids=user_ids) diff --git a/apps/backend/chatballs/notifications/migrations/0011_notification_audience_group.py b/apps/backend/chatballs/notifications/migrations/0011_notification_audience_group.py new file mode 100644 index 0000000..15cd832 --- /dev/null +++ b/apps/backend/chatballs/notifications/migrations/0011_notification_audience_group.py @@ -0,0 +1,36 @@ +"""Граница видимости уведомления — та же, что у диалога. + +Раньше уведомления фильтровались только правом «видеть диалоги», а сами диалоги +— ещё и группой (ADR-CHATBALLS-0043 §4). Из-за расхождения оператор одной группы +получал оклик с именем клиента и куском переписки по диалогу другой группы и, +перейдя по нему, упирался в «диалог не найден». + +Старые записи остаются с NULL: группу диалога, из которого они родились, задним +числом восстанавливать нечем, а NULL здесь означает прежнее поведение — видно +всем, кого пропускает аудитория. +""" + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('identity', '0020_employeegroup_employeegroupmember_and_more'), + ('notifications', '0010_i18n_events'), + ] + + operations = [ + migrations.AddField( + model_name='notification', + name='audience_group', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='notifications', + to='identity.employeegroup', + ), + ), + ] diff --git a/apps/backend/chatballs/notifications/migrations/0012_notification_preference.py b/apps/backend/chatballs/notifications/migrations/0012_notification_preference.py new file mode 100644 index 0000000..3ccd44b --- /dev/null +++ b/apps/backend/chatballs/notifications/migrations/0012_notification_preference.py @@ -0,0 +1,87 @@ +"""Настройка оклика уезжает из привязки к мессенджеру в отдельную сущность. + +Пока транспорт был один, список типов на привязке работал. Со вторым (браузер) в +профиле оказались бы два списка галочек про одно и то же, и человек, отключивший +«новое сообщение», продолжал бы получать его с другой стороны. + +Порядок операций здесь важен: сначала новая таблица, потом перенос, и только +потом удаление старого поля. Django сгенерировал обратный порядок — в нём +настройки всех, кто подключил бота, молча обнулились бы. +""" + +import chatballs.notifications.models +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +MESSENGER = "MESSENGER" + + +def carry_over_push_types(apps, schema_editor): + MessengerBinding = apps.get_model("notifications", "MessengerBinding") + NotificationPreference = apps.get_model("notifications", "NotificationPreference") + rows = [] + seen = set() + for binding in MessengerBinding.objects.all().iterator(): + # Мессенджер у сотрудника один на организацию, но привязок в базе могло + # остаться несколько: берём первую и не плодим дубли под уникальным ключом. + key = (binding.organization_id, binding.user_id) + if key in seen: + continue + seen.add(key) + rows.append( + NotificationPreference( + organization_id=binding.organization_id, + user_id=binding.user_id, + transport=MESSENGER, + # Привязка существует — значит, согласие получать уже дано. + enabled=True, + types=binding.push_types or [], + ) + ) + NotificationPreference.objects.bulk_create(rows, ignore_conflicts=True) + + +def restore_push_types(apps, schema_editor): + MessengerBinding = apps.get_model("notifications", "MessengerBinding") + NotificationPreference = apps.get_model("notifications", "NotificationPreference") + by_user = { + (preference.organization_id, preference.user_id): preference.types + for preference in NotificationPreference.objects.filter(transport=MESSENGER) + } + for binding in MessengerBinding.objects.all().iterator(): + types = by_user.get((binding.organization_id, binding.user_id)) + if types is not None: + binding.push_types = types + binding.save(update_fields=["push_types"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('identity', '0039_remove_organization_currency'), + ('notifications', '0011_notification_audience_group'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='NotificationPreference', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('transport', models.CharField(choices=[('BROWSER', 'Браузер'), ('MESSENGER', 'Мессенджер')], max_length=16)), + ('enabled', models.BooleanField(default=False)), + ('types', models.JSONField(blank=True, default=chatballs.notifications.models.default_push_types)), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='notification_preferences', to='identity.organization')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_preferences', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'constraints': [models.UniqueConstraint(fields=('organization', 'user', 'transport'), name='uniq_notification_preference')], + }, + ), + migrations.RunPython(carry_over_push_types, restore_push_types), + migrations.RemoveField( + model_name='messengerbinding', + name='push_types', + ), + ] diff --git a/apps/backend/chatballs/notifications/migrations/0013_notification_types_from_baseline.py b/apps/backend/chatballs/notifications/migrations/0013_notification_types_from_baseline.py new file mode 100644 index 0000000..9a6a656 --- /dev/null +++ b/apps/backend/chatballs/notifications/migrations/0013_notification_types_from_baseline.py @@ -0,0 +1,89 @@ +"""Набор типов событий — из макета «Очередь и уведомления», фрейм Q1. + +Раньше «клиент запросил оператора» и «диалог долго ждёт» были одним типом +DIALOG_WAITING, и отписаться от второго, не потеряв первое, было нельзя. Теперь +это разные события, и к ним добавилось «диалог назначили на меня». + +Старые записи разделяются по dedup_key — единственному, что о них известно +задним числом: ключи эскалации начинаются с «waiting:», ключи назначения — с +«assign:». Всё остальное, что было DIALOG_WAITING, — это просьба о человеке. + +Выбор сотрудника в настройках переносится по тому же соответствию, иначе человек после +обновления молча перестал бы получать то, на что подписан. +""" + +from django.db import migrations, models + +# Старый код -> новый. RELEASE_PUBLISHED не переносится: он был заделом на +# будущее, никогда не отправлялся и в новом наборе ему места нет. +OLD_TO_NEW = { + "DIALOG_WAITING": "OPERATOR_REQUESTED", + "INTEGRATION_ERROR": "AI_STOPPED", +} + +SPLIT_BY_DEDUP_PREFIX = ( + ("waiting:", "DIALOG_WAITING_LONG"), + ("assign:", "DIALOG_ASSIGNED"), +) + + +def forward(apps, schema_editor): + Notification = apps.get_model("notifications", "Notification") + for old, new in OLD_TO_NEW.items(): + Notification.objects.filter(type=old).update(type=new) + for prefix, new in SPLIT_BY_DEDUP_PREFIX: + Notification.objects.filter( + type="OPERATOR_REQUESTED", dedup_key__startswith=prefix + ).update(type=new) + _remap_preferences(apps, {**OLD_TO_NEW}) + + +def backward(apps, schema_editor): + Notification = apps.get_model("notifications", "Notification") + reverse = { + "OPERATOR_REQUESTED": "DIALOG_WAITING", + "DIALOG_WAITING_LONG": "DIALOG_WAITING", + "DIALOG_ASSIGNED": "DIALOG_WAITING", + "AI_STOPPED": "INTEGRATION_ERROR", + } + for new, old in reverse.items(): + Notification.objects.filter(type=new).update(type=old) + _remap_preferences(apps, reverse) + + +def _remap_preferences(apps, mapping): + """Подписки сотрудников: коды внутри JSON-списка.""" + NotificationPreference = apps.get_model("notifications", "NotificationPreference") + for preference in NotificationPreference.objects.all().iterator(): + types = preference.types or [] + moved = [mapping.get(code, code) for code in types] + # Дубли возможны, когда два старых кода сходятся в один новый. + unique = list(dict.fromkeys(moved)) + if unique != types: + preference.types = unique + preference.save(update_fields=["types"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0012_notification_preference'), + ] + + operations = [ + migrations.RunPython(forward, backward), + migrations.AlterField( + model_name='notification', + name='type', + field=models.CharField( + choices=[ + ('OPERATOR_REQUESTED', 'Клиент запросил оператора'), + ('DIALOG_NEW_MESSAGE', 'Новое сообщение в моём диалоге'), + ('DIALOG_ASSIGNED', 'Диалог назначили на меня'), + ('DIALOG_WAITING_LONG', 'Диалог долго ждёт человека'), + ('AI_STOPPED', 'AI остановлен ошибкой или лимитом'), + ], + max_length=32, + ), + ), + ] diff --git a/apps/backend/chatballs/notifications/migrations/0014_new_dialog_type.py b/apps/backend/chatballs/notifications/migrations/0014_new_dialog_type.py new file mode 100644 index 0000000..0ab56ce --- /dev/null +++ b/apps/backend/chatballs/notifications/migrations/0014_new_dialog_type.py @@ -0,0 +1,69 @@ +"""«Новый диалог» — отдельное событие (макет Q1, дополнение владельца). + +В присланном наборе такого типа не было, и оклик о новом диалоге попал в +«клиент запросил оператора». Но диалог начинается и на канале с работающим +агентом, где человека никто не звал: называть это просьбой о человеке — врать. + +Старые записи узнаются по dedup_key «dialog:» — так помечены именно оклики о +новом диалоге. Разделить их по тому, был ли тогда доступен агент, задним числом +нельзя, поэтому все они становятся «новым диалогом»: это верно для +подавляющего большинства (канал с агентом — обычный случай) и, в отличие от +обратного, ничего не приписывает клиенту. + +Подписки: кому приходило «клиент запросил оператора», тому добавляется и «новый +диалог». До этой миграции это было одно событие, и человек не должен молча +перестать получать половину того, на что был подписан. +""" + +from django.db import migrations, models + + +def forward(apps, schema_editor): + Notification = apps.get_model("notifications", "Notification") + Notification.objects.filter( + type="OPERATOR_REQUESTED", dedup_key__startswith="dialog:" + ).update(type="NEW_DIALOG") + NotificationPreference = apps.get_model("notifications", "NotificationPreference") + for preference in NotificationPreference.objects.all().iterator(): + types = preference.types or [] + if "OPERATOR_REQUESTED" in types and "NEW_DIALOG" not in types: + preference.types = ["NEW_DIALOG", *types] + preference.save(update_fields=["types"]) + + +def backward(apps, schema_editor): + Notification = apps.get_model("notifications", "Notification") + Notification.objects.filter(type="NEW_DIALOG").update(type="OPERATOR_REQUESTED") + NotificationPreference = apps.get_model("notifications", "NotificationPreference") + for preference in NotificationPreference.objects.all().iterator(): + types = preference.types or [] + if "NEW_DIALOG" in types: + kept = [code for code in types if code != "NEW_DIALOG"] + preference.types = kept if "OPERATOR_REQUESTED" in kept else ["OPERATOR_REQUESTED", *kept] + preference.save(update_fields=["types"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0013_notification_types_from_baseline'), + ] + + operations = [ + migrations.AlterField( + model_name='notification', + name='type', + field=models.CharField( + choices=[ + ('NEW_DIALOG', 'Новый диалог'), + ('OPERATOR_REQUESTED', 'Клиент запросил оператора'), + ('DIALOG_NEW_MESSAGE', 'Новое сообщение в моём диалоге'), + ('DIALOG_ASSIGNED', 'Диалог назначили на меня'), + ('DIALOG_WAITING_LONG', 'Диалог долго ждёт человека'), + ('AI_STOPPED', 'AI остановлен ошибкой или лимитом'), + ], + max_length=32, + ), + ), + migrations.RunPython(forward, backward), + ] diff --git a/apps/backend/chatballs/notifications/models.py b/apps/backend/chatballs/notifications/models.py index 193edeb..857be84 100644 --- a/apps/backend/chatballs/notifications/models.py +++ b/apps/backend/chatballs/notifications/models.py @@ -8,11 +8,24 @@ from chatballs.tenancy.models import TenantRelationModel class NotificationType(models.TextChoices): - DIALOG_WAITING = "DIALOG_WAITING", "Диалог ждёт оператора" - DIALOG_NEW_MESSAGE = "DIALOG_NEW_MESSAGE", "Новое сообщение в диалоге" - # Задел на будущее (добавляются записью в реестр notifications.services.TYPE_META): - RELEASE_PUBLISHED = "RELEASE_PUBLISHED", "Опубликован релиз агента" - INTEGRATION_ERROR = "INTEGRATION_ERROR", "Ошибка интеграции" + """Что произошло. Набор — из макета «Очередь и уведомления», фрейм Q1. + + Это ровно тот список, который сотрудник видит галочками в профиле, поэтому + типы разделены по смыслу для человека, а не по месту в коде. «Клиент + запросил оператора» и «диалог долго ждёт» раньше были одним типом, и + отписаться от второго, не потеряв первое, было нельзя. + + «Новый диалог» стоит отдельно от «клиент запросил оператора» по той же + причине: диалог может начаться и на канале с работающим агентом, где + человека никто не звал, и называть такой оклик просьбой о человеке — врать. + """ + + NEW_DIALOG = "NEW_DIALOG", "Новый диалог" + OPERATOR_REQUESTED = "OPERATOR_REQUESTED", "Клиент запросил оператора" + DIALOG_NEW_MESSAGE = "DIALOG_NEW_MESSAGE", "Новое сообщение в моём диалоге" + DIALOG_ASSIGNED = "DIALOG_ASSIGNED", "Диалог назначили на меня" + DIALOG_WAITING_LONG = "DIALOG_WAITING_LONG", "Диалог долго ждёт человека" + AI_STOPPED = "AI_STOPPED", "AI остановлен ошибкой или лимитом" class NotificationLevel(models.TextChoices): @@ -47,6 +60,17 @@ class Notification(models.Model): target_route = models.CharField(max_length=64, blank=True) target_id = models.CharField(max_length=64, blank=True) audience = models.CharField(max_length=16, choices=NotificationAudience.choices, default=NotificationAudience.ALL) + # Граница видимости уведомления (ADR-CHATBALLS-0043 §4). Уведомление о диалоге + # видно тем же, кому виден сам диалог: иначе оператор чужой группы получает + # оклик с именем клиента и куском переписки, а открыть диалог не может. + # NULL — видно всем, кого пропускает аудитория. + audience_group = models.ForeignKey( + "identity.EmployeeGroup", + on_delete=models.SET_NULL, + related_name="notifications", + null=True, + blank=True, + ) recipient_user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True, related_name="direct_notifications") source_type = models.CharField(max_length=64, blank=True) source_id = models.CharField(max_length=64, blank=True) @@ -72,8 +96,57 @@ class NotificationRead(TenantRelationModel): def default_push_types() -> list[str]: - # Дефолт: диалоговые события (новый диалог / ждёт оператора / новое сообщение). - return [NotificationType.DIALOG_WAITING, NotificationType.DIALOG_NEW_MESSAGE] + # Дефолт — то, что касается лично и требует действия: позвали человека, + # написали в мой диалог, назначили на меня. «Долго ждёт» и сбой AI — + # подписка по желанию, иначе оклик обесценивается. + # «Новый диалог» сюда не входит: диалог, которым занимается агент, лично + # никого не касается. Кому он нужен — включит галочкой. + return [ + NotificationType.OPERATOR_REQUESTED, + NotificationType.DIALOG_NEW_MESSAGE, + NotificationType.DIALOG_ASSIGNED, + ] + + +class NotificationTransport(models.TextChoices): + BROWSER = "BROWSER", "Браузер" + MESSENGER = "MESSENGER", "Мессенджер" + + +class NotificationPreference(models.Model): + """Куда и о чём окликать сотрудника. + + Одна настройка на все транспорты. Браузер и бот в мессенджере отвечают на + один и тот же вопрос — «о чём меня звать», — и второй такой же список + галочек в профиле означал бы два расходящихся ответа на него. Новый + транспорт (почта, мобильное приложение) — это строка здесь, а не ещё одна + таблица настроек. + + Разрешение самого браузера тут не хранится: оно живёт в браузере, своё на + каждом устройстве, и сервер его ни выдать, ни отозвать не может. Здесь + только намерение человека — хочет ли он, чтобы его окликали. + """ + + organization = models.ForeignKey( + "identity.Organization", on_delete=models.PROTECT, related_name="notification_preferences" + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="notification_preferences" + ) + transport = models.CharField(max_length=16, choices=NotificationTransport.choices) + enabled = models.BooleanField(default=False) + # Подмножество NotificationType; пустой список — не звать вовсе. + types = models.JSONField(default=default_push_types, blank=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["organization", "user", "transport"], name="uniq_notification_preference" + ) + ] + + def __str__(self) -> str: + return f"pref:{self.user_id}/{self.transport}" class MessengerBinding(TenantRelationModel): @@ -87,8 +160,8 @@ class MessengerBinding(TenantRelationModel): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="messenger_bindings") integration = models.ForeignKey("integrations.Integration", on_delete=models.CASCADE, related_name="messenger_bindings") external_chat_id = models.CharField(max_length=128) - # Типы уведомлений, которые доставляются в мессенджер (подмножество NotificationType). - push_types = models.JSONField(default=default_push_types, blank=True) + # Типы событий живут не здесь, а в NotificationPreference: вопрос «о чём + # звать» один на все транспорты, и ответ на него обязан быть один. created_at = models.DateTimeField(auto_now_add=True) class Meta: diff --git a/apps/backend/chatballs/notifications/preferences.py b/apps/backend/chatballs/notifications/preferences.py new file mode 100644 index 0000000..f708abf --- /dev/null +++ b/apps/backend/chatballs/notifications/preferences.py @@ -0,0 +1,94 @@ +"""Настройки оклика сотрудника: о чём звать и каким транспортом. + +Раньше список типов жил на привязке к мессенджеру. Пока транспорт был один, это +работало; со вторым (браузер) в профиле появились бы два списка галочек про одно +и то же, и человек, отключивший «новое сообщение», продолжал бы получать его с +другой стороны. Поэтому список вынесен из привязки и заведён на транспорт. + +Про `enabled` важно не ошибиться: это согласие получать, и у двух транспортов оно +выражено по-разному. У мессенджера согласие — сама привязка бота: она есть, и +звать есть куда; нет — и `enabled` ничего не спасёт. У браузера предъявить нечего, +кроме намерения человека, поэтому его и храним. Отсюда две разные функции ниже: +доставка в мессенджер спрашивает только типы (получателей она уже отобрала по +наличию привязки), а браузер — типы вместе с согласием. +""" + +from __future__ import annotations + +from chatballs.notifications.models import ( + NotificationPreference, + NotificationTransport, + NotificationType, + default_push_types, +) + + +def preference_for( + *, organization_id: int, user_id: int, transport: str +) -> NotificationPreference: + """Настройка транспорта; при первом обращении заводится с дефолтами.""" + preference, _ = NotificationPreference.objects.get_or_create( + organization_id=organization_id, + user_id=user_id, + transport=transport, + defaults={"enabled": False, "types": default_push_types()}, + ) + return preference + + +def preference_types(*, organization_id: int, user_id: int, transport: str) -> list[str]: + """Типы, выбранные для транспорта. Без строки — дефолтный набор. + + Согласие не проверяется: у мессенджера его подтверждает привязка, и + отсутствие строки настроек не повод замолчать. + """ + preference = NotificationPreference.objects.filter( + organization_id=organization_id, user_id=user_id, transport=transport + ).first() + if preference is None: + return default_push_types() + return list(preference.types or []) + + +def enabled_types(*, organization_id: int, user_id: int, transport: str) -> list[str]: + """Типы транспорта, который человек включил. Не включил — пустой список.""" + preference = NotificationPreference.objects.filter( + organization_id=organization_id, user_id=user_id, transport=transport + ).first() + if preference is None or not preference.enabled: + return [] + return list(preference.types or []) + + +def update_preference( + *, + organization_id: int, + user_id: int, + transport: str, + enabled: bool | None = None, + types: list[str] | None = None, +) -> NotificationPreference: + preference = preference_for( + organization_id=organization_id, user_id=user_id, transport=transport + ) + fields = [] + if enabled is not None and preference.enabled != enabled: + preference.enabled = enabled + fields.append("enabled") + if types is not None: + # Чужие коды в список не пускаем: он приходит из браузера. + allowed = [code for code in types if code in NotificationType.values] + if preference.types != allowed: + preference.types = allowed + fields.append("types") + if fields: + preference.save(update_fields=fields) + return preference + + +def messenger_types(*, organization_id: int, user_id: int) -> list[str]: + return preference_types( + organization_id=organization_id, + user_id=user_id, + transport=NotificationTransport.MESSENGER, + ) diff --git a/apps/backend/chatballs/notifications/realtime.py b/apps/backend/chatballs/notifications/realtime.py new file mode 100644 index 0000000..3304ab3 --- /dev/null +++ b/apps/backend/chatballs/notifications/realtime.py @@ -0,0 +1,27 @@ +"""Оповещение сотрудника о том, что у него появились уведомления. + +Событие адресное — в сокет каждого получателя отдельно. Группа организации, на +которой живут события инбокса, здесь не годится: уведомления сотрудник видит не +все, и рассылка на организацию означала бы, что о чужих он как минимум узнаёт. + +Как и у диалогов, событие несёт только повод перезапросить: ни текста, ни +идентификаторов. Содержимое клиент забирает обычным запросом, где и живёт +проверка видимости — канал о ней не знает и потому не может в ней ошибиться. +""" + +from __future__ import annotations + +from collections.abc import Iterable + +from chatballs.realtime import publish + +NOTIFICATIONS_EVENT = "notifications.changed" + + +def user_group(user_id: int) -> str: + return f"user.{user_id}" + + +def notify_notifications_changed(user_ids: Iterable[int]) -> None: + for user_id in set(user_ids): + publish(user_group(user_id), {"type": NOTIFICATIONS_EVENT}) diff --git a/apps/backend/chatballs/notifications/recipients.py b/apps/backend/chatballs/notifications/recipients.py new file mode 100644 index 0000000..643d221 --- /dev/null +++ b/apps/backend/chatballs/notifications/recipients.py @@ -0,0 +1,71 @@ +"""Кому адресовано уведомление. + +Ответ нужен двум транспортам сразу — сокету открытого приложения и боту в +мессенджере, — поэтому он считается здесь один раз, а не в каждом из них. + +Раньше получатели искались перебором: для каждого сотрудника организации +выполнялся отдельный запрос `visible_for(...).exists()`. На сотню рабочих мест +это сотня запросов на каждое входящее сообщение — терпимо, только пока +уведомления доставляются раз в пятнадцать секунд. Здесь два запроса независимо +от размера организации: право выводится из роли, членство в группе — одним +IN-условием. +""" + +from __future__ import annotations + +from django.db.models import Q + +from chatballs.identity.models import OrganizationMembership +from chatballs.identity.policy import MANAGEMENT_ROLES, roles_with_capability +from chatballs.notifications.models import Notification, NotificationAudience + +# Аудитория — это право её читать. Зеркало notifications.selectors.visible_for: +# расходиться этим двум таблицам нельзя, иначе уведомление придёт тому, кто его +# потом не увидит в списке, или наоборот. +AUDIENCE_CAPABILITY = { + NotificationAudience.ALL: "company.view", + NotificationAudience.OPERATORS: "conversations.view", + NotificationAudience.OWNER: "employees.manage_privileged", +} + + +def audience_user_ids( + *, + organization_id: int, + audience: str, + group_id: int | None = None, + recipient_user_id: int | None = None, +) -> list[int]: + """Кого охватывает такая аудитория. + + Отдельно от уведомления, потому что спросить это нужно и до него: очередь + выясняет, есть ли вообще кому заметить ждущий диалог, ещё не решив, звать ли. + """ + if audience == NotificationAudience.USER: + return [recipient_user_id] if recipient_user_id else [] + capability = AUDIENCE_CAPABILITY.get(audience) + if capability is None: + return [] + memberships = OrganizationMembership.objects.filter( + organization_id=organization_id, + role__in=roles_with_capability(capability), + blocked_at__isnull=True, + user__is_active=True, + ) + if group_id is not None: + # Та же граница, что у диалога: руководство видит всё, остальные — свою + # группу (chatballs.identity.policy.conversation_visibility). + memberships = memberships.filter( + Q(role__in=MANAGEMENT_ROLES) | Q(group_links__group_id=group_id) + ).distinct() + return list(memberships.values_list("user_id", flat=True)) + + +def recipient_user_ids(notification: Notification) -> list[int]: + """Идентификаторы сотрудников, которым это уведомление адресовано.""" + return audience_user_ids( + organization_id=notification.organization_id, + audience=notification.audience, + group_id=notification.audience_group_id, + recipient_user_id=notification.recipient_user_id, + ) diff --git a/apps/backend/chatballs/notifications/selectors.py b/apps/backend/chatballs/notifications/selectors.py index 8f2f8e9..f19c714 100644 --- a/apps/backend/chatballs/notifications/selectors.py +++ b/apps/backend/chatballs/notifications/selectors.py @@ -1,7 +1,12 @@ from django.db.models import Q, QuerySet -from chatballs.identity.policy import ResourceScope, authorize +from chatballs.identity.policy import ( + ResourceScope, + authorize, + conversation_visibility, +) from chatballs.notifications.models import Notification, NotificationAudience +from chatballs.notifications.recipients import AUDIENCE_CAPABILITY from chatballs.tenancy.context import TenantContext @@ -11,16 +16,25 @@ def visible_for(context: TenantContext) -> QuerySet[Notification]: return Notification.objects.none() user = context.actor_user organization_scope = ResourceScope(profile.organization_id) - audiences = [] - if authorize(profile, "company.view", organization_scope): - audiences.append(NotificationAudience.ALL) - if authorize(profile, "conversations.view", organization_scope): - audiences.append(NotificationAudience.OPERATORS) - if authorize(profile, "employees.manage_privileged", organization_scope): - audiences.append(NotificationAudience.OWNER) + audiences = [ + audience + for audience, capability in AUDIENCE_CAPABILITY.items() + if authorize(profile, capability, organization_scope) + ] + by_audience = Q(audience__in=audiences) + scope = conversation_visibility(profile) + if scope is not None: + # Уведомление о диалоге группы читает тот же, кому виден сам диалог. + # Предикат намеренно взят тот же (ADR-CHATBALLS-0043 §4): два разных + # ответа на вопрос «кому это видно» — дефект, а не гибкость. Раньше + # уведомления фильтровались только правом, и оператор чужой группы + # получал оклик с именем клиента и куском переписки по диалогу, который + # не может открыть. + by_audience &= Q(audience_group__isnull=True) | Q( + audience_group_id__in=scope.get("group_ids") or () + ) return Notification.objects.filter(organization_id=profile.organization_id).filter( - Q(audience__in=audiences) - | Q(audience=NotificationAudience.USER, recipient_user=user) + by_audience | Q(audience=NotificationAudience.USER, recipient_user=user) ) diff --git a/apps/backend/chatballs/notifications/services.py b/apps/backend/chatballs/notifications/services.py index 9da099f..04e3084 100644 --- a/apps/backend/chatballs/notifications/services.py +++ b/apps/backend/chatballs/notifications/services.py @@ -20,25 +20,35 @@ from chatballs.notifications.selectors import unread_for # каждый сотрудник читает его на своём языке. Ярлык TextChoices остаётся # английским/русским значением модели и до человека не доходит. TYPE_META: dict[str, dict] = { - NotificationType.DIALOG_WAITING: { + NotificationType.NEW_DIALOG: { + "level": NotificationLevel.INFO, + "route": "chat", + "label": "notifications.type_new_dialog", + }, + NotificationType.OPERATOR_REQUESTED: { "level": NotificationLevel.WARNING, "route": "chat", - "label": "notifications.type_dialog_waiting", + "label": "notifications.type_operator_requested", }, NotificationType.DIALOG_NEW_MESSAGE: { "level": NotificationLevel.INFO, "route": "chat", "label": "notifications.type_dialog_new_message", }, - NotificationType.RELEASE_PUBLISHED: { - "level": NotificationLevel.SUCCESS, - "route": "agents", - "label": "notifications.type_release_published", + NotificationType.DIALOG_ASSIGNED: { + "level": NotificationLevel.INFO, + "route": "chat", + "label": "notifications.type_dialog_assigned", }, - NotificationType.INTEGRATION_ERROR: { + NotificationType.DIALOG_WAITING_LONG: { + "level": NotificationLevel.WARNING, + "route": "chat", + "label": "notifications.type_dialog_waiting_long", + }, + NotificationType.AI_STOPPED: { "level": NotificationLevel.CRITICAL, - "route": "integrations", - "label": "notifications.type_integration_error", + "route": "agents", + "label": "notifications.type_ai_stopped", }, } @@ -48,6 +58,7 @@ def notify( context, type: str, audience: str, + audience_group=None, title: str, body: str = "", title_key: str = "", @@ -71,6 +82,7 @@ def notify( organization=organization, type=type, audience=audience, + audience_group=audience_group, title=title, body=body, title_key=title_key, diff --git a/apps/backend/chatballs/notifications/test_group_visibility.py b/apps/backend/chatballs/notifications/test_group_visibility.py new file mode 100644 index 0000000..3ee83d4 --- /dev/null +++ b/apps/backend/chatballs/notifications/test_group_visibility.py @@ -0,0 +1,86 @@ +"""Уведомление о диалоге видно тем же, кому виден сам диалог. + +Регрессия: уведомления фильтровались только правом «видеть диалоги», а диалоги — +ещё и группой (ADR-CHATBALLS-0043 §4). Оператор чужой группы получал оклик с +именем клиента и куском переписки, а открыть диалог не мог. +""" + +from django.test import TestCase + +from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember +from chatballs.identity.models import ( + EmployeeRole, + HumanUser, + Organization, + OrganizationMembership, +) +from chatballs.notifications.models import NotificationAudience, NotificationType +from chatballs.notifications.recipients import recipient_user_ids +from chatballs.notifications.selectors import visible_for +from chatballs.notifications.services import notify +from chatballs.testing import tenant_context_for + + +class NotificationGroupVisibilityTests(TestCase): + def setUp(self) -> None: + self.organization = Organization.objects.create(name="Example", slug="notify-groups") + self.support = EmployeeGroup.objects.create( + organization=self.organization, name="Поддержка" + ) + self.sales = EmployeeGroup.objects.create(organization=self.organization, name="Продажи") + self.owner = self._employee("owner@notify.test", EmployeeRole.OWNER) + self.insider = self._employee("insider@notify.test", EmployeeRole.EMPLOYEE) + self.outsider = self._employee("outsider@notify.test", EmployeeRole.EMPLOYEE) + EmployeeGroupMember.objects.create( + organization=self.organization, group=self.support, employee=self.insider + ) + EmployeeGroupMember.objects.create( + organization=self.organization, group=self.sales, employee=self.outsider + ) + self.context = tenant_context_for(self.owner.user, self.organization) + + def _employee(self, email: str, role: str) -> OrganizationMembership: + user = HumanUser.objects.create_user(email=email, password="Password-123") + return OrganizationMembership.objects.create( + user=user, organization=self.organization, role=role, position_title="Specialist" + ) + + def _visible_ids(self, membership: OrganizationMembership) -> set[int]: + context = tenant_context_for(membership.user, self.organization) + return set(visible_for(context).values_list("id", flat=True)) + + def _waiting(self, group: EmployeeGroup | None): + return notify( + context=self.context, + type=NotificationType.OPERATOR_REQUESTED, + audience=NotificationAudience.OPERATORS, + audience_group=group, + title="Нужен оператор", + ) + + def test_group_notification_reaches_only_that_group_and_management(self) -> None: + notification = self._waiting(self.support) + self.assertIn(notification.id, self._visible_ids(self.insider)) + self.assertNotIn(notification.id, self._visible_ids(self.outsider)) + self.assertIn(notification.id, self._visible_ids(self.owner)) + + def test_notification_without_group_stays_visible_to_everyone(self) -> None: + notification = self._waiting(None) + for membership in (self.insider, self.outsider, self.owner): + self.assertIn(notification.id, self._visible_ids(membership)) + + def test_recipients_match_what_the_list_shows(self) -> None: + """Доставка и список обязаны отвечать одинаково. + + Иначе уведомление уходит в мессенджер тому, кто потом не находит его в + приложении, или наоборот. + """ + for group in (self.support, None): + notification = self._waiting(group) + delivered = set(recipient_user_ids(notification)) + listed = { + membership.user_id + for membership in (self.owner, self.insider, self.outsider) + if notification.id in self._visible_ids(membership) + } + self.assertEqual(delivered, listed) diff --git a/apps/backend/chatballs/notifications/test_realtime_fanout.py b/apps/backend/chatballs/notifications/test_realtime_fanout.py new file mode 100644 index 0000000..c45e5d2 --- /dev/null +++ b/apps/backend/chatballs/notifications/test_realtime_fanout.py @@ -0,0 +1,93 @@ +"""Уведомление доезжает до открытого приложения событием, а не опросом. + +Событие адресное: в личную группу каждого получателя. Организация целиком не +годится — сотрудник видит не все уведомления, и рассылка на всех означала бы, +что о чужих он как минимум узнаёт. +""" + +from unittest import mock + +from django.test import TestCase + +from chatballs.events.handlers import dispatch +from chatballs.events.models import OutboxEvent +from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember +from chatballs.identity.models import ( + EmployeeRole, + HumanUser, + Organization, + OrganizationMembership, +) +from chatballs.notifications.delivery import NOTIFICATION_CREATED +from chatballs.notifications.models import NotificationAudience, NotificationType +from chatballs.notifications.realtime import user_group +from chatballs.notifications.services import notify +from chatballs.testing import tenant_context_for + + +class NotificationFanoutTests(TestCase): + def setUp(self) -> None: + self.organization = Organization.objects.create(name="Example", slug="notify-fanout") + self.support = EmployeeGroup.objects.create( + organization=self.organization, name="Поддержка" + ) + self.owner = self._employee("owner@fanout.test", EmployeeRole.OWNER) + self.insider = self._employee("insider@fanout.test", EmployeeRole.EMPLOYEE) + self.outsider = self._employee("outsider@fanout.test", EmployeeRole.EMPLOYEE) + EmployeeGroupMember.objects.create( + organization=self.organization, group=self.support, employee=self.insider + ) + self.context = tenant_context_for(self.owner.user, self.organization) + + def _employee(self, email: str, role: str) -> OrganizationMembership: + user = HumanUser.objects.create_user(email=email, password="Password-123") + return OrganizationMembership.objects.create( + user=user, organization=self.organization, role=role, position_title="Specialist" + ) + + def _dispatch_last_event(self) -> list[str]: + """Разбирает событие из outbox и возвращает группы, в которые ушёл оклик.""" + event = OutboxEvent.objects.filter(event_type=NOTIFICATION_CREATED).latest("created_at") + with mock.patch("chatballs.notifications.realtime.publish") as publish: + dispatch(event) + return [call.args[0] for call in publish.call_args_list] + + def test_event_goes_to_personal_groups_of_recipients_only(self) -> None: + notify( + context=self.context, + type=NotificationType.OPERATOR_REQUESTED, + audience=NotificationAudience.OPERATORS, + audience_group=self.support, + title="Нужен оператор", + ) + groups = self._dispatch_last_event() + self.assertEqual( + set(groups), + {user_group(self.owner.user_id), user_group(self.insider.user_id)}, + ) + self.assertNotIn(user_group(self.outsider.user_id), groups) + + def test_event_carries_no_content(self) -> None: + """В канал уходит только повод перезапросить: ни текста, ни номера.""" + notify( + context=self.context, + type=NotificationType.OPERATOR_REQUESTED, + audience=NotificationAudience.OPERATORS, + title="Нужен оператор", + body="Клиент ждёт ответа", + ) + event = OutboxEvent.objects.filter(event_type=NOTIFICATION_CREATED).latest("created_at") + with mock.patch("chatballs.notifications.realtime.publish") as publish: + dispatch(event) + for call in publish.call_args_list: + self.assertEqual(call.args[1], {"type": "notifications.changed"}) + + def test_personal_notification_reaches_only_its_recipient(self) -> None: + notify( + context=self.context, + type=NotificationType.DIALOG_NEW_MESSAGE, + audience=NotificationAudience.USER, + recipient_user=self.outsider.user, + title="Новое сообщение", + ) + self.assertEqual(self._dispatch_last_event(), [user_group(self.outsider.user_id)]) diff --git a/apps/backend/chatballs/notifications/tests.py b/apps/backend/chatballs/notifications/tests.py index df65f6d..46d84fe 100644 --- a/apps/backend/chatballs/notifications/tests.py +++ b/apps/backend/chatballs/notifications/tests.py @@ -97,7 +97,7 @@ class DeliveryTests(NotifierTestBase): with mock.patch("chatballs.notifications.delivery.transports.send_reply", return_value=True) as send: notify( context=self.context, - type=NotificationType.DIALOG_WAITING, + type=NotificationType.OPERATOR_REQUESTED, audience=NotificationAudience.OPERATORS, title="Новый диалог · Acme — сайт", body="Гость · TELEGRAM: Привет", @@ -108,11 +108,11 @@ class DeliveryTests(NotifierTestBase): self.assertIn("Новый диалог", send.call_args.kwargs["text"]) def test_type_not_in_push_types_is_skipped(self) -> None: - # INTEGRATION_ERROR не входит в дефолтные push_types привязки. + # AI_STOPPED не входит в дефолтный набор типов. with mock.patch("chatballs.notifications.delivery.transports.send_reply", return_value=True) as send: notify( context=self.context, - type=NotificationType.INTEGRATION_ERROR, + type=NotificationType.AI_STOPPED, audience=NotificationAudience.OWNER, title="Ошибка интеграции", ) @@ -136,13 +136,13 @@ class DeliveryTests(NotifierTestBase): operator = HumanUser.objects.get(email="staff.member@example.org") operators_notification = notify( context=self.context, - type=NotificationType.DIALOG_WAITING, + type=NotificationType.OPERATOR_REQUESTED, audience=NotificationAudience.OPERATORS, title="Waiting dialog", ) notify( context=self.context, - type=NotificationType.INTEGRATION_ERROR, + type=NotificationType.AI_STOPPED, audience=NotificationAudience.OWNER, title="Owner-only notice", ) @@ -245,13 +245,13 @@ class BindingApiTests(NotifierTestBase): def test_push_types_patch_and_listing(self) -> None: MessengerBinding.objects.create(user=self.owner, integration=self.integration, external_chat_id="777") listed = self.client.get("/api/v1/notifications/messenger-bindings/").json() - self.assertIn({"code": NotificationType.DIALOG_WAITING, "label": "Диалог ждёт оператора"}, listed["availableTypes"]) - self.assertIn(NotificationType.DIALOG_WAITING, listed["items"][0]["pushTypes"]) + self.assertIn({"code": NotificationType.OPERATOR_REQUESTED, "label": "Клиент запросил оператора"}, listed["availableTypes"]) + self.assertIn(NotificationType.OPERATOR_REQUESTED, listed["items"][0]["pushTypes"]) patched = self.client.patch( f"/api/v1/notifications/messenger-bindings/{self.integration.id}/", - data={"pushTypes": [NotificationType.INTEGRATION_ERROR, "NOT_A_TYPE"]}, + data={"pushTypes": [NotificationType.AI_STOPPED, "NOT_A_TYPE"]}, format="json", ) self.assertEqual(patched.status_code, 200) - self.assertEqual(patched.json()["pushTypes"], [NotificationType.INTEGRATION_ERROR]) + self.assertEqual(patched.json()["pushTypes"], [NotificationType.AI_STOPPED]) diff --git a/apps/backend/chatballs/notifications/urls.py b/apps/backend/chatballs/notifications/urls.py index d9d6bce..f5dafb3 100644 --- a/apps/backend/chatballs/notifications/urls.py +++ b/apps/backend/chatballs/notifications/urls.py @@ -7,4 +7,5 @@ urlpatterns = [ path("read/", views.NotificationReadView.as_view(), name="notification-read"), path("messenger-bindings/", views.MessengerBindingListView.as_view(), name="messenger-binding-list"), path("messenger-bindings//", views.MessengerBindingDetailView.as_view(), name="messenger-binding-detail"), + path("preferences//", views.NotificationPreferenceView.as_view(), name="notification-preference"), ] diff --git a/apps/backend/chatballs/notifications/views.py b/apps/backend/chatballs/notifications/views.py index c821a76..6cb4b19 100644 --- a/apps/backend/chatballs/notifications/views.py +++ b/apps/backend/chatballs/notifications/views.py @@ -5,7 +5,16 @@ from rest_framework.views import APIView from chatballs.i18n import t from chatballs.notifications.binding import deep_link, issue_binding_code, notifier_integrations -from chatballs.notifications.models import MessengerBinding, NotificationRead, NotificationType +from chatballs.notifications.models import ( + MessengerBinding, + NotificationRead, + NotificationTransport, +) +from chatballs.notifications.preferences import ( + preference_for, + preference_types, + update_preference, +) from chatballs.notifications.selectors import unread_for, visible_for from chatballs.notifications.serializers import notification_payload from chatballs.notifications.services import TYPE_META, mark_read @@ -42,6 +51,11 @@ class MessengerBindingListView(APIView): integration__organization=request.tenant_context.organization, ) } + messenger_types = preference_types( + organization_id=request.tenant_context.organization_id, + user_id=request.user.id, + transport=NotificationTransport.MESSENGER, + ) items = [] for integration in notifier_integrations(request.tenant_context): binding = bindings.get(integration.id) @@ -52,7 +66,7 @@ class MessengerBindingListView(APIView): "name": integration.name, "botUsername": integration.config.get("bot_username", ""), "bound": binding is not None, - "pushTypes": binding.push_types if binding else [], + "pushTypes": messenger_types if binding else [], } ) # Реестр типов для чекбоксов в профиле (порядок — как в TYPE_META). @@ -91,12 +105,23 @@ class MessengerBindingDetailView(APIView): types = request.data.get("pushTypes") if not isinstance(types, list): return Response({"detail": t("notifications.push_types_list")}, status=400) - binding.push_types = [code for code in types if code in NotificationType.values] - binding.save(update_fields=["push_types"]) - return Response({"pushTypes": binding.push_types}) + preference = update_preference( + organization_id=request.tenant_context.organization_id, + user_id=request.user.id, + transport=NotificationTransport.MESSENGER, + types=types, + ) + return Response({"pushTypes": preference.types}) def delete(self, request: Request, integration_id: int) -> Response: MessengerBinding.objects.filter(user=request.user, integration_id=integration_id).delete() + # Отвязали бота — звать этим транспортом больше некуда. + update_preference( + organization_id=request.tenant_context.organization_id, + user_id=request.user.id, + transport=NotificationTransport.MESSENGER, + enabled=False, + ) return Response({"ok": True}) @@ -112,3 +137,54 @@ class NotificationReadView(APIView): return Response({"detail": t("notifications.ids_list_or_all")}, status=400) mark_read(context=request.tenant_context, ids=[i for i in ids if isinstance(i, int)]) return Response({"unreadCount": unread_for(request.tenant_context).count()}) + + +class NotificationPreferenceView(APIView): + """Настройка транспорта: включён ли он и о чём звать. + + Мессенджер настраивается через свою привязку (там же, где бот), браузеру + отдельной сущности не нужно — разрешение живёт в самом браузере, а сюда + попадает только намерение человека. + """ + + permission_classes = [IsAuthenticated] + + def get(self, request: Request, transport: str) -> Response: + if transport not in NotificationTransport.values: + return Response({"detail": t("notifications.unknown_transport")}, status=404) + preference = preference_for( + organization_id=request.tenant_context.organization_id, + user_id=request.user.id, + transport=transport, + ) + return Response(_preference_payload(preference)) + + def patch(self, request: Request, transport: str) -> Response: + if transport not in NotificationTransport.values: + return Response({"detail": t("notifications.unknown_transport")}, status=404) + types = request.data.get("types") + if types is not None and not isinstance(types, list): + return Response({"detail": t("notifications.push_types_list")}, status=400) + enabled = request.data.get("enabled") + if enabled is not None and not isinstance(enabled, bool): + return Response({"detail": t("notifications.enabled_bool")}, status=400) + preference = update_preference( + organization_id=request.tenant_context.organization_id, + user_id=request.user.id, + transport=transport, + enabled=enabled, + types=types, + ) + return Response(_preference_payload(preference)) + + +def _preference_payload(preference) -> dict: + return { + "transport": preference.transport, + "enabled": preference.enabled, + "types": list(preference.types or []), + # Реестр типов — тот же, что у мессенджера: вопрос один на все транспорты. + "availableTypes": [ + {"code": code, "label": t(meta["label"])} for code, meta in TYPE_META.items() + ], + } diff --git a/apps/backend/chatballs/presence.py b/apps/backend/chatballs/presence.py new file mode 100644 index 0000000..b67f39c --- /dev/null +++ b/apps/backend/chatballs/presence.py @@ -0,0 +1,67 @@ +"""Кто из сотрудников сейчас за рабочим местом. + +Присутствие берётся из открытого сокета, а не из отдельной подсистемы: сокет и +есть признак того, что приложение открыто. Отдельная «доступность» со статусами, +сменами и графиками — это другая, большая задача, и для того, ради чего +присутствие понадобилось, она не нужна. + +Хранится не флаг, а момент последней активности. Флага хватало очереди («есть ли +кому заметить»), но выбору ответственного нужно ещё и «не в приложении · 25 +минут»: разница между «вышел налить кофе» и «ушёл вчера» — это разница между +хорошим и плохим назначением. Отметка обновляется на подключении, на heartbeat +раз в минуту и на обрыве, поэтому оборванное соединение само перестаёт быть +присутствием, без уборщика. + +Это подсказка, а не право. Ошибиться она может в обе стороны: ноутбук уснул, а +отметка ещё свежая; сеть моргнула, и человек на секунду «исчез». Поэтому ни один +доступ и ни одна доставка на присутствии не стоят — оно только помогает решить, +сколько ещё ждать и кого предложить первым. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from datetime import datetime + +from django.core.cache import cache +from django.utils import timezone + +# Клиент шлёт heartbeat раз в минуту; запас вдвое с небольшим покрывает +# пропущенный тик и не держит призраков дольше пары минут. +ONLINE_WITHIN_SECONDS = 150 +# Сколько помним ушедшего, чтобы сказать, как давно его нет. Дольше суток это +# уже не «недавно вышел», а «здесь не работает», и точность не нужна. +REMEMBER_SECONDS = 24 * 3600 + + +def _key(organization_id: int, user_id: int) -> str: + return f"presence:{organization_id}:{user_id}" + + +def touch(organization_id: int, user_id: int) -> None: + """Отметить, что человек сейчас в приложении.""" + cache.set(_key(organization_id, user_id), timezone.now().timestamp(), REMEMBER_SECONDS) + + +def last_seen(organization_id: int, user_ids: Iterable[int]) -> dict[int, datetime]: + """Когда каждого из перечисленных видели в последний раз.""" + wanted = list(user_ids) + if not wanted: + return {} + keys = {_key(organization_id, user_id): user_id for user_id in wanted} + found = cache.get_many(list(keys)) + return { + keys[key]: datetime.fromtimestamp(value, tz=timezone.get_current_timezone()) + for key, value in found.items() + if isinstance(value, (int, float)) + } + + +def online_user_ids(organization_id: int, user_ids: Iterable[int]) -> set[int]: + """Кто из перечисленных на месте прямо сейчас.""" + now = timezone.now() + return { + user_id + for user_id, seen in last_seen(organization_id, user_ids).items() + if (now - seen).total_seconds() <= ONLINE_WITHIN_SECONDS + } diff --git a/apps/backend/chatballs/realtime.py b/apps/backend/chatballs/realtime.py new file mode 100644 index 0000000..794ab9d --- /dev/null +++ b/apps/backend/chatballs/realtime.py @@ -0,0 +1,31 @@ +"""Публикация оповещений в канальный слой. + +Транспорт и только транспорт. Какие бывают группы и какие события в них летят, +знают доменные модули (conversations.realtime, notifications.realtime); здесь +единственное в проекте знание про channels — и единственное место, где сбой +канала гасится, чтобы не уронить уже сделанную запись. +""" + +from __future__ import annotations + +import logging + +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer + +logger = logging.getLogger(__name__) + + +def publish(group: str, payload: dict[str, object]) -> None: + """Оповещение — вспомогательный путь: его сбой не должен ронять запись. + + Данные к моменту отправки уже сохранены; если канал недоступен, клиент + узнает об изменении следующим опросом — он остаётся как запасной путь. + """ + layer = get_channel_layer() + if layer is None: + return + try: + async_to_sync(layer.group_send)(group, {"type": "fanout", "payload": payload}) + except Exception: # noqa: BLE001 — канал не должен ломать сохранение + logger.warning("realtime fanout failed for %s", group, exc_info=True) diff --git a/apps/backend/chatballs/tenancy/migrations/0036_notification_preference_guards.py b/apps/backend/chatballs/tenancy/migrations/0036_notification_preference_guards.py new file mode 100644 index 0000000..0e1d19c --- /dev/null +++ b/apps/backend/chatballs/tenancy/migrations/0036_notification_preference_guards.py @@ -0,0 +1,47 @@ +# Настройки оклика (notifications_notificationpreference): tenant-таблица с +# organization_id, RLS и гранты по образцу tenancy/0022. Настройка принадлежит +# сотруднику в организации, читает и пишет её runtime-роль app. +from django.db import migrations + +TABLES = ("notifications_notificationpreference",) + +FORWARD_RLS = """ +ALTER TABLE {table} OWNER TO chatballs_schema; +ALTER TABLE {table} ENABLE ROW LEVEL SECURITY; +ALTER TABLE {table} FORCE ROW LEVEL SECURITY; +REVOKE ALL ON {table} FROM PUBLIC; +GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO chatballs_runtime_app; +GRANT ALL ON {table} TO chatballs_schema; +GRANT USAGE, SELECT ON SEQUENCE {table}_id_seq + TO chatballs_runtime_app, chatballs_schema; +DROP POLICY IF EXISTS chatballs_tenant_isolation ON {table}; +CREATE POLICY chatballs_tenant_isolation ON {table} + FOR ALL TO chatballs_runtime_app + USING (organization_id = chatballs.current_organization_id()) + WITH CHECK (organization_id = chatballs.current_organization_id()); +DROP POLICY IF EXISTS chatballs_schema_access ON {table}; +CREATE POLICY chatballs_schema_access ON {table} + FOR ALL TO chatballs_schema USING (true) WITH CHECK (true); +""" + + +def apply_guards(apps, schema_editor): + for table in TABLES: + schema_editor.execute(FORWARD_RLS.format(table=table)) + + +def remove_guards(apps, schema_editor): + for table in TABLES: + schema_editor.execute(f"DROP POLICY IF EXISTS chatballs_tenant_isolation ON {table}") + schema_editor.execute(f"DROP POLICY IF EXISTS chatballs_schema_access ON {table}") + schema_editor.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY") + schema_editor.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY") + + +class Migration(migrations.Migration): + dependencies = [ + ("tenancy", "0035_organization_creation_and_invitation_directory"), + ("notifications", "0012_notification_preference"), + ] + + operations = [migrations.RunPython(apply_guards, remove_guards)] diff --git a/apps/backend/chatballs/tenancy/migrations/0037_queue_policy_guards.py b/apps/backend/chatballs/tenancy/migrations/0037_queue_policy_guards.py new file mode 100644 index 0000000..b168c9d --- /dev/null +++ b/apps/backend/chatballs/tenancy/migrations/0037_queue_policy_guards.py @@ -0,0 +1,47 @@ +# Пороги очереди (conversations_queueescalationpolicy): tenant-таблица с +# organization_id, RLS и гранты по образцу tenancy/0022. Читает её воркер под +# ролью app, когда обходит ждущие диалоги организации. +from django.db import migrations + +TABLES = ("conversations_queueescalationpolicy",) + +FORWARD_RLS = """ +ALTER TABLE {table} OWNER TO chatballs_schema; +ALTER TABLE {table} ENABLE ROW LEVEL SECURITY; +ALTER TABLE {table} FORCE ROW LEVEL SECURITY; +REVOKE ALL ON {table} FROM PUBLIC; +GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO chatballs_runtime_app; +GRANT ALL ON {table} TO chatballs_schema; +GRANT USAGE, SELECT ON SEQUENCE {table}_id_seq + TO chatballs_runtime_app, chatballs_schema; +DROP POLICY IF EXISTS chatballs_tenant_isolation ON {table}; +CREATE POLICY chatballs_tenant_isolation ON {table} + FOR ALL TO chatballs_runtime_app + USING (organization_id = chatballs.current_organization_id()) + WITH CHECK (organization_id = chatballs.current_organization_id()); +DROP POLICY IF EXISTS chatballs_schema_access ON {table}; +CREATE POLICY chatballs_schema_access ON {table} + FOR ALL TO chatballs_schema USING (true) WITH CHECK (true); +""" + + +def apply_guards(apps, schema_editor): + for table in TABLES: + schema_editor.execute(FORWARD_RLS.format(table=table)) + + +def remove_guards(apps, schema_editor): + for table in TABLES: + schema_editor.execute(f"DROP POLICY IF EXISTS chatballs_tenant_isolation ON {table}") + schema_editor.execute(f"DROP POLICY IF EXISTS chatballs_schema_access ON {table}") + schema_editor.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY") + schema_editor.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY") + + +class Migration(migrations.Migration): + dependencies = [ + ("tenancy", "0036_notification_preference_guards"), + ("conversations", "0023_queue_escalation"), + ] + + operations = [migrations.RunPython(apply_guards, remove_guards)] diff --git a/apps/internal-ui/src/App.tsx b/apps/internal-ui/src/App.tsx index 9adae18..41bbd45 100644 --- a/apps/internal-ui/src/App.tsx +++ b/apps/internal-ui/src/App.tsx @@ -13,6 +13,7 @@ import { fetchAgentDirectory } from "./features/agents/model"; import { canAccess, defaultRoute, isManager } from "./auth/access"; import { activateOrganization, clearOrganizationPreference } from "./auth/session"; import { AuthChangePassword, AuthChooseOrganization, AuthJoin, AuthJoinGuest, AuthLogin, AuthPasswordRecovery, AuthResetPassword, AuthSetup, AuthTotpCode, AuthTotpSetup } from "./features/auth/AuthScreens"; +import { RealtimeProvider } from "./features/realtime/RealtimeProvider"; import { Shell } from "./layout/Shell"; import { pathFromRoute, routeFromPath } from "./router"; import { ErrorScreen, LoadingScreen, PermissionScreen } from "./shared/ui"; @@ -246,7 +247,11 @@ export function App() { ) : !canAccess(user, navigation.route) ? ( navigate(defaultRoute(user), null, true)} /> ) : ( - navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} portalSettingsSection={navigation.selectedPortalSection} openPortalSettingsRoute={(portalId, section) => navigate("supportPortalSettings", `${portalId}/${section ?? ""}`)} settingsSection={navigation.selectedSettingsSection} openSettingsRoute={(section) => navigate("settings", section)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("knowledgeDetail", knowledgeId)} openKnowledgeEditorRoute={(knowledgeId) => (knowledgeId === null ? navigate("knowledgeCreate") : navigate("knowledgeEdit", knowledgeId))} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} onSwitchOrganization={switchOrganization} onOrganizationCreated={finishOrganizationCreate} /> + // Ключ организации — на провайдере: при переключении сокет обязан + // переоткрыться на адрес новой, а не остаться на прежней. + + navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} portalSettingsSection={navigation.selectedPortalSection} openPortalSettingsRoute={(portalId, section) => navigate("supportPortalSettings", `${portalId}/${section ?? ""}`)} settingsSection={navigation.selectedSettingsSection} openSettingsRoute={(section) => navigate("settings", section)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("knowledgeDetail", knowledgeId)} openKnowledgeEditorRoute={(knowledgeId) => (knowledgeId === null ? navigate("knowledgeCreate") : navigate("knowledgeEdit", knowledgeId))} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} onSwitchOrganization={switchOrganization} onOrganizationCreated={finishOrganizationCreate} /> + )} ); diff --git a/apps/internal-ui/src/features/chat/ChatPage.tsx b/apps/internal-ui/src/features/chat/ChatPage.tsx index bd3fd8e..413ae72 100644 --- a/apps/internal-ui/src/features/chat/ChatPage.tsx +++ b/apps/internal-ui/src/features/chat/ChatPage.tsx @@ -59,7 +59,7 @@ export function ChatPage({ ? {t("admin.see_every_conversation_organization_operators")} : undefined} viewerId={user.id} - renderContextPanel={({ dialog, detail, applyConversation, startCall, closeContext }) => ( + renderContextPanel={({ dialog, detail, applyConversation, startCall, closeContext, assignmentTimeoutMinutes }) => ( )} /> @@ -86,6 +87,7 @@ function ChatContextPanel({ startCall, closeContext, viewerId, + assignmentTimeoutMinutes, }: { rightTab: ChatRightTab; setRightTab: (tab: ChatRightTab) => void; @@ -96,6 +98,7 @@ function ChatContextPanel({ startCall: ((kind: "AUDIO" | "VIDEO") => void) | null; closeContext: () => void; viewerId: number; + assignmentTimeoutMinutes?: number; }) { return (
@@ -106,7 +109,7 @@ function ChatContextPanel({
{rightTab === "client" && ( - + )} {rightTab === "history" && }
diff --git a/apps/internal-ui/src/features/conversations/ConversationThread.tsx b/apps/internal-ui/src/features/conversations/ConversationThread.tsx index 6560ecb..c177d79 100644 --- a/apps/internal-ui/src/features/conversations/ConversationThread.tsx +++ b/apps/internal-ui/src/features/conversations/ConversationThread.tsx @@ -97,9 +97,16 @@ function MessageRow({ message, dialog, viewerId }: { message: ApiMessage; dialog // возврат — акцент. Тон выбирается по коду события, а не по словам в // тексте: текст приходит на языке читателя и на английском не совпал бы // ни с одной русской регуляркой. - const tone = message.systemEvent === "ai_handed_over" || message.systemEvent === "ai_unavailable" + // «Не взял — вернулся в очередь» окрашено предупреждением намеренно (макет + // Q4): это не факт из истории, а сорванная договорённость. + const tone = message.systemEvent === "ai_handed_over" + || message.systemEvent === "ai_unavailable" + || message.systemEvent === "assignment_expired" ? "warning" - : message.systemEvent === "operator_took" || message.systemEvent === "returned_to_ai" || message.systemEvent === "returned_to_queue" + : message.systemEvent === "operator_took" + || message.systemEvent === "returned_to_ai" + || message.systemEvent === "returned_to_queue" + || message.systemEvent === "assigned_to" ? "claimed" : ""; return
{message.text}·{fmtTime(message.createdAt)}
; diff --git a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx index 7d25e19..99fe719 100644 --- a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx +++ b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx @@ -59,7 +59,7 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi isOwner?: boolean; listTitle?: string; searchPlaceholder?: string; - renderContextPanel: (ctx: { dialog: ConversationListItem | null; detail: ApiConversation | null; applyConversation: (updated: ApiConversation) => void; startCall: ((kind: "AUDIO" | "VIDEO") => void) | null; closeContext: () => void }) => ReactNode; + renderContextPanel: (ctx: { dialog: ConversationListItem | null; detail: ApiConversation | null; applyConversation: (updated: ApiConversation) => void; startCall: ((kind: "AUDIO" | "VIDEO") => void) | null; closeContext: () => void; assignmentTimeoutMinutes?: number }) => ReactNode; viewerId?: number | null; mobileHeader?: (info: { total: number }) => ReactNode; hint?: ReactNode; @@ -92,7 +92,8 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi const settledSearch = useDebounced(search.trim()); const query = useMemo(() => ({ ...scopeFilters(scope), - ...(listTab === "wait" ? { waiting: true } : {}), + ...(listTab === "queue" ? { queue: true } : {}), + ...(listTab === "onMe" ? { waitingOnMe: true } : {}), ...(listTab === "mine" ? { assigned: "me" as const } : {}), ...(settledSearch ? { q: settledSearch } : {}), sort, @@ -154,7 +155,10 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi const callController = useConversationCall({ conversationId: selectedId, onConversationChanged }); useIncomingMessageSound(list.conversations, list.loaded); - const dialogs = useMemo(() => list.conversations.map(toConversationListItem), [list.conversations]); + const dialogs = useMemo( + () => list.conversations.map((item) => toConversationListItem(item, { viewerId, assignmentTimeoutMinutes: counters?.assignmentTimeoutMinutes })), + [list.conversations, viewerId, counters?.assignmentTimeoutMinutes], + ); useDialogKeyboardNav({ dialogs, selectedId, @@ -280,6 +284,7 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi startCall: detail?.lifecycle === "OPEN" && (detail.connection?.audioCalls || detail.connection?.videoCalls) ? (kind) => void callController.start(kind) : null, // Кадр S2: выдвижная панель закрывается крестиком в её шапке. closeContext: () => setCtxOpen(false), + assignmentTimeoutMinutes: counters?.assignmentTimeoutMinutes, })} ); diff --git a/apps/internal-ui/src/features/conversations/DialogControls.tsx b/apps/internal-ui/src/features/conversations/DialogControls.tsx index c106a71..bdcdc53 100644 --- a/apps/internal-ui/src/features/conversations/DialogControls.tsx +++ b/apps/internal-ui/src/features/conversations/DialogControls.tsx @@ -3,6 +3,8 @@ import { useEffect, useState } from "react"; import { Icon } from "../../shared/icons"; import { SearchInput } from "../../shared/ui-controls"; +import { assigneeMenuItems, initials, SmallAvatar } from "./assigneeOptions"; +import { WaitingBlock } from "./WaitingBlock"; import { PriorityBars } from "./DialogList"; import { useEmployeeDirectory } from "./useEmployeeDirectory"; import { statusFor } from "./data"; @@ -42,21 +44,19 @@ const PRIORITY_TEXT: Record = { NONE: "var(--n-4)", }; -function initials(name: string): string { - const parts = name.trim().split(/\s+/).filter(Boolean); - return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase(); -} - export function DialogControls({ detail, groups, applyConversation, viewerId = null, + assignmentTimeoutMinutes, }: { detail: ApiConversation; groups: Array; applyConversation: (updated: ApiConversation) => void; viewerId?: number | null; + /** Срок личной очереди из счётчиков: по нему считается «вернётся через». */ + assignmentTimeoutMinutes?: number; }) { const directory = useEmployeeDirectory(); const [busy, setBusy] = useState(false); @@ -111,6 +111,7 @@ export function DialogControls({ const assignedIds = new Set(detail.labels.map((item) => item.id)); const availableLabels = labels.filter((item) => !assignedIds.has(item.id)); const assignee = detail.assignedOperator; + const assigneeHere = assignee ? directory.employees.some((employee) => employee.id === assignee.id && employee.online) : false; const assigneeLabel = assignee ? `${assignee.name}${viewerId != null && assignee.id === viewerId ? t("common.you_suffix") : ""}` : t("conversations.unassigned"); const status = statusFor(controlModeOf(detail), assignee?.name); const priorityLabel = PRIORITY_OPTIONS.find(([value]) => value === detail.priority)?.[1] ?? t("conversations.not_set"); @@ -126,6 +127,7 @@ export function DialogControls({ {!collapsed && (
{errorText &&

{errorText}

} + - ), - }] - : []), - { key: "none", label: }, - ...directory.employees.map((employee) => ({ - key: employee.id, - label: , - })), - ], + items: assigneeMenuItems({ + directory, + viewerId, + assigneeId: assignee?.id, + onPick: (id) => void run(() => setConversationAssignee(detail.id, id)), + }), }} > @@ -272,7 +257,10 @@ export function DialogControls({ ))}
-
{t("conversations.started")}{startedLabel(detail.createdAt)}
+
+ {detail.waitingSince ? t("conversations.in_queue_since") : t("conversations.started")} + {startedLabel(detail.waitingSince ?? detail.createdAt)} +
)}
@@ -341,7 +329,4 @@ export function archiveConversationAction( } // Аватар 22px в поле «Ответственный»: фото сотрудника или инициалы. -function SmallAvatar({ name, avatarUrl }: { name: string; avatarUrl?: string | null }) { - if (avatarUrl) return ; - return {initials(name)}; -} + diff --git a/apps/internal-ui/src/features/conversations/DialogList.tsx b/apps/internal-ui/src/features/conversations/DialogList.tsx index 10f9bf9..f0f0dc9 100644 --- a/apps/internal-ui/src/features/conversations/DialogList.tsx +++ b/apps/internal-ui/src/features/conversations/DialogList.tsx @@ -43,7 +43,8 @@ export function DialogList({ title = t("common.conversations"), searchPlaceholde setSelectedId: (id: number) => void; }) { // Счётчик ждущих — из счётчиков охвата, а не из загруженного окна. - const waitCount = counters?.waiting ?? 0; + const queueCount = counters?.queue ?? 0; + const onMeCount = counters?.waitingOnMe ?? 0; // Лента догружается прокруткой: следующее окно запрашивается на подходе к низу. const onScroll = useCallback((event: UIEvent) => { if (!hasMore) return; @@ -83,9 +84,14 @@ export function DialogList({ title = t("common.conversations"), searchPlaceholde
setListTab("all")}>{t("common.all")} setListTab("mine")}>{t("conversations.mine")} - setListTab("wait")}>{t("conversations.waiting_for_operator")}{waitCount > 0 && {waitCount}} + setListTab("queue")}>{t("conversations.common_queue")}{queueCount > 0 && {queueCount}} + setListTab("onMe")}>{t("conversations.on_me")}{onMeCount > 0 && {onMeCount}}
{hint} + {/* Что значат две вкладки ожидания — прямо там, где их выбирают. */} + {(listTab === "queue" || listTab === "onMe") && ( +
{t("conversations.queue_tabs_note")}
+ )}
{errorText &&
{errorText}
} {/* Кадр S1: пустой список без призыва к действию. Под фильтром и @@ -131,9 +137,10 @@ function DialogListItem({ dialog, active, setSelectedId }: { dialog: Conversatio {dialog.preview} {dialog.unread > 0 && {dialog.unread}} - {(dialog.waitLabel || dialog.groupName || dialog.labels.length > 0) && ( + {(dialog.waitLabel || dialog.mineLabel || dialog.groupName || dialog.labels.length > 0) && ( {dialog.waitLabel && {dialog.waitLabel}} + {dialog.mineLabel && {dialog.mineLabel}} {dialog.groupName && {dialog.groupName}} {dialog.labels.map((label) => ( {label.name} diff --git a/apps/internal-ui/src/features/conversations/WaitingBlock.tsx b/apps/internal-ui/src/features/conversations/WaitingBlock.tsx new file mode 100644 index 0000000..29a9ce3 --- /dev/null +++ b/apps/internal-ui/src/features/conversations/WaitingBlock.tsx @@ -0,0 +1,54 @@ +import { Icon } from "../../shared/icons"; +import { t, tn } from "../../i18n"; +import { SmallAvatar } from "./assigneeOptions"; +import { waitLabelOf, type ApiConversation } from "./model"; + +// Блок «Ожидание» в карточке диалога (макет «Очередь и уведомления», кадр Q4). +// +// Отвечает на два вопроса, которые иначе оператору взять негде: сколько клиент +// уже ждёт человека и почему диалог виден, но брать его не нужно. Второе — +// единственное объяснение назначенному, но не взятому диалогу: без него он +// выглядит как обычный ждущий, который почему-то не даёт себя взять. + +/** «через 3 минуты» — сколько осталось личной очереди. */ +function leftFor(assignedAt: string, timeoutMinutes: number, now: Date): string | null { + const deadline = new Date(assignedAt).getTime() + timeoutMinutes * 60000; + const minutes = Math.round((deadline - now.getTime()) / 60000); + return minutes > 0 ? tn("plural.minutes", minutes) : null; +} + +export function WaitingBlock({ + detail, + timeoutMinutes, + now = new Date(), +}: { + detail: ApiConversation; + timeoutMinutes?: number; + now?: Date; +}) { + if (detail.lifecycle !== "OPEN" || detail.controlMode !== "PAUSED" || !detail.waitingSince) { + return null; + } + const assignee = detail.assignedOperator; + const left = assignee && detail.assignedAt && timeoutMinutes + ? leftFor(detail.assignedAt, timeoutMinutes, now) + : null; + return ( +
+
+ + {t("conversations.waiting_for_person", { duration: waitLabelOf(detail.waitingSince, now) })} +
+ {assignee && ( + <> +
+ + {t("conversations.assigned_not_taken", { name: assignee.name })} +
+ {left &&

{t("conversations.returns_to_queue_in", { duration: left })}

} + + )} +
+ ); +} + diff --git a/apps/internal-ui/src/features/conversations/assigneeOptions.tsx b/apps/internal-ui/src/features/conversations/assigneeOptions.tsx new file mode 100644 index 0000000..13dc8ee --- /dev/null +++ b/apps/internal-ui/src/features/conversations/assigneeOptions.tsx @@ -0,0 +1,129 @@ +import type { ItemType } from "antd/es/menu/interface"; + +import { Icon } from "../../shared/icons"; +import { SearchInput } from "../../shared/ui-controls"; +import { t } from "../../i18n"; +import type { ChatDirectoryEmployee } from "./model"; +import type { EmployeeDirectory } from "./useEmployeeDirectory"; + +/** Инициалы: «Анна Ким» → «АК». */ +export function initials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase(); +} + +/** Маленький аватар строки выбора: используется и в списке, и в закрытом поле. */ +export function SmallAvatar({ name, avatarUrl, online }: { name: string; avatarUrl?: string | null; online?: boolean }) { + // Точка присутствия живёт внутри аватара: у него фиксированный размер, и + // угол, к которому её прижимают, всегда там, где ожидается. + const dot = online ? : null; + if (avatarUrl) { + return {dot}; + } + return {dot}{initials(name)}; +} + +// Выбор ответственного (макет «Очередь и уведомления», кадр Q5): сначала те, +// кто сейчас в приложении, потом остальные. Отсутствующих не скрываем и не +// блокируем — признак приблизительный, назначить можно любого; они просто не +// первыми попадаются на глаза. + +/** «не в приложении · 25 мин» — сколько человека уже нет. */ +export function awayFor(lastSeenAt: string | null | undefined, now = new Date()): string { + if (!lastSeenAt) return t("conversations.never_in_app"); + const seen = new Date(lastSeenAt); + if (Number.isNaN(seen.getTime())) return t("conversations.never_in_app"); + const minutes = Math.max(1, Math.round((now.getTime() - seen.getTime()) / 60000)); + if (minutes < 60) return t("conversations.away_minutes", { count: minutes }); + const hours = Math.round(minutes / 60); + if (hours < 24) return t("conversations.away_hours", { count: hours }); + return t("conversations.away_long"); +} + +function subtitleOf(employee: ChatDirectoryEmployee): string { + if (!employee.online) return awayFor(employee.lastSeenAt); + return employee.role === "OWNER" ? t("conversations.here_owner") : t("conversations.here"); +} + +function personItem( + employee: ChatDirectoryEmployee, + { viewerId, assigneeId, onPick }: { viewerId: number | null; assigneeId?: number; onPick: (id: number) => void }, +): ItemType { + const load = employee.openDialogs ?? 0; + return { + key: employee.id, + label: ( + + ), + }; +} + +export function assigneeMenuItems({ + directory, + viewerId, + assigneeId, + onPick, +}: { + directory: EmployeeDirectory; + viewerId: number | null; + assigneeId?: number; + onPick: (id: number | null) => void; +}): ItemType[] { + const here = directory.employees.filter((employee) => employee.online); + const away = directory.employees.filter((employee) => !employee.online); + const pick = { viewerId, assigneeId, onPick: (id: number) => onPick(id) }; + return [ + // Строка поиска появляется, когда коллег больше, чем помещается в выдачу + // справочника: маленькой команде она не нужна. + ...(directory.hasMore || directory.query + ? [{ + key: "search", + type: "group" as const, + label: ( + + ), + }] + : []), + { + key: "none", + label: ( + + ), + }, + ...(here.length > 0 + ? [{ key: "here-head", type: "group" as const, label: t("conversations.presence_here") }] + : []), + ...here.map((employee) => personItem(employee, pick)), + ...(away.length > 0 + ? [{ key: "away-head", type: "group" as const, label: t("conversations.presence_away") }] + : []), + ...away.map((employee) => personItem(employee, pick)), + { + key: "presence-note", + type: "group" as const, + className: "ctx-menu-note", + label: t("conversations.presence_note"), + }, + ]; +} diff --git a/apps/internal-ui/src/features/conversations/model.ts b/apps/internal-ui/src/features/conversations/model.ts index 0601c66..2f8bf48 100644 --- a/apps/internal-ui/src/features/conversations/model.ts +++ b/apps/internal-ui/src/features/conversations/model.ts @@ -50,7 +50,13 @@ export type ConversationLabelRef = { id: number; name: string; color: string }; export type ConversationCounters = { all: number; waiting: number; + /** Ничей: взять может любой. */ + queue: number; + /** Назначен лично на меня и ждёт, пока я возьму. */ + waitingOnMe: number; mine: number; + /** Сколько минут держится личная очередь, прежде чем диалог вернётся всем. */ + assignmentTimeoutMinutes?: number; ungrouped: number; groups: Array<{ id: number; name: string; color?: string; count: number }>; agents: Array<{ id: number; code: string; name: string; count: number }>; @@ -73,6 +79,10 @@ export type ApiConversation = { assignedOperatorId: number | null; assignedOperator: { id: number; name: string; avatarUrl?: string | null } | null; isAssignedToViewer: boolean; + /** С какого момента диалог ждёт человека (макет Q3/Q4). */ + waitingSince?: string | null; + /** Когда назначили: от него считается срок личной очереди. */ + assignedAt?: string | null; group: { id: number; name: string; color?: string } | null; // Дизайн-базлайн v2: приоритет, метки, заметка, архив. priority: ConversationPriority; @@ -170,7 +180,23 @@ export function conversationName(conversation: ApiConversation): string { return t("conversations.guest"); } -export function toConversationListItem(conversation: ApiConversation): ConversationListItem { +/** «на вас · вернётся через 4 мин» — сколько осталось личной очереди. */ +export function assignedToMeLabel( + conversation: ApiConversation, + { viewerId, timeoutMinutes }: { viewerId: number | null; timeoutMinutes?: number }, + now = new Date(), +): string | null { + if (!conversation.isAssignedToViewer || conversation.controlMode !== "PAUSED") return null; + if (viewerId == null || !conversation.assignedAt || !timeoutMinutes) return t("conversations.on_you"); + const deadline = new Date(conversation.assignedAt).getTime() + timeoutMinutes * 60000; + const left = Math.round((deadline - now.getTime()) / 60000); + return left > 0 ? t("conversations.on_you_returns_in", { count: left }) : t("conversations.on_you"); +} + +export function toConversationListItem( + conversation: ApiConversation, + options: { viewerId?: number | null; assignmentTimeoutMinutes?: number } = {}, +): ConversationListItem { const name = conversationName(conversation); // avatarBg: стабильно из id контакта. const seed = conversation.contact?.id ?? conversation.id; @@ -198,10 +224,16 @@ export function toConversationListItem(conversation: ApiConversation): Conversat agentColor: agentColorOf(conversation.channel.id), groupName: conversation.group?.name ?? null, groupColor: conversation.group ? groupColorOf(conversation.group.id, conversation.group.color) : "var(--n-5)", + // Таймер считается с момента постановки в очередь: по последней активности + // клиент, напомнивший о себе, «ждал» бы заново с нуля. waitLabel: conversation.lifecycle === "OPEN" && conversation.controlMode === "PAUSED" - ? waitLabelOf(conversation.lastActivityAt) + ? waitLabelOf(conversation.waitingSince ?? conversation.lastActivityAt) : null, + mineLabel: assignedToMeLabel(conversation, { + viewerId: options.viewerId ?? null, + timeoutMinutes: options.assignmentTimeoutMinutes, + }), lastIsOurs: conversation.lastMessage?.author === "OPERATOR" || conversation.lastMessage?.author === "AI", lastIsVoice: conversation.lastMessage?.kind === "voice", }; @@ -214,6 +246,8 @@ export type ConversationListFilters = Partial<{ agent: number; // id канала-агента assigned: "me" | number; waiting: boolean; + queue: boolean; + waitingOnMe: boolean; lifecycle: "OPEN" | "CLOSED" | "SPAM"; archived: boolean; q: string; @@ -234,6 +268,8 @@ export const fetchConversations = (query: ConversationListQuery = {}) => { if (query.agent) params.set("agent", String(query.agent)); if (query.assigned) params.set("assigned", String(query.assigned)); if (query.waiting) params.set("waiting", "1"); + if (query.queue) params.set("queue", "1"); + if (query.waitingOnMe) params.set("waitingOnMe", "1"); if (query.lifecycle) params.set("lifecycle", query.lifecycle); if (query.archived) params.set("archived", "1"); if (query.q) params.set("q", query.q); @@ -274,7 +310,17 @@ export const fetchWaitingCount = () => api<{ waiting: number }>("/api/v1/convers // Справочник блока «Диалог» (кадр G): все группы для переноса и коллеги для // назначения — доступен и сотруднику, у которого нет менеджерских списков. -export type ChatDirectoryEmployee = { id: number; name: string; avatarUrl?: string | null }; +export type ChatDirectoryEmployee = { + id: number; + name: string; + avatarUrl?: string | null; + role?: string; + /** Приложение открыто прямо сейчас. Признак приблизительный — см. Q5. */ + online?: boolean; + lastSeenAt?: string | null; + /** Сколько открытых диалогов уже на человеке: второй признак после присутствия. */ + openDialogs?: number; +}; export type ChatDirectory = { groups: Array<{ id: number; name: string; color?: string }>; diff --git a/apps/internal-ui/src/features/conversations/types.ts b/apps/internal-ui/src/features/conversations/types.ts index 4702f1d..0c4b0f1 100644 --- a/apps/internal-ui/src/features/conversations/types.ts +++ b/apps/internal-ui/src/features/conversations/types.ts @@ -3,7 +3,10 @@ export type DialogMode = "ai" | "closed" | "operator" | "wait"; export type ControlMode = "ai" | "assigned" | "closed" | "human" | "waiting"; -export type ListTab = "all" | "mine" | "wait"; +// Два ожидания — разные списки (макет «Очередь и уведомления», кадр Q3): +// «queue» — диалоги без ответственного, взять может любой; «onMe» — +// назначенные лично и ждущие, пока их возьмут. +export type ListTab = "all" | "mine" | "queue" | "onMe"; // Порядок инбокса. Считает его сервер: список приходит окном, и сортировать в // браузере было бы нечего. export type ListSort = "activity" | "waiting"; @@ -33,6 +36,8 @@ export type ConversationListItem = { groupName: string | null; groupColor: string; waitLabel: string | null; + /** «на вас · вернётся через 4 мин» — личная очередь (макет Q3). */ + mineLabel: string | null; lastIsOurs: boolean; // Последнее сообщение — голосовое: в превью иконка микрофона (макет). lastIsVoice: boolean; diff --git a/apps/internal-ui/src/features/conversations/useConversationEvents.ts b/apps/internal-ui/src/features/conversations/useConversationEvents.ts index 050139c..358391b 100644 --- a/apps/internal-ui/src/features/conversations/useConversationEvents.ts +++ b/apps/internal-ui/src/features/conversations/useConversationEvents.ts @@ -1,16 +1,13 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect } from "react"; -import { resolveWebSocketUrl } from "../../api/client"; +import { useRealtime, useRealtimeEvent } from "../realtime/RealtimeProvider"; // Оповещения о диалогах: сервер сообщает, что изменилось, клиент забирает // данные обычным запросом. Поллинг остаётся запасным путём и замедляется, пока // сокет жив, — при обрыве всё работает ровно как раньше. - -const RECONNECT_MIN_MS = 1000; -const RECONNECT_MAX_MS = 30000; -// Поток событий в оживлённой организации плотнее прежнего опроса: несколько -// сообщений подряд должны приводить к одному обновлению, а не к пяти. -const COALESCE_MS = 700; +// +// Сам сокет живёт в оболочке (features/realtime): он общий с уведомлениями и +// обязан работать на любом экране, а не только там, где открыт чат. export type ConversationEvents = { /** Сокет открыт: поллинг можно замедлить. */ @@ -26,84 +23,18 @@ export function useConversationEvents({ onInboxChanged: () => void; onConversationChanged: (conversationId: number) => void; }): ConversationEvents { - const [connected, setConnected] = useState(false); - // Обработчики пересоздаются на каждый рендер — держим их в ref, чтобы сокет - // не переоткрывался вместе с ними. - const inboxRef = useRef(onInboxChanged); - inboxRef.current = onInboxChanged; - const conversationRef = useRef(onConversationChanged); - conversationRef.current = onConversationChanged; - const socketRef = useRef(null); - const watchedRef = useRef(null); - const pendingRef = useRef<{ inbox: boolean; conversation: number | null }>({ - inbox: false, - conversation: null, + const { connected, watch } = useRealtime(); + + useRealtimeEvent("inbox.changed", () => onInboxChanged()); + useRealtimeEvent("conversation.changed", (message) => { + if (typeof message.conversationId === "number") onConversationChanged(message.conversationId); }); - const flushTimerRef = useRef | null>(null); + // Открытый диалог сообщается сокету заново и после переподключения: подписка + // на него живёт в соединении, а не в реестре. useEffect(() => { - const url = resolveWebSocketUrl("/conversations/"); - if (!url) return; - let closed = false; - let retry = RECONNECT_MIN_MS; - let reconnectTimer: ReturnType | null = null; - - function open() { - if (closed) return; - const socket = new WebSocket(url as string); - socketRef.current = socket; - socket.onopen = () => { - retry = RECONNECT_MIN_MS; - setConnected(true); - // После обрыва подписка теряется вместе с сокетом — восстанавливаем её. - if (watchedRef.current != null) { - socket.send(JSON.stringify({ type: "watch", conversationId: watchedRef.current })); - } - }; - socket.onmessage = (event) => { - const payload = JSON.parse(String(event.data)) as { type?: string; conversationId?: number }; - if (payload.type === "inbox.changed") pendingRef.current.inbox = true; - if (payload.type === "conversation.changed" && typeof payload.conversationId === "number") { - pendingRef.current.conversation = payload.conversationId; - } - if (flushTimerRef.current !== null) return; - flushTimerRef.current = setTimeout(() => { - flushTimerRef.current = null; - const pending = pendingRef.current; - pendingRef.current = { inbox: false, conversation: null }; - if (pending.inbox) inboxRef.current(); - if (pending.conversation !== null) conversationRef.current(pending.conversation); - }, COALESCE_MS); - }; - socket.onclose = () => { - setConnected(false); - socketRef.current = null; - if (closed) return; - // Отступ растёт до полуминуты: сервер мог уйти на перезапуск. - reconnectTimer = setTimeout(open, retry); - retry = Math.min(retry * 2, RECONNECT_MAX_MS); - }; - socket.onerror = () => socket.close(); - } - - open(); - return () => { - closed = true; - if (reconnectTimer) clearTimeout(reconnectTimer); - if (flushTimerRef.current !== null) clearTimeout(flushTimerRef.current); - flushTimerRef.current = null; - socketRef.current?.close(); - socketRef.current = null; - }; - }, []); - - useEffect(() => { - watchedRef.current = conversationId; - const socket = socketRef.current; - if (socket && socket.readyState === WebSocket.OPEN && conversationId != null) { - socket.send(JSON.stringify({ type: "watch", conversationId })); - } - }, [conversationId, connected]); + watch(conversationId); + }, [watch, conversationId, connected]); return { connected }; } diff --git a/apps/internal-ui/src/features/conversations/useEmployeeDirectory.ts b/apps/internal-ui/src/features/conversations/useEmployeeDirectory.ts index fc0507a..a7d29be 100644 --- a/apps/internal-ui/src/features/conversations/useEmployeeDirectory.ts +++ b/apps/internal-ui/src/features/conversations/useEmployeeDirectory.ts @@ -29,7 +29,7 @@ export function useEmployeeDirectory(): EmployeeDirectory { fetchChatDirectory(settledQuery) .then((payload) => { if (!active) return; - setEmployees(payload.employees); + setEmployees(payload.employees ?? []); setHasMore(Boolean(payload.hasMoreEmployees)); }) .catch(() => { diff --git a/apps/internal-ui/src/features/notifications/browserNotifications.ts b/apps/internal-ui/src/features/notifications/browserNotifications.ts new file mode 100644 index 0000000..9060d12 --- /dev/null +++ b/apps/internal-ui/src/features/notifications/browserNotifications.ts @@ -0,0 +1,88 @@ +import type { AppNotification } from "./model"; + +// Системное уведомление браузера — то, что видно, когда вкладка свёрнута или +// закрыта другим окном. Работает без service worker и без Web Push: пока хоть +// одна вкладка приложения открыта, этого достаточно, а «браузер закрыт» в +// продукте закрывают боты в мессенджере. Web Push потребовал бы исходящего +// доступа установки к чужому push-сервису — на self-hosted это лишняя внешняя +// зависимость ради случая, который уже закрыт. + +export type BrowserNotificationState = "unsupported" | "default" | "granted" | "denied"; + +export function browserNotificationState(): BrowserNotificationState { + if (typeof window === "undefined" || !("Notification" in window)) return "unsupported"; + return Notification.permission as BrowserNotificationState; +} + +/** Спрашивать разрешение можно только в ответ на действие человека: браузеры + * наказывают за запрос на загрузке, а отказ потом почти не отменить. */ +export async function requestBrowserNotifications(): Promise { + if (browserNotificationState() === "unsupported") return "unsupported"; + try { + return (await Notification.requestPermission()) as BrowserNotificationState; + } catch { + return browserNotificationState(); + } +} + +/** Человек смотрит на приложение: системное уведомление было бы лишним, ему + * хватит тоста в самом интерфейсе. */ +function userIsLooking(): boolean { + return document.visibilityState === "visible" && document.hasFocus(); +} + +export function showBrowserNotification( + notification: AppNotification, + onOpen: (notification: AppNotification) => void, +): void { + if (browserNotificationState() !== "granted" || userIsLooking()) return; + try { + const shown = new Notification(notification.title, { + body: notification.body, + // Одинаковый тег схлопывает уведомление во всех вкладках сразу: у + // оператора их обычно несколько, и три одинаковых оклика об одном + // диалоге — повод выключить уведомления совсем. + tag: `chatballs-notification-${notification.id}`, + renotify: false, + } as NotificationOptions); + shown.onclick = () => { + window.focus(); + onOpen(notification); + shown.close(); + }; + } catch { + /* уведомление — вспомогательный путь: его сбой ничего не ломает */ + } +} + +/** «Chrome · macOS» — чтобы человек понимал, о каком именно устройстве речь: + * разрешение своё в каждом браузере и на каждой машине. */ +export function browserAndOs(): string { + if (typeof navigator === "undefined") return ""; + const ua = navigator.userAgent; + const browser = /Edg\//.test(ua) + ? "Edge" + : /OPR\//.test(ua) + ? "Opera" + : /YaBrowser/.test(ua) + ? "Yandex" + : /Chrome\//.test(ua) + ? "Chrome" + : /Firefox\//.test(ua) + ? "Firefox" + : /Safari\//.test(ua) + ? "Safari" + : ""; + const os = /Windows/.test(ua) + ? "Windows" + : /Mac OS X/.test(ua) + ? "macOS" + : /Android/.test(ua) + ? "Android" + : /iPhone|iPad/.test(ua) + ? "iOS" + : /Linux/.test(ua) + ? "Linux" + : ""; + return [browser, os].filter(Boolean).join(" · "); +} diff --git a/apps/internal-ui/src/features/notifications/model.ts b/apps/internal-ui/src/features/notifications/model.ts index 72c855d..d917858 100644 --- a/apps/internal-ui/src/features/notifications/model.ts +++ b/apps/internal-ui/src/features/notifications/model.ts @@ -24,3 +24,21 @@ 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 type NotificationTransport = "BROWSER" | "MESSENGER"; + +export type NotificationPreference = { + transport: NotificationTransport; + enabled: boolean; + types: string[]; + availableTypes: Array<{ code: string; label: string }>; +}; + +export const fetchPreference = (transport: NotificationTransport) => + api(`/api/v1/notifications/preferences/${transport}/`); + +export const savePreference = (transport: NotificationTransport, patch: { enabled?: boolean; types?: string[] }) => + api(`/api/v1/notifications/preferences/${transport}/`, { + method: "PATCH", + body: JSON.stringify(patch), + }); diff --git a/apps/internal-ui/src/features/notifications/useNotificationAlerts.ts b/apps/internal-ui/src/features/notifications/useNotificationAlerts.ts new file mode 100644 index 0000000..fc83c00 --- /dev/null +++ b/apps/internal-ui/src/features/notifications/useNotificationAlerts.ts @@ -0,0 +1,65 @@ +import { notification as antToast } from "antd"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { fetchPreference, type AppNotification, type NotificationPreference } from "./model"; +import { showBrowserNotification } from "./browserNotifications"; + +// Оклик о новом уведомлении: тост внутри вкладки и системное уведомление, если +// человек смотрит в другое место. +// +// Раньше тост показывался по росту счётчика непрочитанных. Счётчик — плохой +// признак события: три уведомления между опросами давали один тост, а +// прочтение в соседней вкладке роняло счётчик и глушило следующий. Считаем по +// идентификаторам: что человеку ещё не показывали, то и показываем. + +export function useNotificationAlerts({ + items, + onOpen, +}: { + items: AppNotification[]; + onOpen: (notification: AppNotification) => void; +}) { + const [preference, setPreference] = useState(null); + const shown = useRef | null>(null); + const onOpenRef = useRef(onOpen); + onOpenRef.current = onOpen; + const preferenceRef = useRef(preference); + preferenceRef.current = preference; + + const reloadPreference = useCallback(async () => { + try { + setPreference(await fetchPreference("BROWSER")); + } catch { + /* без настройки системных уведомлений просто не будет */ + } + }, []); + + useEffect(() => { + void reloadPreference(); + }, [reloadPreference]); + + useEffect(() => { + // Первая загрузка только запоминает: накопленное за ночь не вываливают + // стопкой тостов в лицо тому, кто открыл приложение утром. + if (shown.current === null) { + shown.current = new Set(items.map((item) => item.id)); + return; + } + const seen = shown.current; + const fresh = items.filter((item) => item.unread && !seen.has(item.id)); + for (const item of items) seen.add(item.id); + if (fresh.length === 0) return; + + const newest = fresh[0]; + antToast.open({ message: newest.title, description: newest.body, placement: "bottomRight" }); + + const allowed = preferenceRef.current; + if (!allowed?.enabled) return; + for (const item of fresh) { + if (!allowed.types?.includes(item.type)) continue; + showBrowserNotification(item, (opened) => onOpenRef.current(opened)); + } + }, [items]); + + return { preference, reloadPreference }; +} diff --git a/apps/internal-ui/src/features/profile/NotificationTypeChecks.tsx b/apps/internal-ui/src/features/profile/NotificationTypeChecks.tsx new file mode 100644 index 0000000..663410c --- /dev/null +++ b/apps/internal-ui/src/features/profile/NotificationTypeChecks.tsx @@ -0,0 +1,37 @@ +import { Icon } from "../../shared/icons"; + +// Галочки типов событий. Один и тот же список у каждого транспорта: вопрос +// «о чём меня звать» один, и выглядеть он обязан одинаково — что у бота в +// мессенджере, что у браузера. + +export type NotificationTypeOption = { code: string; label: string }; + +export function NotificationTypeChecks({ + options, + selected, + onToggle, +}: { + options: NotificationTypeOption[]; + selected: string[]; + onToggle: (code: string) => void; +}) { + // Список приходит из ответа сервера: недостающее поле не повод уронить + // экран, на котором эта строка — лишь часть карточки. + if (!options || options.length === 0) return null; + return ( +
+ {options.map((option) => { + const on = selected.includes(option.code); + return ( + + ); + })} +
+ ); +} diff --git a/apps/internal-ui/src/features/profile/ProfileBrowserNotificationsRow.tsx b/apps/internal-ui/src/features/profile/ProfileBrowserNotificationsRow.tsx new file mode 100644 index 0000000..9917615 --- /dev/null +++ b/apps/internal-ui/src/features/profile/ProfileBrowserNotificationsRow.tsx @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useState } from "react"; + +import { + browserAndOs, + browserNotificationState, + requestBrowserNotifications, + type BrowserNotificationState, +} from "../notifications/browserNotifications"; +import { fetchPreference, savePreference, type NotificationPreference } from "../notifications/model"; +import { Icon, LogoSpinner } from "../../shared/icons"; +import { Button } from "../../shared/ui-controls"; +import { t } from "../../i18n"; +import { NotificationTypeChecks } from "./NotificationTypeChecks"; + +// Строка «Этот браузер» в карточке уведомлений (макет «Очередь и уведомления», +// кадры Q1 и Q1b) — того же вида, что строки ботов, и первой в списке: она +// включается одним нажатием и без внешнего аккаунта. +// +// Состояний три, и они не сводятся к «включено/выключено»: разрешение живёт в +// браузере и своё на каждом устройстве, а желание получать — на сервере. +// Отозвать выданное разрешение сайт не может, поэтому «Отключить» выключает +// именно желание; отказ снимается только руками в настройках браузера. + +export function ProfileBrowserNotificationsRow() { + // Состояние разрешения известно сразу: если узнавать его в эффекте, строка + // на первый кадр возвращает null и мигает на каждой перерисовке страницы. + const [permission, setPermission] = useState(browserNotificationState); + const [preference, setPreference] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + fetchPreference("BROWSER").then(setPreference).catch(() => undefined); + }, []); + + const apply = useCallback(async (patch: { enabled?: boolean; types?: string[] }) => { + setBusy(true); + try { + setPreference(await savePreference("BROWSER", patch)); + } catch { + /* ignore */ + } finally { + setBusy(false); + } + }, []); + + async function enable() { + // Разрешение спрашиваем ровно здесь — в ответ на клик: на загрузке страницы + // браузеры такой запрос наказывают, а отказ потом почти не отменить. + const granted = await requestBrowserNotifications(); + setPermission(granted); + if (granted === "granted") await apply({ enabled: true }); + } + + function toggleType(code: string) { + if (!preference) return; + const next = preference.types.includes(code) + ? preference.types.filter((item) => item !== code) + : [...preference.types, code]; + setPreference({ ...preference, types: next }); + void apply({ types: next }); + } + + if (permission === "unsupported") return null; + + const denied = permission === "denied"; + const on = permission === "granted" && Boolean(preference?.enabled); + const device = browserAndOs(); + const tile = denied + ? "integration-tile--blocked" + : on + ? "integration-tile--web" + : "integration-tile--muted"; + return ( +
+
+ + + +
+ {t("profile.this_browser")} + + {denied + ? device + : on + ? t("profile.browser_visible_when_minimized", { device }) + : t("profile.browser_not_enabled_yet")} + +
+ {denied ? ( + + + {t("profile.browser_blocked")} + + ) : !preference ? ( + + ) : on ? ( + <> + + + {t("profile.browser_enabled")} + + + + ) : ( + + )} +
+ {on && preference && ( + <> + +

{t("profile.browser_disable_note")}

+ + )} + {denied && ( +
+ +
+ {t("profile.browser_blocked_title")} +

{t("profile.browser_blocked_steps")}

+ {/* Разрешение меняют в браузере; приложению остаётся перечитать его. */} + +
+
+ )} +
+ ); +} diff --git a/apps/internal-ui/src/features/profile/ProfileNotificationsCard.tsx b/apps/internal-ui/src/features/profile/ProfileNotificationsCard.tsx index 9d45dd2..0c17507 100644 --- a/apps/internal-ui/src/features/profile/ProfileNotificationsCard.tsx +++ b/apps/internal-ui/src/features/profile/ProfileNotificationsCard.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "../../api/client"; import { Icon, LogoSpinner, MaxLogo, TelegramLogo } from "../../shared/icons"; +import { NotificationTypeChecks } from "./NotificationTypeChecks"; +import { ProfileBrowserNotificationsRow } from "./ProfileBrowserNotificationsRow"; import { Button } from "../../shared/ui-controls"; import { t } from "../../i18n"; @@ -31,8 +33,8 @@ export function ProfileNotificationsCard() { const reload = useCallback(async () => { try { const data = await fetchBindings(); - setItems(data.items); - setTypes(data.availableTypes); + setItems(data.items ?? []); + setTypes(data.availableTypes ?? []); for (const item of data.items) { if (item.bound || issuedFor.current.has(item.integrationId)) continue; issuedFor.current.add(item.integrationId); @@ -56,7 +58,7 @@ export function ProfileNotificationsCard() { useEffect(() => { if (!hasUnbound) return; const timer = window.setInterval(() => { - fetchBindings().then((data) => { setItems(data.items); setTypes(data.availableTypes); }).catch(() => undefined); + fetchBindings().then((data) => { setItems(data.items ?? []); setTypes(data.availableTypes ?? []); }).catch(() => undefined); }, 3000); return () => window.clearInterval(timer); }, [hasUnbound]); @@ -82,8 +84,9 @@ export function ProfileNotificationsCard() { return (
-

{t("profile.messenger_notifications")}

-

{t("profile.work_events_reach_even_with")}

+

{t("profile.work_event_notifications")}

+

{t("profile.choose_where_to_call_you")}

+ {loaded && items.length === 0 && (

{t("profile.no_notification_bots_configured_owner")}

)} @@ -109,19 +112,12 @@ export function ProfileNotificationsCard() { {loaded ? t("profile.preparing_link") : } )}
- {item.bound && types.length > 0 && ( -
- {types.map((type) => { - const on = item.pushTypes.includes(type.code); - return ( - - ); - })} -
+ {item.bound && ( + void toggleType(item, code)} + /> )} ))} diff --git a/apps/internal-ui/src/features/profile/ProfilePage.tsx b/apps/internal-ui/src/features/profile/ProfilePage.tsx index a16e93d..88691c5 100644 --- a/apps/internal-ui/src/features/profile/ProfilePage.tsx +++ b/apps/internal-ui/src/features/profile/ProfilePage.tsx @@ -23,7 +23,7 @@ import { t } from "../../i18n"; type MobileSection = "notifications" | "password" | "totp" | "sessions"; const MOBILE_TITLE: Record = { - notifications: t("profile.messenger_notifications"), + notifications: t("profile.work_event_notifications"), password: t("profile.change_password"), totp: t("profile.two_factor_authentication"), sessions: t("common.active_sessions"), diff --git a/apps/internal-ui/src/features/profile/model.ts b/apps/internal-ui/src/features/profile/model.ts index 5beebd4..562c17b 100644 --- a/apps/internal-ui/src/features/profile/model.ts +++ b/apps/internal-ui/src/features/profile/model.ts @@ -14,8 +14,11 @@ export type ProfileSession = { current: boolean; }; +// Ответ без items — это сломанный ответ, а не пустой список, но карточка не то +// место, где об этом узнают: undefined вместо массива роняет её на items.length +// и уносит вместе с ней всю страницу профиля — пароль, двухфакторку и сессии. export const fetchProfileSessions = () => - api<{ items: ProfileSession[] }>("/api/v1/auth/profile/sessions/").then((payload) => payload.items); + api<{ items: ProfileSession[] }>("/api/v1/auth/profile/sessions/").then((payload) => payload.items ?? []); // «сейчас» · «2 ч назад» · «3 дня назад» — подпись справа в строке сессии. export function lastSeenLabel(value: string | null, now = new Date()): string { diff --git a/apps/internal-ui/src/features/profile/styles.css b/apps/internal-ui/src/features/profile/styles.css index d0f5285..6719c65 100644 --- a/apps/internal-ui/src/features/profile/styles.css +++ b/apps/internal-ui/src/features/profile/styles.css @@ -920,3 +920,65 @@ margin-left: 0; } } + +/* Строка транспорта по состоянию (макет «Очередь и уведомления», кадр Q1b): + плитка и статус меняют цвет вместе со смыслом — серое «не включено», + акцентное «работает», красное «браузер запретил». */ +.integration-tile--muted { + color: var(--n-4); + background: var(--n-9); +} + +.integration-tile--blocked { + color: var(--error-text); + background: var(--error-bg); +} + +.profile-notifications-status.is-blocked { + background: var(--error-bg); + color: var(--error-text); +} + +/* Отказ браузера снимается только руками, поэтому рядом с ним не кнопка + «включить», а шаги: где нажать и что искать. */ +.profile-notifications-hint { + display: flex; + gap: 10px; + margin: 12px 0 0 46px; + padding: 10px 12px; + border-radius: 9px; + background: var(--n-10, var(--surface-card)); + border: 1px solid var(--n-9); +} + +.profile-notifications-hint > span { + flex: none; + margin-top: 1px; + color: var(--n-4); +} + +.profile-notifications-hint strong { + display: block; + font-size: 12.5px; + font-weight: 600; + color: var(--n-2); +} + +.profile-notifications-hint p { + margin: 4px 0 0; + font-size: 12px; + color: var(--n-4); + line-height: 1.5; +} + +.profile-notifications-hint button { + margin-top: 9px; +} + +/* Примечание под включённой строкой: «Отключить» гасит показ, а не разрешение. */ +.profile-notifications-aside { + margin: 10px 0 0 46px; + font-size: 11.5px; + color: var(--n-4); + line-height: 1.45; +} diff --git a/apps/internal-ui/src/features/realtime/RealtimeProvider.tsx b/apps/internal-ui/src/features/realtime/RealtimeProvider.tsx new file mode 100644 index 0000000..ac7eb78 --- /dev/null +++ b/apps/internal-ui/src/features/realtime/RealtimeProvider.tsx @@ -0,0 +1,69 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; + +import { resolveWebSocketUrl } from "../../api/client"; +import { RealtimeConnection } from "./connection"; +import { RealtimeSubscribers, type RealtimeHandler } from "./subscribers"; + +// Сокет живёт на уровне оболочки, а не страницы чата. Раньше он принадлежал +// рабочей области диалогов, и вне чата живых событий не было вовсе: на любом +// другом экране оператор узнавал о новом уведомлении только следующим опросом. + +type Realtime = { + connected: boolean; + subscribe: (type: string, handler: RealtimeHandler) => () => void; + watch: (conversationId: number | null) => void; +}; + +const RealtimeContext = createContext(null); + +export function RealtimeProvider({ children }: { children: ReactNode }) { + const [connected, setConnected] = useState(false); + // Реестр создаётся раз и навсегда: подписки оформляются раньше, чем откроется + // сокет, и переживают его переподключения. + const subscribersRef = useRef(null); + if (subscribersRef.current === null) subscribersRef.current = new RealtimeSubscribers(); + const subscribers = subscribersRef.current; + const connectionRef = useRef(null); + + useEffect(() => { + const url = resolveWebSocketUrl("/conversations/"); + if (!url) return; + const connection = new RealtimeConnection(url, (message) => subscribers.dispatch(message), setConnected); + connectionRef.current = connection; + connection.open(); + return () => { + connection.close(); + connectionRef.current = null; + setConnected(false); + }; + }, [subscribers]); + + // subscribe и watch не зависят от состояния соединения: иначе каждый обрыв + // переподписывал бы всех подписчиков заново. + const subscribe = useCallback( + (type: string, handler: RealtimeHandler) => subscribers.subscribe(type, handler), + [subscribers], + ); + const watch = useCallback((conversationId: number | null) => connectionRef.current?.watch(conversationId), []); + const value = useMemo( + () => ({ connected, subscribe, watch }), + [connected, subscribe, watch], + ); + + return {children}; +} + +export function useRealtime(): Realtime { + const value = useContext(RealtimeContext); + if (value === null) throw new Error("useRealtime is only available inside RealtimeProvider"); + return value; +} + +/** Подписка на один тип события. Обработчик держится в ref: пересоздание + * функции на каждом рендере не должно переподписывать сокет. */ +export function useRealtimeEvent(type: string, handler: RealtimeHandler): void { + const { subscribe } = useRealtime(); + const handlerRef = useRef(handler); + handlerRef.current = handler; + useEffect(() => subscribe(type, (message) => handlerRef.current(message)), [subscribe, type]); +} diff --git a/apps/internal-ui/src/features/realtime/connection.ts b/apps/internal-ui/src/features/realtime/connection.ts new file mode 100644 index 0000000..7182298 --- /dev/null +++ b/apps/internal-ui/src/features/realtime/connection.ts @@ -0,0 +1,104 @@ +// Сокет рабочего места: один на сессию, по нему идут события и диалогов, и +// уведомлений. Второй сокет ради второго источника означал бы второе +// переподключение и вторую точку отказа на ровном месте. +// +// Здесь только провод: подключение, переподключение с отступом и склейка частых +// событий. Кто на что подписан — знает реестр (subscribers.ts), и знает +// независимо от того, открыт сокет или нет. + +const RECONNECT_MIN_MS = 1000; +const RECONNECT_MAX_MS = 30000; +// Поток событий в оживлённой организации плотнее прежнего опроса: несколько +// сообщений подряд должны приводить к одному обновлению, а не к пяти. +const COALESCE_MS = 700; +// Раз в минуту сервер узнаёт, что рабочее место живо: по этому признаку +// очередь решает, есть ли кому заметить ждущий диалог. Без heartbeat +// оборванное соединение считалось бы присутствием до истечения ключа. +const HEARTBEAT_MS = 60000; + +export type RealtimeMessage = { type: string; conversationId?: number }; + +export class RealtimeConnection { + private socket: WebSocket | null = null; + private closed = false; + private retry = RECONNECT_MIN_MS; + private reconnectTimer: ReturnType | null = null; + private flushTimer: ReturnType | null = null; + private heartbeatTimer: ReturnType | null = null; + /** Последнее событие каждого типа: за окно склейки важно только оно. */ + private pending = new Map(); + private watched: number | null = null; + + constructor( + private readonly url: string, + private readonly onMessage: (message: RealtimeMessage) => void, + private readonly onConnectedChange: (connected: boolean) => void, + ) {} + + open(): void { + if (this.closed) return; + const socket = new WebSocket(this.url); + this.socket = socket; + socket.onopen = () => { + this.retry = RECONNECT_MIN_MS; + this.onConnectedChange(true); + this.startHeartbeat(); + // После обрыва подписка теряется вместе с сокетом — восстанавливаем её. + if (this.watched !== null) this.send({ type: "watch", conversationId: this.watched }); + }; + socket.onmessage = (event) => this.receive(String(event.data)); + socket.onclose = () => { + this.onConnectedChange(false); + this.stopHeartbeat(); + this.socket = null; + if (this.closed) return; + // Отступ растёт до полуминуты: сервер мог уйти на перезапуск. + this.reconnectTimer = setTimeout(() => this.open(), this.retry); + this.retry = Math.min(this.retry * 2, RECONNECT_MAX_MS); + }; + socket.onerror = () => socket.close(); + } + + close(): void { + this.closed = true; + this.stopHeartbeat(); + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + if (this.flushTimer !== null) clearTimeout(this.flushTimer); + this.flushTimer = null; + this.socket?.close(); + this.socket = null; + } + + /** Какой диалог открыт: событий по нему клиент и ждёт. */ + watch(conversationId: number | null): void { + this.watched = conversationId; + if (conversationId !== null) this.send({ type: "watch", conversationId }); + } + + private startHeartbeat(): void { + this.stopHeartbeat(); + this.heartbeatTimer = setInterval(() => this.send({ type: "ping" }), HEARTBEAT_MS); + } + + private stopHeartbeat(): void { + if (this.heartbeatTimer !== null) clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + + private send(payload: object): void { + if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(payload)); + } + + private receive(data: string): void { + const message = JSON.parse(data) as RealtimeMessage; + if (!message.type) return; + this.pending.set(message.type, message); + if (this.flushTimer !== null) return; + this.flushTimer = setTimeout(() => { + this.flushTimer = null; + const flushed = [...this.pending.values()]; + this.pending.clear(); + for (const item of flushed) this.onMessage(item); + }, COALESCE_MS); + } +} diff --git a/apps/internal-ui/src/features/realtime/subscribers.ts b/apps/internal-ui/src/features/realtime/subscribers.ts new file mode 100644 index 0000000..bbbd9ea --- /dev/null +++ b/apps/internal-ui/src/features/realtime/subscribers.ts @@ -0,0 +1,25 @@ +import type { RealtimeMessage } from "./connection"; + +// Реестр подписчиков живёт отдельно от сокета и переживает его обрывы: подписка +// оформляется в момент монтирования компонента, а сокет к этому моменту может +// быть ещё не открыт или уже переподключаться. Связывать одно с другим — значит +// терять события на каждом обрыве. + +export type RealtimeHandler = (message: RealtimeMessage) => void; + +export class RealtimeSubscribers { + private handlers = new Map>(); + + subscribe(type: string, handler: RealtimeHandler): () => void { + const forType = this.handlers.get(type) ?? new Set(); + forType.add(handler); + this.handlers.set(type, forType); + return () => { + forType.delete(handler); + }; + } + + dispatch(message: RealtimeMessage): void { + for (const handler of this.handlers.get(message.type) ?? []) handler(message); + } +} diff --git a/apps/internal-ui/src/features/sales/dialogs/context.css b/apps/internal-ui/src/features/sales/dialogs/context.css index eed9361..7e56155 100644 --- a/apps/internal-ui/src/features/sales/dialogs/context.css +++ b/apps/internal-ui/src/features/sales/dialogs/context.css @@ -1367,3 +1367,114 @@ width: 100%; margin: 2px 0 6px; } + +/* Выбор ответственного с присутствием (макет «Очередь и уведомления», кадр Q5). + Заголовки групп и сноска — существующие элементы меню; здесь только то, чего + в нём ещё не было: точка присутствия, подпись под именем и загрузка справа. + Точка — подсказка, а не запрет: отсутствующий выбирается так же, как все. */ +.ctx-avatar-small { + position: relative; + /* Точка висит на краю кружка, поэтому обрезать содержимое нельзя. */ + overflow: visible; +} + +.ctx-avatar-small.has-photo img { + border-radius: 50%; +} + +.assignee-dot { + position: absolute; + right: -1px; + bottom: -1px; + width: 8px; + height: 8px; + border-radius: 50%; + background: #52c41a; + border: 2px solid var(--surface-card); +} + +.ctx-select .assignee-dot { + position: static; + flex: none; + border: none; + width: 7px; + height: 7px; +} + +.app-dropdown.ctx-menu .ant-dropdown-menu-item button.assignee-option { + align-items: center; +} + +.app-dropdown.ctx-menu .ant-dropdown-menu-item button.assignee-option > .assignee-who { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + line-height: 1.25; + text-align: left; + overflow: hidden; +} + +.assignee-who > span, +.assignee-who small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.assignee-who small { + font-size: 11px; + color: var(--n-4); +} + +.assignee-who small.is-here { + color: var(--success-text); +} + +.app-dropdown.ctx-menu .ant-dropdown-menu-item button.assignee-option > .assignee-load { + flex: none; + margin-left: 6px; + font-size: 11px; + color: var(--n-5); + white-space: nowrap; +} + +/* Блок «Ожидание» в карточке диалога (макет «Очередь и уведомления», кадр Q4): + сколько клиент ждёт и почему диалог виден, но брать его не нужно. */ +.ctx-waiting { + padding: 12px; + margin-bottom: 14px; + border-radius: 9px; + background: var(--warning-bg); + border: 1px solid var(--warning-border); +} + +.ctx-waiting-head { + display: flex; + align-items: center; + gap: 8px; + color: var(--warning-text); +} + +.ctx-waiting-head strong { + font-size: 12.5px; + font-weight: 700; +} + +.ctx-waiting-assignee { + display: flex; + align-items: center; + gap: 8px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--warning-border); + font-size: 12.5px; + color: var(--n-2); +} + +.ctx-waiting p { + margin: 6px 0 0; + font-size: 11.5px; + color: var(--warning-text); + line-height: 1.45; +} diff --git a/apps/internal-ui/src/features/sales/dialogs/context/ClientContext.tsx b/apps/internal-ui/src/features/sales/dialogs/context/ClientContext.tsx index c3355fa..9f670a7 100644 --- a/apps/internal-ui/src/features/sales/dialogs/context/ClientContext.tsx +++ b/apps/internal-ui/src/features/sales/dialogs/context/ClientContext.tsx @@ -22,6 +22,7 @@ export function ClientContext({ applyConversation, startCall, viewerId = null, + assignmentTimeoutMinutes, }: { dialog: ConversationListItem | null; detail: ApiConversation | null; @@ -29,6 +30,7 @@ export function ClientContext({ applyConversation?: (updated: ApiConversation) => void; startCall?: ((kind: "AUDIO" | "VIDEO") => void) | null; viewerId?: number | null; + assignmentTimeoutMinutes?: number; }) { const [requesting, setRequesting] = useState(false); const [justRequested, setJustRequested] = useState(false); @@ -130,7 +132,7 @@ export function ClientContext({ {detail && applyConversation && ( - + )} ); diff --git a/apps/internal-ui/src/features/sales/dialogs/list.css b/apps/internal-ui/src/features/sales/dialogs/list.css index d5aab5d..4d2c71a 100644 --- a/apps/internal-ui/src/features/sales/dialogs/list.css +++ b/apps/internal-ui/src/features/sales/dialogs/list.css @@ -515,3 +515,12 @@ img.sales-dialog-avatar { .hub-hint button:hover { background: rgba(0, 0, 0, 0.06); } + +/* «на вас · вернётся через N мин» (макет «Очередь и уведомления», кадр Q3): + личная очередь — не то же, что общая, и цвет у неё акцентный, а не тревожный: + диалог не брошен, он ждёт конкретного человека. */ +.sales-dialog-badge.is-mine { + background: var(--primary-bg); + border-color: var(--primary-border, var(--primary-bg)); + color: var(--primary-text); +} diff --git a/apps/internal-ui/src/features/settings/QueuePolicyCard.tsx b/apps/internal-ui/src/features/settings/QueuePolicyCard.tsx new file mode 100644 index 0000000..bf11ec4 --- /dev/null +++ b/apps/internal-ui/src/features/settings/QueuePolicyCard.tsx @@ -0,0 +1,124 @@ +import { useEffect, useState } from "react"; + +import { Icon } from "../../shared/icons"; +import { Button } from "../../shared/ui-controls"; +import { fmt, t } from "../../i18n"; +import { + QUEUE_POLICY_FIELDS, + fetchQueuePolicy, + saveQueuePolicy, + type QueuePolicy, + type QueuePolicyField, +} from "./queuePolicy"; + +// Раздел «Когда звать на помощь» (макет «Очередь и уведомления», кадр Q2). +// Сроки действуют на всю организацию и считаются с момента, когда диалог встал +// в очередь. Правит их тот, кто управляет настройками; остальным сервер +// отвечает отказом, поэтому карточка просто не покажется. + +type Draft = Record; + +const draftOf = (policy: QueuePolicy): Draft => + Object.fromEntries(QUEUE_POLICY_FIELDS.map(({ key }) => [key, String(policy[key])])) as Draft; + +export function QueuePolicyCard({ canManage }: { canManage: boolean }) { + const [policy, setPolicy] = useState(null); + const [draft, setDraft] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + fetchQueuePolicy() + .then((loaded) => { + setPolicy(loaded); + setDraft(draftOf(loaded)); + }) + .catch(() => setError(t("settings.queue_policy_unavailable"))); + }, []); + + async function save(values: Partial>) { + setBusy(true); + setError(""); + try { + const saved = await saveQueuePolicy(values); + setPolicy(saved); + setDraft(draftOf(saved)); + } catch (problem) { + setError(problem instanceof Error ? problem.message : t("settings.queue_policy_unavailable")); + } finally { + setBusy(false); + } + } + + if (error && !policy) return

{error}

; + if (!policy || !draft) return null; + + const changed = QUEUE_POLICY_FIELDS.some(({ key }) => draft[key] !== String(policy[key])); + const isDefault = QUEUE_POLICY_FIELDS.every(({ key }) => policy[key] === policy.defaults[key]); + + return ( + <> +
+ {QUEUE_POLICY_FIELDS.map(({ key, title, hint }) => ( +
+
+ {title} + {hint} +
+ +
+ ))} +
+ + {policy.updatedAt + ? t("settings.queue_saved_by", { + when: `${fmt.shortDate(new Date(policy.updatedAt))}, ${fmt.time(new Date(policy.updatedAt))}`, + who: policy.updatedBy, + }) + : t("settings.queue_never_changed")} + + {canManage && ( +
+ + +
+ )} +
+
+ {error &&

{error}

} +
+ +

{t("settings.queue_delivery_warning")}

+
+ + ); +} diff --git a/apps/internal-ui/src/features/settings/SettingsPage.tsx b/apps/internal-ui/src/features/settings/SettingsPage.tsx index 5344ce1..90f5493 100644 --- a/apps/internal-ui/src/features/settings/SettingsPage.tsx +++ b/apps/internal-ui/src/features/settings/SettingsPage.tsx @@ -17,6 +17,7 @@ import { CommunicationSettingsCard } from "./CommunicationSettingsCard"; import { PlatformSettingsCard } from "./PlatformSettingsCard"; import { StorageSettingsCard } from "./StorageSettingsCard"; import { GroupsSettingsCard } from "./GroupsSettingsCard"; +import { QueuePolicyCard } from "./QueuePolicyCard"; import { DEFAULT_SETTINGS_SECTION, visibleSettingsSections, type SettingsSectionKey } from "./sections"; import { useIntegrations } from "./useIntegrations"; import { useOnboarding } from "../onboarding/useOnboarding"; @@ -179,6 +180,7 @@ function SectionBody({ section, user, onUserUpdated, reload, groups, integration }): ReactNode { if (section === "organization") return ; if (section === "groups") return ; + if (section === "queue") return ; if (section === "communication") return ; if (section === "platform") return ; if (section === "storage") return ; diff --git a/apps/internal-ui/src/features/settings/queuePolicy.ts b/apps/internal-ui/src/features/settings/queuePolicy.ts new file mode 100644 index 0000000..9ead83c --- /dev/null +++ b/apps/internal-ui/src/features/settings/queuePolicy.ts @@ -0,0 +1,50 @@ +import { api } from "../../api/client"; +import { t } from "../../i18n"; + +// Сроки очереди (макет «Очередь и уведомления», кадр Q2). Четыре числа в +// минутах: через сколько напомнить группе, позвать всех, сообщить руководителю +// и сколько ждём назначенного сотрудника. + +export type QueuePolicyField = + | "remind_after_minutes" + | "widen_after_minutes" + | "escalate_after_minutes" + | "assignment_timeout_minutes"; + +export type QueuePolicy = Record & { + defaults: Record; + updatedAt: string | null; + updatedBy: string; +}; + +/** Порядок — как в макете: от первого напоминания к последнему средству. */ +export const QUEUE_POLICY_FIELDS: Array<{ key: QueuePolicyField; title: string; hint: string }> = [ + { + key: "remind_after_minutes", + title: t("settings.queue_remind_group"), + hint: t("settings.queue_remind_group_hint"), + }, + { + key: "widen_after_minutes", + title: t("settings.queue_call_everyone"), + hint: t("settings.queue_call_everyone_hint"), + }, + { + key: "escalate_after_minutes", + title: t("settings.queue_tell_manager"), + hint: t("settings.queue_tell_manager_hint"), + }, + { + key: "assignment_timeout_minutes", + title: t("settings.queue_wait_for_assignee"), + hint: t("settings.queue_wait_for_assignee_hint"), + }, +]; + +export const fetchQueuePolicy = () => api("/api/v1/conversations/queue-policy/"); + +export const saveQueuePolicy = (patch: Partial>) => + api("/api/v1/conversations/queue-policy/", { + method: "PATCH", + body: JSON.stringify(patch), + }); diff --git a/apps/internal-ui/src/features/settings/sections.ts b/apps/internal-ui/src/features/settings/sections.ts index c7b7a59..a8b26a8 100644 --- a/apps/internal-ui/src/features/settings/sections.ts +++ b/apps/internal-ui/src/features/settings/sections.ts @@ -9,6 +9,7 @@ import { t } from "../../i18n"; export type SettingsSectionKey = | "organization" | "groups" + | "queue" | "ai" | "integrations" | "communication" @@ -43,6 +44,13 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [ heading: t("common.groups"), lead: t("settings.they_split_conversations_between_operators"), }, + { + key: "queue", + label: t("settings.when_to_call_for_help"), + icon: "clock", + heading: t("settings.when_to_call_for_help"), + lead: t("settings.queue_section_lead"), + }, { key: "ai", label: t("settings.ai_provider"), diff --git a/apps/internal-ui/src/features/settings/styles.css b/apps/internal-ui/src/features/settings/styles.css index b25c9f2..4b009cc 100644 --- a/apps/internal-ui/src/features/settings/styles.css +++ b/apps/internal-ui/src/features/settings/styles.css @@ -937,3 +937,110 @@ grid-template-columns: minmax(0, 1fr) 72px 72px 72px; } } + +/* Сроки очереди (макет «Очередь и уведомления», кадр Q2): строка — правило, + поле — число в минутах, единица вынесена из поля. */ +.queue-policy-card { + background: var(--surface-card); + border: 1px solid var(--n-8); + border-radius: 12px; + padding: 6px 24px 20px; +} + +.queue-policy-row { + display: flex; + align-items: center; + gap: 20px; + padding: 16px 0; + border-bottom: 1px solid var(--n-9); +} + +.queue-policy-row > div { + flex: 1; + min-width: 0; +} + +.queue-policy-row strong { + display: block; + font-size: 13.5px; + font-weight: 600; + color: var(--n-1); +} + +.queue-policy-row small { + display: block; + margin-top: 3px; + font-size: 12px; + color: var(--n-4); + line-height: 1.5; +} + +.queue-policy-value { + flex: none; + display: flex; + align-items: center; + gap: 8px; +} + +.queue-policy-value input { + height: 36px; + width: 86px; + padding: 0 12px; + border: 1px solid var(--n-7); + border-radius: 8px; + background: var(--n-10, var(--surface-card)); + color: var(--n-1); + font: inherit; + font-size: 13.5px; + font-weight: 600; + font-variant-numeric: tabular-nums; + text-align: right; +} + +.queue-policy-value span { + width: 40px; + font-size: 12.5px; + color: var(--n-4); +} + +.queue-policy-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding-top: 18px; +} + +.queue-policy-footer small { + font-size: 12px; + color: var(--n-4); +} + +.queue-policy-actions { + display: flex; + gap: 10px; +} + +/* Сроки бесполезны, если сотруднику некуда прийти уведомлению. */ +.queue-policy-warning { + display: flex; + gap: 11px; + margin-top: 18px; + padding: 14px 16px; + border-radius: 10px; + background: var(--warning-bg); + border: 1px solid var(--warning-border, var(--warning-bg)); +} + +.queue-policy-warning span { + flex: none; + margin-top: 1px; + color: var(--warning-text); +} + +.queue-policy-warning p { + margin: 0; + font-size: 12.5px; + color: var(--warning-text); + line-height: 1.55; +} diff --git a/apps/internal-ui/src/i18n/en.ts b/apps/internal-ui/src/i18n/en.ts index bd58696..8c394bc 100644 --- a/apps/internal-ui/src/i18n/en.ts +++ b/apps/internal-ui/src/i18n/en.ts @@ -529,6 +529,7 @@ export const en: Record = { "common.connected": "Connected", "common.connections": "Connections", "common.contact": "Contact", + "common.enabled": "Enabled", "common.contacts": "Contacts", "common.contains_digit": "Contains a digit", "common.conversations": "Conversations", @@ -747,6 +748,25 @@ export const en: Record = { "conversations.transcribe": "Transcribe", "conversations.transcribing": "Transcribing…", "conversations.type_message_shift_enter_line": "Type a message… Shift+Enter for a line break, “/” for a reply template", + "conversations.common_queue": "Common queue", + "conversations.on_me": "On me", + "conversations.queue_tabs_note": "“Common queue” holds conversations with no assignee: anyone can take them. “On me” are assigned to you personally and wait until you take them.", + "conversations.on_you_returns_in": "on you · returns in {count} min", + "conversations.on_you": "on you", + "conversations.waiting_for_person": "Waiting for a person {duration}", + "conversations.assigned_not_taken": "Assigned to {name}, not taken", + "conversations.returns_to_queue_in": "If they do not take it, the conversation returns to the common queue in {duration}.", + "conversations.in_queue_since": "In the queue since", + "conversations.presence_here": "Here now", + "conversations.presence_away": "Away", + "conversations.presence_note": "“Here now” means the app is open. The sign is approximate: anyone can be assigned.", + "conversations.here": "here now", + "conversations.here_owner": "here now · owner", + "conversations.away_minutes": "away · {count} min", + "conversations.away_hours": "away · {count} h", + "conversations.away_long": "away for over a day", + "conversations.never_in_app": "has not signed in yet", + "conversations.open_dialogs": "{count} conv.", "conversations.unassigned": "Unassigned", "conversations.update_browser_or_open_link": "Update your browser or open the link in Chrome, Safari or Edge.", "conversations.video_call": "video call", @@ -763,6 +783,7 @@ export const en: Record = { "plural.categories": { one: "{count} category", other: "{count} categories" }, "plural.chunks": { one: "{count} chunk", other: "{count} chunks" }, "plural.conversations": { one: "{count} conversation", other: "{count} conversations" }, + "plural.minutes": { one: "{count} minute", other: "{count} minutes" }, "plural.devices": { one: "{count} device", other: "{count} devices" }, "plural.documents": { one: "{count} document", other: "{count} documents" }, "plural.events": { one: "{count} event", other: "{count} events" }, @@ -877,8 +898,8 @@ export const en: Record = { "portals.import_2": "Import", "portals.import_articles_from_yaml": "Import articles from YAML", "portals.importing": "Importing…", - "portals.insert_image": "Insert an image", "portals.installation_domain_missing": "The installation address has no domain — there is nowhere to host a portal. Set the installation domain in Settings, then come back.", + "portals.insert_image": "Insert an image", "portals.insert_into_text": "Insert into the text", "portals.inserted_into_text": "· inserted into the text", "portals.invalid_yaml": "Invalid YAML", @@ -1228,6 +1249,15 @@ export const en: Record = { "profile.current": "current", "profile.current_password": "Current password", "profile.custom_colour_hex_value_like": "A custom colour is a HEX value like #0f9b8e", + "profile.browser_blocked": "Blocked by the browser", + "profile.browser_blocked_steps": "Click the lock icon left of the address → “Notifications” → “Allow”, then reload the page. The site cannot lift the block itself.", + "profile.browser_blocked_title": "Only the browser can lift this block", + "profile.browser_disable_note": "“Turn off” stops notifications in this browser. The permission itself stays granted — only your browser settings can revoke it.", + "profile.browser_enabled": "On", + "profile.browser_not_enabled_yet": "Off. The browser will ask for permission when you click.", + "profile.browser_visible_when_minimized": "{device} — visible while the tab is minimised", + "profile.check_again": "Check again", + "profile.enable": "Enable", "profile.dark": "Dark", "profile.disconnect": "Disconnect", "profile.drag_resize_double_click_reset": "Drag to resize, double-click to reset", @@ -1285,7 +1315,8 @@ export const en: Record = { "profile.link_bot": "Link a bot", "profile.match_system": "Match the system", "profile.menu": "Menu", - "profile.messenger_notifications": "Messenger notifications", + "profile.this_browser": "This browser", + "profile.work_event_notifications": "Work event notifications", "profile.min_10_characters": "min. 10 characters", "profile.no_notification_bots_configured_owner": "No notification bots are configured. The owner can add one under Settings → Integrations (the “Notification bot for operators” checkbox).", "profile.off_strongly_recommended_owner_administrators": "Off. Strongly recommended for the owner and administrators.", @@ -1314,7 +1345,7 @@ export const en: Record = { "profile.updating": "Updating", "profile.user_menu": "User menu", "profile.without_second_factor_access_organization": "Without a second factor, access to the organization rests on the password alone", - "profile.work_events_reach_even_with": "Work events reach you even with the browser closed. One messenger at a time: linking a new one replaces the current.", + "profile.choose_where_to_call_you": "Choose where to reach you. The browser — on this device, while the app is open or minimised. A messenger — anywhere, including with the browser closed; one messenger at a time: linking a new one replaces the current.", "sales.all_agents": "All agents", "sales.all_channels": "All channels", "sales.anonymous_session": "{channel} · {connection}, anonymous session", @@ -1407,6 +1438,22 @@ export const en: Record = { "settings.address_hint": "Until you have a domain, this holds the server address — the one you ran the first-start wizard on. Once you have a domain and put TLS in front of the installation, enter it here and switch to https. Links will look like", "settings.address_placeholder": "address", "settings.addressing": "Addressing", + "settings.when_to_call_for_help": "When to call for help", + "settings.queue_section_lead": "When a customer is waiting for a person, we remind about the conversation — the group first, then everyone, then a manager. The delays count from the moment the conversation joined the queue and apply to the whole organisation.", + "settings.queue_remind_group": "Remind the group the conversation is waiting", + "settings.queue_remind_group_hint": "The notification goes to the group the conversation belongs to.", + "settings.queue_call_everyone": "Call everyone if it is still waiting", + "settings.queue_call_everyone_hint": "The notification goes to every employee, not just the group.", + "settings.queue_tell_manager": "Tell a manager", + "settings.queue_tell_manager_hint": "The owner and admins — it means the usual people did not pick it up.", + "settings.queue_wait_for_assignee": "How long we wait for the assigned employee", + "settings.queue_wait_for_assignee_hint": "If they do not take it in time, the conversation goes back to the common queue and is visible to everyone again.", + "settings.queue_minutes_unit": "min", + "settings.queue_saved_by": "Saved {when} · {who}", + "settings.queue_never_changed": "The delays have never been changed", + "settings.queue_restore_defaults": "Restore the usual delays", + "settings.queue_policy_unavailable": "Could not load the delays", + "settings.queue_delivery_warning": "Reminders arrive where the employee will see them: the browser and the linked messenger. If a group has neither, only the counter in the conversation list is left.", "settings.ai_provider": "AI provider", "settings.allowed_domains": "Allowed domains", "settings.answers_from_agent_s_knowledge": "Answers from the agent's knowledge with no external requests or keys. Answer quality is limited — for real work connect OpenRouter or a compatible provider.", diff --git a/apps/internal-ui/src/i18n/ru.ts b/apps/internal-ui/src/i18n/ru.ts index f14b6b1..6c7b60a 100644 --- a/apps/internal-ui/src/i18n/ru.ts +++ b/apps/internal-ui/src/i18n/ru.ts @@ -530,6 +530,7 @@ export const ru = { "common.connected": "Подключено", "common.connections": "Подключения", "common.contact": "Контакт", + "common.enabled": "Включено", "common.contacts": "Контакты", "common.contains_digit": "Содержит цифру", "common.conversations": "Диалоги", @@ -748,6 +749,25 @@ export const ru = { "conversations.transcribe": "Расшифровать", "conversations.transcribing": "Расшифровываем…", "conversations.type_message_shift_enter_line": "Введите сообщение… Shift+Enter — перенос строки, «/» — шаблон ответа", + "conversations.common_queue": "Общая очередь", + "conversations.on_me": "На мне", + "conversations.queue_tabs_note": "«Общая очередь» — диалоги без ответственного: взять может любой. «На мне» — назначены лично вам и ждут, пока вы их возьмёте.", + "conversations.on_you_returns_in": "на вас · вернётся через {count} мин", + "conversations.on_you": "на вас", + "conversations.waiting_for_person": "Ждёт человека {duration}", + "conversations.assigned_not_taken": "Назначен на {name}, не взят", + "conversations.returns_to_queue_in": "Если не возьмёт — через {duration} диалог вернётся в общую очередь.", + "conversations.in_queue_since": "В очереди с", + "conversations.presence_here": "На месте", + "conversations.presence_away": "Сейчас нет", + "conversations.presence_note": "«На месте» — приложение открыто. Признак приблизительный: назначить можно любого.", + "conversations.here": "на месте", + "conversations.here_owner": "на месте · владелец", + "conversations.away_minutes": "не в приложении · {count} мин", + "conversations.away_hours": "не в приложении · {count} ч", + "conversations.away_long": "не в приложении больше суток", + "conversations.never_in_app": "ещё не заходил", + "conversations.open_dialogs": "{count} диал.", "conversations.unassigned": "Не назначен", "conversations.update_browser_or_open_link": "Обновите браузер или откройте ссылку в Chrome, Safari или Edge.", "conversations.video_call": "видеозвонок", @@ -764,6 +784,7 @@ export const ru = { "plural.categories": { one: "{count} категория", few: "{count} категории", many: "{count} категорий", other: "{count} категорий" }, "plural.chunks": { one: "{count} фрагмент", few: "{count} фрагмента", many: "{count} фрагментов", other: "{count} фрагментов" }, "plural.conversations": { one: "{count} диалог", few: "{count} диалога", many: "{count} диалогов", other: "{count} диалогов" }, + "plural.minutes": { one: "{count} минуту", few: "{count} минуты", many: "{count} минут", other: "{count} минут" }, "plural.devices": { one: "{count} устройство", few: "{count} устройства", many: "{count} устройств", other: "{count} устройств" }, "plural.documents": { one: "{count} документ", few: "{count} документа", many: "{count} документов", other: "{count} документов" }, "plural.events": { one: "{count} событие", few: "{count} события", many: "{count} событий", other: "{count} событий" }, @@ -878,8 +899,8 @@ export const ru = { "portals.import_2": "Импорт", "portals.import_articles_from_yaml": "Импорт статей из YAML", "portals.importing": "Импорт…", - "portals.insert_image": "Вставить изображение", "portals.installation_domain_missing": "Адрес установки задан без домена — размещать портал негде. Укажите домен установки в «Настройках», затем вернитесь сюда.", + "portals.insert_image": "Вставить изображение", "portals.insert_into_text": "Вставить в текст", "portals.inserted_into_text": "· вставлен в текст", "portals.invalid_yaml": "Невалидный YAML", @@ -1229,6 +1250,15 @@ export const ru = { "profile.current": "текущая", "profile.current_password": "Текущий пароль", "profile.custom_colour_hex_value_like": "Свой цвет — HEX вида #0f9b8e", + "profile.browser_blocked": "Браузер запретил", + "profile.browser_blocked_steps": "Нажмите значок замка слева от адреса → «Уведомления» → «Разрешать», затем обновите страницу. Сайт снять запрет не может.", + "profile.browser_blocked_title": "Снять запрет можно только в браузере", + "profile.browser_disable_note": "«Отключить» выключает показ уведомлений в этом браузере. Само разрешение остаётся выданным — снять его можно только в настройках браузера.", + "profile.browser_enabled": "Включены", + "profile.browser_not_enabled_yet": "Не включены. Браузер спросит разрешение по нажатию.", + "profile.browser_visible_when_minimized": "{device} — видно, когда вкладка свёрнута", + "profile.check_again": "Проверить снова", + "profile.enable": "Включить", "profile.dark": "Тёмная", "profile.disconnect": "Отключить", "profile.drag_resize_double_click_reset": "Потяните, двойной клик — сбросить", @@ -1286,7 +1316,8 @@ export const ru = { "profile.link_bot": "Привязать бота", "profile.match_system": "Как в системе", "profile.menu": "Меню", - "profile.messenger_notifications": "Уведомления в мессенджер", + "profile.this_browser": "Этот браузер", + "profile.work_event_notifications": "Уведомления о рабочих событиях", "profile.min_10_characters": "мин. 10 символов", "profile.no_notification_bots_configured_owner": "Боты уведомлений не настроены. Владелец может добавить бота в «Настройках» → «Интеграции» (флажок «Бот уведомлений для сотрудников»).", "profile.off_strongly_recommended_owner_administrators": "Отключена. Для владельца и администраторов настоятельно рекомендуется включить.", @@ -1315,7 +1346,7 @@ export const ru = { "profile.updating": "Обновление", "profile.user_menu": "Меню пользователя", "profile.without_second_factor_access_organization": "Без второго фактора доступ к организации защищён только паролем", - "profile.work_events_reach_even_with": "Рабочие события приходят и при закрытом браузере. Один мессенджер: привязка нового заменяет текущий.", + "profile.choose_where_to_call_you": "Выберите, где вас звать. Браузер — на этом устройстве, пока приложение открыто или свёрнуто. Мессенджер — где угодно, в том числе при закрытом браузере; мессенджер один: привязка нового заменяет текущий.", "sales.all_agents": "Все агенты", "sales.all_channels": "Все каналы", "sales.anonymous_session": "{channel} · {connection}, анонимная сессия", @@ -1408,6 +1439,22 @@ export const ru = { "settings.address_hint": "Пока домена нет, здесь стоит адрес сервера — тот, на котором вы прошли первый запуск. Заведёте домен и поставите перед установкой TLS — впишите его и переключите на https. Ссылки будут вида", "settings.address_placeholder": "адрес", "settings.addressing": "Адресация", + "settings.when_to_call_for_help": "Когда звать на помощь", + "settings.queue_section_lead": "Если клиент ждёт человека, мы напоминаем о диалоге — сначала группе, потом всем, потом руководителю. Сроки считаются с момента, когда диалог встал в очередь. Действуют на всю организацию.", + "settings.queue_remind_group": "Напомнить группе, что диалог ждёт", + "settings.queue_remind_group_hint": "Уведомление получат сотрудники группы, за которой закреплён диалог.", + "settings.queue_call_everyone": "Позвать всех, если диалог всё ещё ждёт", + "settings.queue_call_everyone_hint": "Уведомление уйдёт всем сотрудникам, а не только группе.", + "settings.queue_tell_manager": "Сообщить руководителю", + "settings.queue_tell_manager_hint": "Владельцу и администраторам — значит, обычными силами диалог не разобрали.", + "settings.queue_wait_for_assignee": "Сколько ждём сотрудника, на которого назначен диалог", + "settings.queue_wait_for_assignee_hint": "Не взял за это время — диалог возвращается в общую очередь и снова виден всем.", + "settings.queue_minutes_unit": "мин", + "settings.queue_saved_by": "Сохранено {when} · {who}", + "settings.queue_never_changed": "Сроки ни разу не меняли", + "settings.queue_restore_defaults": "Вернуть обычные сроки", + "settings.queue_policy_unavailable": "Не удалось загрузить сроки", + "settings.queue_delivery_warning": "Напоминания приходят туда, где сотрудник их получит: в браузер и в привязанный мессенджер. Если у группы не настроен ни один способ, останется только счётчик в списке диалогов.", "settings.ai_provider": "AI-провайдер", "settings.allowed_domains": "Разрешённые домены", "settings.answers_from_agent_s_knowledge": "Отвечает по знаниям агента без внешних запросов и ключей. Качество ответов ограничено — для реальной работы подключите OpenRouter или совместимый провайдер.", diff --git a/apps/internal-ui/src/layout/Shell.tsx b/apps/internal-ui/src/layout/Shell.tsx index 3623718..f55324f 100644 --- a/apps/internal-ui/src/layout/Shell.tsx +++ b/apps/internal-ui/src/layout/Shell.tsx @@ -1,12 +1,13 @@ -import { notification as antToast } from "antd"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import type { AppData, AuthenticatedUser, Employee, RouteKey, SessionUser } from "../types"; import type { SettingsSectionKey } from "../features/settings/sections"; import type { PortalSettingsSectionKey } from "../features/support-portals/sections"; import { NotificationDrawer } from "../features/notifications/NotificationDrawer"; import { fetchNotifications, markAllRead, markRead, type AppNotification } from "../features/notifications/model"; +import { useNotificationAlerts } from "../features/notifications/useNotificationAlerts"; import { fetchWaitingCount } from "../features/conversations/model"; +import { useRealtime, useRealtimeEvent } from "../features/realtime/RealtimeProvider"; import { useChatScope } from "../features/chat/useChatScope"; import { isManager } from "../auth/access"; import { DemoInstallBanner } from "../features/settings/DemoInstallBanner"; @@ -26,7 +27,6 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel const [waitingCount, setWaitingCount] = useState(0); // Кадры S2/M1: рейка или ☰ раскрывают сайдбар поверх контента. const [sidebarExpanded, setSidebarExpanded] = useState(false); - const prevUnread = useRef(null); const manager = isManager(user); // Охват чата живёт здесь: сотрудницкий сайдбар и страница чата делят одно // состояние (дизайн-базлайн v2 §4.1). @@ -47,28 +47,41 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel const loadNotifications = useCallback(async () => { try { const payload = await fetchNotifications(); - setNotifications(payload.items); - setUnreadCount(payload.unreadCount); - // Тост при появлении новых непрочитанных (после первой загрузки). - if (prevUnread.current !== null && payload.unreadCount > prevUnread.current) { - const latest = payload.items.find((item) => item.unread); - if (latest) antToast.open({ message: latest.title, description: latest.body, placement: "bottomRight" }); - } - prevUnread.current = payload.unreadCount; + setNotifications(payload.items ?? []); + setUnreadCount(payload.unreadCount ?? 0); } catch { /* ignore transient errors */ } }, []); + // Уведомление доезжает событием, а не следующим опросом: раньше оператор + // узнавал о ждущем диалоге в среднем через восемь секунд после того, как + // клиент написал, и только если вкладка была открыта на чате. + const realtime = useRealtime(); + useRealtimeEvent("notifications.changed", () => void loadNotifications()); + useRealtimeEvent("inbox.changed", () => void loadWaitingCount()); + useEffect(() => { + void loadNotifications(); + void loadWaitingCount(); + }, [loadNotifications, loadWaitingCount]); + + useEffect(() => { + // Опрос остаётся страховкой на случай обрыва сокета и потому разрежается, + // пока тот жив. Смена состояния соединения меняет только интервал: если + // перезапускать вместе с ним и загрузку, недоступный сокет с его + // переподключениями превращается в поток лишних запросов. const tick = () => { void loadNotifications(); void loadWaitingCount(); }; - tick(); - const timer = setInterval(tick, 15000); + const timer = setInterval(tick, realtime.connected ? 60000 : 15000); return () => clearInterval(timer); - }, [loadNotifications, loadWaitingCount]); + }, [loadNotifications, loadWaitingCount, realtime.connected]); + + // Тост и системное уведомление — по идентификаторам пришедшего, а не по + // росту счётчика непрочитанных. + useNotificationAlerts({ items: notifications, onOpen: (item) => void onNotificationClick(item) }); async function onNotificationClick(notification: AppNotification) { setNotifOpen(false); diff --git a/apps/internal-ui/src/shared/icons.tsx b/apps/internal-ui/src/shared/icons.tsx index 9757e3e..a96e7be 100644 --- a/apps/internal-ui/src/shared/icons.tsx +++ b/apps/internal-ui/src/shared/icons.tsx @@ -57,7 +57,7 @@ export function MaxLogo({ size = 24 }: { size?: number }) { ); } -export function Icon({ name, size = 17, strokeWidth = 1.8 }: { name: "grid" | "building" | "team" | "box" | "robot" | "plug" | "settings" | "bell" | "chevron" | "chevronLeft" | "chevronRight" | "plus" | "arrow" | "paperclip" | "send" | "phone" | "video" | "lock" | "mail" | "eye" | "eyeOff" | "logout" | "user" | "refresh" | "transfer" | "warning" | "alert" | "bolt" | "shop" | "search" | "more" | "external" | "key" | "pause" | "play" | "percent" | "save" | "check" | "copy" | "clock" | "message" | "inbox" | "cart" | "columns" | "download" | "list" | "split" | "gitBranch" | "route" | "expand" | "edit" | "trash" | "wrench" | "xCircle" | "close" | "folder" | "grip" | "reply" | "sort" | "collapseLeft" | "mic" | "text" | "pin" | "smile" | "globe" | "doc" | "sparkles" | "database" | "sun" | "sunny" | "moon" | "monitor" | "laptop" | "smartphone" | "arrowDown" | "link" | "paint" | "widget" | "danger" | "image" | "file" | "bold" | "italic" | "code" | "numlist" | "quote" | "table" | "attach" | "undo" | "upload" | "import" | "thumbUp" | "thumbDown" | "book" | "move"; size?: number; strokeWidth?: number }) { +export function Icon({ name, size = 17, strokeWidth = 1.8 }: { name: "grid" | "building" | "team" | "box" | "robot" | "plug" | "settings" | "bell" | "bellOff" | "chevron" | "chevronLeft" | "chevronRight" | "plus" | "arrow" | "paperclip" | "send" | "phone" | "video" | "lock" | "mail" | "eye" | "eyeOff" | "logout" | "user" | "refresh" | "transfer" | "warning" | "alert" | "bolt" | "shop" | "search" | "more" | "external" | "key" | "pause" | "play" | "percent" | "save" | "check" | "copy" | "clock" | "message" | "inbox" | "cart" | "columns" | "download" | "list" | "split" | "gitBranch" | "route" | "expand" | "edit" | "trash" | "wrench" | "xCircle" | "close" | "folder" | "grip" | "reply" | "sort" | "collapseLeft" | "mic" | "text" | "pin" | "smile" | "globe" | "doc" | "sparkles" | "database" | "sun" | "sunny" | "moon" | "monitor" | "laptop" | "smartphone" | "arrowDown" | "link" | "paint" | "widget" | "danger" | "image" | "file" | "bold" | "italic" | "code" | "numlist" | "quote" | "table" | "attach" | "undo" | "upload" | "import" | "thumbUp" | "thumbDown" | "book" | "move"; size?: number; strokeWidth?: number }) { const common = { width: size, height: size, fill: "none", stroke: "currentColor", strokeWidth, strokeLinecap: "round", strokeLinejoin: "round" } as const; const paths: Record = { thumbUp: <>, @@ -74,6 +74,8 @@ export function Icon({ name, size = 17, strokeWidth = 1.8 }: { name: "grid" | "b plug: , settings: <>, bell: <>, + // Перечёркнутый колокол: браузер запретил уведомления (макет Q1b). + bellOff: <>, message: , cart: <>, globe: , diff --git a/design/baseline/Очередь и уведомления/README.md b/design/baseline/Очередь и уведомления/README.md new file mode 100644 index 0000000..1150b38 --- /dev/null +++ b/design/baseline/Очередь и уведомления/README.md @@ -0,0 +1,54 @@ +# Очередь, ожидание и уведомления — решения по макету + +Файл: `Очередь и уведомления Baseline.dc.html` (тема/акцент — в Tweaks, как у остальных baseline-файлов). Фреймы Q1–Q5 соответствуют пяти пунктам задачи. Тексты — без слов «эскалация», «порог», «SLA». + +## Q1 · Профиль → карточка уведомлений + +- **Заголовок:** «Уведомления о рабочих событиях». +- **Подводка:** «Выберите, где вас звать. Браузер — на этом устройстве, пока приложение открыто или свёрнуто. Мессенджер — где угодно, в том числе при закрытом браузере; мессенджер один: привязка нового заменяет текущий.» Первая фраза честна для обоих способов, ограничение «один мессенджер» отнесено к мессенджеру. +- **Порядок:** браузер **выше** ботов. Он включается одним нажатием и без внешнего аккаунта — это первый разумный шаг; боты ниже как «доставка наружу». +- **Три состояния строки (Q1b):** + 1. *не запрашивали* — серая плитка, текст «Не включены. Браузер спросит разрешение по нажатию», primary «Включить»; + 2. *выдано и включено* — зелёная плашка «Включены», «Отключить», галочки типов событий; + 3. *браузер отказал* — красная плашка «Браузер запретил» + подсказка «Снять запрет можно только в браузере» с шагами (замок в адресной строке → «Уведомления» → «Разрешать» → обновить) и кнопкой «Проверить снова». Кнопки «Включить» в этом состоянии нет — она бы ничего не сделала. +- Под включённой строкой — примечание, что «Отключить» выключает показ, а не разрешение. +- В типах событий добавлен пункт «Диалог назначили на меня» — иначе новый вид ожидания никак не доходит до сотрудника. +- **Дополнено после хендоффа (решение владельца):** первым пунктом вернулся «Новый диалог». В присланном наборе такого события не было, а в продукте оклик о новом диалоге уходит и тогда, когда диалог ведёт агент; называть его «клиент запросил оператора» — неправда. По умолчанию пункт выключен: новый диалог, которым занимается робот, лично никого не касается. Тем, у кого он был включён раньше, он сохранён при обновлении. + +## Q2 · Настройки → «Когда звать на помощь» + +- **Новый раздел субменю** сразу после «Групп» (сроки касаются групп), не внутри «Организации»: там реквизиты организации, а здесь правила работы очереди — смешивать их значит спрятать сроки. +- Название раздела — по сути: «Когда звать на помощь». +- Четыре поля, каждое с пояснением: напомнить группе (5), позвать всех (15), сообщить руководителю (30), сколько ждём назначенного сотрудника (10). Единица «мин» вынесена из поля. +- Футер: дата и автор сохранения, «Вернуть обычные сроки», primary «Сохранить». +- Предупреждение внизу: если у группы не настроен ни один способ доставки, останется только счётчик в списке. + +## Q3 · Чат → два вида ожидания + +Показаны два варианта, **рекомендуем А**: + +- **А · две вкладки.** «Ждут оператора» разделена на «Общая очередь» и «На мне». Разные действия (взять чужое / не потерять своё) — разные списки; счётчик «На мне» становится личной нормой, а не частью общего числа. +- **Б · одна вкладка с метками.** Меньше вкладок, но два вида ожидания перемешаны, и своё приходится выцеплять глазами. + +В обоих вариантах строка получила синюю метку «на вас · вернётся через N мин». + +## Q4 · Строка и карточка диалога + +- **Время ожидания — и там и там.** В строке это уже существующий тёплый бейдж с таймером (без слова «ждёт»), в карточке — новый блок «Ожидание» сверху: «Ждёт человека 7 минут». +- **«Назначен, но не взят» показываем явно** — с аватаром и оставшимся временем: «Назначен на Анну Ким, не взят» + «Если не возьмёт — через 3 минуты диалог вернётся в общую очередь». Это единственное, что объясняет, почему диалог виден, но брать его не нужно. +- Поле «Начат» в карточке заменено на «В очереди с» — момент постановки в очередь информативнее для оператора. +- **Системные события** — обычный вид. Единственное исключение: «не взял — вернулся в очередь» окрашен как предупреждение (тёплая плашка): это не факт из истории, а сорванная договорённость. + +## Q5 · Ответственный → присутствие + +- В списке две группы: «На месте» и «Сейчас нет», внутри — сотрудники с точкой присутствия (зелёная / пустая обводка) и подписью «на месте» / «не в приложении · 25 мин». +- Отсутствующие **не блокируются и не скрываются** — только уходят в конец списка; в футере списка честная оговорка: «„На месте“ — приложение открыто. Признак приблизительный: назначить можно любого». +- Справа у строки — текущая загрузка («4 диалога»): при выборе ответственного это второй по важности признак после присутствия. +- В закрытом поле «Ответственный» присутствие показано той же точкой справа от имени. + +## Что требует бэкенда + +- Разрешение браузера и «желание получать» — два разных флага: `browser_push_enabled` на устройстве/сессии + типы событий, как у ботов. +- Четыре срока организации — в API настроек (сейчас только служебная админка). +- В списке диалогов нужны `assignee`, `assigned_at` и момент постановки в очередь, чтобы считать «вернётся через N мин». +- Признак присутствия сотрудника (последняя активность) в выборе ответственного. diff --git a/design/baseline/Очередь и уведомления/support.js b/design/baseline/Очередь и уведомления/support.js new file mode 100644 index 0000000..cb009b6 --- /dev/null +++ b/design/baseline/Очередь и уведомления/support.js @@ -0,0 +1,1911 @@ +// GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`. +"use strict"; +(() => { + var __defProp = Object.defineProperty; + var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; + var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + + // src/react.ts + function getReact() { + const R = window.React; + if (!R) throw new Error("dc-runtime: window.React is not available yet"); + return R; + } + function getReactDOM() { + const RD = window.ReactDOM; + if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet"); + return RD; + } + var h = ((...args) => getReact().createElement( + ...args + )); + + // src/parse.ts + function parseDcDocument(doc) { + const dc = doc.querySelector("x-dc"); + if (!dc) return null; + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template: dc.innerHTML, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDcText(src) { + const openMatch = /]*)?>/.exec(src); + if (!openMatch) return null; + const close = src.lastIndexOf(""); + if (close === -1 || close < openMatch.index) return null; + const template = src.slice(openMatch.index + openMatch[0].length, close); + const doc = new DOMParser().parseFromString(src, "text/html"); + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDataProps(raw) { + if (!raw) return { props: null, preview: null }; + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { props: null, preview: null }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { props: null, preview: null }; + } + const obj = parsed; + const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null; + const rest = {}; + for (const k of Object.keys(obj)) { + if (k[0] !== "$") rest[k] = obj[k]; + } + return { props: Object.keys(rest).length ? rest : null, preview }; + } + function dcNameFromPath(pathname) { + let p = pathname || ""; + try { + p = decodeURIComponent(p); + } catch { + } + const base = p.split("/").pop() || "Root"; + return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root"; + } + + // src/boot.ts + var BASE_CSS = ` + .sc-placeholder{background:color-mix(in srgb,currentColor 8%,transparent); + border:1px solid color-mix(in srgb,currentColor 50%,transparent); + border-radius:2px;box-sizing:border-box;overflow:hidden} + @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}} + html.sc-dc-streaming .sc-placeholder, + html.sc-dc-streaming .sc-interp.sc-missing{position:relative; + background:color-mix(in srgb,currentColor 5%,transparent); + border-color:transparent} + html.sc-dc-streaming .sc-placeholder::before, + html.sc-dc-streaming .sc-interp.sc-missing::before{content:''; + position:absolute;inset:0;pointer-events:none; + background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%); + background-size:400% 100%;animation:sc-shine 1.4s ease infinite} + html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before, + html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none; + background:color-mix(in srgb,currentColor 8%,transparent)} + .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace; + color:color-mix(in srgb,currentColor 70%,transparent);word-break:break-word} + .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden; + vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); + border-radius:2px;box-sizing:border-box;color:transparent; + user-select:none} + .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em; + color:color-mix(in srgb,currentColor 50%,transparent); + background:color-mix(in srgb,currentColor 10%,transparent);border-radius:3px; + padding:0 3px} + .sc-host.sc-has-error{position:relative} + .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch; + padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace; + border-radius:4px;white-space:pre-wrap;pointer-events:none} + /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both + in sync until dc-runtime regains a build step. */ + @media print { + @page { margin: 0.5cm; } + figure, table { break-inside: avoid; } + #dc-root, #dc-root > .sc-host { height: auto; } + *, *::before, *::after { + print-color-adjust: exact; -webkit-print-color-adjust: exact; + backdrop-filter: none !important; -webkit-backdrop-filter: none !important; + animation-delay: -99s !important; animation-duration: .001s !important; + animation-iteration-count: 1 !important; animation-fill-mode: both !important; + animation-play-state: running !important; transition-duration: 0s !important; + } + } + `; + var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}"; + function rootNameForDocument(doc, loc) { + let bootPath = loc.pathname || ""; + if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) { + try { + bootPath = new URL(doc.baseURI || "/").pathname; + } catch { + } + } + return dcNameFromPath(bootPath); + } + function safeDecode(s) { + try { + return decodeURIComponent(s); + } catch { + return s; + } + } + function boot(runtime, doc = document) { + const parsed = parseDcDocument(doc); + if (!parsed) return null; + const React = getReact(); + const rootName = rootNameForDocument(doc, location); + runtime.markFetched(rootName); + runtime.setRootName(rootName); + runtime.adoptParsed(rootName, parsed); + if (!window.__resources) { + fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => { + const raw = t ? parseDcText(t) : null; + if (raw?.template) runtime.updateHtml(rootName, raw.template); + }).catch(() => { + }); + } + const dc = doc.querySelector("x-dc"); + const hostEl = doc.createElement("div"); + hostEl.id = "dc-root"; + dc.replaceWith(hostEl); + if (!parsed.preview) { + const s = doc.createElement("style"); + s.textContent = FULL_PAGE_CSS; + doc.head.appendChild(s); + } + const Root = runtime.getDC(rootName); + const entry = runtime.registry.get(rootName); + function StandaloneRoot() { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + entry.subs.add(sub); + return () => { + entry.subs.delete(sub); + }; + }, []); + const defaults = React.useMemo(() => { + const d = {}; + for (const k in entry.propsMeta || {}) { + const v = entry.propsMeta?.[k]?.default; + if (v !== void 0) d[k] = v; + } + return d; + }, [entry.propsMeta]); + return h(Root, { ...defaults, ...entry.propOverrides || {} }); + } + const ReactDOM = getReactDOM(); + if (ReactDOM.createRoot) + ReactDOM.createRoot(hostEl).render(h(StandaloneRoot)); + else ReactDOM.render(h(StandaloneRoot), hostEl); + return rootName; + } + + // src/expr.ts + var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/; + var NUMBER_RE = /^-?\d+(\.\d+)?$/; + function resolve(vals, src) { + const expr = String(src).trim(); + if (!expr) return void 0; + if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) { + return resolve(vals, expr.slice(1, -1)); + } + const eq = findTopLevelEquality(expr); + if (eq) { + const lv = resolve(vals, expr.slice(0, eq.index)); + const rv = resolve(vals, expr.slice(eq.index + eq.op.length)); + switch (eq.op) { + case "===": + return lv === rv; + case "!==": + return lv !== rv; + case "==": + return lv == rv; + default: + return lv != rv; + } + } + if (expr[0] === "!") return !resolve(vals, expr.slice(1)); + if (expr === "true") return true; + if (expr === "false") return false; + if (expr === "null") return null; + if (expr === "undefined") return void 0; + if (NUMBER_RE.test(expr)) return Number(expr); + if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) { + return expr.slice(1, -1); + } + return resolvePath(vals, expr); + } + function parensWrapWhole(expr) { + let depth = 0; + for (let i = 0; i < expr.length - 1; i++) { + if (expr[i] === "(") depth++; + else if (expr[i] === ")") { + depth--; + if (depth === 0) return false; + } + } + return true; + } + function findTopLevelEquality(expr) { + let depth = 0; + for (let i = 0; i < expr.length; i++) { + const c = expr[i]; + if (c === "[" || c === "(") depth++; + else if (c === "]" || c === ")") depth--; + else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") { + if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue; + if (!expr.slice(0, i).trim()) continue; + const op = expr[i + 2] === "=" ? c + "==" : c + "="; + return { index: i, op }; + } + } + return null; + } + function resolvePath(vals, expr) { + const head = expr.match(IDENT_RE); + if (!head) return void 0; + let cur = vals == null ? void 0 : vals[head[0]]; + let i = head[0].length; + while (i < expr.length) { + if (expr[i] === ".") { + const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/); + if (!m) return void 0; + cur = cur == null ? void 0 : cur[m[0]]; + i += 1 + m[0].length; + } else if (expr[i] === "[") { + let depth = 1; + let j = i + 1; + while (j < expr.length && depth > 0) { + if (expr[j] === "[") depth++; + else if (expr[j] === "]") { + depth--; + if (depth === 0) break; + } + j++; + } + if (depth !== 0) return void 0; + const key = resolve(vals, expr.slice(i + 1, j)); + cur = cur == null ? void 0 : cur[key]; + i = j + 1; + } else { + return void 0; + } + } + return cur; + } + + // src/encode.ts + var CAMEL_ATTR = "sc-camel-"; + var INLINE_TEXT_TAGS = new Set( + "a abbr b bdi bdo br cite code del dfn em i ins kbd mark q s samp small span strike strong sub sup u var wbr".split( + " " + ) + ); + var RAW_WRAP = { + select: "sc-raw-select", + table: "sc-raw-table", + tbody: "sc-raw-tbody", + thead: "sc-raw-thead", + tfoot: "sc-raw-tfoot", + tr: "sc-raw-tr", + td: "sc-raw-td", + th: "sc-raw-th", + caption: "sc-raw-caption" + }; + var RAW_UNWRAP = Object.fromEntries( + Object.entries(RAW_WRAP).map(([k, v]) => [v, k]) + ); + var EVENT_MAP = { + onclick: "onClick", + onchange: "onChange", + oninput: "onInput", + onsubmit: "onSubmit", + onkeydown: "onKeyDown", + onkeyup: "onKeyUp", + onkeypress: "onKeyPress", + onmousedown: "onMouseDown", + onmouseup: "onMouseUp", + onmouseenter: "onMouseEnter", + onmouseleave: "onMouseLeave", + onfocus: "onFocus", + onblur: "onBlur", + ondoubleclick: "onDoubleClick", + oncontextmenu: "onContextMenu", + onmousemove: "onMouseMove", + onmouseover: "onMouseOver", + onmouseout: "onMouseOut", + onpointerdown: "onPointerDown", + onpointerup: "onPointerUp", + onpointermove: "onPointerMove", + onpointerenter: "onPointerEnter", + onpointerleave: "onPointerLeave", + onpointercancel: "onPointerCancel", + onpointerover: "onPointerOver", + onpointerout: "onPointerOut", + ongotpointercapture: "onGotPointerCapture", + onlostpointercapture: "onLostPointerCapture", + ontouchstart: "onTouchStart", + ontouchend: "onTouchEnd", + ontouchmove: "onTouchMove", + ontouchcancel: "onTouchCancel", + ondragstart: "onDragStart", + ondragend: "onDragEnd", + ondragenter: "onDragEnter", + ondragleave: "onDragLeave", + ondragover: "onDragOver", + onanimationstart: "onAnimationStart", + onanimationend: "onAnimationEnd", + onanimationiteration: "onAnimationIteration", + ontransitionend: "onTransitionEnd" + }; + var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`; + var IMPORT_SELF_CLOSE_RE = new RegExp( + "<(x-import|dc-import)(" + ATTRS + ")/>", + "gi" + ); + var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g; + function encodeCamelAttrs(html) { + return html.replace( + CAMEL_ATTR_RE, + (_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq + ); + } + function encodeCase(html) { + html = html.replace( + IMPORT_SELF_CLOSE_RE, + (_, t, a) => "<" + t + a + ">" + ); + html = html.replace(/)/gi, "/gi, ""); + html = encodeCamelAttrs(html); + for (const [real, alias] of Object.entries(RAW_WRAP)) { + html = html.replace( + new RegExp("(])", "gi"), + "$1" + alias + ); + } + return html; + } + function kebabToCamel(s) { + return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + } + function cssToObj(css) { + const o = {}; + for (const decl of css.split(";")) { + const i = decl.indexOf(":"); + if (i < 0) continue; + const prop = decl.slice(0, i).trim(); + o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim(); + } + return o; + } + function compileAttr(raw) { + const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/); + if (whole) { + const path = whole[1]; + return (vals) => resolve(vals, path); + } + if (raw.includes("{{")) { + const parts = raw.split(/\{\{([\s\S]+?)\}\}/g); + return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join(""); + } + return () => raw; + } + + // src/compile.ts + function collectProps(node, kind, host) { + const propGetters = []; + const pseudoClasses = []; + let hintSize = null; + for (const { name, value } of [...node.attributes]) { + if (name === "sc-name" || name === "data-dc-tpl") continue; + let key = name; + if (key.startsWith(CAMEL_ATTR)) + key = kebabToCamel(key.slice(CAMEL_ATTR.length)); + if (key === "hint-size") { + hintSize = value; + continue; + } + if (key.startsWith("style-")) { + pseudoClasses.push(host.pseudoClass(key.slice(6), value)); + continue; + } + if (kind !== "dom") { + if (key.includes("-") && !(kind === "x-import" && (key.startsWith("aria-") || key.startsWith("data-")))) + key = kebabToCamel(key); + } else { + if (key === "class") key = "className"; + else if (key === "for") key = "htmlFor"; + else if (key.startsWith("on")) + key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3); + } + propGetters.push([key, compileAttr(value)]); + } + return { propGetters, pseudoClasses, hintSize }; + } + var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([ + "position", + "left", + "right", + "top", + "bottom", + "inset", + "width", + "height", + "z-index", + "transform" + ]); + function hostPositionStyle(style) { + const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null; + if (!all) return void 0; + const out = {}; + for (const [k, v] of Object.entries(all)) { + const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); + if (HOST_STYLE_PROPS.has(kebab)) out[k] = v; + } + return Object.keys(out).length ? out : void 0; + } + function compileTemplate(html, host) { + const tpl = document.createElement("template"); + //! nosemgrep: direct-inner-html-assignment + tpl.innerHTML = encodeCase(html); + let tplN = 0; + (function stamp(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + node.setAttribute("data-dc-tpl", String(tplN++)); + } + for (const c of node.childNodes) stamp(c); + })(tpl.content); + const builders = walkChildren(tpl.content, host); + const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i))); + render.__annotated = tpl.innerHTML; + return render; + } + function walkChildren(node, host) { + return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null); + } + var SLIDE_ID_VALUE_RE = /^[0-9a-f]{8}$/; + var DECK_CONTROL_FLOW_RE = /^(sc-if|sc-for|sc-else|dc-import|x-import)$/; + var DECK_AUX_RE = /^(template|script|style|sc-helmet|helmet)$/; + function isDeckMountTag(el) { + if (el.localName === "deck-stage") return true; + return el.localName === "x-import" && (el.getAttribute("component-from-global-scope") || "") === "deck-stage"; + } + function walkDeckChildren(el, host) { + const pairs = [...el.childNodes].map((c) => ({ c, b: walk(c, host) })).filter((p) => p.b !== null); + const kids = pairs.map((p) => p.b); + const seen = /* @__PURE__ */ new Set(); + const wsSeen = /* @__PURE__ */ new Map(); + const keys = []; + const nextSlideId = new Array(pairs.length); + { + let upcoming = null; + for (let j = pairs.length - 1; j >= 0; j--) { + const n = pairs[j].c; + if (n.nodeType === Node.ELEMENT_NODE) { + const t = n.localName; + upcoming = !DECK_AUX_RE.test(t) && !DECK_CONTROL_FLOW_RE.test(t) ? n.getAttribute("data-om-slide-id") : null; + } + nextSlideId[j] = upcoming; + } + } + for (let j = 0; j < pairs.length; j++) { + const { c } = pairs[j]; + if (c.nodeType === Node.TEXT_NODE) { + if ((c.nodeValue ?? "").trim() === "") { + const base = nextSlideId[j] ? "omid-ws:" + nextSlideId[j] : "omid-ws:aux"; + const n = wsSeen.get(base) ?? 0; + wsSeen.set(base, n + 1); + keys.push(n === 0 ? base : base + ":" + n); + continue; + } + return { kids, keys: null }; + } + if (c.nodeType !== Node.ELEMENT_NODE) { + keys.push(j); + continue; + } + const child = c; + const tag = child.localName; + if (DECK_AUX_RE.test(tag)) { + keys.push(j); + continue; + } + if (DECK_CONTROL_FLOW_RE.test(tag)) return { kids, keys: null }; + const v = child.getAttribute("data-om-slide-id"); + if (!v || !SLIDE_ID_VALUE_RE.test(v) || seen.has(v)) { + return { kids, keys: null }; + } + seen.add(v); + keys.push("omid:" + v); + } + return { kids, keys }; + } + function renderDeckKids(kids, kidKeys, vals, ctx) { + return kids.map((b, j) => { + const k = kidKeys ? kidKeys[j] : j; + const out = b(vals, ctx, k); + return kidKeys != null && typeof out === "string" ? h(getReact().Fragment, { key: k }, out) : out; + }); + } + function walk(node, host) { + if (node.nodeType === Node.TEXT_NODE) return walkText(node); + if (node.nodeType !== Node.ELEMENT_NODE) return null; + const el = node; + const tag = el.tagName.toLowerCase(); + if (tag === "sc-for") return walkFor(el, host); + if (tag === "sc-if") return walkIf(el, host); + if (tag === "x-import") return walkXImport(el, host); + if (tag === "sc-helmet") return host.helmet(el); + if (tag === "dc-import") return walkComponent(el, host); + return walkElement(el, host); + } + var warnedHoles = /* @__PURE__ */ new Set(); + function warnUnresolved(ctx, what) { + const key = (ctx?.__name || "?") + "\0" + what; + if (warnedHoles.has(key)) return; + warnedHoles.add(key); + console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what); + } + function walkText(node) { + const txt = node.nodeValue ?? ""; + if (!txt.includes("{{")) { + if (!txt.trim() && !txt.includes(" ")) return null; + return () => txt; + } + const parts = txt.split(/\{\{([\s\S]+?)\}\}/g); + return (vals, ctx, key) => h( + getReact().Fragment, + { key }, + ...parts.map((p, i) => { + if (!(i & 1)) return p; + const v = resolve(vals, p); + if (v === void 0) { + if (!ctx?.__streamingNow) { + if (document.body?.hasAttribute("data-dc-editor-on")) { + return h( + "span", + { key: i, className: "sc-interp sc-unresolved" }, + "{{ " + p.trim() + " }}" + ); + } + warnUnresolved( + ctx, + "{{ " + p.trim() + " }} never resolved \u2014 rendered as empty" + ); + return null; + } + return h( + "span", + { key: i, className: "sc-interp sc-missing" }, + p.trim() + ); + } + if (getReact().isValidElement(v) || Array.isArray(v)) { + return h(getReact().Fragment, { key: i }, v); + } + if (v === null || typeof v === "boolean") return null; + return h("span", { key: i, className: "sc-interp" }, String(v)); + }) + ); + } + function walkFor(el, host) { + const listGet = compileAttr(el.getAttribute("list") || ""); + const asName = el.getAttribute("as") || "item"; + const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10); + const kids = walkChildren(el, host); + const listSrc = el.getAttribute("list") || ""; + return (vals, ctx, key) => { + let list = listGet(vals); + if (!Array.isArray(list)) { + if (!ctx?.__streamingNow) { + if (list !== void 0 && list !== null) { + warnUnresolved( + ctx, + 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")" + ); + } + list = []; + } else { + list = hintN > 0 ? Array(hintN).fill(void 0) : []; + } + } + return h( + getReact().Fragment, + { key }, + list.map((item, i) => { + const sub = { ...vals, [asName]: item, $index: i }; + return h( + getReact().Fragment, + { key: i }, + kids.map((b, j) => b(sub, ctx, j)) + ); + }) + ); + }; + } + function walkIf(el, host) { + const valGet = compileAttr(el.getAttribute("value") || ""); + const hintRaw = el.getAttribute("hint-placeholder-val"); + const hintGet = hintRaw != null ? compileAttr(hintRaw) : null; + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + let v = valGet(vals); + if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals); + return v ? h( + getReact().Fragment, + { key }, + kids.map((b, j) => b(vals, ctx, j)) + ) : null; + }; + } + function walkComponent(el, host) { + const name = el.getAttribute("name") || el.getAttribute("component") || ""; + el.removeAttribute("name"); + el.removeAttribute("component"); + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const { propGetters, hintSize } = collectProps(el, "dc-import", host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key, + __hintSize: hintSize, + __tplId: tplId, + __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0 + }; + for (const [k, g] of propGetters) { + const v = g(vals); + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return h(host.component(name), props); + }; + } + function walkXImport(el, host) { + const globalNameGet = compileAttr( + el.getAttribute("component-from-global-scope") || "" + ); + const exportNameGet = compileAttr( + el.getAttribute("component") || el.getAttribute("name") || "" + ); + const fromRaw = el.getAttribute("from") || (el.getAttribute("component-from-global-scope") ? "" : el.getAttribute("src") || el.getAttribute("import") || ""); + const urls = fromRaw.trim() ? fromRaw.trim().split(/\s+/) : []; + const url = urls.length ? urls[urls.length - 1] : ""; + const kindOf = (u) => /\.(jsx|tsx)(\?|#|$)/i.test(u) ? "jsx" : "js"; + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const wrap = tplId != null || styleGet != null; + const { propGetters, hintSize } = collectProps(el, "x-import", host); + const hasContent = el.children.length > 0 || !!(el.textContent || "").trim(); + const deckKeyed = hasContent && isDeckMountTag(el) ? walkDeckChildren(el, host) : null; + const kids = deckKeyed ? deckKeyed.kids : hasContent ? walkChildren(el, host) : []; + const kidKeys = deckKeyed?.keys ?? null; + const urlBindable = fromRaw.includes("{{"); + if (urls.length && !urlBindable) { + let prev; + for (const u of urls) prev = host.loadExternal(kindOf(u), u, prev); + } + const evalName = (g, vals) => { + const v = g(vals); + const s = v == null ? "" : String(v); + return s.includes("{{") ? "" : s; + }; + return (vals, ctx, key) => { + const globalName = evalName(globalNameGet, vals); + const name = globalName || evalName(exportNameGet, vals); + const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name); + const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0; + const wrapper = wrap ? { + key, + className: "sc-host-x", + "data-dc-tpl": tplId, + style: hostStyle || { display: "contents" } + } : null; + if (!C) { + const error = urlBindable ? "x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name); + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + const props = wrapper ? {} : { key }; + let unresolvedHole = false; + for (const [k, g] of propGetters) { + if (k === "component" || k === "componentFromGlobalScope" || k === "from") { + continue; + } + const v = g(vals); + if (v === void 0) unresolvedHole = true; + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (unresolvedHole && ctx?.__htmlStreamingNow) { + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error: null + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + if (kids.length) { + props.children = renderDeckKids(kids, kidKeys, vals, ctx); + } + return wrapper ? h("div", wrapper, h(C, props)) : h(C, props); + }; + } + function contentKey(el) { + const clone = el.cloneNode(true); + for (const d of clone.querySelectorAll("*")) { + while (d.attributes.length) d.removeAttribute(d.attributes[0].name); + } + const s = clone.innerHTML; + let h2 = 5381; + for (let i = 0; i < s.length; i++) h2 = (h2 << 5) + h2 + s.charCodeAt(i) | 0; + return s.length + "." + (h2 >>> 0).toString(36); + } + var NEVER_CONTENT_KEYED = new Set( + "script style textarea option title select canvas iframe video audio".split( + " " + ) + ); + var NOT_INLINE_SELECTOR = ":not(" + [...INLINE_TEXT_TAGS].join(",") + ")"; + function walkElement(el, host) { + const realTag = RAW_UNWRAP[el.localName] || el.localName; + const tplId = el.getAttribute("data-dc-tpl"); + const inlineOnly = el.childNodes.length > 0 && !NEVER_CONTENT_KEYED.has(realTag) && el.querySelector(NOT_INLINE_SELECTOR) === null; + const keySuffix = inlineOnly ? "|" + contentKey(el) : ""; + const { propGetters, pseudoClasses } = collectProps(el, "dom", host); + const deckKeyed = isDeckMountTag(el) ? walkDeckChildren(el, host) : null; + const kids = deckKeyed ? deckKeyed.kids : walkChildren(el, host); + const kidKeys = deckKeyed?.keys ?? null; + return (vals, ctx, key) => { + const props = { + key: key + keySuffix, + "data-dc-tpl": tplId + }; + for (const [k, g] of propGetters) { + let v = g(vals); + if (k === "style" && typeof v === "string") v = cssToObj(v); + if ((k === "value" || k === "checked") && v === void 0) { + v = k === "checked" ? false : ""; + } + props[k] = v; + } + if (pseudoClasses.length) { + props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" "); + } + return h(realTag, props, ...renderDeckKids(kids, kidKeys, vals, ctx)); + }; + } + + // src/logic.ts + var StreamableLogic = class { + constructor(props) { + __publicField(this, "props"); + __publicField(this, "state", {}); + /** Back-pointer to the wrapper component, installed after construction. */ + __publicField(this, "__host"); + this.props = props || {}; + } + setState(update, cb) { + this.__host && this.__host.__setLogicState(update, cb); + } + forceUpdate() { + this.__host && this.__host.forceUpdate(); + } + componentDidMount() { + } + componentDidUpdate(_prevProps) { + } + componentWillUnmount() { + } + /** The flat object the template renders against (merged over props). */ + renderVals() { + return {}; + } + }; + function evalDcLogic(src) { + //! nosemgrep: eval-and-function-constructor + const fn = new Function( + "DCLogic", + "StreamableLogic", + "React", + src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;' + ); + return fn(StreamableLogic, StreamableLogic, getReact()); + } + + // src/component.ts + function shallowEqual(a, b) { + if (!b) return false; + const ak = Object.keys(a).filter((k) => k !== "children"); + const bk = Object.keys(b).filter((k) => k !== "children"); + if (ak.length !== bk.length) return false; + for (const k of ak) if (a[k] !== b[k]) return false; + return true; + } + function Placeholder({ + name, + hintSize, + streaming, + error + }) { + const [w, hgt] = (hintSize || "100%,60px").split(","); + return h( + "div", + { + className: "sc-placeholder" + (streaming ? " sc-streaming" : ""), + style: { width: w.trim(), height: hgt && hgt.trim() }, + title: name + }, + error ? h( + "div", + { className: "sc-placeholder-error" }, + (name ? name + ": " : "") + error + ) : null + ); + } + function hintToMin(hint) { + if (!hint) return void 0; + const [w, hgt] = hint.split(","); + return { minWidth: w.trim(), minHeight: hgt && hgt.trim() }; + } + function createComponentFactory(registry, ensureFetched) { + const React = getReact(); + const AncestorContext = React.createContext([]); + class StreamableComponent extends React.Component { + constructor(props) { + super(props); + __publicField(this, "__name"); + __publicField(this, "__sub"); + __publicField(this, "__needsDidMount", false); + /** Snapshot of the registry's streaming flags taken at render time — + * builders read it off the RenderCtx (this) to pick placeholder vs + * render-nothing for unresolved values. */ + __publicField(this, "__streamingNow", false); + __publicField(this, "__htmlStreamingNow", false); + /** When a construct throws, remember the (class, registry.ver, props) + * triple so render-time reconcile doesn't re-attempt it on every parent + * re-render. A registry bump (new class, template, external module + * resolving via bumpAll) changes `ver` and breaks the memo so an + * env-dependent constructor can self-heal. */ + __publicField(this, "__failedLogic", null); + __publicField(this, "__failedUserProps", null); + __publicField(this, "__failedVer", -1); + /** Per-instance constructor error — kept here (not on the registry entry) + * so one instance's successful construct can't hide a sibling's failure, + * and a construct can never wipe an eval error `updateJs` recorded on + * `r.logicError`. */ + __publicField(this, "__ctorError", null); + __publicField(this, "logic"); + this.__name = props.__name; + this.state = { __v: 0, __err: null }; + this.__sub = () => { + if (this.state.__err) this.setState({ __err: null }); + this.forceUpdate(); + }; + this.__makeLogic(registry.get(this.__name).Logic, null); + ensureFetched(this.__name); + } + /** Error-boundary hook: a render crash anywhere in this DC's subtree + * (its own template, an x-import'd component, a child DC without its + * own deeper boundary) lands here instead of unmounting the page. */ + static getDerivedStateFromError(e) { + return { __err: e instanceof Error && e.message ? e.message : String(e) }; + } + componentDidCatch(e, info) { + console.error( + "[dc-runtime] render error in <" + this.__name + ">:", + e, + info?.componentStack || "" + ); + } + /** Instantiate the logic class (or the no-op base) and adopt `prevState` + * over its initial state — used both at mount and on hot-swap. */ + __makeLogic(Logic, prevState) { + const L = Logic || StreamableLogic; + try { + this.logic = new L(this.__userProps()); + this.__failedLogic = null; + this.__failedUserProps = null; + this.__ctorError = null; + } catch (e) { + console.error(e); + this.__failedLogic = Logic; + this.__failedUserProps = this.__userProps(); + this.__failedVer = registry.get(this.__name).ver; + this.__ctorError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e)); + this.logic = new StreamableLogic( + this.__userProps() + ); + } + this.logic.__host = this; + if (prevState) + this.logic.state = { ...this.logic.state || {}, ...prevState }; + } + /** The props the author's logic + template see — internal __-prefixed + * wiring stripped. */ + __userProps() { + const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props; + return rest; + } + __setLogicState(update, cb) { + const prev = this.logic.state; + const patch = typeof update === "function" ? update(prev) : update; + this.logic.state = { ...prev, ...patch }; + this.setState((s) => ({ __v: s.__v + 1 }), cb); + } + /** Swap the logic instance when the registry's Logic class changed + * (streaming completion, hot reload). State carries over; didMount + * re-fires after the swap commits so refs exist. */ + __reconcileLogic() { + const r = registry.get(this.__name); + const Next = r.Logic; + const Cur = this.logic.constructor; + if (Next === Cur || !Next && Cur === StreamableLogic || Next === this.__failedLogic && r.ver === this.__failedVer && shallowEqual(this.__userProps(), this.__failedUserProps)) { + return; + } + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + this.__makeLogic(Next, this.logic.state); + this.__needsDidMount = true; + } + componentDidMount() { + registry.get(this.__name).subs.add(this.__sub); + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } + componentDidUpdate(prevProps) { + this.logic.props = this.__userProps(); + if (this.__needsDidMount) { + if (this.state.__err || !registry.get(this.__name).tpl) return; + this.__needsDidMount = false; + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } else { + try { + this.logic.componentDidUpdate(prevProps); + } catch (e) { + console.error(e); + } + } + } + componentWillUnmount() { + registry.get(this.__name).subs.delete(this.__sub); + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + } + render() { + const r = registry.get(this.__name); + const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : ""); + const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0; + const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0; + const hostBase = { + className: cls, + style: hostStyle, + "data-sc-name": this.__name, + "data-dc-tpl": this.props.__tplId + }; + const chain = Array.isArray(this.context) ? this.context : []; + if (chain.includes(this.__name)) { + const cycle = [ + ...chain.slice(chain.indexOf(this.__name)), + this.__name + ].join(" \u2192 "); + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: "circular import: " + cycle + }) + ); + } + if (this.state.__err) { + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + this.__name + ": " + this.state.__err + ), + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: this.state.__err + }) + ); + } + this.__reconcileLogic(); + if (!r.tpl) { + return h( + "div", + hostBase, + h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize }) + ); + } + const userProps = this.__userProps(); + this.logic.props = userProps; + let vals = userProps; + let renderErr = r.logicError || this.__ctorError; + try { + vals = { ...userProps, ...this.logic.renderVals() || {} }; + } catch (e) { + console.error(e); + renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e)); + } + this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming); + this.__htmlStreamingNow = !!r.htmlStreaming; + return h( + "div", + { ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") }, + renderErr && h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + renderErr + ), + h( + AncestorContext.Provider, + { value: [...chain, this.__name] }, + r.tpl(vals, this) + ) + ); + } + } + __publicField(StreamableComponent, "contextType", AncestorContext); + const named = /* @__PURE__ */ new Map(); + function getDC(name) { + const hit = named.get(name); + if (hit) return hit; + function Dispatcher(p) { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + registry.get(name).subs.add(sub); + return () => { + registry.get(name).subs.delete(sub); + }; + }, []); + ensureFetched(name); + return h(StreamableComponent, { ...p, __name: name }); + } + Dispatcher.displayName = name; + named.set(name, Dispatcher); + return Dispatcher; + } + return { + getDC, + StreamableComponent + }; + } + + // src/bundled.ts + function bundledBlob(url) { + const blobs = window.__resourceBlobs; + const b = blobs ? blobs[url.split("#")[0]] : void 0; + return b instanceof Blob ? b : null; + } + + // src/cdn.ts + var REACT_URL = "https://unpkg.com/react@18.3.1/umd/react.production.min.js"; + var REACT_SRI = "sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z"; + var REACT_DOM_URL = "https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"; + var REACT_DOM_SRI = "sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1"; + var BABEL_URL = "https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"; + var BABEL_SRI = "sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y"; + function cdnScriptFor(url, sri) { + const res = window.__resources; + const v = res ? res[url] : void 0; + return typeof v === "string" && v ? { src: v } : { src: url, integrity: sri }; + } + + // src/external.ts + var isCustomElementName = (n) => !n.includes(".") && n.includes("-"); + function isRenderableType(g) { + if (typeof g === "function") return !isElementClass(g); + return typeof g === "object" && g !== null && typeof g.$$typeof === "symbol"; + } + function resolveDottedPath(root, name) { + let cur = root; + for (const seg of name.split(".")) { + if (cur == null) return void 0; + cur = cur[seg]; + } + return cur; + } + var GLOBAL_POLL_INTERVAL_MS = 50; + var GLOBAL_POLL_TIMEOUT_MS = 3e4; + function createExternalModules(onResolved) { + const cache = /* @__PURE__ */ new Map(); + let babelLoading = null; + const reportedMissing = /* @__PURE__ */ new Map(); + const polling = /* @__PURE__ */ new Set(); + function ensureBabel() { + if (window.Babel) return Promise.resolve(); + if (babelLoading) return babelLoading; + const babel = cdnScriptFor(BABEL_URL, BABEL_SRI); + babelLoading = new Promise((res, rej) => { + const s = document.createElement("script"); + s.src = babel.src; + if (babel.integrity) { + s.integrity = babel.integrity; + s.crossOrigin = "anonymous"; + } + s.onload = () => res(); + s.onerror = rej; + document.head.appendChild(s); + }); + return babelLoading; + } + const pending = /* @__PURE__ */ new Map(); + function load(kind, url, after) { + const existing = pending.get(url); + if (existing) return existing; + cache.set(url, null); + console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")"); + const ready = Promise.all([ + kind === "jsx" ? ensureBabel() : Promise.resolve(), + after ?? Promise.resolve() + ]); + const p = ready.then(() => { + const pre = bundledBlob(url); + if (pre) return pre.text(); + return fetch(url).then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.text(); + }); + }).then((src) => { + const code = kind === "jsx" ? window.Babel.transform(src, { + filename: url, + presets: ["react", "typescript"] + }).code : src; + const module = { exports: {} }; + const before = new Set(Object.keys(window)); + //! nosemgrep: eval-and-function-constructor + new Function("React", "module", "exports", "require", code)( + getReact(), + module, + module.exports, + () => ({}) + ); + const globals = {}; + for (const k of Object.keys(window)) { + if (!before.has(k) && typeof window[k] === "function") { + globals[k] = window[k]; + } + } + cache.set(url, { mod: module.exports, globals }); + console.info( + "[dc-runtime] x-import: loaded", + url, + "\u2014 exports:", + Object.keys(module.exports), + "window globals:", + Object.keys(globals) + ); + onResolved(); + }).catch((e) => { + cache.set(url, { + mod: {}, + globals: {}, + error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e)) + }); + console.error( + "[dc-runtime] x-import: FAILED to load", + url, + "(" + kind + ")", + e + ); + onResolved(); + }); + pending.set(url, p); + return p; + } + function resolve2(url, name) { + const entry = cache.get(url); + if (!entry) return null; + const { mod, globals } = entry; + const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default; + if (typeof C === "function") return C; + const key = url + "\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set( + key, + entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")" + ); + console.error( + "[dc-runtime] x-import: module", + url, + "loaded but has no component named", + JSON.stringify(name), + "\u2014 available exports:", + Object.keys(mod), + "window globals:", + Object.keys(globals), + ". The module must `module.exports = {" + name + "}` or set `window." + name + "`." + ); + } + return null; + } + function waitForGlobal(name) { + if (polling.has(name)) return; + polling.add(name); + const started = Date.now(); + const isCE = isCustomElementName(name); + const tick = () => { + const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name)); + if (found) { + polling.delete(name); + onResolved(); + return; + } + if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) { + console.warn( + "[dc-runtime] x-import: global", + JSON.stringify(name), + "never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms" + ); + return; + } + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + }; + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + } + function resolveGlobal(url, name) { + const isCE = isCustomElementName(name); + if (!url) { + if (isCE) { + if (customElements.get(name)) return name; + waitForGlobal(name); + return null; + } + const g2 = resolveDottedPath(window, name); + if (isRenderableType(g2)) return g2; + waitForGlobal(name); + return null; + } + const entry = cache.get(url); + if (!entry) return null; + if (isCE && customElements.get(name)) return name; + const g = entry.globals[name] ?? resolveDottedPath(window, name); + if (isRenderableType(g)) return g; + if (name.includes(".")) return null; + const key = url + "\0global\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set(key, null); + if (isCE && !customElements.get(name)) { + console.warn( + "[dc-runtime] x-import:", + url, + "loaded but no custom element", + JSON.stringify(name), + "is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element." + ); + } + } + return name; + } + function getError(url, name) { + const entry = cache.get(url); + if (entry?.error) return entry.error; + return reportedMissing.get(url + "\0" + name) || null; + } + return { load, resolve: resolve2, resolveGlobal, getError }; + } + function isElementClass(g) { + try { + return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement; + } catch { + return false; + } + } + + // src/atomics.ts + var ATOMIC_CSS = ( + // layout + ".fx{display:flex}.col{display:flex;flex-direction:column}.grid{display:grid}.ac{align-items:center}.jc{justify-content:center}.jb{justify-content:space-between}.f1{flex:1}.noshrink{flex-shrink:0}.wrap{flex-wrap:wrap}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}.fs15{font-size:15px}.fs16{font-size:16px}.fs20{font-size:20px}.fs22{font-size:22px}.upper{text-transform:uppercase}.tc{text-align:center}.nowrap{white-space:nowrap}.gap8{gap:8px}.gap10{gap:10px}.gap12{gap:12px}.gap16{gap:16px}.gap24{gap:24px}.m0{margin:0}.mt8{margin-top:8px}.mt12{margin-top:12px}.mt16{margin-top:16px}.mb8{margin-bottom:8px}.mb12{margin-bottom:12px}.mb16{margin-bottom:16px}.posrel{position:relative}.posabs{position:absolute}.round{border-radius:50%}.ohide{overflow:hidden}.bbox{box-sizing:border-box}.pointer{cursor:pointer}.w100{width:100%}.b0{border:none}" + ); + + // src/helmet.ts + var DESIGN_DOC_MODE_RE = /]*\bname\s*=\s*["']design_doc_mode["'][^>]*\b(?:content|value)\s*=\s*["'](\w+)["']/i; + var CANVAS_BG_LIGHT = "#f0eee6"; + var CANVAS_BG_DARK = "#2e2c26"; + function createHelmetManager(doc, isStreaming) { + const mounted = /* @__PURE__ */ new Set(); + const live = /* @__PURE__ */ new Map(); + let designDocMode = null; + let canvasStyleEl = null; + let appTheme = "light"; + try { + const ds = doc.documentElement.dataset.theme; + appTheme = ds === "dark" || ds === "light" ? ds : new URLSearchParams(doc.defaultView?.location.search ?? "").get( + "theme" + ) === "dark" ? "dark" : "light"; + } catch { + } + function applyCanvasBg() { + if (!canvasStyleEl) return; + const bg = appTheme === "dark" ? CANVAS_BG_DARK : CANVAS_BG_LIGHT; + canvasStyleEl.textContent = `html,body{background:${bg}}#dc-root>.sc-host{position:relative}`; + } + function postDesignMode(mode) { + if (window.parent === window) return; + try { + window.parent.postMessage({ type: "__dc_design_mode", mode }, "*"); + } catch { + } + } + function setDesignDocMode(mode) { + if (mode === designDocMode) return; + designDocMode = mode; + postDesignMode(mode); + if (mode === "canvas") { + doc.documentElement.setAttribute("data-dc-canvas", ""); + canvasStyleEl = doc.createElement("style"); + canvasStyleEl.setAttribute("data-dc-canvas", ""); + applyCanvasBg(); + doc.head.appendChild(canvasStyleEl); + } else { + doc.documentElement.removeAttribute("data-dc-canvas"); + canvasStyleEl?.remove(); + canvasStyleEl = null; + } + } + window.addEventListener("message", (e) => { + const type = e.data && e.data.type; + if (type === "__dc_theme") { + const t = e.data.theme; + if (t === "light" || t === "dark") { + appTheme = t; + applyCanvasBg(); + } + return; + } + if (!designDocMode || type !== "__dc_probe") return; + postDesignMode(designDocMode); + }); + function compile(node) { + const raw = [...node.children]; + const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null; + if (node.hasAttribute("data-dc-atomics") && !mounted.has("__dc-atomics")) { + mounted.add("__dc-atomics"); + const el = doc.createElement("style"); + el.id = "__dc-atomics"; + el.textContent = ATOMIC_CSS; + doc.head.appendChild(el); + } + return (_vals, ctx) => { + const name = ctx && ctx.__name || ""; + const streaming = !!(name && isStreaming(name)); + for (let i = 0; i < raw.length; i++) { + const child = raw[i]; + const tag = child.tagName; + const mayBePartial = streaming && !helmetClosed && i === raw.length - 1; + if (tag === "SCRIPT") { + if (mayBePartial) continue; + const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || ""); + if (mounted.has(key)) continue; + mounted.add(key); + const el = doc.createElement("script"); + for (const { name: an, value } of [...child.attributes]) + el.setAttribute(an, value); + if (child.textContent) el.textContent = child.textContent; + doc.head.appendChild(el); + } else if (tag === "LINK" || tag === "META") { + if (mayBePartial) continue; + const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML); + if (mounted.has(key)) continue; + mounted.add(key); + if (tag === "LINK") { + const rel = (child.getAttribute("rel") || "").toLowerCase().split(/\s+/); + const href = (child.getAttribute("href") || "").trim(); + const res = window.__resources; + const pre = res && rel.includes("stylesheet") && !rel.includes("alternate") ? res[href] : void 0; + const blob = typeof pre === "string" && pre ? bundledBlob(pre) : null; + if (blob) { + const el = doc.createElement("style"); + if (child.hasAttribute("disabled")) { + el.setAttribute("media", "not all"); + } else if (child.getAttribute("media")) { + el.setAttribute("media", child.getAttribute("media")); + } + if (child.getAttribute("title")) + el.setAttribute("title", child.getAttribute("title")); + void blob.text().then((css) => { + el.textContent = css; + }); + doc.head.appendChild(el); + continue; + } + } + doc.head.appendChild(child.cloneNode(true)); + } else { + const key = name + "|" + i; + let el = live.get(key); + if (!el || el.tagName !== tag) { + if (el) el.remove(); + el = doc.createElement(tag.toLowerCase()); + live.set(key, el); + doc.head.appendChild(el); + } + for (const { name: an, value } of [...child.attributes]) { + if (el.getAttribute(an) !== value) el.setAttribute(an, value); + } + if (el.textContent !== child.textContent) + el.textContent = child.textContent; + } + } + return null; + }; + } + return { compile, setDesignDocMode }; + } + + // src/pseudo.ts + function scanUnquotedUrl(css, i) { + if (css[i] !== "u" && css[i] !== "U" || css.slice(i, i + 4).toLowerCase() !== "url(" || /[a-z0-9_-]/i.test(css[i - 1] ?? "")) { + return -1; + } + let j = i + 4; + while (j < css.length && /\s/.test(css[j])) j++; + if (css[j] === '"' || css[j] === "'") return -1; + while (j < css.length && css[j] !== ")") { + if (css[j] === "\\") j++; + j++; + } + return j < css.length ? j + 1 : css.length; + } + function stripComments(css) { + let out = ""; + let quote = ""; + for (let i = 0; i < css.length; i++) { + const c = css[i]; + if (quote) { + if (c === "\\") { + out += c + (css[i + 1] ?? ""); + i++; + continue; + } + if (c === quote) quote = ""; + out += c; + } else if (c === "'" || c === '"') { + quote = c; + out += c; + } else if (c === "/" && css[i + 1] === "*") { + const end = css.indexOf("*/", i + 2); + i = end === -1 ? css.length : end + 1; + out += " "; + } else { + const end = scanUnquotedUrl(css, i); + if (end === -1) out += c; + else { + out += css.slice(i, end); + i = end - 1; + } + } + } + return out; + } + function importantify(css) { + css = stripComments(css); + const decls = []; + let start = 0; + let depth = 0; + let quote = ""; + for (let i = 0; i < css.length; i++) { + const c = css[i]; + if (quote) { + if (c === "\\") i++; + else if (c === quote) quote = ""; + } else if (c === "'" || c === '"') quote = c; + else if (c === "(") depth++; + else if (c === ")") depth = Math.max(0, depth - 1); + else if (c === ";" && depth === 0) { + decls.push(css.slice(start, i)); + start = i + 1; + } else { + const end = scanUnquotedUrl(css, i); + if (end !== -1) i = end - 1; + } + } + decls.push(css.slice(start)); + return decls.map((d) => d.trim()).filter(Boolean).map((d) => /!\s*important$/i.test(d) ? d : d + " !important").join(";"); + } + function createPseudoSheet(doc) { + let el = null; + const cache = /* @__PURE__ */ new Map(); + let n = 0; + return (pseudo, css) => { + const k = pseudo + "|" + css; + const hit = cache.get(k); + if (hit) return hit; + if (!el) { + el = doc.createElement("style"); + doc.head.appendChild(el); + } + const cls = "scp" + (n++).toString(36); + const isPseudoElement = pseudo === "before" || pseudo === "after"; + const sel = isPseudoElement ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo; + el.sheet.insertRule( + sel + "{" + (isPseudoElement ? css : importantify(css)) + "}", + el.sheet.cssRules.length + ); + cache.set(k, cls); + return cls; + }; + } + + // src/registry.ts + function createRegistry() { + const entries = /* @__PURE__ */ Object.create(null); + function get(name) { + return entries[name] || (entries[name] = { + html: "", + tpl: null, + Logic: null, + jsStreaming: false, + htmlStreaming: false, + ver: 0, + subs: /* @__PURE__ */ new Set(), + fetched: false + }); + } + function bump(name) { + const r = get(name); + r.ver++; + for (const fn of r.subs) fn(); + } + return { + entries, + get, + bump, + bumpAll() { + for (const n in entries) bump(n); + } + }; + } + + // src/runtime.ts + var COMPONENT_DIR = "."; + function createRuntime(doc = document) { + const registry = createRegistry(); + const pseudoClass = createPseudoSheet(doc); + const helmet = createHelmetManager( + doc, + (name) => registry.get(name).htmlStreaming + ); + const external = createExternalModules(() => registry.bumpAll()); + const factory = createComponentFactory(registry, ensureFetched); + const host = { + component: (name) => factory.getDC(name), + placeholder: (props) => h(Placeholder, props), + helmet: (node) => helmet.compile(node), + loadExternal: (kind, url, after) => external.load(kind, url, after), + resolveExternal: (url, name) => external.resolve(url, name), + resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name), + resolveExternalError: (url, name) => external.getError(url, name), + pseudoClass + }; + function ensureFetched(name) { + const r = registry.get(name); + if (r.fetched) return; + r.fetched = true; + const url = COMPONENT_DIR + "/" + encodeURIComponent(name) + ".dc.html"; + const res = window.__resources; + const pre = res ? res[url] : void 0; + const target = typeof pre === "string" && pre ? pre : url; + const blob = bundledBlob(target); + (blob ? blob.text() : fetch(target).then((res2) => { + if (!res2.ok) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '" failed:', + url, + "returned", + res2.status, + "\u2014 the reference renders as an empty placeholder." + ); + return ""; + } + return res2.text(); + })).then((t) => { + if (!t) return; + const parsed = parseDcText(t); + if (!parsed) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '":', + url, + "has no block \u2014 not a Design Component." + ); + return; + } + if (parsed.props) r.propsMeta = parsed.props; + if (parsed.preview) r.preview = parsed.preview; + if (parsed.template && !r.html) updateHtml(name, parsed.template); + if (parsed.js && !r.Logic) updateJs(name, parsed.js); + }).catch( + (e) => console.error( + '[dc-runtime] sibling fetch for "' + name + '" threw:', + url, + e + ) + ); + } + let rootName = null; + function updateHtml(name, html) { + const r = registry.get(name); + r.html = html; + if (name === rootName) { + const mode = DESIGN_DOC_MODE_RE.exec(html)?.[1] ?? null; + if (mode || !r.htmlStreaming) helmet.setDesignDocMode(mode); + } + try { + r.tpl = compileTemplate(html, host); + } catch (e) { + console.error("[dc-runtime] template compile FAILED for", name, e); + } + registry.bump(name); + } + function updateJs(name, src) { + const r = registry.get(name); + const seq = r.jsSeq = (r.jsSeq || 0) + 1; + try { + const Cls = evalDcLogic(src); + if (r.jsSeq !== seq) return; + if (typeof Cls !== "function") { + r.logicError = name + ".dc.html: + + + + + + + + +
+
+

Chatballs · Очередь, ожидание и уведомления

+ дополнение к design baseline · пять мест +
+

Дорисованы места, которых не было в макете: уведомления браузера в профиле, сроки ожидания в настройках, разделение двух видов ожидания в списке диалогов, время ожидания в строке и карточке, присутствие сотрудника в выборе ответственного. Служебные слова («эскалация», «порог», «SLA») в текстах не используются.

+ +
+ + +
+
+ Q1 + Профиль · карточка уведомлений + новый заголовок и подводка; строка браузера выше ботов +
+ +
+

Уведомления о рабочих событиях

+

Выберите, где вас звать. Браузер — на этом устройстве, пока приложение открыто или свёрнуто. Мессенджер — где угодно, в том числе при закрытом браузере; мессенджер один: привязка нового заменяет текущий.

+ + +
+
+ +
Этот браузерChrome · macOS — видно, когда вкладка свёрнута
+ Включены + +
+
+ +
+

«Отключить» выключает показ уведомлений в этом браузере. Само разрешение остаётся выданным — снять его можно только в настройках браузера.

+
+ + +
+
+ +
Telegram@nord_notify_bot · сервисный бот уведомлений
+ Подключено + +
+
+ +
+
+ + +
+
+ +
MAXПривязка заменит Telegram
+ Привязать бота +
+
+

Нажмите «Привязать бота» и в открывшемся чате — «Начать»: привязка подтвердится сама.

+
+
+ + +
+
+ Q1b + Строка браузера · три состояния +
+ +
+ +
+
1 · РАЗРЕШЕНИЕ НЕ ЗАПРАШИВАЛИ
+
+ +
Этот браузерНе включены. Браузер спросит разрешение по нажатию.
+ +
+
+ + +
+
2 · РАЗРЕШЕНИЕ ВЫДАНО, ВКЛЮЧЕНО
+
+ +
Этот браузерChrome · macOS — видно, когда вкладка свёрнута
+ Включены + +
+
+ + +
+
3 · БРАУЗЕР ОТКАЗАЛ
+
+ +
Этот браузерChrome · macOS
+ Браузер запретил +
+
+ +
+ Снять запрет можно только в браузере +

Нажмите значок замка слева от адреса → «Уведомления» → «Разрешать», затем обновите страницу. Сайт снять запрет не может.

+ +
+
+
+
+
+ + +
+
+ Q2 + Настройки · новый раздел «Когда звать на помощь» + отдельный пункт субменю сразу после «Групп» +
+ +
+ + +
+
+

Когда звать на помощь

+

Если клиент ждёт человека, мы напоминаем о диалоге — сначала группе, потом всем, потом руководителю. Сроки считаются с момента, когда диалог встал в очередь. Действуют на всю организацию.

+ +
+ +
+
+ {{ d.title }} + {{ d.hint }} +
+
+ {{ d.value }} + мин +
+
+
+
+ Сохранено 12 сентября, 14:20 · Елена Кузнецова +
+ + +
+
+
+ +
+ +

Напоминания приходят туда, где сотрудник их получит: в браузер и в привязанный мессенджер. Если у группы не настроен ни один способ, останется только счётчик в списке диалогов.

+
+
+
+
+
+ + +
+
+ Q3 + Чат · два вида ожидания в списке + вариант А — две вкладки (рекомендуем) · вариант Б — одна вкладка с метками +
+ +
+ +
+
{{ v.title }}{{ v.note }}
+
+
+
Все диалоги{{ v.total }}
+
+
+ + + +
+
+ + + +
{{ v.footer }}
+
+
+
+
+
+
+ + +
+
+ Q4 + Карточка диалога · блок «Ожидание» и системные события +
+ +
+ +
+

Диалог

фрагмент контекст-панели
+ +
+
Ждёт человека 7 минут
+
+ АК + Назначен на Анну Ким, не взят +
+

Если не возьмёт — через 3 минуты диалог вернётся в общую очередь.

+
+ + + + + + +
В очереди с14:12
+
+ + +
+
Системные события в истории диалога — тот же вид, что у остальных; возврат в очередь окрашен как предупреждение.
+ +
{{ m.text }} · {{ m.time }}
+
+
+
+
+ + +
+
+ Q5 + Ответственный · кто сейчас на месте + подсказка, а не запрет: выбрать отсутствующего можно +
+ +
+ + + +
+
Поиск сотрудника
+ + +
{{ a.head }}
+
+ + + +
+
«На месте» — приложение открыто. Признак приблизительный: назначить можно любого.
+
+
+
+ +
+
+
+ + + diff --git a/tests/e2e/profile.spec.ts b/tests/e2e/profile.spec.ts new file mode 100644 index 0000000..5d71d37 --- /dev/null +++ b/tests/e2e/profile.spec.ts @@ -0,0 +1,193 @@ +import { expect, test, type Page } from "@playwright/test"; + +// Страница профиля: карточка уведомлений со строкой «Браузер» и устойчивость +// страницы к неполному ответу соседней карточки. +// +// Разрешение браузера в тестах подменяется: выдать его сайт не может, а +// состояние иначе зависело бы от того, что headless-Chromium ответит сегодня. + +const ORGANIZATION_PUBLIC_ID = "123e4567-e89b-12d3-a456-426614174000"; + +const OWNER = { + id: 1, + email: "owner@example.com", + fullName: "Елена Кузнецова", + avatarUrl: null, + mustChangePassword: false, + totpEnabled: false, + totpLastUsedAt: null, + deliveryMode: "SELF_HOSTED", + isInstanceAdmin: true, + language: "ru", + uiLanguage: "", + uiTheme: "LIGHT", + uiAccent: "#1677ff", + memberships: [ + { + id: 1, + organizationPublicId: ORGANIZATION_PUBLIC_ID, + organization: "atelier-nord", + organizationName: "Ателье Норд", + organizationLogoUrl: null, + role: "OWNER", + positionTitle: "Владелец", + totpRequired: false, + capabilities: ["company.view", "conversations.view", "employees.manage_privileged", "settings.view"], + groups: [], + joinedAt: "2026-02-02T10:00:00Z", + }, + ], +}; + +const TYPES = [ + { code: "DIALOG_WAITING", label: "Диалог ждёт оператора" }, + { code: "DIALOG_NEW_MESSAGE", label: "Новое сообщение в диалоге" }, +]; + +// Сценарий дважды загружает SPA (вход формой, затем адрес профиля) — штатных +// тридцати секунд на это впритык. +test.describe.configure({ timeout: 60_000 }); + +test.beforeEach(({}, testInfo) => { + test.skip(testInfo.project.name !== "internal-ui", "internal-ui only"); +}); + +/** Разрешение браузера задаём сами: выдать его сайт не может, а тест обязан + * быть воспроизводимым. */ +async function pretendPermission(page: Page, value: "default" | "granted" | "denied") { + await page.addInitScript((permission) => { + class FakeNotification { + static permission = permission; + static requestPermission = async () => permission; + onclick: (() => void) | null = null; + close() {} + } + Object.defineProperty(window, "Notification", { value: FakeNotification, configurable: true }); + }, value); +} + +async function openProfile( + page: Page, + preference: { enabled: boolean; types: string[] }, + sessions: object = { items: [] }, +) { + // Порядок важен: в Playwright выигрывает маршрут, зарегистрированный + // последним, поэтому общие заглушки идут первыми, частные — за ними. + await page.route("**/api/v1/**", (route) => route.fulfill({ json: {} })); + await page.route("**/api/v1/organizations/*/conversations/**", (route) => { + const path = new URL(route.request().url()).pathname; + if (path.endsWith("/conversations/counters/")) { + return route.fulfill({ + json: { all: 0, waiting: 0, mine: 0, ungrouped: 0, groups: [], agents: [], assignees: [] }, + }); + } + if (path.endsWith("/conversations/directory/")) return route.fulfill({ json: { groups: [], employees: [] } }); + if (path.endsWith("/conversations/stats/")) return route.fulfill({ json: { waiting: 0 } }); + return route.fulfill({ json: { items: [] } }); + }); + await page.route("**/api/v1/organizations/*/company/**", (route) => { + const path = new URL(route.request().url()).pathname; + if (path.endsWith("/company/onboarding/")) { + return route.fulfill({ + json: { steps: {}, dismissedAt: "2026-01-01T00:00:00Z", completedAt: "2026-01-01T00:00:00Z" }, + }); + } + return route.fulfill({ json: { items: [] } }); + }); + await page.route("**/api/v1/organizations/*/notifications/**", (route) => + route.fulfill({ json: { items: [], unreadCount: 0 } }), + ); + await page.route("**/api/v1/organizations/*/notifications/messenger-bindings/", (route) => + route.fulfill({ + json: { + items: [ + { + integrationId: 1, + provider: "TELEGRAM", + name: "notify", + botUsername: "chatballs_notify_bot", + bound: true, + pushTypes: ["DIALOG_WAITING"], + }, + ], + availableTypes: TYPES, + }, + }), + ); + await page.route("**/api/v1/organizations/*/notifications/preferences/BROWSER/", (route) => + route.fulfill({ json: { transport: "BROWSER", ...preference, availableTypes: TYPES } }), + ); + // Соседние карточки профиля тоже ходят за данными: без их ответов страница + // падает целиком и проверять было бы нечего. + await page.route("**/api/v1/auth/profile/sessions/", (route) => route.fulfill({ json: sessions })); + await page.route("**/api/v1/setup/", (route) => route.fulfill({ json: { needsSetup: false } })); + + // Вход формой: после него сессия обязана быть живой, иначе переход на адрес + // профиля выкинет обратно на логин. + let signedIn = false; + await page.route("**/api/v1/auth/session/", (route) => + route.fulfill({ + json: signedIn ? { authenticated: true, user: OWNER } : { authenticated: false, language: "ru" }, + }), + ); + await page.route("**/api/v1/auth/login/", (route) => { + signedIn = true; + return route.fulfill({ json: { authenticated: true, user: OWNER } }); + }); + + await page.goto("/"); + await page.getByPlaceholder("you@domain.ru").fill("owner@example.com"); + await page.getByPlaceholder("Пароль").fill("Password-123"); + await page.getByRole("button", { name: "Войти" }).click(); + await page.goto(`/organizations/${ORGANIZATION_PUBLIC_ID}/profile`); + + const card = page.locator(".profile-card", { hasText: "Уведомления о рабочих событиях" }); + return { card, browserRow: card.locator(".profile-notifications-block").first() }; +} + +test("разрешение не запрошено — строку браузера можно включить", async ({ page }) => { + await pretendPermission(page, "default"); + const { card, browserRow } = await openProfile(page, { enabled: false, types: [] }); + + await expect(browserRow).toContainText("Этот браузер"); + await expect(browserRow).toContainText("Браузер спросит разрешение по нажатию"); + await expect(browserRow.getByRole("button", { name: "Включить" })).toBeVisible(); + // Галочки типов — только у включённого транспорта. + await expect(browserRow.locator("input[type=checkbox]")).toHaveCount(0); + // Строка бота осталась рядом и того же вида: шаблон у них один. + await expect(card).toContainText("Telegram"); +}); + +test("разрешение выдано и включено — видны те же галочки типов", async ({ page }) => { + await pretendPermission(page, "granted"); + const { browserRow } = await openProfile(page, { enabled: true, types: ["DIALOG_WAITING"] }); + + await expect(browserRow).toContainText("Включены"); + await expect(browserRow.getByRole("button", { name: "Отключить" })).toBeVisible(); + await expect(browserRow.locator("input[type=checkbox]")).toHaveCount(TYPES.length); + // Под включённой строкой — примечание, что «Отключить» гасит показ, а не разрешение. + await expect(browserRow).toContainText("Само разрешение остаётся выданным"); +}); + +test("браузер отказал — вместо кнопки подсказка, как это снять", async ({ page }) => { + await pretendPermission(page, "denied"); + const { browserRow } = await openProfile(page, { enabled: true, types: ["DIALOG_WAITING"] }); + + await expect(browserRow).toContainText("Браузер запретил"); + // Кнопки «Включить» нет — она ничего не сделала бы; есть «Проверить снова». + await expect(browserRow.getByRole("button", { name: "Включить" })).toHaveCount(0); + await expect(browserRow).toContainText("Снять запрет можно только в браузере"); + await expect(browserRow.getByRole("button", { name: "Проверить снова" })).toBeVisible(); +}); + +test("неполный ответ одной карточки не гасит всю страницу профиля", async ({ page }) => { + // Регрессия: список сессий приходил без items, карточка падала на + // items.length, и вместе с ней исчезала вся страница — пароль, двухфакторка + // и уведомления в том числе. + await pretendPermission(page, "default"); + const { card } = await openProfile(page, { enabled: false, types: [] }, {}); + + await expect(card).toBeVisible(); + await expect(page.locator(".profile-card")).toHaveCount(7); + await expect(page.locator(".sessions-card")).toContainText("Активные сессии"); +});