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 }) => {