diff --git a/README.md b/README.md
index a2b9536..74c1ac8 100644
--- a/README.md
+++ b/README.md
@@ -60,6 +60,18 @@ the local migration DB role, is excluded from the backend image, and the
importer refuses any environment other than `CUS_ENV=local`. Production compose
has no seed service or seed command.
+Демо-контент портала FoxRay и отдельный анонимный Web-виджет поддержки можно
+установить независимо от общего seed:
+
+```powershell
+.\scripts\seed-support-portal.ps1
+```
+
+Этот portal-only импорт идемпотентен и не создаёт пользователей, не меняет
+пароли, роли, сессии или права доступа. Он публикует локальный портал
+`http://foxray.localhost/`, категории и статьи, а также подключает существующий
+Web Chat через отдельный канал `foxray-help`.
+
## Tests
All suites run in Docker, so no manual environment is required — the test
diff --git a/apps/backend/hub_backend/settings_base.py b/apps/backend/hub_backend/settings_base.py
index 725d0e8..f16ebe9 100644
--- a/apps/backend/hub_backend/settings_base.py
+++ b/apps/backend/hub_backend/settings_base.py
@@ -1,5 +1,6 @@
import os
import sys
+from ipaddress import IPv4Address
from pathlib import Path
from django.core.exceptions import ImproperlyConfigured
@@ -215,6 +216,27 @@ CUS_HELP_BASE_DOMAIN = os.environ.get(
).strip().lower().rstrip(".")
CUS_HELP_PUBLIC_SCHEME = os.environ.get("CUS_HELP_PUBLIC_SCHEME", "https").strip().lower()
CUS_HELP_PUBLIC_PORT = os.environ.get("CUS_HELP_PUBLIC_PORT", "").strip()
+_default_help_public_ipv4 = os.environ.get(
+ "CUSTOCRM_WEB_LISTENING_IP",
+ "",
+).strip()
+if _default_help_public_ipv4 in {"", "0.0.0.0", "::"}:
+ _default_help_public_ipv4 = "127.0.0.1" if CUS_HELP_BASE_DOMAIN == "localhost" else ""
+CUS_HELP_PUBLIC_IPV4 = os.environ.get(
+ "CUS_HELP_PUBLIC_IPV4",
+ _default_help_public_ipv4,
+).strip()
+if not DEBUG and not TESTING and not CUS_HELP_PUBLIC_IPV4:
+ raise ImproperlyConfigured(
+ "CUS_HELP_PUBLIC_IPV4 or a non-wildcard CUSTOCRM_WEB_LISTENING_IP is required"
+ )
+if CUS_HELP_PUBLIC_IPV4:
+ try:
+ IPv4Address(CUS_HELP_PUBLIC_IPV4)
+ except ValueError as error:
+ raise ImproperlyConfigured(
+ "CUS_HELP_PUBLIC_IPV4 must be a valid IPv4 address"
+ ) from error
# P2P calls: opaque invitation lifetime and short-lived signaling/media access.
CUS_CALL_INVITE_TTL_SECONDS = int(os.environ.get("CUS_CALL_INVITE_TTL_SECONDS", str(5 * 60)))
diff --git a/apps/backend/hub_platform/support_portals/domain_services.py b/apps/backend/hub_platform/support_portals/domain_services.py
index 08c2a43..23d1667 100644
--- a/apps/backend/hub_platform/support_portals/domain_services.py
+++ b/apps/backend/hub_platform/support_portals/domain_services.py
@@ -4,6 +4,7 @@ import uuid
import dns.exception
import dns.resolver
+from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils import timezone
@@ -48,6 +49,27 @@ def verify_custom_domain(portal: SupportPortal) -> SupportPortal:
)
if not portal.custom_domain:
raise ValidationError({"customDomain": "Сначала укажите домен"})
+ if settings.CUS_HELP_PUBLIC_IPV4:
+ try:
+ address_answers = dns.resolver.resolve(portal.custom_domain, "A")
+ addresses = {
+ getattr(answer, "address", str(answer).rstrip("."))
+ for answer in address_answers
+ }
+ except (
+ dns.resolver.NoAnswer,
+ dns.resolver.NXDOMAIN,
+ dns.resolver.NoNameservers,
+ dns.exception.Timeout,
+ ) as error:
+ raise ValidationError(
+ {"customDomain": "A-запись домена пока не найдена"}
+ ) from error
+ if settings.CUS_HELP_PUBLIC_IPV4 not in addresses:
+ raise ValidationError(
+ {"customDomain": "A-запись домена указывает не на сервер CustoCRM"}
+ )
+
expected = domain_verification_value(portal)
try:
answers = dns.resolver.resolve(domain_verification_name(portal), "TXT")
diff --git a/apps/backend/hub_platform/support_portals/migrations/0006_support_portal_widget_channel.py b/apps/backend/hub_platform/support_portals/migrations/0006_support_portal_widget_channel.py
new file mode 100644
index 0000000..58c1a01
--- /dev/null
+++ b/apps/backend/hub_platform/support_portals/migrations/0006_support_portal_widget_channel.py
@@ -0,0 +1,20 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+ dependencies = [("support_portals", "0005_custom_domain_verification_token")]
+
+ operations = [
+ migrations.AddField(
+ model_name="supportportal",
+ name="widget_channel",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="support_portal_widgets",
+ to="channels.channel",
+ ),
+ ),
+ ]
diff --git a/apps/backend/hub_platform/support_portals/models.py b/apps/backend/hub_platform/support_portals/models.py
index 0569646..a411f3c 100644
--- a/apps/backend/hub_platform/support_portals/models.py
+++ b/apps/backend/hub_platform/support_portals/models.py
@@ -11,7 +11,7 @@ from hub_platform.support_portals.statuses import ArticleStatus, PortalStatus
class SupportPortal(TenantRelationModel):
"""Публичный Help Center, управляемый внутри отдела поддержки."""
- tenant_relation_fields = ("department",)
+ tenant_relation_fields = ("department", "widget_channel")
department = models.ForeignKey(
"identity.Department",
on_delete=models.PROTECT,
@@ -34,6 +34,13 @@ class SupportPortal(TenantRelationModel):
)
transition_version = models.PositiveIntegerField(default=0)
published_at = models.DateTimeField(null=True, blank=True)
+ widget_channel = models.ForeignKey(
+ "channels.Channel",
+ on_delete=models.PROTECT,
+ related_name="support_portal_widgets",
+ null=True,
+ blank=True,
+ )
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
products = models.ManyToManyField(
@@ -69,6 +76,19 @@ class SupportPortal(TenantRelationModel):
raise ValidationError(
{"department": "Support portal must belong to the support department"}
)
+ if self.widget_channel_id is not None:
+ channel = self.widget_channel
+ if channel.department_id is None or channel.department.code != "support":
+ raise ValidationError(
+ {"widget_channel": "Portal widget must belong to the support department"}
+ )
+ if (
+ channel.requires_authenticated_product_identity
+ or not channel.allow_anonymous_sessions
+ ):
+ raise ValidationError(
+ {"widget_channel": "Portal widget must allow anonymous web sessions"}
+ )
class SupportPortalProduct(TenantRelationModel):
diff --git a/apps/backend/hub_platform/support_portals/portal_services.py b/apps/backend/hub_platform/support_portals/portal_services.py
index 863bcfc..49b22a1 100644
--- a/apps/backend/hub_platform/support_portals/portal_services.py
+++ b/apps/backend/hub_platform/support_portals/portal_services.py
@@ -6,6 +6,7 @@ from django.utils import timezone
from hub_platform.channels.models import Channel
from hub_platform.identity.models import Department
+from hub_platform.integrations.models import IntegrationProvider, IntegrationStatus
from hub_platform.products.models import Product
from hub_platform.subscriptions.keys import EntitlementKey, QuotaKey
from hub_platform.subscriptions.policy import require_entitlement
@@ -24,6 +25,7 @@ class PortalInput:
slug: str
name: str
default_locale: str = "ru"
+ widget_channel_id: int | None = None
@transaction.atomic
@@ -40,6 +42,7 @@ def create_portal(*, context: TenantContext, data: PortalInput) -> SupportPortal
hosted_domain=hosted_domain(data.slug.strip().lower()),
name=data.name.strip(),
default_locale=data.default_locale.strip().lower() or "ru",
+ widget_channel=_widget_channel(context, data.widget_channel_id),
)
portal.full_clean()
portal.save()
@@ -65,11 +68,41 @@ def update_portal(
portal.hosted_domain = hosted_domain(portal.slug)
portal.name = data.name.strip()
portal.default_locale = data.default_locale.strip().lower() or "ru"
+ portal.widget_channel = _widget_channel(context, data.widget_channel_id)
portal.full_clean()
portal.save()
return portal
+def _widget_channel(
+ context: TenantContext,
+ channel_id: int | None,
+) -> Channel | None:
+ if channel_id is None:
+ return None
+ try:
+ return (
+ Channel.objects.select_related("department")
+ .filter(
+ id=channel_id,
+ organization=context.organization,
+ department__code="support",
+ is_active=True,
+ requires_authenticated_product_identity=False,
+ allow_anonymous_sessions=True,
+ connections__provider=IntegrationProvider.WEB,
+ connections__status=IntegrationStatus.OK,
+ connections__is_active=True,
+ )
+ .distinct()
+ .get()
+ )
+ except Channel.DoesNotExist as error:
+ raise ValidationError(
+ {"widgetChannelId": "Активный Web-виджет поддержки не найден"}
+ ) from error
+
+
@transaction.atomic
def set_portal_status(
*, context: TenantContext, portal: SupportPortal, status: str
diff --git a/apps/backend/hub_platform/support_portals/portal_views.py b/apps/backend/hub_platform/support_portals/portal_views.py
index 691db86..2c7cb41 100644
--- a/apps/backend/hub_platform/support_portals/portal_views.py
+++ b/apps/backend/hub_platform/support_portals/portal_views.py
@@ -8,6 +8,7 @@ from hub_platform.api.permissions import HasCapability, HasEntitlement
from hub_platform.channels.models import Channel
from hub_platform.channels.serializers import channel_payload
from hub_platform.identity.audit import record_audit_event
+from hub_platform.integrations.models import IntegrationProvider, IntegrationStatus
from hub_platform.support_portals.api import validation_response
from hub_platform.support_portals.models import SupportPortal
from hub_platform.support_portals.domain_services import (
@@ -28,6 +29,17 @@ from hub_platform.subscriptions.keys import QuotaKey
from hub_platform.subscriptions.policy import get_effective_policy
+def _optional_id(value) -> int | None:
+ if value in (None, ""):
+ return None
+ try:
+ return int(value)
+ except (TypeError, ValueError) as error:
+ raise ValidationError(
+ {"widgetChannelId": "Канал веб-виджета не найден"}
+ ) from error
+
+
class PortalBaseView(APIView):
permission_classes = [HasEntitlement, HasCapability]
required_entitlement = "support_department"
@@ -58,6 +70,15 @@ def _input(request: Request, current: SupportPortal | None = None) -> PortalInpu
current.default_locale if current else "ru",
)
),
+ widget_channel_id=(
+ _optional_id(request.data["widgetChannelId"])
+ if request.data.get("widgetChannelId") not in (None, "")
+ else (
+ current.widget_channel_id
+ if current is not None and "widgetChannelId" not in request.data
+ else None
+ )
+ ),
)
@@ -198,7 +219,29 @@ class PortalSupportChannelsView(PortalBaseView):
)
.order_by("product__name", "name", "id")
)
- return Response({"items": [channel_payload(channel) for channel in channels]})
+ widget_channels = (
+ Channel.objects.select_related("department", "product")
+ .filter(
+ organization=request.tenant_context.organization,
+ department__code="support",
+ is_active=True,
+ requires_authenticated_product_identity=False,
+ allow_anonymous_sessions=True,
+ connections__provider=IntegrationProvider.WEB,
+ connections__status=IntegrationStatus.OK,
+ connections__is_active=True,
+ )
+ .distinct()
+ .order_by("name", "id")
+ )
+ return Response(
+ {
+ "items": [channel_payload(channel) for channel in channels],
+ "widgetItems": [
+ channel_payload(channel) for channel in widget_channels
+ ],
+ }
+ )
class PortalDomainView(PortalBaseView):
diff --git a/apps/backend/hub_platform/support_portals/selectors.py b/apps/backend/hub_platform/support_portals/selectors.py
index be39652..3b8c970 100644
--- a/apps/backend/hub_platform/support_portals/selectors.py
+++ b/apps/backend/hub_platform/support_portals/selectors.py
@@ -10,7 +10,7 @@ def portals_for_context(context: TenantContext) -> QuerySet[SupportPortal]:
organization=context.organization,
department__code="support",
)
- .select_related("department")
+ .select_related("department", "widget_channel")
.prefetch_related("product_links__product", "product_links__support_channel")
)
diff --git a/apps/backend/hub_platform/support_portals/serializers.py b/apps/backend/hub_platform/support_portals/serializers.py
index 0836183..9c379e3 100644
--- a/apps/backend/hub_platform/support_portals/serializers.py
+++ b/apps/backend/hub_platform/support_portals/serializers.py
@@ -1,3 +1,5 @@
+from django.conf import settings
+
from hub_platform.support_portals.models import (
PortalArticle,
PortalArticleRevision,
@@ -5,6 +7,7 @@ from hub_platform.support_portals.models import (
SupportPortal,
SupportPortalProduct,
)
+from hub_platform.integrations.models import IntegrationProvider, IntegrationStatus
from hub_platform.support_portals.addressing import portal_public_url
from hub_platform.support_portals.domain_services import (
domain_verification_name,
@@ -38,6 +41,15 @@ def portal_payload(portal: SupportPortal) -> dict:
"slug": portal.slug,
"hostedDomain": portal.hosted_domain,
"customDomain": portal.custom_domain or None,
+ "customDomainAddress": (
+ {
+ "name": portal.custom_domain,
+ "type": "A",
+ "value": settings.CUS_HELP_PUBLIC_IPV4,
+ }
+ if portal.custom_domain and settings.CUS_HELP_PUBLIC_IPV4
+ else None
+ ),
"customDomainVerifiedAt": portal.custom_domain_verified_at,
"customDomainVerification": (
{
@@ -53,6 +65,8 @@ def portal_payload(portal: SupportPortal) -> dict:
"defaultLocale": portal.default_locale,
"status": portal.status,
"publishedAt": portal.published_at,
+ "widgetChannelId": portal.widget_channel_id,
+ "widgetChannelCode": _public_widget_channel_code(portal),
"products": [product_link_payload(link) for link in portal.product_links.all()],
"createdAt": portal.created_at,
"updatedAt": portal.updated_at,
@@ -122,6 +136,7 @@ def public_portal_payload(portal: SupportPortal) -> dict:
"slug": portal.slug,
"name": portal.name,
"defaultLocale": portal.default_locale,
+ "webWidgetChannelCode": _public_widget_channel_code(portal),
"products": [
{
"code": link.product.code,
@@ -137,6 +152,23 @@ def public_portal_payload(portal: SupportPortal) -> dict:
}
+def _public_widget_channel_code(portal: SupportPortal) -> str | None:
+ channel = portal.widget_channel
+ if (
+ channel is None
+ or not channel.is_active
+ or channel.requires_authenticated_product_identity
+ or not channel.allow_anonymous_sessions
+ ):
+ return None
+ available = channel.connections.filter(
+ provider=IntegrationProvider.WEB,
+ status=IntegrationStatus.OK,
+ is_active=True,
+ ).exists()
+ return channel.code if available else None
+
+
def public_article_payload(article: PortalArticle, *, content: bool = True) -> dict:
return {
"slug": article.slug,
diff --git a/apps/backend/hub_platform/support_portals/tests/test_management_api.py b/apps/backend/hub_platform/support_portals/tests/test_management_api.py
index 293a793..7bb27c0 100644
--- a/apps/backend/hub_platform/support_portals/tests/test_management_api.py
+++ b/apps/backend/hub_platform/support_portals/tests/test_management_api.py
@@ -14,12 +14,88 @@ from hub_platform.identity.models import (
Organization,
OrganizationMembership,
)
+from hub_platform.integrations.models import (
+ Integration,
+ IntegrationKind,
+ IntegrationProvider,
+ IntegrationStatus,
+)
from hub_platform.subscriptions.testing import create_test_subscription
from hub_platform.support_portals.models import SupportPortal, SupportPortalProduct
from hub_platform.support_portals.tests.base import SupportPortalTestCase
class SupportPortalManagementTests(SupportPortalTestCase):
+ def test_anonymous_web_channel_can_be_attached_as_portal_widget(self) -> None:
+ portal_id = self.create_portal().json()["portal"]["id"]
+ widget_channel = Channel.objects.create(
+ organization=self.organization,
+ code="foxray-portal-chat",
+ name="FoxRay — чат портала",
+ department=self.channel.department,
+ product=self.product,
+ requires_authenticated_product_identity=False,
+ allow_anonymous_sessions=True,
+ allow_self_reported_contact=True,
+ )
+ Integration.objects.create(
+ organization=self.organization,
+ kind=IntegrationKind.MESSENGER,
+ provider=IntegrationProvider.WEB,
+ name="FoxRay portal widget",
+ channel=widget_channel,
+ status=IntegrationStatus.OK,
+ )
+
+ options = self.client.get(
+ f"/api/v1/support/portals/{portal_id}/support-channels/"
+ )
+ self.assertEqual(options.status_code, 200, options.content)
+ self.assertEqual(
+ [item["id"] for item in options.json()["widgetItems"]],
+ [widget_channel.id],
+ )
+
+ updated = self.client.patch(
+ f"/api/v1/support/portals/{portal_id}/",
+ {"widgetChannelId": widget_channel.id},
+ format="json",
+ )
+ self.assertEqual(updated.status_code, 200, updated.content)
+ self.assertEqual(
+ updated.json()["portal"]["widgetChannelCode"],
+ widget_channel.code,
+ )
+
+ config = self.client.get(
+ f"/api/v1/webchat/config/?channel={widget_channel.code}",
+ HTTP_ORIGIN="http://foxray-help.localhost",
+ )
+ self.assertEqual(config.status_code, 200, config.content)
+ self.assertTrue(config.json()["available"])
+
+ def test_authenticated_support_channel_is_rejected_by_public_webchat(self) -> None:
+ Integration.objects.create(
+ organization=self.organization,
+ kind=IntegrationKind.MESSENGER,
+ provider=IntegrationProvider.WEB,
+ name="Authenticated support widget",
+ channel=self.channel,
+ status=IntegrationStatus.OK,
+ )
+
+ config = self.client.get(
+ f"/api/v1/webchat/config/?channel={self.channel.code}",
+ )
+ session = self.client.post(
+ "/api/v1/webchat/session/",
+ {"channel": self.channel.code},
+ format="json",
+ )
+
+ self.assertFalse(config.json()["available"])
+ self.assertEqual(session.status_code, 404, session.content)
+
def test_subscription_allows_multiple_active_portals(self) -> None:
first = self.create_portal()
self.assertEqual(first.status_code, 201, first.content)
@@ -85,7 +161,8 @@ class SupportPortalManagementTests(SupportPortalTestCase):
link.refresh_from_db()
self.assertEqual(link.support_channel, self.channel)
- def test_custom_domain_requires_matching_dns_record(self) -> None:
+ @override_settings(CUS_HELP_PUBLIC_IPV4="203.0.113.42")
+ def test_custom_domain_requires_matching_dns_records(self) -> None:
portal_id = self.create_portal().json()["portal"]["id"]
configured = self.client.put(
f"/api/v1/support/portals/{portal_id}/domain/",
@@ -94,9 +171,23 @@ class SupportPortalManagementTests(SupportPortalTestCase):
)
self.assertEqual(configured.status_code, 200, configured.content)
verification = configured.json()["portal"]["customDomainVerification"]
- answer = mock.Mock(strings=[verification["value"].encode("utf-8")])
+ self.assertEqual(
+ configured.json()["portal"]["customDomainAddress"],
+ {
+ "name": "help.customer.example",
+ "type": "A",
+ "value": "203.0.113.42",
+ },
+ )
+ address_answer = mock.Mock(address="203.0.113.42")
+ verification_answer = mock.Mock(
+ strings=[verification["value"].encode("utf-8")]
+ )
- with mock.patch("dns.resolver.resolve", return_value=[answer]):
+ with mock.patch(
+ "dns.resolver.resolve",
+ side_effect=[[address_answer], [verification_answer]],
+ ) as resolve:
verified = self.client.post(
f"/api/v1/support/portals/{portal_id}/domain/verify/",
{},
@@ -106,6 +197,35 @@ class SupportPortalManagementTests(SupportPortalTestCase):
self.assertEqual(verified.status_code, 200, verified.content)
self.assertIsNotNone(verified.json()["portal"]["customDomainVerifiedAt"])
self.assertIsNone(verified.json()["portal"]["customDomainVerification"])
+ self.assertEqual(
+ resolve.call_args_list,
+ [
+ mock.call("help.customer.example", "A"),
+ mock.call("_custocrm.help.customer.example", "TXT"),
+ ],
+ )
+
+ @override_settings(CUS_HELP_PUBLIC_IPV4="203.0.113.42")
+ def test_custom_domain_rejects_wrong_address_record(self) -> None:
+ portal_id = self.create_portal().json()["portal"]["id"]
+ self.client.put(
+ f"/api/v1/support/portals/{portal_id}/domain/",
+ {"customDomain": "help.customer.example"},
+ format="json",
+ )
+
+ with mock.patch(
+ "dns.resolver.resolve",
+ return_value=[mock.Mock(address="203.0.113.99")],
+ ):
+ response = self.client.post(
+ f"/api/v1/support/portals/{portal_id}/domain/verify/",
+ {},
+ format="json",
+ )
+
+ self.assertEqual(response.status_code, 400, response.content)
+ self.assertIn("указывает не на сервер CustoCRM", str(response.json()))
@override_settings(CUS_APP_PRIMARY_HOSTS=["app.customer.example"])
def test_custom_domain_cannot_shadow_application_host(self) -> None:
diff --git a/apps/backend/hub_platform/support_portals/tests/test_public_api.py b/apps/backend/hub_platform/support_portals/tests/test_public_api.py
index 87729a5..708bc69 100644
--- a/apps/backend/hub_platform/support_portals/tests/test_public_api.py
+++ b/apps/backend/hub_platform/support_portals/tests/test_public_api.py
@@ -2,6 +2,13 @@ from django.test import TestCase, override_settings
from django.utils import timezone
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
+from hub_platform.channels.models import Channel
+from hub_platform.integrations.models import (
+ Integration,
+ IntegrationKind,
+ IntegrationProvider,
+ IntegrationStatus,
+)
from hub_platform.support_portals.models import PortalArticleFeedback, SupportPortal
from hub_platform.testing import TenantAPIClient
@@ -21,6 +28,27 @@ class PublicSupportPortalTests(TestCase):
).json()["portal"]
self.portal_id = portal["id"]
self.portal_host = portal["hostedDomain"]
+ widget_channel = Channel.objects.create(
+ organization=result.organization,
+ code="edevs-help-chat",
+ name="Edevs Help — чат",
+ department=result.support_department,
+ requires_authenticated_product_identity=False,
+ allow_anonymous_sessions=True,
+ )
+ Integration.objects.create(
+ organization=result.organization,
+ kind=IntegrationKind.MESSENGER,
+ provider=IntegrationProvider.WEB,
+ name="Edevs Help widget",
+ channel=widget_channel,
+ status=IntegrationStatus.OK,
+ )
+ self.client.patch(
+ f"/api/v1/support/portals/{self.portal_id}/",
+ {"widgetChannelId": widget_channel.id},
+ format="json",
+ )
category = self.client.post(
f"/api/v1/support/portals/{self.portal_id}/categories/",
{
@@ -71,6 +99,10 @@ class PublicSupportPortalTests(TestCase):
portal = self.client.get("/api/v1/help/", HTTP_HOST=self.portal_host)
self.assertEqual(portal.status_code, 200, portal.content)
self.assertEqual(portal.json()["portal"]["name"], "Edevs Help")
+ self.assertEqual(
+ portal.json()["portal"]["webWidgetChannelCode"],
+ "edevs-help-chat",
+ )
self.assertEqual(
portal.json()["categories"][0]["description"],
"Быстрый старт и основные возможности",
@@ -128,3 +160,33 @@ class PublicSupportPortalTests(TestCase):
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response.json()["portal"]["name"], "Edevs Help")
+
+ @override_settings(ROOT_URLCONF="hub_backend.urls_platform")
+ def test_gateway_authorizes_only_published_portal_domains(self) -> None:
+ custom_domain = "help.foxray.example"
+ SupportPortal.objects.filter(id=self.portal_id).update(
+ status="PUBLISHED",
+ custom_domain=custom_domain,
+ )
+
+ hosted = self.client.get(
+ "/api/v1/gateway/help-domain/",
+ {"domain": self.portal_host},
+ )
+ unverified = self.client.get(
+ "/api/v1/gateway/help-domain/",
+ {"domain": custom_domain},
+ )
+
+ self.assertEqual(hosted.status_code, 204, hosted.content)
+ self.assertEqual(unverified.status_code, 404, unverified.content)
+
+ SupportPortal.objects.filter(id=self.portal_id).update(
+ custom_domain_verified_at=timezone.now(),
+ )
+ verified = self.client.get(
+ "/api/v1/gateway/help-domain/",
+ {"domain": custom_domain},
+ )
+
+ self.assertEqual(verified.status_code, 204, verified.content)
diff --git a/apps/backend/hub_platform/webchat/views.py b/apps/backend/hub_platform/webchat/views.py
index a132ad4..aee48d1 100644
--- a/apps/backend/hub_platform/webchat/views.py
+++ b/apps/backend/hub_platform/webchat/views.py
@@ -51,6 +51,7 @@ def _resolved_web_connection(channel_code: str):
id=route.resource_id,
organization=organization,
provider=IntegrationProvider.WEB,
+ is_active=True,
channel__organization=organization,
channel__code=channel_code,
channel__is_active=True,
@@ -86,6 +87,11 @@ class WebchatConfigView(_Public):
with _resolved_web_connection(channel_code) as (context, integration):
if context is None or integration is None:
return Response({"available": False})
+ if (
+ integration.channel.requires_authenticated_product_identity
+ or not integration.channel.allow_anonymous_sessions
+ ):
+ return Response({"available": False})
return Response(
services.public_config(
context=context,
@@ -101,6 +107,11 @@ class WebchatSessionView(_Public):
with _resolved_web_connection(channel_code) as (context, integration):
if context is None or integration is None:
return Response({"detail": "Канал недоступен"}, status=404)
+ if (
+ integration.channel.requires_authenticated_product_identity
+ or not integration.channel.allow_anonymous_sessions
+ ):
+ return Response({"detail": "Канал недоступен"}, status=404)
result = services.issue_session(context=context, integration=integration)
if result is None:
return Response({"detail": "Канал недоступен"}, status=404)
diff --git a/apps/internal-ui/src/features/help-center/HelpLayout.tsx b/apps/internal-ui/src/features/help-center/HelpLayout.tsx
index 8df7fbe..82332ef 100644
--- a/apps/internal-ui/src/features/help-center/HelpLayout.tsx
+++ b/apps/internal-ui/src/features/help-center/HelpLayout.tsx
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import { LogoIcon } from "../../shared/icons";
import { HelpSearch } from "./HelpSearch";
+import { PortalWebWidget } from "./PortalWebWidget";
import { SupportLauncher } from "./SupportLauncher";
import type { HelpPortal } from "./types";
@@ -52,7 +53,9 @@ export function HelpLayout({
База знаний и поддержка
-
+ {portal.webWidgetChannelCode
+ ?
+ : }
);
}
diff --git a/apps/internal-ui/src/features/help-center/PortalWebWidget.tsx b/apps/internal-ui/src/features/help-center/PortalWebWidget.tsx
new file mode 100644
index 0000000..eb30c31
--- /dev/null
+++ b/apps/internal-ui/src/features/help-center/PortalWebWidget.tsx
@@ -0,0 +1,17 @@
+import { useEffect } from "react";
+
+const SCRIPT_ID = "custocrm-help-widget-loader";
+
+export function PortalWebWidget({ channelCode }: { channelCode: string }) {
+ useEffect(() => {
+ if (document.getElementById(SCRIPT_ID)) return;
+ const script = document.createElement("script");
+ script.id = SCRIPT_ID;
+ script.src = "/chat-widget.js";
+ script.async = true;
+ script.dataset.channel = channelCode;
+ document.body.appendChild(script);
+ }, [channelCode]);
+
+ return null;
+}
diff --git a/apps/internal-ui/src/features/help-center/types.ts b/apps/internal-ui/src/features/help-center/types.ts
index 9818a25..212bdc5 100644
--- a/apps/internal-ui/src/features/help-center/types.ts
+++ b/apps/internal-ui/src/features/help-center/types.ts
@@ -38,6 +38,7 @@ export type HelpPortal = {
slug: string;
name: string;
defaultLocale: string;
+ webWidgetChannelCode: string | null;
products: HelpProduct[];
};
diff --git a/apps/internal-ui/src/features/support-portals/PortalArticleTable.tsx b/apps/internal-ui/src/features/support-portals/PortalArticleTable.tsx
new file mode 100644
index 0000000..8ea38a5
--- /dev/null
+++ b/apps/internal-ui/src/features/support-portals/PortalArticleTable.tsx
@@ -0,0 +1,77 @@
+import { Dropdown } from "antd";
+
+import { Icon } from "../../shared/icons";
+import { StatusPill } from "../../shared/ui";
+import { formatDate } from "../../shared/utils";
+import type { PortalArticle } from "./model";
+
+export function PortalArticleTable({
+ articles,
+ canManage,
+ onArchive,
+ onEdit,
+}: {
+ articles: PortalArticle[];
+ canManage: boolean;
+ onArchive: (article: PortalArticle) => void;
+ onEdit: (article: PortalArticle) => void;
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ | СТАТЬЯ | РАЗДЕЛ | ЯЗЫК | ВЕРСИЯ | СТАТУС | ОБНОВЛЕНО | |
+
+ {articles.map((article) => {
+ const title = article.latestRevision?.title || article.slug;
+ const menuItems = [
+ {
+ key: "edit",
+ label: ,
+ },
+ ...(article.status !== "ARCHIVED" ? [{
+ key: "archive",
+ label: ,
+ }] : []),
+ ];
+ return (
+ onEdit(article)}>
+ |
+
+ {article.latestRevision?.summary || article.slug}
+ |
+ {article.category.name} |
+ {article.locale.toLocaleUpperCase()} |
+ {article.publishedRevision ? article.publishedRevision.revision : "—"} |
+ |
+ {formatDate(article.updatedAt)} |
+ event.stopPropagation()}>
+ {canManage && (
+
+
+
+ )}
+ |
+
+ );
+ })}
+
+
+ );
+}
diff --git a/apps/internal-ui/src/features/support-portals/PortalContent.tsx b/apps/internal-ui/src/features/support-portals/PortalContent.tsx
index 221d093..f750cb0 100644
--- a/apps/internal-ui/src/features/support-portals/PortalContent.tsx
+++ b/apps/internal-ui/src/features/support-portals/PortalContent.tsx
@@ -1,15 +1,12 @@
-import { Dropdown } from "antd";
import { useMemo, useState } from "react";
import { CategoryTree } from "../../shared/content-library/CategoryTree";
import { ContentLibraryTable } from "../../shared/content-library/ContentLibraryTable";
import { ContentLibraryToolbar } from "../../shared/content-library/ContentLibraryToolbar";
import { DecisionDialog } from "../../shared/DecisionDialog";
-import { Icon } from "../../shared/icons";
-import { StatusPill } from "../../shared/ui";
import { Button } from "../../shared/ui-controls";
-import { formatDate } from "../../shared/utils";
import { PortalArticleEditor } from "./PortalArticleEditor";
+import { PortalArticleTable } from "./PortalArticleTable";
import { PortalCategoryManagement } from "./PortalCategoryManagement";
import {
archivePortalArticle,
@@ -150,35 +147,12 @@ export function PortalContent({
onRetry={() => void reload()}
footer={{filtered.length} материалов
}
>
-
- | СТАТЬЯ | РАЗДЕЛ | ЯЗЫК | ВЕРСИЯ | СТАТУС | ОБНОВЛЕНО | |
-
- {filtered.map((article) => (
- setEditing(article)}>
- | {article.latestRevision?.summary || article.slug} |
- {article.category.name} |
- {article.locale.toLocaleUpperCase()} |
- {article.publishedRevision ? article.publishedRevision.revision : "—"} |
- |
- {formatDate(article.updatedAt)} |
- event.stopPropagation()}>
- {canManage && (
- setEditing(article) },
- ...(article.status !== "ARCHIVED" ? [{ key: "archive", danger: true, label: "В архив", onClick: () => setDecision({ article, type: "archive" as const }) }] : []),
- ] }}
- >
-
-
- )}
- |
-
- ))}
-
-
+ setDecision({ article, type: "archive" })}
+ onEdit={setEditing}
+ />
diff --git a/apps/internal-ui/src/features/support-portals/PortalDomainSettings.tsx b/apps/internal-ui/src/features/support-portals/PortalDomainSettings.tsx
index 3b74595..fc307e5 100644
--- a/apps/internal-ui/src/features/support-portals/PortalDomainSettings.tsx
+++ b/apps/internal-ui/src/features/support-portals/PortalDomainSettings.tsx
@@ -30,7 +30,7 @@ export function PortalDomainSettings({
try {
const payload = await setPortalCustomDomain(portal.id, domain);
onChanged(payload.portal);
- setFeedback(domain.trim() ? "Домен сохранён. Добавьте TXT-запись для подтверждения." : "Свой домен отключён");
+ setFeedback(domain.trim() ? "Домен сохранён. Добавьте показанные DNS-записи и проверьте домен." : "Свой домен отключён");
} catch (error) {
setFeedback(portalErrorMessage(error, "Не удалось сохранить домен"));
} finally {
@@ -68,14 +68,29 @@ export function PortalDomainSettings({
wide
/>
- {portal.customDomainVerification && (
+ {(portal.customDomainAddress || portal.customDomainVerification) && (
-
Добавьте DNS-запись
-
- - Тип
- {portal.customDomainVerification.type}
- - Имя
{portal.customDomainVerification.name}
- - Значение
{portal.customDomainVerification.value}
-
+
DNS-записи для {portal.customDomain}
+ {portal.customDomainAddress && (
+
+
A-запись
+
+ - Тип
- {portal.customDomainAddress.type}
+ - Имя
{portal.customDomainAddress.name}
+ - Значение
{portal.customDomainAddress.value}
+
+
+ )}
+ {portal.customDomainVerification && (
+
+
TXT-запись
+
+ - Тип
- {portal.customDomainVerification.type}
+ - Имя
{portal.customDomainVerification.name}
+ - Значение
{portal.customDomainVerification.value}
+
+
+ )}
)}
diff --git a/apps/internal-ui/src/features/support-portals/PortalProductSettings.tsx b/apps/internal-ui/src/features/support-portals/PortalProductSettings.tsx
new file mode 100644
index 0000000..e635d61
--- /dev/null
+++ b/apps/internal-ui/src/features/support-portals/PortalProductSettings.tsx
@@ -0,0 +1,116 @@
+import { useEffect, useMemo, useState } from "react";
+
+import type { Channel } from "../channels/types";
+import { SelectField, SwitchButton } from "../../shared/form-controls";
+import { Button } from "../../shared/ui-controls";
+import type { Product } from "../../types";
+import {
+ portalErrorMessage,
+ replacePortalProducts,
+ type SupportPortal,
+} from "./model";
+
+export function PortalProductSettings({
+ portal,
+ products,
+ channels,
+ canManage,
+ onChanged,
+}: {
+ portal: SupportPortal;
+ products: Product[];
+ channels: Channel[];
+ canManage: boolean;
+ onChanged: (portal: SupportPortal) => void;
+}) {
+ const [selected, setSelected] = useState
>({});
+ const [busy, setBusy] = useState(false);
+ const [feedback, setFeedback] = useState("");
+
+ useEffect(() => {
+ setSelected(Object.fromEntries(
+ portal.products.map((item) => [item.productId, item.supportChannelId]),
+ ));
+ }, [portal.products]);
+
+ const supportChannels = useMemo(() => channels.filter((channel) => (
+ channel.isActive
+ && channel.department === "support"
+ && channel.product
+ && channel.policy.requiresAuthenticatedProductIdentity
+ && !channel.policy.allowAnonymousSessions
+ )), [channels]);
+
+ function toggleProduct(productId: number) {
+ setSelected((current) => {
+ const next = { ...current };
+ if (productId in next) delete next[productId];
+ else next[productId] = null;
+ return next;
+ });
+ }
+
+ async function save() {
+ setBusy(true);
+ setFeedback("");
+ try {
+ const items = Object.entries(selected).map(([productId, supportChannelId], index) => ({
+ productId: Number(productId),
+ supportChannelId,
+ sortOrder: index,
+ }));
+ const payload = await replacePortalProducts(portal.id, items);
+ onChanged(payload.portal);
+ setFeedback("Продукты портала обновлены");
+ } catch (caught) {
+ setFeedback(portalErrorMessage(caught, "Не удалось сохранить продукты"));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+
Продукты и поддержка
Выберите продукты и каналы, в которых клиенты смогут обратиться в поддержку.
+
+
+ {products.map((product) => {
+ const enabled = product.id in selected;
+ const options: Array<[string, string]> = [
+ ["", "Без перехода в поддержку"],
+ ...supportChannels
+ .filter((channel) => channel.product?.id === product.id)
+ .map((channel): [string, string] => [String(channel.id), channel.name]),
+ ];
+ return (
+
+ toggleProduct(product.id)}
+ />
+ {product.name}{product.code}
+ {enabled && (
+ setSelected((current) => ({
+ ...current,
+ [product.id]: value ? Number(value) : null,
+ }))}
+ options={options}
+ />
+ )}
+
+ );
+ })}
+
+ {canManage && }
+ {feedback && {feedback}
}
+
+ );
+}
diff --git a/apps/internal-ui/src/features/support-portals/PortalSettings.tsx b/apps/internal-ui/src/features/support-portals/PortalSettings.tsx
index fc726f1..7bee120 100644
--- a/apps/internal-ui/src/features/support-portals/PortalSettings.tsx
+++ b/apps/internal-ui/src/features/support-portals/PortalSettings.tsx
@@ -1,17 +1,18 @@
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useState } from "react";
import type { Channel } from "../channels/types";
-import { FormField, SelectField, SwitchButton } from "../../shared/form-controls";
+import { FormField, SelectField } from "../../shared/form-controls";
import { Button } from "../../shared/ui-controls";
import type { Product } from "../../types";
import {
listPortalSupportChannels,
portalErrorMessage,
- replacePortalProducts,
updateSupportPortal,
type SupportPortal,
} from "./model";
import { PortalDomainSettings } from "./PortalDomainSettings";
+import { PortalProductSettings } from "./PortalProductSettings";
+import { PortalWidgetSettings } from "./PortalWidgetSettings";
export function PortalSettings({
portal,
@@ -28,7 +29,7 @@ export function PortalSettings({
const [slug, setSlug] = useState(portal.slug);
const [locale, setLocale] = useState(portal.defaultLocale);
const [channels, setChannels] = useState([]);
- const [selected, setSelected] = useState>({});
+ const [widgetChannels, setWidgetChannels] = useState([]);
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState("");
@@ -36,25 +37,20 @@ export function PortalSettings({
setName(portal.name);
setSlug(portal.slug);
setLocale(portal.defaultLocale);
- setSelected(Object.fromEntries(
- portal.products.map((item) => [item.productId, item.supportChannelId]),
- ));
}, [portal]);
useEffect(() => {
listPortalSupportChannels(portal.id)
- .then((payload) => setChannels(payload.items))
- .catch(() => setChannels([]));
+ .then((payload) => {
+ setChannels(payload.items);
+ setWidgetChannels(payload.widgetItems);
+ })
+ .catch(() => {
+ setChannels([]);
+ setWidgetChannels([]);
+ });
}, [portal.id]);
- const supportChannels = useMemo(() => channels.filter((channel) => (
- channel.isActive
- && channel.department === "support"
- && channel.product
- && channel.policy.requiresAuthenticatedProductIdentity
- && !channel.policy.allowAnonymousSessions
- )), [channels]);
-
async function saveBasics() {
setBusy(true);
setFeedback("");
@@ -73,34 +69,6 @@ export function PortalSettings({
}
}
- async function saveProducts() {
- setBusy(true);
- setFeedback("");
- try {
- const items = Object.entries(selected).map(([productId, supportChannelId], index) => ({
- productId: Number(productId),
- supportChannelId,
- sortOrder: index,
- }));
- const payload = await replacePortalProducts(portal.id, items);
- onChanged(payload.portal);
- setFeedback("Продукты портала обновлены");
- } catch (caught) {
- setFeedback(portalErrorMessage(caught, "Не удалось сохранить продукты"));
- } finally {
- setBusy(false);
- }
- }
-
- function toggleProduct(productId: number) {
- setSelected((current) => {
- const next = { ...current };
- if (productId in next) delete next[productId];
- else next[productId] = null;
- return next;
- });
- }
-
return (
@@ -122,47 +90,19 @@ export function PortalSettings({
{canManage && }
-
-
-
Продукты и поддержка
Выберите продукты и каналы, в которых клиенты смогут обратиться в поддержку.
-
-
- {products.map((product) => {
- const enabled = product.id in selected;
- const options: Array<[string, string]> = [
- ["", "Без перехода в поддержку"],
- ...supportChannels
- .filter((channel) => channel.product?.id === product.id)
- .map((channel): [string, string] => [String(channel.id), channel.name]),
- ];
- return (
-
- toggleProduct(product.id)}
- />
- {product.name}{product.code}
- {enabled && (
- setSelected((current) => ({
- ...current,
- [product.id]: value ? Number(value) : null,
- }))}
- options={options}
- />
- )}
-
- );
- })}
-
- {canManage && }
-
+
+
{feedback &&
{feedback}
}
diff --git a/apps/internal-ui/src/features/support-portals/PortalWidgetSettings.tsx b/apps/internal-ui/src/features/support-portals/PortalWidgetSettings.tsx
new file mode 100644
index 0000000..731bac4
--- /dev/null
+++ b/apps/internal-ui/src/features/support-portals/PortalWidgetSettings.tsx
@@ -0,0 +1,76 @@
+import { useEffect, useState } from "react";
+
+import type { Channel } from "../channels/types";
+import { SelectField } from "../../shared/form-controls";
+import { Button } from "../../shared/ui-controls";
+import {
+ portalErrorMessage,
+ updateSupportPortal,
+ type SupportPortal,
+} from "./model";
+
+export function PortalWidgetSettings({
+ canManage,
+ channels,
+ portal,
+ onChanged,
+}: {
+ canManage: boolean;
+ channels: Channel[];
+ portal: SupportPortal;
+ onChanged: (portal: SupportPortal) => void;
+}) {
+ const [channelId, setChannelId] = useState(portal.widgetChannelId);
+ const [busy, setBusy] = useState(false);
+ const [feedback, setFeedback] = useState("");
+
+ useEffect(() => setChannelId(portal.widgetChannelId), [portal.widgetChannelId]);
+
+ async function save() {
+ setBusy(true);
+ setFeedback("");
+ try {
+ const payload = await updateSupportPortal(portal.id, {
+ widgetChannelId: channelId,
+ });
+ onChanged(payload.portal);
+ setFeedback("Веб-виджет обновлён");
+ } catch (caught) {
+ setFeedback(portalErrorMessage(caught, "Не удалось сохранить веб-виджет"));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+
+
Веб-виджет
+
Публичный чат отображается на всех страницах портала.
+
+
+
+ setChannelId(value ? Number(value) : null)}
+ options={[
+ ["", "Не показывать"],
+ ...channels.map((channel): [string, string] => [
+ String(channel.id),
+ channel.name,
+ ]),
+ ]}
+ />
+
+ {canManage && (
+
+ )}
+ {feedback && {feedback}
}
+
+ );
+}
diff --git a/apps/internal-ui/src/features/support-portals/api.ts b/apps/internal-ui/src/features/support-portals/api.ts
new file mode 100644
index 0000000..675e1a0
--- /dev/null
+++ b/apps/internal-ui/src/features/support-portals/api.ts
@@ -0,0 +1,184 @@
+import { api } from "../../api/client";
+import type { Channel } from "../channels/types";
+import type {
+ ArticleRevision,
+ PortalArticle,
+ PortalCategory,
+ PortalInput,
+ PortalStatus,
+ SupportPortal,
+ SupportPortalList,
+} from "./model";
+
+export function listSupportPortals(): Promise {
+ return api("/api/v1/support/portals/");
+}
+
+export function createSupportPortal(input: PortalInput): Promise<{ portal: SupportPortal }> {
+ return api("/api/v1/support/portals/", {
+ method: "POST",
+ body: JSON.stringify(input),
+ });
+}
+
+export function loadSupportPortal(id: number): Promise<{ portal: SupportPortal }> {
+ return api(`/api/v1/support/portals/${id}/`);
+}
+
+export function updateSupportPortal(
+ id: number,
+ input: Partial,
+): Promise<{ portal: SupportPortal }> {
+ return api(`/api/v1/support/portals/${id}/`, {
+ method: "PATCH",
+ body: JSON.stringify(input),
+ });
+}
+
+export function changePortalStatus(
+ id: number,
+ status: PortalStatus,
+): Promise<{ portal: SupportPortal }> {
+ return api(`/api/v1/support/portals/${id}/status/`, {
+ method: "POST",
+ body: JSON.stringify({ status }),
+ });
+}
+
+export function replacePortalProducts(
+ id: number,
+ items: Array<{ productId: number; supportChannelId: number | null; sortOrder: number }>,
+): Promise<{ portal: SupportPortal }> {
+ return api(`/api/v1/support/portals/${id}/products/`, {
+ method: "PUT",
+ body: JSON.stringify({ items }),
+ });
+}
+
+export function listPortalCategories(id: number): Promise<{ items: PortalCategory[] }> {
+ return api(`/api/v1/support/portals/${id}/categories/`);
+}
+
+export function createPortalCategory(
+ id: number,
+ input: Pick & Partial>,
+): Promise<{ category: PortalCategory }> {
+ return api(`/api/v1/support/portals/${id}/categories/`, {
+ method: "POST",
+ body: JSON.stringify(input),
+ });
+}
+
+export function updatePortalCategory(
+ portalId: number,
+ categoryId: number,
+ input: Partial>,
+): Promise<{ category: PortalCategory }> {
+ return api(`/api/v1/support/portals/${portalId}/categories/${categoryId}/`, {
+ method: "PATCH",
+ body: JSON.stringify(input),
+ });
+}
+
+export function deletePortalCategory(
+ portalId: number,
+ categoryId: number,
+): Promise {
+ return api(`/api/v1/support/portals/${portalId}/categories/${categoryId}/`, {
+ method: "DELETE",
+ });
+}
+
+export function listPortalSupportChannels(
+ portalId: number,
+): Promise<{ items: Channel[]; widgetItems: Channel[] }> {
+ return api(`/api/v1/support/portals/${portalId}/support-channels/`);
+}
+
+export function setPortalCustomDomain(
+ portalId: number,
+ customDomain: string,
+): Promise<{ portal: SupportPortal }> {
+ return api(`/api/v1/support/portals/${portalId}/domain/`, {
+ method: "PUT",
+ body: JSON.stringify({ customDomain }),
+ });
+}
+
+export function verifyPortalCustomDomain(
+ portalId: number,
+): Promise<{ portal: SupportPortal }> {
+ return api(`/api/v1/support/portals/${portalId}/domain/verify/`, {
+ method: "POST",
+ });
+}
+
+export function listPortalArticles(id: number): Promise<{ items: PortalArticle[] }> {
+ return api(`/api/v1/support/portals/${id}/articles/`);
+}
+
+export function createPortalArticle(
+ portalId: number,
+ input: {
+ categoryId: number;
+ slug: string;
+ locale: string;
+ title: string;
+ summary: string;
+ content: string;
+ },
+): Promise<{ article: PortalArticle }> {
+ return api(`/api/v1/support/portals/${portalId}/articles/`, {
+ method: "POST",
+ body: JSON.stringify(input),
+ });
+}
+
+export function loadPortalArticle(
+ portalId: number,
+ articleId: number,
+): Promise<{ article: PortalArticle }> {
+ return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/`);
+}
+
+export function updatePortalArticle(
+ portalId: number,
+ articleId: number,
+ input: { categoryId: number; locale: string; slug: string },
+): Promise<{ article: PortalArticle }> {
+ return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/`, {
+ method: "PATCH",
+ body: JSON.stringify(input),
+ });
+}
+
+export function addArticleRevision(
+ portalId: number,
+ articleId: number,
+ input: Pick,
+): Promise<{ revision: ArticleRevision }> {
+ return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/revisions/`, {
+ method: "POST",
+ body: JSON.stringify(input),
+ });
+}
+
+export function publishArticleRevision(
+ portalId: number,
+ articleId: number,
+ revisionId: number,
+): Promise<{ article: PortalArticle }> {
+ return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/publish/`, {
+ method: "POST",
+ body: JSON.stringify({ revisionId }),
+ });
+}
+
+export function archivePortalArticle(
+ portalId: number,
+ articleId: number,
+): Promise<{ article: PortalArticle }> {
+ return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/archive/`, {
+ method: "POST",
+ });
+}
diff --git a/apps/internal-ui/src/features/support-portals/model.ts b/apps/internal-ui/src/features/support-portals/model.ts
index c9f42ab..8cdaa0e 100644
--- a/apps/internal-ui/src/features/support-portals/model.ts
+++ b/apps/internal-ui/src/features/support-portals/model.ts
@@ -1,5 +1,4 @@
-import { api, ApiError } from "../../api/client";
-import type { Channel } from "../channels/types";
+import { ApiError } from "../../api/client";
export type PortalStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
export type ArticleStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
@@ -20,6 +19,11 @@ export type SupportPortal = {
slug: string;
hostedDomain: string;
customDomain: string | null;
+ customDomainAddress: {
+ name: string;
+ type: "A";
+ value: string;
+ } | null;
customDomainVerifiedAt: string | null;
customDomainVerification: {
name: string;
@@ -31,6 +35,8 @@ export type SupportPortal = {
defaultLocale: string;
status: PortalStatus;
publishedAt: string | null;
+ widgetChannelId: number | null;
+ widgetChannelCode: string | null;
products: PortalProductLink[];
createdAt: string;
updatedAt: string;
@@ -73,6 +79,7 @@ export type PortalInput = {
slug: string;
name: string;
defaultLocale: string;
+ widgetChannelId?: number | null;
};
export type PortalCreationPolicy = {
@@ -94,178 +101,28 @@ export type SupportPortalList = {
address: PortalAddressConfig;
};
-export function listSupportPortals(): Promise {
- return api("/api/v1/support/portals/");
-}
-
-export function createSupportPortal(input: PortalInput): Promise<{ portal: SupportPortal }> {
- return api("/api/v1/support/portals/", {
- method: "POST",
- body: JSON.stringify(input),
- });
-}
-
-export function loadSupportPortal(id: number): Promise<{ portal: SupportPortal }> {
- return api(`/api/v1/support/portals/${id}/`);
-}
-
-export function updateSupportPortal(
- id: number,
- input: PortalInput,
-): Promise<{ portal: SupportPortal }> {
- return api(`/api/v1/support/portals/${id}/`, {
- method: "PATCH",
- body: JSON.stringify(input),
- });
-}
-
-export function changePortalStatus(
- id: number,
- status: PortalStatus,
-): Promise<{ portal: SupportPortal }> {
- return api(`/api/v1/support/portals/${id}/status/`, {
- method: "POST",
- body: JSON.stringify({ status }),
- });
-}
-
-export function replacePortalProducts(
- id: number,
- items: Array<{ productId: number; supportChannelId: number | null; sortOrder: number }>,
-): Promise<{ portal: SupportPortal }> {
- return api(`/api/v1/support/portals/${id}/products/`, {
- method: "PUT",
- body: JSON.stringify({ items }),
- });
-}
-
-export function listPortalCategories(id: number): Promise<{ items: PortalCategory[] }> {
- return api(`/api/v1/support/portals/${id}/categories/`);
-}
-
-export function createPortalCategory(
- id: number,
- input: Pick & Partial>,
-): Promise<{ category: PortalCategory }> {
- return api(`/api/v1/support/portals/${id}/categories/`, {
- method: "POST",
- body: JSON.stringify(input),
- });
-}
-
-export function updatePortalCategory(
- portalId: number,
- categoryId: number,
- input: Partial>,
-): Promise<{ category: PortalCategory }> {
- return api(`/api/v1/support/portals/${portalId}/categories/${categoryId}/`, {
- method: "PATCH",
- body: JSON.stringify(input),
- });
-}
-
-export function deletePortalCategory(
- portalId: number,
- categoryId: number,
-): Promise {
- return api(`/api/v1/support/portals/${portalId}/categories/${categoryId}/`, {
- method: "DELETE",
- });
-}
-
-export function listPortalSupportChannels(
- portalId: number,
-): Promise<{ items: Channel[] }> {
- return api(`/api/v1/support/portals/${portalId}/support-channels/`);
-}
-
-export function setPortalCustomDomain(
- portalId: number,
- customDomain: string,
-): Promise<{ portal: SupportPortal }> {
- return api(`/api/v1/support/portals/${portalId}/domain/`, {
- method: "PUT",
- body: JSON.stringify({ customDomain }),
- });
-}
-
-export function verifyPortalCustomDomain(
- portalId: number,
-): Promise<{ portal: SupportPortal }> {
- return api(`/api/v1/support/portals/${portalId}/domain/verify/`, {
- method: "POST",
- });
-}
-
-export function listPortalArticles(id: number): Promise<{ items: PortalArticle[] }> {
- return api(`/api/v1/support/portals/${id}/articles/`);
-}
-
-export function createPortalArticle(
- portalId: number,
- input: {
- categoryId: number;
- slug: string;
- locale: string;
- title: string;
- summary: string;
- content: string;
- },
-): Promise<{ article: PortalArticle }> {
- return api(`/api/v1/support/portals/${portalId}/articles/`, {
- method: "POST",
- body: JSON.stringify(input),
- });
-}
-
-export function loadPortalArticle(
- portalId: number,
- articleId: number,
-): Promise<{ article: PortalArticle }> {
- return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/`);
-}
-
-export function updatePortalArticle(
- portalId: number,
- articleId: number,
- input: { categoryId: number; locale: string; slug: string },
-): Promise<{ article: PortalArticle }> {
- return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/`, {
- method: "PATCH",
- body: JSON.stringify(input),
- });
-}
-
-export function addArticleRevision(
- portalId: number,
- articleId: number,
- input: Pick,
-): Promise<{ revision: ArticleRevision }> {
- return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/revisions/`, {
- method: "POST",
- body: JSON.stringify(input),
- });
-}
-
-export function publishArticleRevision(
- portalId: number,
- articleId: number,
- revisionId: number,
-): Promise<{ article: PortalArticle }> {
- return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/publish/`, {
- method: "POST",
- body: JSON.stringify({ revisionId }),
- });
-}
-
-export function archivePortalArticle(
- portalId: number,
- articleId: number,
-): Promise<{ article: PortalArticle }> {
- return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/archive/`, {
- method: "POST",
- });
-}
+export {
+ addArticleRevision,
+ archivePortalArticle,
+ changePortalStatus,
+ createPortalArticle,
+ createPortalCategory,
+ createSupportPortal,
+ deletePortalCategory,
+ listPortalArticles,
+ listPortalCategories,
+ listPortalSupportChannels,
+ listSupportPortals,
+ loadPortalArticle,
+ loadSupportPortal,
+ publishArticleRevision,
+ replacePortalProducts,
+ setPortalCustomDomain,
+ updatePortalArticle,
+ updatePortalCategory,
+ updateSupportPortal,
+ verifyPortalCustomDomain,
+} from "./api";
export const PORTAL_STATUS_LABEL: Record = {
DRAFT: "Черновик",
diff --git a/apps/internal-ui/src/features/support-portals/styles-detail.css b/apps/internal-ui/src/features/support-portals/styles-detail.css
index 931aac7..a41ca73 100644
--- a/apps/internal-ui/src/features/support-portals/styles-detail.css
+++ b/apps/internal-ui/src/features/support-portals/styles-detail.css
@@ -75,6 +75,20 @@
font-size: 11.5px;
}
+.portal-article-col-title { width: 26%; }
+.portal-article-col-category { width: 18%; }
+.portal-article-col-language { width: 7%; }
+.portal-article-col-version { width: 7%; }
+.portal-article-col-status { width: 18%; }
+.portal-article-col-updated { width: 17%; }
+.portal-article-col-actions { width: 48px; }
+
+.portal-article-table th:last-child,
+.portal-article-table td:last-child {
+ padding-right: 10px;
+ padding-left: 8px;
+}
+
.portal-product-list {
display: grid;
gap: 8px;
@@ -130,10 +144,19 @@
font-size: 13px;
}
+.portal-domain-record {
+ margin-top: 14px;
+}
+
+.portal-domain-record > b {
+ color: var(--text-heading);
+ font-size: 12.5px;
+}
+
.portal-domain-verification dl {
display: grid;
gap: 8px;
- margin: 12px 0 0;
+ margin: 8px 0 0;
}
.portal-domain-verification dl > div {
diff --git a/apps/internal-ui/src/layout/AiSidebarNav.tsx b/apps/internal-ui/src/layout/AiSidebarNav.tsx
deleted file mode 100644
index 817039a..0000000
--- a/apps/internal-ui/src/layout/AiSidebarNav.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import type { RouteKey } from "../types";
-
-const ITEMS: Array<{
- activeRoutes: RouteKey[];
- disabled?: boolean;
- key: RouteKey;
- label: string;
-}> = [
- {
- activeRoutes: ["aiAgents", "aiAgentCreate", "aiAgentDetail"],
- key: "aiAgents",
- label: "AI-агенты",
- },
- {
- activeRoutes: ["aiKnowledge", "aiKnowledgeCreate", "aiKnowledgeDetail"],
- key: "aiKnowledge",
- label: "Знания",
- },
- {
- activeRoutes: ["aiUsage"],
- disabled: true,
- key: "aiUsage",
- label: "Использование AI",
- },
-];
-
-export function AiSidebarNav({
- route,
- setRoute,
-}: {
- route: RouteKey;
- setRoute: (route: RouteKey) => void;
-}) {
- return (
-
- {ITEMS.map((item) => (
-
- ))}
-
- );
-}
diff --git a/apps/internal-ui/src/layout/SalesSidebar.tsx b/apps/internal-ui/src/layout/SalesSidebar.tsx
deleted file mode 100644
index d632eba..0000000
--- a/apps/internal-ui/src/layout/SalesSidebar.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import type { RouteKey, SessionUser } from "../types";
-import { Icon } from "../shared/icons";
-import { canAccess } from "../auth/access";
-import { SidebarUserMenu } from "./SidebarUserMenu";
-
-export function SalesSidebar({ route, user, setRoute, onLogout, waitingCount = 0 }: { route: RouteKey; user: SessionUser; setRoute: (route: RouteKey) => void; onLogout: () => void; waitingCount?: number }) {
- const nav = [
- { key: "salesOverview" as const, label: "Обзор", icon: "grid" as const },
- { key: "salesDialogs" as const, label: "Диалоги", icon: "message" as const, badge: waitingCount > 0 ? String(waitingCount) : undefined },
- { key: "salesClients" as const, label: "Контакты", icon: "team" as const },
- { key: "salesOrders" as const, label: "Продажи", icon: "cart" as const },
- ];
- return (
-
- );
-}
diff --git a/apps/internal-ui/src/layout/Shell.tsx b/apps/internal-ui/src/layout/Shell.tsx
index 04d5bb7..82c3be6 100644
--- a/apps/internal-ui/src/layout/Shell.tsx
+++ b/apps/internal-ui/src/layout/Shell.tsx
@@ -6,11 +6,8 @@ import { NotificationDrawer } from "../features/notifications/NotificationDrawer
import { fetchNotifications, markAllRead, markRead, type AppNotification } from "../features/notifications/model";
import { fetchWaitingCount } from "../features/conversations/model";
import { Sidebar } from "./Sidebar";
-import { SalesSidebar } from "./SalesSidebar";
-import { SupportSidebar } from "./SupportSidebar";
import { ShellRouteContent } from "./ShellRouteContent";
import { TopBar } from "./TopBar";
-import { canAccess } from "../auth/access";
export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId, selectedProductCode, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, selectedOrderId, selectedChannelId, selectedSupportPortalId, openChannelRoute, openSupportPortalRoute, openEmployeeRoute, openProductRoute, openAgentCreateRoute, openAgentRoute, openKnowledgeRoute, openConversationRoute, openClientRoute, openOrderRoute, user, data, reload, onUserUpdated, onLogout }: { route: RouteKey; setRoute: (route: RouteKey) => void; selectedEmployeeId: number | null; selectedProductId: number | null; selectedProductCode: string | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; selectedOrderId: number | null; selectedChannelId: number | null; selectedSupportPortalId: number | null; openEmployeeRoute: (employeeId: number) => void; openProductRoute: (productId: number) => void; openAgentCreateRoute: (productCode: string | null) => void; openAgentRoute: (agentId: number) => void; openKnowledgeRoute: (knowledgeId: number) => void; openConversationRoute: (conversationId: number) => void; openClientRoute: (clientId: number) => void; openOrderRoute: (orderId: number) => void; openChannelRoute: (channelId: number) => void; openSupportPortalRoute: (portalId: number) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) {
const [agentName, setAgentName] = useState(null);
@@ -95,15 +92,9 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId,
const isAiFullWidth = route === "aiAgentCreate";
const isKnowledgeLibrary = route === "aiKnowledge";
const isKnowledgeEditor = route === "aiKnowledgeCreate" || route === "aiKnowledgeDetail";
- const showSalesSidebar = isSalesWorkspace || (
- canAccess(user, "salesDialogs") && !canAccess(user, "command") && !isSupportWorkspace
- );
- const showSupportSidebar = isSupportWorkspace || (
- canAccess(user, "supportDialogs") && !canAccess(user, "command") && !isSalesWorkspace
- );
return (
- {showSupportSidebar ?
: showSalesSidebar ?
:
}
+
{ setNotifOpen(true); void loadNotifications(); }} />
diff --git a/apps/internal-ui/src/layout/Sidebar.tsx b/apps/internal-ui/src/layout/Sidebar.tsx
index b5158ca..c56c546 100644
--- a/apps/internal-ui/src/layout/Sidebar.tsx
+++ b/apps/internal-ui/src/layout/Sidebar.tsx
@@ -1,47 +1,89 @@
import type { RouteKey, SessionUser } from "../types";
import { Icon, LogoIcon } from "../shared/icons";
-import { canAccess } from "../auth/access";
-import { AiSidebarNav } from "./AiSidebarNav";
+import { canAccess, defaultRoute } from "../auth/access";
+import { SidebarNavSection, type SidebarNavSectionItem } from "./SidebarNavSection";
import { SidebarUserMenu } from "./SidebarUserMenu";
-export function Sidebar({ route, user, setRoute, onLogout }: { route: RouteKey; user: SessionUser; setRoute: (route: RouteKey) => void; onLogout: () => void }) {
- const nav = [
- { key: "command" as const, label: "Обзор", icon: "grid" as const },
- { label: "КОМПАНИЯ", group: true },
- { key: "departments" as const, label: "Отделы", icon: "building" as const },
- { key: "employees" as const, label: "Сотрудники", icon: "team" as const },
- { key: "products" as const, label: "Продукты", icon: "box" as const },
- { key: "channels" as const, label: "Каналы", icon: "route" as const },
- { label: "ПЛАТФОРМА", group: true },
- { key: "aiAgents" as const, label: "AI", icon: "robot" as const },
- { key: "integrations" as const, label: "Интеграции", icon: "plug" as const },
- { divider: true },
- { label: "Администрирование", icon: "settings" as const, disabled: true },
- ];
+type SidebarLinkProps = {
+ activeRoutes?: RouteKey[];
+ disabled?: boolean;
+ icon: Parameters[0]["name"];
+ label: string;
+ route: RouteKey;
+ routeKey?: RouteKey;
+ setRoute: (route: RouteKey) => void;
+};
+
+function SidebarLink({ activeRoutes, disabled, icon, label, route, routeKey, setRoute }: SidebarLinkProps) {
+ const active = activeRoutes?.includes(route) ?? routeKey === route;
+ return (
+
+ );
+}
+
+const SALES_ITEMS: SidebarNavSectionItem[] = [
+ { activeRoutes: ["salesOverview"], key: "salesOverview", label: "Обзор" },
+ { activeRoutes: ["salesDialogs"], key: "salesDialogs", label: "Диалоги" },
+ { activeRoutes: ["salesClients", "salesClientDetail"], key: "salesClients", label: "Контакты" },
+ { activeRoutes: ["salesOrders", "salesOrderDetail"], key: "salesOrders", label: "Продажи" },
+];
+
+const SUPPORT_ITEMS: SidebarNavSectionItem[] = [
+ { activeRoutes: ["supportOverview"], key: "supportOverview", label: "Обзор" },
+ { activeRoutes: ["supportDialogs"], key: "supportDialogs", label: "Диалоги" },
+ { activeRoutes: ["supportPortals", "supportPortalDetail"], key: "supportPortals", label: "Порталы" },
+];
+
+const AI_ITEMS: SidebarNavSectionItem[] = [
+ { activeRoutes: ["aiAgents", "aiAgentCreate", "aiAgentDetail"], key: "aiAgents", label: "AI-агенты" },
+ { activeRoutes: ["aiKnowledge", "aiKnowledgeCreate", "aiKnowledgeDetail"], key: "aiKnowledge", label: "Знания" },
+ { activeRoutes: ["aiUsage"], disabled: true, key: "aiUsage", label: "Использование AI" },
+];
+
+function visibleItems(user: SessionUser, items: SidebarNavSectionItem[]) {
+ return items.filter((item) => canAccess(user, item.key));
+}
+
+export function Sidebar({ route, user, setRoute, onLogout, waitingCount = 0 }: { route: RouteKey; user: SessionUser; setRoute: (route: RouteKey) => void; onLogout: () => void; waitingCount?: number }) {
+ const sectionStorageKey = (section: string) => (
+ `custocrm.sidebar.${user.organizationPublicId}.${section}.expanded`
+ );
+ const salesItems = visibleItems(user, SALES_ITEMS).map((item) => (
+ item.key === "salesDialogs" && waitingCount > 0
+ ? { ...item, badge: String(waitingCount) }
+ : item
+ ));
+ const supportItems = visibleItems(user, SUPPORT_ITEMS);
+ const aiItems = visibleItems(user, AI_ITEMS);
+
return (
diff --git a/apps/internal-ui/src/layout/SidebarNavSection.tsx b/apps/internal-ui/src/layout/SidebarNavSection.tsx
new file mode 100644
index 0000000..a185e0c
--- /dev/null
+++ b/apps/internal-ui/src/layout/SidebarNavSection.tsx
@@ -0,0 +1,83 @@
+import { useState } from "react";
+
+import { Icon } from "../shared/icons";
+import type { RouteKey } from "../types";
+
+export type SidebarNavSectionItem = {
+ activeRoutes: RouteKey[];
+ badge?: string;
+ disabled?: boolean;
+ key: RouteKey;
+ label: string;
+};
+
+export function SidebarNavSection({
+ icon,
+ items,
+ label,
+ route,
+ setRoute,
+ storageKey,
+}: {
+ icon: Parameters[0]["name"];
+ items: SidebarNavSectionItem[];
+ label: string;
+ route: RouteKey;
+ setRoute: (route: RouteKey) => void;
+ storageKey: string;
+}) {
+ const [expanded, setExpanded] = useState(() => {
+ try {
+ return window.localStorage.getItem(storageKey) === "true";
+ } catch {
+ return false;
+ }
+ });
+ const active = items.some((item) => item.activeRoutes.includes(route));
+
+ if (items.length === 0) return null;
+
+ function toggleExpanded() {
+ setExpanded((current) => {
+ const next = !current;
+ try {
+ window.localStorage.setItem(storageKey, String(next));
+ } catch {
+ // Навигация остаётся рабочей, даже если хранилище браузера недоступно.
+ }
+ return next;
+ });
+ }
+
+ return (
+
+
+ {expanded && (
+
+ {items.map((item) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/internal-ui/src/layout/SupportSidebar.tsx b/apps/internal-ui/src/layout/SupportSidebar.tsx
deleted file mode 100644
index 4c42ed6..0000000
--- a/apps/internal-ui/src/layout/SupportSidebar.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import type { RouteKey, SessionUser } from "../types";
-import { Icon } from "../shared/icons";
-import { canAccess } from "../auth/access";
-import { SidebarUserMenu } from "./SidebarUserMenu";
-
-// SPEC-HUB-0010 §8.1: вся внутренняя support-навигация живёт в workspace отдела.
-// Зеркало SalesSidebar (переиспользование layout/структуры), переиспользует
-// существующие CSS-классы sales-workspace-sidebar/nav — дизайн baseline.
-export function SupportSidebar({ route, user, setRoute, onLogout, waitingCount = 0 }: { route: RouteKey; user: SessionUser; setRoute: (route: RouteKey) => void; onLogout: () => void; waitingCount?: number }) {
- const nav = [
- { key: "supportOverview" as const, label: "Обзор", icon: "grid" as const },
- { key: "supportDialogs" as const, label: "Диалоги", icon: "message" as const, badge: waitingCount > 0 ? String(waitingCount) : undefined },
- { key: "supportPortals" as const, label: "Порталы", icon: "folder" as const },
- ];
- return (
-
- );
-}
diff --git a/apps/internal-ui/src/layout/styles.css b/apps/internal-ui/src/layout/styles.css
index 6edb13a..a95241f 100644
--- a/apps/internal-ui/src/layout/styles.css
+++ b/apps/internal-ui/src/layout/styles.css
@@ -102,15 +102,17 @@
font-weight: 600;
}
-.hub-ai-subnav {
+.hub-subnav {
display: flex;
flex-direction: column;
gap: 2px;
padding: 2px 0 2px 30px;
}
-.hub-ai-nav-item {
+.hub-subnav-item {
width: 100%;
+ display: flex;
+ align-items: center;
padding: 6px 12px;
border: 0;
border-radius: 7px;
@@ -122,22 +124,47 @@
text-align: left;
}
-.hub-ai-nav-item:not(:disabled):hover {
+.hub-subnav-item:not(:disabled):hover {
color: #262626;
background: #f5f5f5;
}
-.hub-ai-nav-item.is-active {
+.hub-subnav-item.is-active {
color: #0958d9;
background: #e6f4ff;
font-weight: 600;
}
-.hub-ai-nav-item:disabled {
+.hub-subnav-item:disabled {
color: #bfbfbf;
cursor: not-allowed;
}
+.hub-nav-chevron {
+ display: inline-flex;
+ margin-left: auto;
+ transition: transform 0.15s ease;
+}
+
+.hub-nav-section-toggle[aria-expanded="false"] .hub-nav-chevron {
+ transform: rotate(-90deg);
+}
+
+.hub-subnav-item b {
+ min-width: 20px;
+ height: 20px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ margin-left: auto;
+ padding: 0 6px;
+ color: #ffffff;
+ background: #faad14;
+ border-radius: 10px;
+ font-size: 11px;
+ font-weight: 700;
+}
+
.active-bar {
position: absolute;
left: 0;
@@ -179,98 +206,6 @@
color: #bfbfbf;
}
-.sales-workspace-sidebar {
- width: 240px;
-}
-
-.sales-sidebar-back-wrap {
- height: 56px;
- display: flex;
- align-items: center;
- flex: none;
- padding: 0 14px;
- border-bottom: 1px solid #f5f5f5;
-}
-
-.sales-sidebar-back {
- width: 100%;
- height: 36px;
- display: flex;
- align-items: center;
- gap: 9px;
- padding: 0 12px;
- color: #595959;
- background: #ffffff;
- border: 1px solid #f0f0f0;
- border-radius: 8px;
- font-size: 13px;
- font-weight: 500;
-}
-
-.sales-sidebar-back:hover {
- color: #1677ff;
- background: #f5f5f5;
- border-color: #d9d9d9;
-}
-
-.sales-sidebar-back svg {
- transform: rotate(180deg);
-}
-
-.sales-sidebar-title {
- display: flex;
- align-items: center;
- gap: 11px;
- padding: 16px 16px 8px;
-}
-
-.sales-sidebar-icon {
- width: 34px;
- height: 34px;
- display: flex;
- align-items: center;
- justify-content: center;
- flex: none;
- color: #1677ff;
- background: #e6f4ff;
- border-radius: 9px;
-}
-
-.sales-sidebar-title strong {
- display: block;
- color: #1f1f1f;
- font-size: 15px;
- font-weight: 700;
- line-height: 1.15;
-}
-
-.sales-sidebar-title span {
- display: block;
- margin-top: 2px;
- color: #8c8c8c;
- font-size: 11px;
- line-height: 1.15;
-}
-
-.sales-workspace-nav {
- padding-top: 8px;
-}
-
-.sales-workspace-nav b {
- min-width: 20px;
- height: 20px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- margin-left: auto;
- padding: 0 6px;
- color: #ffffff;
- background: #faad14;
- border-radius: 10px;
- font-size: 11px;
- font-weight: 700;
-}
-
.avatar {
width: 32px;
height: 32px;
diff --git a/compose.dev.yaml b/compose.dev.yaml
index 1a50834..4d2d00c 100644
--- a/compose.dev.yaml
+++ b/compose.dev.yaml
@@ -135,6 +135,32 @@ services:
redis:
condition: service_healthy
+ # Отдельный импорт демо-контента портала не создаёт пользователей и не
+ # изменяет пароли, роли, сессии или другие учётные данные.
+ support-portal-seed:
+ profiles: ["support-portal-seed"]
+ build:
+ context: .
+ dockerfile: apps/backend/Dockerfile
+ env_file:
+ - .env
+ command: ["python", "/opt/custocrm-local-seed/seed_support_portal.py"]
+ working_dir: /app/apps/backend
+ environment:
+ CUS_DB_ROLE: migration
+ CUSTOCRM_LOCAL_SEED: "1"
+ CUS_HELP_BASE_DOMAIN: localhost
+ CUS_HELP_PUBLIC_SCHEME: http
+ CUS_HELP_PUBLIC_PORT: ""
+ volumes:
+ - ./apps/backend:/app/apps/backend
+ - ./dev/local-seed:/opt/custocrm-local-seed:ro
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+
# Dev: gateway — nginx с local.conf вместо Caddy (та же маршрутизация, HMR-friendly).
gateway:
image: nginx:1.27-alpine
diff --git a/deploy/nginx/local.conf b/deploy/nginx/local.conf
index 00f5611..4782dda 100644
--- a/deploy/nginx/local.conf
+++ b/deploy/nginx/local.conf
@@ -32,7 +32,8 @@ server {
}
location /chat/ {
- add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors *" always;
+ # Vite React Refresh injects an inline preamble in local development.
+ add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors *" always;
proxy_pass http://web-chat:5175;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
diff --git a/dev/local-seed/seed_support_portal.py b/dev/local-seed/seed_support_portal.py
new file mode 100644
index 0000000..9a56bb1
--- /dev/null
+++ b/dev/local-seed/seed_support_portal.py
@@ -0,0 +1,265 @@
+#!/usr/bin/env python3
+"""Local-only, credential-safe demo importer for the FoxRay support portal."""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+from pathlib import Path
+
+
+SEED_DIR = Path(__file__).resolve().parent
+MANIFEST_PATH = SEED_DIR / "support_portal.json"
+
+
+def require_local_environment() -> None:
+ if (
+ os.environ.get("CUS_ENV") != "local"
+ or os.environ.get("CUSTOCRM_LOCAL_SEED") != "1"
+ ):
+ raise SystemExit(
+ "Support portal seed is available only through compose.dev.yaml "
+ "with CUS_ENV=local."
+ )
+
+
+def load_manifest() -> dict:
+ with MANIFEST_PATH.open(encoding="utf-8") as stream:
+ manifest = json.load(stream)
+ if manifest.get("schemaVersion") != 1:
+ raise SystemExit("Unsupported support portal seed manifest version.")
+ return manifest
+
+
+def update_fields(instance, values: dict) -> None:
+ changed = []
+ for field, value in values.items():
+ if getattr(instance, field) != value:
+ setattr(instance, field, value)
+ changed.append(field)
+ if changed:
+ instance.full_clean()
+ if any(field.name == "updated_at" for field in instance._meta.fields):
+ changed.append("updated_at")
+ instance.save(update_fields=changed)
+
+
+def run() -> None:
+ require_local_environment()
+ sys.path.insert(0, "/app/apps/backend")
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub_backend.settings")
+
+ import django
+
+ django.setup()
+
+ from django.db import transaction
+ from django.utils import timezone
+
+ from hub_platform.channels.models import Channel
+ from hub_platform.identity.models import Department, Organization
+ from hub_platform.integrations.models import (
+ Integration,
+ IntegrationKind,
+ IntegrationProvider,
+ IntegrationStatus,
+ )
+ from hub_platform.products.models import Product
+ from hub_platform.support_portals.addressing import hosted_domain
+ from hub_platform.support_portals.models import (
+ PortalArticle,
+ PortalArticleRevision,
+ PortalCategory,
+ SupportPortal,
+ SupportPortalProduct,
+ )
+ from hub_platform.support_portals.statuses import ArticleStatus, PortalStatus
+
+ manifest = load_manifest()
+ portal_data = manifest["portal"]
+ widget_data = manifest["widget"]
+ now = timezone.now()
+
+ with transaction.atomic():
+ organization = Organization.objects.get(slug=manifest["organization"])
+ department = Department.objects.get(
+ organization=organization,
+ code="support",
+ )
+ product = Product.objects.get(
+ organization=organization,
+ code=portal_data["product"],
+ )
+
+ channel, _ = Channel.objects.get_or_create(
+ organization=organization,
+ code=widget_data["channel"],
+ defaults={
+ "name": widget_data["channelName"],
+ "department": department,
+ "product": product,
+ },
+ )
+ update_fields(
+ channel,
+ {
+ "name": widget_data["channelName"],
+ "department": department,
+ "product": product,
+ "is_active": True,
+ "requires_authenticated_product_identity": False,
+ "allow_anonymous_sessions": True,
+ "allow_self_reported_contact": True,
+ "allow_sales_attribution": False,
+ "allow_checkout_actions": False,
+ },
+ )
+
+ integration, _ = Integration.objects.get_or_create(
+ organization=organization,
+ provider=IntegrationProvider.WEB,
+ name=widget_data["integrationName"],
+ defaults={"kind": IntegrationKind.MESSENGER},
+ )
+ update_fields(
+ integration,
+ {
+ "kind": IntegrationKind.MESSENGER,
+ "channel": channel,
+ "config": widget_data["config"],
+ "status": IntegrationStatus.OK,
+ "is_active": True,
+ "last_error": "",
+ },
+ )
+
+ portal, _ = SupportPortal.objects.get_or_create(
+ slug=portal_data["slug"],
+ defaults={
+ "organization": organization,
+ "department": department,
+ "hosted_domain": hosted_domain(portal_data["slug"]),
+ "name": portal_data["name"],
+ "default_locale": portal_data["locale"],
+ },
+ )
+ update_fields(
+ portal,
+ {
+ "department": department,
+ "hosted_domain": hosted_domain(portal_data["slug"]),
+ "name": portal_data["name"],
+ "default_locale": portal_data["locale"],
+ "widget_channel": channel,
+ "status": PortalStatus.PUBLISHED,
+ "published_at": portal.published_at or now,
+ },
+ )
+
+ authenticated_channel = Channel.objects.filter(
+ organization=organization,
+ code=portal_data["authenticatedSupportChannel"],
+ product=product,
+ department=department,
+ requires_authenticated_product_identity=True,
+ allow_anonymous_sessions=False,
+ ).first()
+ product_link, _ = SupportPortalProduct.objects.get_or_create(
+ organization=organization,
+ portal=portal,
+ product=product,
+ defaults={"support_channel": authenticated_channel},
+ )
+ if product_link.support_channel_id != getattr(
+ authenticated_channel,
+ "id",
+ None,
+ ):
+ product_link.support_channel = authenticated_channel
+ product_link.full_clean()
+ product_link.save(update_fields=["support_channel"])
+
+ categories = {}
+ for item in manifest["categories"]:
+ category, _ = PortalCategory.objects.get_or_create(
+ organization=organization,
+ portal=portal,
+ slug=item["slug"],
+ defaults={"name": item["name"]},
+ )
+ update_fields(
+ category,
+ {
+ "name": item["name"],
+ "description": item["description"],
+ "sort_order": item["sortOrder"],
+ },
+ )
+ categories[item["slug"]] = category
+
+ for item in manifest["articles"]:
+ article, _ = PortalArticle.objects.get_or_create(
+ organization=organization,
+ portal=portal,
+ locale=portal_data["locale"],
+ slug=item["slug"],
+ defaults={"category": categories[item["category"]]},
+ )
+ category = categories[item["category"]]
+ latest = article.revisions.order_by("-revision").first()
+ revision_content = (
+ item["title"],
+ item["summary"],
+ item["content"],
+ )
+ if latest is None or (
+ latest.title,
+ latest.summary,
+ latest.content,
+ ) != revision_content:
+ latest = PortalArticleRevision.objects.create(
+ organization=organization,
+ article=article,
+ revision=(latest.revision + 1) if latest else 1,
+ title=item["title"],
+ summary=item["summary"],
+ content=item["content"],
+ published_at=now,
+ )
+ elif latest.published_at is None:
+ latest.published_at = now
+ latest.save(update_fields=["published_at"])
+
+ article.category = category
+ article.status = ArticleStatus.PUBLISHED
+ article.published_revision = latest
+ article.full_clean()
+ article.save(
+ update_fields=[
+ "category",
+ "status",
+ "published_revision",
+ "updated_at",
+ ]
+ )
+
+ print(
+ json.dumps(
+ {
+ "organization": organization.slug,
+ "portal": portal.slug,
+ "url": f"http://{portal.hosted_domain}/",
+ "widgetChannel": channel.code,
+ "categories": len(categories),
+ "articles": len(manifest["articles"]),
+ "idempotent": True,
+ "credentialsChanged": False,
+ },
+ ensure_ascii=False,
+ )
+ )
+
+
+if __name__ == "__main__":
+ run()
diff --git a/dev/local-seed/support_portal.json b/dev/local-seed/support_portal.json
new file mode 100644
index 0000000..7fcc414
--- /dev/null
+++ b/dev/local-seed/support_portal.json
@@ -0,0 +1,102 @@
+{
+ "schemaVersion": 1,
+ "organization": "edevs",
+ "portal": {
+ "slug": "foxray",
+ "name": "FoxRay",
+ "locale": "ru",
+ "product": "foxray",
+ "authenticatedSupportChannel": "foxray-support"
+ },
+ "widget": {
+ "channel": "foxray-help",
+ "channelName": "FoxRay — чат поддержки",
+ "integrationName": "FoxRay Help Center Web",
+ "config": {
+ "title": "Поддержка FoxRay",
+ "accent": "#1677ff",
+ "greeting": "Здравствуйте! Поможем разобраться с FoxRay.",
+ "consent_text": "Продолжая, вы соглашаетесь на обработку сообщений для ответа на обращение.",
+ "consent_version": "v1",
+ "quick_replies": [
+ "Как начать работу",
+ "Не могу войти",
+ "Связаться со специалистом"
+ ],
+ "allowed_domains": [
+ "foxray.localhost",
+ "localhost"
+ ]
+ }
+ },
+ "categories": [
+ {
+ "slug": "getting-started",
+ "name": "Начало работы",
+ "description": "Первые шаги и основные возможности",
+ "sortOrder": 10
+ },
+ {
+ "slug": "account-access",
+ "name": "Аккаунт и доступ",
+ "description": "Вход, восстановление доступа и уведомления",
+ "sortOrder": 20
+ },
+ {
+ "slug": "support",
+ "name": "Обращения в поддержку",
+ "description": "Создание и отслеживание обращений",
+ "sortOrder": 30
+ },
+ {
+ "slug": "billing",
+ "name": "Оплата и документы",
+ "description": "Счета и закрывающие документы",
+ "sortOrder": 40
+ }
+ ],
+ "articles": [
+ {
+ "slug": "first-login",
+ "category": "getting-started",
+ "title": "Первый вход в FoxRay",
+ "summary": "Краткий маршрут после получения доступа.",
+ "content": "# Первый вход в FoxRay\n\n1. Откройте адрес рабочего пространства, который прислал администратор.\n2. Введите рабочий email и пароль.\n3. Проверьте название организации в верхней части страницы.\n\n> Если приглашение или адрес не открывается, напишите в чат поддержки на этой странице.\n\n## Что проверить после входа\n\n- доступные разделы;\n- профиль и контактные данные;\n- настройки уведомлений."
+ },
+ {
+ "slug": "restore-access",
+ "category": "account-access",
+ "title": "Как восстановить доступ",
+ "summary": "Что проверить, если не получается войти.",
+ "content": "# Как восстановить доступ\n\nСначала убедитесь, что используете рабочий email и правильный адрес организации.\n\n## Если войти не удалось\n\n- повторите ввод без пробелов в начале и конце;\n- проверьте раскладку и `Caps Lock`;\n- обратитесь к администратору вашей организации;\n- напишите в чат поддержки и укажите рабочий email.\n\nПароль в сообщении отправлять не нужно."
+ },
+ {
+ "slug": "notifications",
+ "category": "account-access",
+ "title": "Настройка уведомлений",
+ "summary": "Как выбрать важные события и не пропустить ответ.",
+ "content": "# Настройка уведомлений\n\nОткройте настройки профиля и проверьте доступные способы уведомлений.\n\n| Событие | Рекомендация |\n| --- | --- |\n| Ответ специалиста | Оставить включённым |\n| Изменение статуса | Оставить включённым |\n| Информационные сообщения | Настроить по необходимости |\n\nИзменения применяются после сохранения формы."
+ },
+ {
+ "slug": "create-support-request",
+ "category": "support",
+ "title": "Как обратиться в поддержку",
+ "summary": "Какие данные помогут быстрее разобраться в вопросе.",
+ "content": "# Как обратиться в поддержку\n\nНажмите синюю кнопку чата в правом нижнем углу портала.\n\nОпишите:\n\n- что вы хотели сделать;\n- что произошло вместо ожидаемого результата;\n- адрес страницы;\n- время возникновения проблемы.\n\nСекреты, пароли и платёжные данные отправлять не нужно."
+ },
+ {
+ "slug": "track-request",
+ "category": "support",
+ "title": "Где посмотреть статус обращения",
+ "summary": "Как продолжить уже начатый разговор.",
+ "content": "# Где посмотреть статус обращения\n\nВернитесь на портал в том же браузере и снова откройте чат. Активная локальная сессия продолжится автоматически.\n\nСтатусы в чате показывают, кто сейчас готовит ответ: виртуальный помощник или специалист поддержки."
+ },
+ {
+ "slug": "payment-documents",
+ "category": "billing",
+ "title": "Оплата и документы",
+ "summary": "Куда обратиться по вопросам счетов и документов.",
+ "content": "# Оплата и документы\n\nПо вопросу оплаты или закрывающих документов напишите в чат поддержки.\n\nУкажите:\n\n1. название организации;\n2. номер счёта, если он известен;\n3. период, за который нужны документы.\n\nНе прикладывайте данные банковской карты."
+ }
+ ]
+}
diff --git a/env.example b/env.example
index 815db3e..0f2faa0 100644
--- a/env.example
+++ b/env.example
@@ -11,6 +11,9 @@ CUSTOCRM_PLATFORM_DOMAIN=platform.example.com
CUS_HELP_BASE_DOMAIN=help.example.com
CUS_HELP_PUBLIC_SCHEME=https
CUS_HELP_PUBLIC_PORT=
+# Необязательный отдельный IPv4 для A-записей собственных доменов.
+# По умолчанию используется CUSTOCRM_WEB_LISTENING_IP.
+CUS_HELP_PUBLIC_IPV4=203.0.113.10
CUSTOCRM_ACME_EMAIL=admin@example.com
CUSTOCRM_ADMIN_PORT=18001
# IP web-gateway. Оставьте 0.0.0.0 без profile calls. Для calls укажите
diff --git a/scripts/seed-support-portal.ps1 b/scripts/seed-support-portal.ps1
new file mode 100644
index 0000000..7c1a71d
--- /dev/null
+++ b/scripts/seed-support-portal.ps1
@@ -0,0 +1,5 @@
+$ErrorActionPreference = "Stop"
+
+# Portal-only demo data: no users, passwords, roles, sessions, or credentials.
+docker compose -f compose.yaml -f compose.dev.yaml --profile support-portal-seed `
+ --env-file .env.example --env-file .env run --rm support-portal-seed
diff --git a/tests/e2e/internal-ui.spec.ts b/tests/e2e/internal-ui.spec.ts
index b5066c4..dbf5336 100644
--- a/tests/e2e/internal-ui.spec.ts
+++ b/tests/e2e/internal-ui.spec.ts
@@ -145,24 +145,23 @@ async function login(page: Page, user: object) {
await page.getByRole("button", { name: "Войти" }).click();
}
-test("OWNER logs in and lands on the command center with the global sidebar", async ({ page }) => {
+test("OWNER logs in and sees every available sidebar section collapsed", async ({ page }) => {
await login(page, OWNER_IDENTITY);
await expect(page).toHaveURL(new RegExp(`/organizations/${ORGANIZATION_PUBLIC_ID}/`));
await expect(page.getByRole("heading", { name: "Командный центр" })).toBeVisible();
- // Глобальный sidebar уровня компании.
- await expect(page.getByText("Уровень компании")).toBeVisible();
- await expect(page.getByText("Рабочее пространство")).toHaveCount(0);
+ await expect(page.locator(".hub-nav-section-toggle", { hasText: "Продажи" })).toHaveAttribute("aria-expanded", "false");
+ await expect(page.locator(".hub-nav-section-toggle", { hasText: "Поддержка" })).toHaveAttribute("aria-expanded", "false");
+ await expect(page.locator(".hub-nav-section-toggle", { hasText: "AI" })).toHaveAttribute("aria-expanded", "false");
+ await expect(page.getByRole("button", { name: "Отделы", exact: true })).toHaveCount(0);
});
-test("OPERATOR logs in and lands on sales dialogs with the sales sidebar", async ({ page }) => {
+test("OPERATOR logs in and sees the accessible department in the main sidebar", async ({ page }) => {
await login(page, OPERATOR_IDENTITY);
await expect(page).toHaveURL(/\/departments\/sales\/dialogs/);
- // Операторская навигация показывает только рабочее пространство продаж.
- await expect(page.getByText("Рабочее пространство")).toBeVisible();
- await expect(page.getByText("Уровень компании")).toHaveCount(0);
- await expect(page.getByRole("button", { name: "Назад" })).toHaveCount(0);
+ await expect(page.locator(".hub-nav-section-toggle", { hasText: "Продажи" })).toHaveAttribute("aria-expanded", "false");
+ await expect(page.getByRole("button", { name: "Поддержка", exact: true })).toHaveCount(0);
});
test("OPERATOR opening an owner-only route sees the 403 permission screen", async ({ page }) => {