diff --git a/apps/backend/hub_platform/conversations/clients.py b/apps/backend/hub_platform/conversations/clients.py
index 76b2976..664d114 100644
--- a/apps/backend/hub_platform/conversations/clients.py
+++ b/apps/backend/hub_platform/conversations/clients.py
@@ -21,7 +21,12 @@ from hub_platform.identity.models import AuditEvent
from hub_platform.orders.models import Order, PaymentStatus
# Короткие коды для UI (совпадают с фронтовыми справочниками).
-PROVIDER_CODE = {"MAX": "MAX", "TELEGRAM": "TG", "WEB": "WEB"}
+PROVIDER_CODE = {
+ "MAX": "MAX",
+ "TELEGRAM": "TG",
+ "WEB": "WEB",
+ "EMAIL": "EMAIL",
+}
PRODUCT_CODE = {"firepage": "FP", "foxray": "FX"}
# Понятные подписи для аудита диалогов.
@@ -30,6 +35,7 @@ AUDIT_LABELS = {
"conversations.released_to_ai": "Возврат к AI",
"conversations.returned_to_queue": "Возврат в очередь",
"conversations.closed": "Диалог закрыт",
+ "conversations.marked_spam": "Диалог помечен как спам",
}
@@ -96,6 +102,14 @@ def clients_overview(
"cid": f"CUS-{contact.id}",
"name": contact.name or "Гость",
"phone": contact.phone,
+ "email": next(
+ (
+ identity.external_user_id
+ for identity in contact.identities.all()
+ if identity.connection.provider == "EMAIL"
+ ),
+ "",
+ ),
# Первый непустой @логин среди identity каналов (остальные — в карточке).
"username": next((identity.username for identity in contact.identities.all() if identity.username), ""),
# Статус выводится из данных: есть оплаченный заказ — клиент, иначе лид.
@@ -167,7 +181,11 @@ def client_detail(
identities = [
{
"provider": identity.connection.provider,
- "value": identity.display_name or identity.external_user_id,
+ "value": (
+ identity.external_user_id
+ if identity.connection.provider == "EMAIL"
+ else identity.display_name or identity.external_user_id
+ ),
"username": identity.username,
"createdAt": identity.created_at.isoformat(),
}
@@ -228,6 +246,14 @@ def client_detail(
"cid": f"CUS-{contact.id}",
"name": contact.name or "Гость",
"phone": contact.phone,
+ "email": next(
+ (
+ identity.external_user_id
+ for identity in identity_qs
+ if identity.connection.provider == "EMAIL"
+ ),
+ "",
+ ),
"channels": sorted(channels),
"products": sorted(products),
"openDialogs": open_dialogs,
diff --git a/apps/backend/hub_platform/conversations/html_sanitizer.py b/apps/backend/hub_platform/conversations/html_sanitizer.py
new file mode 100644
index 0000000..b00d6c2
--- /dev/null
+++ b/apps/backend/hub_platform/conversations/html_sanitizer.py
@@ -0,0 +1,122 @@
+from __future__ import annotations
+
+import html
+from html.parser import HTMLParser
+from urllib.parse import urlsplit
+
+
+_ALLOWED_TAGS = {
+ "a",
+ "b",
+ "blockquote",
+ "br",
+ "code",
+ "div",
+ "em",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ "hr",
+ "i",
+ "li",
+ "ol",
+ "p",
+ "pre",
+ "s",
+ "span",
+ "strong",
+ "table",
+ "tbody",
+ "td",
+ "th",
+ "thead",
+ "tr",
+ "u",
+ "ul",
+}
+_BLOCKED_TAGS = {
+ "applet",
+ "audio",
+ "button",
+ "embed",
+ "form",
+ "head",
+ "iframe",
+ "input",
+ "link",
+ "meta",
+ "object",
+ "script",
+ "select",
+ "style",
+ "svg",
+ "textarea",
+ "title",
+ "video",
+}
+_VOID_TAGS = {"br", "hr"}
+
+
+def _safe_href(value: str) -> str:
+ candidate = value.strip()
+ if not candidate:
+ return ""
+ parsed = urlsplit(candidate)
+ if parsed.scheme.lower() not in {"http", "https", "mailto"}:
+ return ""
+ return candidate
+
+
+class _Sanitizer(HTMLParser):
+ def __init__(self) -> None:
+ super().__init__(convert_charrefs=True)
+ self.parts: list[str] = []
+ self.blocked_depth = 0
+
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+ tag = tag.lower()
+ if tag in _BLOCKED_TAGS:
+ self.blocked_depth += 1
+ return
+ if self.blocked_depth or tag not in _ALLOWED_TAGS:
+ return
+ safe_attrs: list[str] = []
+ if tag == "a":
+ href = next((value or "" for key, value in attrs if key.lower() == "href"), "")
+ href = _safe_href(href)
+ if href:
+ safe_attrs.extend(
+ [
+ f'href="{html.escape(href, quote=True)}"',
+ 'target="_blank"',
+ 'rel="noopener noreferrer"',
+ ]
+ )
+ suffix = f" {' '.join(safe_attrs)}" if safe_attrs else ""
+ self.parts.append(f"<{tag}{suffix}>")
+
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+ self.handle_starttag(tag, attrs)
+
+ def handle_endtag(self, tag: str) -> None:
+ tag = tag.lower()
+ if tag in _BLOCKED_TAGS:
+ self.blocked_depth = max(0, self.blocked_depth - 1)
+ return
+ if self.blocked_depth or tag not in _ALLOWED_TAGS or tag in _VOID_TAGS:
+ return
+ self.parts.append(f"{tag}>")
+
+ def handle_data(self, data: str) -> None:
+ if not self.blocked_depth:
+ self.parts.append(html.escape(data))
+
+
+def sanitize_email_html(markup: str) -> str:
+ parser = _Sanitizer()
+ parser.feed(markup)
+ parser.close()
+ return "".join(parser.parts).strip()
diff --git a/apps/backend/hub_platform/conversations/ingest.py b/apps/backend/hub_platform/conversations/ingest.py
index a470a2f..b73d57b 100644
--- a/apps/backend/hub_platform/conversations/ingest.py
+++ b/apps/backend/hub_platform/conversations/ingest.py
@@ -76,6 +76,8 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
logger.warning("Integration %s has no channel — inbound dropped", integration.id)
return
context = TenantContext.for_resource(channel.organization)
+ agent = getattr(channel, "ai_agent", None)
+ ai_available = bool(agent and agent.is_active)
source = f"{integration.provider.lower()}:{integration.id}"
if _already_processed(context, source, inbound.external_id, inbound.text):
return
@@ -123,8 +125,8 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
connection=integration,
contact=contact,
external_chat_id=inbound.chat_id,
- control_mode=ControlMode.AI,
- expected_responder=ExpectedResponder.AI,
+ control_mode=ControlMode.AI if ai_available else ControlMode.PAUSED,
+ expected_responder=ExpectedResponder.AI if ai_available else ExpectedResponder.OPERATOR,
previous_conversation=previous,
)
# C07: only a brand-new dialog counts toward new_dialogs quota.
@@ -146,10 +148,15 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
author_type=MessageAuthor.CONTACT,
kind=MessageKind.CONTACT if is_contact_share else MessageKind.TEXT,
text=message_text,
+ content_html=inbound.content_html,
external_id=inbound.external_id,
)
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"])
if inbound.thread_meta:
# Email: Message-ID последнего входящего — для ответа в тред;
# тема диалога фиксируется по первому письму (ADR-HUB-0035).
@@ -202,7 +209,8 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
transports.send_contact_ack(integration, chat_id=conversation.external_chat_id, user_id=inbound.user_id, text=ack)
return
- # AI отвечает только когда диалог ведёт AI (ADR-HUB-0003).
+ # Операторский канал без активного агента сразу создаёт очередь и не
+ # имитирует сбой AI перед клиентом.
if conversation.control_mode != ControlMode.AI:
return
diff --git a/apps/backend/hub_platform/conversations/migrations/0008_message_content_html.py b/apps/backend/hub_platform/conversations/migrations/0008_message_content_html.py
new file mode 100644
index 0000000..eac1ca2
--- /dev/null
+++ b/apps/backend/hub_platform/conversations/migrations/0008_message_content_html.py
@@ -0,0 +1,15 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("conversations", "0007_conversation_transport_meta"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="message",
+ name="content_html",
+ field=models.TextField(blank=True),
+ ),
+ ]
diff --git a/apps/backend/hub_platform/conversations/models.py b/apps/backend/hub_platform/conversations/models.py
index 11d8365..98c1627 100644
--- a/apps/backend/hub_platform/conversations/models.py
+++ b/apps/backend/hub_platform/conversations/models.py
@@ -145,6 +145,9 @@ class Message(TenantRelationModel):
# телефона), полученный контакт. Пустая строка = текст.
kind = models.CharField(max_length=32, choices=MessageKind.choices, default=MessageKind.TEXT, blank=True)
text = models.TextField(blank=True)
+ # Санитизированный HTML входящего email. Остальные транспорты и исходящие
+ # ответы используют plain text.
+ content_html = models.TextField(blank=True)
external_id = models.CharField(max_length=128, blank=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
diff --git a/apps/backend/hub_platform/conversations/poller.py b/apps/backend/hub_platform/conversations/poller.py
index 2350651..066db41 100644
--- a/apps/backend/hub_platform/conversations/poller.py
+++ b/apps/backend/hub_platform/conversations/poller.py
@@ -17,7 +17,9 @@ def poll_all_messengers(context) -> int:
for integration in Integration.objects.filter(
organization=context.organization,
provider__in=transports.SUPPORTED_PROVIDERS,
+ is_active=True,
channel__isnull=False,
+ channel__is_active=True,
).exclude(secret="")
if integration.config.get("purpose") != "notifications"
]
diff --git a/apps/backend/hub_platform/conversations/reporting_views.py b/apps/backend/hub_platform/conversations/reporting_views.py
new file mode 100644
index 0000000..1969626
--- /dev/null
+++ b/apps/backend/hub_platform/conversations/reporting_views.py
@@ -0,0 +1,64 @@
+from rest_framework.request import Request
+from rest_framework.response import Response
+
+from hub_platform.conversations.clients import client_detail, clients_overview
+from hub_platform.conversations.command import command_center_overview
+from hub_platform.conversations.models import Contact
+from hub_platform.conversations.stats import sales_overview_stats
+from hub_platform.conversations.view_base import ConversationViewBase
+from hub_platform.identity.policy import accessible_department_ids
+
+
+class ConversationStatsView(ConversationViewBase):
+ def get(self, request: Request) -> Response:
+ period = request.query_params.get("period", "today")
+ if period not in ("today", "d7", "d30"):
+ period = "today"
+ department_ids = accessible_department_ids(
+ request.tenant_context.membership, self.required_capability
+ )
+ return Response(
+ sales_overview_stats(request.tenant_context, period, department_ids)
+ )
+
+
+class CommandOverviewView(ConversationViewBase):
+ required_capability = "company.view"
+ require_organization_scope = True
+
+ def get(self, request: Request) -> Response:
+ period = request.query_params.get("period", "today")
+ if period not in ("today", "d7", "d30"):
+ period = "today"
+ return Response(command_center_overview(request.tenant_context, period))
+
+
+class ClientsView(ConversationViewBase):
+ required_capability = "customers.view"
+
+ def get(self, request: Request) -> Response:
+ department_ids = accessible_department_ids(
+ request.tenant_context.membership, self.required_capability
+ )
+ return Response(
+ {"items": clients_overview(self._org(request).id, department_ids)}
+ )
+
+
+class ClientDetailView(ConversationViewBase):
+ required_capability = "customers.view"
+
+ def get(self, request: Request, contact_id: int) -> Response:
+ try:
+ department_ids = accessible_department_ids(
+ request.tenant_context.membership, self.required_capability
+ )
+ return Response(
+ {
+ "client": client_detail(
+ self._org(request).id, contact_id, department_ids
+ )
+ }
+ )
+ except Contact.DoesNotExist:
+ return Response({"detail": "Клиент не найден"}, status=404)
diff --git a/apps/backend/hub_platform/conversations/serializers.py b/apps/backend/hub_platform/conversations/serializers.py
index 00bde4a..038348a 100644
--- a/apps/backend/hub_platform/conversations/serializers.py
+++ b/apps/backend/hub_platform/conversations/serializers.py
@@ -1,4 +1,5 @@
from hub_platform.conversations.models import ConnectionIdentity, Conversation, Message, MessageAuthor
+from hub_platform.integrations.models import IntegrationProvider
def message_payload(message: Message) -> dict[str, object]:
@@ -8,6 +9,7 @@ def message_payload(message: Message) -> dict[str, object]:
"authorUserId": message.author_user_id,
"kind": message.kind,
"text": message.text,
+ "contentHtml": message.content_html,
"createdAt": message.created_at.isoformat(),
}
@@ -74,6 +76,15 @@ def _contact_username(conversation: Conversation) -> str:
return identity.username if identity else ""
+def _contact_email(conversation: Conversation) -> str:
+ if (
+ conversation.connection_id
+ and conversation.connection.provider == IntegrationProvider.EMAIL
+ ):
+ return conversation.external_chat_id
+ return ""
+
+
def _conversation_history(conversation: Conversation) -> list[Conversation]:
# История по тому же источнику identity: для sales — по contact, для
# support — по snapshot (ADR-HUB-0002: цепочка прошлых обращений).
@@ -90,7 +101,13 @@ def _conversation_history(conversation: Conversation) -> list[Conversation]:
)
-def conversation_payload(conversation: Conversation, *, with_messages: bool = False, last_read_id: int = 0) -> dict[str, object]:
+def conversation_payload(
+ conversation: Conversation,
+ *,
+ with_messages: bool = False,
+ last_read_id: int = 0,
+ viewer_id: int | None = None,
+) -> dict[str, object]:
last = None if with_messages else _last_message(conversation)
channel = conversation.channel
payload = {
@@ -112,6 +129,7 @@ def conversation_payload(conversation: Conversation, *, with_messages: bool = Fa
"id": conversation.contact_id,
"name": conversation.contact.name,
"phone": conversation.contact.phone,
+ "email": _contact_email(conversation),
"username": _contact_username(conversation) if with_messages else "",
}
if conversation.contact_id
@@ -122,6 +140,20 @@ def conversation_payload(conversation: Conversation, *, with_messages: bool = Fa
"controlMode": conversation.control_mode,
"expectedResponder": conversation.expected_responder,
"assignedOperatorId": conversation.assigned_operator_id,
+ "assignedOperator": (
+ {
+ "id": conversation.assigned_operator_id,
+ "name": (
+ conversation.assigned_operator.full_name
+ or conversation.assigned_operator.email
+ ),
+ }
+ if conversation.assigned_operator_id
+ else None
+ ),
+ "isAssignedToViewer": bool(
+ viewer_id and conversation.assigned_operator_id == viewer_id
+ ),
"lastActivityAt": conversation.last_activity_at.isoformat(),
"createdAt": conversation.created_at.isoformat(),
}
diff --git a/apps/backend/hub_platform/conversations/services.py b/apps/backend/hub_platform/conversations/services.py
index 60ad49a..9103e2e 100644
--- a/apps/backend/hub_platform/conversations/services.py
+++ b/apps/backend/hub_platform/conversations/services.py
@@ -23,6 +23,11 @@ class ClaimError(Exception):
pass
+def _require_open(conversation: Conversation) -> None:
+ if conversation.lifecycle != LifecycleState.OPEN:
+ raise ClaimError("Диалог закрыт")
+
+
def _operator_label(operator) -> str:
return getattr(operator, "full_name", "") or operator.email
@@ -41,6 +46,7 @@ def claim_locked_conversation(*, context: TenantContext, conversation: Conversat
operator = context.actor_user
if operator is None or conversation.organization_id != context.organization_id:
raise ClaimError("Диалог недоступен")
+ _require_open(conversation)
if (
conversation.control_mode == ControlMode.HUMAN
and conversation.assigned_operator_id
@@ -64,6 +70,10 @@ def release_to_ai(*, context: TenantContext, conversation_id: int) -> Conversati
conversation = Conversation.objects.select_for_update().get(
id=conversation_id, organization=context.organization
)
+ _require_open(conversation)
+ agent = getattr(conversation.channel, "ai_agent", None)
+ if agent is None or not agent.is_active:
+ raise ClaimError("У канала нет активного AI-агента")
conversation.control_mode = ControlMode.AI
conversation.assigned_operator = None
conversation.expected_responder = ExpectedResponder.AI
@@ -78,6 +88,7 @@ def return_to_queue(*, context: TenantContext, conversation_id: int) -> Conversa
conversation = Conversation.objects.select_for_update().get(
id=conversation_id, organization=context.organization
)
+ _require_open(conversation)
conversation.control_mode = ControlMode.PAUSED
conversation.assigned_operator = None
conversation.expected_responder = ExpectedResponder.OPERATOR
@@ -92,6 +103,7 @@ def post_operator_message(
operator = context.actor_user
if operator is None or conversation.organization_id != context.organization_id:
raise Conversation.DoesNotExist
+ _require_open(conversation)
message = Message.objects.create(
conversation=conversation, author_type=MessageAuthor.OPERATOR, author_user=operator, text=text
)
@@ -118,6 +130,7 @@ def request_contact(*, context: TenantContext, conversation: Conversation) -> Me
operator = context.actor_user
if operator is None or conversation.organization_id != context.organization_id:
raise Conversation.DoesNotExist
+ _require_open(conversation)
is_web = conversation.connection_id and conversation.connection.provider == IntegrationProvider.WEB
text = CONTACT_REQUEST_TEXT_WEB if is_web else CONTACT_REQUEST_TEXT
message = Message.objects.create(
@@ -148,7 +161,40 @@ def close_conversation(*, context: TenantContext, conversation_id: int) -> Conve
conversation = Conversation.objects.select_for_update().get(
id=conversation_id, organization=context.organization
)
+ _require_open(conversation)
conversation.lifecycle = LifecycleState.CLOSED
+ conversation.control_mode = ControlMode.PAUSED
+ conversation.assigned_operator = None
conversation.expected_responder = ExpectedResponder.NOBODY
- conversation.save(update_fields=["lifecycle", "expected_responder"])
+ conversation.save(
+ update_fields=[
+ "lifecycle",
+ "control_mode",
+ "assigned_operator",
+ "expected_responder",
+ ]
+ )
+ return conversation
+
+
+@transaction.atomic
+def mark_conversation_as_spam(
+ *, context: TenantContext, conversation_id: int
+) -> Conversation:
+ conversation = Conversation.objects.select_for_update().get(
+ id=conversation_id, organization=context.organization
+ )
+ _require_open(conversation)
+ conversation.lifecycle = LifecycleState.SPAM
+ conversation.control_mode = ControlMode.PAUSED
+ conversation.assigned_operator = None
+ conversation.expected_responder = ExpectedResponder.NOBODY
+ conversation.save(
+ update_fields=[
+ "lifecycle",
+ "control_mode",
+ "assigned_operator",
+ "expected_responder",
+ ]
+ )
return conversation
diff --git a/apps/backend/hub_platform/conversations/test_email_transport.py b/apps/backend/hub_platform/conversations/test_email_transport.py
index fab58a2..2b5ba80 100644
--- a/apps/backend/hub_platform/conversations/test_email_transport.py
+++ b/apps/backend/hub_platform/conversations/test_email_transport.py
@@ -8,6 +8,8 @@ from django.test import TestCase
from hub_platform.channels.models import Channel
from hub_platform.conversations.models import Contact, Conversation, MessageAuthor
+from hub_platform.conversations.clients import client_detail, clients_overview
+from hub_platform.conversations.serializers import conversation_payload
from hub_platform.conversations.transports import email as email_transport
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
from hub_platform.identity.models import Organization
@@ -86,6 +88,29 @@ class EmailNormalizeTests(TestCase):
self.assertIn("Добрый день!", inbound.text)
self.assertIn("«FoxRay»", inbound.text)
self.assertNotIn("
", inbound.text)
+ self.assertIn("
Добрый день!
", inbound.content_html)
+
+ def test_html_body_is_sanitized_before_ingest(self) -> None:
+ inbound, _ = email_transport._normalize(
+ _parsed(
+ _raw(
+ html=(
+ 'Здравствуйте!
'
+ ''
+ 'опасная ссылка'
+ 'сайт'
+ )
+ )
+ ),
+ own_address="support@edevs.tech",
+ fallback_id="7:100",
+ )
+ self.assertIsNotNone(inbound)
+ self.assertNotIn("onclick", inbound.content_html)
+ self.assertNotIn("script", inbound.content_html)
+ self.assertNotIn("alert", inbound.content_html)
+ self.assertNotIn("javascript:", inbound.content_html)
+ self.assertIn('href="https://example.com/path"', inbound.content_html)
def test_attachments_add_note(self) -> None:
inbound, _ = email_transport._normalize(
@@ -225,6 +250,7 @@ class EmailIngestThreadMetaTests(TestCase):
with (
mock.patch("hub_platform.conversations.ingest.run_channel_turn", return_value=mock.Mock(text="Ответ")),
mock.patch("hub_platform.conversations.ingest.transports.send_reply", return_value=True),
+ mock.patch("hub_platform.conversations.ingest.record_usage"),
):
ingest_inbound(self.integration, inbound)
@@ -236,3 +262,23 @@ class EmailIngestThreadMetaTests(TestCase):
self.assertEqual(conversation.transport_meta["last_message_id"], "")
authors = list(conversation.messages.values_list("author_type", flat=True))
self.assertIn(MessageAuthor.CONTACT, authors)
+
+ def test_email_identity_is_exposed_in_dialog_and_contact_payloads(self) -> None:
+ self._ingest(
+ external_id="",
+ subject="Контакты",
+ message_id="",
+ )
+ conversation = Conversation.objects.get(channel=self.channel)
+ dialog = conversation_payload(conversation, with_messages=True)
+ self.assertEqual(dialog["connection"]["provider"], "EMAIL")
+ self.assertEqual(dialog["contact"]["email"], "ivan@example.com")
+ self.assertEqual(dialog["messages"][0]["contentHtml"], "")
+
+ overview = clients_overview(self.organization.id)
+ self.assertEqual(overview[0]["email"], "ivan@example.com")
+ self.assertIn("EMAIL", overview[0]["channels"])
+
+ detail = client_detail(self.organization.id, conversation.contact_id)
+ self.assertEqual(detail["email"], "ivan@example.com")
+ self.assertEqual(detail["identities"][0]["value"], "ivan@example.com")
diff --git a/apps/backend/hub_platform/conversations/test_lifecycle.py b/apps/backend/hub_platform/conversations/test_lifecycle.py
new file mode 100644
index 0000000..077b4c7
--- /dev/null
+++ b/apps/backend/hub_platform/conversations/test_lifecycle.py
@@ -0,0 +1,190 @@
+import json
+from unittest import mock
+
+from django.test import TestCase
+
+from hub_platform.channels.models import Channel
+from hub_platform.conversations.ingest import ingest_inbound
+from hub_platform.conversations.models import (
+ ConnectionIdentity,
+ Contact,
+ ControlMode,
+ Conversation,
+ ExpectedResponder,
+ LifecycleState,
+ MessageAuthor,
+)
+from hub_platform.conversations.transports.base import InboundMessage
+from hub_platform.identity.bootstrap import bootstrap_edevs_owner
+from hub_platform.identity.models import HumanUser, Organization
+from hub_platform.integrations.models import (
+ Integration,
+ IntegrationKind,
+ IntegrationProvider,
+)
+from hub_platform.testing import TenantAPIClient as APIClient
+
+
+def _connection(channel: Channel) -> Integration:
+ return Integration.objects.create(
+ organization=channel.organization,
+ kind=IntegrationKind.MESSENGER,
+ provider=IntegrationProvider.TELEGRAM,
+ name="test-bot",
+ channel=channel,
+ )
+
+
+class OperatorOnlyIngestTests(TestCase):
+ def setUp(self) -> None:
+ bootstrap_edevs_owner(
+ email="owner@edevs.tech", password="temporary-password"
+ )
+ self.organization = Organization.objects.get(slug="edevs")
+ self.channel = Channel.objects.create(
+ organization=self.organization,
+ code="operator-only",
+ name="Операторский канал",
+ )
+ self.integration = _connection(self.channel)
+
+ def _ingest_without_ai(self, inbound: InboundMessage) -> tuple[mock.Mock, mock.Mock]:
+ with (
+ mock.patch(
+ "hub_platform.conversations.ingest.run_channel_turn"
+ ) as ai_turn,
+ mock.patch(
+ "hub_platform.conversations.ingest.transports.send_reply"
+ ) as send,
+ ):
+ ingest_inbound(self.integration, inbound)
+ return ai_turn, send
+
+ def test_new_dialog_starts_in_queue_without_ai_fallback(self) -> None:
+ ai_turn, send = self._ingest_without_ai(
+ InboundMessage(
+ external_id="operator-1",
+ user_id="user-1",
+ chat_id="chat-1",
+ text="Нужна помощь",
+ display_name="Гость",
+ )
+ )
+
+ conversation = self.channel.conversations.get()
+ self.assertEqual(conversation.control_mode, ControlMode.PAUSED)
+ self.assertEqual(
+ conversation.expected_responder, ExpectedResponder.OPERATOR
+ )
+ self.assertEqual(
+ list(conversation.messages.values_list("author_type", flat=True)),
+ [MessageAuthor.CONTACT],
+ )
+ ai_turn.assert_not_called()
+ send.assert_not_called()
+
+ def test_existing_ai_dialog_moves_to_queue_when_agent_is_unavailable(self) -> None:
+ contact = Contact.objects.create(
+ organization=self.organization, name="Клиент"
+ )
+ ConnectionIdentity.objects.create(
+ contact=contact,
+ connection=self.integration,
+ external_user_id="user-existing",
+ )
+ conversation = Conversation.objects.create(
+ organization=self.organization,
+ channel=self.channel,
+ connection=self.integration,
+ contact=contact,
+ external_chat_id="chat-existing",
+ control_mode=ControlMode.AI,
+ expected_responder=ExpectedResponder.AI,
+ )
+
+ ai_turn, send = self._ingest_without_ai(
+ InboundMessage(
+ external_id="operator-2",
+ user_id="user-existing",
+ chat_id="chat-existing",
+ text="Вы здесь?",
+ display_name="Клиент",
+ )
+ )
+
+ conversation.refresh_from_db()
+ self.assertEqual(conversation.control_mode, ControlMode.PAUSED)
+ self.assertEqual(
+ conversation.expected_responder, ExpectedResponder.OPERATOR
+ )
+ self.assertEqual(conversation.messages.count(), 1)
+ ai_turn.assert_not_called()
+ send.assert_not_called()
+
+
+class ClosedConversationActionTests(TestCase):
+ def setUp(self) -> None:
+ bootstrap_edevs_owner(
+ email="owner@edevs.tech", password="temporary-password"
+ )
+ organization = Organization.objects.get(slug="edevs")
+ channel = Channel.objects.create(
+ organization=organization,
+ code="closed-actions",
+ name="Закрытые диалоги",
+ )
+ integration = _connection(channel)
+ contact = Contact.objects.create(organization=organization, name="Иван")
+ owner = HumanUser.objects.get(email="owner@edevs.tech")
+ self.conversation = Conversation.objects.create(
+ organization=organization,
+ channel=channel,
+ connection=integration,
+ contact=contact,
+ lifecycle=LifecycleState.CLOSED,
+ control_mode=ControlMode.HUMAN,
+ assigned_operator=owner,
+ expected_responder=ExpectedResponder.NOBODY,
+ )
+ self.client = APIClient()
+ self.client.login(
+ username="owner@edevs.tech", password="temporary-password"
+ )
+
+ def test_closed_dialog_rejects_operational_actions(self) -> None:
+ base = f"/api/v1/conversations/{self.conversation.id}"
+ self.assertEqual(self.client.post(f"{base}/claim/").status_code, 409)
+ self.assertEqual(self.client.post(f"{base}/release/").status_code, 409)
+ self.assertEqual(
+ self.client.post(f"{base}/return-queue/").status_code, 409
+ )
+ self.assertEqual(
+ self.client.post(
+ f"{base}/messages/",
+ data=json.dumps({"text": "Поздний ответ"}),
+ content_type="application/json",
+ ).status_code,
+ 409,
+ )
+ self.assertEqual(self.client.post(f"{base}/close/").status_code, 409)
+ self.assertEqual(self.client.post(f"{base}/spam/").status_code, 409)
+
+ def test_open_dialog_can_be_marked_as_spam(self) -> None:
+ self.conversation.lifecycle = LifecycleState.OPEN
+ self.conversation.expected_responder = ExpectedResponder.OPERATOR
+ self.conversation.save(
+ update_fields=["lifecycle", "expected_responder"]
+ )
+
+ response = self.client.post(
+ f"/api/v1/conversations/{self.conversation.id}/spam/"
+ )
+
+ self.assertEqual(response.status_code, 200)
+ payload = response.json()["conversation"]
+ self.assertEqual(payload["lifecycle"], LifecycleState.SPAM)
+ self.assertEqual(payload["controlMode"], ControlMode.PAUSED)
+ self.assertIsNone(payload["assignedOperator"])
+ self.assertEqual(
+ payload["expectedResponder"], ExpectedResponder.NOBODY
+ )
diff --git a/apps/backend/hub_platform/conversations/transports/base.py b/apps/backend/hub_platform/conversations/transports/base.py
index 5ef000e..6eb843d 100644
--- a/apps/backend/hub_platform/conversations/transports/base.py
+++ b/apps/backend/hub_platform/conversations/transports/base.py
@@ -24,6 +24,9 @@ class InboundMessage:
username: str = ""
# Телефон из явного шаринга контакта (кнопка/форма); text при этом может быть пуст.
phone: str = ""
+ # Безопасный форматированный вариант входящего письма. Plain text остаётся
+ # обязательным fallback для AI, поиска, уведомлений и превью.
+ content_html: str = ""
# Транспортная мета для тредирования ответа (email: subject/last_message_id).
# Пишется в Conversation.transport_meta при ingest (ADR-HUB-0035).
thread_meta: dict | None = None
diff --git a/apps/backend/hub_platform/conversations/transports/email.py b/apps/backend/hub_platform/conversations/transports/email.py
index 77a3a70..9a0a9fb 100644
--- a/apps/backend/hub_platform/conversations/transports/email.py
+++ b/apps/backend/hub_platform/conversations/transports/email.py
@@ -18,6 +18,7 @@ from email.utils import parseaddr
from django.conf import settings
+from hub_platform.conversations.html_sanitizer import sanitize_email_html
from hub_platform.conversations.transports.base import InboundMessage
logger = logging.getLogger(__name__)
@@ -56,6 +57,13 @@ def _body_text(message) -> str:
return _html_to_text(str(rich.get_content())) if rich is not None else ""
+def _body_html(message) -> str:
+ rich = message.get_body(preferencelist=("html",))
+ if rich is None:
+ return ""
+ return sanitize_email_html(str(rich.get_content()))
+
+
def _normalize(message, *, own_address: str, fallback_id: str) -> tuple[InboundMessage | None, str]:
"""Parse one RFC822 message → (InboundMessage | None, raw Message-ID)."""
display_name, address = parseaddr(str(message.get("From", "")))
@@ -65,6 +73,7 @@ def _normalize(message, *, own_address: str, fallback_id: str) -> tuple[InboundM
return None, ""
message_id = str(message.get("Message-ID", "")).strip()
text = _body_text(message)
+ content_html = _body_html(message)
attachments = sum(1 for _ in message.iter_attachments())
if attachments:
# Вложения в первой итерации не принимаются (ADR-HUB-0035).
@@ -77,6 +86,7 @@ def _normalize(message, *, own_address: str, fallback_id: str) -> tuple[InboundM
user_id=address,
chat_id=address,
text=text,
+ content_html=content_html,
display_name=str(display_name).strip() or address,
thread_meta={"subject": str(message.get("Subject", "")).strip(), "last_message_id": message_id},
)
diff --git a/apps/backend/hub_platform/conversations/urls.py b/apps/backend/hub_platform/conversations/urls.py
index d99ef49..3f3cedd 100644
--- a/apps/backend/hub_platform/conversations/urls.py
+++ b/apps/backend/hub_platform/conversations/urls.py
@@ -1,13 +1,13 @@
from django.urls import path
-from hub_platform.conversations import views
+from hub_platform.conversations import reporting_views, views
urlpatterns = [
path("", views.ConversationListView.as_view(), name="conversation-list"),
- path("stats/", views.ConversationStatsView.as_view(), name="conversation-stats"),
- path("command-overview/", views.CommandOverviewView.as_view(), name="conversation-command-overview"),
- path("clients/", views.ClientsView.as_view(), name="conversation-clients"),
- path("clients//", views.ClientDetailView.as_view(), name="conversation-client-detail"),
+ path("stats/", reporting_views.ConversationStatsView.as_view(), name="conversation-stats"),
+ path("command-overview/", reporting_views.CommandOverviewView.as_view(), name="conversation-command-overview"),
+ path("clients/", reporting_views.ClientsView.as_view(), name="conversation-clients"),
+ path("clients//", reporting_views.ClientDetailView.as_view(), name="conversation-client-detail"),
path("/", views.ConversationDetailView.as_view(), name="conversation-detail"),
path("/claim/", views.ConversationClaimView.as_view(), name="conversation-claim"),
path("/release/", views.ConversationReleaseView.as_view(), name="conversation-release"),
@@ -15,4 +15,5 @@ urlpatterns = [
path("/messages/", views.ConversationMessageView.as_view(), name="conversation-messages"),
path("/request-contact/", views.ConversationRequestContactView.as_view(), name="conversation-request-contact"),
path("/close/", views.ConversationCloseView.as_view(), name="conversation-close"),
+ path("/spam/", views.ConversationSpamView.as_view(), name="conversation-spam"),
]
diff --git a/apps/backend/hub_platform/conversations/view_base.py b/apps/backend/hub_platform/conversations/view_base.py
new file mode 100644
index 0000000..df7b411
--- /dev/null
+++ b/apps/backend/hub_platform/conversations/view_base.py
@@ -0,0 +1,43 @@
+from rest_framework.request import Request
+from rest_framework.views import APIView
+
+from hub_platform.api.permissions import HasCapability
+from hub_platform.conversations.models import Conversation
+from hub_platform.conversations.selectors import conversation_for_context
+from hub_platform.identity.audit import record_audit_event
+from hub_platform.identity.policy import require_capability
+
+
+class ConversationViewBase(APIView):
+ permission_classes = [HasCapability]
+ required_capability = "conversations.view"
+
+ def _org(self, request: Request):
+ return request.tenant_context.organization
+
+ def _conversation(
+ self,
+ request: Request,
+ conversation_id: int,
+ capability: str = "conversations.view",
+ ) -> Conversation:
+ conversation = conversation_for_context(
+ context=request.tenant_context, conversation_id=conversation_id
+ )
+ if not require_capability(
+ request.tenant_context.membership, capability, conversation
+ ):
+ raise Conversation.DoesNotExist
+ return conversation
+
+ def _audit(
+ self, request: Request, action: str, conversation: Conversation
+ ) -> None:
+ record_audit_event(
+ action=f"conversations.{action}",
+ actor=request.user,
+ organization=self._org(request),
+ object_type="Conversation",
+ object_id=str(conversation.id),
+ request=request,
+ )
diff --git a/apps/backend/hub_platform/conversations/views.py b/apps/backend/hub_platform/conversations/views.py
index aed3b93..2cb191f 100644
--- a/apps/backend/hub_platform/conversations/views.py
+++ b/apps/backend/hub_platform/conversations/views.py
@@ -1,67 +1,33 @@
from rest_framework.request import Request
from rest_framework.response import Response
-from rest_framework.views import APIView
-from hub_platform.api.permissions import HasCapability
-from hub_platform.conversations.clients import client_detail, clients_overview
-from hub_platform.conversations.command import command_center_overview
-from hub_platform.conversations.models import Contact, ControlMode, Conversation, ConversationRead
-from hub_platform.conversations.selectors import (
- conversation_for_context,
- conversations_for_context,
+from hub_platform.conversations.models import (
+ ControlMode,
+ Conversation,
+ ConversationRead,
+ LifecycleState,
)
+from hub_platform.conversations.selectors import conversations_for_context
from hub_platform.conversations.serializers import conversation_payload, message_payload
from hub_platform.conversations.services import (
ClaimError,
claim_conversation,
close_conversation,
+ mark_conversation_as_spam,
post_operator_message,
release_to_ai,
request_contact,
return_to_queue,
)
-from hub_platform.conversations.stats import sales_overview_stats
-from hub_platform.identity.audit import record_audit_event
+from hub_platform.conversations.view_base import ConversationViewBase
from hub_platform.identity.policy import (
ResourceScope,
accessible_department_ids,
authorize,
- require_capability,
)
-class _Base(APIView):
- permission_classes = [HasCapability]
- required_capability = "conversations.view"
-
- def _org(self, request: Request):
- return request.tenant_context.organization
-
- def _conversation(
- self,
- request: Request,
- conversation_id: int,
- capability: str = "conversations.view",
- ) -> Conversation:
- conversation = conversation_for_context(
- context=request.tenant_context, conversation_id=conversation_id
- )
- if not require_capability(request.tenant_context.membership, capability, conversation):
- raise Conversation.DoesNotExist
- return conversation
-
- def _audit(self, request: Request, action: str, conversation: Conversation) -> None:
- record_audit_event(
- action=f"conversations.{action}",
- actor=request.user,
- organization=self._org(request),
- object_type="Conversation",
- object_id=str(conversation.id),
- request=request,
- )
-
-
-class ConversationListView(_Base):
+class ConversationListView(ConversationViewBase):
def get(self, request: Request) -> Response:
items = conversations_for_context(request.tenant_context)
department_ids = accessible_department_ids(request.tenant_context.membership, self.required_capability)
@@ -79,10 +45,21 @@ class ConversationListView(_Base):
ConversationRead.objects.filter(user=request.user, conversation__in=items)
.values_list("conversation_id", "last_read_message_id")
)
- return Response({"items": [conversation_payload(c, last_read_id=read_map.get(c.id, 0)) for c in items]})
+ return Response(
+ {
+ "items": [
+ conversation_payload(
+ c,
+ last_read_id=read_map.get(c.id, 0),
+ viewer_id=request.user.id,
+ )
+ for c in items
+ ]
+ }
+ )
-class ConversationDetailView(_Base):
+class ConversationDetailView(ConversationViewBase):
def get(self, request: Request, conversation_id: int) -> Response:
try:
conversation = self._conversation(request, conversation_id)
@@ -98,10 +75,18 @@ class ConversationDetailView(_Base):
if not created and read.last_read_message_id < last_id:
read.last_read_message_id = last_id
read.save(update_fields=["last_read_message_id", "updated_at"])
- return Response({"conversation": conversation_payload(conversation, with_messages=True)})
+ return Response(
+ {
+ "conversation": conversation_payload(
+ conversation,
+ with_messages=True,
+ viewer_id=request.user.id,
+ )
+ }
+ )
-class ConversationClaimView(_Base):
+class ConversationClaimView(ConversationViewBase):
required_capability = "conversations.operate"
def post(self, request: Request, conversation_id: int) -> Response:
@@ -116,10 +101,18 @@ class ConversationClaimView(_Base):
except ClaimError as error:
return Response({"detail": str(error)}, status=409)
self._audit(request, "claimed", conversation)
- return Response({"conversation": conversation_payload(conversation, with_messages=True)})
+ return Response(
+ {
+ "conversation": conversation_payload(
+ conversation,
+ with_messages=True,
+ viewer_id=request.user.id,
+ )
+ }
+ )
-class ConversationReleaseView(_Base):
+class ConversationReleaseView(ConversationViewBase):
required_capability = "conversations.operate"
def post(self, request: Request, conversation_id: int) -> Response:
@@ -127,70 +120,51 @@ class ConversationReleaseView(_Base):
self._conversation(request, conversation_id, self.required_capability)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
- conversation = release_to_ai(
- context=request.tenant_context, conversation_id=conversation_id
- )
- self._audit(request, "released_to_ai", conversation)
- return Response({"conversation": conversation_payload(conversation, with_messages=True)})
-
-
-class ConversationReturnQueueView(_Base):
- required_capability = "conversations.operate"
-
- def post(self, request: Request, conversation_id: int) -> Response:
try:
- self._conversation(request, conversation_id, self.required_capability)
- except Conversation.DoesNotExist:
- return Response({"detail": "Диалог не найден"}, status=404)
- conversation = return_to_queue(
- context=request.tenant_context, conversation_id=conversation_id
- )
- self._audit(request, "returned_to_queue", conversation)
- return Response({"conversation": conversation_payload(conversation, with_messages=True)})
-
-
-class ConversationStatsView(_Base):
- def get(self, request: Request) -> Response:
- period = request.query_params.get("period", "today")
- if period not in ("today", "d7", "d30"):
- period = "today"
- department_ids = accessible_department_ids(request.tenant_context.membership, self.required_capability)
- return Response(sales_overview_stats(request.tenant_context, period, department_ids))
-
-
-class CommandOverviewView(_Base):
- required_capability = "company.view"
- require_organization_scope = True
-
- def get(self, request: Request) -> Response:
- period = request.query_params.get("period", "today")
- if period not in ("today", "d7", "d30"):
- period = "today"
- return Response(command_center_overview(request.tenant_context, period))
-
-
-class ClientsView(_Base):
- required_capability = "customers.view"
-
- def get(self, request: Request) -> Response:
- department_ids = accessible_department_ids(request.tenant_context.membership, self.required_capability)
- return Response({"items": clients_overview(self._org(request).id, department_ids)})
-
-
-class ClientDetailView(_Base):
- required_capability = "customers.view"
-
- def get(self, request: Request, contact_id: int) -> Response:
- try:
- department_ids = accessible_department_ids(request.tenant_context.membership, self.required_capability)
- return Response(
- {"client": client_detail(self._org(request).id, contact_id, department_ids)}
+ conversation = release_to_ai(
+ context=request.tenant_context, conversation_id=conversation_id
)
- except Contact.DoesNotExist:
- return Response({"detail": "Клиент не найден"}, status=404)
+ except ClaimError as error:
+ return Response({"detail": str(error)}, status=409)
+ self._audit(request, "released_to_ai", conversation)
+ return Response(
+ {
+ "conversation": conversation_payload(
+ conversation,
+ with_messages=True,
+ viewer_id=request.user.id,
+ )
+ }
+ )
-class ConversationMessageView(_Base):
+class ConversationReturnQueueView(ConversationViewBase):
+ required_capability = "conversations.operate"
+
+ def post(self, request: Request, conversation_id: int) -> Response:
+ try:
+ self._conversation(request, conversation_id, self.required_capability)
+ except Conversation.DoesNotExist:
+ return Response({"detail": "Диалог не найден"}, status=404)
+ try:
+ conversation = return_to_queue(
+ context=request.tenant_context, conversation_id=conversation_id
+ )
+ except ClaimError as error:
+ return Response({"detail": str(error)}, status=409)
+ self._audit(request, "returned_to_queue", conversation)
+ return Response(
+ {
+ "conversation": conversation_payload(
+ conversation,
+ with_messages=True,
+ viewer_id=request.user.id,
+ )
+ }
+ )
+
+
+class ConversationMessageView(ConversationViewBase):
required_capability = "conversations.operate"
def post(self, request: Request, conversation_id: int) -> Response:
@@ -203,6 +177,8 @@ class ConversationMessageView(_Base):
return Response({"detail": "Пустое сообщение"}, status=400)
if conversation.control_mode != ControlMode.HUMAN:
return Response({"detail": "Сначала перехватите диалог"}, status=409)
+ if conversation.lifecycle != LifecycleState.OPEN:
+ return Response({"detail": "Диалог закрыт"}, status=409)
manager_override = authorize(
request.tenant_context.membership,
self.required_capability,
@@ -210,13 +186,16 @@ class ConversationMessageView(_Base):
)
if conversation.assigned_operator_id != request.user.id and not manager_override:
return Response({"detail": "Диалог ведёт другой оператор"}, status=409)
- message = post_operator_message(
- context=request.tenant_context, conversation=conversation, text=text
- )
+ try:
+ message = post_operator_message(
+ context=request.tenant_context, conversation=conversation, text=text
+ )
+ except ClaimError as error:
+ return Response({"detail": str(error)}, status=409)
return Response({"message": message_payload(message)}, status=201)
-class ConversationRequestContactView(_Base):
+class ConversationRequestContactView(ConversationViewBase):
required_capability = "conversations.operate"
def post(self, request: Request, conversation_id: int) -> Response:
@@ -226,14 +205,22 @@ class ConversationRequestContactView(_Base):
return Response({"detail": "Диалог не найден"}, status=404)
if not conversation.contact_id or not conversation.connection_id:
return Response({"detail": "У диалога нет канала для запроса контакта"}, status=409)
+ if conversation.lifecycle != LifecycleState.OPEN:
+ return Response({"detail": "Диалог закрыт"}, status=409)
if conversation.contact.phone:
return Response({"detail": "Контакт уже получен"}, status=409)
- message = request_contact(context=request.tenant_context, conversation=conversation)
+ try:
+ message = request_contact(
+ context=request.tenant_context,
+ conversation=conversation,
+ )
+ except ClaimError as error:
+ return Response({"detail": str(error)}, status=409)
self._audit(request, "contact_requested", conversation)
return Response({"message": message_payload(message)}, status=201)
-class ConversationCloseView(_Base):
+class ConversationCloseView(ConversationViewBase):
required_capability = "conversations.operate"
def post(self, request: Request, conversation_id: int) -> Response:
@@ -241,8 +228,45 @@ class ConversationCloseView(_Base):
self._conversation(request, conversation_id, self.required_capability)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
- conversation = close_conversation(
- context=request.tenant_context, conversation_id=conversation_id
- )
+ try:
+ conversation = close_conversation(
+ context=request.tenant_context, conversation_id=conversation_id
+ )
+ except ClaimError as error:
+ return Response({"detail": str(error)}, status=409)
self._audit(request, "closed", conversation)
- return Response({"conversation": conversation_payload(conversation, with_messages=True)})
+ return Response(
+ {
+ "conversation": conversation_payload(
+ conversation,
+ with_messages=True,
+ viewer_id=request.user.id,
+ )
+ }
+ )
+
+
+class ConversationSpamView(ConversationViewBase):
+ required_capability = "conversations.operate"
+
+ def post(self, request: Request, conversation_id: int) -> Response:
+ try:
+ self._conversation(request, conversation_id, self.required_capability)
+ except Conversation.DoesNotExist:
+ return Response({"detail": "Диалог не найден"}, status=404)
+ try:
+ conversation = mark_conversation_as_spam(
+ context=request.tenant_context, conversation_id=conversation_id
+ )
+ except ClaimError as error:
+ return Response({"detail": str(error)}, status=409)
+ self._audit(request, "marked_spam", conversation)
+ return Response(
+ {
+ "conversation": conversation_payload(
+ conversation,
+ with_messages=True,
+ viewer_id=request.user.id,
+ )
+ }
+ )
diff --git a/apps/backend/hub_platform/integrations/migrations/0005_integration_is_active.py b/apps/backend/hub_platform/integrations/migrations/0005_integration_is_active.py
new file mode 100644
index 0000000..4bced9d
--- /dev/null
+++ b/apps/backend/hub_platform/integrations/migrations/0005_integration_is_active.py
@@ -0,0 +1,15 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("integrations", "0004_alter_integration_provider_email"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="integration",
+ name="is_active",
+ field=models.BooleanField(default=True),
+ ),
+ ]
diff --git a/apps/backend/hub_platform/integrations/models.py b/apps/backend/hub_platform/integrations/models.py
index 461f1e5..9bef1ab 100644
--- a/apps/backend/hub_platform/integrations/models.py
+++ b/apps/backend/hub_platform/integrations/models.py
@@ -49,6 +49,9 @@ class Integration(models.Model):
# Несекретная конфигурация: base_url, модель по умолчанию и т.п.
config = models.JSONField(default=dict, blank=True)
status = models.CharField(max_length=16, choices=IntegrationStatus.choices, default=IntegrationStatus.UNCHECKED)
+ # Операционное состояние подключения. В отличие от status (результата
+ # последней проверки), is_active явно разрешает или запрещает runtime.
+ is_active = models.BooleanField(default=True)
# Подключение (бот/виджет) привязано к каналу обработки (ADR-HUB-0019, M2).
channel = models.ForeignKey("channels.Channel", on_delete=models.SET_NULL, null=True, blank=True, related_name="connections")
# Курсор Long Polling (marker MAX / offset Telegram).
diff --git a/apps/backend/hub_platform/integrations/serializers.py b/apps/backend/hub_platform/integrations/serializers.py
index c977b78..08bd932 100644
--- a/apps/backend/hub_platform/integrations/serializers.py
+++ b/apps/backend/hub_platform/integrations/serializers.py
@@ -9,6 +9,7 @@ def integration_payload(integration: Integration) -> dict[str, object]:
"provider": integration.provider,
"name": integration.name,
"hasSecret": bool(integration.secret),
+ "isActive": integration.is_active,
"config": {
"baseUrl": integration.config.get("base_url", ""),
"defaultModel": integration.config.get("default_model", ""),
diff --git a/apps/backend/hub_platform/integrations/services.py b/apps/backend/hub_platform/integrations/services.py
index f980d69..cef1385 100644
--- a/apps/backend/hub_platform/integrations/services.py
+++ b/apps/backend/hub_platform/integrations/services.py
@@ -24,17 +24,26 @@ class IntegrationInput:
secret: str | None = None # None = не менять при update
config: dict = field(default_factory=dict)
channel_id: int | None = None # канал обработки для подключения (ADR-HUB-0019)
+ is_active: bool | None = None
-def _resolve_channel(organization: Organization, channel_id: int | None):
+def _resolve_channel(
+ organization: Organization,
+ channel_id: int | None,
+ *,
+ current_channel_id: int | None = None,
+):
if not channel_id:
return None
from hub_platform.channels.models import Channel
try:
- return Channel.objects.get(organization=organization, id=channel_id)
+ channel = Channel.objects.get(organization=organization, id=channel_id)
except Channel.DoesNotExist as error:
raise ValidationError({"channel": "Channel not found"}) from error
+ if not channel.is_active and channel.id != current_channel_id:
+ raise ValidationError({"channel": "Inactive channel cannot accept connections"})
+ return channel
def _email_config(config: dict) -> dict:
@@ -124,6 +133,7 @@ def create_integration(*, context: TenantContext, data: IntegrationInput) -> Int
secret=(data.secret or "").strip(),
config=_normalized_config(provider, data.config),
channel=_resolve_channel(organization, data.channel_id),
+ is_active=True if data.is_active is None else data.is_active,
status=IntegrationStatus.UNCHECKED,
)
integration.full_clean(exclude=["secret"])
@@ -148,7 +158,13 @@ def update_integration(
raise ValidationError({"integration": "Integration belongs to another organization"})
integration.name = data.name.strip() or integration.name
integration.config = _normalized_config(integration.provider, data.config)
- integration.channel = _resolve_channel(integration.organization, data.channel_id)
+ integration.channel = _resolve_channel(
+ integration.organization,
+ data.channel_id,
+ current_channel_id=integration.channel_id,
+ )
+ if data.is_active is not None:
+ integration.is_active = data.is_active
# Пустой/отсутствующий секрет при обновлении не затирает существующий.
if data.secret:
integration.secret = data.secret.strip()
diff --git a/apps/backend/hub_platform/integrations/test_activation.py b/apps/backend/hub_platform/integrations/test_activation.py
new file mode 100644
index 0000000..b093251
--- /dev/null
+++ b/apps/backend/hub_platform/integrations/test_activation.py
@@ -0,0 +1,66 @@
+from django.core.exceptions import ValidationError
+from django.test import TestCase
+
+from hub_platform.channels.models import Channel
+from hub_platform.identity.bootstrap import bootstrap_edevs_owner
+from hub_platform.identity.models import Organization
+from hub_platform.integrations.models import IntegrationProvider
+from hub_platform.integrations.serializers import integration_payload
+from hub_platform.integrations.services import (
+ IntegrationInput,
+ create_integration,
+ update_integration,
+)
+from hub_platform.testing import system_tenant_context
+
+
+class IntegrationActivationTests(TestCase):
+ def setUp(self) -> None:
+ bootstrap_edevs_owner(
+ email="owner@edevs.tech", password="temporary-password"
+ )
+ organization = Organization.objects.get(slug="edevs")
+ self.context = system_tenant_context(organization)
+ self.channel = Channel.objects.create(
+ organization=organization,
+ code="activation",
+ name="Канал подключения",
+ )
+
+ def test_activation_round_trip_in_service_and_payload(self) -> None:
+ integration = create_integration(
+ context=self.context,
+ data=IntegrationInput(
+ provider=IntegrationProvider.WEB,
+ name="Виджет",
+ channel_id=self.channel.id,
+ ),
+ )
+ updated = update_integration(
+ context=self.context,
+ integration=integration,
+ data=IntegrationInput(
+ provider=integration.provider,
+ name=integration.name,
+ config=integration.config,
+ channel_id=self.channel.id,
+ is_active=False,
+ ),
+ )
+
+ self.assertFalse(updated.is_active)
+ self.assertFalse(integration_payload(updated)["isActive"])
+
+ def test_new_binding_to_inactive_channel_is_rejected(self) -> None:
+ self.channel.is_active = False
+ self.channel.save(update_fields=["is_active"])
+
+ with self.assertRaises(ValidationError):
+ create_integration(
+ context=self.context,
+ data=IntegrationInput(
+ provider=IntegrationProvider.WEB,
+ name="Виджет",
+ channel_id=self.channel.id,
+ ),
+ )
diff --git a/apps/backend/hub_platform/integrations/views.py b/apps/backend/hub_platform/integrations/views.py
index 02ef6c2..2eaaf76 100644
--- a/apps/backend/hub_platform/integrations/views.py
+++ b/apps/backend/hub_platform/integrations/views.py
@@ -31,6 +31,11 @@ def _input(body: dict[str, object], *, current: Integration | None = None) -> In
secret=body.get("secret") if "secret" in body else None,
config=config if isinstance(config, dict) else {},
channel_id=channel_id,
+ is_active=(
+ body["isActive"]
+ if isinstance(body.get("isActive"), bool)
+ else (current.is_active if current else None)
+ ),
)
diff --git a/apps/backend/hub_platform/notifications/tests.py b/apps/backend/hub_platform/notifications/tests.py
index d1a0aa1..c43ec97 100644
--- a/apps/backend/hub_platform/notifications/tests.py
+++ b/apps/backend/hub_platform/notifications/tests.py
@@ -170,6 +170,49 @@ class PollerSelectionTests(NotifierTestBase):
self.assertIn(client_bot.id, polled_ids)
self.assertNotIn(self.integration.id, polled_ids)
+ def test_disabled_connection_and_inactive_channel_are_not_polled(self) -> None:
+ from hub_platform.channels.models import Channel
+ from hub_platform.conversations import poller
+
+ active_channel = Channel.objects.create(
+ organization=self.organization,
+ code="active-channel",
+ name="Активный канал",
+ )
+ inactive_channel = Channel.objects.create(
+ organization=self.organization,
+ code="inactive-channel",
+ name="Неактивный канал",
+ is_active=False,
+ )
+ Integration.objects.create(
+ organization=self.organization,
+ kind=IntegrationKind.MESSENGER,
+ provider=IntegrationProvider.TELEGRAM,
+ name="disabled-client",
+ secret="token",
+ channel=active_channel,
+ is_active=False,
+ )
+ Integration.objects.create(
+ organization=self.organization,
+ kind=IntegrationKind.MESSENGER,
+ provider=IntegrationProvider.TELEGRAM,
+ name="inactive-channel-client",
+ secret="token",
+ channel=inactive_channel,
+ )
+
+ with mock.patch(
+ "hub_platform.conversations.poller.transports.poll",
+ return_value=([], ""),
+ ) as poll:
+ poller.poll_all_messengers(self.context)
+
+ polled_names = [call.args[0].name for call in poll.call_args_list]
+ self.assertNotIn("disabled-client", polled_names)
+ self.assertNotIn("inactive-channel-client", polled_names)
+
class BindingApiTests(NotifierTestBase):
def setUp(self) -> None:
diff --git a/apps/internal-ui/src/features/conversations/Composer.tsx b/apps/internal-ui/src/features/conversations/Composer.tsx
index 1988c42..89e9ac7 100644
--- a/apps/internal-ui/src/features/conversations/Composer.tsx
+++ b/apps/internal-ui/src/features/conversations/Composer.tsx
@@ -4,9 +4,36 @@ import { Icon } from "../../shared/icons";
import { sendOperatorMessage } from "./model";
import type { ControlMode } from "./types";
-export function Composer({ mode, conversationId, onClaim, onRelease, onReturnQueue, onClose, onSent }: { mode: ControlMode; conversationId: number | null; onClaim: () => void; onRelease: () => void; onReturnQueue: () => void; onClose: () => void; onSent: () => void }) {
+export function Composer({ mode, loaded, assignedOperatorName, conversationId, onClaim, onRelease, onReturnQueue, onClose, onSent }: { mode: ControlMode; loaded: boolean; assignedOperatorName?: string; conversationId: number | null; onClaim: () => void; onRelease: () => void; onReturnQueue: () => void; onClose: () => void; onSent: () => void }) {
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
+ const [sendError, setSendError] = useState("");
+
+ if (conversationId == null) {
+ return ;
+ }
+
+ if (!loaded) {
+ return ;
+ }
+
+ if (mode === "closed") {
+ return (
+
+
+
Диалог закрытИстория сохранена. Новое обращение клиента создаст новый диалог.
+
+ );
+ }
+
+ if (mode === "assigned") {
+ return (
+
+
+
{assignedOperatorName ? `Диалог ведёт ${assignedOperatorName}` : "Диалог ведёт другой оператор"}Отправка сообщений доступна назначенному оператору.
+
+ );
+ }
if (mode === "waiting") {
return (
@@ -32,10 +59,13 @@ export function Composer({ mode, conversationId, onClaim, onRelease, onReturnQue
const value = text.trim();
if (!value || conversationId == null || sending) return;
setSending(true);
+ setSendError("");
try {
await sendOperatorMessage(conversationId, value);
setText("");
onSent();
+ } catch (error) {
+ setSendError(error instanceof Error ? error.message : "Не удалось отправить сообщение");
} finally {
setSending(false);
}
@@ -50,7 +80,6 @@ export function Composer({ mode, conversationId, onClaim, onRelease, onReturnQue
-
+ {sendError && {sendError}
}
);
}
diff --git a/apps/internal-ui/src/features/conversations/ConversationActions.tsx b/apps/internal-ui/src/features/conversations/ConversationActions.tsx
new file mode 100644
index 0000000..08cc553
--- /dev/null
+++ b/apps/internal-ui/src/features/conversations/ConversationActions.tsx
@@ -0,0 +1,62 @@
+import { Dropdown } from "antd";
+import { useState } from "react";
+
+import { DecisionDialog } from "../../shared/DecisionDialog";
+import { Icon } from "../../shared/icons";
+import { Button } from "../../shared/ui-controls";
+
+export function ConversationActions({
+ open,
+ onClose,
+ onSpam,
+}: {
+ open: boolean;
+ onClose: () => void;
+ onSpam: () => Promise;
+}) {
+ const [confirmSpam, setConfirmSpam] = useState(false);
+ const [busy, setBusy] = useState(false);
+
+ if (!open) return null;
+
+ async function confirm() {
+ setBusy(true);
+ try {
+ if (await onSpam()) setConfirmSpam(false);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ const items = [
+ {
+ key: "close",
+ label: ,
+ },
+ { type: "divider" as const },
+ {
+ key: "spam",
+ label: ,
+ },
+ ];
+
+ return (
+ <>
+
+
+
+ !busy && setConfirmSpam(false)}
+ tone="danger"
+ icon="warning"
+ title="Пометить диалог как спам?"
+ description="Диалог будет закрыт для ответов и останется в истории со статусом «Спам»."
+ actions={<>
+
+
+ >}
+ />
+ >
+ );
+}
diff --git a/apps/internal-ui/src/features/conversations/ConversationThread.tsx b/apps/internal-ui/src/features/conversations/ConversationThread.tsx
index 2084f66..ff1ed1d 100644
--- a/apps/internal-ui/src/features/conversations/ConversationThread.tsx
+++ b/apps/internal-ui/src/features/conversations/ConversationThread.tsx
@@ -1,6 +1,8 @@
import { useEffect, useRef, type ReactNode } from "react";
import { Icon } from "../../shared/icons";
+import { ConversationActions } from "./ConversationActions";
+import { EmailMessageBody } from "./EmailMessageBody";
import { statusFor } from "./data";
import { providerMeta } from "../../shared/providers";
import type { ApiConversation, ApiMessage } from "./model";
@@ -10,7 +12,7 @@ function fmtTime(value: string): string {
return new Date(value).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
}
-export function ConversationThread({ controlMode, dialog, detail, onClaim, onCall }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; onClaim: () => void; onCall: () => void }) {
+export function ConversationThread({ controlMode, dialog, detail, onClaim, onCall, onClose, onSpam }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; onClaim: () => void; onCall: () => void; onClose: () => void; onSpam: () => Promise }) {
const timelineRef = useRef(null);
const messages = detail?.messages ?? [];
const lastMessageId = messages.length ? messages[messages.length - 1].id : 0;
@@ -24,7 +26,7 @@ export function ConversationThread({ controlMode, dialog, detail, onClaim, onCal
if (!dialog) {
return ;
}
- const status = statusFor(controlMode);
+ const status = statusFor(controlMode, detail?.assignedOperator?.name);
const channel = providerMeta[dialog.channel];
return (
<>
@@ -39,10 +41,10 @@ export function ConversationThread({ controlMode, dialog, detail, onClaim, onCal
{controlMode === "waiting" && }
{controlMode === "ai" && }
- {detail?.lifecycle === "OPEN" && (
+ {detail?.lifecycle === "OPEN" && dialog.channel !== "EMAIL" && (
)}
-
+
@@ -65,7 +67,9 @@ function MessageRow({ message, dialog }: { message: ApiMessage; dialog: Conversa
const actor = message.author === "AI" ? "AI-агент" : message.author === "OPERATOR" ? "Оператор" : undefined;
return (
- {message.text}
+ {message.author === "CONTACT" && message.contentHtml
+ ?
+ : message.text}
);
}
diff --git a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx
index 038f387..6e654e4 100644
--- a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx
+++ b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { CallOverlay } from "./CallOverlay";
import { Composer } from "./Composer";
@@ -10,6 +10,7 @@ import {
controlModeOf,
fetchConversation,
fetchConversations,
+ markConversationAsSpam,
releaseConversation,
returnToQueue,
toConversationListItem,
@@ -35,25 +36,37 @@ export function ConversationWorkspace({ department, listTitle, searchPlaceholder
const [search, setSearch] = useState("");
const [conversations, setConversations] = useState
([]);
const [listLoaded, setListLoaded] = useState(false);
+ const [listError, setListError] = useState("");
const [selectedId, setSelectedId] = useState(initialConversationId ?? null);
const [detail, setDetail] = useState(null);
+ const [detailError, setDetailError] = useState("");
+ const [actionError, setActionError] = useState("");
+ const selectedIdRef = useRef(selectedId);
+ selectedIdRef.current = selectedId;
const loadList = useCallback(async () => {
try {
const items = await fetchConversations(department);
setConversations(items);
setListLoaded(true);
+ setListError("");
setSelectedId((current) => current ?? items[0]?.id ?? null);
} catch {
- /* keep previous list on transient errors */
+ setListError("Не удалось обновить список диалогов");
}
}, [department]);
const loadDetail = useCallback(async (id: number) => {
try {
- setDetail(await fetchConversation(id));
+ const loaded = await fetchConversation(id);
+ if (selectedIdRef.current === id) {
+ setDetail(loaded);
+ setDetailError("");
+ }
} catch {
- /* ignore */
+ if (selectedIdRef.current === id) {
+ setDetailError("Не удалось загрузить диалог");
+ }
}
}, []);
@@ -69,6 +82,9 @@ export function ConversationWorkspace({ department, listTitle, searchPlaceholder
useEffect(() => {
if (selectedId == null) return;
+ setDetail(null);
+ setDetailError("");
+ setActionError("");
void loadDetail(selectedId);
const timer = setInterval(() => loadDetail(selectedId), 3000);
return () => clearInterval(timer);
@@ -84,7 +100,7 @@ export function ConversationWorkspace({ department, listTitle, searchPlaceholder
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
return dialogs.filter((dialog) => {
- if (query && !`${dialog.name} ${dialog.product} ${dialog.preview}`.toLowerCase().includes(query)) return false;
+ if (query && !`${dialog.name} ${dialog.email} ${dialog.product} ${dialog.preview}`.toLowerCase().includes(query)) return false;
if (listTab === "wait") return dialog.mode === "wait";
if (listTab === "ai") return dialog.mode === "ai";
if (listTab === "operator") return dialog.mode === "operator";
@@ -94,29 +110,32 @@ export function ConversationWorkspace({ department, listTitle, searchPlaceholder
}, [dialogs, listTab, search]);
const selectedDialog = dialogs.find((dialog) => dialog.id === selectedId) ?? null;
- const controlMode = detail ? controlModeOf(detail) : "ai";
+ const detailLoaded = detail?.id === selectedId;
+ const controlMode = detailLoaded ? controlModeOf(detail) : "waiting";
function applyUpdated(updated: ApiConversation) {
setDetail(updated);
+ setActionError("");
void loadList();
}
- const onClaim = async () => {
- if (selectedId == null) return;
- try { applyUpdated(await claimConversation(selectedId)); } catch { /* ignore */ }
- };
- const onRelease = async () => {
- if (selectedId == null) return;
- try { applyUpdated(await releaseConversation(selectedId)); } catch { /* ignore */ }
- };
- const onReturnQueue = async () => {
- if (selectedId == null) return;
- try { applyUpdated(await returnToQueue(selectedId)); } catch { /* ignore */ }
- };
- const onClose = async () => {
- if (selectedId == null) return;
- try { applyUpdated(await closeConversation(selectedId)); } catch { /* ignore */ }
- };
+ async function updateConversation(action: (id: number) => Promise): Promise {
+ if (selectedId == null) return false;
+ setActionError("");
+ try {
+ applyUpdated(await action(selectedId));
+ return true;
+ } catch (error) {
+ setActionError(error instanceof Error ? error.message : "Не удалось выполнить действие");
+ return false;
+ }
+ }
+
+ const onClaim = () => { void updateConversation(claimConversation); };
+ const onRelease = () => { void updateConversation(releaseConversation); };
+ const onReturnQueue = () => { void updateConversation(returnToQueue); };
+ const onClose = () => { void updateConversation(closeConversation); };
+ const onSpam = () => updateConversation(markConversationAsSpam);
return (
@@ -128,12 +147,14 @@ export function ConversationWorkspace({ department, listTitle, searchPlaceholder
listTab={listTab}
selectedId={selectedId ?? -1}
search={search}
+ errorText={listError}
setSearch={setSearch}
setListTab={setListTab}
setSelectedId={setSelectedId}
/>
- void callController.start()} />
+ void callController.start()} onClose={onClose} onSpam={onSpam} />
+ {(detailError || actionError) && {detailError || actionError}
}
setListTab("unread")}>Непрочитанные {unreadCount > 0 && {unreadCount}}
+ {errorText &&
{errorText}
}
{filtered.map((dialog) =>
)}
diff --git a/apps/internal-ui/src/features/conversations/EmailMessageBody.tsx b/apps/internal-ui/src/features/conversations/EmailMessageBody.tsx
new file mode 100644
index 0000000..22c6a47
--- /dev/null
+++ b/apps/internal-ui/src/features/conversations/EmailMessageBody.tsx
@@ -0,0 +1,37 @@
+import { useMemo } from "react";
+
+const DOCUMENT_START = `
+
+
+
+
+
+
+
+`;
+
+export function EmailMessageBody({ html }: { html: string }) {
+ const srcDoc = useMemo(
+ () => `${DOCUMENT_START}${html}`,
+ [html],
+ );
+ return (
+
+ );
+}
diff --git a/apps/internal-ui/src/features/conversations/data.ts b/apps/internal-ui/src/features/conversations/data.ts
index 8f4ab5e..c699f74 100644
--- a/apps/internal-ui/src/features/conversations/data.ts
+++ b/apps/internal-ui/src/features/conversations/data.ts
@@ -7,8 +7,10 @@ export const modeDots: Record = {
closed: "#bfbfbf",
};
-export function statusFor(mode: ControlMode): StatusInfo {
+export function statusFor(mode: ControlMode, assignedOperatorName?: string): StatusInfo {
if (mode === "ai") return { label: "AI отвечает", color: "#722ed1", bg: "#f9f0ff", border: "#efdbff", dot: "#722ed1" };
if (mode === "human") return { label: "Вы ведёте диалог", color: "#0958d9", bg: "#e6f4ff", border: "#91caff", dot: "#1677ff" };
+ if (mode === "assigned") return { label: assignedOperatorName ? `Ведёт ${assignedOperatorName}` : "Ведёт другой оператор", color: "#595959", bg: "#fafafa", border: "#d9d9d9", dot: "#8c8c8c" };
+ if (mode === "closed") return { label: "Диалог закрыт", color: "#595959", bg: "#f5f5f5", border: "#d9d9d9", dot: "#bfbfbf" };
return { label: "Ждёт оператора", color: "#d48806", bg: "#fffbe6", border: "#ffe58f", dot: "#faad14" };
}
diff --git a/apps/internal-ui/src/features/conversations/model.ts b/apps/internal-ui/src/features/conversations/model.ts
index 2c7dbf4..15e8753 100644
--- a/apps/internal-ui/src/features/conversations/model.ts
+++ b/apps/internal-ui/src/features/conversations/model.ts
@@ -2,12 +2,12 @@ import { api } from "../../api/client";
import type { ChannelKey, ConversationListItem, ControlMode, DialogMode } from "./types";
// kind: "" — текст, "contact_request" — запрос контакта, "contact" — клиент поделился номером.
-export type ApiMessage = { id: number; author: "CONTACT" | "AI" | "OPERATOR" | "SYSTEM"; kind?: string; text: string; createdAt: string };
+export type ApiMessage = { id: number; author: "CONTACT" | "AI" | "OPERATOR" | "SYSTEM"; kind?: string; text: string; contentHtml?: string; createdAt: string };
export type HistoryItem = {
id: number;
channelName: string;
- provider: "MAX" | "TELEGRAM" | "WEB" | null;
+ provider: "EMAIL" | "MAX" | "TELEGRAM" | "WEB" | null;
lifecycle: "OPEN" | "CLOSED" | "SPAM";
createdAt: string;
lastActivityAt: string;
@@ -41,16 +41,18 @@ export type OperatorCard = {
export type ApiConversation = {
id: number;
channel: { code: string; name: string; product: { code: string; name: string } | null };
- connection: { id: number; provider: "MAX" | "TELEGRAM" | "WEB"; name: string } | null;
+ connection: { id: number; provider: "EMAIL" | "MAX" | "TELEGRAM" | "WEB"; name: string } | null;
// Источник identity: sales Contact (лид) ИЛИ verified SupportIdentitySnapshot.
// ADR-HUB-0022: ровно один заполнен.
// phone появляется после явного шаринга контакта; username (@логин TG/MAX) — только в detail-режиме.
- contact: { id: number; name: string; phone?: string; username?: string } | null;
+ contact: { id: number; name: string; email?: string; phone?: string; username?: string } | null;
supportIdentitySnapshot: SupportIdentitySnapshotRef | null;
lifecycle: "OPEN" | "CLOSED" | "SPAM";
controlMode: "AI" | "HUMAN" | "PAUSED";
expectedResponder: string;
assignedOperatorId: number | null;
+ assignedOperator: { id: number; name: string } | null;
+ isAssignedToViewer: boolean;
lastActivityAt: string;
createdAt: string;
lastMessage: ApiMessage | null;
@@ -60,10 +62,18 @@ export type ApiConversation = {
};
const AVATAR_PALETTE = ["#eb6f4b", "#3b82c4", "#9254de", "#13a8a8", "#d4860b", "#52a838", "#c4413b", "#6b5be0"];
-const PROVIDER_CHANNEL: Record = { MAX: "MAX", TELEGRAM: "TG", WEB: "WEB" };
+const PROVIDER_CHANNEL: Record = {
+ EMAIL: "EMAIL",
+ MAX: "MAX",
+ TELEGRAM: "TG",
+ WEB: "WEB",
+};
export function controlModeOf(conversation: ApiConversation): ControlMode {
- if (conversation.controlMode === "HUMAN") return "human";
+ if (conversation.lifecycle !== "OPEN") return "closed";
+ if (conversation.controlMode === "HUMAN") {
+ return conversation.isAssignedToViewer ? "human" : "assigned";
+ }
if (conversation.controlMode === "AI") return "ai";
return "waiting";
}
@@ -100,6 +110,7 @@ export function toConversationListItem(conversation: ApiConversation): Conversat
avatarBg: AVATAR_PALETTE[seed % AVATAR_PALETTE.length],
product: conversation.channel.name,
channel: PROVIDER_CHANNEL[conversation.connection?.provider ?? "WEB"] ?? "WEB",
+ email: conversation.contact?.email ?? "",
mode: dialogMode(conversation),
preview: conversation.lastMessage?.text.replace(/\s+/g, " ").slice(0, 80) ?? "—",
time: new Date(conversation.lastActivityAt).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }),
@@ -118,6 +129,7 @@ export const returnToQueue = (id: number) => api<{ conversation: ApiConversation
// Запрос контакта: в TG/MAX клиент видит кнопку «Поделиться контактом», в веб-чате — форму телефона.
export const requestContact = (id: number) => api(`/api/v1/conversations/${id}/request-contact/`, { method: "POST" });
export const closeConversation = (id: number) => api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/close/`, { method: "POST" }).then((r) => r.conversation);
+export const markConversationAsSpam = (id: number) => api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/spam/`, { method: "POST" }).then((r) => r.conversation);
// Бейдж ожидающих диалогов. ConversationStatsView сейчас sales-only (SPEC §12:
// support-метрики — отдельный endpoint); department-параметр backend не использует.
export const fetchWaitingCount = () => api<{ waiting: number }>("/api/v1/conversations/stats/").then((r) => r.waiting);
diff --git a/apps/internal-ui/src/features/conversations/types.ts b/apps/internal-ui/src/features/conversations/types.ts
index 1490247..aa3d08a 100644
--- a/apps/internal-ui/src/features/conversations/types.ts
+++ b/apps/internal-ui/src/features/conversations/types.ts
@@ -2,9 +2,9 @@
// общий conversation workspace, не отдельная реализация под каждый отдел.
export type DialogMode = "ai" | "closed" | "operator" | "wait";
-export type ControlMode = "ai" | "human" | "waiting";
+export type ControlMode = "ai" | "assigned" | "closed" | "human" | "waiting";
export type ListTab = "ai" | "all" | "operator" | "unread" | "wait";
-export type ChannelKey = "MAX" | "TG" | "WEB";
+export type ChannelKey = "EMAIL" | "MAX" | "TG" | "WEB";
// Элемент списка диалогов (бывш. SalesDialog). Полностью generic.
export type ConversationListItem = {
@@ -14,6 +14,7 @@ export type ConversationListItem = {
avatarBg: string;
product: string;
channel: ChannelKey;
+ email: string;
mode: DialogMode;
preview: string;
time: string;
diff --git a/apps/internal-ui/src/features/integrations/ConnectionsTable.tsx b/apps/internal-ui/src/features/integrations/ConnectionsTable.tsx
index e845bd8..c52fe77 100644
--- a/apps/internal-ui/src/features/integrations/ConnectionsTable.tsx
+++ b/apps/internal-ui/src/features/integrations/ConnectionsTable.tsx
@@ -17,10 +17,11 @@ type ConnectionsTableProps = {
testingId: number | null;
onTest: (integration: Integration) => void;
onEdit: (integration: Integration) => void;
+ onToggleActive: (integration: Integration) => void;
onDelete: (integration: Integration) => void;
};
-export function ConnectionsTable({ items, testingId, onTest, onEdit, onDelete }: ConnectionsTableProps) {
+export function ConnectionsTable({ items, testingId, onTest, onEdit, onToggleActive, onDelete }: ConnectionsTableProps) {
return (
@@ -44,7 +45,7 @@ export function ConnectionsTable({ items, testingId, onTest, onEdit, onDelete }:
| {item.channel ? item.channel.name : —} |
|
-
+
|
))}
diff --git a/apps/internal-ui/src/features/integrations/IntegrationsPage.tsx b/apps/internal-ui/src/features/integrations/IntegrationsPage.tsx
index 0f22458..d23e9da 100644
--- a/apps/internal-ui/src/features/integrations/IntegrationsPage.tsx
+++ b/apps/internal-ui/src/features/integrations/IntegrationsPage.tsx
@@ -52,6 +52,20 @@ export function IntegrationsPage() {
}
}
+ async function toggleActive(integration: Integration) {
+ try {
+ const payload = await api<{ integration: Integration }>(`/api/v1/integrations/${integration.id}/`, {
+ method: "PATCH",
+ body: JSON.stringify({ isActive: !integration.isActive }),
+ });
+ setItems((current) => current.map((item) => (
+ item.id === integration.id ? payload.integration : item
+ )));
+ } catch {
+ void load();
+ }
+ }
+
function startDelete(integration: Integration) {
setDeletingError(null);
setDeleting(integration);
@@ -72,7 +86,13 @@ export function IntegrationsPage() {
const providers = items.filter((item) => item.kind === "LLM_PROVIDER");
const isConnections = tab === "MESSENGER";
const shown = isConnections ? connections : providers;
- const rowHandlers = { testingId, onTest: test, onEdit: (item: Integration) => setForm({ initial: item, kind: item.kind }), onDelete: startDelete };
+ const rowHandlers = {
+ testingId,
+ onTest: test,
+ onEdit: (item: Integration) => setForm({ initial: item, kind: item.kind }),
+ onToggleActive: toggleActive,
+ onDelete: startDelete,
+ };
const header = (
void;
onEdit: (integration: Integration) => void;
+ onToggleActive: (integration: Integration) => void;
onDelete: (integration: Integration) => void;
};
-export function ProvidersTable({ items, testingId, onTest, onEdit, onDelete }: ProvidersTableProps) {
+export function ProvidersTable({ items, testingId, onTest, onEdit, onToggleActive, onDelete }: ProvidersTableProps) {
return (
@@ -40,7 +41,7 @@ export function ProvidersTable({ items, testingId, onTest, onEdit, onDelete }: P
|
-
+
|
);
diff --git a/apps/internal-ui/src/features/integrations/model.ts b/apps/internal-ui/src/features/integrations/model.ts
index e537858..8f3fed8 100644
--- a/apps/internal-ui/src/features/integrations/model.ts
+++ b/apps/internal-ui/src/features/integrations/model.ts
@@ -10,6 +10,7 @@ export type Integration = {
provider: IntegrationProvider;
name: string;
hasSecret: boolean;
+ isActive: boolean;
// purpose="notifications" — сервисный бот уведомлений сотрудников (не привязан к каналу продаж).
// email/imap*/smtp* — Email-подключение (SPEC-HUB-0025 §3.1).
config: {
diff --git a/apps/internal-ui/src/features/integrations/rows.tsx b/apps/internal-ui/src/features/integrations/rows.tsx
index 755bf47..5c41787 100644
--- a/apps/internal-ui/src/features/integrations/rows.tsx
+++ b/apps/internal-ui/src/features/integrations/rows.tsx
@@ -33,6 +33,14 @@ export function formatChecked(value: string | null): string {
}
export function StatusCell({ integration }: { integration: Integration }) {
+ if (!integration.isActive) {
+ return (
+
+ Отключено
+ Приём и отправка сообщений остановлены
+
+ );
+ }
const status = STATUS_META[integration.status];
return (
@@ -47,14 +55,24 @@ type RowActionsProps = {
testing: boolean;
onTest: (integration: Integration) => void;
onEdit: (integration: Integration) => void;
+ onToggleActive: (integration: Integration) => void;
onDelete: (integration: Integration) => void;
};
-export function RowActions({ integration, testing, onTest, onEdit, onDelete }: RowActionsProps) {
+export function RowActions({ integration, testing, onTest, onEdit, onToggleActive, onDelete }: RowActionsProps) {
const [open, setOpen] = useState(false);
const meta = PROVIDERS[integration.provider];
const menuItems = [
{ key: "edit", label:
},
+ ...(integration.kind === "MESSENGER" ? [{
+ key: "active",
+ label: (
+
+ ),
+ }] : []),
{ type: "divider" as const },
{ key: "delete", label:
},
];
diff --git a/apps/internal-ui/src/features/sales/SalesClientsPage.tsx b/apps/internal-ui/src/features/sales/SalesClientsPage.tsx
index b22851f..d550f02 100644
--- a/apps/internal-ui/src/features/sales/SalesClientsPage.tsx
+++ b/apps/internal-ui/src/features/sales/SalesClientsPage.tsx
@@ -2,6 +2,7 @@ import { EmptyState, LoadingState } from "../../shared/ui";
import { SalesClientsFilters } from "./clients/SalesClientsFilters";
import { SalesClientsHeader } from "./clients/SalesClientsHeader";
import { SalesClientsTable } from "./clients/SalesClientsTable";
+import { exportClientsCsv } from "./clients/exportClientsCsv";
import { useClientsData } from "./clients/useClientsData";
import { useSalesClients } from "./clients/useSalesClients";
@@ -11,7 +12,7 @@ export function SalesClientsPage({ openClient }: { openClient: (id: number) => v
return (
<>
-
+
exportClientsCsv(clients.filteredRows)} />
{loading ? (
) : error ? (
@@ -19,7 +20,7 @@ export function SalesClientsPage({ openClient }: { openClient: (id: number) => v
) : (
<>
-
+
>
)}
>
diff --git a/apps/internal-ui/src/features/sales/client-detail/SalesClientDetailPage.tsx b/apps/internal-ui/src/features/sales/client-detail/SalesClientDetailPage.tsx
index 9b393ad..1874896 100644
--- a/apps/internal-ui/src/features/sales/client-detail/SalesClientDetailPage.tsx
+++ b/apps/internal-ui/src/features/sales/client-detail/SalesClientDetailPage.tsx
@@ -1,7 +1,6 @@
import { useState } from "react";
import { EmptyState, LoadingState } from "../../../shared/ui";
-import type { RouteKey } from "../../../types";
import { type ClientDetailTab } from "./model";
import { SalesClientAuditTab } from "./SalesClientAuditTab";
import { SalesClientConsentTab } from "./SalesClientConsentTab";
@@ -13,7 +12,7 @@ import { SalesClientOverviewTab } from "./SalesClientOverviewTab";
import { SalesClientTabs } from "./SalesClientTabs";
import { useClientDetail } from "./useClientDetail";
-export function SalesClientDetailPage({ contactId, setRoute }: { contactId: number | null; setRoute: (route: RouteKey) => void }) {
+export function SalesClientDetailPage({ contactId, openConversation }: { contactId: number | null; openConversation: (conversationId: number) => void }) {
const [tab, setTab] = useState("overview");
const { client, loading, error } = useClientDetail(contactId);
@@ -22,10 +21,10 @@ export function SalesClientDetailPage({ contactId, setRoute }: { contactId: numb
return (
<>
-
+
- {tab === "overview" && }
- {tab === "dialogs" && }
+ {tab === "overview" && }
+ {tab === "dialogs" && }
{tab === "orders" && }
{tab === "ids" && }
{tab === "consent" && }
diff --git a/apps/internal-ui/src/features/sales/client-detail/SalesClientDialogsTab.tsx b/apps/internal-ui/src/features/sales/client-detail/SalesClientDialogsTab.tsx
index bbaea33..5940d0b 100644
--- a/apps/internal-ui/src/features/sales/client-detail/SalesClientDialogsTab.tsx
+++ b/apps/internal-ui/src/features/sales/client-detail/SalesClientDialogsTab.tsx
@@ -1,13 +1,12 @@
import { EmptyState } from "../../../shared/ui";
-import type { RouteKey } from "../../../types";
import type { ClientDetailVm } from "./model";
-export function SalesClientDialogsTab({ dialogs, setRoute }: { dialogs: ClientDetailVm["dialogs"]; setRoute: (route: RouteKey) => void }) {
+export function SalesClientDialogsTab({ dialogs, openConversation }: { dialogs: ClientDetailVm["dialogs"]; openConversation: (conversationId: number) => void }) {
if (dialogs.length === 0) return ;
return (
{dialogs.map((dialog) => (
-
- setRoute("salesDialogs")}>Открыть диалог
-
-
-
+ targetDialog && openConversation(targetDialog.id)}>Открыть диалог
);
}
-
-const clientMenuItems = [
- { key: "merge", label: Объединить контакты },
- { key: "split", label: Разъединить контакты },
- { key: "edit", label: Исправить данные },
- { type: "divider" as const },
- { key: "anonymize", label: Обезличить данные },
-];
diff --git a/apps/internal-ui/src/features/sales/client-detail/SalesClientOverviewTab.tsx b/apps/internal-ui/src/features/sales/client-detail/SalesClientOverviewTab.tsx
index c982f59..96783de 100644
--- a/apps/internal-ui/src/features/sales/client-detail/SalesClientOverviewTab.tsx
+++ b/apps/internal-ui/src/features/sales/client-detail/SalesClientOverviewTab.tsx
@@ -1,8 +1,7 @@
import { Button } from "../../../shared/ui-controls";
-import type { RouteKey } from "../../../types";
import type { ClientDetailVm } from "./model";
-export function SalesClientOverviewTab({ client, setRoute }: { client: ClientDetailVm; setRoute: (route: RouteKey) => void }) {
+export function SalesClientOverviewTab({ client, openConversation }: { client: ClientDetailVm; openConversation: (conversationId: number) => void }) {
const currentDialog = client.dialogs.find((dialog) => dialog.active);
return (
@@ -18,7 +17,7 @@ export function SalesClientOverviewTab({ client, setRoute }: { client: ClientDet
{currentDialog.status}
{currentDialog.meta}
«{currentDialog.title}»
- setRoute("salesDialogs")}>Открыть диалог
+ openConversation(currentDialog.id)}>Открыть диалог
)}
diff --git a/apps/internal-ui/src/features/sales/client-detail/model.ts b/apps/internal-ui/src/features/sales/client-detail/model.ts
index 7a48a01..62fa380 100644
--- a/apps/internal-ui/src/features/sales/client-detail/model.ts
+++ b/apps/internal-ui/src/features/sales/client-detail/model.ts
@@ -11,8 +11,8 @@ export const clientDetailTabs: Array<{ key: ClientDetailTab; label: string }> =
{ key: "audit", label: "Аудит" },
];
-const PROVIDER_TO_CHANNEL: Record = { MAX: "MAX", TELEGRAM: "TG", WEB: "WEB" };
-const PROVIDER_LABEL: Record = { MAX: "MAX", TELEGRAM: "Telegram", WEB: "Web Chat" };
+const PROVIDER_TO_CHANNEL: Record = { EMAIL: "EMAIL", MAX: "MAX", TELEGRAM: "TG", WEB: "WEB" };
+const PROVIDER_LABEL: Record = { EMAIL: "Email", MAX: "MAX", TELEGRAM: "Telegram", WEB: "Web Chat" };
const PAYMENT_LABEL: Record = { PENDING: "Ожидает", PAID: "Оплачен", CANCELLED: "Отменён", REFUNDED: "Возврат" };
const FULFILLMENT_LABEL: Record = { NONE: "—", PENDING: "В процессе", DELIVERED: "Исполнен", FAILED: "Ошибка" };
@@ -22,6 +22,8 @@ export type ApiClientDetail = {
id: number;
cid: string;
name: string;
+ email: string;
+ phone: string;
channels: ClientChannelCode[];
products: ClientProductCode[];
openDialogs: number;
@@ -71,8 +73,8 @@ export function toClientDetailVm(api: ApiClientDetail): ClientDetailVm {
name: api.name,
initials: initialsOf(api.name),
avatarBg: isGuest ? "#8c8c8c" : avatarColor(api.cid),
- email: isGuest ? "без контакта" : "—",
- phone: "идентификация по каналу",
+ email: api.email || (isGuest ? "без контакта" : "—"),
+ phone: api.phone || "—",
channels: api.channels.map((code) => ({ label: channelMap[code].label, color: channelMap[code].color, bg: channelMap[code].bg })),
products: api.products.map((code) => ({ name: productMap[code].name, color: productMap[code].color, bg: productMap[code].bg })),
summary: [
diff --git a/apps/internal-ui/src/features/sales/clients/SalesClientRow.tsx b/apps/internal-ui/src/features/sales/clients/SalesClientRow.tsx
index 0be3adc..2746980 100644
--- a/apps/internal-ui/src/features/sales/clients/SalesClientRow.tsx
+++ b/apps/internal-ui/src/features/sales/clients/SalesClientRow.tsx
@@ -7,9 +7,6 @@ export function SalesClientRow({ client, menu, openClient, setMenu }: { client:
const menuOpen = menu === client.cid;
const menuItems = [
{ key: "open", label: { setMenu(null); openClient(client.id); }}>Открыть клиента },
- { key: "merge", label: Объединить контакты },
- { type: "divider" as const },
- { key: "anonymize", label: Обезличить данные },
];
return (
@@ -26,8 +23,8 @@ export function SalesClientRow({ client, menu, openClient, setMenu }: { client:
{client.statusMeta.label}
|
- {client.phone || "—"}
- {client.username ? `@${client.username}` : ""}
+ {client.phone || client.email || "—"}
+ {client.phone && client.email ? client.email : client.username ? `@${client.username}` : ""}
|
diff --git a/apps/internal-ui/src/features/sales/clients/SalesClientsFilters.tsx b/apps/internal-ui/src/features/sales/clients/SalesClientsFilters.tsx
index 3d4105d..fcdc733 100644
--- a/apps/internal-ui/src/features/sales/clients/SalesClientsFilters.tsx
+++ b/apps/internal-ui/src/features/sales/clients/SalesClientsFilters.tsx
@@ -6,7 +6,7 @@ import type { SalesClientsState } from "./useSalesClients";
export function SalesClientsFilters({ clients }: { clients: SalesClientsState }) {
return (
-
+
0 || clients.dropdown === "products"}
count={clients.productFilter.length}
diff --git a/apps/internal-ui/src/features/sales/clients/SalesClientsHeader.tsx b/apps/internal-ui/src/features/sales/clients/SalesClientsHeader.tsx
index a614dc3..4830e75 100644
--- a/apps/internal-ui/src/features/sales/clients/SalesClientsHeader.tsx
+++ b/apps/internal-ui/src/features/sales/clients/SalesClientsHeader.tsx
@@ -1,6 +1,6 @@
import { ActionButton } from "../../../shared/ui-controls";
-export function SalesClientsHeader({ shownCount, totalCount }: { shownCount: number; totalCount: number }) {
+export function SalesClientsHeader({ shownCount, totalCount, onExport }: { shownCount: number; totalCount: number; onExport: () => void }) {
return (
@@ -8,8 +8,7 @@ export function SalesClientsHeader({ shownCount, totalCount }: { shownCount: num
Лиды и клиенты отдела продаж · показано {shownCount} из {totalCount}
- Столбцы
- Экспорт
+ Экспорт
);
diff --git a/apps/internal-ui/src/features/sales/clients/SalesClientsPagination.test.ts b/apps/internal-ui/src/features/sales/clients/SalesClientsPagination.test.ts
new file mode 100644
index 0000000..9cae7d1
--- /dev/null
+++ b/apps/internal-ui/src/features/sales/clients/SalesClientsPagination.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+
+import { paginationItems } from "./SalesClientsPagination";
+
+describe("paginationItems", () => {
+ it("returns every page for a short list", () => {
+ expect(paginationItems(1, 4)).toEqual([1, 2, 3, 4]);
+ });
+
+ it("keeps the current page and boundaries for a long list", () => {
+ expect(paginationItems(5, 10)).toEqual([
+ 1,
+ "ellipsis",
+ 4,
+ 5,
+ 6,
+ "ellipsis",
+ 10,
+ ]);
+ });
+
+ it("does not add a leading ellipsis next to the first page", () => {
+ expect(paginationItems(2, 10)).toEqual([
+ 1,
+ 2,
+ 3,
+ "ellipsis",
+ 10,
+ ]);
+ });
+});
diff --git a/apps/internal-ui/src/features/sales/clients/SalesClientsPagination.tsx b/apps/internal-ui/src/features/sales/clients/SalesClientsPagination.tsx
index 4ced827..503fd66 100644
--- a/apps/internal-ui/src/features/sales/clients/SalesClientsPagination.tsx
+++ b/apps/internal-ui/src/features/sales/clients/SalesClientsPagination.tsx
@@ -1,5 +1,17 @@
import { TablePagination } from "../../../shared/ui-controls";
-export function SalesClientsPagination({ shownCount, totalCount }: { shownCount: number; totalCount: number }) {
- return ;
+export function paginationItems(page: number, pageCount: number): Array {
+ if (pageCount <= 7) return Array.from({ length: pageCount }, (_, index) => index + 1);
+ const pages = new Set([1, pageCount, page - 1, page, page + 1]);
+ const visible = [...pages].filter((item) => item >= 1 && item <= pageCount).sort((a, b) => a - b);
+ const result: Array = [];
+ visible.forEach((item, index) => {
+ if (index > 0 && item - visible[index - 1] > 1) result.push("ellipsis");
+ result.push(item);
+ });
+ return result;
+}
+
+export function SalesClientsPagination({ page, pageCount, shownCount, totalCount, setPage }: { page: number; pageCount: number; shownCount: number; totalCount: number; setPage: (page: number) => void }) {
+ return ;
}
diff --git a/apps/internal-ui/src/features/sales/clients/SalesClientsTable.tsx b/apps/internal-ui/src/features/sales/clients/SalesClientsTable.tsx
index 6317e29..6a0f00b 100644
--- a/apps/internal-ui/src/features/sales/clients/SalesClientsTable.tsx
+++ b/apps/internal-ui/src/features/sales/clients/SalesClientsTable.tsx
@@ -4,7 +4,7 @@ import { SalesClientsPagination } from "./SalesClientsPagination";
import type { ClientSortKey } from "./model";
import type { SalesClientsState } from "./useSalesClients";
-export function SalesClientsTable({ clients, openClient, totalCount }: { clients: SalesClientsState; openClient: (id: number) => void; totalCount: number }) {
+export function SalesClientsTable({ clients, openClient }: { clients: SalesClientsState; openClient: (id: number) => void }) {
return (
<>
@@ -14,7 +14,7 @@ export function SalesClientsTable({ clients, openClient, totalCount }: { clients
| КОНТАКТ |
СТАТУС |
- ТЕЛЕФОН / ЛОГИН |
+ ТЕЛЕФОН / EMAIL / ЛОГИН |
КАНАЛЫ |
ПРОДУКТЫ |
@@ -30,7 +30,7 @@ export function SalesClientsTable({ clients, openClient, totalCount }: { clients
|
{clients.rows.length === 0 && }
-
+
>
);
diff --git a/apps/internal-ui/src/features/sales/clients/exportClientsCsv.ts b/apps/internal-ui/src/features/sales/clients/exportClientsCsv.ts
new file mode 100644
index 0000000..50ad51d
--- /dev/null
+++ b/apps/internal-ui/src/features/sales/clients/exportClientsCsv.ts
@@ -0,0 +1,47 @@
+import type { SalesClientRowVm } from "./model";
+
+const HEADERS = [
+ "Контакт",
+ "Статус",
+ "Телефон",
+ "Email",
+ "Логин",
+ "Каналы",
+ "Продукты",
+ "Последний диалог",
+ "Открытые диалоги",
+ "Заказы",
+ "Сумма покупок",
+];
+
+function csvCell(value: string | number): string {
+ const text = String(value);
+ return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
+}
+
+export function exportClientsCsv(rows: SalesClientRowVm[]): void {
+ const data = rows.map((client) => [
+ client.name,
+ client.statusMeta.label,
+ client.phone,
+ client.email,
+ client.username,
+ client.channels.map((channel) => channel.full).join(", "),
+ client.products.map((product) => product.name).join(", "),
+ client.lastLabel,
+ client.openDialogs,
+ client.orders,
+ client.totalLabel,
+ ]);
+ const csv = [HEADERS, ...data]
+ .map((row) => row.map(csvCell).join(","))
+ .join("\r\n");
+ const url = URL.createObjectURL(
+ new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" }),
+ );
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = `contacts-${new Date().toISOString().slice(0, 10)}.csv`;
+ link.click();
+ URL.revokeObjectURL(url);
+}
diff --git a/apps/internal-ui/src/features/sales/clients/model.ts b/apps/internal-ui/src/features/sales/clients/model.ts
index 7a82eff..ef592bd 100644
--- a/apps/internal-ui/src/features/sales/clients/model.ts
+++ b/apps/internal-ui/src/features/sales/clients/model.ts
@@ -1,5 +1,5 @@
export type ClientProductCode = "FP" | "FX";
-export type ClientChannelCode = "MAX" | "TG" | "WEB";
+export type ClientChannelCode = "EMAIL" | "MAX" | "TG" | "WEB";
export type ClientSortKey = "last" | "open" | "orders" | "total";
export type ClientDropdown = "products" | "channels";
@@ -12,6 +12,7 @@ export type SalesClient = {
avatarBg: string;
cid: string;
phone: string;
+ email: string;
username: string;
status: ClientStatus;
anon?: boolean;
@@ -42,6 +43,7 @@ export const statusMap = {
} satisfies Record;
export const channelMap = {
+ EMAIL: { label: "Email", full: "Email", color: "#d48806", bg: "#fff7e6" },
MAX: { label: "MAX", full: "MAX", color: "#6b5be0", bg: "#f2f0ff" },
TG: { label: "TG", full: "Telegram", color: "#2f8fd0", bg: "#eaf6fd" },
WEB: { label: "Web", full: "Web Chat", color: "#0f9b8e", bg: "#e8f7f4" },
@@ -53,6 +55,7 @@ export const productMap = {
} satisfies Record;
export const channelOptions: Array<{ code: ClientChannelCode; name: string; color: string }> = [
+ { code: "EMAIL", name: "Email", color: channelMap.EMAIL.color },
{ code: "MAX", name: "MAX", color: channelMap.MAX.color },
{ code: "TG", name: "Telegram", color: channelMap.TG.color },
{ code: "WEB", name: "Web Chat", color: channelMap.WEB.color },
@@ -76,6 +79,7 @@ export type ApiClient = {
cid: string;
name: string;
phone: string;
+ email: string;
username: string;
status: ClientStatus;
channels: ClientChannelCode[];
@@ -120,6 +124,7 @@ export function toSalesClient(api: ApiClient): SalesClient {
avatarBg: isGuest ? "#8c8c8c" : avatarColor(api.cid),
cid: api.cid,
phone: api.phone,
+ email: api.email,
username: api.username,
status: api.status,
anon: isGuest,
diff --git a/apps/internal-ui/src/features/sales/clients/useSalesClients.ts b/apps/internal-ui/src/features/sales/clients/useSalesClients.ts
index 04dd548..95cb68d 100644
--- a/apps/internal-ui/src/features/sales/clients/useSalesClients.ts
+++ b/apps/internal-ui/src/features/sales/clients/useSalesClients.ts
@@ -1,4 +1,4 @@
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import type { ClientChannelCode, ClientDropdown, ClientProductCode, ClientSortKey, ClientStatus, SalesClient } from "./model";
import { toSalesClientRow } from "./model";
@@ -16,11 +16,13 @@ export function useSalesClients(salesClients: SalesClient[]) {
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [menu, setMenu] = useState(null);
const [dropdown, setDropdown] = useState(null);
+ const [page, setPage] = useState(1);
+ const pageSize = 20;
- const rows = useMemo(() => {
+ const filteredRows = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
const filtered = salesClients.filter((client) => {
- return (!normalizedQuery || client.name.toLowerCase().includes(normalizedQuery) || client.phone.includes(normalizedQuery) || client.username.toLowerCase().includes(normalizedQuery))
+ return (!normalizedQuery || client.name.toLowerCase().includes(normalizedQuery) || client.email.toLowerCase().includes(normalizedQuery) || client.phone.includes(normalizedQuery) || client.username.toLowerCase().includes(normalizedQuery))
&& (productFilter.length === 0 || client.products.some((product) => productFilter.includes(product)))
&& (channelFilter.length === 0 || client.channels.some((channel) => channelFilter.includes(channel)))
&& (!openOnly || client.openDialogs > 0)
@@ -31,17 +33,29 @@ export function useSalesClients(salesClients: SalesClient[]) {
.sort((left, right) => sortDir === "asc" ? Number(left[key[sortKey]]) - Number(right[key[sortKey]]) : Number(right[key[sortKey]]) - Number(left[key[sortKey]]))
.map(toSalesClientRow);
}, [salesClients, statusFilter, channelFilter, openOnly, productFilter, query, sortDir, sortKey]);
+ const pageCount = Math.max(1, Math.ceil(filteredRows.length / pageSize));
+ const rows = useMemo(
+ () => filteredRows.slice((page - 1) * pageSize, page * pageSize),
+ [filteredRows, page],
+ );
+
+ useEffect(() => {
+ setPage((current) => Math.min(current, pageCount));
+ }, [pageCount]);
function setQuery(value: string) {
setQueryState(value);
+ setPage(1);
setMenu(null);
}
function toggleProduct(code: string) {
+ setPage(1);
setProductFilter((current) => current.includes(code as ClientProductCode) ? current.filter((item) => item !== code) : [...current, code as ClientProductCode]);
}
function toggleChannel(code: string) {
+ setPage(1);
setChannelFilter((current) => current.includes(code as ClientChannelCode) ? current.filter((item) => item !== code) : [...current, code as ClientChannelCode]);
}
@@ -62,12 +76,18 @@ export function useSalesClients(salesClients: SalesClient[]) {
setChannelFilter([]);
setOpenOnly(false);
setStatusFilter(null);
+ setPage(1);
setMenu(null);
setDropdown(null);
}
return {
rows,
+ filteredRows,
+ filteredCount: filteredRows.length,
+ page,
+ pageCount,
+ setPage,
query,
productFilter,
channelFilter,
@@ -82,9 +102,9 @@ export function useSalesClients(salesClients: SalesClient[]) {
toggleDropdown,
setDropdown,
closeDropdown: () => setDropdown(null),
- toggleOpenOnly: () => { setOpenOnly((current) => !current); setMenu(null); setDropdown(null); },
+ toggleOpenOnly: () => { setOpenOnly((current) => !current); setPage(1); setMenu(null); setDropdown(null); },
// Повторный клик по активному статусу снимает фильтр (возврат к «все»).
- toggleStatus: (status: ClientStatus) => { setStatusFilter((current) => (current === status ? null : status)); setMenu(null); setDropdown(null); },
+ toggleStatus: (status: ClientStatus) => { setStatusFilter((current) => (current === status ? null : status)); setPage(1); setMenu(null); setDropdown(null); },
sortBy,
sortArrow: (key: ClientSortKey) => sortKey === key ? (sortDir === "asc" ? "↑" : "↓") : "",
reset,
diff --git a/apps/internal-ui/src/features/sales/dialogs/composer.css b/apps/internal-ui/src/features/sales/dialogs/composer.css
index fa53995..6ac0794 100644
--- a/apps/internal-ui/src/features/sales/dialogs/composer.css
+++ b/apps/internal-ui/src/features/sales/dialogs/composer.css
@@ -116,24 +116,6 @@
border-color: #1677ff;
}
-.sales-message-input button:first-child {
- width: 34px;
- height: 34px;
- display: flex;
- align-items: center;
- justify-content: center;
- flex: none;
- color: #8c8c8c;
- background: transparent;
- border: none;
- border-radius: 8px;
-}
-
-.sales-message-input button:first-child:hover {
- color: #262626;
- background: #f5f5f5;
-}
-
.sales-message-input textarea {
flex: 1;
max-height: 120px;
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 8a55c80..83283e8 100644
--- a/apps/internal-ui/src/features/sales/dialogs/context/ClientContext.tsx
+++ b/apps/internal-ui/src/features/sales/dialogs/context/ClientContext.tsx
@@ -33,6 +33,7 @@ export function ClientContext({ dialog, detail }: { dialog: ConversationListItem
const messageCount = detail?.messages?.length ?? 0;
const contact = detail?.contact ?? null;
+ const email = contact?.email ?? "";
const phone = contact?.phone ?? "";
const username = contact?.username ?? "";
// Запрос уже отправлен, если в диалоге есть сообщение kind=contact_request (detail поллится каждые 3 с).
@@ -58,7 +59,11 @@ export function ClientContext({ dialog, detail }: { dialog: ConversationListItem
{dialog.initials}{dialog.name}
-
+ {dialog.channel === "EMAIL" ? (
+
+ ) : (
+
+ )}
{contact && !phone && (
<>
diff --git a/apps/internal-ui/src/features/sales/dialogs/context/HistoryContext.tsx b/apps/internal-ui/src/features/sales/dialogs/context/HistoryContext.tsx
index 586ed73..0703770 100644
--- a/apps/internal-ui/src/features/sales/dialogs/context/HistoryContext.tsx
+++ b/apps/internal-ui/src/features/sales/dialogs/context/HistoryContext.tsx
@@ -1,5 +1,6 @@
import { ContextSection } from "../../../conversations/ContextSection";
import type { ApiConversation } from "../../../conversations/model";
+import { providerLabel } from "../../../../shared/providers";
const LIFECYCLE: Record = { OPEN: "открыт", CLOSED: "закрыт", SPAM: "спам" };
@@ -20,7 +21,7 @@ export function HistoryContext({ detail }: { detail: ApiConversation | null }) {
{history.map((item) => (
{item.channelName}{fmtDate(item.lastActivityAt)}
-
{(item.provider ?? "—")} · {LIFECYCLE[item.lifecycle] ?? item.lifecycle}{item.preview ? `. ${item.preview}` : ""}
+
{item.provider ? providerLabel(item.provider) : "—"} · {LIFECYCLE[item.lifecycle] ?? item.lifecycle}{item.preview ? `. ${item.preview}` : ""}
))}
diff --git a/apps/internal-ui/src/features/sales/dialogs/email-message.css b/apps/internal-ui/src/features/sales/dialogs/email-message.css
new file mode 100644
index 0000000..d00bc55
--- /dev/null
+++ b/apps/internal-ui/src/features/sales/dialogs/email-message.css
@@ -0,0 +1,8 @@
+.sales-email-body-frame {
+ display: block;
+ width: min(560px, 58vw);
+ max-width: 100%;
+ height: 260px;
+ background: #ffffff;
+ border: 0;
+}
diff --git a/apps/internal-ui/src/features/sales/dialogs/feedback.css b/apps/internal-ui/src/features/sales/dialogs/feedback.css
new file mode 100644
index 0000000..f808874
--- /dev/null
+++ b/apps/internal-ui/src/features/sales/dialogs/feedback.css
@@ -0,0 +1,24 @@
+.sales-load-error,
+.sales-composer-error,
+.sales-conversation-error {
+ color: #cf1322;
+ background: #fff1f0;
+ border: 1px solid #ffa39e;
+ border-radius: 8px;
+ font-size: 12px;
+}
+
+.sales-load-error {
+ margin: 12px;
+ padding: 8px 10px;
+}
+
+.sales-composer-error {
+ margin-top: 8px;
+ padding: 7px 10px;
+}
+
+.sales-conversation-error {
+ margin: 0 18px 8px;
+ padding: 8px 10px;
+}
diff --git a/apps/internal-ui/src/features/sales/dialogs/styles.css b/apps/internal-ui/src/features/sales/dialogs/styles.css
index 5cc3536..f1568ec 100644
--- a/apps/internal-ui/src/features/sales/dialogs/styles.css
+++ b/apps/internal-ui/src/features/sales/dialogs/styles.css
@@ -2,4 +2,6 @@
@import "./list.css";
@import "./conversation.css";
@import "./composer.css";
+@import "./email-message.css";
+@import "./feedback.css";
@import "./context.css";
diff --git a/apps/internal-ui/src/layout/ShellRouteContent.tsx b/apps/internal-ui/src/layout/ShellRouteContent.tsx
index caa1980..49c177c 100644
--- a/apps/internal-ui/src/layout/ShellRouteContent.tsx
+++ b/apps/internal-ui/src/layout/ShellRouteContent.tsx
@@ -41,7 +41,7 @@ export function ShellRouteContent({ route, data, currentEmployee, currentProduct
{route === "profile" && }
{route === "settings" && }
{route === "salesClients" && }
- {route === "salesClientDetail" && }
+ {route === "salesClientDetail" && }
{route === "salesOverview" && }
{route === "supportOverview" && }
{route === "supportDialogs" && }
diff --git a/apps/internal-ui/src/shared/providers.ts b/apps/internal-ui/src/shared/providers.ts
index c059883..0ca4684 100644
--- a/apps/internal-ui/src/shared/providers.ts
+++ b/apps/internal-ui/src/shared/providers.ts
@@ -1,13 +1,14 @@
-/* Провайдеры подключений: MAX / Telegram / Web Chat.
+/* Провайдеры подключений: MAX / Telegram / Web Chat / Email.
Общий словарь для всего приложения — раньше он лежал в демо-данных диалогов,
и продуктовые экраны каналов зависели от файла с примерами.
Провайдеры различаются точкой и коротким лейблом на своих токенах
(ADR-HUB-0013), а не бренд-логотипами. */
-export type ProviderKey = "MAX" | "TG" | "WEB";
+export type ProviderKey = "EMAIL" | "MAX" | "TG" | "WEB";
export const providerMeta: Record = {
+ EMAIL: { label: "Email", short: "Email", color: "#d48806", bg: "#fff7e6" },
MAX: { label: "MAX", short: "MAX", color: "#6b5be0", bg: "#f2f0ff" },
TG: { label: "Telegram", short: "TG", color: "#2f8fd0", bg: "#eaf6fd" },
WEB: { label: "Web Chat", short: "Web", color: "#0f9b8e", bg: "#e8f7f4" },
@@ -15,6 +16,7 @@ export const providerMeta: Record = {
+ EMAIL: "EMAIL",
MAX: "MAX",
TELEGRAM: "TG",
TG: "TG",
diff --git a/apps/internal-ui/src/shared/ui-controls.tsx b/apps/internal-ui/src/shared/ui-controls.tsx
index d30b14d..7007099 100644
--- a/apps/internal-ui/src/shared/ui-controls.tsx
+++ b/apps/internal-ui/src/shared/ui-controls.tsx
@@ -45,6 +45,8 @@ type ToneBadgeProps = {
type TablePaginationProps = {
className?: string;
+ currentPage?: number;
+ onPageChange?: (page: number) => void;
pageSizeLabel: string;
pages: Array;
shown: number;
@@ -99,14 +101,16 @@ export function ToneBadge({ bg, className = "", color, children }: ToneBadgeProp
return {children};
}
-export function TablePagination({ className = "", pageSizeLabel, pages, shown, total }: TablePaginationProps) {
+export function TablePagination({ className = "", currentPage = 1, onPageChange, pageSizeLabel, pages, shown, total }: TablePaginationProps) {
+ const numericPages = pages.filter((page): page is number => page !== "ellipsis");
+ const lastPage = Math.max(...numericPages, 1);
return (
Показано {shown} из {total}
- ‹
- {pages.map((page, index) => page === "ellipsis" ? … : {page})}
- ›
+ onPageChange?.(currentPage - 1)}>‹
+ {pages.map((page, index) => page === "ellipsis" ? … : onPageChange?.(page)} key={page}>{page})}
+ = lastPage} onClick={() => onPageChange?.(currentPage + 1)}>›
{pageSizeLabel}