Единая 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>
При включённом VPN у клиента прямое P2P не устанавливается и звонок обязан
идти через TURN-relay, но coturn отдавал только turn: на UDP/TCP 3478 — а VPN
режет UDP и нестандартные порты. Добавлен turns:443 (TLS маскируется под HTTPS
и проходит строгие сети). coturn слушает выделенный публичный IP (3478 + TLS
443) с монтированием LE-сертификата; env-пример дополнен HUB_TURN_LISTENING_IP,
HUB_TURN_TLS_PORT и требованиями к облачному firewall.
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>
- Move pack.bat to repository root
- Ignore CustoCRM.zip produced by pack.bat
- Minor doc edits in ADRs, specs, config and plan
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>
Прошлый padding-bottom двигал буквы вверх — направление было неверным. По
факту flex ставит baseline в центр круга, а заглавные без нижних выносных
сидят на baseline и уходят вверх на ~0.37em. Сдвиг вниз padding-top:0.75em
(замерено попиксельно по факту рендера: big +0.25px, small -0.25px).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- поток собеседника монтируется всё время активного звонка, а не только при
включённой камере — иначе в аудиозвонке (камера выключена) не было звука
- :fullscreen раскладывает карточку колонкой (media flex:1) — нижняя панель
кнопок больше не уходит за край экрана
- кнопка полного экрана стала тумблером; крестик в полноэкранном режиме
сначала выходит из fullscreen, а не завершает звонок
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>
- .hub-call-avatar: padding-bottom 0.1em компенсирует ассиметрию метрик
Segoe UI (ink уходил на ~1.5px ниже центра круга при flex-центрировании)
- @media <=480px: клиентская вьюха звонка разворачивается на весь экран
(height 100dvh, media flex:1, aspect-ratio снят) вместо squat 16:9
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.
Удалён фронтенд-only статический прототип тестового чата (features/ai/test-chat/)
и все точки его подключения: роут aiTestChat, таб поднавигации, кнопки в заголовках
AI-агента/release и таблице агентов, CSS-классы. Мёртвая ветка логики статусов релиза
(параметр tested) убрана, тексты про тест-чат заменены на нейтральные.
Baseline-прототип Тестовый чат.dc.html удалён; упразднение AI-04 зафиксировано
в SPEC-HUB-0001, SPEC-HUB-0004, SPEC-HUB-0005.
- model.ts: config.proxyUrl в типе Integration.
- IntegrationForm.tsx: стейт proxyUrl, поле «Прокси» (placeholder
http://user:pass@host:port) для всех провайдеров кроме WEB (виджет клиентский),
proxyUrl в payload create/update.
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 зелёные.
Владелец не мог изменить ни параметры агента (имя/модель/инструменты/лимиты),
ни название канала — UI отсутствовал, хотя backend-эндпоинт агента уже работал.
Из-за пустых allowed_tools/limits релиз нельзя было опубликовать → правки
знаний/инструкций не вступали в силу.
- AgentEditForm.tsx: модалка name/model/инструменты(чекбоксы)/лимиты(числовые
поля) → PATCH /api/v1/ai/agents/<id>/update/ (endpoint уже существует).
- ChannelEditForm.tsx: модалка переименования канала → PATCH /api/v1/channels/<id>/.
- AiAgentOverviewTab.tsx: кнопка «Изменить агента» (была disabled-заглушкой).
- AiAgentDetailHeader.tsx: клик по имени канала открывает форму переименования.
- AiAgentDetailPage.tsx: стейт и подключение обеих форм.
- styles.css: стили .ai-edit-* для инструментов/лимитов.
- release/model.ts: убран чек #5 «Sales behavior» (искал подстроку «sales» в
коде промпта, не в категории — блокировал публикацию). Остаются 4 проверки.
- release/model.test.ts: публикация без sales-промпта; блокировка без tools/limits.
Цепочка: владелец задаёт инструменты/лимиты → новый черновик релиза наследует
их → canPublishRelease проходит → публикация → правки знаний/инструкций
вступают в силу.
У канала не было 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.
VITE_PUBLIC_HUB_URL — build-time переменная Vite (инлайнится в бандл при сборке
образа), в runtime контейнера бесполезна. Прокидывается на этапе сборки образа
frontend в CI.
- deploy/docker/frontend.Dockerfile: ARG/ENV VITE_PUBLIC_HUB_URL по образцу
VITE_API_BASE_URL.
- .gitlab-ci.yml (images:build): --build-arg VITE_PUBLIC_HUB_URL с fallback
https://hub.edevs.tech (если CI-переменная не задана — сниппет не пустой).
Значение можно переопределить CI/CD Variable VITE_PUBLIC_HUB_URL в GitLab.
Локальный dev: добавить VITE_PUBLIC_HUB_URL в gitignored .env (рядом с .env.example).