mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ feat(support): add reusable multi-portal help centers
This commit is contained in:
1 parent
55ab309ae7
commit
2b246e193b
128 files changed
+9178
-655
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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,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", []),
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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"
|
||||
+19
@@ -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,
|
||||
),
|
||||
]
|
||||
+114
@@ -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,
|
||||
),
|
||||
]
|
||||
+98
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
+17
@@ -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)]
|
||||
@@ -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,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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -28,6 +28,7 @@ const publicPaths = [
|
||||
"/api/v1/calls/invites/",
|
||||
"/api/v1/orders/ingest/",
|
||||
"/api/v1/support/sessions/",
|
||||
"/api/v1/help/",
|
||||
];
|
||||
|
||||
export type ApiErrorPayload = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
Reference in new issue
Block a user