Единая target-aware policy can_manage_employee(actor, target, action) и can_create_role
(governance.py) по матрице SPEC-HUB-0016 §8: OWNER управляет ADMIN и EMPLOYEE, ADMIN —
только EMPLOYEE, смена роли и передача владения — только OWNER.
Роль ADMIN активирована: is_manager/IsManager, обычные capability уровня организации
открыты OWNER и ADMIN (каналы, AI, интеграции, продукты, заказы, корректировки продаж,
контракты поддержки, командный центр, звонки, уведомления). Governance-действия над
привилегированными целями остаются за OWNER.
Employee endpoints переведены на governance: create ADMIN/EMPLOYEE по policy, update
делит проверки profile/role/placement, block/unblock/reset/terminate — через
can_manage_employee с аудитом отказа. Новый атомарный OwnershipTransferView сохраняет
инвариант ровно одного владельца (понижение прежнего перед промоутом, row-lock).
Аудит по SPEC-HUB-0016 §16; payload отдаёт per-actor permissions.
Frontend: ADMIN получает доступ уровня владельца (access.ts + тесты), действия над
сотрудником скрываются по backend permissions. UI передачи владения не проектируется
до утверждения design gate (SPEC-HUB-0016 §14).
Проверено полным backend-suite в Docker (293 tests, OK), typecheck и vitest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Пропущенный при переименовании department->primary_department ORM-фильтр в
command_center_overview падал с FieldError. Проверено полным backend-suite в Docker
(276 tests, OK), паритет с CI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Системные роли OWNER/ADMIN/EMPLOYEE (удалён OPERATOR), обязательная должность
position_title и основной отдел primary_department. Инварианты: владелец на уровне
компании, ровно один OWNER на организацию. Двухфазная миграция OPERATOR->EMPLOYEE
без расширения прав; операционный доступ сохранён compatibility-адаптером.
Обновлены DTO/API/формы, список и карточка сотрудника, тесты модели и инвариантов.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- New logo: favicon.svg and LogoIcon (replaces PulseIcon) in sidebar and auth screens
- Source logo asset saved to resources/icon.svg
- Replace "Edevs Hub" with "CustoCRM" across internal-ui, web-chat, backend strings (email sender/subject, TOTP issuer, notification bot texts, OpenAPI title), env examples and README
- Add .claude/launch.json for internal-ui dev server (bind 127.0.0.1)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 3 миграции legacy orders → домен продаж. Команда import_legacy_orders,
по умолчанию DRY-RUN (только отчёт по классам, без записи).
- classify_legacy_order: external / manual / pending_ambiguous; PENDING не
считается продажей и требует ручного решения (§11.2)
- import_legacy_order: подтверждённый Order (PAID/REFUNDED/CANCELLED) →
Sale + SaleEvent(sale.legacy_imported), sales_source=null, идемпотентно по
metadata.legacy_order_id; OrderItem → line_items_snapshot; fulfillment_status
и legacy id — только в metadata; contact/conversation сохраняются
- provision_product_sales_source: создать SalesSource(PRODUCT_API) и скопировать
действующий Product.ingest_token_hash (§11 шаг 2-3) — без генерации/ротации
- флаги --apply / --provision-sources / --product; команда НИКОГДА не удаляет
legacy, не отключает legacy write API и не ротирует token (гейты §11 11-12)
- 7 тестов (dry-run, import, идемпотентность, refund→0, PENDING-skip, provision)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Заменяет учёт заказов Hub на учёт подтверждённых внешних продаж по
ADR-HUB-0025 / SPEC-HUB-0014. Legacy orders оставлен как есть до миграции.
Backend (новый app hub_platform.sales):
- модели Sale (проекция) / SaleEvent (append-only журнал) / SalesSource /
AttributionToken / ExternalCustomerIdentity
- единый application service: событие сохраняется raw, проекция применяется
идемпотентно и синхронно в транзакции (async-воркера в проекте нет)
- Product Sales API POST /api/v1/product-sales/events (Bearer на источник)
- ручная фиксация + correct/cancel/partial-refund/refund через /api/v1/sales/
- attribution token issue, аналитика gross/refund/net
- команда issue_sales_source_credential; 19 тестов
Frontend (internal-ui):
- единый реестр продаж (registry) вместо вкладок Заказы/Платежи/Исполнение
- карточка продажи (sale-detail) с append-only таймлайном событий и
действиями OWNER; вкладка клиента Заказы -> Продажи
- роуты salesOrders/salesOrderDetail перенаправлены на новые страницы
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- инбокс сортируется по времени последнего сообщения (annotate
Max(messages.created_at)), а не по служебной активности (claim/takeover)
- правый сайдбар: вкладка «Клиент» → «Контакт» (sales и support)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Единый воркер делает inbound getUpdates/updates и outbox-диспатч в одном
потоке. Hold-time long-poll был 20с: при подключённых MAX+Telegram цикл
блокировался до ~40с, из-за чего приглашения звонков, уведомления и ответы
AI уходили с задержкой ~30с и дольше. Hold-time вынесен в
HUB_MESSENGER_POLL_TIMEOUT_SECONDS (default 2с) и применяется в обоих
транспортах.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- CallMetric: тип соединения DIRECT/RELAY/UNKNOWN + категория ICE-кандидата
(host/srflx/prflx/relay) и RTT; храним только КАТЕГОРИЮ, без адресов/SDP/ICE
- record_call_metric: derive connection_type, whitelist кандидатов, clamp RTT,
идемпотентный upsert по (call_session, side)
- signaling/consumer: тип participant.metrics — сохраняем, не ретранслируем
и не логируем; call_payload отдаёт metrics для internal-ui/аналитики E15
- callRtc.ts: на connected снимает getStats выбранной candidate-pair и шлёт
только категорию кандидата + RTT (подтверждает direct vs TURN relay)
- tests: derive/relay/unknown, санитизация мусора и RTT, upsert, payload
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- командный центр: карточки отделов разделены отступом
- «Проверить» доступна для Web-виджета: backend валидирует привязку к
каналу и что виджет канала обслуживает именно это подключение;
во frontend флаг checkable отделён от testable (секрета у WEB нет)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- командный центр: endpoint command-overview (OWNER) — оба отдела с живыми
метриками диалогов, коммерция в карточке продаж, реальные «Требует
внимания»/интеграции/расходы AI; фронт переведён с мок-данных, статичные
статусы в топбаре и «Отделах» заменены на реальные
- ConversationRead: открытие диалога двигает персональную отметку прочтения,
бейдж непрочитанных в списке гаснет без ответа оператора
- уведомления: привязка мессенджера эксклюзивна — новая заменяет прежнюю
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- fix: JSON-exclude по config.purpose отбрасывал в SQL все интеграции без
ключа — клиентские TG/MAX боты переставали поллиться; фильтр в Python
- fix: MAX передаёт payload деплинка апдейтом bot_started — нормализуем
в «/start <payload>» (как Telegram); «Начать» у клиентских MAX-ботов
теперь тоже открывает диалог
- профиль: одна кнопка «Привязать бота» (код выдаётся заранее, клик =
переход по диплинку) + чекбоксы типов уведомлений (PATCH pushTypes)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- MessengerBinding/MessengerBindingCode: привязка сотрудника к боту по
одноразовому коду (deep-link ?start= для TG и MAX, TTL 10 мин)
- сервисный бот = messenger-интеграция с config.purpose=notifications,
без канала продаж; worker поллит его отдельно (только коды привязки)
- доставка через outbox: notify() -> notification_created -> handler
разворачивает аудиторию и шлёт привязанным сотрудникам (best-effort)
- API messenger-bindings (список/код/отвязка); профиль: карточка
«Уведомления в мессенджер»; форма интеграций: флажок бота уведомлений
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Клиенты → Контакты: сайдбар, крошки, роуты, тексты detail-страницы
- таблица: колонка Статус (Лид/Клиент — из оплаченных заказов), реальные
телефон и @логин вместо заглушек, фильтры Лиды/Клиенты, поиск по
имени/телефону/логину, реальные счётчики вместо фейковых 248
- сайдбар компании: группа «Инструменты» с неактивной «Доской»
- fix: бейдж «Диалоги» считает только очередь (PAUSED) — взятые оператором
диалоги без ответа больше не подсвечиваются
- fix: состояние «Контакт запрошен» сбрасывается при смене диалога
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- username из TG/MAX сохраняется в ConnectionIdentity и виден во вкладке «Клиент»
- кнопка «Запросить контакт»: TG/MAX — кнопка «Поделиться контактом» в чате бота,
Web — форма телефона с маской в виджете; телефон пишется в Contact.phone
- приём контакта без AI-хода: сообщение kind=contact + подтверждение
(в TG со снятием reply-клавиатуры)
- POST /conversations/<id>/request-contact/ и POST /webchat/contact/
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Знания: плоская сущность Knowledge (заголовок, описание, MD) + файловые
вложения с оригинальными именами (retrieval из md/txt/pdf/docx, публичная
ссылка для клиента). Агент: без релизов, инструкции из трёх частей
(персонализация/тон/инструкции) + выбор знаний из библиотеки; лимит
диалогов упразднён (остался dailyCostUsd). channel.system_prompt/model
удалены — AI-поведение только на агенте. Продукт — техническая запись
без summary/sales_description. Data-миграция переносит опубликованные
документы и активные релизы в новую модель. Internal-UI: раздел Знания,
новая карточка агента, упрощённые продукты, release-страницы удалены.
ADR-0005/0007/0017 → superseded; SPEC-HUB-0012 переписан.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Деплой падал на seed_catalog → Price.objects.update_or_create(... version=1)
реактивировал архивную версию цены, нарушая констрейнт uniq_active_price_offer.
Причина: цену оффера меняли через UI (создана новая версия), а seed при каждом
деплое пытался вернуть version=1 с is_active=True. На проде seed выполнялся
13 раз — команда跑了 на развёрнутой установке.
seed_hub_initial_data теперь no-op, если уже есть OWNER (маркер первичного
bootstrap). На новой БД сеет как раньше; на развёрнутой — skip, не трогая
данные, изменённые через UI/API (каталог, цены, AI-контент).
Тест: test_seed_skipped_when_owner_exists покрывает guard.
Лимиты агента раньше хранились вразнобой (dailyCostMicros/dailyBudgetRub/
dailyDialogs/maxMessagesPerDialog), из них бэкенд читал только dailyCostMicros.
Из-за путаницы валют на агенте FoxRay стоял лимит dailyCostMicros:100 (= \$0.0001),
который блокировал ответы ИИ — 2 сообщения зависли без ответа.
- limits.py: ключ dailyCostUsd (целые центы USD), сравнение с расходом в micros.
У агента FoxRay ключа нет → лимит больше не блокирует ответы.
- services.py: _normalize_limits отбрасывает устаревшие ключи при сохранении.
- ingest.py: LimitExceeded теперь ловится как ProviderError — диалог передаётся
оператору с fallback вместо зависания (раньше исключение пробрасывалось до poller).
- AgentEditForm: одно поле «Бюджет в день, \$» вместо четырёх разнородных.
- release/model.ts: удалён мёртвый limitLabel; расходы уже везде в долларах.
- tests: обновлены под dailyCostUsd, добавлен тест на LimitExceeded в ingest.
OpenRouter (LLM+embeddings), MAX, Telegram заблокированы из РФ (403/недоступно).
Мессенджер-транспорт уже поддерживал config["proxy_url"], но OpenRouter и кнопка
«Проверить» прокси не использовали. Теперь единый механизм для всех интеграций.
- openrouter.py: __init__ принимает proxy_url; _post через opener с ProxyHandler
(по образцу transports/base.py). chat и embed идут через прокси.
- factory.py: proxy_url из config интеграции передаётся в OpenRouterProvider.
- checks.py: _get принимает proxy_url (ProxyHandler); check_openrouter/max/telegram
пробрасывают его — кнопка «Проверить» гоняет реальный запрос через прокси.
- services.py: _normalized_config сохраняет proxy_url (для всех провайдеров);
test_integration передаёт proxy_url в check.
- serializers.py: отдаёт proxyUrl.
- integrations/tests.py: persist/clear proxy_url, serializer proxyUrl, проверка
ProxyHandler в check_openrouter и OpenRouterProvider (mock build_opener).
Формат proxy_url: http://user:pass@host:port (auth в URL, stdlib-конвенция).
Только HTTP/HTTPS — SOCKS5 потребует PySocks (отдельная задача). WEB-виджет
клиентский — прокси не нужен.
_import_one возвращал неверные счётчики: новый документ давал created=1 и
updated=1 (лишний updated), а существующий без изменений не учитывался в
unchanged. Из-за этого падали test_creates_new_documents_and_publishes и
test_reimport_same_content_is_unchanged.
Разделены три состояния: новый → created=1; существующий без изменений →
unchanged=1; существующий с изменённым контентом → updated=1. Создание версии
вынесено в _publish_version.
Проверено: 43 теста hub_platform.ai зелёные.
У канала не было endpoint редактирования вообще (только GET list + POST
test-chat) — владелец не мог изменить название канала.
- channels/services.py: update_channel (минимум — name; code не трогаем,
смена сломала бы embed-сниппеты data-channel и URL).
- channels/views.py: ChannelDetailView.patch — org-scoped lookup,
валидация пустого имени, audit channels.channel_renamed.
- channels/urls.py: route channels/<id>/.
- channels/tests.py: переименование, пустое имя → 400, чужая орга → 404,
operator → 403.
SPEC-HUB-0010 §7.3: для production настроить CSP frame-ancestors для /chat/
(раздаётся vite/nginx, не Django — настраивается в infra/deploy), разрешив домены
продуктов Edevs. Только origin недостаточен — support-виджет дополнительно
проверяется signed Product Support Token. Домены — у владельца.
SPEC-HUB-0010 §7: виджет поддержки polling/send. Support-сессия не создаёт
WebSession (стартует по токену), а webchat/messages_payload ищет по contact
(support contact=null) — поэтому отдельные support-specific endpoints.
- widget_credential.py: stateless HMAC-signed credential {conversation_id,
snapshot_id, exp}, TTL 1ч. Poll/send авторизуются им, не Product Support Token.
- messages.py: support_messages_since (по conversation, без contact-lookup) +
post_support_message (Message(CONTACT) + AI-путь run_channel_turn/handoff,
без Contact-creation и transports.send_reply — ответ идёт через polling).
- session.start_support_session: issue widget-credential, отдаётся в ответе
sessions/ (widgetCredential).
- views: SupportSessionMessagesView GET/POST (Bearer widget-credential).
- urls: sessions/messages/.
- Тесты: start→credential, send→poll возвращает, invalid/no-credential→401,
empty→400.
SPEC-HUB-0010 §8.3 + ADR-HUB-0022: правая панель оператора рендерится по
operator_cards из Product Support Identity Contract, не хардкод.
- backend: conversation_payload в detail-режиме отдаёт operatorContextJson +
accountKey в supportIdentitySnapshot (один запрос, без доп. polling).
- OperatorCards: renderer operator_cards[] из snapshot (field-renderer по type:
text/email/phone/url/code/badge/datetime/boolean/number; unknown→text; null→—).
- SupportHistory: история прошлых обращений по subject_key (detail.history уже
группируется по snapshot на backend).
- SupportContextPanel: табы Клиент (OperatorCards) / История (SupportHistory),
зеркало SalesContextPanel по структуре, контент по контракту.
conversation_payload для support-диалогов дёргает support_identity_snapshot FK
на каждую строку списка inbox → N+1. Добавлен select_related в
conversations_for_organization. Для sales (snapshot=null) безвредно.
В состав отдела (card meta) вернул AI-агентов, как в исходной sales-карточке:
«N сотрудник(ов) · M операторов · K AI-агент(ов)».
- Backend: _department_payload отдаёт agentCount — реальное число AIAgent на
каналах отдела (channel.department, ADR-HUB-0019).
- Frontend: тип Department.agentCount; обе карточки (sales + support).
SPEC-HUB-0010 §8.1/§10: изоляция inbox по отделу (sales operator не видит
support, и наоборот; OWNER видит всё).
- identity/permissions.py: can_access_support_workspace, is_sales_operator,
is_support_operator, _operator_department_code. TODO §8.1 (multi-department
operator) отмечен в коде — одиночный FK department покрывает §10, но не §8.1.
- conversations ConversationListView: фильтр по channel.department.code для
OPERATOR (свой отдел); ?department=sales|support для явного выбора (OWNER).
SPEC-HUB-0010 §4.1/§4.2, SPEC-HUB-0011 §5.2.
- bootstrap_edevs_owner: создаёт отдел support (BootstrapResult.support_department).
- seed_hub_initial_data: support-каналы foxray-support/firepage-support с политикой
requires_auth=True, anonymous/sales/checkout=False; CHANNEL_SPECS вынесены в
_seed_specs.py (NO GOD FILES: команда 226 строк).
- support/seed_support.py: контракт foxray.support.v1 (ACTIVE, пример из SPEC §5.2),
привязка к каналу через allowed_channels; генерация support_token_secret для
продуктов (EncryptedCharField, не печатается/не логируется).
- Обновлены существующие тесты: departments теперь {sales, support}.
ADR-HUB-0022: sales Contact и Support Identity разделены. Conversation теперь
содержит ровно один источник identity (XOR): nullable sales Contact ИЛИ nullable
FK support_identity_snapshot.
- Conversation.contact → nullable (существующие sales-диалоги валидны).
- Conversation.support_identity_snapshot — nullable FK (PROTECT).
- CheckConstraint conversation_exactly_one_identity.
- conversation_payload: contact опционален, добавлен supportIdentitySnapshot
(краткая карточка); история выбирается по тому же источнику identity.
- stats.py: safe fallback имени клиента (snapshot.display_name) для support-диалогов.
- clients.py остаётся sales-специфичным (строится от Contact — корректно).
SPEC-HUB-0010 §4.2 / SPEC-HUB-0011 §4.3.
- Channel: флаги политики (requires_authenticated_product_identity,
allow_anonymous_sessions, allow_self_reported_contact, allow_sales_attribution,
allow_checkout_actions). Значения по умолчанию сохраняют поведение sales-каналов.
- Product.support_token_secret: EncryptedCharField (Fernet at rest) — HS256-секрет
проверки Product Support Token. В API не отдаётся (product_payload не включает).
- channel_payload отдаёт флаги политики (camelCase) для UI.
Новый bounded-context app hub_platform.support (ADR-HUB-0022, SPEC-HUB-0011).
- ProductSupportContract: версионированный контракт идентификации клиента
поддержки (code <product>.support.v<major>, статусы DRAFT/ACTIVE/DEPRECATED/
DISABLED, schema/mapping/operator_ui/ai_context/search/sensitive JSON).
- SupportIdentitySnapshot: проверенный снимок identity (subject_key обязателен,
raw token не хранится — только хэш jti; исторический снимок диалога).
- Регистрация app в INSTALLED_APPS и префикс /api/v1/support/ (endpoints далее).
- clean() проверяет формат code, версию и принадлежность продукта организации.
Orders originate in the product backends (FoxRay/FirePage); Hub records
them. Added an authenticated ingest webhook instead of manual order entry:
- POST /api/v1/orders/ingest/ (AllowAny + product-token auth via
X-Product-Token; token stored only as SHA-256). Idempotent by
(organization, source=product.code, externalId). Resolves the contact
from conversationId when given, else creates one from contactName;
resolves items by offerCode within the product; PAID stamps paid_at +
fulfillment PENDING. Audited (orders.ingested / ingest_duplicate).
- Order gains source/external_id with a partial-unique constraint;
Product gains ingest_token_hash. Migrations included.
- issue_product_ingest_token <code> management command generates + prints
the token once (user-run; rotates on re-run).
Tests: 10 orders tests (incl. ingest auth + idempotency); full suite 95/95.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New orders app (ADR-HUB-0018: catalog & sale recorded in Hub, execution
in product backends):
- Order + OrderItem models with payment/fulfillment status; items snapshot
offer + price version so the record is stable. Migration included.
- Services (create_order, mark_paid, cancel_order, set_fulfillment),
selectors, serializers, REST API (list/create/detail + status actions)
under IsAuthenticated/IsOwner, org-scoped. Tests: 6 green.
- seed_orders management command for local demo (idempotent).
Wired real numbers into the spots that were "—":
- Sales overview: Продажи / Выручка / Конверсия + per-product sales/revenue.
- Clients list: orders count and purchases total per contact.
- Client detail: Заказы tab + summary (orders, purchases).
Full backend suite: 91/91.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The client card was a single hardcoded mock. It now loads a real contact
by id and renders real data:
- Backend: conversations/clients.py client_detail() returns the contact's
channels/products, dialog list, channel identities, lifecycle-derived
activity, and real audit events (AuditEvent for the contact's
conversations). Served at /api/v1/conversations/clients/<id>/.
- Routing: clients list passes the contact id; App/Shell thread
selectedClientId through to the detail page, which fetches by id.
- UI: header/overview/dialogs/identities/audit show real data; orders and
consent have no domain in Hub, so they render honest empty states (no
fabricated orders, revenue, or consent log). Removed the fake
"Согласие получено" header badge.
Backend suite 85/85; typecheck green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Clients section rendered a hardcoded mock array. Now it lists real
contacts with their conversations:
- Backend: conversations/clients.py aggregates each Contact into channels
used (MAX/TG/WEB), products (via channel.product), open-dialog count,
last activity and current mode (ai/operator/wait/closed). Served at
/api/v1/conversations/clients/ (IsAuthenticated, org-scoped).
- UI: useClientsData fetches; useSalesClients now takes the data as input
(filters/sort unchanged); the page shows loading/empty states.
Avatar initials/colour and relative time are derived client-side.
Orders/revenue have no domain in Hub, so those columns stay 0/"—" — no
fabricated numbers. The client detail page is still mock (separate task).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
hub_platform.ai tests still used the pre-refactor product-anchored API
(AIAgent.product, invoke_chat(product=...), ProductAIRelease, product on
doc/release create), so 4 failed + 18 errored. Rewritten against the
current channel anchoring (ADR-HUB-0019):
- helper seeds channels + agents (bootstrap no longer does — seed_channels
owns that in prod); agents/releases/invocations/retrieval/test-chat are
all channel-scoped now.
- knowledge/prompt create uses scope (GLOBAL default; PRODUCT for the
release/retrieval fixtures); foreign-product rejection uses scope=PRODUCT.
Also fixed two stale identity tests that asserted the old TOTP-on default:
bootstrap now sets totp_required=False by design, and the setup→confirm
flow opts the profile into TOTP first.
Full backend suite: 85/85 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AI cost was always 0 because the pricing table had no entry for the
channel model. Now:
- OpenRouter chat requests ask for usage accounting (usage.include=true)
and capture the provider-reported usage.cost into ChatResult.cost_micros.
- invocation recording uses the provider cost when present, falling back
to the local price table otherwise.
- Added Anthropic Sonnet to the fallback table.
Verified live: a real channel turn recorded cost_micros=11688 (~$0.012)
and the sales overview "Стоимость AI" now reflects it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>