✨ feat(support): add reusable multi-portal help centers

This commit is contained in:
Andrey committed 2026-07-28 21:39:25 +03:00
1 parent 55ab309ae7
commit 2b246e193b
128 files changed
+9178 -655

No files matched your search

+3
View File
@@ -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
+19
View File
@@ -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.
+13 -1
View File
@@ -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", []),
+12
View File
@@ -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}
@@ -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 = []
+1
View File
@@ -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")),
]
@@ -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")),
]
@@ -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,
@@ -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(
@@ -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"
@@ -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,
),
]
@@ -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,
),
]
@@ -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,
),
]
@@ -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)
+2 -1
View File
@@ -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/<int:contract_id>/",
@@ -0,0 +1 @@
@@ -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 ''}"
@@ -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,
)
)
@@ -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)
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class SupportPortalsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "hub_platform.support_portals"
@@ -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": "Восстановите портал, чтобы изменить его содержимое"}
)
@@ -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)})
@@ -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
@@ -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)
@@ -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
@@ -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",
),
),
]
@@ -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",
),
),
]
@@ -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),
),
]
@@ -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",
),
),
]
@@ -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),
),
]
@@ -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"]
@@ -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": "Портал не относится к отделу поддержки"})
@@ -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)})
@@ -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/<slug:article_slug>/", public_views.PublicArticleDetailView.as_view(), name="public-support-article"),
path("articles/<slug:article_slug>/feedback/", public_views.PublicArticleFeedbackView.as_view(), name="public-support-feedback"),
]
@@ -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)
@@ -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
@@ -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,
}
@@ -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", "Архив"
@@ -0,0 +1 @@
@@ -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",
)
@@ -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)
@@ -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])
@@ -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")
@@ -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("<int:portal_id>/", portal_views.PortalDetailView.as_view(), name="support-portal-detail"),
path("<int:portal_id>/status/", portal_views.PortalStatusView.as_view(), name="support-portal-status"),
path("<int:portal_id>/products/", portal_views.PortalProductsView.as_view(), name="support-portal-products"),
path("<int:portal_id>/support-channels/", portal_views.PortalSupportChannelsView.as_view(), name="support-portal-support-channels"),
path("<int:portal_id>/domain/", portal_views.PortalDomainView.as_view(), name="support-portal-domain"),
path("<int:portal_id>/domain/verify/", portal_views.PortalDomainVerifyView.as_view(), name="support-portal-domain-verify"),
path("<int:portal_id>/categories/", content_views.CategoryListView.as_view(), name="support-portal-categories"),
path("<int:portal_id>/categories/<int:category_id>/", content_views.CategoryDetailView.as_view(), name="support-portal-category-detail"),
path("<int:portal_id>/articles/", content_views.ArticleListView.as_view(), name="support-portal-articles"),
path("<int:portal_id>/articles/<int:article_id>/", content_views.ArticleDetailView.as_view(), name="support-portal-article"),
path("<int:portal_id>/articles/<int:article_id>/revisions/", content_views.ArticleRevisionListView.as_view(), name="support-portal-article-revisions"),
path("<int:portal_id>/articles/<int:article_id>/publish/", content_views.ArticlePublishView.as_view(), name="support-portal-article-publish"),
path("<int:portal_id>/articles/<int:article_id>/archive/", content_views.ArticleArchiveView.as_view(), name="support-portal-article-archive"),
]
@@ -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(
@@ -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)]
@@ -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)]
+1
View File
@@ -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:
+1
View File
@@ -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
+3 -1
View File
@@ -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",
+2 -2
View File
@@ -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) ? (
<PermissionScreen onReturn={() => navigate(defaultRoute(user), null, true)} />
) : (
<Shell route={navigation.route} setRoute={(nextRoute) => 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} />
<Shell route={navigation.route} setRoute={(nextRoute) => 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} />
)}
</ConfigProvider>
);
+12
View File
@@ -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",
+1
View File
@@ -28,6 +28,7 @@ const publicPaths = [
"/api/v1/calls/invites/",
"/api/v1/orders/ingest/",
"/api/v1/support/sessions/",
"/api/v1/help/",
];
export type ApiErrorPayload = {
+2
View File
@@ -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);
});
+3
View File
@@ -29,6 +29,8 @@ const ROUTE_ACCESS: Partial<Record<RouteKey, RouteAccess>> = {
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",
];
@@ -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<unknown>;
onClose: () => void;
};
export function CategoryManagement({ categories, onChanged, onClose }: CategoryManagementProps) {
const [editingId, setEditingId] = useState<number | null>(null);
const [createParentId, setCreateParentId] = useState<number | null | undefined>();
const [draftName, setDraftName] = useState("");
const [draggedId, setDraggedId] = useState<number | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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<unknown>) {
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 (
<Modal
className="knowledge-category-modal"
destroyOnClose
footer={(
<div className="knowledge-category-modal-footer">
<Button variant="secondary" icon="plus" disabled={busy} onClick={() => startCreate(null)}>Создать категорию</Button>
<Button variant="primary" disabled={busy} onClick={onClose}>Готово</Button>
</div>
)}
open
title={(
<div className="knowledge-category-modal-title">
<strong>Управление категориями</strong>
<span>Категория определяет только размещение, не доступ</span>
</div>
)}
width={560}
onCancel={onClose}
>
<div className="knowledge-category-manage-list" aria-busy={busy}>
{tree.map((category) => (
<CategoryManagementRow
category={category}
createParentId={createParentId}
draftName={draftName}
editingId={editingId}
level={0}
onCancelEdit={cancelEdit}
onCreateChild={startCreate}
onDelete={removeCategory}
onDragStart={setDraggedId}
onDrop={dropCategory}
onDraftNameChange={setDraftName}
onSaveCreate={saveCreate}
onSaveEdit={saveEdit}
onStartEdit={startEdit}
key={category.id}
/>
))}
{createParentId === null && (
<div className="knowledge-category-create-row root">
<input
autoFocus
placeholder="Название категории"
value={draftName}
onChange={(event) => setDraftName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") saveCreate();
if (event.key === "Escape") {
event.stopPropagation();
cancelEdit();
}
}}
/>
<button className="save" disabled={!draftName.trim()} type="button" onClick={saveCreate}>Сохранить</button>
<button type="button" onClick={cancelEdit}>Отмена</button>
</div>
)}
{error && <div className="knowledge-category-error">{error}</div>}
</div>
</Modal>
<CategoryManagementDialog
categories={categories.map((item) => ({
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}
/>
);
}
@@ -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<HTMLDivElement>, 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 (
<>
<div
className="knowledge-category-manage-row"
draggable={!category.isSystem && editingId !== category.id}
style={{ paddingLeft: 10 + level * 20 }}
onDragOver={(event) => event.preventDefault()}
onDragStart={() => onDragStart(category.id)}
onDrop={(event) => {
event.preventDefault();
event.stopPropagation();
onDrop(category.id, dropPosition(event, category.isSystem));
}}
>
{category.isSystem ? <span className="knowledge-category-drag-spacer" /> : (
<span className="knowledge-category-drag-handle" title="Перетащите для перемещения">
<Icon name="grip" size={14} />
</span>
)}
{hasChildren ? <Icon name="chevron" size={14} /> : <Icon name="folder" size={15} />}
{editingId === category.id ? (
<div className="knowledge-category-inline-edit">
<input
autoFocus
value={draftName}
onChange={(event) => onDraftNameChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") onSaveEdit(category);
if (event.key === "Escape") {
event.stopPropagation();
onCancelEdit();
}
}}
/>
<button className="save" disabled={!draftName.trim()} type="button" onClick={() => onSaveEdit(category)}>Сохранить</button>
<button type="button" onClick={onCancelEdit}>Отмена</button>
</div>
) : (
<>
<button
className="knowledge-category-manage-name"
disabled={category.isSystem}
title={category.isSystem ? "Системная категория — нельзя изменить" : "Переименовать"}
type="button"
onClick={() => onStartEdit(category)}
>
{category.name}
</button>
{category.isSystem && <span className="knowledge-system-badge">СИСТ.</span>}
<span className="knowledge-category-count">{category.knowledgeCount ?? 0}</span>
{!category.isSystem && (
<button aria-label={`Создать подкатегорию в ${category.name}`} className="knowledge-category-action" type="button" onClick={() => onCreateChild(category.id)}>
<Icon name="plus" size={14} />
</button>
)}
<button
aria-label={`Удалить ${category.name}`}
className="knowledge-category-delete"
disabled={!deletable}
title={!deletable ? "Можно удалить только пустую категорию без вложенных категорий" : "Удалить"}
type="button"
onClick={() => onDelete(category)}
>
<Icon name="trash" size={14} />
</button>
</>
)}
</div>
{createParentId === category.id && (
<div className="knowledge-category-create-row" style={{ marginLeft: 50 + level * 20 }}>
<input
autoFocus
placeholder="Название категории"
value={draftName}
onChange={(event) => onDraftNameChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") onSaveCreate();
if (event.key === "Escape") {
event.stopPropagation();
onCancelEdit();
}
}}
/>
<button className="save" disabled={!draftName.trim()} type="button" onClick={onSaveCreate}>Сохранить</button>
<button type="button" onClick={onCancelEdit}>Отмена</button>
</div>
)}
{category.children.map((child) => (
<CategoryManagementRow
category={child}
createParentId={createParentId}
draftName={draftName}
editingId={editingId}
level={level + 1}
onCancelEdit={onCancelEdit}
onCreateChild={onCreateChild}
onDelete={onDelete}
onDragStart={onDragStart}
onDrop={onDrop}
onDraftNameChange={onDraftNameChange}
onSaveEdit={onSaveEdit}
onSaveCreate={onSaveCreate}
onStartEdit={onStartEdit}
key={child.id}
/>
))}
</>
);
}
@@ -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<number>;
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 (
<div key={category.id}>
<div
className={`knowledge-category-row${selectedId === category.id ? " active" : ""}`}
style={{ paddingLeft: 10 + level * 20 }}
>
{hasChildren ? (
<button
aria-label={isExpanded ? `Свернуть ${category.name}` : `Развернуть ${category.name}`}
className={`knowledge-category-chevron${isExpanded ? " expanded" : ""}`}
type="button"
onClick={() => toggleExpanded(category.id)}
>
<Icon name="chevron" size={14} />
</button>
) : <span className="knowledge-category-chevron-spacer" />}
{!hasChildren && <Icon name="folder" size={15} />}
<button className="knowledge-category-name" type="button" onClick={() => onSelect(category.id)}>
{category.name}
</button>
{category.isSystem && <span className="knowledge-system-badge">СИСТ.</span>}
<span className="knowledge-category-count">{category.knowledgeCount ?? 0}</span>
</div>
{hasChildren && isExpanded && (
<CategoryRows
expanded={expanded}
level={level + 1}
nodes={category.children}
onSelect={onSelect}
selectedId={selectedId}
toggleExpanded={toggleExpanded}
/>
)}
</div>
);
});
}
export function KnowledgeCategoryTree({
canManage,
categories,
@@ -80,69 +10,33 @@ export function KnowledgeCategoryTree({
onRetry,
onSelect,
selectedId,
}: KnowledgeCategoryTreeProps) {
const [expanded, setExpanded] = useState<Set<number>>(() => 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 (
<aside className="knowledge-category-tree">
<header>
<span>КАТЕГОРИИ</span>
{canManage && (
<button type="button" onClick={onManage}>
<Icon name="plus" size={13} />Управление
</button>
)}
</header>
<div className="knowledge-category-tree-body">
<button
className={`knowledge-category-all${selectedId === undefined ? " active" : ""}`}
type="button"
onClick={() => onSelect(undefined)}
>
<span className="knowledge-category-active-mark" />
<Icon name="folder" size={16} />
<span>Все знания</span>
<b>{totalKnowledgeCount(categories)}</b>
</button>
{loading ? (
<div className="knowledge-category-state">Загрузка…</div>
) : error ? (
<div className="knowledge-category-state">
<span>Не удалось загрузить категории</span>
<button type="button" onClick={onRetry}>Повторить</button>
</div>
) : tree.length === 0 ? (
<div className="knowledge-category-state">Категорий пока нет</div>
) : (
<CategoryRows
expanded={expanded}
level={0}
nodes={tree}
onSelect={onSelect}
selectedId={selectedId}
toggleExpanded={toggleExpanded}
/>
)}
</div>
</aside>
<CategoryTree
allLabel="Все знания"
canManage={canManage}
categories={categories.map((category) => ({
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}
/>
);
}
@@ -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 (
<div className="knowledge-editor-breadcrumb">
<button type="button" onClick={onBack}>Знания</button>
{category && <><span>/</span><em>{category}</em></>}
<span>/</span><strong>{title || "Создание знания"}</strong>
</div>
<ContentEditorBreadcrumb
backLabel="Знания"
category={category}
title={title || "Создание знания"}
onBack={onBack}
/>
);
}
@@ -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 (
<section className="knowledge-editor-card knowledge-main-card">
<div className="knowledge-card-heading">
<h3>Знание</h3>
<span className={`knowledge-card-status ${state.isEnabled ? "active" : "disabled"}`}><i />{state.isEnabled ? "Активно" : "Выключено"}</span>
{item && <span className="knowledge-card-updated">обновлено {formatDate(item.updatedAt)}</span>}
</div>
<KnowledgeEditorFields categories={categories} departments={departments} disabled={busy || !editable} state={state} onChange={onChange} />
{error && <div className="knowledge-editor-error">{error}</div>}
{editable && <div className="knowledge-editor-actions">
<ContentEditorCard
heading={<h3>Знание</h3>}
meta={<><span className={`knowledge-card-status ${state.isEnabled ? "active" : "disabled"}`}><i />{state.isEnabled ? "Активно" : "Выключено"}</span>{item && <span className="knowledge-card-updated">обновлено {formatDate(item.updatedAt)}</span>}</>}
error={error}
actions={editable && <>
<Button variant="primary" icon={item ? "save" : "plus"} disabled={busy} onClick={onSave}>
{item ? "Сохранить" : "Создать знание"}
</Button>
@@ -50,7 +47,9 @@ export function KnowledgeEditorCard({
)}
<span />
{item && onDelete && <Button className="knowledge-delete-button" variant="secondary" icon="trash" disabled={busy} onClick={onDelete}>Удалить</Button>}
</div>}
</section>
</>}
>
<KnowledgeEditorFields categories={categories} departments={departments} disabled={busy || !editable} state={state} onChange={onChange} />
</ContentEditorCard>
);
}
@@ -36,7 +36,7 @@ function KnowledgeAccessNotice({ departments, user }: { departments: Department[
return (
<div className="knowledge-access-notice">
<span>Доступ ограничен отделом</span>
<p>Ваш доступ <code>ai.view</code> охватывает: {departmentNames.join(", ")}. Знания других отделов не показываются и недоступны для изменения.</p>
<p>Доступны знания отделов: {departmentNames.join(", ")}. Материалы других отделов не показываются.</p>
</div>
);
}
@@ -125,7 +125,7 @@ export function KnowledgePage({ departments, openAgent, openKnowledge, setRoute,
onToggleSelected={library.toggleSelected}
onToggleVisible={library.toggleVisible}
/>
{bulkMode && <p className="knowledge-bulk-note">Операция атомарна: при одной недопустимой записи или конфликтующем агенте ничего не изменяется. Выбор снимается только после успеха.</p>}
{bulkMode && <p className="knowledge-bulk-note">Если часть выбранных знаний нельзя изменить, список останется без изменений. Выбор снимется после успешного сохранения.</p>}
</main>
</div>
{categoryManagementOpen && (
@@ -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 <div className="knowledge-table-state"><LoadingState variant="inline" /></div>;
if (error) return <div className="knowledge-table-state"><strong>Не удалось загрузить знания</strong><Button variant="secondary" onClick={onRetry}>Повторить</Button></div>;
if (items.length === 0) {
if (selectedCategoryId !== undefined && !hasActiveFilters) {
return (
<div className="knowledge-empty-category">
<span><Icon name="folder" size={24} /></span>
<strong>В категории пока нет знаний</strong>
<p>Создайте знание в этой категории или переместите существующие.</p>
{canCreate && <Button variant="primary" onClick={onCreate}>Создать знание</Button>}
</div>
);
}
return <div className="knowledge-table-state"><strong>{hasActiveFilters ? "Ничего не найдено" : "Знаний пока нет"}</strong></div>;
}
const allVisibleSelected = items.every((item) => selectedIds.has(item.id));
const emptyCategory = selectedCategoryId !== undefined && !hasActiveFilters;
return (
<div className="table-card knowledge-table-card">
<ContentLibraryTable
canCreate={canCreate && emptyCategory}
createLabel="Создать знание"
emptyDescription={emptyCategory ? "Создайте знание в этой категории или переместите существующие." : undefined}
emptyTitle={emptyCategory ? "В категории пока нет знаний" : hasActiveFilters ? "Ничего не найдено" : "Знаний пока нет"}
error={error}
errorTitle="Не удалось загрузить знания"
hasItems={items.length > 0}
loading={loading}
onCreate={onCreate}
onRetry={onRetry}
footer={!bulkMode && <div className="ai-table-footer">
<span>{items.length} знаний</span>
<span>{selectedIds.size > 0 ? `Выбрано: ${selectedIds.size}` : "Изменения знаний применяются к агентам сразу"}</span>
</div>}
>
<table className="baseline-table knowledge-table">
<colgroup>
{canSelect && <col className="knowledge-col-select" />}
@@ -135,10 +133,6 @@ export function KnowledgeTable({
))}
</tbody>
</table>
{!bulkMode && <div className="ai-table-footer">
<span>{items.length} знаний</span>
<span>{selectedIds.size > 0 ? `Выбрано: ${selectedIds.size}` : "Изменения знаний применяются к агентам сразу"}</span>
</div>}
</div>
</ContentLibraryTable>
);
}
@@ -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 (
<div className="knowledge-toolbar">
<SearchInput
placeholder="Поиск по заголовку и описанию"
value={query}
onChange={onQueryChange}
/>
<label className="knowledge-filter-select department">
<span>Отдел:</span>
<select value={department ?? ""} onChange={(event) => onDepartmentChange(event.target.value ? Number(event.target.value) : undefined)}>
<option value="">Все</option>
{departments.map((item) => <option value={item.id} key={item.id}>{item.name}</option>)}
</select>
</label>
<label className="knowledge-filter-select visibility">
<span>Доступность:</span>
<select value={visibility ?? ""} onChange={(event) => onVisibilityChange(event.target.value ? event.target.value as KnowledgeVisibility : undefined)}>
<option value="">Все</option>
<option value="ORGANIZATION">Организация</option>
<option value="DEPARTMENTS">Отделы</option>
</select>
</label>
<label className="knowledge-filter-select status">
<span>Статус:</span>
<select value={isEnabled === undefined ? "" : String(isEnabled)} onChange={(event) => onEnabledChange(event.target.value === "" ? undefined : event.target.value === "true")}>
<option value="">Все</option>
<option value="true">Активно</option>
<option value="false">Выключено</option>
</select>
</label>
</div>
<ContentLibraryToolbar query={query} onQueryChange={onQueryChange}>
<label className="knowledge-filter-select department">
<span>Отдел:</span>
<select value={department ?? ""} onChange={(event) => onDepartmentChange(event.target.value ? Number(event.target.value) : undefined)}>
<option value="">Все</option>
{departments.map((item) => <option value={item.id} key={item.id}>{item.name}</option>)}
</select>
</label>
<label className="knowledge-filter-select visibility">
<span>Доступность:</span>
<select value={visibility ?? ""} onChange={(event) => onVisibilityChange(event.target.value ? event.target.value as KnowledgeVisibility : undefined)}>
<option value="">Все</option>
<option value="ORGANIZATION">Организация</option>
<option value="DEPARTMENTS">Отделы</option>
</select>
</label>
<label className="knowledge-filter-select status">
<span>Статус:</span>
<select value={isEnabled === undefined ? "" : String(isEnabled)} onChange={(event) => onEnabledChange(event.target.value === "" ? undefined : event.target.value === "true")}>
<option value="">Все</option>
<option value="true">Активно</option>
<option value="false">Выключено</option>
</select>
</label>
</ContentLibraryToolbar>
);
}
@@ -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([]);
});
});
@@ -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<number, KnowledgeCategoryNode>(
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<number> {
const children = new Map<number, number[]>();
categories.forEach((category) => {
if (category.parentId === null) return;
children.set(category.parentId, [...(children.get(category.parentId) ?? []), category.id]);
});
const descendants = new Set<number>();
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 }];
});
}
@@ -67,8 +67,8 @@ function MessageRow({ message, dialog }: { message: ApiMessage; dialog: Conversa
const actor = message.author === "AI" ? "AI-агент" : message.author === "OPERATOR" ? "Оператор" : undefined;
return (
<Message side={side} initials={dialog.initials} avatarBg={dialog.avatarBg} actor={actor} time={fmtTime(message.createdAt)}>
{message.author === "CONTACT" && message.contentHtml
? <EmailMessageBody html={message.contentHtml} />
{message.author === "CONTACT" && dialog.channel === "EMAIL"
? <EmailMessageBody html={message.contentHtml} text={message.text} />
: message.text}
</Message>
);
@@ -1,5 +1,7 @@
import { useMemo } from "react";
import { foldQuotedHtml, splitQuotedEmail } from "./emailContent";
const DOCUMENT_START = `<!doctype html>
<html lang="ru">
<head>
@@ -16,15 +18,32 @@ const DOCUMENT_START = `<!doctype html>
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; }
</style>
</head>
<body>`;
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}</body></html>`,
() => `${DOCUMENT_START}${foldQuotedHtml(html ?? "")}</body></html>`,
[html],
);
if (!html) {
return (
<div className="sales-email-plain">
{textParts.latest && <div>{textParts.latest}</div>}
{textParts.quoted && (
<details className="sales-email-quoted">
<summary>Показать предыдущие сообщения</summary>
<div>{textParts.quoted}</div>
</details>
)}
</div>
);
}
return (
<iframe
className="sales-email-body-frame"
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { foldQuotedHtml, splitQuotedEmail } from "./emailContent";
describe("splitQuotedEmail", () => {
it("preserves a message without quoted history", () => {
expect(splitQuotedEmail("Первая строка\n\nВторая строка")).toEqual({
latest: "Первая строка\n\nВторая строка",
quoted: "",
});
});
it("separates lines prefixed as an email quote", () => {
expect(splitQuotedEmail("Новый ответ\n\n> Старое письмо\n> Вторая строка")).toEqual({
latest: "Новый ответ",
quoted: "> Старое письмо\n> Вторая строка",
});
});
it("separates an inline localized reply header", () => {
const text = "Держись\nСр, 22 июля 2026 г. в 21:10, Foxray <foxray@edevs.tech>: > Старое письмо";
expect(splitQuotedEmail(text)).toEqual({
latest: "Держись",
quoted: "Ср, 22 июля 2026 г. в 21:10, Foxray <foxray@edevs.tech>: > Старое письмо",
});
});
});
describe("foldQuotedHtml", () => {
it("wraps an HTML quote in a native collapsed section", () => {
expect(foldQuotedHtml("<p>Ответ</p><blockquote>История</blockquote>")).toBe(
'<p>Ответ</p><details class="email-quoted"><summary>Показать предыдущие сообщения</summary><blockquote>История</blockquote></details>',
);
});
it("does not change HTML without a quote", () => {
expect(foldQuotedHtml("<p>Ответ</p>")).toBe("<p>Ответ</p>");
});
});
@@ -0,0 +1,48 @@
export type EmailTextParts = {
latest: string;
quoted: string;
};
const QUOTE_HEADER_PATTERNS = [
/^on .+ wrote:\s*$/i,
/^.+(?:писал|писала|написал|написала):\s*$/i,
/^-{2,}\s*(?:original message|исходное сообщение)\s*-{2,}$/i,
];
const INLINE_HEADER = /(?:Пн|Вт|Ср|Чт|Пт|Сб|Вс|Mon|Tue|Wed|Thu|Fri|Sat|Sun),?\s+[^<\n]{1,120}<[^>\n]+>:\s*>?/i;
function quoteOffset(line: string): number {
const firstContent = line.search(/\S/);
if (firstContent < 0) return -1;
const trimmed = line.slice(firstContent);
if (trimmed.startsWith(">")) return firstContent;
if (QUOTE_HEADER_PATTERNS.some((pattern) => pattern.test(trimmed))) return firstContent;
const inlineHeader = line.search(INLINE_HEADER);
return inlineHeader >= 0 ? inlineHeader : -1;
}
export function splitQuotedEmail(text: string): EmailTextParts {
const normalized = text.replace(/\r\n?/g, "\n").trim();
if (!normalized) return { latest: "", quoted: "" };
const lines = normalized.split("\n");
for (let index = 0; index < lines.length; index += 1) {
const offset = quoteOffset(lines[index]);
if (offset < 0) continue;
const latestLines = [...lines.slice(0, index), lines[index].slice(0, offset)];
const quotedLines = [lines[index].slice(offset), ...lines.slice(index + 1)];
return {
latest: latestLines.join("\n").trim(),
quoted: quotedLines.join("\n").trim(),
};
}
return { latest: normalized, quoted: "" };
}
export function foldQuotedHtml(html: string): string {
const quoteStart = html.toLowerCase().indexOf("<blockquote>");
if (quoteStart < 0) return html;
const latest = html.slice(0, quoteStart);
const quoted = html.slice(quoteStart);
return `${latest}<details class="email-quoted"><summary>Показать предыдущие сообщения</summary>${quoted}</details>`;
}
@@ -0,0 +1,146 @@
import { useEffect, useMemo, useState } from "react";
import { fetchHelpArticle, sendArticleFeedback } from "./api";
import { HelpChevronIcon, HelpThumbIcon } from "./HelpIcons";
import { HelpLayout } from "./HelpLayout";
import { MarkdownContent, parseMarkdown } from "./MarkdownContent";
import type { HelpArticle as HelpArticleType, HelpManifest } from "./types";
function formattedDate(value: string, locale: string): string {
return new Intl.DateTimeFormat(locale, {
day: "numeric",
month: "long",
year: "numeric",
}).format(new Date(value));
}
export function HelpArticle({
manifest,
articleSlug,
}: {
manifest: HelpManifest;
articleSlug: string;
}) {
const [article, setArticle] = useState<HelpArticleType | null>(null);
const [failed, setFailed] = useState(false);
const [search, setSearch] = useState("");
const [feedback, setFeedback] = useState<"idle" | "sending" | "sent" | "error">("idle");
useEffect(() => {
fetchHelpArticle(articleSlug, manifest.portal.defaultLocale)
.then((payload) => setArticle(payload.article))
.catch(() => setFailed(true));
}, [articleSlug, manifest.portal.defaultLocale]);
const headings = useMemo(
() => article ? parseMarkdown(article.revision.content ?? "").headings.filter((item) => item.level <= 3) : [],
[article],
);
const homeHref = "/";
function searchSubmit(value: string) {
const suffix = value ? `?q=${encodeURIComponent(value)}` : "";
window.location.href = `${homeHref}${suffix}`;
}
async function vote(helpful: boolean) {
if (!article || feedback === "sending" || feedback === "sent") return;
setFeedback("sending");
try {
await sendArticleFeedback(
article.slug,
helpful,
article.locale,
);
setFeedback("sent");
} catch {
setFeedback("error");
}
}
return (
<HelpLayout
compactHeader
portal={manifest.portal}
search={search}
onSearchChange={setSearch}
onSearchSubmit={searchSubmit}
>
{failed ? (
<section className="help-article-error">
<strong>Статья не найдена</strong>
<a href={homeHref}>Вернуться в базу знаний</a>
</section>
) : !article ? (
<section className="help-article-loading"><i /><i /><i /><i /></section>
) : (
<div className="help-article-shell">
<nav className="help-breadcrumbs" aria-label="Навигация">
<a href={homeHref}>Все разделы</a>
<HelpChevronIcon />
<a href={`${homeHref}?category=${encodeURIComponent(article.category.slug)}`}>
{article.category.name}
</a>
<HelpChevronIcon />
<span>{article.revision.title}</span>
</nav>
<div className="help-article-grid">
<article className="help-article">
<header>
<h1>{article.revision.title}</h1>
{article.revision.summary && <p>{article.revision.summary}</p>}
<time dateTime={article.updatedAt}>
Обновлено {formattedDate(article.updatedAt, article.locale)}
</time>
</header>
<MarkdownContent content={article.revision.content ?? ""} />
<section className="help-feedback">
{feedback === "sent" ? (
<div className="help-feedback-thanks">
<strong>Спасибо за отзыв</strong>
<span>Он поможет сделать базу знаний полезнее.</span>
</div>
) : (
<>
<h2>Эта статья была полезна?</h2>
{feedback === "error" && <p role="alert">Не удалось отправить отзыв. Попробуйте ещё раз.</p>}
<div>
<button
aria-label="Да, статья полезна"
disabled={feedback === "sending"}
type="button"
onClick={() => void vote(true)}
>
<HelpThumbIcon direction="up" />
</button>
<button
aria-label="Нет, статья не помогла"
disabled={feedback === "sending"}
type="button"
onClick={() => void vote(false)}
>
<HelpThumbIcon direction="down" />
</button>
</div>
</>
)}
</section>
</article>
{headings.length > 0 && (
<aside className="help-toc" aria-label="Содержание статьи">
<strong>В этой статье</strong>
<nav>
{headings.map((heading) => (
<a className={heading.level === 3 ? "is-nested" : ""} href={`#${heading.id}`} key={heading.id}>
{heading.title}
</a>
))}
</nav>
</aside>
)}
</div>
</div>
)}
</HelpLayout>
);
}
@@ -0,0 +1,52 @@
import { useEffect, useMemo, useState } from "react";
import { fetchHelpManifest } from "./api";
import { HelpArticle } from "./HelpArticle";
import { HelpHome } from "./HelpHome";
import type { HelpManifest } from "./types";
import "./styles-layout.css";
import "./styles-home.css";
import "./styles-article.css";
import "./styles-responsive.css";
type HelpRoute = {
articleSlug: string | null;
};
function currentRoute(): HelpRoute | null {
const normalized = window.location.pathname.replace(/\/+$/, "");
if (normalized === "") return { articleSlug: null };
const match = normalized.match(/^\/articles\/([^/]+)$/);
return match ? { articleSlug: decodeURIComponent(match[1]) } : null;
}
export function HelpCenterApp() {
const route = useMemo(currentRoute, []);
const [manifest, setManifest] = useState<HelpManifest | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
if (!route) {
setFailed(true);
return;
}
fetchHelpManifest()
.then(setManifest)
.catch(() => setFailed(true));
}, [route]);
if (failed || !route) {
return (
<main className="help-fatal">
<strong>Портал поддержки не найден</strong>
<span>Проверьте адрес или обратитесь к администратору организации.</span>
</main>
);
}
if (!manifest) {
return <main className="help-boot-loading"><i /><i /><i /></main>;
}
return route.articleSlug
? <HelpArticle manifest={manifest} articleSlug={route.articleSlug} />
: <HelpHome manifest={manifest} />;
}
@@ -0,0 +1,148 @@
import { useEffect, useMemo, useState } from "react";
import { fetchHelpArticles } from "./api";
import { HelpArrowIcon, HelpFolderIcon } from "./HelpIcons";
import { HelpLayout } from "./HelpLayout";
import { HelpSearch } from "./HelpSearch";
import type { HelpArticle, HelpManifest } from "./types";
function articleHref(articleSlug: string): string {
return `/articles/${encodeURIComponent(articleSlug)}/`;
}
function articleCount(value: number): string {
const mod100 = value % 100;
const mod10 = value % 10;
const word = mod100 >= 11 && mod100 <= 14
? "статей"
: mod10 === 1 ? "статья" : mod10 >= 2 && mod10 <= 4 ? "статьи" : "статей";
return `${value} ${word}`;
}
export function HelpHome({ manifest }: { manifest: HelpManifest }) {
const params = useMemo(() => new URLSearchParams(window.location.search), []);
const [search, setSearch] = useState(params.get("q") ?? "");
const [category, setCategory] = useState(params.get("category") ?? "");
const [articles, setArticles] = useState<HelpArticle[] | null>(null);
const [hasMore, setHasMore] = useState(false);
const [failed, setFailed] = useState(false);
useEffect(() => {
if (!search.trim() && !category) {
setArticles([]);
setFailed(false);
return;
}
let cancelled = false;
const timer = window.setTimeout(() => {
setFailed(false);
setArticles(null);
fetchHelpArticles({
locale: manifest.portal.defaultLocale,
category,
query: search.trim(),
})
.then((payload) => {
if (cancelled) return;
setArticles(payload.items);
setHasMore(payload.pagination.hasMore);
})
.catch(() => {
if (cancelled) return;
setFailed(true);
setArticles([]);
});
}, search ? 180 : 0);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [category, manifest.portal.defaultLocale, search]);
const filtering = Boolean(search.trim() || category);
const selectedCategory = manifest.categories.find((item) => item.slug === category);
function chooseCategory(slug: string) {
setCategory(slug);
const next = new URL(window.location.href);
if (slug) next.searchParams.set("category", slug);
else next.searchParams.delete("category");
window.history.replaceState({}, "", next);
}
async function loadMore() {
if (!articles) return;
try {
const payload = await fetchHelpArticles({
locale: manifest.portal.defaultLocale,
category,
query: search.trim(),
offset: articles.length,
});
setArticles((current) => [...(current ?? []), ...payload.items]);
setHasMore(payload.pagination.hasMore);
} catch {
setFailed(true);
}
}
return (
<HelpLayout
portal={manifest.portal}
search={search}
onSearchChange={setSearch}
>
<section className="help-hero">
<HelpSearch value={search} onChange={setSearch} />
</section>
<section className="help-home-content">
{filtering ? (
<div className="help-results">
<div className="help-results-heading">
<div>
<span>{selectedCategory ? selectedCategory.name : "Результаты поиска"}</span>
<h1>{search.trim() ? `Поиск: «${search.trim()}»` : selectedCategory?.name}</h1>
</div>
<button type="button" onClick={() => { setSearch(""); chooseCategory(""); }}>
Все разделы
</button>
</div>
{failed ? (
<div className="help-empty"><strong>Не удалось загрузить статьи</strong><span>Попробуйте обновить страницу.</span></div>
) : articles === null ? (
<div className="help-loading-lines"><i /><i /><i /></div>
) : articles.length ? (
<div className="help-article-results">
{articles.map((article) => (
<a href={articleHref(article.slug)} key={`${article.locale}-${article.slug}`}>
<span>{article.category.name}</span>
<strong>{article.revision.title}</strong>
{article.revision.summary && <p>{article.revision.summary}</p>}
<HelpArrowIcon />
</a>
))}
{hasMore && <button className="help-load-more" type="button" onClick={() => void loadMore()}>Показать ещё</button>}
</div>
) : (
<div className="help-empty"><strong>Ничего не найдено</strong><span>Попробуйте изменить запрос или открыть другой раздел.</span></div>
)}
</div>
) : (
<div className="help-category-grid">
{manifest.categories.filter((item) => item.parentId === null).map((item) => (
<button type="button" key={item.id} onClick={() => chooseCategory(item.slug)}>
<span className="help-category-icon"><HelpFolderIcon /></span>
<span className="help-category-copy">
<strong>{item.name}</strong>
{item.description && <p>{item.description}</p>}
<small>{articleCount(item.articleCount)}</small>
</span>
</button>
))}
</div>
)}
</section>
</HelpLayout>
);
}
@@ -0,0 +1,47 @@
type IconProps = { size?: number };
const iconProps = (size: number) => ({
width: size,
height: size,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.8,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
});
export function HelpSearchIcon({ size = 21 }: IconProps) {
return <svg {...iconProps(size)}><circle cx="11" cy="11" r="7.5" /><path d="m20 20-3.7-3.7" /></svg>;
}
export function HelpFolderIcon({ size = 30 }: IconProps) {
return <svg {...iconProps(size)}><path d="M3 19V6.8A1.8 1.8 0 0 1 4.8 5H9l2 2.4h8.2A1.8 1.8 0 0 1 21 9.2V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" /></svg>;
}
export function HelpArrowIcon({ size = 18 }: IconProps) {
return <svg {...iconProps(size)}><path d="M5 12h14M14 7l5 5-5 5" /></svg>;
}
export function HelpChevronIcon({ size = 15 }: IconProps) {
return <svg {...iconProps(size)}><path d="m9 18 6-6-6-6" /></svg>;
}
export function HelpMessageIcon({ size = 23 }: IconProps) {
return <svg {...iconProps(size)}><path d="M20 15a2 2 0 0 1-2 2H8l-5 4V5a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2Z" /></svg>;
}
export function HelpThumbIcon({
direction,
size = 21,
}: IconProps & { direction: "up" | "down" }) {
return (
<svg
{...iconProps(size)}
style={direction === "down" ? { transform: "rotate(180deg)" } : undefined}
>
<path d="M7 10v11H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3Z" />
<path d="M7 10 11 3a2.2 2.2 0 0 1 4 1.8L14.5 8H20a2 2 0 0 1 2 2.4l-1.4 7A4 4 0 0 1 16.7 21H7" />
</svg>
);
}
@@ -0,0 +1,58 @@
import type { ReactNode } from "react";
import { LogoIcon } from "../../shared/icons";
import { HelpSearch } from "./HelpSearch";
import { SupportLauncher } from "./SupportLauncher";
import type { HelpPortal } from "./types";
export function HelpLayout({
portal,
children,
search,
onSearchChange,
onSearchSubmit,
compactHeader = false,
}: {
portal: HelpPortal;
children: ReactNode;
search: string;
onSearchChange: (value: string) => void;
onSearchSubmit?: (value: string) => void;
compactHeader?: boolean;
}) {
const homeHref = "/";
return (
<div className={`help-center ${compactHeader ? "has-compact-header" : ""}`}>
<header className="help-header">
<div className="help-header-row">
<a className="help-brand" href={homeHref}>
<span className="help-brand-mark"><LogoIcon /></span>
<span>{portal.name}</span>
</a>
<div className="help-products">
{portal.products.filter((product) => product.siteUrl).map((product) => (
<a href={product.siteUrl} key={product.code} rel="noreferrer">{product.name}</a>
))}
</div>
</div>
{compactHeader && (
<HelpSearch
compact
value={search}
onChange={onSearchChange}
onSubmit={onSearchSubmit}
/>
)}
</header>
<main>{children}</main>
<footer className="help-footer">
<a className="help-footer-brand" href={homeHref}>
<span className="help-brand-mark"><LogoIcon /></span>
<span>{portal.name}</span>
</a>
<span>База знаний и поддержка</span>
</footer>
<SupportLauncher products={portal.products} />
</div>
);
}
@@ -0,0 +1,41 @@
import { type FormEvent, useState } from "react";
import { HelpSearchIcon } from "./HelpIcons";
export function HelpSearch({
value,
onChange,
onSubmit,
compact = false,
}: {
value: string;
onChange: (value: string) => void;
onSubmit?: (value: string) => void;
compact?: boolean;
}) {
const [focused, setFocused] = useState(false);
function submit(event: FormEvent) {
event.preventDefault();
onSubmit?.(value.trim());
}
return (
<form
className={`help-search ${compact ? "is-compact" : ""} ${focused ? "is-focused" : ""}`}
role="search"
onSubmit={submit}
>
<HelpSearchIcon />
<input
aria-label="Поиск по базе знаний"
placeholder="Поиск по статьям..."
type="search"
value={value}
onBlur={() => setFocused(false)}
onChange={(event) => onChange(event.target.value)}
onFocus={() => setFocused(true)}
/>
</form>
);
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { MarkdownContent, parseMarkdown } from "./MarkdownContent";
describe("parseMarkdown", () => {
it("creates stable unique ids for repeated headings", () => {
const result = parseMarkdown("## Установка\n\nТекст\n\n## Установка");
expect(result.headings.map((heading) => heading.id)).toEqual([
"установка",
"установка-2",
]);
});
it("keeps code fences out of paragraphs", () => {
const result = parseMarkdown("До\n\n```sh\necho ok\n```\n\nПосле");
expect(result.blocks.map((block) => block.kind)).toEqual([
"paragraph",
"code",
"paragraph",
]);
});
it("renders GFM tables, quotes and task lists", () => {
const html = renderToStaticMarkup(
<MarkdownContent content={"> Важно\n\n| Поле | Значение |\n| --- | --- |\n| A | B |\n\n- [x] Готово"} />,
);
expect(html).toContain("<blockquote>");
expect(html).toContain("<table>");
expect(html).toContain('type="checkbox"');
});
});
@@ -0,0 +1,127 @@
import { Children, isValidElement, type JSX, type ReactNode } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
export type MarkdownHeading = {
id: string;
level: number;
title: string;
};
type MarkdownBlock =
| { kind: "heading"; level: number; text: string; id: string }
| { kind: "paragraph"; text: string }
| { kind: "list"; ordered: boolean; items: string[] }
| { kind: "code"; text: string };
function headingId(text: string, index: number): string {
const normalized = text
.toLocaleLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, "");
return normalized || `section-${index + 1}`;
}
function plainHeadingText(text: string): string {
return text
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[*_~`]/g, "")
.trim();
}
function uniqueHeadingId(
text: string,
index: number,
occurrences: Map<string, number>,
): string {
const baseId = headingId(text, index);
const occurrence = (occurrences.get(baseId) ?? 0) + 1;
occurrences.set(baseId, occurrence);
return occurrence === 1 ? baseId : `${baseId}-${occurrence}`;
}
export function parseMarkdown(content: string): {
blocks: MarkdownBlock[];
headings: MarkdownHeading[];
} {
const lines = content.replace(/\r\n/g, "\n").split("\n");
const blocks: MarkdownBlock[] = [];
const headings: MarkdownHeading[] = [];
const headingIds = new Map<string, number>();
let index = 0;
let inCodeFence = false;
let code: string[] = [];
while (index < lines.length) {
const sourceLine = lines[index];
const line = sourceLine.trim();
if (line.startsWith("```") || line.startsWith("~~~")) {
if (inCodeFence) {
blocks.push({ kind: "code", text: code.join("\n") });
code = [];
}
inCodeFence = !inCodeFence;
index += 1;
continue;
}
if (inCodeFence) {
code.push(sourceLine);
index += 1;
continue;
}
const heading = line.match(/^(#{1,6})\s+(.+?)\s*#*$/);
if (heading) {
const level = Math.min(6, Math.max(2, heading[1].length));
const title = plainHeadingText(heading[2]);
const id = uniqueHeadingId(title, headings.length, headingIds);
blocks.push({ kind: "heading", level, text: title, id });
headings.push({ id, level, title });
} else if (line) {
blocks.push({ kind: "paragraph", text: line });
}
index += 1;
}
if (code.length) blocks.push({ kind: "code", text: code.join("\n") });
return { blocks, headings };
}
function nodeText(node: ReactNode): string {
if (typeof node === "string" || typeof node === "number") return String(node);
if (Array.isArray(node)) return node.map(nodeText).join("");
if (isValidElement<{ children?: ReactNode }>(node)) {
return nodeText(node.props.children);
}
return "";
}
export function MarkdownContent({ content }: { content: string }) {
const headingIds = new Map<string, number>();
let headingIndex = 0;
const heading = (level: number) => {
const Heading = ({ children }: { children?: ReactNode }) => {
const id = uniqueHeadingId(nodeText(Children.toArray(children)), headingIndex, headingIds);
headingIndex += 1;
const Tag = `h${Math.min(6, Math.max(2, level))}` as keyof JSX.IntrinsicElements;
return <Tag id={id}>{children}</Tag>;
};
return Heading;
};
const components: Components = {
h1: heading(2),
h2: heading(2),
h3: heading(3),
h4: heading(4),
h5: heading(5),
h6: heading(6),
a: ({ children, href }) => <a className="link" href={href}>{children}</a>,
};
return (
<div className="help-markdown">
<ReactMarkdown components={components} remarkPlugins={[remarkGfm]}>
{content}
</ReactMarkdown>
</div>
);
}
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { supportHref } from "./SupportLauncher";
describe("supportHref", () => {
it("passes the selected support channel to the product", () => {
expect(supportHref({
code: "foxray",
name: "Foxray",
siteUrl: "https://foxray.example/account?from=help",
supportAvailable: true,
supportChannelCode: "foxray-support",
})).toBe("https://foxray.example/account?from=help&custocrmSupportChannel=foxray-support");
});
it("does not build an unauthenticated fallback without a channel", () => {
expect(supportHref({
code: "foxray",
name: "Foxray",
siteUrl: "https://foxray.example",
supportAvailable: false,
supportChannelCode: null,
})).toBeNull();
});
});
@@ -0,0 +1,53 @@
import { useState } from "react";
import { HelpMessageIcon } from "./HelpIcons";
import type { HelpProduct } from "./types";
export function supportHref(product: HelpProduct): string | null {
if (!product.siteUrl || !product.supportChannelCode) return null;
try {
const url = new URL(product.siteUrl);
url.searchParams.set("custocrmSupportChannel", product.supportChannelCode);
return url.toString();
} catch {
return null;
}
}
export function SupportLauncher({ products }: { products: HelpProduct[] }) {
const [open, setOpen] = useState(false);
const available = products
.map((product) => ({ product, href: supportHref(product) }))
.filter((item): item is { product: HelpProduct; href: string } => (
item.product.supportAvailable && Boolean(item.href)
));
if (!available.length) return null;
return (
<div className={`help-support-launcher ${open ? "is-open" : ""}`}>
{open && (
<div className="help-support-panel" role="dialog" aria-label="Связаться с поддержкой">
<strong>Связаться с поддержкой</strong>
<p>Откройте продукт и напишите нам из своего аккаунта.</p>
<div className="help-support-products">
{available.map(({ product, href }) => (
<a href={href} key={product.code} rel="noreferrer">
<span>{product.name}</span>
<small>Открыть продукт</small>
</a>
))}
</div>
</div>
)}
<button
aria-expanded={open}
aria-label={open ? "Закрыть меню поддержки" : "Связаться с поддержкой"}
className="help-support-button"
type="button"
onClick={() => setOpen((current) => !current)}
>
<HelpMessageIcon />
</button>
</div>
);
}
@@ -0,0 +1,40 @@
import { api } from "../../api/client";
import type { HelpArticle, HelpManifest } from "./types";
export function fetchHelpManifest(): Promise<HelpManifest> {
return api<HelpManifest>("/api/v1/help/");
}
export function fetchHelpArticles(
options: { locale?: string; category?: string; query?: string; offset?: number } = {},
): Promise<{ items: HelpArticle[]; pagination: { hasMore: boolean; limit: number; offset: number; total: number } }> {
const search = new URLSearchParams();
if (options.locale) search.set("locale", options.locale);
if (options.category) search.set("category", options.category);
if (options.query) search.set("q", options.query);
if (options.offset) search.set("offset", String(options.offset));
const suffix = search.size ? `?${search.toString()}` : "";
return api(`/api/v1/help/articles/${suffix}`);
}
export function fetchHelpArticle(
articleSlug: string,
locale?: string,
): Promise<{ article: HelpArticle }> {
const suffix = locale ? `?locale=${encodeURIComponent(locale)}` : "";
return api<{ article: HelpArticle }>(
`/api/v1/help/articles/${encodeURIComponent(articleSlug)}/${suffix}`,
);
}
export function sendArticleFeedback(
articleSlug: string,
helpful: boolean,
locale?: string,
): Promise<{ ok: boolean }> {
const suffix = locale ? `?locale=${encodeURIComponent(locale)}` : "";
return api<{ ok: boolean }>(
`/api/v1/help/articles/${encodeURIComponent(articleSlug)}/feedback/${suffix}`,
{ method: "POST", body: JSON.stringify({ helpful }) },
);
}
@@ -0,0 +1,310 @@
.help-article-shell {
width: min(1024px, calc(100% - 48px));
margin: 42px auto 0;
}
.help-breadcrumbs {
display: flex;
min-width: 0;
align-items: center;
gap: 7px;
overflow: hidden;
color: #666;
font-size: 14px;
white-space: nowrap;
}
.help-breadcrumbs a {
color: #555;
text-decoration: none;
}
.help-breadcrumbs a:hover {
text-decoration: underline;
text-underline-offset: 4px;
}
.help-breadcrumbs span {
overflow: hidden;
text-overflow: ellipsis;
}
.help-article-grid {
display: grid;
margin-top: 25px;
grid-template-columns: minmax(0, 720px) minmax(190px, 240px);
gap: 64px;
align-items: start;
}
.help-article > header h1 {
margin: 0;
font-size: clamp(38px, 4.2vw, 54px);
font-weight: 650;
letter-spacing: -0.055em;
line-height: 1.08;
}
.help-article > header > p {
margin: 16px 0 0;
color: var(--help-muted);
font-size: 18px;
line-height: 1.5;
}
.help-article > header time {
display: block;
margin-top: 14px;
color: #929292;
font-size: 14px;
}
.help-markdown {
margin-top: 42px;
font-size: 17px;
line-height: 1.7;
}
.help-markdown h2,
.help-markdown h3,
.help-markdown h4,
.help-markdown h5,
.help-markdown h6 {
scroll-margin-top: 28px;
color: var(--help-ink);
font-weight: 650;
letter-spacing: -0.035em;
line-height: 1.25;
}
.help-markdown h2 {
margin: 48px 0 14px;
font-size: 27px;
}
.help-markdown h3 {
margin: 38px 0 12px;
font-size: 22px;
}
.help-markdown h4 {
margin: 30px 0 10px;
font-size: 18px;
}
.help-markdown h5,
.help-markdown h6 {
margin: 26px 0 10px;
font-size: 16px;
}
.help-markdown p {
margin: 0 0 22px;
}
.help-markdown ul,
.help-markdown ol {
margin: 0 0 24px;
padding-left: 25px;
}
.help-markdown li {
margin: 8px 0;
padding-left: 4px;
}
.help-markdown a {
color: var(--help-ink);
font-weight: 550;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
.help-markdown code {
padding: 2px 5px;
border-radius: 5px;
background: #f2f2f2;
font-family: var(--font-mono);
font-size: 0.9em;
}
.help-markdown pre {
margin: 26px 0;
padding: 20px;
overflow: auto;
border-radius: 12px;
background: #191919;
color: #f7f7f7;
}
.help-markdown pre code {
padding: 0;
background: transparent;
}
.help-markdown blockquote {
margin: 26px 0;
padding: 2px 0 2px 20px;
border-left: 3px solid #d8d8d8;
color: #626262;
}
.help-markdown blockquote p:last-child {
margin-bottom: 0;
}
.help-markdown table {
width: 100%;
margin: 26px 0;
border-collapse: collapse;
font-size: 15px;
}
.help-markdown th,
.help-markdown td {
padding: 10px 12px;
border: 1px solid #dedede;
text-align: left;
vertical-align: top;
}
.help-markdown th {
background: #f7f7f7;
font-weight: 650;
}
.help-markdown img {
display: block;
max-width: 100%;
height: auto;
margin: 26px 0;
border-radius: 12px;
}
.help-markdown hr {
margin: 38px 0;
border: 0;
border-top: 1px solid #dedede;
}
.help-markdown input[type="checkbox"] {
margin-right: 8px;
}
.help-toc {
position: sticky;
top: 26px;
padding-top: 4px;
}
.help-toc > strong {
display: block;
margin-bottom: 14px;
font-size: 14px;
font-weight: 650;
}
.help-toc nav {
display: grid;
gap: 9px;
}
.help-toc a {
color: #626262;
font-size: 13px;
line-height: 1.35;
text-decoration: none;
}
.help-toc a:hover {
color: #111;
}
.help-toc a.is-nested {
padding-left: 12px;
}
.help-feedback {
display: grid;
min-height: 190px;
margin-top: 68px;
place-content: center;
justify-items: center;
border-top: 1px solid #dedede;
border-bottom: 1px solid #dedede;
}
.help-feedback h2 {
margin: 0 0 18px;
font-size: 20px;
letter-spacing: -0.02em;
}
.help-feedback > div:not(.help-feedback-thanks) {
display: flex;
gap: 10px;
}
.help-feedback button {
display: grid;
width: 48px;
height: 48px;
place-items: center;
border: 1px solid #d7d7d7;
border-radius: 50%;
background: #fff;
color: #333;
}
.help-feedback button:hover {
border-color: #222;
background: #f4f4f4;
}
.help-feedback-thanks {
display: grid;
justify-items: center;
gap: 7px;
}
.help-feedback-thanks strong {
font-size: 20px;
}
.help-feedback-thanks span {
color: var(--help-muted);
}
.help-article-loading,
.help-article-error {
display: grid;
width: min(720px, calc(100% - 48px));
min-height: 420px;
margin: 40px auto 0;
align-content: center;
gap: 15px;
}
.help-article-loading i {
height: 26px;
border-radius: 6px;
background: #f0f0f0;
}
.help-article-loading i:first-child {
width: 78%;
height: 58px;
}
.help-article-error {
justify-items: center;
}
.help-article-error strong {
font-size: 26px;
}
.help-article-error a {
color: #333;
}
@@ -0,0 +1,204 @@
.help-hero {
width: min(1024px, calc(100% - 48px));
margin: 45px auto 0;
}
.help-home-content {
width: min(1024px, calc(100% - 48px));
margin: 62px auto 0;
}
.help-category-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
column-gap: 48px;
row-gap: 44px;
}
.help-category-grid > button {
display: flex;
min-height: 144px;
padding: 16px;
align-items: flex-start;
gap: 16px;
border: 0;
border-radius: 12px;
background: #fff;
color: var(--help-ink);
text-align: left;
transition: background 150ms ease, transform 150ms ease;
}
.help-category-grid > button:hover {
background: #f6f6f6;
transform: translateY(-1px);
}
.help-category-icon {
display: grid;
width: 64px;
height: 64px;
flex: 0 0 64px;
place-items: center;
border-radius: 7px;
background: #171717;
color: #fff;
}
.help-category-copy {
display: block;
min-width: 0;
}
.help-category-copy strong {
display: block;
margin: 0 0 5px;
font-size: 19px;
font-weight: 650;
letter-spacing: -0.025em;
line-height: 1.3;
}
.help-category-copy p {
display: -webkit-box;
margin: 0 0 8px;
overflow: hidden;
color: var(--help-muted);
font-size: 14px;
line-height: 1.4;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.help-category-copy small {
color: #8a8a8a;
font-size: 12px;
}
.help-results-heading {
display: flex;
margin-bottom: 30px;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
}
.help-results-heading span {
display: block;
margin-bottom: 7px;
color: var(--help-muted);
font-size: 14px;
}
.help-results-heading h1 {
margin: 0;
font-size: clamp(28px, 3.4vw, 42px);
font-weight: 650;
letter-spacing: -0.045em;
}
.help-results-heading button {
padding: 8px 0;
border: 0;
border-bottom: 1px solid #929292;
background: transparent;
color: var(--help-ink);
font-size: 14px;
}
.help-article-results {
border-top: 1px solid #dcdcdc;
}
.help-article-results > a {
position: relative;
display: block;
min-height: 128px;
padding: 24px 52px 24px 4px;
border-bottom: 1px solid #e5e5e5;
color: var(--help-ink);
text-decoration: none;
transition: padding-left 150ms ease;
}
.help-article-results > a:hover {
padding-left: 12px;
}
.help-article-results > a > span {
display: block;
margin-bottom: 6px;
color: var(--help-muted);
font-size: 13px;
}
.help-article-results > a > strong {
display: block;
font-size: 20px;
letter-spacing: -0.02em;
}
.help-article-results > a > p {
margin: 7px 0 0;
color: var(--help-muted);
line-height: 1.45;
}
.help-article-results > a > svg {
position: absolute;
top: 50%;
right: 8px;
transform: translateY(-50%);
}
.help-load-more {
display: block;
margin: 24px auto 0;
padding: 9px 18px;
border: 1px solid #c9c9c9;
border-radius: 999px;
color: var(--help-ink);
background: #fff;
font: inherit;
cursor: pointer;
}
.help-load-more:hover {
border-color: var(--help-ink);
}
.help-empty {
display: grid;
min-height: 220px;
place-content: center;
justify-items: center;
gap: 7px;
border-top: 1px solid #dedede;
border-bottom: 1px solid #dedede;
}
.help-empty strong {
font-size: 20px;
}
.help-empty span {
color: var(--help-muted);
}
.help-loading-lines {
display: grid;
gap: 18px;
padding: 24px 0;
}
.help-loading-lines i {
height: 74px;
border-radius: 8px;
background: linear-gradient(90deg, #f1f1f1, #f8f8f8, #f1f1f1);
background-size: 200% 100%;
animation: helpShimmer 1.2s linear infinite;
}
@keyframes helpShimmer {
to { background-position: -200% 0; }
}
@@ -0,0 +1,299 @@
html.help-center-document,
html.help-center-document body,
html.help-center-document #root {
min-width: 0;
min-height: 100%;
background: #fff;
}
html.help-center-document {
scroll-behavior: smooth;
}
html.help-center-document body {
color: #282828;
font-family: Inter, "Segoe UI", Helvetica, Arial, sans-serif;
}
.help-center {
--help-ink: #282828;
--help-muted: #6f6f6f;
--help-soft: #f2f2f2;
--help-line: #d8d8d8;
display: flex;
min-height: 100vh;
flex-direction: column;
background: #fff;
color: var(--help-ink);
}
.help-header {
width: min(1024px, calc(100% - 48px));
margin: 0 auto;
padding: 34px 0 0;
}
.help-header-row {
display: flex;
min-height: 40px;
align-items: center;
justify-content: space-between;
gap: 32px;
}
.help-brand,
.help-footer-brand {
display: inline-flex;
align-items: center;
gap: 12px;
color: var(--help-ink);
font-size: 22px;
font-weight: 650;
letter-spacing: -0.03em;
text-decoration: none;
}
.help-brand-mark {
display: inline-grid;
width: 31px;
height: 31px;
place-items: center;
border-radius: 50%;
background: #171717;
color: #fff;
}
.help-brand-mark svg {
width: 18px;
height: 18px;
}
.help-products {
display: flex;
align-items: center;
gap: 22px;
}
.help-products a {
color: #4b4b4b;
font-size: 14px;
text-underline-offset: 4px;
}
.help-center main {
flex: 1;
}
.help-search {
position: relative;
display: flex;
height: 65px;
align-items: center;
border-bottom: 1.5px solid #222;
transition: border-color 160ms ease;
}
.help-search > svg {
position: absolute;
left: 16px;
color: #3a3a3a;
pointer-events: none;
}
.help-search input {
width: 100%;
height: 100%;
padding: 16px 48px;
border: 0;
border-radius: 0;
outline: 0;
background: transparent;
color: var(--help-ink);
font-size: 18px;
}
.help-search input::placeholder {
color: #4f4f4f;
opacity: 1;
}
.help-search.is-focused {
border-color: #8a8a8a;
}
.help-search input::-webkit-search-cancel-button {
opacity: 0.55;
}
.help-header .help-search.is-compact {
width: min(420px, 46vw);
height: 44px;
border: 1px solid var(--help-line);
border-radius: 999px;
background: #f8f8f8;
}
.help-search.is-compact > svg {
left: 15px;
width: 18px;
}
.help-search.is-compact input {
padding: 10px 42px;
font-size: 15px;
}
.help-center.has-compact-header .help-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 28px;
border-bottom: 1px solid #ececec;
}
.help-center.has-compact-header .help-header-row {
min-height: 44px;
}
.help-footer {
display: flex;
width: min(1024px, calc(100% - 48px));
margin: 80px auto 0;
padding: 34px 0 42px;
align-items: center;
justify-content: space-between;
border-top: 1px solid #ececec;
color: var(--help-muted);
font-size: 14px;
}
.help-footer-brand {
gap: 9px;
font-size: 16px;
letter-spacing: -0.01em;
}
.help-footer-brand .help-brand-mark {
width: 25px;
height: 25px;
}
.help-footer-brand svg {
width: 14px;
height: 14px;
}
.help-support-launcher {
position: fixed;
right: 26px;
bottom: 24px;
z-index: 20;
}
.help-support-button {
display: grid;
width: 54px;
height: 54px;
margin-left: auto;
place-items: center;
border: 0;
border-radius: 18px;
background: #171717;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.2);
color: #fff;
transition: transform 160ms ease, background 160ms ease;
}
.help-support-button:hover {
transform: translateY(-2px);
background: #000;
}
.help-support-panel {
width: 310px;
margin-bottom: 12px;
padding: 22px;
border: 1px solid #e4e4e4;
border-radius: 16px;
background: #fff;
box-shadow: 0 16px 50px rgba(0, 0, 0, 0.15);
}
.help-support-panel > strong {
display: block;
font-size: 18px;
}
.help-support-panel > p {
margin: 8px 0 18px;
color: var(--help-muted);
font-size: 14px;
line-height: 1.45;
}
.help-support-products {
display: grid;
gap: 8px;
}
.help-support-products a {
display: flex;
padding: 12px 13px;
align-items: center;
justify-content: space-between;
border-radius: 10px;
background: #f3f3f3;
color: var(--help-ink);
text-decoration: none;
}
.help-support-products a:hover {
background: #e9e9e9;
}
.help-support-products span {
font-weight: 600;
}
.help-support-products small {
color: var(--help-muted);
}
.help-fatal,
.help-boot-loading {
display: grid;
min-height: 100vh;
place-content: center;
justify-items: center;
gap: 10px;
background: #fff;
color: #282828;
}
.help-fatal strong {
font-size: 24px;
}
.help-fatal span {
color: #6f6f6f;
}
.help-boot-loading {
grid-template-columns: repeat(3, 8px);
}
.help-boot-loading i {
width: 8px;
height: 8px;
border-radius: 50%;
background: #282828;
animation: helpPulse 900ms ease-in-out infinite alternate;
}
.help-boot-loading i:nth-child(2) { animation-delay: 150ms; }
.help-boot-loading i:nth-child(3) { animation-delay: 300ms; }
@keyframes helpPulse {
from { opacity: 0.2; transform: translateY(2px); }
to { opacity: 1; transform: translateY(-2px); }
}
@@ -0,0 +1,132 @@
@media (max-width: 860px) {
.help-header,
.help-hero,
.help-home-content,
.help-article-shell,
.help-footer {
width: min(100% - 36px, 680px);
}
.help-category-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px;
}
.help-article-grid {
display: block;
}
.help-toc {
display: none;
}
.help-center.has-compact-header .help-header {
display: block;
}
.help-header .help-search.is-compact {
width: 100%;
margin-top: 22px;
}
}
@media (max-width: 560px) {
.help-header,
.help-hero,
.help-home-content,
.help-article-shell,
.help-footer {
width: calc(100% - 28px);
}
.help-header {
padding-top: 22px;
}
.help-brand {
font-size: 18px;
}
.help-products {
display: none;
}
.help-hero {
margin-top: 30px;
}
.help-search {
height: 58px;
}
.help-search input {
font-size: 16px;
}
.help-home-content {
margin-top: 38px;
}
.help-category-grid {
display: block;
}
.help-category-grid > button {
width: 100%;
min-height: 126px;
padding: 14px 4px;
border-radius: 0;
border-bottom: 1px solid #ececec;
}
.help-category-icon {
width: 56px;
height: 56px;
flex-basis: 56px;
}
.help-results-heading {
display: block;
}
.help-results-heading button {
margin-top: 18px;
}
.help-breadcrumbs {
font-size: 12px;
}
.help-article-grid {
margin-top: 20px;
}
.help-article > header h1 {
font-size: 36px;
}
.help-article > header > p {
font-size: 16px;
}
.help-markdown {
margin-top: 34px;
font-size: 16px;
}
.help-footer {
display: grid;
justify-content: start;
gap: 12px;
margin-top: 54px;
}
.help-support-launcher {
right: 15px;
bottom: 15px;
}
.help-support-panel {
width: min(310px, calc(100vw - 30px));
}
}
@@ -0,0 +1,47 @@
export type HelpProduct = {
code: string;
name: string;
siteUrl: string;
supportAvailable: boolean;
supportChannelCode: string | null;
};
export type HelpCategory = {
id: number;
slug: string;
name: string;
description: string;
parentId: number | null;
sortOrder: number;
articleCount: number;
};
export type HelpRevision = {
id: number;
revision: number;
title: string;
summary: string;
content?: string;
createdAt: string;
publishedAt: string | null;
};
export type HelpArticle = {
slug: string;
locale: string;
category: HelpCategory;
revision: HelpRevision;
updatedAt: string;
};
export type HelpPortal = {
slug: string;
name: string;
defaultLocale: string;
products: HelpProduct[];
};
export type HelpManifest = {
portal: HelpPortal;
categories: HelpCategory[];
};
@@ -7,7 +7,7 @@ import { requestContact, type ApiConversation } from "../../../conversations/mod
import type { ConversationListItem } from "../../../conversations/types";
const LIFECYCLE_LABEL: Record<string, string> = { OPEN: "Открыт", CLOSED: "Закрыт", SPAM: "Спам" };
const CONTROL_LABEL: Record<string, string> = { AI: "AI ведёт", HUMAN: "Оператор ведёт", PAUSED: "Пауза" };
const CONTROL_LABEL: Record<string, string> = { AI: "AI ведёт", HUMAN: "Человек", PAUSED: "Пауза" };
function fmt(value?: string): string {
if (!value) return "—";
@@ -6,3 +6,26 @@
background: #ffffff;
border: 0;
}
.sales-email-plain > div,
.sales-email-quoted > div {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.sales-email-quoted {
margin-top: 10px;
color: #595959;
}
.sales-email-quoted > summary {
color: #8c8c8c;
cursor: pointer;
user-select: none;
}
.sales-email-quoted > div {
margin-top: 8px;
padding-left: 12px;
border-left: 3px solid #d9d9d9;
}
@@ -0,0 +1,166 @@
import { useEffect, useMemo, useState } from "react";
import { ContentEditorBreadcrumb } from "../../shared/content-library/ContentEditorBreadcrumb";
import { ContentEditorCard } from "../../shared/content-library/ContentEditorCard";
import { FormField, SelectField, TextAreaField } from "../../shared/form-controls";
import { Button } from "../../shared/ui-controls";
import { formatDate } from "../../shared/utils";
import {
addArticleRevision,
createPortalArticle,
loadPortalArticle,
portalErrorMessage,
updatePortalArticle,
type PortalArticle,
type PortalCategory,
} from "./model";
export function PortalArticleEditor({
article,
categories,
defaultLocale,
onClose,
onPublish,
onSaved,
portalId,
}: {
article: PortalArticle | null;
categories: PortalCategory[];
defaultLocale: string;
onClose: () => void;
onPublish: (article: PortalArticle, revisionId: number) => void;
onSaved: () => Promise<void>;
portalId: number;
}) {
const [loaded, setLoaded] = useState<PortalArticle | null>(article);
const [categoryId, setCategoryId] = useState(String(article?.category.id ?? categories[0]?.id ?? ""));
const [locale, setLocale] = useState(article?.locale ?? defaultLocale);
const [slug, setSlug] = useState(article?.slug ?? "");
const [selectedRevisionId, setSelectedRevisionId] = useState<number | null>(null);
const [title, setTitle] = useState("");
const [summary, setSummary] = useState("");
const [content, setContent] = useState("");
const [busy, setBusy] = useState(Boolean(article));
const [error, setError] = useState("");
useEffect(() => {
if (!article) return;
setBusy(true);
loadPortalArticle(portalId, article.id)
.then(({ article: detail }) => {
setLoaded(detail);
const revision = detail.revisions?.[0] ?? detail.publishedRevision;
setSelectedRevisionId(revision?.id ?? null);
setTitle(revision?.title ?? "");
setSummary(revision?.summary ?? "");
setContent(revision?.content ?? "");
})
.catch((caught) => setError(portalErrorMessage(caught, "Не удалось загрузить статью")))
.finally(() => setBusy(false));
}, [article, portalId]);
const revisions = useMemo(() => loaded?.revisions ?? [], [loaded]);
function selectRevision(value: string) {
const revision = revisions.find((item) => item.id === Number(value));
if (!revision) return;
setSelectedRevisionId(revision.id);
setTitle(revision.title);
setSummary(revision.summary);
setContent(revision.content);
}
async function save() {
setBusy(true);
setError("");
try {
if (article) {
await updatePortalArticle(portalId, article.id, {
categoryId: Number(categoryId),
locale,
slug,
});
const payload = await addArticleRevision(portalId, article.id, { title, summary, content });
const detail = await loadPortalArticle(portalId, article.id);
setLoaded(detail.article);
setSelectedRevisionId(payload.revision.id);
} else {
await createPortalArticle(portalId, {
categoryId: Number(categoryId),
slug,
locale,
title,
summary,
content,
});
}
await onSaved();
if (!article) onClose();
} catch (caught) {
setError(portalErrorMessage(caught, "Не удалось сохранить статью"));
} finally {
setBusy(false);
}
}
const ready = Boolean(title.trim() && content.trim() && (article || (categoryId && slug.trim())));
const categoryName = categories.find((item) => item.id === Number(categoryId))?.name;
const active = loaded?.status === "PUBLISHED";
return (
<div className="knowledge-editor-page">
<ContentEditorBreadcrumb
backLabel="Материалы"
category={categoryName}
title={article?.latestRevision?.title ?? "Создание статьи"}
onBack={onClose}
/>
<div className="knowledge-editor-grid create">
<div className="knowledge-editor-main">
<ContentEditorCard
heading={<h3>{article ? "Статья" : "Новая статья"}</h3>}
meta={<><span className={`knowledge-card-status${active ? "" : " disabled"}`}><i />{active ? "Опубликована" : "Черновик"}</span>{loaded && <span className="knowledge-card-updated">обновлено {formatDate(loaded.updatedAt)}</span>}</>}
error={error}
actions={<>
<Button variant="primary" icon={article ? "save" : "plus"} disabled={busy || !ready} onClick={() => void save()}>
{article ? "Сохранить новую версию" : "Создать статью"}
</Button>
{article && selectedRevisionId && (
<Button variant="secondary" disabled={busy} onClick={() => onPublish(article, selectedRevisionId)}>
Опубликовать версию
</Button>
)}
</>}
>
<div className="knowledge-editor-fields">
<div className="knowledge-editor-scope-row">
<SelectField label="Раздел" value={categoryId} onChange={setCategoryId} options={categories.map((category) => [String(category.id), category.name])} />
<SelectField label="Язык" value={locale} onChange={setLocale} options={[["ru", "Русский"], ["en", "English"]]} />
</div>
<div className="knowledge-editor-scope-row">
<FormField label="Адрес статьи" value={slug} onChange={setSlug} mono wide />
{article && revisions.length > 0 && (
<SelectField
label="Версия"
value={String(selectedRevisionId ?? "")}
onChange={selectRevision}
options={revisions.map((revision) => [
String(revision.id),
`Версия ${revision.revision}${revision.id === article.publishedRevision?.id ? " · опубликована" : ""}`,
])}
/>
)}
</div>
<FormField label="Заголовок" value={title} onChange={setTitle} wide />
<TextAreaField label="Краткое описание" value={summary} onChange={setSummary} />
<label className="knowledge-content-field">
<span>Содержимое (Markdown)</span>
<textarea value={content} rows={10} onChange={(event) => setContent(event.target.value)} />
</label>
</div>
</ContentEditorCard>
</div>
</div>
</div>
);
}
@@ -0,0 +1,41 @@
import { CategoryManagementDialog } from "../../shared/content-library/CategoryManagementDialog";
import {
createPortalCategory,
deletePortalCategory,
updatePortalCategory,
type PortalCategory,
} from "./model";
export function PortalCategoryManagement({
categories,
onChanged,
onClose,
portalId,
}: {
categories: PortalCategory[];
onChanged: () => Promise<void>;
onClose: () => void;
portalId: number;
}) {
return (
<CategoryManagementDialog
categories={categories.map((item) => ({
id: item.id,
name: item.name,
parentId: item.parentId,
sortOrder: item.sortOrder,
count: item.articleCount,
}))}
createLabel="Создать раздел"
deleteDescription="Раздел можно удалить только после переноса вложенных разделов и статей."
errorMessage="Не удалось изменить разделы"
subtitle="Раздел определяет расположение статей в публичном портале"
title="Управление разделами"
onChanged={onChanged}
onClose={onClose}
onCreate={(input) => createPortalCategory(portalId, input)}
onDelete={(categoryId) => deletePortalCategory(portalId, categoryId)}
onUpdate={(categoryId, input) => updatePortalCategory(portalId, categoryId, input)}
/>
);
}
@@ -0,0 +1,197 @@
import { Dropdown } from "antd";
import { useMemo, useState } from "react";
import { CategoryTree } from "../../shared/content-library/CategoryTree";
import { ContentLibraryTable } from "../../shared/content-library/ContentLibraryTable";
import { ContentLibraryToolbar } from "../../shared/content-library/ContentLibraryToolbar";
import { DecisionDialog } from "../../shared/DecisionDialog";
import { Icon } from "../../shared/icons";
import { StatusPill } from "../../shared/ui";
import { Button } from "../../shared/ui-controls";
import { formatDate } from "../../shared/utils";
import { PortalArticleEditor } from "./PortalArticleEditor";
import { PortalCategoryManagement } from "./PortalCategoryManagement";
import {
archivePortalArticle,
portalErrorMessage,
publishArticleRevision,
type PortalArticle,
type PortalCategory,
} from "./model";
type Decision = {
article: PortalArticle;
revisionId?: number;
type: "archive" | "publish";
};
export function PortalContent({
articles,
canManage,
categories,
locale,
portalId,
reload,
}: {
articles: PortalArticle[];
canManage: boolean;
categories: PortalCategory[];
locale: string;
portalId: number;
reload: () => Promise<void>;
}) {
const [selectedCategory, setSelectedCategory] = useState<number | undefined>();
const [query, setQuery] = useState("");
const [language, setLanguage] = useState("");
const [status, setStatus] = useState("");
const [editing, setEditing] = useState<PortalArticle | null | undefined>(undefined);
const [managingCategories, setManagingCategories] = useState(false);
const [decision, setDecision] = useState<Decision | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const filtered = useMemo(() => {
const childIds = new Set<number>();
if (selectedCategory !== undefined) {
const pending = [selectedCategory];
while (pending.length) {
const id = pending.pop()!;
childIds.add(id);
categories.filter((item) => item.parentId === id).forEach((item) => pending.push(item.id));
}
}
const normalized = query.trim().toLocaleLowerCase();
return articles.filter((article) => (
(selectedCategory === undefined || childIds.has(article.category.id))
&& (!language || article.locale === language)
&& (!status || article.status === status)
&& (!normalized || [
article.slug,
article.latestRevision?.title,
article.latestRevision?.summary,
].some((value) => value?.toLocaleLowerCase().includes(normalized)))
));
}, [articles, categories, language, query, selectedCategory, status]);
async function applyDecision() {
if (!decision) return;
setBusy(true);
setError("");
try {
if (decision.type === "archive") {
await archivePortalArticle(portalId, decision.article.id);
} else if (decision.revisionId) {
await publishArticleRevision(portalId, decision.article.id, decision.revisionId);
}
setDecision(null);
setEditing(undefined);
await reload();
} catch (caught) {
setError(portalErrorMessage(caught, "Не удалось изменить статью"));
setDecision(null);
} finally {
setBusy(false);
}
}
if (editing !== undefined) {
return (
<PortalArticleEditor
article={editing}
categories={categories}
defaultLocale={locale}
portalId={portalId}
onClose={() => setEditing(undefined)}
onPublish={(article, revisionId) => setDecision({ article, revisionId, type: "publish" })}
onSaved={reload}
/>
);
}
return (
<>
<div className="knowledge-library-layout">
<CategoryTree
allLabel="Все материалы"
canManage={canManage}
categories={categories.map((category) => ({
id: category.id,
name: category.name,
parentId: category.parentId,
count: category.articleCount,
}))}
error={false}
loading={false}
selectedId={selectedCategory}
onManage={() => setManagingCategories(true)}
onRetry={() => void reload()}
onSelect={setSelectedCategory}
/>
<main className="knowledge-library-list">
<ContentLibraryToolbar
query={query}
onQueryChange={setQuery}
action={canManage && categories.length > 0 && <Button variant="primary" icon="plus" onClick={() => setEditing(null)}>Новая статья</Button>}
>
<label className="knowledge-filter-select"><span>Язык:</span><select value={language} onChange={(event) => setLanguage(event.target.value)}><option value="">Все</option><option value="ru">Русский</option><option value="en">English</option></select></label>
<label className="knowledge-filter-select"><span>Статус:</span><select value={status} onChange={(event) => setStatus(event.target.value)}><option value="">Все</option><option value="DRAFT">Черновик</option><option value="PUBLISHED">Опубликована</option><option value="ARCHIVED">Архив</option></select></label>
</ContentLibraryToolbar>
{error && <div className="portal-form-error">{error}</div>}
<ContentLibraryTable
canCreate={canManage && categories.length > 0 && selectedCategory !== undefined && !query && !language && !status}
createLabel="Создать статью"
emptyDescription={articles.length === 0 && selectedCategory !== undefined ? "Создайте статью в этом разделе или переместите существующую." : undefined}
emptyTitle={articles.length ? "Ничего не найдено" : "Материалов пока нет"}
error={false}
errorTitle="Не удалось загрузить материалы"
hasItems={filtered.length > 0}
loading={false}
onCreate={() => setEditing(null)}
onRetry={() => void reload()}
footer={<div className="ai-table-footer"><span>{filtered.length} материалов</span></div>}
>
<table className="baseline-table knowledge-table">
<thead><tr><th>СТАТЬЯ</th><th>РАЗДЕЛ</th><th>ЯЗЫК</th><th>ВЕРСИЯ</th><th>СТАТУС</th><th>ОБНОВЛЕНО</th><th /></tr></thead>
<tbody>
{filtered.map((article) => (
<tr className="knowledge-row" key={article.id} onClick={() => setEditing(article)}>
<td><button className="link is-strong is-neutral" type="button" onClick={() => setEditing(article)}>{article.latestRevision?.title || article.slug}</button><small className="knowledge-description">{article.latestRevision?.summary || article.slug}</small></td>
<td className="knowledge-category-path">{article.category.name}</td>
<td>{article.locale.toLocaleUpperCase()}</td>
<td>{article.publishedRevision ? article.publishedRevision.revision : "—"}</td>
<td><StatusPill status={article.status === "PUBLISHED" ? "published" : article.status === "ARCHIVED" ? "archived" : "draft"} /></td>
<td>{formatDate(article.updatedAt)}</td>
<td onClick={(event) => event.stopPropagation()}>
{canManage && (
<Dropdown
overlayClassName="app-dropdown"
trigger={["click"]}
menu={{ items: [
{ key: "edit", label: "Изменить", onClick: () => setEditing(article) },
...(article.status !== "ARCHIVED" ? [{ key: "archive", danger: true, label: "В архив", onClick: () => setDecision({ article, type: "archive" as const }) }] : []),
] }}
>
<button aria-label={`Действия: ${article.latestRevision?.title ?? article.slug}`} className="row-menu-button" type="button"><Icon name="more" size={18} /></button>
</Dropdown>
)}
</td>
</tr>
))}
</tbody>
</table>
</ContentLibraryTable>
</main>
</div>
{managingCategories && <PortalCategoryManagement categories={categories} portalId={portalId} onChanged={reload} onClose={() => setManagingCategories(false)} />}
<DecisionDialog
open={Boolean(decision)}
onClose={() => setDecision(null)}
tone={decision?.type === "archive" ? "danger" : "warning"}
icon={decision?.type === "archive" ? "trash" : "check"}
title={decision?.type === "archive" ? "Перенести статью в архив?" : "Опубликовать выбранную версию?"}
description={decision?.type === "archive" ? "Статья исчезнет с публичного портала." : "Эта версия станет доступна посетителям портала."}
actions={<><Button variant="secondary" onClick={() => setDecision(null)}>Отмена</Button><Button variant={decision?.type === "archive" ? "danger-outline" : "primary"} disabled={busy} onClick={() => void applyDecision()}>{decision?.type === "archive" ? "В архив" : "Опубликовать"}</Button></>}
/>
</>
);
}
@@ -0,0 +1,115 @@
import { Modal } from "antd";
import { useState } from "react";
import { ApiError } from "../../api/client";
import { FormField, SelectField } from "../../shared/form-controls";
import { Button } from "../../shared/ui-controls";
import {
createSupportPortal,
portalFieldErrors,
type PortalAddressConfig,
type SupportPortal,
} from "./model";
function configuredAddress(config: PortalAddressConfig, slug: string): string {
const port = config.port ? `:${config.port}` : "";
return `${config.scheme}://${slug}.${config.baseDomain}${port}`;
}
function validPortalKey(value: string): boolean {
return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(value);
}
function creationError(caught: unknown): string {
if (caught instanceof ApiError) {
if (caught.payload.code === "policy_unavailable") {
return "Создание порталов временно недоступно. Обратитесь к администратору CustoCRM.";
}
if (caught.payload.code === "quota_exceeded") {
return "Достигнут лимит порталов для текущего тарифа.";
}
if (caught.status === 400) {
return "Проверьте название и адрес портала.";
}
}
return "Не удалось создать портал. Повторите попытку позже.";
}
export function PortalCreateDialog({
open,
onClose,
onCreated,
address,
}: {
open: boolean;
onClose: () => void;
onCreated: (portal: SupportPortal) => void;
address: PortalAddressConfig;
}) {
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [locale, setLocale] = useState("ru");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const portalKeyValid = validPortalKey(slug.trim());
async function submit() {
setBusy(true);
setError("");
setFieldErrors({});
try {
const payload = await createSupportPortal({
name,
slug,
defaultLocale: locale,
});
onCreated(payload.portal);
} catch (caught) {
setFieldErrors(portalFieldErrors(caught));
setError(creationError(caught));
} finally {
setBusy(false);
}
}
return (
<Modal open={open} title="Новый портал поддержки" footer={null} onCancel={onClose}>
<div className="portal-dialog-form">
<p>Портал — это публичный центр помощи с инструкциями и ответами для клиентов.</p>
<FormField error={fieldErrors.name} label="Название портала" value={name} onChange={setName} placeholder="Например, Центр помощи Foxray" wide />
<label className={`portal-address-field${fieldErrors.slug ? " is-invalid" : ""}`}>
<span>Адрес CustoCRM</span>
<div>
<input
type="text"
value={slug}
onChange={(event) => setSlug(event.target.value.toLocaleLowerCase())}
placeholder="foxray"
autoComplete="off"
maxLength={63}
pattern="[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
/>
<b>.{address.baseDomain}</b>
</div>
<small>Используйте строчные латинские буквы, цифры и дефисы.</small>
{fieldErrors.slug && <small className="form-field-error" role="alert">{fieldErrors.slug}</small>}
{slug.trim() && <code>{configuredAddress(address, slug.trim())}</code>}
</label>
<SelectField
label="Основной язык"
value={locale}
onChange={setLocale}
options={[["ru", "Русский"], ["en", "English"]]}
/>
{error && <div className="portal-form-error">{error}</div>}
<div className="portal-dialog-actions">
<Button variant="secondary" onClick={onClose}>Отмена</Button>
<Button variant="primary" disabled={busy || !name.trim() || !portalKeyValid} onClick={() => void submit()}>
{busy ? "Создаём…" : "Создать портал"}
</Button>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,88 @@
import { useEffect, useState } from "react";
import { FormField } from "../../shared/form-controls";
import { Button } from "../../shared/ui-controls";
import {
portalErrorMessage,
setPortalCustomDomain,
verifyPortalCustomDomain,
type SupportPortal,
} from "./model";
export function PortalDomainSettings({
canManage,
onChanged,
portal,
}: {
canManage: boolean;
onChanged: (portal: SupportPortal) => void;
portal: SupportPortal;
}) {
const [domain, setDomain] = useState(portal.customDomain ?? "");
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState("");
useEffect(() => setDomain(portal.customDomain ?? ""), [portal.customDomain]);
async function save() {
setBusy(true);
setFeedback("");
try {
const payload = await setPortalCustomDomain(portal.id, domain);
onChanged(payload.portal);
setFeedback(domain.trim() ? "Домен сохранён. Добавьте TXT-запись для подтверждения." : "Свой домен отключён");
} catch (error) {
setFeedback(portalErrorMessage(error, "Не удалось сохранить домен"));
} finally {
setBusy(false);
}
}
async function verify() {
setBusy(true);
setFeedback("");
try {
const payload = await verifyPortalCustomDomain(portal.id);
onChanged(payload.portal);
setFeedback("Домен подтверждён");
} catch (error) {
setFeedback(portalErrorMessage(error, "Не удалось подтвердить домен"));
} finally {
setBusy(false);
}
}
return (
<section className="portal-section">
<div className="portal-section-heading">
<div><h2>Свой домен</h2><p>Подключите адрес вашей компании к опубликованному порталу.</p></div>
</div>
<div className="portal-settings-fields">
<FormField
disabled={!canManage}
label="Домен"
placeholder="help.example.com"
value={domain}
onChange={canManage ? setDomain : undefined}
mono
wide
/>
</div>
{portal.customDomainVerification && (
<div className="portal-domain-verification">
<strong>Добавьте DNS-запись</strong>
<dl>
<div><dt>Тип</dt><dd>{portal.customDomainVerification.type}</dd></div>
<div><dt>Имя</dt><dd><code>{portal.customDomainVerification.name}</code></dd></div>
<div><dt>Значение</dt><dd><code>{portal.customDomainVerification.value}</code></dd></div>
</dl>
</div>
)}
<div className="portal-domain-actions">
{canManage && <Button variant="secondary" disabled={busy} onClick={() => void save()}>Сохранить домен</Button>}
{canManage && portal.customDomainVerification && <Button variant="primary" disabled={busy} onClick={() => void verify()}>Проверить DNS</Button>}
</div>
{feedback && <div className="portal-save-feedback">{feedback}</div>}
</section>
);
}
@@ -0,0 +1,170 @@
import { useEffect, useMemo, useState } from "react";
import type { Channel } from "../channels/types";
import { FormField, SelectField, SwitchButton } from "../../shared/form-controls";
import { Button } from "../../shared/ui-controls";
import type { Product } from "../../types";
import {
listPortalSupportChannels,
portalErrorMessage,
replacePortalProducts,
updateSupportPortal,
type SupportPortal,
} from "./model";
import { PortalDomainSettings } from "./PortalDomainSettings";
export function PortalSettings({
portal,
products,
canManage,
onChanged,
}: {
portal: SupportPortal;
products: Product[];
canManage: boolean;
onChanged: (portal: SupportPortal) => void;
}) {
const [name, setName] = useState(portal.name);
const [slug, setSlug] = useState(portal.slug);
const [locale, setLocale] = useState(portal.defaultLocale);
const [channels, setChannels] = useState<Channel[]>([]);
const [selected, setSelected] = useState<Record<number, number | null>>({});
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState("");
useEffect(() => {
setName(portal.name);
setSlug(portal.slug);
setLocale(portal.defaultLocale);
setSelected(Object.fromEntries(
portal.products.map((item) => [item.productId, item.supportChannelId]),
));
}, [portal]);
useEffect(() => {
listPortalSupportChannels(portal.id)
.then((payload) => setChannels(payload.items))
.catch(() => setChannels([]));
}, [portal.id]);
const supportChannels = useMemo(() => channels.filter((channel) => (
channel.isActive
&& channel.department === "support"
&& channel.product
&& channel.policy.requiresAuthenticatedProductIdentity
&& !channel.policy.allowAnonymousSessions
)), [channels]);
async function saveBasics() {
setBusy(true);
setFeedback("");
try {
const payload = await updateSupportPortal(portal.id, {
name,
slug,
defaultLocale: locale,
});
onChanged(payload.portal);
setFeedback("Настройки сохранены");
} catch (caught) {
setFeedback(portalErrorMessage(caught, "Не удалось сохранить настройки"));
} finally {
setBusy(false);
}
}
async function saveProducts() {
setBusy(true);
setFeedback("");
try {
const items = Object.entries(selected).map(([productId, supportChannelId], index) => ({
productId: Number(productId),
supportChannelId,
sortOrder: index,
}));
const payload = await replacePortalProducts(portal.id, items);
onChanged(payload.portal);
setFeedback("Продукты портала обновлены");
} catch (caught) {
setFeedback(portalErrorMessage(caught, "Не удалось сохранить продукты"));
} finally {
setBusy(false);
}
}
function toggleProduct(productId: number) {
setSelected((current) => {
const next = { ...current };
if (productId in next) delete next[productId];
else next[productId] = null;
return next;
});
}
return (
<div className="portal-settings-grid">
<section className="portal-section">
<div className="portal-section-heading">
<div><h2>Основные настройки</h2><p>Название, публичный адрес и язык материалов.</p></div>
</div>
<div className="portal-settings-fields">
<FormField disabled={!canManage} label="Название портала" value={name} onChange={canManage ? setName : undefined} wide />
<FormField disabled={!canManage} label="Адрес CustoCRM" value={slug} onChange={canManage ? setSlug : undefined} mono wide />
<p className="portal-address-preview">{portal.publicUrl}</p>
<SelectField
disabled={!canManage}
label="Язык по умолчанию"
value={locale}
onChange={setLocale}
options={[["ru", "Русский"], ["en", "English"]]}
/>
</div>
{canManage && <Button variant="primary" disabled={busy} onClick={() => void saveBasics()}>Сохранить настройки</Button>}
</section>
<section className="portal-section">
<div className="portal-section-heading">
<div><h2>Продукты и поддержка</h2><p>Выберите продукты и каналы, в которых клиенты смогут обратиться в поддержку.</p></div>
</div>
<div className="portal-product-list">
{products.map((product) => {
const enabled = product.id in selected;
const options: Array<[string, string]> = [
["", "Без перехода в поддержку"],
...supportChannels
.filter((channel) => channel.product?.id === product.id)
.map((channel): [string, string] => [String(channel.id), channel.name]),
];
return (
<div className={enabled ? "is-enabled" : ""} key={product.id}>
<SwitchButton
checked={enabled}
className="ui-switch"
disabled={!canManage}
label={`Добавить ${product.name}`}
onClick={() => toggleProduct(product.id)}
/>
<span><strong>{product.name}</strong><small>{product.code}</small></span>
{enabled && (
<SelectField
disabled={!canManage}
label="Канал поддержки"
value={String(selected[product.id] ?? "")}
onChange={(value) => setSelected((current) => ({
...current,
[product.id]: value ? Number(value) : null,
}))}
options={options}
/>
)}
</div>
);
})}
</div>
{canManage && <Button variant="secondary" disabled={busy} onClick={() => void saveProducts()}>Сохранить продукты</Button>}
</section>
<PortalDomainSettings portal={portal} canManage={canManage} onChanged={onChanged} />
{feedback && <div className="portal-save-feedback">{feedback}</div>}
</div>
);
}
@@ -0,0 +1,133 @@
import { Modal } from "antd";
import { useCallback, useEffect, useState } from "react";
import { hasCapability } from "../../auth/access";
import { DecisionDialog } from "../../shared/DecisionDialog";
import { ErrorScreen, LoadingState, PageHeader, StatusPill } from "../../shared/ui";
import { Button } from "../../shared/ui-controls";
import type { Product, SessionUser } from "../../types";
import {
changePortalStatus,
listPortalArticles,
listPortalCategories,
loadSupportPortal,
portalErrorMessage,
type PortalArticle,
type PortalCategory,
type SupportPortal,
} from "./model";
import { PortalContent } from "./PortalContent";
import { PortalSettings } from "./PortalSettings";
import "./styles";
export function SupportPortalDetailPage({
portalId,
products,
user,
openPortals,
}: {
portalId: number | null;
products: Product[];
user: SessionUser;
openPortals: () => void;
}) {
const [portal, setPortal] = useState<SupportPortal | null>(null);
const [categories, setCategories] = useState<PortalCategory[]>([]);
const [articles, setArticles] = useState<PortalArticle[]>([]);
const [settingsOpen, setSettingsOpen] = useState(false);
const [failed, setFailed] = useState(false);
const [busy, setBusy] = useState(false);
const [pendingStatus, setPendingStatus] = useState<"DRAFT" | "PUBLISHED" | "ARCHIVED" | null>(null);
const [actionError, setActionError] = useState("");
const canManage = hasCapability(user, "support.operate", "support");
const load = useCallback(async () => {
if (!portalId) return;
setFailed(false);
try {
const [portalPayload, categoryPayload, articlePayload] = await Promise.all([
loadSupportPortal(portalId),
listPortalCategories(portalId),
listPortalArticles(portalId),
]);
setPortal(portalPayload.portal);
setCategories(categoryPayload.items);
setArticles(articlePayload.items);
} catch {
setFailed(true);
}
}, [portalId]);
useEffect(() => {
void load();
}, [load]);
async function setStatus(status: "DRAFT" | "PUBLISHED" | "ARCHIVED") {
if (!portal) return;
setBusy(true);
setActionError("");
try {
const payload = await changePortalStatus(portal.id, status);
setPortal(payload.portal);
setPendingStatus(null);
} catch (caught) {
setActionError(portalErrorMessage(caught, "Не удалось изменить статус портала"));
} finally {
setBusy(false);
}
}
if (!portalId) return <ErrorScreen retry={openPortals} />;
if (failed) return <ErrorScreen retry={() => void load()} />;
if (!portal) return <LoadingState />;
const action = portal.status === "PUBLISHED" || canManage ? (
<div className="portal-header-actions">
{portal.status === "PUBLISHED" && <a className="link has-icon" href={portal.publicUrl} target="_blank" rel="noreferrer">Открыть портал</a>}
{canManage && <Button variant="secondary" icon="settings" onClick={() => setSettingsOpen(true)}>Настройки</Button>}
{canManage && portal.status !== "PUBLISHED" && portal.status !== "ARCHIVED" && <Button variant="primary" disabled={busy} onClick={() => setPendingStatus("PUBLISHED")}>Опубликовать</Button>}
{canManage && portal.status === "ARCHIVED" && <Button variant="secondary" disabled={busy} onClick={() => void setStatus("DRAFT")}>Вернуть из архива</Button>}
{canManage && portal.status !== "ARCHIVED" && <Button variant="secondary" disabled={busy} onClick={() => setPendingStatus("ARCHIVED")}>В архив</Button>}
</div>
) : undefined;
return (
<div className="support-portal-detail">
<PageHeader
title={portal.name}
text={<span><StatusPill status={portal.status === "PUBLISHED" ? "published" : portal.status === "ARCHIVED" ? "archived" : "draft"} /><code>{portal.publicUrl}</code></span>}
action={action}
/>
<PortalContent
articles={articles}
canManage={canManage && portal.status !== "ARCHIVED"}
categories={categories}
locale={portal.defaultLocale}
portalId={portal.id}
reload={load}
/>
<Modal
destroyOnHidden
footer={null}
open={settingsOpen}
title="Настройки портала"
width={960}
onCancel={() => setSettingsOpen(false)}
>
<PortalSettings portal={portal} products={products} canManage={canManage && portal.status !== "ARCHIVED"} onChanged={setPortal} />
</Modal>
{actionError && <div className="portal-form-error">{actionError}</div>}
<DecisionDialog
open={pendingStatus !== null}
onClose={() => setPendingStatus(null)}
tone={pendingStatus === "ARCHIVED" ? "danger" : "warning"}
icon={pendingStatus === "ARCHIVED" ? "trash" : "check"}
title={pendingStatus === "ARCHIVED" ? "Перенести портал в архив?" : "Опубликовать портал?"}
description={pendingStatus === "ARCHIVED"
? "Портал и его материалы станут недоступны посетителям до восстановления."
: "Опубликованные статьи станут доступны по публичному адресу."}
actions={<><Button variant="secondary" onClick={() => setPendingStatus(null)}>Отмена</Button><Button variant={pendingStatus === "ARCHIVED" ? "danger-outline" : "primary"} disabled={busy} onClick={() => pendingStatus && void setStatus(pendingStatus)}>{pendingStatus === "ARCHIVED" ? "В архив" : "Опубликовать"}</Button></>}
/>
</div>
);
}
@@ -0,0 +1,155 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { hasCapability } from "../../auth/access";
import { ContentLibraryTable } from "../../shared/content-library/ContentLibraryTable";
import { ContentLibraryToolbar } from "../../shared/content-library/ContentLibraryToolbar";
import { Icon } from "../../shared/icons";
import { ContentState, ErrorScreen, LoadingState, PageHeader, StatusPill } from "../../shared/ui";
import { Button } from "../../shared/ui-controls";
import type { SessionUser } from "../../types";
import {
listSupportPortals,
type PortalAddressConfig,
type PortalCreationPolicy,
type SupportPortal,
} from "./model";
import { PortalCreateDialog } from "./PortalCreateDialog";
import "./styles";
export function SupportPortalsPage({
user,
openPortal,
}: {
user: SessionUser;
openPortal: (portalId: number) => void;
}) {
const [portals, setPortals] = useState<SupportPortal[] | null>(null);
const [creation, setCreation] = useState<PortalCreationPolicy | null>(null);
const [address, setAddress] = useState<PortalAddressConfig | null>(null);
const [failed, setFailed] = useState(false);
const [search, setSearch] = useState("");
const [creating, setCreating] = useState(false);
const canManage = hasCapability(user, "support.operate", "support");
const load = useCallback(async () => {
setFailed(false);
try {
const payload = await listSupportPortals();
setPortals(payload.items);
setCreation(payload.creation);
setAddress(payload.address);
} catch {
setFailed(true);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const filtered = useMemo(() => {
const query = search.trim().toLocaleLowerCase();
if (!query) return portals ?? [];
return (portals ?? []).filter((portal) => (
portal.name.toLocaleLowerCase().includes(query)
|| portal.slug.toLocaleLowerCase().includes(query)
));
}, [portals, search]);
if (failed) return <ErrorScreen retry={() => void load()} />;
if (!portals || !creation || !address) return <LoadingState />;
const createAction = canManage ? (
<Button
variant="primary"
icon="plus"
disabled={!creation.canCreate}
onClick={() => creation.canCreate && setCreating(true)}
>
Создать портал
</Button>
) : undefined;
return (
<div className="support-portals-page">
<PageHeader
title="Порталы поддержки"
text="Публичные базы знаний отдела поддержки"
action={createAction}
/>
{!creation.available && portals.length === 0 ? (
<ContentState
icon={<Icon name="folder" size={24} />}
title="Создание порталов временно недоступно"
text="Обратитесь к администратору CustoCRM."
/>
) : portals.length === 0 ? (
<ContentState
icon={<Icon name="folder" size={24} />}
title={creation.canCreate ? "Создайте первый портал поддержки" : "Порталы недоступны на текущем тарифе"}
text={creation.canCreate
? "Публикуйте инструкции и ответы на частые вопросы для клиентов."
: "Чтобы создать портал поддержки, перейдите на тариф с порталами."}
action={createAction}
/>
) : (
<>
{!creation.available && (
<div className="portal-policy-notice">
Создание порталов временно недоступно. Существующие порталы остаются доступны для просмотра.
</div>
)}
<div className="knowledge-library-list">
<ContentLibraryToolbar
placeholder="Поиск по названию и адресу"
query={search}
onQueryChange={setSearch}
/>
<ContentLibraryTable
emptyTitle="Порталы не найдены"
error={false}
errorTitle="Не удалось загрузить порталы"
hasItems={filtered.length > 0}
loading={false}
onRetry={() => void load()}
footer={<div className="ai-table-footer"><span>{filtered.length} порталов</span></div>}
>
<table className="baseline-table knowledge-table">
<thead><tr><th>ПОРТАЛ</th><th>СТАТУС</th><th>ПРОДУКТЫ</th><th /></tr></thead>
<tbody>
{filtered.map((portal) => (
<tr className="knowledge-row" key={portal.id} onClick={() => openPortal(portal.id)}>
<td>
<span className="support-portal-name">
<i><Icon name="folder" size={19} /></i>
<span>
<button className="link is-strong is-neutral" type="button" onClick={() => openPortal(portal.id)}>{portal.name}</button>
<small>{portal.publicUrl}</small>
</span>
</span>
</td>
<td><StatusPill status={portal.status === "PUBLISHED" ? "published" : portal.status === "ARCHIVED" ? "archived" : "draft"} /></td>
<td>{portal.products.length || "—"}</td>
<td className="support-portal-open"><Icon name="arrow" size={16} /></td>
</tr>
))}
</tbody>
</table>
</ContentLibraryTable>
</div>
</>
)}
{creating && (
<PortalCreateDialog
open
address={address}
onClose={() => setCreating(false)}
onCreated={(portal) => {
setCreating(false);
openPortal(portal.id);
}}
/>
)}
</div>
);
}
@@ -0,0 +1,310 @@
import { api, ApiError } from "../../api/client";
import type { Channel } from "../channels/types";
export type PortalStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
export type ArticleStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
export type PortalProductLink = {
productId: number;
code: string;
name: string;
supportChannelId: number | null;
supportChannelCode: string | null;
sortOrder: number;
};
export type SupportPortal = {
id: number;
publicId: string;
departmentCode: "support";
slug: string;
hostedDomain: string;
customDomain: string | null;
customDomainVerifiedAt: string | null;
customDomainVerification: {
name: string;
type: "TXT";
value: string;
} | null;
publicUrl: string;
name: string;
defaultLocale: string;
status: PortalStatus;
publishedAt: string | null;
products: PortalProductLink[];
createdAt: string;
updatedAt: string;
};
export type PortalCategory = {
id: number;
slug: string;
name: string;
description: string;
parentId: number | null;
sortOrder: number;
articleCount: number;
};
export type ArticleRevision = {
id: number;
revision: number;
title: string;
summary: string;
content: string;
createdAt: string;
publishedAt: string | null;
};
export type PortalArticle = {
id: number;
slug: string;
locale: string;
status: ArticleStatus;
category: PortalCategory;
publishedRevision: ArticleRevision | null;
latestRevision: Omit<ArticleRevision, "content"> | null;
revisions?: ArticleRevision[];
createdAt: string;
updatedAt: string;
};
export type PortalInput = {
slug: string;
name: string;
defaultLocale: string;
};
export type PortalCreationPolicy = {
available: boolean;
canCreate: boolean;
limit: number | null;
used: number;
};
export type PortalAddressConfig = {
scheme: string;
baseDomain: string;
port: string | null;
};
export type SupportPortalList = {
items: SupportPortal[];
creation: PortalCreationPolicy;
address: PortalAddressConfig;
};
export function listSupportPortals(): Promise<SupportPortalList> {
return api("/api/v1/support/portals/");
}
export function createSupportPortal(input: PortalInput): Promise<{ portal: SupportPortal }> {
return api("/api/v1/support/portals/", {
method: "POST",
body: JSON.stringify(input),
});
}
export function loadSupportPortal(id: number): Promise<{ portal: SupportPortal }> {
return api(`/api/v1/support/portals/${id}/`);
}
export function updateSupportPortal(
id: number,
input: PortalInput,
): Promise<{ portal: SupportPortal }> {
return api(`/api/v1/support/portals/${id}/`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function changePortalStatus(
id: number,
status: PortalStatus,
): Promise<{ portal: SupportPortal }> {
return api(`/api/v1/support/portals/${id}/status/`, {
method: "POST",
body: JSON.stringify({ status }),
});
}
export function replacePortalProducts(
id: number,
items: Array<{ productId: number; supportChannelId: number | null; sortOrder: number }>,
): Promise<{ portal: SupportPortal }> {
return api(`/api/v1/support/portals/${id}/products/`, {
method: "PUT",
body: JSON.stringify({ items }),
});
}
export function listPortalCategories(id: number): Promise<{ items: PortalCategory[] }> {
return api(`/api/v1/support/portals/${id}/categories/`);
}
export function createPortalCategory(
id: number,
input: Pick<PortalCategory, "name"> & Partial<Omit<PortalCategory, "id" | "articleCount" | "name">>,
): Promise<{ category: PortalCategory }> {
return api(`/api/v1/support/portals/${id}/categories/`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function updatePortalCategory(
portalId: number,
categoryId: number,
input: Partial<Omit<PortalCategory, "id" | "articleCount">>,
): Promise<{ category: PortalCategory }> {
return api(`/api/v1/support/portals/${portalId}/categories/${categoryId}/`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function deletePortalCategory(
portalId: number,
categoryId: number,
): Promise<void> {
return api(`/api/v1/support/portals/${portalId}/categories/${categoryId}/`, {
method: "DELETE",
});
}
export function listPortalSupportChannels(
portalId: number,
): Promise<{ items: Channel[] }> {
return api(`/api/v1/support/portals/${portalId}/support-channels/`);
}
export function setPortalCustomDomain(
portalId: number,
customDomain: string,
): Promise<{ portal: SupportPortal }> {
return api(`/api/v1/support/portals/${portalId}/domain/`, {
method: "PUT",
body: JSON.stringify({ customDomain }),
});
}
export function verifyPortalCustomDomain(
portalId: number,
): Promise<{ portal: SupportPortal }> {
return api(`/api/v1/support/portals/${portalId}/domain/verify/`, {
method: "POST",
});
}
export function listPortalArticles(id: number): Promise<{ items: PortalArticle[] }> {
return api(`/api/v1/support/portals/${id}/articles/`);
}
export function createPortalArticle(
portalId: number,
input: {
categoryId: number;
slug: string;
locale: string;
title: string;
summary: string;
content: string;
},
): Promise<{ article: PortalArticle }> {
return api(`/api/v1/support/portals/${portalId}/articles/`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function loadPortalArticle(
portalId: number,
articleId: number,
): Promise<{ article: PortalArticle }> {
return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/`);
}
export function updatePortalArticle(
portalId: number,
articleId: number,
input: { categoryId: number; locale: string; slug: string },
): Promise<{ article: PortalArticle }> {
return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function addArticleRevision(
portalId: number,
articleId: number,
input: Pick<ArticleRevision, "title" | "summary" | "content">,
): Promise<{ revision: ArticleRevision }> {
return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/revisions/`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function publishArticleRevision(
portalId: number,
articleId: number,
revisionId: number,
): Promise<{ article: PortalArticle }> {
return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/publish/`, {
method: "POST",
body: JSON.stringify({ revisionId }),
});
}
export function archivePortalArticle(
portalId: number,
articleId: number,
): Promise<{ article: PortalArticle }> {
return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/archive/`, {
method: "POST",
});
}
export const PORTAL_STATUS_LABEL: Record<PortalStatus, string> = {
DRAFT: "Черновик",
PUBLISHED: "Опубликован",
ARCHIVED: "В архиве",
};
export const ARTICLE_STATUS_LABEL: Record<ArticleStatus, string> = {
DRAFT: "Черновик",
PUBLISHED: "Опубликована",
ARCHIVED: "В архиве",
};
type ValidationPayload = {
detail?: string;
errors?: Record<string, string[]>;
};
export function portalErrorMessage(error: unknown, fallback: string): string {
if (!(error instanceof ApiError)) return fallback;
const payload = error.payload as ValidationPayload;
const first = payload.errors
? Object.values(payload.errors).flat().find(Boolean)
: undefined;
if (first && /[А-Яа-яЁё]/.test(first)) return first;
if (error.status === 403) return "Недостаточно прав для этого действия";
if (error.status === 404) return "Запись не найдена";
if (error.status === 409) return "Изменение конфликтует с текущими данными";
if (error.status === 429) return "Слишком много запросов. Повторите позже";
return fallback;
}
export function portalFieldErrors(error: unknown): Record<string, string> {
if (!(error instanceof ApiError)) return {};
const payload = error.payload as ValidationPayload;
return Object.fromEntries(
Object.entries(payload.errors ?? {}).map(([field, messages]) => [
field,
messages.find((message) => /[А-Яа-яЁё]/.test(message)) ?? "Проверьте значение",
]),
);
}
@@ -0,0 +1,161 @@
.support-portal-detail {
display: grid;
gap: 16px;
}
.support-portal-detail .page-header {
margin-bottom: 0;
}
.support-portal-detail .page-header p > span {
display: inline-flex;
align-items: center;
gap: 10px;
}
.support-portal-detail .page-header code {
color: var(--text-tertiary);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 12px;
}
.portal-header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.portal-settings-grid,
.portal-content-layout {
display: grid;
gap: 16px;
}
.portal-section {
padding: 22px;
border: 1px solid var(--border-default);
border-radius: var(--radius-2xl);
background: #fff;
box-shadow: var(--shadow-sm);
}
.portal-section-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
margin-bottom: 20px;
}
.portal-section-heading h2 {
margin: 0 0 4px;
color: var(--text-heading);
font-size: 16px;
font-weight: 700;
}
.portal-section-heading p {
margin: 0;
color: var(--text-tertiary);
font-size: 12.5px;
}
.portal-settings-fields {
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) 180px;
gap: 12px;
margin-bottom: 16px;
}
.portal-address-preview {
grid-column: 1 / -1;
margin: -4px 0 0;
color: var(--text-tertiary);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 11.5px;
}
.portal-product-list {
display: grid;
gap: 8px;
margin-bottom: 16px;
}
.portal-product-list > div {
display: grid;
grid-template-columns: 38px minmax(160px, 1fr) minmax(240px, .8fr);
align-items: center;
gap: 12px;
min-height: 62px;
padding: 10px 12px;
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
}
.portal-product-list > div.is-enabled {
border-color: var(--primary-border);
background: var(--primary-bg);
}
.portal-product-list strong,
.portal-product-list small {
display: block;
}
.portal-product-list small {
margin-top: 2px;
color: var(--text-tertiary);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 11px;
}
.portal-product-list .readonly-field {
margin: 0;
}
.portal-save-feedback {
color: var(--text-secondary);
font-size: 12.5px;
}
.portal-domain-verification {
margin: 0 0 16px;
padding: 14px;
border: 1px solid var(--primary-border);
border-radius: var(--radius-lg);
background: var(--primary-bg);
}
.portal-domain-verification > strong {
font-size: 13px;
}
.portal-domain-verification dl {
display: grid;
gap: 8px;
margin: 12px 0 0;
}
.portal-domain-verification dl > div {
display: grid;
grid-template-columns: 70px minmax(0, 1fr);
gap: 10px;
}
.portal-domain-verification dt { color: var(--text-tertiary); }
.portal-domain-verification dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
.portal-domain-actions { display: flex; gap: 8px; }
@media (max-width: 1040px) {
.portal-settings-fields {
grid-template-columns: 1fr 1fr;
}
.portal-product-list > div {
grid-template-columns: 38px 1fr;
}
.portal-product-list .readonly-field {
grid-column: 2;
}
}
@@ -0,0 +1,133 @@
.portal-policy-notice {
margin-bottom: 14px;
padding: 11px 13px;
border: 1px solid #ffe58f;
border-radius: var(--radius-lg);
color: #ad6800;
background: #fffbe6;
font-size: 12.5px;
}
.support-portal-name {
display: flex;
align-items: center;
gap: 12px;
}
.support-portal-name > i {
display: grid;
flex: 0 0 38px;
width: 38px;
height: 38px;
place-items: center;
border-radius: var(--radius-lg);
background: var(--n-9);
color: var(--text-secondary);
}
.support-portal-name small {
display: block;
}
.support-portal-name .link {
margin-bottom: 3px;
font-size: 14px;
}
.support-portal-name small {
color: var(--text-tertiary);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 11.5px;
}
.support-portal-open {
color: var(--text-tertiary);
}
.portal-dialog-form {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 16px;
padding-top: 8px;
}
.portal-dialog-form > * {
grid-column: 1;
}
.portal-dialog-form > p {
margin: 0;
color: var(--text-secondary);
line-height: 1.55;
}
.portal-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 4px;
}
.portal-form-error {
padding: 9px 11px;
border-radius: var(--radius-md);
background: #fff2f0;
color: #cf1322;
font-size: 12.5px;
}
.portal-address-field {
display: grid;
gap: 6px;
}
.portal-address-field > span {
color: var(--text-tertiary);
font-size: 12px;
font-weight: 500;
}
.portal-address-field > div {
display: flex;
height: 40px;
overflow: hidden;
border: 1px solid var(--border-input);
border-radius: var(--radius-md);
background: #fff;
}
.portal-address-field > div:focus-within {
border-color: var(--primary);
}
.portal-address-field input {
min-width: 0;
flex: 1;
padding: 0 12px;
border: 0;
outline: 0;
font-family: var(--font-mono);
}
.portal-address-field b {
display: flex;
align-items: center;
padding: 0 12px;
border-left: 1px solid var(--border-input);
background: var(--n-10);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 12px;
font-weight: 500;
}
.portal-address-field small,
.portal-address-field code {
color: var(--text-tertiary);
font-size: 11.5px;
}
.portal-address-field code {
color: var(--text-secondary);
font-family: var(--font-mono);
}
@@ -0,0 +1,2 @@
import "./styles-list.css";
import "./styles-detail.css";
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff. Show more