From 2b246e193bdc9df120eb0a8bb180409be83788bf Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 28 Jul 2026 21:39:25 +0300 Subject: [PATCH] :sparkles: feat(support): add reusable multi-portal help centers --- .env.example | 3 + Caddyfile | 19 + apps/backend/hub_backend/settings_app.py | 14 +- apps/backend/hub_backend/settings_base.py | 12 + apps/backend/hub_backend/settings_platform.py | 2 + apps/backend/hub_backend/urls_app.py | 1 + apps/backend/hub_backend/urls_platform.py | 6 + apps/backend/hub_platform/api/permissions.py | 19 + .../subscriptions/default_subscription.py | 29 +- .../hub_platform/subscriptions/keys.py | 1 + .../0008_support_portal_quota_definition.py | 19 + .../0009_support_portal_plan_grants.py | 114 ++ .../0010_remove_support_portal_limit.py | 98 ++ .../subscriptions/tests/test_plans.py | 26 +- apps/backend/hub_platform/support/urls.py | 3 +- .../hub_platform/support_portals/__init__.py | 1 + .../support_portals/addressing.py | 74 + .../hub_platform/support_portals/admin.py | 22 + .../hub_platform/support_portals/api.py | 14 + .../hub_platform/support_portals/apps.py | 6 + .../support_portals/content_services.py | 180 ++ .../support_portals/content_views.py | 211 +++ .../support_portals/domain_services.py | 74 + .../support_portals/gateway_views.py | 20 + .../support_portals/host_boundary.py | 43 + .../migrations/0001_portals.py | 106 ++ .../migrations/0002_content_and_products.py | 212 +++ .../migrations/0003_category_description.py | 13 + .../migrations/0004_portal_domains.py | 49 + .../0005_custom_domain_verification_token.py | 17 + .../support_portals/migrations/__init__.py | 1 + .../hub_platform/support_portals/models.py | 263 +++ .../support_portals/portal_services.py | 170 ++ .../support_portals/portal_views.py | 229 +++ .../support_portals/public_urls.py | 10 + .../support_portals/public_views.py | 157 ++ .../hub_platform/support_portals/selectors.py | 98 ++ .../support_portals/serializers.py | 147 ++ .../hub_platform/support_portals/statuses.py | 13 + .../support_portals/tests/__init__.py | 1 + .../support_portals/tests/base.py | 44 + .../tests/test_management_api.py | 196 +++ .../tests/test_management_content_api.py | 117 ++ .../support_portals/tests/test_public_api.py | 130 ++ .../hub_platform/support_portals/urls.py | 20 + apps/backend/hub_platform/tenancy/ingress.py | 4 + .../migrations/0010_support_portal_guards.py | 122 ++ .../0011_support_portal_host_directory.py | 58 + apps/backend/hub_platform/testing.py | 1 + apps/backend/requirements.txt | 1 + apps/internal-ui/package.json | 4 +- apps/internal-ui/src/App.tsx | 4 +- apps/internal-ui/src/api/client.test.ts | 12 + apps/internal-ui/src/api/client.ts | 1 + apps/internal-ui/src/auth/access.test.ts | 2 + apps/internal-ui/src/auth/access.ts | 3 + .../ai/knowledge/CategoryManagement.tsx | 176 +- .../ai/knowledge/CategoryManagementRow.tsx | 158 -- .../ai/knowledge/KnowledgeCategoryTree.tsx | 162 +- .../knowledge/KnowledgeEditorBreadcrumb.tsx | 12 +- .../ai/knowledge/KnowledgeEditorCard.tsx | 21 +- .../features/ai/knowledge/KnowledgePage.tsx | 4 +- .../features/ai/knowledge/KnowledgeTable.tsx | 44 +- .../ai/knowledge/KnowledgeToolbar.tsx | 57 +- .../ai/knowledge/knowledgeTree.test.ts | 29 +- .../features/ai/knowledge/knowledgeTree.ts | 90 - .../conversations/ConversationThread.tsx | 4 +- .../conversations/EmailMessageBody.tsx | 23 +- .../conversations/emailContent.test.ts | 39 + .../features/conversations/emailContent.ts | 48 + .../src/features/help-center/HelpArticle.tsx | 146 ++ .../features/help-center/HelpCenterApp.tsx | 52 + .../src/features/help-center/HelpHome.tsx | 148 ++ .../src/features/help-center/HelpIcons.tsx | 47 + .../src/features/help-center/HelpLayout.tsx | 58 + .../src/features/help-center/HelpSearch.tsx | 41 + .../help-center/MarkdownContent.test.tsx | 32 + .../features/help-center/MarkdownContent.tsx | 127 ++ .../help-center/SupportLauncher.test.ts | 25 + .../features/help-center/SupportLauncher.tsx | 53 + .../src/features/help-center/api.ts | 40 + .../features/help-center/styles-article.css | 310 ++++ .../src/features/help-center/styles-home.css | 204 +++ .../features/help-center/styles-layout.css | 299 ++++ .../help-center/styles-responsive.css | 132 ++ .../src/features/help-center/types.ts | 47 + .../sales/dialogs/context/ClientContext.tsx | 2 +- .../features/sales/dialogs/email-message.css | 23 + .../support-portals/PortalArticleEditor.tsx | 166 ++ .../PortalCategoryManagement.tsx | 41 + .../support-portals/PortalContent.tsx | 197 +++ .../support-portals/PortalCreateDialog.tsx | 115 ++ .../support-portals/PortalDomainSettings.tsx | 88 + .../support-portals/PortalSettings.tsx | 170 ++ .../SupportPortalDetailPage.tsx | 133 ++ .../support-portals/SupportPortalsPage.tsx | 155 ++ .../src/features/support-portals/model.ts | 310 ++++ .../support-portals/styles-detail.css | 161 ++ .../features/support-portals/styles-list.css | 133 ++ .../src/features/support-portals/styles.ts | 2 + apps/internal-ui/src/layout/Shell.tsx | 6 +- .../src/layout/ShellRouteContent.tsx | 14 +- .../internal-ui/src/layout/SupportSidebar.tsx | 7 +- apps/internal-ui/src/layout/TopBar.tsx | 7 +- apps/internal-ui/src/main.tsx | 34 +- apps/internal-ui/src/router.test.ts | 13 + apps/internal-ui/src/router.ts | 10 +- apps/internal-ui/src/routes.ts | 2 + .../CategoryManagementDialog.tsx | 142 ++ .../content-library/CategoryManagementRow.tsx | 103 ++ .../shared/content-library/CategoryTree.tsx | 153 ++ .../ContentEditorBreadcrumb.tsx | 20 + .../content-library/ContentEditorCard.tsx | 27 + .../content-library/ContentLibraryTable.tsx | 63 + .../content-library/ContentLibraryToolbar.tsx | 25 + .../categoryManagementModel.ts | 87 + apps/internal-ui/src/shared/form-controls.css | 8 + apps/internal-ui/src/shared/form-controls.tsx | 4 + apps/internal-ui/src/shared/ui.tsx | 3 +- apps/internal-ui/src/surface.test.ts | 19 + apps/internal-ui/src/surface.ts | 21 + apps/internal-ui/src/types.ts | 2 +- apps/internal-ui/src/useRouteNavigation.ts | 4 + apps/internal-ui/vite.config.ts | 21 + compose.dev.yaml | 14 + deploy/nginx/local.conf | 6 +- env.example | 3 + package-lock.json | 1475 ++++++++++++++++- 128 files changed, 9178 insertions(+), 655 deletions(-) create mode 100644 apps/backend/hub_platform/subscriptions/migrations/0008_support_portal_quota_definition.py create mode 100644 apps/backend/hub_platform/subscriptions/migrations/0009_support_portal_plan_grants.py create mode 100644 apps/backend/hub_platform/subscriptions/migrations/0010_remove_support_portal_limit.py create mode 100644 apps/backend/hub_platform/support_portals/__init__.py create mode 100644 apps/backend/hub_platform/support_portals/addressing.py create mode 100644 apps/backend/hub_platform/support_portals/admin.py create mode 100644 apps/backend/hub_platform/support_portals/api.py create mode 100644 apps/backend/hub_platform/support_portals/apps.py create mode 100644 apps/backend/hub_platform/support_portals/content_services.py create mode 100644 apps/backend/hub_platform/support_portals/content_views.py create mode 100644 apps/backend/hub_platform/support_portals/domain_services.py create mode 100644 apps/backend/hub_platform/support_portals/gateway_views.py create mode 100644 apps/backend/hub_platform/support_portals/host_boundary.py create mode 100644 apps/backend/hub_platform/support_portals/migrations/0001_portals.py create mode 100644 apps/backend/hub_platform/support_portals/migrations/0002_content_and_products.py create mode 100644 apps/backend/hub_platform/support_portals/migrations/0003_category_description.py create mode 100644 apps/backend/hub_platform/support_portals/migrations/0004_portal_domains.py create mode 100644 apps/backend/hub_platform/support_portals/migrations/0005_custom_domain_verification_token.py create mode 100644 apps/backend/hub_platform/support_portals/migrations/__init__.py create mode 100644 apps/backend/hub_platform/support_portals/models.py create mode 100644 apps/backend/hub_platform/support_portals/portal_services.py create mode 100644 apps/backend/hub_platform/support_portals/portal_views.py create mode 100644 apps/backend/hub_platform/support_portals/public_urls.py create mode 100644 apps/backend/hub_platform/support_portals/public_views.py create mode 100644 apps/backend/hub_platform/support_portals/selectors.py create mode 100644 apps/backend/hub_platform/support_portals/serializers.py create mode 100644 apps/backend/hub_platform/support_portals/statuses.py create mode 100644 apps/backend/hub_platform/support_portals/tests/__init__.py create mode 100644 apps/backend/hub_platform/support_portals/tests/base.py create mode 100644 apps/backend/hub_platform/support_portals/tests/test_management_api.py create mode 100644 apps/backend/hub_platform/support_portals/tests/test_management_content_api.py create mode 100644 apps/backend/hub_platform/support_portals/tests/test_public_api.py create mode 100644 apps/backend/hub_platform/support_portals/urls.py create mode 100644 apps/backend/hub_platform/tenancy/migrations/0010_support_portal_guards.py create mode 100644 apps/backend/hub_platform/tenancy/migrations/0011_support_portal_host_directory.py delete mode 100644 apps/internal-ui/src/features/ai/knowledge/CategoryManagementRow.tsx create mode 100644 apps/internal-ui/src/features/conversations/emailContent.test.ts create mode 100644 apps/internal-ui/src/features/conversations/emailContent.ts create mode 100644 apps/internal-ui/src/features/help-center/HelpArticle.tsx create mode 100644 apps/internal-ui/src/features/help-center/HelpCenterApp.tsx create mode 100644 apps/internal-ui/src/features/help-center/HelpHome.tsx create mode 100644 apps/internal-ui/src/features/help-center/HelpIcons.tsx create mode 100644 apps/internal-ui/src/features/help-center/HelpLayout.tsx create mode 100644 apps/internal-ui/src/features/help-center/HelpSearch.tsx create mode 100644 apps/internal-ui/src/features/help-center/MarkdownContent.test.tsx create mode 100644 apps/internal-ui/src/features/help-center/MarkdownContent.tsx create mode 100644 apps/internal-ui/src/features/help-center/SupportLauncher.test.ts create mode 100644 apps/internal-ui/src/features/help-center/SupportLauncher.tsx create mode 100644 apps/internal-ui/src/features/help-center/api.ts create mode 100644 apps/internal-ui/src/features/help-center/styles-article.css create mode 100644 apps/internal-ui/src/features/help-center/styles-home.css create mode 100644 apps/internal-ui/src/features/help-center/styles-layout.css create mode 100644 apps/internal-ui/src/features/help-center/styles-responsive.css create mode 100644 apps/internal-ui/src/features/help-center/types.ts create mode 100644 apps/internal-ui/src/features/support-portals/PortalArticleEditor.tsx create mode 100644 apps/internal-ui/src/features/support-portals/PortalCategoryManagement.tsx create mode 100644 apps/internal-ui/src/features/support-portals/PortalContent.tsx create mode 100644 apps/internal-ui/src/features/support-portals/PortalCreateDialog.tsx create mode 100644 apps/internal-ui/src/features/support-portals/PortalDomainSettings.tsx create mode 100644 apps/internal-ui/src/features/support-portals/PortalSettings.tsx create mode 100644 apps/internal-ui/src/features/support-portals/SupportPortalDetailPage.tsx create mode 100644 apps/internal-ui/src/features/support-portals/SupportPortalsPage.tsx create mode 100644 apps/internal-ui/src/features/support-portals/model.ts create mode 100644 apps/internal-ui/src/features/support-portals/styles-detail.css create mode 100644 apps/internal-ui/src/features/support-portals/styles-list.css create mode 100644 apps/internal-ui/src/features/support-portals/styles.ts create mode 100644 apps/internal-ui/src/shared/content-library/CategoryManagementDialog.tsx create mode 100644 apps/internal-ui/src/shared/content-library/CategoryManagementRow.tsx create mode 100644 apps/internal-ui/src/shared/content-library/CategoryTree.tsx create mode 100644 apps/internal-ui/src/shared/content-library/ContentEditorBreadcrumb.tsx create mode 100644 apps/internal-ui/src/shared/content-library/ContentEditorCard.tsx create mode 100644 apps/internal-ui/src/shared/content-library/ContentLibraryTable.tsx create mode 100644 apps/internal-ui/src/shared/content-library/ContentLibraryToolbar.tsx create mode 100644 apps/internal-ui/src/shared/content-library/categoryManagementModel.ts create mode 100644 apps/internal-ui/src/surface.test.ts create mode 100644 apps/internal-ui/src/surface.ts diff --git a/.env.example b/.env.example index 3318ebe..e1e664f 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,9 @@ COMPOSE_PROJECT_NAME=edevs_hub CUSTOCRM_APP_DOMAIN=app.localhost CUSTOCRM_PLATFORM_DOMAIN=platform.localhost +CUS_HELP_BASE_DOMAIN=localhost +CUS_HELP_PUBLIC_SCHEME=http +CUS_HELP_PUBLIC_PORT=8080 CUSTOCRM_ACME_EMAIL=local@example.invalid CUSTOCRM_ADMIN_PORT=18001 diff --git a/Caddyfile b/Caddyfile index 3823a59..b596631 100644 --- a/Caddyfile +++ b/Caddyfile @@ -7,6 +7,25 @@ { email {$CUSTOCRM_ACME_EMAIL:} # Caddy admin API не публикуется наружу (default localhost:2019). + on_demand_tls { + ask http://backend-platform:8000/api/v1/gateway/help-domain/ + } +} + +# Published hosted and verified custom Help Center domains. Certificate +# issuance is authorized against the platform ingress directory above. +https:// { + encode gzip zstd + tls { + on_demand + } + + reverse_proxy frontend:80 { + header_up Host {host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } } # Tenant-facing application surface. diff --git a/apps/backend/hub_backend/settings_app.py b/apps/backend/hub_backend/settings_app.py index 405d143..43d7440 100644 --- a/apps/backend/hub_backend/settings_app.py +++ b/apps/backend/hub_backend/settings_app.py @@ -13,10 +13,22 @@ WSGI_APPLICATION = "hub_backend.wsgi_app.application" _app_hosts = os.environ.get("CUSTOCRM_APP_ALLOWED_HOSTS", "") if not DEBUG and not TESTING and not _app_hosts: raise ImproperlyConfigured("CUSTOCRM_APP_ALLOWED_HOSTS is required for the app surface") -ALLOWED_HOSTS = env_list( +CUS_APP_PRIMARY_HOSTS = env_list( "CUSTOCRM_APP_ALLOWED_HOSTS", env_list("CUS_ALLOWED_HOSTS", ["localhost", "127.0.0.1", "app.localhost"]), ) +_help_host_pattern = f".{CUS_HELP_BASE_DOMAIN}" +if _help_host_pattern not in CUS_APP_PRIMARY_HOSTS: + CUS_APP_PRIMARY_HOSTS.append(_help_host_pattern) +if TESTING and "testserver" not in CUS_APP_PRIMARY_HOSTS: + CUS_APP_PRIMARY_HOSTS.append("testserver") +# Custom portal domains are checked against the published ingress directory by +# SupportPortalHostBoundaryMiddleware. Django's static list cannot express them. +ALLOWED_HOSTS = ["*"] +MIDDLEWARE.insert( + 1, + "hub_platform.support_portals.host_boundary.SupportPortalHostBoundaryMiddleware", +) CSRF_TRUSTED_ORIGINS = env_list( "CUSTOCRM_APP_CSRF_TRUSTED_ORIGINS", env_list("CUS_CSRF_TRUSTED_ORIGINS", []), diff --git a/apps/backend/hub_backend/settings_base.py b/apps/backend/hub_backend/settings_base.py index 605b57a..725d0e8 100644 --- a/apps/backend/hub_backend/settings_base.py +++ b/apps/backend/hub_backend/settings_base.py @@ -18,6 +18,8 @@ TESTING = "test" in sys.argv or "pytest" in sys.modules SECRET_KEY = os.environ.get("CUS_SECRET_KEY", INSECURE_SECRET_KEY) DEBUG = env_bool("CUS_DEBUG") ALLOWED_HOSTS = env_list("CUS_ALLOWED_HOSTS", ["localhost", "127.0.0.1"]) +if TESTING: + ALLOWED_HOSTS.extend(["testserver", ".help.custocrm.ru", ".localhost"]) CSRF_TRUSTED_ORIGINS = env_list("CUS_CSRF_TRUSTED_ORIGINS", []) # Запрещаем запуск в production с дефолтным/пустым ключом подписи. @@ -50,6 +52,7 @@ INSTALLED_APPS = [ "hub_platform.health", "hub_platform.events", "hub_platform.support", + "hub_platform.support_portals", "hub_platform.calls", # django-channels НЕ добавляется в INSTALLED_APPS: его app label «channels» # конфликтует с доменным hub_platform.channels, а без runserver-оверрайда @@ -205,6 +208,14 @@ CUS_STORAGE_BACKEND, MEDIA_ROOT, STORAGES = build_storage_settings( # Публичный адрес Hub: абсолютные ссылки, уходящие клиентам (download вложений). CUS_PUBLIC_BASE_URL = os.environ.get("CUS_PUBLIC_BASE_URL", "http://localhost:8000") +# Публичные порталы поддержки размещаются на отдельных хостах. +CUS_HELP_BASE_DOMAIN = os.environ.get( + "CUS_HELP_BASE_DOMAIN", + "help.custocrm.ru", +).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() + # 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))) CUS_CALL_ACCESS_TTL_SECONDS = int(os.environ.get("CUS_CALL_ACCESS_TTL_SECONDS", str(60 * 60))) @@ -245,6 +256,7 @@ _THROTTLE_RATES = { "call_invite": "30/min", # Страница звонка поллит состояние по access token — лимит с запасом. "call_access": "120/min", + "help_feedback": "20/hour", } if TESTING: _THROTTLE_RATES = {scope: None for scope in _THROTTLE_RATES} diff --git a/apps/backend/hub_backend/settings_platform.py b/apps/backend/hub_backend/settings_platform.py index b0fe48a..4d2087d 100644 --- a/apps/backend/hub_backend/settings_platform.py +++ b/apps/backend/hub_backend/settings_platform.py @@ -19,6 +19,8 @@ ALLOWED_HOSTS = env_list( "CUSTOCRM_PLATFORM_ALLOWED_HOSTS", ["localhost", "127.0.0.1", "platform.localhost"], ) +if "backend-platform" not in ALLOWED_HOSTS: + ALLOWED_HOSTS.append("backend-platform") CSRF_TRUSTED_ORIGINS = env_list("CUSTOCRM_PLATFORM_CSRF_TRUSTED_ORIGINS", []) CORS_ALLOWED_ORIGINS = [] diff --git a/apps/backend/hub_backend/urls_app.py b/apps/backend/hub_backend/urls_app.py index 4331652..88d9502 100644 --- a/apps/backend/hub_backend/urls_app.py +++ b/apps/backend/hub_backend/urls_app.py @@ -60,5 +60,6 @@ urlpatterns = [ path("api/v1/orders/", include("hub_platform.orders.public_urls")), path("api/v1/ai/", include("hub_platform.ai.public_urls")), path("api/v1/support/", include("hub_platform.support.public_urls")), + path("api/v1/help/", include("hub_platform.support_portals.public_urls")), path("api/v1/calls/", include("hub_platform.calls.public_urls")), ] diff --git a/apps/backend/hub_backend/urls_platform.py b/apps/backend/hub_backend/urls_platform.py index 53a0c06..7a775ea 100644 --- a/apps/backend/hub_backend/urls_platform.py +++ b/apps/backend/hub_backend/urls_platform.py @@ -1,6 +1,12 @@ from django.urls import include, path +from hub_platform.support_portals.gateway_views import HelpDomainAuthorizationView urlpatterns = [ path("api/v1/health/", include("hub_platform.health.urls")), + path( + "api/v1/gateway/help-domain/", + HelpDomainAuthorizationView.as_view(), + name="gateway-help-domain", + ), path("api/v1/", include("hub_platform.platform.urls")), ] diff --git a/apps/backend/hub_platform/api/permissions.py b/apps/backend/hub_platform/api/permissions.py index 8a849fc..cfb9a6b 100644 --- a/apps/backend/hub_platform/api/permissions.py +++ b/apps/backend/hub_platform/api/permissions.py @@ -2,6 +2,7 @@ from rest_framework.permissions import BasePermission from rest_framework.request import Request from rest_framework.views import APIView +from hub_platform.identity.models import Department from hub_platform.identity.policy import ResourceScope, authorize, has_capability_any_scope from hub_platform.subscriptions.policy import get_effective_policy @@ -26,6 +27,24 @@ class HasCapability(BasePermission): if context is None or context.membership is None: return False profile = context.membership + department_code = getattr(view, "required_department_code", None) + if department_code: + department_id = ( + Department.objects.filter( + organization_id=profile.organization_id, + code=department_code, + ) + .values_list("id", flat=True) + .first() + ) + return bool(department_id) and authorize( + profile, + capability, + ResourceScope( + organization_id=profile.organization_id, + department_id=department_id, + ), + ) if getattr(view, "require_organization_scope", False): return authorize( profile, diff --git a/apps/backend/hub_platform/subscriptions/default_subscription.py b/apps/backend/hub_platform/subscriptions/default_subscription.py index 6f8cc61..55fec79 100644 --- a/apps/backend/hub_platform/subscriptions/default_subscription.py +++ b/apps/backend/hub_platform/subscriptions/default_subscription.py @@ -50,10 +50,18 @@ def _startup_plan_version() -> PlanVersion: defaults={"agent_unit_price_minor": 290_000, "currency": "RUB", "billing_period": "MONTH"}, ) if created: - entitlement, _ = EntitlementDefinition.objects.get_or_create( - key="byok_ai", defaults={"name": "BYOK AI"} - ) - EntitlementGrant.objects.get_or_create(plan_version=version, definition=entitlement) + for key, name in ( + ("byok_ai", "BYOK AI"), + ("support_department", "Support department"), + ("knowledge_base", "Knowledge base"), + ): + entitlement, _ = EntitlementDefinition.objects.get_or_create( + key=key, defaults={"name": name} + ) + EntitlementGrant.objects.get_or_create( + plan_version=version, + definition=entitlement, + ) slots_quota, _ = QuotaDefinition.objects.get_or_create( key=QuotaKey.AI_AGENT_SLOTS, defaults={"name": "AI agent slots", "unit": "slots"} ) @@ -65,6 +73,19 @@ def _startup_plan_version() -> PlanVersion: "limit_source": QuotaLimitSource.SUBSCRIPTION_AI_AGENT_QUANTITY, }, ) + portal_quota, _ = QuotaDefinition.objects.get_or_create( + key=QuotaKey.SUPPORT_PORTALS, + defaults={"name": "Support portals", "unit": "portals"}, + ) + QuotaGrant.objects.get_or_create( + plan_version=version, + definition=portal_quota, + defaults={ + "mode": QuotaMode.UNLIMITED, + "limit_source": QuotaLimitSource.FIXED, + "limit_value": None, + }, + ) # C07: concurrent_p2p_calls derives from the active non-OWNER membership # count, matching the published STARTUP PlanVersion (PLAN §12). _ensure_membership_quota( diff --git a/apps/backend/hub_platform/subscriptions/keys.py b/apps/backend/hub_platform/subscriptions/keys.py index 81a7154..c80042c 100644 --- a/apps/backend/hub_platform/subscriptions/keys.py +++ b/apps/backend/hub_platform/subscriptions/keys.py @@ -34,3 +34,4 @@ class QuotaKey(models.TextChoices): CONCURRENT_P2P_CALLS = "concurrent_p2p_calls", "Concurrent P2P calls" CONCURRENT_VOICE_SESSIONS = "concurrent_voice_sessions", "Concurrent voice sessions" AUDIT_RETENTION_DAYS = "audit_retention_days", "Audit retention days" + SUPPORT_PORTALS = "support_portals", "Support portals" diff --git a/apps/backend/hub_platform/subscriptions/migrations/0008_support_portal_quota_definition.py b/apps/backend/hub_platform/subscriptions/migrations/0008_support_portal_quota_definition.py new file mode 100644 index 0000000..99035aa --- /dev/null +++ b/apps/backend/hub_platform/subscriptions/migrations/0008_support_portal_quota_definition.py @@ -0,0 +1,19 @@ +from django.db import migrations + + +def add_support_portal_quota_definition(apps, schema_editor): + QuotaDefinition = apps.get_model("subscriptions", "QuotaDefinition") + QuotaDefinition.objects.get_or_create( + key="support_portals", + defaults={"name": "Support portals", "unit": "portals"}, + ) + + +class Migration(migrations.Migration): + dependencies = [("subscriptions", "0007_quota_accounting_and_reservation_quantity")] + operations = [ + migrations.RunPython( + add_support_portal_quota_definition, + migrations.RunPython.noop, + ), + ] diff --git a/apps/backend/hub_platform/subscriptions/migrations/0009_support_portal_plan_grants.py b/apps/backend/hub_platform/subscriptions/migrations/0009_support_portal_plan_grants.py new file mode 100644 index 0000000..27059c1 --- /dev/null +++ b/apps/backend/hub_platform/subscriptions/migrations/0009_support_portal_plan_grants.py @@ -0,0 +1,114 @@ +from django.db import migrations +from django.db.models import Max +from django.utils import timezone + + +LIMITS = { + "FREE": None, + "STARTUP": None, + "BUSINESS": None, + "CORPORATION": None, +} + + +def _add_grant(QuotaGrant, version, definition, limit): + QuotaGrant.objects.get_or_create( + plan_version=version, + definition=definition, + defaults={ + "mode": "UNLIMITED" if limit is None else "HARD", + "limit_source": "FIXED", + "limit_value": limit, + "window_seconds": None, + }, + ) + + +def add_support_portal_plan_grants(apps, schema_editor): + PlanVersion = apps.get_model("subscriptions", "PlanVersion") + EntitlementGrant = apps.get_model("subscriptions", "EntitlementGrant") + QuotaDefinition = apps.get_model("subscriptions", "QuotaDefinition") + QuotaGrant = apps.get_model("subscriptions", "QuotaGrant") + Subscription = apps.get_model("subscriptions", "Subscription") + + definition = QuotaDefinition.objects.get(key="support_portals") + published_sources = [] + for version in PlanVersion.objects.select_related("plan").order_by("plan_id", "version"): + if version.plan.code not in LIMITS: + continue + if QuotaGrant.objects.filter( + plan_version=version, + definition=definition, + ).exists(): + continue + if version.published_at is None: + _add_grant( + QuotaGrant, + version, + definition, + LIMITS[version.plan.code], + ) + else: + published_sources.append(version) + + for source in published_sources: + next_version = ( + PlanVersion.objects.filter(plan_id=source.plan_id).aggregate(Max("version"))[ + "version__max" + ] + + 1 + ) + replacement = PlanVersion.objects.create( + plan_id=source.plan_id, + version=next_version, + agent_unit_price_minor=source.agent_unit_price_minor, + currency=source.currency, + billing_period=source.billing_period, + fixed_ai_agent_quantity=source.fixed_ai_agent_quantity, + effective_from=timezone.now(), + transition_rules=source.transition_rules, + ) + EntitlementGrant.objects.bulk_create( + [ + EntitlementGrant( + plan_version=replacement, + definition_id=grant.definition_id, + enabled=grant.enabled, + ) + for grant in EntitlementGrant.objects.filter(plan_version=source) + ] + ) + QuotaGrant.objects.bulk_create( + [ + QuotaGrant( + plan_version=replacement, + definition_id=grant.definition_id, + mode=grant.mode, + limit_source=grant.limit_source, + limit_value=grant.limit_value, + window_seconds=grant.window_seconds, + ) + for grant in QuotaGrant.objects.filter(plan_version=source) + ] + ) + _add_grant( + QuotaGrant, + replacement, + definition, + LIMITS[source.plan.code], + ) + replacement.published_at = timezone.now() + replacement.save(update_fields=["published_at"]) + Subscription.objects.filter(plan_version=source).update( + plan_version=replacement + ) + + +class Migration(migrations.Migration): + dependencies = [("subscriptions", "0008_support_portal_quota_definition")] + operations = [ + migrations.RunPython( + add_support_portal_plan_grants, + migrations.RunPython.noop, + ), + ] diff --git a/apps/backend/hub_platform/subscriptions/migrations/0010_remove_support_portal_limit.py b/apps/backend/hub_platform/subscriptions/migrations/0010_remove_support_portal_limit.py new file mode 100644 index 0000000..7426431 --- /dev/null +++ b/apps/backend/hub_platform/subscriptions/migrations/0010_remove_support_portal_limit.py @@ -0,0 +1,98 @@ +from django.db import migrations +from django.db.models import Max +from django.utils import timezone + + +def _clone_with_unlimited_portals( + PlanVersion, + EntitlementGrant, + QuotaGrant, + source, + definition, +): + next_version = ( + PlanVersion.objects.filter(plan_id=source.plan_id).aggregate(Max("version"))[ + "version__max" + ] + + 1 + ) + replacement = PlanVersion.objects.create( + plan_id=source.plan_id, + version=next_version, + agent_unit_price_minor=source.agent_unit_price_minor, + currency=source.currency, + billing_period=source.billing_period, + fixed_ai_agent_quantity=source.fixed_ai_agent_quantity, + effective_from=timezone.now(), + transition_rules=source.transition_rules, + ) + EntitlementGrant.objects.bulk_create( + [ + EntitlementGrant( + plan_version=replacement, + definition_id=grant.definition_id, + enabled=grant.enabled, + ) + for grant in EntitlementGrant.objects.filter(plan_version=source) + ] + ) + QuotaGrant.objects.bulk_create( + [ + QuotaGrant( + plan_version=replacement, + definition_id=grant.definition_id, + mode="UNLIMITED" if grant.definition_id == definition.id else grant.mode, + limit_source=grant.limit_source, + limit_value=None if grant.definition_id == definition.id else grant.limit_value, + window_seconds=grant.window_seconds, + ) + for grant in QuotaGrant.objects.filter(plan_version=source) + ] + ) + replacement.published_at = timezone.now() + replacement.save(update_fields=["published_at"]) + return replacement + + +def remove_support_portal_limit(apps, schema_editor): + PlanVersion = apps.get_model("subscriptions", "PlanVersion") + EntitlementGrant = apps.get_model("subscriptions", "EntitlementGrant") + QuotaDefinition = apps.get_model("subscriptions", "QuotaDefinition") + QuotaGrant = apps.get_model("subscriptions", "QuotaGrant") + Subscription = apps.get_model("subscriptions", "Subscription") + definition = QuotaDefinition.objects.filter(key="support_portals").first() + if definition is None: + return + + source_ids = list( + QuotaGrant.objects.filter(definition=definition) + .exclude(mode="UNLIMITED") + .values_list("plan_version_id", flat=True) + ) + for source in PlanVersion.objects.filter(id__in=source_ids): + if source.published_at is None: + QuotaGrant.objects.filter( + plan_version=source, + definition=definition, + ).update(mode="UNLIMITED", limit_value=None, window_seconds=None) + continue + replacement = _clone_with_unlimited_portals( + PlanVersion, + EntitlementGrant, + QuotaGrant, + source, + definition, + ) + Subscription.objects.filter(plan_version=source).update( + plan_version=replacement + ) + + +class Migration(migrations.Migration): + dependencies = [("subscriptions", "0009_support_portal_plan_grants")] + operations = [ + migrations.RunPython( + remove_support_portal_limit, + migrations.RunPython.noop, + ), + ] diff --git a/apps/backend/hub_platform/subscriptions/tests/test_plans.py b/apps/backend/hub_platform/subscriptions/tests/test_plans.py index 1c92a54..7334a2e 100644 --- a/apps/backend/hub_platform/subscriptions/tests/test_plans.py +++ b/apps/backend/hub_platform/subscriptions/tests/test_plans.py @@ -49,7 +49,9 @@ class PlanVersionTests(TestCase): self.assertEqual( free_quotas[QuotaKey.CONCURRENT_VOICE_SESSIONS], ("CONCURRENT", 0, None) ) - + self.assertEqual( + free_quotas[QuotaKey.SUPPORT_PORTALS], ("UNLIMITED", None, None) + ) startup_quotas = self._quota_map(startup) self.assertEqual( startup_quotas[QuotaKey.MANAGED_AI_CREDITS], ("HARD", 1000, None) @@ -69,7 +71,9 @@ class PlanVersionTests(TestCase): startup_quotas[QuotaKey.CONCURRENT_VOICE_SESSIONS], ("CONCURRENT", 10, None), ) - + self.assertEqual( + startup_quotas[QuotaKey.SUPPORT_PORTALS], ("UNLIMITED", None, None) + ) # BUSINESS received the owner-approved Managed AI pool in migration 0006. business_quotas = self._quota_map(business) self.assertEqual( @@ -77,10 +81,24 @@ class PlanVersionTests(TestCase): ) self.assertEqual( set(business_quotas), - {QuotaKey.AI_AGENT_SLOTS, QuotaKey.MANAGED_AI_CREDITS}, + { + QuotaKey.AI_AGENT_SLOTS, + QuotaKey.MANAGED_AI_CREDITS, + QuotaKey.SUPPORT_PORTALS, + }, + ) + self.assertEqual( + business_quotas[QuotaKey.SUPPORT_PORTALS], ("UNLIMITED", None, None) ) # CORPORATION remains contract-specific and has no numeric credits grant. - self.assertEqual(set(self._quota_map(corporation)), {QuotaKey.AI_AGENT_SLOTS}) + corporation_quotas = self._quota_map(corporation) + self.assertEqual( + set(corporation_quotas), + {QuotaKey.AI_AGENT_SLOTS, QuotaKey.SUPPORT_PORTALS}, + ) + self.assertEqual( + corporation_quotas[QuotaKey.SUPPORT_PORTALS], ("UNLIMITED", None, None) + ) def test_business_has_3000_managed_credits(self) -> None: business = PlanVersion.objects.get(plan__code=PlanCode.BUSINESS, version=1) diff --git a/apps/backend/hub_platform/support/urls.py b/apps/backend/hub_platform/support/urls.py index 284bccb..d4c13c4 100644 --- a/apps/backend/hub_platform/support/urls.py +++ b/apps/backend/hub_platform/support/urls.py @@ -1,8 +1,9 @@ -from django.urls import path +from django.urls import include, path from hub_platform.support import views urlpatterns = [ + path("portals/", include("hub_platform.support_portals.urls")), path("contracts/", views.SupportContractListView.as_view(), name="support-contract-list"), path( "contracts//", diff --git a/apps/backend/hub_platform/support_portals/__init__.py b/apps/backend/hub_platform/support_portals/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/backend/hub_platform/support_portals/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/backend/hub_platform/support_portals/addressing.py b/apps/backend/hub_platform/support_portals/addressing.py new file mode 100644 index 0000000..fd72c43 --- /dev/null +++ b/apps/backend/hub_platform/support_portals/addressing.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import re + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models + + +HOST_LABEL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$") + + +def normalize_domain(value: str) -> str: + return value.strip().lower().rstrip(".") + + +def validate_domain(value: str) -> str: + domain = normalize_domain(value) + if not domain or len(domain) > 253: + raise ValidationError("Некорректное доменное имя") + labels = domain.split(".") + if len(labels) < 2 or any(not HOST_LABEL_RE.fullmatch(label) for label in labels): + raise ValidationError("Некорректное доменное имя") + return domain + + +def hosted_domain(portal_key: str) -> str: + base_domain = normalize_domain(settings.CUS_HELP_BASE_DOMAIN) + return validate_domain(f"{portal_key}.{base_domain}") + + +def clean_portal_domains(portal) -> None: + portal.slug = portal.slug.strip().lower() + portal.hosted_domain = hosted_domain(portal.slug) + portal.custom_domain = normalize_domain(portal.custom_domain) + if portal.custom_domain: + portal.custom_domain = validate_domain(portal.custom_domain) + application_hosts = { + normalize_domain(value.lstrip(".")) + for value in getattr(settings, "CUS_APP_PRIMARY_HOSTS", []) + if value and not value.startswith(".") + } + if portal.custom_domain in application_hosts: + raise ValidationError( + {"custom_domain": "Домен внутреннего приложения использовать нельзя"} + ) + if portal.custom_domain == portal.hosted_domain: + raise ValidationError( + {"custom_domain": "Свой домен должен отличаться от адреса CustoCRM"} + ) + collision = portal.__class__.objects.exclude(pk=portal.pk).filter( + models.Q(hosted_domain=portal.custom_domain) + | models.Q(custom_domain=portal.custom_domain) + | models.Q(custom_domain=portal.hosted_domain) + ) + if collision.exists(): + raise ValidationError( + {"custom_domain": "Этот домен уже используется другим порталом"} + ) + elif portal.custom_domain_verified_at is not None: + raise ValidationError( + {"custom_domain_verified_at": "Нельзя подтвердить пустой домен"} + ) + elif portal.__class__.objects.exclude(pk=portal.pk).filter( + custom_domain=portal.hosted_domain + ).exists(): + raise ValidationError({"slug": "Этот адрес уже используется другим порталом"}) + + +def portal_public_url(*, hosted: str, custom: str = "", custom_verified: bool = False) -> str: + host = custom if custom and custom_verified else hosted + scheme = "https" if custom and custom_verified else settings.CUS_HELP_PUBLIC_SCHEME + port = "" if custom and custom_verified else settings.CUS_HELP_PUBLIC_PORT + return f"{scheme}://{host}{f':{port}' if port else ''}" diff --git a/apps/backend/hub_platform/support_portals/admin.py b/apps/backend/hub_platform/support_portals/admin.py new file mode 100644 index 0000000..e857c7c --- /dev/null +++ b/apps/backend/hub_platform/support_portals/admin.py @@ -0,0 +1,22 @@ +from django.contrib import admin + +from hub_platform.support_portals.models import ( + PortalArticle, + PortalArticleFeedback, + PortalArticleRevision, + PortalCategory, + SupportPortal, + SupportPortalProduct, +) + + +admin.site.register( + ( + SupportPortal, + SupportPortalProduct, + PortalCategory, + PortalArticle, + PortalArticleRevision, + PortalArticleFeedback, + ) +) diff --git a/apps/backend/hub_platform/support_portals/api.py b/apps/backend/hub_platform/support_portals/api.py new file mode 100644 index 0000000..8dc36a3 --- /dev/null +++ b/apps/backend/hub_platform/support_portals/api.py @@ -0,0 +1,14 @@ +from django.core.exceptions import ValidationError +from rest_framework.response import Response + + +def validation_response(error: ValidationError) -> Response: + if hasattr(error, "message_dict"): + errors = { + field: [str(message) for message in messages] + for field, messages in error.message_dict.items() + } + detail = "; ".join(message for messages in errors.values() for message in messages) + return Response({"detail": detail, "errors": errors}, status=400) + detail = "; ".join(str(message) for message in error.messages) + return Response({"detail": detail}, status=400) diff --git a/apps/backend/hub_platform/support_portals/apps.py b/apps/backend/hub_platform/support_portals/apps.py new file mode 100644 index 0000000..95d7d78 --- /dev/null +++ b/apps/backend/hub_platform/support_portals/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SupportPortalsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "hub_platform.support_portals" diff --git a/apps/backend/hub_platform/support_portals/content_services.py b/apps/backend/hub_platform/support_portals/content_services.py new file mode 100644 index 0000000..c824c5b --- /dev/null +++ b/apps/backend/hub_platform/support_portals/content_services.py @@ -0,0 +1,180 @@ +from django.core.exceptions import ValidationError +from django.db import transaction +from django.db.models import Max +from django.utils import timezone +from django.utils.text import slugify + +from hub_platform.support_portals.models import ( + PortalArticle, + PortalArticleFeedback, + PortalArticleRevision, + PortalCategory, + SupportPortal, +) +from hub_platform.support_portals.statuses import ArticleStatus, PortalStatus +from hub_platform.tenancy.context import TenantContext + + +def create_category( + *, context: TenantContext, portal: SupportPortal, data: dict +) -> PortalCategory: + ensure_portal_editable(portal) + parent = _category(portal, data.get("parentId")) if data.get("parentId") else None + name = str(data.get("name", "")).strip() + category = PortalCategory( + organization=context.organization, + portal=portal, + parent=parent, + slug=str(data.get("slug", "")).strip().lower() or _category_slug(portal, name), + name=name, + description=str(data.get("description", "")).strip(), + sort_order=int(data.get("sortOrder", 0)), + ) + category.full_clean() + category.save() + return category + + +def _category_slug(portal: SupportPortal, name: str) -> str: + base = slugify(name)[:54] or "category" + slug = base + suffix = 2 + while portal.categories.filter(slug=slug).exists(): + slug = f"{base[: 63 - len(str(suffix)) - 1]}-{suffix}" + suffix += 1 + return slug + + +def update_category( + *, portal: SupportPortal, category: PortalCategory, data: dict +) -> PortalCategory: + ensure_portal_editable(portal) + category.parent = ( + _category(portal, data.get("parentId")) if data.get("parentId") else None + ) + category.slug = str(data.get("slug", category.slug)).strip().lower() + category.name = str(data.get("name", category.name)).strip() + category.description = str( + data.get("description", category.description) + ).strip() + category.sort_order = int(data.get("sortOrder", category.sort_order)) + category.full_clean() + category.save() + return category + + +def delete_category(*, portal: SupportPortal, category: PortalCategory) -> None: + ensure_portal_editable(portal) + if category.children.exists(): + raise ValidationError( + {"category": "Сначала переместите или удалите вложенные разделы"} + ) + if category.articles.exists(): + raise ValidationError( + {"category": "Сначала переместите статьи из этого раздела"} + ) + category.delete() + + +@transaction.atomic +def create_article( + *, context: TenantContext, portal: SupportPortal, data: dict +) -> PortalArticle: + ensure_portal_editable(portal) + category = _category(portal, data.get("categoryId")) + article = PortalArticle( + organization=context.organization, + portal=portal, + category=category, + slug=str(data.get("slug", "")).strip().lower(), + locale=str(data.get("locale", portal.default_locale)).strip().lower(), + ) + article.full_clean() + article.save() + add_revision(context=context, article=article, data=data) + return article + + +def add_revision( + *, context: TenantContext, article: PortalArticle, data: dict +) -> PortalArticleRevision: + ensure_portal_editable(article.portal) + if article.status == ArticleStatus.ARCHIVED: + raise ValidationError({"article": "Архивную статью нельзя изменять"}) + last = article.revisions.aggregate(value=Max("revision"))["value"] or 0 + revision = PortalArticleRevision( + organization=context.organization, + article=article, + revision=last + 1, + title=str(data.get("title", "")).strip(), + summary=str(data.get("summary", "")).strip(), + content=str(data.get("content", "")), + ) + revision.full_clean() + revision.save() + return revision + + +def update_article( + *, article: PortalArticle, data: dict +) -> PortalArticle: + ensure_portal_editable(article.portal) + if article.status == ArticleStatus.ARCHIVED: + raise ValidationError({"article": "Архивную статью нельзя изменять"}) + article.category = _category( + article.portal, data.get("categoryId", article.category_id) + ) + article.slug = str(data.get("slug", article.slug)).strip().lower() + article.locale = str(data.get("locale", article.locale)).strip().lower() + article.full_clean() + article.save(update_fields=["category", "slug", "locale", "updated_at"]) + return article + + +@transaction.atomic +def publish_revision( + *, article: PortalArticle, revision_id: int +) -> PortalArticle: + ensure_portal_editable(article.portal) + if article.status == ArticleStatus.ARCHIVED: + raise ValidationError({"article": "Архивную статью нельзя публиковать"}) + try: + revision = article.revisions.get(id=revision_id) + except PortalArticleRevision.DoesNotExist as error: + raise ValidationError({"revisionId": "Версия статьи не найдена"}) from error + revision.published_at = timezone.now() + revision.save(update_fields=["published_at"]) + article.published_revision = revision + article.status = ArticleStatus.PUBLISHED + article.full_clean() + article.save(update_fields=["published_revision", "status", "updated_at"]) + return article + + +def archive_article(article: PortalArticle) -> PortalArticle: + ensure_portal_editable(article.portal) + article.status = ArticleStatus.ARCHIVED + article.save(update_fields=["status", "updated_at"]) + return article + + +def record_feedback(article: PortalArticle, helpful: bool) -> None: + PortalArticleFeedback.objects.create( + organization=article.organization, + article=article, + helpful=helpful, + ) + + +def _category(portal: SupportPortal, category_id) -> PortalCategory: + try: + return portal.categories.get(id=int(category_id)) + except (PortalCategory.DoesNotExist, TypeError, ValueError) as error: + raise ValidationError({"categoryId": "Раздел не найден"}) from error + + +def ensure_portal_editable(portal: SupportPortal) -> None: + if portal.status == PortalStatus.ARCHIVED: + raise ValidationError( + {"portal": "Восстановите портал, чтобы изменить его содержимое"} + ) diff --git a/apps/backend/hub_platform/support_portals/content_views.py b/apps/backend/hub_platform/support_portals/content_views.py new file mode 100644 index 0000000..bbc855e --- /dev/null +++ b/apps/backend/hub_platform/support_portals/content_views.py @@ -0,0 +1,211 @@ +from django.core.exceptions import ValidationError +from rest_framework.request import Request +from rest_framework.response import Response + +from hub_platform.support_portals.api import validation_response +from hub_platform.support_portals.content_services import ( + add_revision, + archive_article, + create_article, + create_category, + delete_category, + publish_revision, + update_category, + update_article, +) +from hub_platform.support_portals.models import PortalArticle +from hub_platform.support_portals.portal_views import PortalBaseView +from hub_platform.support_portals.serializers import ( + article_payload, + category_payload, + revision_payload, +) +from hub_platform.support_portals.selectors import category_article_counts + + +def _article(portal, article_id: int) -> PortalArticle | None: + return ( + portal.articles.select_related("portal", "category", "published_revision") + .prefetch_related("revisions") + .filter(id=article_id) + .first() + ) + + +class CategoryListView(PortalBaseView): + def get(self, request: Request, portal_id: int) -> Response: + portal = self.portal(request, portal_id) + if portal is None: + return Response({"detail": "Портал не найден"}, status=404) + counts = category_article_counts(portal) + return Response( + { + "items": [ + category_payload(item, article_count=counts.get(item.id, 0)) + for item in portal.categories.all() + ] + } + ) + + def post(self, request: Request, portal_id: int) -> Response: + portal = self.portal(request, portal_id) + if portal is None: + return Response({"detail": "Портал не найден"}, status=404) + try: + category = create_category( + context=request.tenant_context, + portal=portal, + data=request.data, + ) + except (ValidationError, TypeError, ValueError) as error: + if isinstance(error, ValidationError): + return validation_response(error) + return Response({"detail": "Некорректная категория"}, status=400) + return Response({"category": category_payload(category)}, status=201) + + +class CategoryDetailView(PortalBaseView): + def _category(self, request: Request, portal_id: int, category_id: int): + portal = self.portal(request, portal_id) + if portal is None: + return None, None + return portal, portal.categories.filter(id=category_id).first() + + def patch( + self, request: Request, portal_id: int, category_id: int + ) -> Response: + portal, category = self._category(request, portal_id, category_id) + if portal is None: + return Response({"detail": "Портал не найден"}, status=404) + if category is None: + return Response({"detail": "Раздел не найден"}, status=404) + try: + category = update_category( + portal=portal, category=category, data=request.data + ) + except (ValidationError, TypeError, ValueError) as error: + if isinstance(error, ValidationError): + return validation_response(error) + return Response({"detail": "Некорректный раздел"}, status=400) + return Response({"category": category_payload(category)}) + + def delete( + self, request: Request, portal_id: int, category_id: int + ) -> Response: + portal, category = self._category(request, portal_id, category_id) + if portal is None: + return Response({"detail": "Портал не найден"}, status=404) + if category is None: + return Response({"detail": "Раздел не найден"}, status=404) + try: + delete_category(portal=portal, category=category) + except ValidationError as error: + return validation_response(error) + return Response(status=204) + + +class ArticleListView(PortalBaseView): + def get(self, request: Request, portal_id: int) -> Response: + portal = self.portal(request, portal_id) + if portal is None: + return Response({"detail": "Портал не найден"}, status=404) + articles = portal.articles.select_related( + "category", "published_revision" + ).prefetch_related("revisions").all() + return Response({"items": [article_payload(item) for item in articles]}) + + def post(self, request: Request, portal_id: int) -> Response: + portal = self.portal(request, portal_id) + if portal is None: + return Response({"detail": "Портал не найден"}, status=404) + try: + article = create_article( + context=request.tenant_context, + portal=portal, + data=request.data, + ) + except (ValidationError, TypeError, ValueError) as error: + if isinstance(error, ValidationError): + return validation_response(error) + return Response({"detail": "Некорректная статья"}, status=400) + article = _article(portal, article.id) + return Response({"article": article_payload(article, revisions=True)}, status=201) + + +class ArticleDetailView(PortalBaseView): + def get(self, request: Request, portal_id: int, article_id: int) -> Response: + portal = self.portal(request, portal_id) + if portal is None: + return Response({"detail": "Портал не найден"}, status=404) + article = _article(portal, article_id) + if article is None: + return Response({"detail": "Статья не найдена"}, status=404) + return Response({"article": article_payload(article, revisions=True)}) + + def patch(self, request: Request, portal_id: int, article_id: int) -> Response: + portal = self.portal(request, portal_id) + if portal is None: + return Response({"detail": "Портал не найден"}, status=404) + article = _article(portal, article_id) + if article is None: + return Response({"detail": "Статья не найдена"}, status=404) + try: + article = update_article(article=article, data=request.data) + except (ValidationError, TypeError, ValueError) as error: + if isinstance(error, ValidationError): + return validation_response(error) + return Response({"detail": "Некорректные данные статьи"}, status=400) + article = _article(portal, article.id) + return Response({"article": article_payload(article, revisions=True)}) + + +class ArticleRevisionListView(PortalBaseView): + def post(self, request: Request, portal_id: int, article_id: int) -> Response: + portal = self.portal(request, portal_id) + if portal is None: + return Response({"detail": "Портал не найден"}, status=404) + article = _article(portal, article_id) + if article is None: + return Response({"detail": "Статья не найдена"}, status=404) + try: + revision = add_revision( + context=request.tenant_context, + article=article, + data=request.data, + ) + except ValidationError as error: + return validation_response(error) + return Response({"revision": revision_payload(revision)}, status=201) + + +class ArticlePublishView(PortalBaseView): + def post(self, request: Request, portal_id: int, article_id: int) -> Response: + portal = self.portal(request, portal_id) + if portal is None: + return Response({"detail": "Портал не найден"}, status=404) + article = _article(portal, article_id) + if article is None: + return Response({"detail": "Статья не найдена"}, status=404) + try: + article = publish_revision( + article=article, + revision_id=int(request.data.get("revisionId", 0)), + ) + except (ValidationError, TypeError, ValueError) as error: + if isinstance(error, ValidationError): + return validation_response(error) + return Response({"detail": "Некорректная версия"}, status=400) + article = _article(portal, article.id) + return Response({"article": article_payload(article, revisions=True)}) + + +class ArticleArchiveView(PortalBaseView): + def post(self, request: Request, portal_id: int, article_id: int) -> Response: + portal = self.portal(request, portal_id) + if portal is None: + return Response({"detail": "Портал не найден"}, status=404) + article = _article(portal, article_id) + if article is None: + return Response({"detail": "Статья не найдена"}, status=404) + article = archive_article(article) + return Response({"article": article_payload(article, revisions=True)}) diff --git a/apps/backend/hub_platform/support_portals/domain_services.py b/apps/backend/hub_platform/support_portals/domain_services.py new file mode 100644 index 0000000..08c2a43 --- /dev/null +++ b/apps/backend/hub_platform/support_portals/domain_services.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import uuid + +import dns.exception +import dns.resolver +from django.core.exceptions import ValidationError +from django.utils import timezone + +from hub_platform.support_portals.addressing import normalize_domain, validate_domain +from hub_platform.support_portals.models import SupportPortal +from hub_platform.support_portals.statuses import PortalStatus + + +def domain_verification_name(portal: SupportPortal) -> str: + return f"_custocrm.{portal.custom_domain}" if portal.custom_domain else "" + + +def domain_verification_value(portal: SupportPortal) -> str: + return f"custocrm-verification={portal.custom_domain_verification_token}" + + +def set_custom_domain(portal: SupportPortal, value: str) -> SupportPortal: + if portal.status == PortalStatus.ARCHIVED: + raise ValidationError( + {"portal": "Восстановите портал, чтобы изменить его домен"} + ) + normalized = normalize_domain(value) + portal.custom_domain = validate_domain(normalized) if normalized else "" + portal.custom_domain_verified_at = None + portal.custom_domain_verification_token = uuid.uuid4() + portal.full_clean() + portal.save( + update_fields=[ + "custom_domain", + "custom_domain_verified_at", + "custom_domain_verification_token", + "updated_at", + ] + ) + return portal + + +def verify_custom_domain(portal: SupportPortal) -> SupportPortal: + if portal.status == PortalStatus.ARCHIVED: + raise ValidationError( + {"portal": "Восстановите портал, чтобы подтвердить его домен"} + ) + if not portal.custom_domain: + raise ValidationError({"customDomain": "Сначала укажите домен"}) + expected = domain_verification_value(portal) + try: + answers = dns.resolver.resolve(domain_verification_name(portal), "TXT") + values = { + b"".join(answer.strings).decode("utf-8", errors="replace") + for answer in answers + } + except ( + dns.resolver.NoAnswer, + dns.resolver.NXDOMAIN, + dns.resolver.NoNameservers, + dns.exception.Timeout, + ) as error: + raise ValidationError( + {"customDomain": "TXT-запись пока не найдена"} + ) from error + if expected not in values: + raise ValidationError( + {"customDomain": "TXT-запись не содержит код подтверждения"} + ) + portal.custom_domain_verified_at = timezone.now() + portal.full_clean() + portal.save(update_fields=["custom_domain_verified_at", "updated_at"]) + return portal diff --git a/apps/backend/hub_platform/support_portals/gateway_views.py b/apps/backend/hub_platform/support_portals/gateway_views.py new file mode 100644 index 0000000..8f30d28 --- /dev/null +++ b/apps/backend/hub_platform/support_portals/gateway_views.py @@ -0,0 +1,20 @@ +from rest_framework.permissions import AllowAny +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from hub_platform.support_portals.addressing import normalize_domain +from hub_platform.tenancy.ingress import support_portal_route + + +class HelpDomainAuthorizationView(APIView): + """Caddy on-demand TLS authorization for published portal hosts.""" + + authentication_classes: list = [] + permission_classes = [AllowAny] + + def get(self, request: Request) -> Response: + domain = normalize_domain(str(request.query_params.get("domain", ""))) + if not domain or support_portal_route(domain) is None: + return Response(status=404) + return Response(status=204) diff --git a/apps/backend/hub_platform/support_portals/host_boundary.py b/apps/backend/hub_platform/support_portals/host_boundary.py new file mode 100644 index 0000000..086f8a3 --- /dev/null +++ b/apps/backend/hub_platform/support_portals/host_boundary.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import sys + +from django.conf import settings +from django.db import connections +from django.http import HttpRequest, HttpResponseBadRequest + +from hub_platform.support_portals.addressing import normalize_domain +from hub_platform.tenancy.ingress import support_portal_route + + +class SupportPortalHostBoundaryMiddleware: + """Accept app hosts and hosts present in the published portal directory.""" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request: HttpRequest): + raw_host = request.META.get("HTTP_HOST") or request.META.get("SERVER_NAME", "") + host = normalize_domain(raw_host.partition(":")[0]) + if not self._allowed(host): + return HttpResponseBadRequest("Invalid host") + return self.get_response(request) + + @staticmethod + def _allowed(host: str) -> bool: + if host == "testserver" and ( + "pytest" in sys.modules + or any("pytest" in argument for argument in sys.argv) + or str(connections["default"].settings_dict["NAME"]).startswith("test_") + ): + return True + for allowed in settings.CUS_APP_PRIMARY_HOSTS: + normalized = normalize_domain(allowed.lstrip(".")) + if host == normalized or ( + allowed.startswith(".") and host.endswith(f".{normalized}") + ): + return True + try: + return support_portal_route(host) is not None + except Exception: + return False diff --git a/apps/backend/hub_platform/support_portals/migrations/0001_portals.py b/apps/backend/hub_platform/support_portals/migrations/0001_portals.py new file mode 100644 index 0000000..1790373 --- /dev/null +++ b/apps/backend/hub_platform/support_portals/migrations/0001_portals.py @@ -0,0 +1,106 @@ +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [("identity", "0016_channel_capabilities")] + + operations = [ + migrations.CreateModel( + name="SupportPortal", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("public_id", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ("slug", models.SlugField(max_length=64, unique=True)), + ("name", models.CharField(max_length=255)), + ("default_locale", models.CharField(default="ru", max_length=16)), + ( + "status", + models.CharField( + choices=[ + ("DRAFT", "Черновик"), + ("PUBLISHED", "Опубликован"), + ("ARCHIVED", "Архив"), + ], + default="DRAFT", + max_length=16, + ), + ), + ("transition_version", models.PositiveIntegerField(default=0)), + ("published_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "department", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="support_portals", + to="identity.department", + ), + ), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="identity.organization", + ), + ), + ], + options={"ordering": ["name", "id"]}, + ), + migrations.CreateModel( + name="PortalCategory", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("slug", models.SlugField(max_length=64)), + ("name", models.CharField(max_length=255)), + ("sort_order", models.PositiveIntegerField(default=0)), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="identity.organization", + ), + ), + ( + "parent", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="children", + to="support_portals.portalcategory", + ), + ), + ( + "portal", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="categories", + to="support_portals.supportportal", + ), + ), + ], + options={"ordering": ["sort_order", "name", "id"]}, + ), + migrations.AddConstraint( + model_name="supportportal", + constraint=models.CheckConstraint( + condition=models.Q(status__in=["DRAFT", "PUBLISHED", "ARCHIVED"]), + name="support_portal_status_valid", + ), + ), + migrations.AddConstraint( + model_name="portalcategory", + constraint=models.UniqueConstraint( + fields=("portal", "slug"), + name="uniq_support_portal_category_slug", + ), + ), + ] diff --git a/apps/backend/hub_platform/support_portals/migrations/0002_content_and_products.py b/apps/backend/hub_platform/support_portals/migrations/0002_content_and_products.py new file mode 100644 index 0000000..31835d0 --- /dev/null +++ b/apps/backend/hub_platform/support_portals/migrations/0002_content_and_products.py @@ -0,0 +1,212 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("channels", "0005_enforce_policy_invariants"), + ("products", "0010_alter_marketplacepublication_organization_and_more"), + ("support_portals", "0001_portals"), + ] + + operations = [ + migrations.CreateModel( + name="PortalArticle", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("slug", models.SlugField(max_length=96)), + ("locale", models.CharField(default="ru", max_length=16)), + ( + "status", + models.CharField( + choices=[ + ("DRAFT", "Черновик"), + ("PUBLISHED", "Опубликована"), + ("ARCHIVED", "Архив"), + ], + default="DRAFT", + max_length=16, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "category", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="articles", + to="support_portals.portalcategory", + ), + ), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="identity.organization", + ), + ), + ( + "portal", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="articles", + to="support_portals.supportportal", + ), + ), + ], + options={"ordering": ["slug", "id"]}, + ), + migrations.CreateModel( + name="PortalArticleRevision", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("revision", models.PositiveIntegerField()), + ("title", models.CharField(max_length=255)), + ("summary", models.CharField(blank=True, max_length=500)), + ("content", models.TextField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("published_at", models.DateTimeField(blank=True, null=True)), + ( + "article", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="revisions", + to="support_portals.portalarticle", + ), + ), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="identity.organization", + ), + ), + ], + options={"ordering": ["-revision"]}, + ), + migrations.AddField( + model_name="portalarticle", + name="published_revision", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="support_portals.portalarticlerevision", + ), + ), + migrations.CreateModel( + name="PortalArticleFeedback", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("helpful", models.BooleanField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "article", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="feedback", + to="support_portals.portalarticle", + ), + ), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="identity.organization", + ), + ), + ], + options={"ordering": ["-created_at"]}, + ), + migrations.CreateModel( + name="SupportPortalProduct", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("sort_order", models.PositiveIntegerField(default=0)), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="identity.organization", + ), + ), + ( + "portal", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="product_links", + to="support_portals.supportportal", + ), + ), + ( + "product", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="support_portal_links", + to="products.product", + ), + ), + ( + "support_channel", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="support_portal_routes", + to="channels.channel", + ), + ), + ], + options={"ordering": ["sort_order", "product__name", "id"]}, + ), + migrations.AddField( + model_name="supportportal", + name="products", + field=models.ManyToManyField( + blank=True, + related_name="support_portals", + through="support_portals.SupportPortalProduct", + to="products.product", + ), + ), + migrations.AddConstraint( + model_name="portalarticle", + constraint=models.UniqueConstraint( + fields=("portal", "locale", "slug"), + name="uniq_support_portal_article_slug", + ), + ), + migrations.AddConstraint( + model_name="portalarticle", + constraint=models.CheckConstraint( + condition=models.Q(status__in=["DRAFT", "PUBLISHED", "ARCHIVED"]), + name="support_portal_article_status_valid", + ), + ), + migrations.AddConstraint( + model_name="portalarticlerevision", + constraint=models.UniqueConstraint( + fields=("article", "revision"), + name="uniq_support_portal_article_revision", + ), + ), + migrations.AddConstraint( + model_name="portalarticlerevision", + constraint=models.CheckConstraint( + condition=models.Q(revision__gt=0), + name="support_portal_article_revision_positive", + ), + ), + migrations.AddConstraint( + model_name="supportportalproduct", + constraint=models.UniqueConstraint( + fields=("portal", "product"), + name="uniq_support_portal_product", + ), + ), + ] diff --git a/apps/backend/hub_platform/support_portals/migrations/0003_category_description.py b/apps/backend/hub_platform/support_portals/migrations/0003_category_description.py new file mode 100644 index 0000000..20a9a74 --- /dev/null +++ b/apps/backend/hub_platform/support_portals/migrations/0003_category_description.py @@ -0,0 +1,13 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("support_portals", "0002_content_and_products")] + + operations = [ + migrations.AddField( + model_name="portalcategory", + name="description", + field=models.CharField(blank=True, max_length=500), + ), + ] diff --git a/apps/backend/hub_platform/support_portals/migrations/0004_portal_domains.py b/apps/backend/hub_platform/support_portals/migrations/0004_portal_domains.py new file mode 100644 index 0000000..ee8322a --- /dev/null +++ b/apps/backend/hub_platform/support_portals/migrations/0004_portal_domains.py @@ -0,0 +1,49 @@ +from django.conf import settings +from django.db import migrations, models +from django.db.models import Q + + +def fill_hosted_domains(apps, schema_editor): + SupportPortal = apps.get_model("support_portals", "SupportPortal") + base_domain = settings.CUS_HELP_BASE_DOMAIN.strip().lower().rstrip(".") + for portal in SupportPortal.objects.all().only("id", "slug"): + SupportPortal.objects.filter(id=portal.id).update( + hosted_domain=f"{portal.slug}.{base_domain}" + ) + + +class Migration(migrations.Migration): + dependencies = [("support_portals", "0003_category_description")] + + operations = [ + migrations.AddField( + model_name="supportportal", + name="hosted_domain", + field=models.CharField(blank=True, max_length=253), + preserve_default=False, + ), + migrations.AddField( + model_name="supportportal", + name="custom_domain", + field=models.CharField(blank=True, default="", max_length=253), + ), + migrations.AddField( + model_name="supportportal", + name="custom_domain_verified_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.RunPython(fill_hosted_domains, migrations.RunPython.noop), + migrations.AlterField( + model_name="supportportal", + name="hosted_domain", + field=models.CharField(max_length=253, unique=True), + ), + migrations.AddConstraint( + model_name="supportportal", + constraint=models.UniqueConstraint( + fields=("custom_domain",), + condition=~Q(custom_domain=""), + name="uniq_support_portal_custom_domain", + ), + ), + ] diff --git a/apps/backend/hub_platform/support_portals/migrations/0005_custom_domain_verification_token.py b/apps/backend/hub_platform/support_portals/migrations/0005_custom_domain_verification_token.py new file mode 100644 index 0000000..febd4d6 --- /dev/null +++ b/apps/backend/hub_platform/support_portals/migrations/0005_custom_domain_verification_token.py @@ -0,0 +1,17 @@ +import uuid + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("support_portals", "0004_portal_domains"), + ] + + operations = [ + migrations.AddField( + model_name="supportportal", + name="custom_domain_verification_token", + field=models.UUIDField(default=uuid.uuid4, editable=False), + ), + ] diff --git a/apps/backend/hub_platform/support_portals/migrations/__init__.py b/apps/backend/hub_platform/support_portals/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/backend/hub_platform/support_portals/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/backend/hub_platform/support_portals/models.py b/apps/backend/hub_platform/support_portals/models.py new file mode 100644 index 0000000..0569646 --- /dev/null +++ b/apps/backend/hub_platform/support_portals/models.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import uuid +from django.core.exceptions import ValidationError +from django.db import models + +from hub_platform.tenancy.models import TenantRelationModel +from hub_platform.support_portals.statuses import ArticleStatus, PortalStatus + + +class SupportPortal(TenantRelationModel): + """Публичный Help Center, управляемый внутри отдела поддержки.""" + + tenant_relation_fields = ("department",) + department = models.ForeignKey( + "identity.Department", + on_delete=models.PROTECT, + related_name="support_portals", + ) + public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) + slug = models.SlugField(max_length=64, unique=True) + hosted_domain = models.CharField(max_length=253, unique=True) + custom_domain = models.CharField(max_length=253, blank=True, default="") + custom_domain_verified_at = models.DateTimeField(null=True, blank=True) + custom_domain_verification_token = models.UUIDField( + default=uuid.uuid4, editable=False + ) + name = models.CharField(max_length=255) + default_locale = models.CharField(max_length=16, default="ru") + status = models.CharField( + max_length=16, + choices=PortalStatus.choices, + default=PortalStatus.DRAFT, + ) + transition_version = models.PositiveIntegerField(default=0) + published_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + products = models.ManyToManyField( + "products.Product", + through="SupportPortalProduct", + related_name="support_portals", + blank=True, + ) + + class Meta: + ordering = ["name", "id"] + constraints = [ + models.CheckConstraint( + condition=models.Q(status__in=PortalStatus.values), + name="support_portal_status_valid", + ), + models.UniqueConstraint( + fields=["custom_domain"], + condition=~models.Q(custom_domain=""), + name="uniq_support_portal_custom_domain", + ), + ] + + def __str__(self) -> str: + return f"{self.organization.slug}/{self.slug}" + + def clean(self) -> None: + super().clean() + from hub_platform.support_portals.addressing import clean_portal_domains + + clean_portal_domains(self) + if self.department_id is not None and self.department.code != "support": + raise ValidationError( + {"department": "Support portal must belong to the support department"} + ) + + +class SupportPortalProduct(TenantRelationModel): + """Продукт портала и его authenticated support-маршрут.""" + + tenant_relation_fields = ("portal", "product", "support_channel") + portal = models.ForeignKey( + SupportPortal, + on_delete=models.CASCADE, + related_name="product_links", + ) + product = models.ForeignKey( + "products.Product", + on_delete=models.PROTECT, + related_name="support_portal_links", + ) + support_channel = models.ForeignKey( + "channels.Channel", + on_delete=models.PROTECT, + related_name="support_portal_routes", + null=True, + blank=True, + ) + sort_order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["sort_order", "product__name", "id"] + constraints = [ + models.UniqueConstraint( + fields=["portal", "product"], + name="uniq_support_portal_product", + ), + ] + + def clean(self) -> None: + super().clean() + if self.support_channel_id is None: + return + channel = self.support_channel + if channel.product_id != self.product_id: + raise ValidationError( + {"support_channel": "Support channel must belong to the linked product"} + ) + if channel.department_id is None or channel.department.code != "support": + raise ValidationError( + {"support_channel": "Support channel must belong to the support department"} + ) + if ( + not channel.requires_authenticated_product_identity + or channel.allow_anonymous_sessions + ): + raise ValidationError( + {"support_channel": "Portal support route must require product identity"} + ) + + +class PortalCategory(TenantRelationModel): + tenant_relation_fields = ("portal", "parent") + portal = models.ForeignKey( + SupportPortal, + on_delete=models.CASCADE, + related_name="categories", + ) + parent = models.ForeignKey( + "self", + on_delete=models.PROTECT, + related_name="children", + null=True, + blank=True, + ) + slug = models.SlugField(max_length=64) + name = models.CharField(max_length=255) + description = models.CharField(max_length=500, blank=True) + sort_order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["sort_order", "name", "id"] + constraints = [ + models.UniqueConstraint( + fields=["portal", "slug"], + name="uniq_support_portal_category_slug", + ), + ] + + def clean(self) -> None: + super().clean() + if self.parent_id is not None and self.parent.portal_id != self.portal_id: + raise ValidationError({"parent": "Parent category belongs to another portal"}) + current = self.parent + visited = {self.pk} if self.pk else set() + while current is not None: + if current.pk in visited: + raise ValidationError({"parent": "Category cycle is not allowed"}) + visited.add(current.pk) + current = current.parent + + +class PortalArticle(TenantRelationModel): + tenant_relation_fields = ("portal", "category", "published_revision") + portal = models.ForeignKey( + SupportPortal, + on_delete=models.CASCADE, + related_name="articles", + ) + category = models.ForeignKey( + PortalCategory, + on_delete=models.PROTECT, + related_name="articles", + ) + slug = models.SlugField(max_length=96) + locale = models.CharField(max_length=16, default="ru") + status = models.CharField( + max_length=16, + choices=ArticleStatus.choices, + default=ArticleStatus.DRAFT, + ) + published_revision = models.ForeignKey( + "PortalArticleRevision", + on_delete=models.PROTECT, + related_name="+", + null=True, + blank=True, + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["slug", "id"] + constraints = [ + models.UniqueConstraint( + fields=["portal", "locale", "slug"], + name="uniq_support_portal_article_slug", + ), + models.CheckConstraint( + condition=models.Q(status__in=ArticleStatus.values), + name="support_portal_article_status_valid", + ), + ] + + def clean(self) -> None: + super().clean() + if self.category_id is not None and self.category.portal_id != self.portal_id: + raise ValidationError({"category": "Category belongs to another portal"}) + if ( + self.published_revision_id is not None + and self.published_revision.article_id != self.pk + ): + raise ValidationError( + {"published_revision": "Published revision belongs to another article"} + ) + + +class PortalArticleRevision(TenantRelationModel): + tenant_relation_fields = ("article",) + article = models.ForeignKey( + PortalArticle, + on_delete=models.CASCADE, + related_name="revisions", + ) + revision = models.PositiveIntegerField() + title = models.CharField(max_length=255) + summary = models.CharField(max_length=500, blank=True) + content = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + published_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-revision"] + constraints = [ + models.UniqueConstraint( + fields=["article", "revision"], + name="uniq_support_portal_article_revision", + ), + models.CheckConstraint( + condition=models.Q(revision__gt=0), + name="support_portal_article_revision_positive", + ), + ] + + +class PortalArticleFeedback(TenantRelationModel): + tenant_relation_fields = ("article",) + article = models.ForeignKey( + PortalArticle, + on_delete=models.CASCADE, + related_name="feedback", + ) + helpful = models.BooleanField() + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] diff --git a/apps/backend/hub_platform/support_portals/portal_services.py b/apps/backend/hub_platform/support_portals/portal_services.py new file mode 100644 index 0000000..863bcfc --- /dev/null +++ b/apps/backend/hub_platform/support_portals/portal_services.py @@ -0,0 +1,170 @@ +from dataclasses import dataclass + +from django.core.exceptions import ValidationError +from django.db import transaction +from django.utils import timezone + +from hub_platform.channels.models import Channel +from hub_platform.identity.models import Department +from hub_platform.products.models import Product +from hub_platform.subscriptions.keys import EntitlementKey, QuotaKey +from hub_platform.subscriptions.policy import require_entitlement +from hub_platform.subscriptions.usage_service import record_usage +from hub_platform.support_portals.addressing import hosted_domain +from hub_platform.support_portals.models import ( + SupportPortal, + SupportPortalProduct, +) +from hub_platform.support_portals.statuses import PortalStatus +from hub_platform.tenancy.context import TenantContext + + +@dataclass(frozen=True) +class PortalInput: + slug: str + name: str + default_locale: str = "ru" + + +@transaction.atomic +def create_portal(*, context: TenantContext, data: PortalInput) -> SupportPortal: + require_entitlement(context, EntitlementKey.SUPPORT_DEPARTMENT) + support_department = Department.objects.get( + organization=context.organization, + code="support", + ) + portal = SupportPortal( + organization=context.organization, + department=support_department, + slug=data.slug.strip().lower(), + hosted_domain=hosted_domain(data.slug.strip().lower()), + name=data.name.strip(), + default_locale=data.default_locale.strip().lower() or "ru", + ) + portal.full_clean() + portal.save() + record_usage( + context=context, + quota_key=QuotaKey.SUPPORT_PORTALS, + quantity=1, + idempotency_key=f"support-portal:{portal.public_id}:create", + source="support_portal.created", + aggregate_type="SupportPortal", + aggregate_id=str(portal.public_id), + ) + return portal + + +def update_portal( + *, context: TenantContext, portal: SupportPortal, data: PortalInput +) -> SupportPortal: + _check_tenant(context, portal) + if portal.status == PortalStatus.ARCHIVED: + raise ValidationError({"portal": "Восстановите портал, чтобы изменить настройки"}) + portal.slug = data.slug.strip().lower() + portal.hosted_domain = hosted_domain(portal.slug) + portal.name = data.name.strip() + portal.default_locale = data.default_locale.strip().lower() or "ru" + portal.full_clean() + portal.save() + return portal + + +@transaction.atomic +def set_portal_status( + *, context: TenantContext, portal: SupportPortal, status: str +) -> SupportPortal: + _check_tenant(context, portal) + if status not in PortalStatus.values: + raise ValidationError({"status": "Неизвестный статус портала"}) + if status == portal.status: + return portal + portal.transition_version += 1 + if status == PortalStatus.ARCHIVED: + record_usage( + context=context, + quota_key=QuotaKey.SUPPORT_PORTALS, + quantity=-1, + idempotency_key=f"support-portal:{portal.public_id}:archive:{portal.transition_version}", + source="support_portal.archived", + aggregate_type="SupportPortal", + aggregate_id=str(portal.public_id), + ) + portal.published_at = None + elif portal.status == PortalStatus.ARCHIVED: + require_entitlement(context, EntitlementKey.SUPPORT_DEPARTMENT) + record_usage( + context=context, + quota_key=QuotaKey.SUPPORT_PORTALS, + quantity=1, + idempotency_key=f"support-portal:{portal.public_id}:restore:{portal.transition_version}", + source="support_portal.restored", + aggregate_type="SupportPortal", + aggregate_id=str(portal.public_id), + ) + status = PortalStatus.DRAFT + elif status == PortalStatus.PUBLISHED: + require_entitlement(context, EntitlementKey.SUPPORT_DEPARTMENT) + portal.published_at = timezone.now() + else: + portal.published_at = None + portal.status = status + portal.save(update_fields=["status", "transition_version", "published_at", "updated_at"]) + return portal + + +@transaction.atomic +def replace_product_links( + *, context: TenantContext, portal: SupportPortal, links: list[dict] +) -> SupportPortal: + _check_tenant(context, portal) + if portal.status == PortalStatus.ARCHIVED: + raise ValidationError( + {"portal": "Восстановите портал, чтобы изменить его продукты"} + ) + product_ids = [int(item.get("productId", 0)) for item in links] + channel_ids = [ + int(item["supportChannelId"]) + for item in links + if item.get("supportChannelId") is not None + ] + if len(product_ids) != len(set(product_ids)): + raise ValidationError({"products": "Один продукт нельзя добавить дважды"}) + products = { + item.id: item + for item in Product.objects.filter( + organization=context.organization, + id__in=product_ids, + ) + } + channels = { + item.id: item + for item in Channel.objects.select_related("department").filter( + organization=context.organization, + id__in=channel_ids, + ) + } + if len(products) != len(product_ids) or len(channels) != len(channel_ids): + raise ValidationError({"products": "Продукт или канал поддержки не найден"}) + replacements = [] + for position, item in enumerate(links): + channel_id = item.get("supportChannelId") + link = SupportPortalProduct( + organization=context.organization, + portal=portal, + product=products[int(item["productId"])], + support_channel=channels.get(int(channel_id)) if channel_id is not None else None, + sort_order=int(item.get("sortOrder", position)), + ) + link.full_clean() + replacements.append(link) + portal.product_links.all().delete() + SupportPortalProduct.objects.bulk_create(replacements) + return portal + + +def _check_tenant(context: TenantContext, portal: SupportPortal) -> None: + if portal.organization_id != context.organization_id: + raise ValidationError({"portal": "Портал принадлежит другой организации"}) + if portal.department.code != "support": + raise ValidationError({"portal": "Портал не относится к отделу поддержки"}) diff --git a/apps/backend/hub_platform/support_portals/portal_views.py b/apps/backend/hub_platform/support_portals/portal_views.py new file mode 100644 index 0000000..691db86 --- /dev/null +++ b/apps/backend/hub_platform/support_portals/portal_views.py @@ -0,0 +1,229 @@ +from django.conf import settings +from django.core.exceptions import ValidationError +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from hub_platform.api.permissions import HasCapability, 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.support_portals.api import validation_response +from hub_platform.support_portals.models import SupportPortal +from hub_platform.support_portals.domain_services import ( + set_custom_domain, + verify_custom_domain, +) +from hub_platform.support_portals.portal_services import ( + PortalInput, + create_portal, + replace_product_links, + set_portal_status, + update_portal, +) +from hub_platform.support_portals.selectors import portal_for_context, portals_for_context +from hub_platform.support_portals.serializers import portal_payload +from hub_platform.subscriptions.errors import PolicyUnavailable +from hub_platform.subscriptions.keys import QuotaKey +from hub_platform.subscriptions.policy import get_effective_policy + + +class PortalBaseView(APIView): + permission_classes = [HasEntitlement, HasCapability] + required_entitlement = "support_department" + required_capability = "support.view" + required_capabilities = { + "GET": "support.view", + "POST": "support.operate", + "PATCH": "support.operate", + "PUT": "support.operate", + "DELETE": "support.operate", + } + required_department_code = "support" + + def portal(self, request: Request, portal_id: int) -> SupportPortal | None: + try: + return portal_for_context(request.tenant_context, portal_id) + except SupportPortal.DoesNotExist: + return None + + +def _input(request: Request, current: SupportPortal | None = None) -> PortalInput: + return PortalInput( + slug=str(request.data.get("slug", current.slug if current else "")), + name=str(request.data.get("name", current.name if current else "")), + default_locale=str( + request.data.get( + "defaultLocale", + current.default_locale if current else "ru", + ) + ), + ) + + +class PortalListView(PortalBaseView): + def get(self, request: Request) -> Response: + portals = list(portals_for_context(request.tenant_context)) + try: + quota = get_effective_policy(request.tenant_context).quota( + QuotaKey.SUPPORT_PORTALS + ) + except PolicyUnavailable: + quota = None + active_count = sum(item.status != "ARCHIVED" for item in portals) + available = quota is not None + can_create = available and ( + quota.limit is None or active_count < quota.limit + ) + return Response( + { + "items": [portal_payload(item) for item in portals], + "creation": { + "available": available, + "canCreate": can_create, + "limit": quota.limit if quota else None, + "used": active_count, + }, + "address": { + "scheme": settings.CUS_HELP_PUBLIC_SCHEME, + "baseDomain": settings.CUS_HELP_BASE_DOMAIN, + "port": settings.CUS_HELP_PUBLIC_PORT or None, + }, + } + ) + + def post(self, request: Request) -> Response: + try: + portal = create_portal(context=request.tenant_context, data=_input(request)) + except ValidationError as error: + return validation_response(error) + record_audit_event( + action="support_portal.created", + actor=request.user, + organization=request.tenant_context.organization, + object_type="SupportPortal", + object_id=str(portal.public_id), + request=request, + ) + portal = portal_for_context(request.tenant_context, portal.id) + return Response({"portal": portal_payload(portal)}, status=201) + + +class PortalDetailView(PortalBaseView): + def get(self, request: Request, portal_id: int) -> Response: + portal = self.portal(request, portal_id) + if portal is None: + return Response({"detail": "Портал не найден"}, status=404) + return Response({"portal": portal_payload(portal)}) + + def patch(self, request: Request, portal_id: int) -> Response: + portal = self.portal(request, portal_id) + if portal is None: + return Response({"detail": "Портал не найден"}, status=404) + try: + update_portal( + context=request.tenant_context, + portal=portal, + data=_input(request, portal), + ) + except ValidationError as error: + return validation_response(error) + portal = portal_for_context(request.tenant_context, portal.id) + return Response({"portal": portal_payload(portal)}) + + +class PortalStatusView(PortalBaseView): + def post(self, request: Request, portal_id: int) -> Response: + portal = self.portal(request, portal_id) + if portal is None: + return Response({"detail": "Портал не найден"}, status=404) + try: + portal = set_portal_status( + context=request.tenant_context, + portal=portal, + status=str(request.data.get("status", "")), + ) + except ValidationError as error: + return validation_response(error) + record_audit_event( + action=f"support_portal.{portal.status.lower()}", + actor=request.user, + organization=request.tenant_context.organization, + object_type="SupportPortal", + object_id=str(portal.public_id), + request=request, + ) + portal = portal_for_context(request.tenant_context, portal.id) + return Response({"portal": portal_payload(portal)}) + + +class PortalProductsView(PortalBaseView): + def put(self, request: Request, portal_id: int) -> Response: + portal = self.portal(request, portal_id) + if portal is None: + return Response({"detail": "Портал не найден"}, status=404) + links = request.data.get("items") + if not isinstance(links, list): + return Response({"detail": "items должен быть списком"}, status=400) + try: + replace_product_links( + context=request.tenant_context, + portal=portal, + links=links, + ) + except (ValidationError, TypeError, ValueError) as error: + if isinstance(error, ValidationError): + return validation_response(error) + return Response({"detail": "Некорректная привязка продукта"}, status=400) + portal = portal_for_context(request.tenant_context, portal.id) + return Response({"portal": portal_payload(portal)}) + + +class PortalSupportChannelsView(PortalBaseView): + """Portal-scoped channel options available to support operators.""" + + def get(self, request: Request, portal_id: int) -> Response: + portal = self.portal(request, portal_id) + if portal is None: + return Response({"detail": "Портал не найден"}, status=404) + channels = ( + Channel.objects.select_related("department", "product") + .filter( + organization=request.tenant_context.organization, + department__code="support", + product__isnull=False, + is_active=True, + requires_authenticated_product_identity=True, + allow_anonymous_sessions=False, + ) + .order_by("product__name", "name", "id") + ) + return Response({"items": [channel_payload(channel) for channel in channels]}) + + +class PortalDomainView(PortalBaseView): + def put(self, request: Request, portal_id: int) -> Response: + portal = self.portal(request, portal_id) + if portal is None: + return Response({"detail": "Портал не найден"}, status=404) + try: + portal = set_custom_domain( + portal, str(request.data.get("customDomain", "")) + ) + except ValidationError as error: + return validation_response(error) + portal = portal_for_context(request.tenant_context, portal.id) + return Response({"portal": portal_payload(portal)}) + + +class PortalDomainVerifyView(PortalBaseView): + def post(self, request: Request, portal_id: int) -> Response: + portal = self.portal(request, portal_id) + if portal is None: + return Response({"detail": "Портал не найден"}, status=404) + try: + portal = verify_custom_domain(portal) + except ValidationError as error: + return validation_response(error) + portal = portal_for_context(request.tenant_context, portal.id) + return Response({"portal": portal_payload(portal)}) diff --git a/apps/backend/hub_platform/support_portals/public_urls.py b/apps/backend/hub_platform/support_portals/public_urls.py new file mode 100644 index 0000000..684497b --- /dev/null +++ b/apps/backend/hub_platform/support_portals/public_urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from hub_platform.support_portals import public_views + +urlpatterns = [ + path("", public_views.PublicPortalDetailView.as_view(), name="public-support-portal"), + path("articles/", public_views.PublicArticleListView.as_view(), name="public-support-articles"), + path("articles//", public_views.PublicArticleDetailView.as_view(), name="public-support-article"), + path("articles//feedback/", public_views.PublicArticleFeedbackView.as_view(), name="public-support-feedback"), +] diff --git a/apps/backend/hub_platform/support_portals/public_views.py b/apps/backend/hub_platform/support_portals/public_views.py new file mode 100644 index 0000000..fad5a60 --- /dev/null +++ b/apps/backend/hub_platform/support_portals/public_views.py @@ -0,0 +1,157 @@ +from rest_framework.permissions import AllowAny +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.throttling import ScopedRateThrottle + +from hub_platform.identity.models import Organization +from hub_platform.subscriptions.keys import EntitlementKey +from hub_platform.subscriptions.policy import get_effective_policy +from hub_platform.support_portals.content_services import record_feedback +from hub_platform.support_portals.models import SupportPortal +from hub_platform.support_portals.selectors import category_article_counts, public_articles +from hub_platform.support_portals.serializers import ( + category_payload, + public_article_payload, + public_portal_payload, +) +from hub_platform.tenancy.context import TenantContext +from hub_platform.tenancy.database import tenant_atomic +from hub_platform.tenancy.ingress import support_portal_route + + +class PublicPortalView(APIView): + authentication_classes: list = [] + permission_classes = [AllowAny] + + def resolve(self, request: Request): + hostname = request.get_host().partition(":")[0].strip().lower().rstrip(".") + route = support_portal_route(hostname) + if route is None: + return None + try: + organization = Organization.objects.get(id=route.organization_id) + except Organization.DoesNotExist: + return None + context = TenantContext.for_resource(organization) + with tenant_atomic(context): + try: + policy = get_effective_policy(context) + except Exception: + return None + if not policy.has_entitlement(EntitlementKey.SUPPORT_DEPARTMENT): + return None + portal = ( + SupportPortal.objects.prefetch_related( + "categories", + "product_links__product", + "product_links__support_channel", + ) + .filter( + id=route.resource_id, + organization=organization, + department__code="support", + status="PUBLISHED", + ) + .first() + ) + if portal is None: + return None + return context, portal + + +class PublicPortalDetailView(PublicPortalView): + def get(self, request: Request) -> Response: + resolved = self.resolve(request) + if resolved is None: + return Response({"detail": "Портал не найден"}, status=404) + context, portal = resolved + with tenant_atomic(context): + counts = category_article_counts(portal, published_only=True) + return Response( + { + "portal": public_portal_payload(portal), + "categories": [ + category_payload( + category, article_count=counts.get(category.id, 0) + ) + for category in portal.categories.all() + ], + } + ) + + +class PublicArticleListView(PublicPortalView): + def get(self, request: Request) -> Response: + resolved = self.resolve(request) + if resolved is None: + return Response({"detail": "Портал не найден"}, status=404) + context, portal = resolved + locale = str(request.query_params.get("locale", portal.default_locale)) + with tenant_atomic(context): + articles = public_articles( + portal, + locale=locale, + category=str(request.query_params.get("category", "")), + query=str(request.query_params.get("q", "")).strip(), + ) + try: + limit = min(max(int(request.query_params.get("limit", 50)), 1), 100) + offset = max(int(request.query_params.get("offset", 0)), 0) + except (TypeError, ValueError): + return Response({"detail": "Некорректная пагинация"}, status=400) + total = articles.count() + page = articles[offset : offset + limit] + return Response( + { + "items": [ + public_article_payload(article, content=False) + for article in page + ], + "pagination": { + "limit": limit, + "offset": offset, + "total": total, + "hasMore": offset + limit < total, + }, + } + ) + + +class PublicArticleDetailView(PublicPortalView): + def get(self, request: Request, article_slug: str) -> Response: + resolved = self.resolve(request) + if resolved is None: + return Response({"detail": "Портал не найден"}, status=404) + context, portal = resolved + locale = str(request.query_params.get("locale", portal.default_locale)) + with tenant_atomic(context): + article = public_articles(portal, locale=locale).filter( + slug=article_slug + ).first() + if article is None: + return Response({"detail": "Статья не найдена"}, status=404) + return Response({"article": public_article_payload(article)}) + + +class PublicArticleFeedbackView(PublicPortalView): + throttle_classes = [ScopedRateThrottle] + throttle_scope = "help_feedback" + + def post(self, request: Request, article_slug: str) -> Response: + resolved = self.resolve(request) + if resolved is None: + return Response({"detail": "Портал не найден"}, status=404) + helpful = request.data.get("helpful") + if not isinstance(helpful, bool): + return Response({"detail": "helpful должен быть boolean"}, status=400) + context, portal = resolved + locale = str(request.query_params.get("locale", portal.default_locale)) + with tenant_atomic(context): + article = public_articles(portal, locale=locale).filter( + slug=article_slug + ).first() + if article is None: + return Response({"detail": "Статья не найдена"}, status=404) + record_feedback(article, helpful) + return Response({"ok": True}, status=201) diff --git a/apps/backend/hub_platform/support_portals/selectors.py b/apps/backend/hub_platform/support_portals/selectors.py new file mode 100644 index 0000000..be39652 --- /dev/null +++ b/apps/backend/hub_platform/support_portals/selectors.py @@ -0,0 +1,98 @@ +from django.db.models import Count, Q, QuerySet + +from hub_platform.support_portals.models import PortalArticle, SupportPortal +from hub_platform.tenancy.context import TenantContext + + +def portals_for_context(context: TenantContext) -> QuerySet[SupportPortal]: + return ( + SupportPortal.objects.filter( + organization=context.organization, + department__code="support", + ) + .select_related("department") + .prefetch_related("product_links__product", "product_links__support_channel") + ) + + +def portal_for_context(context: TenantContext, portal_id: int) -> SupportPortal: + return portals_for_context(context).get(id=portal_id) + + +def public_articles( + portal: SupportPortal, + *, + locale: str, + category: str = "", + query: str = "", +) -> QuerySet[PortalArticle]: + articles = ( + PortalArticle.objects.filter( + portal=portal, + status="PUBLISHED", + locale=locale, + published_revision__isnull=False, + ) + .select_related("category", "published_revision") + .order_by("category__sort_order", "published_revision__title") + ) + if category: + root = portal.categories.filter(slug=category).first() + if root is None: + return articles.none() + category_ids = descendant_category_ids(portal, root.id) + articles = articles.filter(category_id__in=category_ids) + if query: + articles = articles.filter( + Q(published_revision__title__icontains=query) + | Q(published_revision__summary__icontains=query) + | Q(published_revision__content__icontains=query) + ) + return articles + + +def descendant_category_ids(portal: SupportPortal, root_id: int) -> set[int]: + children: dict[int | None, list[int]] = {} + for category_id, parent_id in portal.categories.values_list("id", "parent_id"): + children.setdefault(parent_id, []).append(category_id) + result: set[int] = set() + pending = [root_id] + while pending: + current = pending.pop() + if current in result: + continue + result.add(current) + pending.extend(children.get(current, [])) + return result + + +def category_article_counts( + portal: SupportPortal, *, published_only: bool = False +) -> dict[int, int]: + categories = list(portal.categories.all()) + direct_query = PortalArticle.objects.filter(portal=portal) + if published_only: + direct_query = direct_query.filter( + status="PUBLISHED", published_revision__isnull=False + ) + direct = dict( + direct_query.values("category_id") + .annotate(total=Count("id")) + .values_list("category_id", "total") + ) + children: dict[int | None, list[int]] = {} + for category in categories: + children.setdefault(category.parent_id, []).append(category.id) + totals: dict[int, int] = {} + + def total(category_id: int) -> int: + if category_id in totals: + return totals[category_id] + value = direct.get(category_id, 0) + value += sum(total(child_id) for child_id in children.get(category_id, [])) + totals[category_id] = value + return value + + for category in categories: + total(category.id) + return totals diff --git a/apps/backend/hub_platform/support_portals/serializers.py b/apps/backend/hub_platform/support_portals/serializers.py new file mode 100644 index 0000000..0836183 --- /dev/null +++ b/apps/backend/hub_platform/support_portals/serializers.py @@ -0,0 +1,147 @@ +from hub_platform.support_portals.models import ( + PortalArticle, + PortalArticleRevision, + PortalCategory, + SupportPortal, + SupportPortalProduct, +) +from hub_platform.support_portals.addressing import portal_public_url +from hub_platform.support_portals.domain_services import ( + domain_verification_name, + domain_verification_value, +) + + +def product_link_payload(link: SupportPortalProduct) -> dict: + return { + "productId": link.product_id, + "code": link.product.code, + "name": link.product.name, + "supportChannelId": link.support_channel_id, + "supportChannelCode": ( + link.support_channel.code if link.support_channel_id else None + ), + "sortOrder": link.sort_order, + } + + +def portal_payload(portal: SupportPortal) -> dict: + public_url = portal_public_url( + hosted=portal.hosted_domain, + custom=portal.custom_domain, + custom_verified=portal.custom_domain_verified_at is not None, + ) + return { + "id": portal.id, + "publicId": str(portal.public_id), + "departmentCode": portal.department.code, + "slug": portal.slug, + "hostedDomain": portal.hosted_domain, + "customDomain": portal.custom_domain or None, + "customDomainVerifiedAt": portal.custom_domain_verified_at, + "customDomainVerification": ( + { + "name": domain_verification_name(portal), + "type": "TXT", + "value": domain_verification_value(portal), + } + if portal.custom_domain and portal.custom_domain_verified_at is None + else None + ), + "publicUrl": public_url, + "name": portal.name, + "defaultLocale": portal.default_locale, + "status": portal.status, + "publishedAt": portal.published_at, + "products": [product_link_payload(link) for link in portal.product_links.all()], + "createdAt": portal.created_at, + "updatedAt": portal.updated_at, + } + + +def category_payload( + category: PortalCategory, *, article_count: int | None = None +) -> dict: + payload = { + "id": category.id, + "slug": category.slug, + "name": category.name, + "description": category.description, + "parentId": category.parent_id, + "sortOrder": category.sort_order, + } + if article_count is not None: + payload["articleCount"] = article_count + return payload + + +def revision_payload(revision: PortalArticleRevision, *, content: bool = True) -> dict: + payload = { + "id": revision.id, + "revision": revision.revision, + "title": revision.title, + "summary": revision.summary, + "createdAt": revision.created_at, + "publishedAt": revision.published_at, + } + if content: + payload["content"] = revision.content + return payload + + +def article_payload(article: PortalArticle, *, revisions: bool = False) -> dict: + latest_revision = next(iter(article.revisions.all()), None) + payload = { + "id": article.id, + "slug": article.slug, + "locale": article.locale, + "status": article.status, + "category": category_payload(article.category), + "publishedRevision": ( + revision_payload(article.published_revision) + if article.published_revision_id + else None + ), + "latestRevision": ( + revision_payload(latest_revision, content=False) + if latest_revision is not None + else None + ), + "createdAt": article.created_at, + "updatedAt": article.updated_at, + } + if revisions: + payload["revisions"] = [ + revision_payload(revision) for revision in article.revisions.all() + ] + return payload + + +def public_portal_payload(portal: SupportPortal) -> dict: + return { + "slug": portal.slug, + "name": portal.name, + "defaultLocale": portal.default_locale, + "products": [ + { + "code": link.product.code, + "name": link.product.name, + "siteUrl": link.product.site_url, + "supportAvailable": link.support_channel_id is not None, + "supportChannelCode": ( + link.support_channel.code if link.support_channel_id else None + ), + } + for link in portal.product_links.all() + ], + } + + +def public_article_payload(article: PortalArticle, *, content: bool = True) -> dict: + return { + "slug": article.slug, + "locale": article.locale, + "category": category_payload(article.category), + "revision": revision_payload(article.published_revision, content=content), + "updatedAt": article.updated_at, + } diff --git a/apps/backend/hub_platform/support_portals/statuses.py b/apps/backend/hub_platform/support_portals/statuses.py new file mode 100644 index 0000000..38717af --- /dev/null +++ b/apps/backend/hub_platform/support_portals/statuses.py @@ -0,0 +1,13 @@ +from django.db import models + + +class PortalStatus(models.TextChoices): + DRAFT = "DRAFT", "Черновик" + PUBLISHED = "PUBLISHED", "Опубликован" + ARCHIVED = "ARCHIVED", "Архив" + + +class ArticleStatus(models.TextChoices): + DRAFT = "DRAFT", "Черновик" + PUBLISHED = "PUBLISHED", "Опубликована" + ARCHIVED = "ARCHIVED", "Архив" diff --git a/apps/backend/hub_platform/support_portals/tests/__init__.py b/apps/backend/hub_platform/support_portals/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/backend/hub_platform/support_portals/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/backend/hub_platform/support_portals/tests/base.py b/apps/backend/hub_platform/support_portals/tests/base.py new file mode 100644 index 0000000..1c9d3a8 --- /dev/null +++ b/apps/backend/hub_platform/support_portals/tests/base.py @@ -0,0 +1,44 @@ +from django.test import TestCase + +from hub_platform.channels.models import Channel +from hub_platform.identity.bootstrap import bootstrap_edevs_owner +from hub_platform.products.models import Product +from hub_platform.testing import TenantAPIClient + + +class SupportPortalTestCase(TestCase): + organization = None + owner = None + product = None + channel = None + client = None + + def setUp(self) -> None: + result = bootstrap_edevs_owner( + email="portal-owner@edevs.tech", + password="temporary-password", + ) + self.organization = result.organization + self.owner = result.owner + self.product = Product.objects.get( + organization=self.organization, + code="foxray", + ) + self.channel = Channel.objects.create( + organization=self.organization, + code="foxray-help", + name="Foxray support", + department=result.support_department, + product=self.product, + requires_authenticated_product_identity=True, + allow_anonymous_sessions=False, + allow_self_reported_contact=False, + ) + self.client = TenantAPIClient() + self.client.force_authenticate(self.owner) + def create_portal(self, slug: str = "foxray-help"): + return self.client.post( + "/api/v1/support/portals/", + {"slug": slug, "name": "Foxray Help", "defaultLocale": "ru"}, + format="json", + ) 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 new file mode 100644 index 0000000..293a793 --- /dev/null +++ b/apps/backend/hub_platform/support_portals/tests/test_management_api.py @@ -0,0 +1,196 @@ +from unittest import mock + +from django.conf import settings +from django.test import override_settings + +from hub_platform.channels.models import Channel +from hub_platform.identity.capabilities import ScopeType +from hub_platform.identity.models import ( + AccessProfile, + AccessProfileCapability, + EmployeeAccessAssignment, + EmployeeRole, + HumanUser, + Organization, + OrganizationMembership, +) +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_subscription_allows_multiple_active_portals(self) -> None: + first = self.create_portal() + self.assertEqual(first.status_code, 201, first.content) + self.assertEqual(first.json()["portal"]["departmentCode"], "support") + self.assertEqual( + first.json()["portal"]["hostedDomain"], + f"foxray-help.{settings.CUS_HELP_BASE_DOMAIN}", + ) + self.assertTrue(first.json()["portal"]["publicUrl"].endswith( + f"foxray-help.{settings.CUS_HELP_BASE_DOMAIN}" + )) + + listed = self.client.get("/api/v1/support/portals/") + self.assertEqual(listed.status_code, 200, listed.content) + self.assertEqual( + listed.json()["creation"], + {"available": True, "canCreate": True, "limit": None, "used": 1}, + ) + + second = self.create_portal("another-help") + self.assertEqual(second.status_code, 201, second.content) + self.assertEqual(SupportPortal.objects.count(), 2) + + def test_product_route_requires_authenticated_support_channel(self) -> None: + portal_id = self.create_portal().json()["portal"]["id"] + response = self.client.put( + f"/api/v1/support/portals/{portal_id}/products/", + { + "items": [ + { + "productId": self.product.id, + "supportChannelId": self.channel.id, + } + ] + }, + format="json", + ) + self.assertEqual(response.status_code, 200, response.content) + link = SupportPortalProduct.objects.get() + self.assertEqual(link.product, self.product) + self.assertEqual(link.support_channel, self.channel) + + anonymous_channel = Channel.objects.create( + organization=self.organization, + code="anonymous-help", + name="Anonymous", + department=self.channel.department, + product=self.product, + ) + rejected = self.client.put( + f"/api/v1/support/portals/{portal_id}/products/", + { + "items": [ + { + "productId": self.product.id, + "supportChannelId": anonymous_channel.id, + } + ] + }, + format="json", + ) + self.assertEqual(rejected.status_code, 400, rejected.content) + link.refresh_from_db() + self.assertEqual(link.support_channel, self.channel) + + def test_custom_domain_requires_matching_dns_record(self) -> None: + portal_id = self.create_portal().json()["portal"]["id"] + configured = self.client.put( + f"/api/v1/support/portals/{portal_id}/domain/", + {"customDomain": "help.customer.example"}, + format="json", + ) + self.assertEqual(configured.status_code, 200, configured.content) + verification = configured.json()["portal"]["customDomainVerification"] + answer = mock.Mock(strings=[verification["value"].encode("utf-8")]) + + with mock.patch("dns.resolver.resolve", return_value=[answer]): + verified = self.client.post( + f"/api/v1/support/portals/{portal_id}/domain/verify/", + {}, + format="json", + ) + + self.assertEqual(verified.status_code, 200, verified.content) + self.assertIsNotNone(verified.json()["portal"]["customDomainVerifiedAt"]) + self.assertIsNone(verified.json()["portal"]["customDomainVerification"]) + + @override_settings(CUS_APP_PRIMARY_HOSTS=["app.customer.example"]) + def test_custom_domain_cannot_shadow_application_host(self) -> None: + portal_id = self.create_portal().json()["portal"]["id"] + response = self.client.put( + f"/api/v1/support/portals/{portal_id}/domain/", + {"customDomain": "app.customer.example"}, + format="json", + ) + self.assertEqual(response.status_code, 400, response.content) + + def test_portal_is_not_visible_from_another_tenant(self) -> None: + portal_id = self.create_portal().json()["portal"]["id"] + other_organization = Organization.objects.create( + slug="other-portal-tenant", + name="Other tenant", + ) + other_owner = HumanUser.objects.create_user( + email="other-portal-owner@example.com", + password="temporary-password", + ) + other_owner_membership = OrganizationMembership.objects.create( + organization=other_organization, + user=other_owner, + role=EmployeeRole.OWNER, + ) + other_support = other_organization.departments.create( + code="support", + name="Поддержка", + ) + other_profile = AccessProfile.objects.create( + organization=other_organization, + name="Portal reader", + ) + AccessProfileCapability.objects.create( + access_profile=other_profile, + capability_code="support.view", + ) + EmployeeAccessAssignment.objects.create( + employee=other_owner_membership, + access_profile=other_profile, + scope_type=ScopeType.DEPARTMENT, + department=other_support, + assigned_by=other_owner_membership, + ) + create_test_subscription(other_organization) + self.client.force_authenticate(other_owner) + self.client.set_tenant(other_organization) + + response = self.client.get(f"/api/v1/support/portals/{portal_id}/") + + self.assertEqual(response.status_code, 404, response.content) + + def test_support_scope_is_required_for_portal_access(self) -> None: + self.create_portal() + employee_user = HumanUser.objects.create_user( + email="scoped-portal-reader@example.com", + password="temporary-password", + ) + employee = OrganizationMembership.objects.create( + organization=self.organization, + user=employee_user, + role=EmployeeRole.EMPLOYEE, + ) + profile = AccessProfile.objects.create( + organization=self.organization, + name="Portal reader", + ) + AccessProfileCapability.objects.create( + access_profile=profile, + capability_code="support.view", + ) + sales = self.organization.departments.get(code="sales") + assignment = EmployeeAccessAssignment.objects.create( + employee=employee, + access_profile=profile, + scope_type=ScopeType.DEPARTMENT, + department=sales, + assigned_by=self.organization.memberships.get(user=self.owner), + ) + self.client.force_authenticate(employee_user) + denied = self.client.get("/api/v1/support/portals/") + self.assertEqual(denied.status_code, 403, denied.content) + + assignment.department = self.organization.departments.get(code="support") + assignment.save(update_fields=["department"]) + allowed = self.client.get("/api/v1/support/portals/") + self.assertEqual(allowed.status_code, 200, allowed.content) diff --git a/apps/backend/hub_platform/support_portals/tests/test_management_content_api.py b/apps/backend/hub_platform/support_portals/tests/test_management_content_api.py new file mode 100644 index 0000000..14e1f4e --- /dev/null +++ b/apps/backend/hub_platform/support_portals/tests/test_management_content_api.py @@ -0,0 +1,117 @@ +from hub_platform.support_portals.tests.base import SupportPortalTestCase + + +class SupportPortalContentManagementTests(SupportPortalTestCase): + def test_article_revisions_are_preserved_and_selected_for_publish(self) -> None: + portal_id = self.create_portal().json()["portal"]["id"] + category = self.client.post( + f"/api/v1/support/portals/{portal_id}/categories/", + {"name": "Аккаунт"}, + format="json", + ) + self.assertEqual(category.status_code, 201, category.content) + self.assertTrue(category.json()["category"]["slug"]) + article = self.client.post( + f"/api/v1/support/portals/{portal_id}/articles/", + { + "categoryId": category.json()["category"]["id"], + "slug": "sign-in", + "title": "Вход", + "summary": "Как войти", + "content": "Версия 1", + }, + format="json", + ) + self.assertEqual(article.status_code, 201, article.content) + article_id = article.json()["article"]["id"] + revision = self.client.post( + f"/api/v1/support/portals/{portal_id}/articles/{article_id}/revisions/", + {"title": "Вход в аккаунт", "summary": "Как войти", "content": "Версия 2"}, + format="json", + ) + self.assertEqual(revision.status_code, 201, revision.content) + published = self.client.post( + f"/api/v1/support/portals/{portal_id}/articles/{article_id}/publish/", + {"revisionId": revision.json()["revision"]["id"]}, + format="json", + ) + self.assertEqual(published.status_code, 200, published.content) + body = published.json()["article"] + self.assertEqual(len(body["revisions"]), 2) + self.assertEqual(body["publishedRevision"]["content"], "Версия 2") + + def test_archived_portal_rejects_every_content_mutation(self) -> None: + portal_id = self.create_portal().json()["portal"]["id"] + category = self.client.post( + f"/api/v1/support/portals/{portal_id}/categories/", + {"slug": "account", "name": "Аккаунт"}, + format="json", + ).json()["category"] + self.client.post( + f"/api/v1/support/portals/{portal_id}/status/", + {"status": "ARCHIVED"}, + format="json", + ) + + responses = [ + self.client.post( + f"/api/v1/support/portals/{portal_id}/categories/", + {"slug": "new", "name": "Новый"}, + format="json", + ), + self.client.patch( + f"/api/v1/support/portals/{portal_id}/categories/{category['id']}/", + {"name": "Изменён"}, + format="json", + ), + self.client.post( + f"/api/v1/support/portals/{portal_id}/articles/", + { + "categoryId": category["id"], + "slug": "draft", + "title": "Черновик", + "content": "Текст", + }, + format="json", + ), + self.client.put( + f"/api/v1/support/portals/{portal_id}/products/", + {"items": []}, + format="json", + ), + ] + + self.assertTrue(all(response.status_code == 400 for response in responses)) + + def test_category_hierarchy_can_be_updated_and_protected_on_delete(self) -> None: + portal_id = self.create_portal().json()["portal"]["id"] + parent = self.client.post( + f"/api/v1/support/portals/{portal_id}/categories/", + {"slug": "account", "name": "Аккаунт"}, + format="json", + ).json()["category"] + child = self.client.post( + f"/api/v1/support/portals/{portal_id}/categories/", + {"slug": "security", "name": "Безопасность", "parentId": parent["id"]}, + format="json", + ).json()["category"] + + updated = self.client.patch( + f"/api/v1/support/portals/{portal_id}/categories/{child['id']}/", + {"name": "Защита аккаунта", "parentId": parent["id"], "sortOrder": 4}, + format="json", + ) + self.assertEqual(updated.status_code, 200, updated.content) + self.assertEqual(updated.json()["category"]["parentId"], parent["id"]) + protected = self.client.delete( + f"/api/v1/support/portals/{portal_id}/categories/{parent['id']}/" + ) + self.assertEqual(protected.status_code, 400, protected.content) + + def test_support_operator_uses_portal_scoped_channel_options(self) -> None: + portal_id = self.create_portal().json()["portal"]["id"] + response = self.client.get( + f"/api/v1/support/portals/{portal_id}/support-channels/" + ) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual([item["id"] for item in response.json()["items"]], [self.channel.id]) 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 new file mode 100644 index 0000000..87729a5 --- /dev/null +++ b/apps/backend/hub_platform/support_portals/tests/test_public_api.py @@ -0,0 +1,130 @@ +from django.test import TestCase, override_settings +from django.utils import timezone + +from hub_platform.identity.bootstrap import bootstrap_edevs_owner +from hub_platform.support_portals.models import PortalArticleFeedback, SupportPortal +from hub_platform.testing import TenantAPIClient + + +class PublicSupportPortalTests(TestCase): + def setUp(self) -> None: + result = bootstrap_edevs_owner( + email="public-portal-owner@edevs.tech", + password="temporary-password", + ) + self.client = TenantAPIClient() + self.client.force_authenticate(result.owner) + portal = self.client.post( + "/api/v1/support/portals/", + {"slug": "edevs-help", "name": "Edevs Help"}, + format="json", + ).json()["portal"] + self.portal_id = portal["id"] + self.portal_host = portal["hostedDomain"] + category = self.client.post( + f"/api/v1/support/portals/{self.portal_id}/categories/", + { + "slug": "getting-started", + "name": "Начало работы", + "description": "Быстрый старт и основные возможности", + }, + format="json", + ).json()["category"] + article = self.client.post( + f"/api/v1/support/portals/{self.portal_id}/articles/", + { + "categoryId": category["id"], + "slug": "first-steps", + "title": "Первые шаги", + "summary": "Начало работы с продуктом", + "content": "# Первые шаги\n\nОткройте кабинет.", + }, + format="json", + ).json()["article"] + revision_id = article["revisions"][0]["id"] + self.client.post( + f"/api/v1/support/portals/{self.portal_id}/articles/{article['id']}/publish/", + {"revisionId": revision_id}, + format="json", + ) + self.client.logout() + + def test_draft_portal_is_not_public(self) -> None: + response = self.client.get("/api/v1/help/", HTTP_HOST=self.portal_host) + self.assertEqual(response.status_code, 404) + + def test_published_portal_exposes_search_article_and_feedback(self) -> None: + self.client.force_authenticate( + bootstrap_edevs_owner( + email="public-portal-owner@edevs.tech", + password="temporary-password", + ).owner + ) + published = self.client.post( + f"/api/v1/support/portals/{self.portal_id}/status/", + {"status": "PUBLISHED"}, + format="json", + ) + self.assertEqual(published.status_code, 200, published.content) + self.client.logout() + + 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()["categories"][0]["description"], + "Быстрый старт и основные возможности", + ) + self.assertEqual(portal.json()["categories"][0]["articleCount"], 1) + + search = self.client.get( + "/api/v1/help/articles/?q=кабинет", + HTTP_HOST=self.portal_host, + ) + self.assertEqual(search.status_code, 200, search.content) + self.assertEqual(len(search.json()["items"]), 1) + self.assertNotIn("content", search.json()["items"][0]["revision"]) + self.assertEqual(search.json()["pagination"]["total"], 1) + + article = self.client.get( + "/api/v1/help/articles/first-steps/", + HTTP_HOST=self.portal_host, + ) + self.assertEqual(article.status_code, 200, article.content) + self.assertIn("Откройте кабинет", article.json()["article"]["revision"]["content"]) + + feedback = self.client.post( + "/api/v1/help/articles/first-steps/feedback/", + {"helpful": True}, + format="json", + HTTP_HOST=self.portal_host, + ) + self.assertEqual(feedback.status_code, 201, feedback.content) + self.assertEqual(PortalArticleFeedback.objects.filter(helpful=True).count(), 1) + + @override_settings(ALLOWED_HOSTS=["testserver", "help.foxray.example"]) + def test_verified_custom_domain_resolves_the_same_portal(self) -> None: + self.client.force_authenticate( + bootstrap_edevs_owner( + email="public-portal-owner@edevs.tech", + password="temporary-password", + ).owner + ) + self.client.post( + f"/api/v1/support/portals/{self.portal_id}/status/", + {"status": "PUBLISHED"}, + format="json", + ) + SupportPortal.objects.filter(id=self.portal_id).update( + custom_domain="help.foxray.example", + custom_domain_verified_at=timezone.now(), + ) + self.client.logout() + + response = self.client.get( + "/api/v1/help/", + HTTP_HOST="help.foxray.example", + ) + + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response.json()["portal"]["name"], "Edevs Help") diff --git a/apps/backend/hub_platform/support_portals/urls.py b/apps/backend/hub_platform/support_portals/urls.py new file mode 100644 index 0000000..6b06f48 --- /dev/null +++ b/apps/backend/hub_platform/support_portals/urls.py @@ -0,0 +1,20 @@ +from django.urls import path + +from hub_platform.support_portals import content_views, portal_views + +urlpatterns = [ + path("", portal_views.PortalListView.as_view(), name="support-portal-list"), + path("/", portal_views.PortalDetailView.as_view(), name="support-portal-detail"), + path("/status/", portal_views.PortalStatusView.as_view(), name="support-portal-status"), + path("/products/", portal_views.PortalProductsView.as_view(), name="support-portal-products"), + path("/support-channels/", portal_views.PortalSupportChannelsView.as_view(), name="support-portal-support-channels"), + path("/domain/", portal_views.PortalDomainView.as_view(), name="support-portal-domain"), + path("/domain/verify/", portal_views.PortalDomainVerifyView.as_view(), name="support-portal-domain-verify"), + path("/categories/", content_views.CategoryListView.as_view(), name="support-portal-categories"), + path("/categories//", content_views.CategoryDetailView.as_view(), name="support-portal-category-detail"), + path("/articles/", content_views.ArticleListView.as_view(), name="support-portal-articles"), + path("/articles//", content_views.ArticleDetailView.as_view(), name="support-portal-article"), + path("/articles//revisions/", content_views.ArticleRevisionListView.as_view(), name="support-portal-article-revisions"), + path("/articles//publish/", content_views.ArticlePublishView.as_view(), name="support-portal-article-publish"), + path("/articles//archive/", content_views.ArticleArchiveView.as_view(), name="support-portal-article-archive"), +] diff --git a/apps/backend/hub_platform/tenancy/ingress.py b/apps/backend/hub_platform/tenancy/ingress.py index 98b3366..e1e0c87 100644 --- a/apps/backend/hub_platform/tenancy/ingress.py +++ b/apps/backend/hub_platform/tenancy/ingress.py @@ -87,6 +87,10 @@ def web_channel_route(channel_code: str) -> IngressRoute | None: return _unique_route("web_channel_directory", channel_code) +def support_portal_route(hostname: str) -> IngressRoute | None: + return _unique_route("support_portal_directory", hostname.strip().lower().rstrip(".")) + + def support_channel_routes(channel_code: str) -> list[SupportIngressRoute]: return [ SupportIngressRoute( diff --git a/apps/backend/hub_platform/tenancy/migrations/0010_support_portal_guards.py b/apps/backend/hub_platform/tenancy/migrations/0010_support_portal_guards.py new file mode 100644 index 0000000..ef17179 --- /dev/null +++ b/apps/backend/hub_platform/tenancy/migrations/0010_support_portal_guards.py @@ -0,0 +1,122 @@ +from django.db import migrations + + +TABLES = ( + "support_portals_supportportal", + "support_portals_supportportalproduct", + "support_portals_portalcategory", + "support_portals_portalarticle", + "support_portals_portalarticlerevision", + "support_portals_portalarticlefeedback", +) + +RELATIONS = ( + ("sp_portal_department", TABLES[0], "identity_department", "department_id"), + ("sp_product_portal", TABLES[1], TABLES[0], "portal_id"), + ("sp_product_product", TABLES[1], "identity_product", "product_id"), + ("sp_product_channel", TABLES[1], "channels_channel", "support_channel_id"), + ("sp_category_portal", TABLES[2], TABLES[0], "portal_id"), + ("sp_category_parent", TABLES[2], TABLES[2], "parent_id"), + ("sp_article_portal", TABLES[3], TABLES[0], "portal_id"), + ("sp_article_category", TABLES[3], TABLES[2], "category_id"), + ("sp_article_revision", TABLES[3], TABLES[4], "published_revision_id"), + ("sp_revision_article", TABLES[4], TABLES[3], "article_id"), + ("sp_feedback_article", TABLES[5], TABLES[3], "article_id"), +) + + +def install(apps, schema_editor): + # Runtime roles must never inherit the permissive schema-owner RLS policy. + # Fresh clusters enforce this in init-runtime-roles.sh; the migration also + # repairs upgraded clusters created before that guard existed. + schema_editor.execute( + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_auth_members membership + JOIN pg_roles parent ON parent.oid = membership.roleid + JOIN pg_roles child ON child.oid = membership.member + WHERE parent.rolname = 'custocrm_schema' + AND child.rolname IN ( + 'custocrm_runtime_app', + 'custocrm_runtime_platform' + ) + ) THEN + REVOKE custocrm_schema + FROM custocrm_runtime_app, custocrm_runtime_platform; + END IF; + END + $$; + """ + ) + for table in TABLES: + schema_editor.execute( + f""" + ALTER TABLE {table} OWNER TO custocrm_schema; + ALTER TABLE {table} ENABLE ROW LEVEL SECURITY; + ALTER TABLE {table} FORCE ROW LEVEL SECURITY; + REVOKE ALL ON TABLE {table} FROM PUBLIC; + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {table} TO custocrm_runtime_app; + GRANT ALL ON TABLE {table} TO custocrm_schema; + CREATE POLICY custocrm_tenant_isolation ON {table} + FOR ALL TO custocrm_runtime_app + USING (organization_id = custocrm.current_organization_id()) + WITH CHECK (organization_id = custocrm.current_organization_id()); + CREATE POLICY custocrm_schema_access ON {table} + FOR ALL TO custocrm_schema USING (true) WITH CHECK (true); + """ + ) + for trigger, child, parent, column in RELATIONS: + schema_editor.execute( + f""" + CREATE CONSTRAINT TRIGGER {trigger} + AFTER INSERT OR UPDATE ON {child} + DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION + custocrm.enforce_tenant_fk('{parent}', '{column}'); + """ + ) + schema_editor.execute( + """ + CREATE OR REPLACE VIEW custocrm.support_portal_directory + WITH (security_barrier = true) AS + SELECT portal.id AS resource_id, + portal.organization_id, + portal.slug AS lookup_key + FROM support_portals_supportportal portal + JOIN identity_department department + ON department.id = portal.department_id + AND department.organization_id = portal.organization_id + WHERE portal.status = 'PUBLISHED' + AND department.code = 'support'; + ALTER VIEW custocrm.support_portal_directory OWNER TO custocrm_schema; + REVOKE ALL ON custocrm.support_portal_directory FROM PUBLIC; + GRANT SELECT ON custocrm.support_portal_directory TO custocrm_runtime_platform; + GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public + TO custocrm_runtime_app, custocrm_runtime_platform, custocrm_schema; + """ + ) + + +def uninstall(apps, schema_editor): + schema_editor.execute("DROP VIEW IF EXISTS custocrm.support_portal_directory") + for trigger, child, _parent, _column in RELATIONS: + schema_editor.execute(f"DROP TRIGGER IF EXISTS {trigger} ON {child}") + for table in TABLES: + schema_editor.execute( + f""" + DROP POLICY IF EXISTS custocrm_tenant_isolation ON {table}; + DROP POLICY IF EXISTS custocrm_schema_access ON {table}; + ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY; + ALTER TABLE {table} DISABLE ROW LEVEL SECURITY; + """ + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("tenancy", "0009_tenant_guards_security_definer"), + ("support_portals", "0002_content_and_products"), + ] + operations = [migrations.RunPython(install, uninstall)] diff --git a/apps/backend/hub_platform/tenancy/migrations/0011_support_portal_host_directory.py b/apps/backend/hub_platform/tenancy/migrations/0011_support_portal_host_directory.py new file mode 100644 index 0000000..bcb3deb --- /dev/null +++ b/apps/backend/hub_platform/tenancy/migrations/0011_support_portal_host_directory.py @@ -0,0 +1,58 @@ +from django.db import migrations + + +FORWARD_SQL = """ +DROP VIEW custocrm.support_portal_directory; +CREATE VIEW custocrm.support_portal_directory +WITH (security_barrier = true) AS + SELECT portal.id AS resource_id, + portal.organization_id, + portal.hosted_domain AS lookup_key + FROM support_portals_supportportal portal + JOIN identity_department department + ON department.id = portal.department_id + AND department.organization_id = portal.organization_id + WHERE portal.status = 'PUBLISHED' + AND department.code = 'support' + UNION ALL + SELECT portal.id AS resource_id, + portal.organization_id, + portal.custom_domain AS lookup_key + FROM support_portals_supportportal portal + JOIN identity_department department + ON department.id = portal.department_id + AND department.organization_id = portal.organization_id + WHERE portal.status = 'PUBLISHED' + AND department.code = 'support' + AND portal.custom_domain <> '' + AND portal.custom_domain_verified_at IS NOT NULL; +ALTER VIEW custocrm.support_portal_directory OWNER TO custocrm_schema; +REVOKE ALL ON custocrm.support_portal_directory FROM PUBLIC; +GRANT SELECT ON custocrm.support_portal_directory TO custocrm_runtime_platform; +""" + +REVERSE_SQL = """ +DROP VIEW custocrm.support_portal_directory; +CREATE VIEW custocrm.support_portal_directory +WITH (security_barrier = true) AS + SELECT portal.id AS resource_id, + portal.organization_id, + portal.slug AS lookup_key + FROM support_portals_supportportal portal + JOIN identity_department department + ON department.id = portal.department_id + AND department.organization_id = portal.organization_id + WHERE portal.status = 'PUBLISHED' + AND department.code = 'support'; +ALTER VIEW custocrm.support_portal_directory OWNER TO custocrm_schema; +REVOKE ALL ON custocrm.support_portal_directory FROM PUBLIC; +GRANT SELECT ON custocrm.support_portal_directory TO custocrm_runtime_platform; +""" + + +class Migration(migrations.Migration): + dependencies = [ + ("tenancy", "0010_support_portal_guards"), + ("support_portals", "0004_portal_domains"), + ] + operations = [migrations.RunSQL(FORWARD_SQL, REVERSE_SQL)] diff --git a/apps/backend/hub_platform/testing.py b/apps/backend/hub_platform/testing.py index f280337..7bb95c0 100644 --- a/apps/backend/hub_platform/testing.py +++ b/apps/backend/hub_platform/testing.py @@ -50,6 +50,7 @@ class TenantAPIClient(APIClient): "/api/v1/calls/invites/", "/api/v1/orders/ingest/", "/api/v1/support/sessions/", + "/api/v1/help/", ) def __init__(self, *args, **kwargs) -> None: diff --git a/apps/backend/requirements.txt b/apps/backend/requirements.txt index 3287b75..27494e4 100644 --- a/apps/backend/requirements.txt +++ b/apps/backend/requirements.txt @@ -1,5 +1,6 @@ Django>=5.2,<5.3 djangorestframework>=3.16,<3.17 +dnspython>=2.7,<3 django-storages[s3]>=1.14,<1.15 channels>=4.2,<5 channels-redis>=4.2,<5 diff --git a/apps/internal-ui/package.json b/apps/internal-ui/package.json index 29a1fd9..54c3dfe 100644 --- a/apps/internal-ui/package.json +++ b/apps/internal-ui/package.json @@ -17,7 +17,9 @@ "antd": "^5.26.0", "js-yaml": "^5.2.1", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@types/js-yaml": "^4.0.9", diff --git a/apps/internal-ui/src/App.tsx b/apps/internal-ui/src/App.tsx index 0e63a37..81758e5 100644 --- a/apps/internal-ui/src/App.tsx +++ b/apps/internal-ui/src/App.tsx @@ -70,7 +70,7 @@ export function App() { if (activeUser && !initialRoute.organizationPublicId) { const nextPath = pathFromRoute( initialRoute.route, - initialRoute.employeeId || initialRoute.productId || initialRoute.agentId || initialRoute.knowledgeId || initialRoute.clientId || initialRoute.orderId, + initialRoute.employeeId || initialRoute.productId || initialRoute.agentId || initialRoute.knowledgeId || initialRoute.clientId || initialRoute.orderId || initialRoute.channelId || initialRoute.supportPortalId, initialRoute.productCode, activeUser.organizationPublicId, ); @@ -151,7 +151,7 @@ export function App() { ) : !canAccess(user, navigation.route) ? ( navigate(defaultRoute(user), null, true)} /> ) : ( - navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedProductId={navigation.selectedProductId} selectedProductCode={navigation.selectedProductCode} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedOrderId={navigation.selectedOrderId} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("channelDetail", channelId)} openOrderRoute={(orderId) => navigate("salesOrderDetail", orderId)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openProductRoute={(productId) => navigate("productDetail", productId)} openAgentCreateRoute={(productCode) => navigate("aiAgentCreate", null, false, productCode)} openAgentRoute={(agentId) => navigate("aiAgentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("aiKnowledgeDetail", knowledgeId)} openConversationRoute={(conversationId) => navigate("salesDialogs", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} /> + navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedProductId={navigation.selectedProductId} selectedProductCode={navigation.selectedProductCode} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedOrderId={navigation.selectedOrderId} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("channelDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} openOrderRoute={(orderId) => navigate("salesOrderDetail", orderId)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openProductRoute={(productId) => navigate("productDetail", productId)} openAgentCreateRoute={(productCode) => navigate("aiAgentCreate", null, false, productCode)} openAgentRoute={(agentId) => navigate("aiAgentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("aiKnowledgeDetail", knowledgeId)} openConversationRoute={(conversationId) => navigate("salesDialogs", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} /> )} ); diff --git a/apps/internal-ui/src/api/client.test.ts b/apps/internal-ui/src/api/client.test.ts index 4afc000..c95506b 100644 --- a/apps/internal-ui/src/api/client.test.ts +++ b/apps/internal-ui/src/api/client.test.ts @@ -92,6 +92,18 @@ describe("api client", () => { ); }); + it("keeps portal management inside the organization support namespace", async () => { + setActiveOrganization(organizationPublicId); + globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse({ items: [] })) as unknown as typeof fetch; + + await api("/api/v1/support/portals/"); + + expect(globalThis.fetch).toHaveBeenCalledWith( + `/api/v1/organizations/${organizationPublicId}/support/portals/`, + expect.objectContaining({ credentials: "include" }), + ); + }); + it("fails closed when a tenant request has no selected organization", async () => { await expect(api("/api/v1/integrations/")).rejects.toThrow( "Organization context is required", diff --git a/apps/internal-ui/src/api/client.ts b/apps/internal-ui/src/api/client.ts index ee9cbfd..c28b492 100644 --- a/apps/internal-ui/src/api/client.ts +++ b/apps/internal-ui/src/api/client.ts @@ -28,6 +28,7 @@ const publicPaths = [ "/api/v1/calls/invites/", "/api/v1/orders/ingest/", "/api/v1/support/sessions/", + "/api/v1/help/", ]; export type ApiErrorPayload = { diff --git a/apps/internal-ui/src/auth/access.test.ts b/apps/internal-ui/src/auth/access.test.ts index 335c2ff..607b44b 100644 --- a/apps/internal-ui/src/auth/access.test.ts +++ b/apps/internal-ui/src/auth/access.test.ts @@ -65,7 +65,9 @@ describe("effective access navigation", () => { expect(canAccess(sales, "salesDialogs")).toBe(true); expect(canAccess(sales, "supportDialogs")).toBe(false); + expect(canAccess(sales, "supportPortals")).toBe(false); expect(canAccess(support, "supportDialogs")).toBe(true); + expect(canAccess(support, "supportPortals")).toBe(true); expect(canAccess(support, "salesDialogs")).toBe(false); }); diff --git a/apps/internal-ui/src/auth/access.ts b/apps/internal-ui/src/auth/access.ts index bf70189..7e37561 100644 --- a/apps/internal-ui/src/auth/access.ts +++ b/apps/internal-ui/src/auth/access.ts @@ -29,6 +29,8 @@ const ROUTE_ACCESS: Partial> = { salesOrderDetail: { capability: "sales.view", departmentCode: "sales" }, supportOverview: { capability: "support.view", departmentCode: "support" }, supportDialogs: { capability: "conversations.view", departmentCode: "support" }, + supportPortals: { capability: "support.view", departmentCode: "support" }, + supportPortalDetail: { capability: "support.view", departmentCode: "support" }, }; export function hasCapability( @@ -75,6 +77,7 @@ const LANDING_PRIORITY: RouteKey[] = [ "supportOverview", "employees", "products", + "supportPortals", "aiAgents", "integrations", ]; diff --git a/apps/internal-ui/src/features/ai/knowledge/CategoryManagement.tsx b/apps/internal-ui/src/features/ai/knowledge/CategoryManagement.tsx index 8830162..7e4cd32 100644 --- a/apps/internal-ui/src/features/ai/knowledge/CategoryManagement.tsx +++ b/apps/internal-ui/src/features/ai/knowledge/CategoryManagement.tsx @@ -1,14 +1,4 @@ -import { Modal } from "antd"; -import { useMemo, useState } from "react"; - -import { Button } from "../../../shared/ui-controls"; -import { CategoryManagementRow } from "./CategoryManagementRow"; -import { - buildKnowledgeCategoryTree, - planCategoryDrop, - type CategoryDropPosition, - type KnowledgeCategoryNode, -} from "./knowledgeTree"; +import { CategoryManagementDialog } from "../../../shared/content-library/CategoryManagementDialog"; import { createKnowledgeCategory, deleteKnowledgeCategory, @@ -16,152 +6,30 @@ import { type KnowledgeCategory, } from "./model"; -type CategoryManagementProps = { +export function CategoryManagement({ + categories, + onChanged, + onClose, +}: { categories: KnowledgeCategory[]; onChanged: () => Promise; onClose: () => void; -}; - -export function CategoryManagement({ categories, onChanged, onClose }: CategoryManagementProps) { - const [editingId, setEditingId] = useState(null); - const [createParentId, setCreateParentId] = useState(); - const [draftName, setDraftName] = useState(""); - const [draggedId, setDraggedId] = useState(null); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const tree = useMemo(() => buildKnowledgeCategoryTree(categories), [categories]); - - function cancelEdit() { - setEditingId(null); - setCreateParentId(undefined); - setDraftName(""); - } - - function startEdit(category: KnowledgeCategoryNode) { - setCreateParentId(undefined); - setEditingId(category.id); - setDraftName(category.name); - setError(null); - } - - function startCreate(parentId: number | null) { - setEditingId(null); - setCreateParentId(parentId); - setDraftName(""); - setError(null); - } - - async function run(action: () => Promise) { - setBusy(true); - setError(null); - try { - await action(); - cancelEdit(); - await onChanged(); - } catch (caught) { - setError(caught instanceof Error ? caught.message : "Не удалось изменить категории"); - } finally { - setBusy(false); - } - } - - function saveEdit(category: KnowledgeCategoryNode) { - const name = draftName.trim(); - if (!name || name === category.name) { - cancelEdit(); - return; - } - void run(() => updateKnowledgeCategory(category.id, { name })); - } - - function saveCreate() { - const name = draftName.trim(); - if (!name || createParentId === undefined) return; - const siblings = categories.filter((category) => category.parentId === createParentId); - const sortOrder = Math.max(0, ...siblings.map((category) => category.sortOrder)) + 10; - void run(() => createKnowledgeCategory({ name, parentId: createParentId, sortOrder })); - } - - function removeCategory(category: KnowledgeCategoryNode) { - void run(() => deleteKnowledgeCategory(category.id)); - } - - function dropCategory(targetId: number, position: CategoryDropPosition) { - if (draggedId === null) return; - const updates = planCategoryDrop(categories, draggedId, targetId, position); - setDraggedId(null); - if (updates.length === 0) return; - void run(async () => { - for (const update of updates) { - await updateKnowledgeCategory(update.id, { - parentId: update.parentId, - sortOrder: update.sortOrder, - }); - } - }); - } - +}) { return ( - - - - - )} - open - title={( -
- Управление категориями - Категория определяет только размещение, не доступ -
- )} - width={560} - onCancel={onClose} - > -
- {tree.map((category) => ( - - ))} - {createParentId === null && ( -
- setDraftName(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") saveCreate(); - if (event.key === "Escape") { - event.stopPropagation(); - cancelEdit(); - } - }} - /> - - -
- )} - {error &&
{error}
} -
-
+ ({ + id: item.id, + name: item.name, + parentId: item.parentId, + sortOrder: item.sortOrder, + count: item.knowledgeCount ?? 0, + isSystem: item.isSystem, + }))} + onChanged={onChanged} + onClose={onClose} + onCreate={createKnowledgeCategory} + onDelete={deleteKnowledgeCategory} + onUpdate={updateKnowledgeCategory} + /> ); } diff --git a/apps/internal-ui/src/features/ai/knowledge/CategoryManagementRow.tsx b/apps/internal-ui/src/features/ai/knowledge/CategoryManagementRow.tsx deleted file mode 100644 index e9f0de3..0000000 --- a/apps/internal-ui/src/features/ai/knowledge/CategoryManagementRow.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import type { DragEvent } from "react"; - -import { Icon } from "../../../shared/icons"; -import type { CategoryDropPosition, KnowledgeCategoryNode } from "./knowledgeTree"; - -type CategoryManagementRowProps = { - category: KnowledgeCategoryNode; - createParentId: number | null | undefined; - draftName: string; - editingId: number | null; - level: number; - onCancelEdit: () => void; - onCreateChild: (categoryId: number) => void; - onDelete: (category: KnowledgeCategoryNode) => void; - onDragStart: (categoryId: number) => void; - onDrop: (targetId: number, position: CategoryDropPosition) => void; - onDraftNameChange: (value: string) => void; - onSaveEdit: (category: KnowledgeCategoryNode) => void; - onSaveCreate: () => void; - onStartEdit: (category: KnowledgeCategoryNode) => void; -}; - -function dropPosition(event: DragEvent, isSystem: boolean): CategoryDropPosition { - const bounds = event.currentTarget.getBoundingClientRect(); - const offset = (event.clientY - bounds.top) / bounds.height; - if (offset < 0.3) return "before"; - if (offset > 0.7 || isSystem) return "after"; - return "inside"; -} - -export function CategoryManagementRow({ - category, - createParentId, - draftName, - editingId, - level, - onCancelEdit, - onCreateChild, - onDelete, - onDragStart, - onDrop, - onDraftNameChange, - onSaveEdit, - onSaveCreate, - onStartEdit, -}: CategoryManagementRowProps) { - const hasChildren = category.children.length > 0; - const deletable = !category.isSystem && !hasChildren && category.knowledgeCount === 0; - - return ( - <> -
event.preventDefault()} - onDragStart={() => onDragStart(category.id)} - onDrop={(event) => { - event.preventDefault(); - event.stopPropagation(); - onDrop(category.id, dropPosition(event, category.isSystem)); - }} - > - {category.isSystem ? : ( - - - - )} - {hasChildren ? : } - {editingId === category.id ? ( -
- onDraftNameChange(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") onSaveEdit(category); - if (event.key === "Escape") { - event.stopPropagation(); - onCancelEdit(); - } - }} - /> - - -
- ) : ( - <> - - {category.isSystem && СИСТ.} - {category.knowledgeCount ?? 0} - {!category.isSystem && ( - - )} - - - )} -
- {createParentId === category.id && ( -
- onDraftNameChange(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") onSaveCreate(); - if (event.key === "Escape") { - event.stopPropagation(); - onCancelEdit(); - } - }} - /> - - -
- )} - {category.children.map((child) => ( - - ))} - - ); -} diff --git a/apps/internal-ui/src/features/ai/knowledge/KnowledgeCategoryTree.tsx b/apps/internal-ui/src/features/ai/knowledge/KnowledgeCategoryTree.tsx index d0928df..9485f1e 100644 --- a/apps/internal-ui/src/features/ai/knowledge/KnowledgeCategoryTree.tsx +++ b/apps/internal-ui/src/features/ai/knowledge/KnowledgeCategoryTree.tsx @@ -1,76 +1,6 @@ -import { useEffect, useRef, useState } from "react"; - -import { Icon } from "../../../shared/icons"; -import { buildKnowledgeCategoryTree, totalKnowledgeCount, type KnowledgeCategoryNode } from "./knowledgeTree"; +import { CategoryTree } from "../../../shared/content-library/CategoryTree"; import type { KnowledgeCategory } from "./types"; -type KnowledgeCategoryTreeProps = { - canManage: boolean; - categories: KnowledgeCategory[]; - error: boolean; - loading: boolean; - onManage: () => void; - onRetry: () => void; - onSelect: (categoryId: number | undefined) => void; - selectedId?: number; -}; - -function CategoryRows({ - expanded, - level, - nodes, - onSelect, - selectedId, - toggleExpanded, -}: { - expanded: Set; - level: number; - nodes: KnowledgeCategoryNode[]; - onSelect: (categoryId: number) => void; - selectedId?: number; - toggleExpanded: (categoryId: number) => void; -}) { - return nodes.map((category) => { - const hasChildren = category.children.length > 0; - const isExpanded = expanded.has(category.id); - return ( -
-
- {hasChildren ? ( - - ) : } - {!hasChildren && } - - {category.isSystem && СИСТ.} - {category.knowledgeCount ?? 0} -
- {hasChildren && isExpanded && ( - - )} -
- ); - }); -} - export function KnowledgeCategoryTree({ canManage, categories, @@ -80,69 +10,33 @@ export function KnowledgeCategoryTree({ onRetry, onSelect, selectedId, -}: KnowledgeCategoryTreeProps) { - const [expanded, setExpanded] = useState>(() => new Set()); - const initialized = useRef(false); - const tree = buildKnowledgeCategoryTree(categories); - - useEffect(() => { - if (initialized.current || categories.length === 0) return; - initialized.current = true; - setExpanded(new Set(categories.filter((category) => ( - categories.some((item) => item.parentId === category.id) - )).map((category) => category.id))); - }, [categories]); - - function toggleExpanded(categoryId: number) { - setExpanded((current) => { - const next = new Set(current); - if (next.has(categoryId)) next.delete(categoryId); - else next.add(categoryId); - return next; - }); - } - +}: { + canManage: boolean; + categories: KnowledgeCategory[]; + error: boolean; + loading: boolean; + onManage: () => void; + onRetry: () => void; + onSelect: (categoryId: number | undefined) => void; + selectedId?: number; +}) { return ( - + ({ + id: category.id, + name: category.name, + parentId: category.parentId, + count: category.knowledgeCount ?? 0, + badge: category.isSystem ? "СИСТ." : undefined, + }))} + error={error} + loading={loading} + selectedId={selectedId} + onManage={onManage} + onRetry={onRetry} + onSelect={onSelect} + /> ); } diff --git a/apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorBreadcrumb.tsx b/apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorBreadcrumb.tsx index b2008f1..754ff02 100644 --- a/apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorBreadcrumb.tsx +++ b/apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorBreadcrumb.tsx @@ -1,3 +1,4 @@ +import { ContentEditorBreadcrumb } from "../../../shared/content-library/ContentEditorBreadcrumb"; import { knowledgeCategoryPath } from "./knowledgeTree"; import type { KnowledgeCategory } from "./types"; @@ -14,10 +15,11 @@ export function KnowledgeEditorBreadcrumb({ }) { const category = categoryId === null ? "" : knowledgeCategoryPath(categories, categoryId); return ( -
- - {category && <>/{category}} - /{title || "Создание знания"} -
+ ); } diff --git a/apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorCard.tsx b/apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorCard.tsx index 4e21f28..9e02fd5 100644 --- a/apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorCard.tsx +++ b/apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorCard.tsx @@ -1,4 +1,5 @@ import { Icon } from "../../../shared/icons"; +import { ContentEditorCard } from "../../../shared/content-library/ContentEditorCard"; import { Button } from "../../../shared/ui-controls"; import { formatDate } from "../../../shared/utils"; import { KnowledgeEditorFields } from "./KnowledgeEditorFields"; @@ -31,15 +32,11 @@ export function KnowledgeEditorCard({ onToggleEnabled?: () => void; }) { return ( -
-
-

Знание

- {state.isEnabled ? "Активно" : "Выключено"} - {item && обновлено {formatDate(item.updatedAt)}} -
- - {error &&
{error}
} - {editable &&
+ Знание} + meta={<>{state.isEnabled ? "Активно" : "Выключено"}{item && обновлено {formatDate(item.updatedAt)}}} + error={error} + actions={editable && <> @@ -50,7 +47,9 @@ export function KnowledgeEditorCard({ )} {item && onDelete && } -
} -
+ } + > + + ); } diff --git a/apps/internal-ui/src/features/ai/knowledge/KnowledgePage.tsx b/apps/internal-ui/src/features/ai/knowledge/KnowledgePage.tsx index 0d0dd07..ad214ef 100644 --- a/apps/internal-ui/src/features/ai/knowledge/KnowledgePage.tsx +++ b/apps/internal-ui/src/features/ai/knowledge/KnowledgePage.tsx @@ -36,7 +36,7 @@ function KnowledgeAccessNotice({ departments, user }: { departments: Department[ return (
Доступ ограничен отделом -

Ваш доступ ai.view охватывает: {departmentNames.join(", ")}. Знания других отделов не показываются и недоступны для изменения.

+

Доступны знания отделов: {departmentNames.join(", ")}. Материалы других отделов не показываются.

); } @@ -125,7 +125,7 @@ export function KnowledgePage({ departments, openAgent, openKnowledge, setRoute, onToggleSelected={library.toggleSelected} onToggleVisible={library.toggleVisible} /> - {bulkMode &&

Операция атомарна: при одной недопустимой записи или конфликтующем агенте ничего не изменяется. Выбор снимается только после успеха.

} + {bulkMode &&

Если часть выбранных знаний нельзя изменить, список останется без изменений. Выбор снимется после успешного сохранения.

} {categoryManagementOpen && ( diff --git a/apps/internal-ui/src/features/ai/knowledge/KnowledgeTable.tsx b/apps/internal-ui/src/features/ai/knowledge/KnowledgeTable.tsx index b518c95..5386430 100644 --- a/apps/internal-ui/src/features/ai/knowledge/KnowledgeTable.tsx +++ b/apps/internal-ui/src/features/ai/knowledge/KnowledgeTable.tsx @@ -1,7 +1,5 @@ -import { Icon } from "../../../shared/icons"; -import { LoadingState } from "../../../shared/ui"; +import { ContentLibraryTable } from "../../../shared/content-library/ContentLibraryTable"; import { formatDate } from "../../../shared/utils"; -import { Button } from "../../../shared/ui-controls"; import { knowledgeCategoryPath } from "./knowledgeTree"; import type { KnowledgeCategory, KnowledgeItem } from "./model"; @@ -40,26 +38,26 @@ export function KnowledgeTable({ selectedCategoryId, selectedIds, }: KnowledgeTableProps) { - if (loading) return
; - if (error) return
Не удалось загрузить знания
; - if (items.length === 0) { - if (selectedCategoryId !== undefined && !hasActiveFilters) { - return ( -
- - В категории пока нет знаний -

Создайте знание в этой категории или переместите существующие.

- {canCreate && } -
- ); - } - return
{hasActiveFilters ? "Ничего не найдено" : "Знаний пока нет"}
; - } - const allVisibleSelected = items.every((item) => selectedIds.has(item.id)); + const emptyCategory = selectedCategoryId !== undefined && !hasActiveFilters; return ( -
+ 0} + loading={loading} + onCreate={onCreate} + onRetry={onRetry} + footer={!bulkMode &&
+ {items.length} знаний + {selectedIds.size > 0 ? `Выбрано: ${selectedIds.size}` : "Изменения знаний применяются к агентам сразу"} +
} + > {canSelect && } @@ -135,10 +133,6 @@ export function KnowledgeTable({ ))}
- {!bulkMode &&
- {items.length} знаний - {selectedIds.size > 0 ? `Выбрано: ${selectedIds.size}` : "Изменения знаний применяются к агентам сразу"} -
} -
+ ); } diff --git a/apps/internal-ui/src/features/ai/knowledge/KnowledgeToolbar.tsx b/apps/internal-ui/src/features/ai/knowledge/KnowledgeToolbar.tsx index dbcb828..cc7c944 100644 --- a/apps/internal-ui/src/features/ai/knowledge/KnowledgeToolbar.tsx +++ b/apps/internal-ui/src/features/ai/knowledge/KnowledgeToolbar.tsx @@ -1,4 +1,4 @@ -import { SearchInput } from "../../../shared/ui-controls"; +import { ContentLibraryToolbar } from "../../../shared/content-library/ContentLibraryToolbar"; import type { KnowledgeDepartmentReference, KnowledgeVisibility } from "./types"; type KnowledgeToolbarProps = { @@ -25,35 +25,30 @@ export function KnowledgeToolbar({ visibility, }: KnowledgeToolbarProps) { return ( -
- - - - -
+ + + + + ); } diff --git a/apps/internal-ui/src/features/ai/knowledge/knowledgeTree.test.ts b/apps/internal-ui/src/features/ai/knowledge/knowledgeTree.test.ts index 2f9892b..55f990d 100644 --- a/apps/internal-ui/src/features/ai/knowledge/knowledgeTree.test.ts +++ b/apps/internal-ui/src/features/ai/knowledge/knowledgeTree.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest"; import type { KnowledgeCategory } from "./types"; import { - buildKnowledgeCategoryTree, - knowledgeCategoryPath, + buildCategoryTree, planCategoryDrop, +} from "../../../shared/content-library/categoryManagementModel"; +import { + knowledgeCategoryPath, totalKnowledgeCount, } from "./knowledgeTree"; @@ -18,7 +20,14 @@ const categories: KnowledgeCategory[] = [ describe("knowledge category tree", () => { it("builds a deterministic hierarchy and aggregate total", () => { - const tree = buildKnowledgeCategoryTree(categories); + const tree = buildCategoryTree(categories.map((item) => ({ + id: item.id, + name: item.name, + parentId: item.parentId, + sortOrder: item.sortOrder, + count: item.knowledgeCount ?? 0, + isSystem: item.isSystem, + }))); expect(tree.map((category) => category.id)).toEqual([1, 2, 3]); expect(tree[1].children.map((category) => category.id)).toEqual([5, 4]); @@ -30,10 +39,18 @@ describe("knowledge category tree", () => { }); it("plans reparenting and rejects cycles and system moves", () => { - expect(planCategoryDrop(categories, 3, 5, "inside")).toEqual([ + const managed = categories.map((item) => ({ + id: item.id, + name: item.name, + parentId: item.parentId, + sortOrder: item.sortOrder, + count: item.knowledgeCount ?? 0, + isSystem: item.isSystem, + })); + expect(planCategoryDrop(managed, 3, 5, "inside")).toEqual([ { id: 3, parentId: 5, sortOrder: 10 }, ]); - expect(planCategoryDrop(categories, 2, 4, "inside")).toEqual([]); - expect(planCategoryDrop(categories, 1, 3, "after")).toEqual([]); + expect(planCategoryDrop(managed, 2, 4, "inside")).toEqual([]); + expect(planCategoryDrop(managed, 1, 3, "after")).toEqual([]); }); }); diff --git a/apps/internal-ui/src/features/ai/knowledge/knowledgeTree.ts b/apps/internal-ui/src/features/ai/knowledge/knowledgeTree.ts index f574f6e..0621be2 100644 --- a/apps/internal-ui/src/features/ai/knowledge/knowledgeTree.ts +++ b/apps/internal-ui/src/features/ai/knowledge/knowledgeTree.ts @@ -1,45 +1,5 @@ import type { KnowledgeCategory } from "./types"; -export type KnowledgeCategoryNode = KnowledgeCategory & { - children: KnowledgeCategoryNode[]; -}; - -export type CategoryDropPosition = "after" | "before" | "inside"; - -export type CategoryOrderUpdate = { - id: number; - parentId: number | null; - sortOrder: number; -}; - -function categoryOrder(left: KnowledgeCategory, right: KnowledgeCategory): number { - return left.sortOrder - right.sortOrder - || left.name.localeCompare(right.name, "ru") - || left.id - right.id; -} - -export function buildKnowledgeCategoryTree( - categories: KnowledgeCategory[], -): KnowledgeCategoryNode[] { - const nodes = new Map( - categories.map((category) => [category.id, { ...category, children: [] }]), - ); - const roots: KnowledgeCategoryNode[] = []; - - for (const node of nodes.values()) { - const parent = node.parentId === null ? undefined : nodes.get(node.parentId); - if (parent && parent.id !== node.id) parent.children.push(node); - else roots.push(node); - } - - const sortNodes = (items: KnowledgeCategoryNode[]) => { - items.sort(categoryOrder); - items.forEach((item) => sortNodes(item.children)); - }; - sortNodes(roots); - return roots; -} - export function totalKnowledgeCount(categories: KnowledgeCategory[]): number { return categories .filter((category) => category.parentId === null) @@ -62,53 +22,3 @@ export function knowledgeCategoryPath( } return path.join(" / "); } - -function descendantsOf(categories: KnowledgeCategory[], categoryId: number): Set { - const children = new Map(); - categories.forEach((category) => { - if (category.parentId === null) return; - children.set(category.parentId, [...(children.get(category.parentId) ?? []), category.id]); - }); - const descendants = new Set(); - const pending = [...(children.get(categoryId) ?? [])]; - while (pending.length > 0) { - const next = pending.pop(); - if (next === undefined || descendants.has(next)) continue; - descendants.add(next); - pending.push(...(children.get(next) ?? [])); - } - return descendants; -} - -export function planCategoryDrop( - categories: KnowledgeCategory[], - draggedId: number, - targetId: number, - position: CategoryDropPosition, -): CategoryOrderUpdate[] { - const dragged = categories.find((category) => category.id === draggedId); - const target = categories.find((category) => category.id === targetId); - if (!dragged || !target || dragged.isSystem || dragged.id === target.id) return []; - if (descendantsOf(categories, dragged.id).has(target.id)) return []; - if (position === "inside" && target.isSystem) return []; - - const parentId = position === "inside" ? target.id : target.parentId; - const siblings = categories - .filter((category) => category.parentId === parentId && category.id !== dragged.id) - .sort(categoryOrder); - const targetIndex = position === "inside" - ? siblings.length - : Math.max(0, siblings.findIndex((category) => category.id === target.id)); - const insertIndex = position === "after" ? targetIndex + 1 : targetIndex; - siblings.splice(insertIndex, 0, { ...dragged, parentId }); - - return siblings.flatMap((category, index) => { - const sortOrder = (index + 1) * 10; - if ( - category.id !== dragged.id - && category.parentId === categories.find((item) => item.id === category.id)?.parentId - && category.sortOrder === sortOrder - ) return []; - return [{ id: category.id, parentId, sortOrder }]; - }); -} diff --git a/apps/internal-ui/src/features/conversations/ConversationThread.tsx b/apps/internal-ui/src/features/conversations/ConversationThread.tsx index ff1ed1d..b554c58 100644 --- a/apps/internal-ui/src/features/conversations/ConversationThread.tsx +++ b/apps/internal-ui/src/features/conversations/ConversationThread.tsx @@ -67,8 +67,8 @@ function MessageRow({ message, dialog }: { message: ApiMessage; dialog: Conversa const actor = message.author === "AI" ? "AI-агент" : message.author === "OPERATOR" ? "Оператор" : undefined; return ( - {message.author === "CONTACT" && message.contentHtml - ? + {message.author === "CONTACT" && dialog.channel === "EMAIL" + ? : message.text} ); diff --git a/apps/internal-ui/src/features/conversations/EmailMessageBody.tsx b/apps/internal-ui/src/features/conversations/EmailMessageBody.tsx index 22c6a47..543512c 100644 --- a/apps/internal-ui/src/features/conversations/EmailMessageBody.tsx +++ b/apps/internal-ui/src/features/conversations/EmailMessageBody.tsx @@ -1,5 +1,7 @@ import { useMemo } from "react"; +import { foldQuotedHtml, splitQuotedEmail } from "./emailContent"; + const DOCUMENT_START = ` @@ -16,15 +18,32 @@ const DOCUMENT_START = ` table { width: 100%; border-collapse: collapse; } th, td { padding: 5px 7px; border: 1px solid #d9d9d9; text-align: left; vertical-align: top; } a { color: #1677ff; } + details.email-quoted { margin-top: 10px; color: #595959; } + details.email-quoted > summary { color: #8c8c8c; cursor: pointer; user-select: none; } + details.email-quoted > blockquote { margin-top: 8px; } `; -export function EmailMessageBody({ html }: { html: string }) { +export function EmailMessageBody({ html, text }: { html?: string; text: string }) { + const textParts = useMemo(() => splitQuotedEmail(text), [text]); const srcDoc = useMemo( - () => `${DOCUMENT_START}${html}`, + () => `${DOCUMENT_START}${foldQuotedHtml(html ?? "")}`, [html], ); + if (!html) { + return ( +
+ {textParts.latest &&
{textParts.latest}
} + {textParts.quoted && ( +
+ Показать предыдущие сообщения +
{textParts.quoted}
+
+ )} +
+ ); + } return (