Commit Graph
378 Commits
Author SHA1 Message Date
AndreyandClaude Opus 5 3e62ce1ed5 🔧 fix(repo): поправить комментарий в .gitattributes
В комментарий попал настоящий возврат каретки, из-за чего git читал хвост
строки как отдельный шаблон и ругался «такой is not a valid attribute name».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 07:01:37 +03:00
AndreyandClaude Opus 5 bd40ff38fe 🔧 fix(repo): переводы строк в .py — только LF
21 файл доехал до репозитория с `\r\r\n` внутри: строка кончается двумя
возвратами каретки и переводом строки. Python такое читает, поэтому оно и
дожило, но линуксовые инструменты спотыкаются — ruff принимает одиночный CR за
перевод строки и пишет его в вывод, портя файл дальше.

Содержимое не менялось: `git diff -w` по этим файлам пуст, различаются только
переводы строк. Правило `*.py text eol=lf` в .gitattributes закрывает повтор —
на Windows такие файлы теперь и выкладываются с LF.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 07:00:53 +03:00
AndreyandClaude Opus 5 87a4359b04 🗑️ chore(audit): убрать временный чек-лист
Файл существовал только пока в нём был хотя бы один незакрытый пункт. Всё,
что должно жить дальше, уже описано в README, в коде и в тестах; сам чек-лист
в его финальном виде остаётся в истории — предыдущий коммит.

Осталась одна проверка, которую не сделать из репозитория: пройти установку на
настоящем домене с живым выпуском сертификата Let's Encrypt. Она вынесена в
описание PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 06:40:06 +03:00
AndreyandClaude Opus 5 00e4501a50 📝 docs(audit): чек-лист аудита закрыт целиком
739 passed в бэкенд-сьюте, e2e и фронтовые проверки зелёные, установка в
каталоге с одним compose.yaml поднимается одной командой и отвечает через
шлюз. Открытых пунктов не осталось — файл удаляется следующим коммитом, как в
нём и записано.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 06:39:55 +03:00
AndreyandClaude Opus 5 f74d242988 ✅ fix(tests): признак «мастер пройден» не кэшируется в тестах
Кэш живёт на весь процесс — в проде это ровно то, что нужно: после установки
признак больше не меняется, а до неё его спрашивает каждый запрос. Но в
тестах каждый тест начинается с пустой базы, и первый же из них, который
создал организацию и постучался с нелокальным Host, закрывал мастер всем
остальным в том же процессе: тесты мастера падали с «Invalid host» в
зависимости от порядка сбора.

Заодно README описывает переход на TLS так, как он теперь работает: домен
задаётся в «Настройках», шлюз выписывает на него сертификат сам, прежний
адрес остаётся принятым.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 06:12:01 +03:00
AndreyandClaude Opus 5 cc03c5fa3d 🔒 fix(security): границы, которые обходились через соседний эндпоинт
Разбор аудита, блоки B, C и D. Каждая правка — с тестом.

Критичное:
- 2FA снималась без пароля: POST /auth/profile/totp/start/ выключал уже
  включённую 2FA, минуя и текущий пароль, и требование организации, которые
  спрашивает соседний /disable/. Теперь 409, если 2FA включена.
- Сброс пароля по письму оставлял чужие сессии живыми — то есть не помогал
  ровно в том случае, ради которого пароль и сбрасывают. Сессии завершаются,
  как при смене пароля из профиля.
- HTTPS на домене установки не выпускался никогда: ask-эндпоинт шлюза знал
  только домены порталов. Теперь он признаёт и адрес самой установки.
- WebSocket молча не работал на любой установке с TLS: браузер держит
  __Host-cookie, а Channels ищет сессию по обычному имени, и HTTP-middleware
  на хендшейк не выполняется. Добавлен chatballs.http.ws_middleware.

Существенное:
- Повтор входящего сообщения ронял весь цикл поллинга: IntegrityError ловился
  без точки сохранения внутри чужой транзакции.
- Смена адреса в «Настройках» выбрасывала того, кто её делает. Прежний адрес
  остаётся принятым (identity.0033).
- Редирект уводил скачивание во внутреннюю сеть: политика исходящих проверяла
  только исходный адрес. Проверка висит на каждом Location.
- Портал помощи можно было повесить на адрес установки и подменить
  сотрудникам приложение своим Help Center.
- Пароль прокси уходил в ответ API целиком; теперь маскируется, а маска при
  сохранении возвращает сохранённый пароль.
- /api/v1/health/ready/ закрыт на публичной границе.

Мелочи: адрес для A-записи портала считается от адреса установки, а не от
127.0.0.1; колонка EncryptedCharField вмещает шифротекст, а не открытое
значение; WS-маршруты проверяют Origin; мёртвый require_organization_scope
убран.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 05:35:16 +03:00
AndreyandClaude Opus 5 8fe66385d6 ✨ feat(release): установка на чистый хост одной командой
Раньше compose монтировал с хоста Caddyfile, init-скрипты базы и генератор
секретов. Из-за этого установка требовала рядом распакованный репозиторий, а
файл, забытый при сборке релиза, Docker молча подменял пустым каталогом — и
стек падал на первом старте у человека. Плюс production-образ backend вообще
не собирался: COPY content ссылался на каталог, которого в репозитории нет,
так что релиза не существовало ни на GitHub, ни на GitLab.

Теперь весь дистрибутив — один compose.yaml со страницы релиза:

  curl -fsSL .../compose.yaml -o compose.yaml
  docker compose up -d --wait

- Caddyfile переехал в свой образ шлюза (caddy validate — в сборке),
  init-скрипты базы — в свой образ postgres, генератор секретов — в
  backend-образ. Bind-mount'ов в production-манифесте не осталось.
- Состояние установки — именованные тома вместо каталогов рабочего каталога.
  Заодно чинит загрузку файлов на Linux: том наследует владельца из образа
  (hub), тогда как bind-mount доставался контейнеру как root:root.
- scripts/pin-release-compose.py закрепляет ссылки на образы по digest и
  падает, если хоть один ключ остался подстановкой.
- GitHub-workflow собирает четыре образа и прикладывает к релизу compose.yaml
  (основной путь) и release.env (для `chatballs deploy`).
- tests/cli/test_release_compose.py держит свойство: манифест без bind-mount'ов
  и полностью закрепляем по digest.
- Из окружения шлюза убраны CHATBALLS_APP_DOMAIN и CHATBALLS_ACME_EMAIL —
  Caddyfile их не читает.

Dev-контур не меняется по смыслу: compose.dev.yaml по-прежнему собирает всё
из исходников и держит состояние в ./data.

AUDIT-TODO.md — временный список остального из аудита; удаляется целиком,
когда закрыт последний пункт.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 04:55:17 +03:00
Andrey a141cd4f50 Merge branch 'fix/calls-audio-layout-and-lifecycle' into main 2026-09-09 04:02:52 +03:00
AndreyandClaude Opus 5 77552dfad4 ♻️ refactor(calls): статус видеозвонка считается без мнимой мемоизации
useMemo держал в зависимостях весь объект `props`, новый на каждом рендере, —
мемоизация не срабатывала ни разу. Сделать её настоящей нельзя без стабилизации
`onRetry` и `onClose` выше по дереву, а смысла в этом нет: buildStatus дёшев, а
идентичность результата никому не нужна — CallView не обёрнут в React.memo.
Считаем на месте, как это уже сделано в AudioCallOverlay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 04:00:14 +03:00
AndreyandClaude Opus 5 48003f9bab 🐛 fix(calls): устройства отпускаются после отбоя, экран звонка на токенах
Утечка камеры и микрофона у оператора:
- терминальный статус приходит и поллингом состояния, а не только по RTC-сокету
  (до «Присоединиться» сокет ещё не открыт), поэтому `onCallState` → `stop()` не
  срабатывал. Экран уходил в `status`, а оба выхода с него RTC не глушили:
  `endAndClose` для этого режима пропускает `finish()`, а «Закрыть» зовёт
  `close()`, который только прячет модалку. Оверлей при этом остаётся
  смонтированным, так что и cleanup хука не отрабатывал — камера оператора
  горела до переключения диалога. Оба оверлея явно освобождают устройства,
  когда оверлей закрыт или звонок завершён.

call-view.css переведён на токены дизайн-системы:
- радиус карточки 24px → `--radius-4xl` (14px), тень → `--shadow-lg`,
  рамки → `--border-default`, второстепенный текст → `--text-secondary`,
  круглые кнопки → `--n-7`/`--n-9`/`--n-3`, выключенные → `--error-*`,
  таймер → `--error-bg`/`--error-text` + 12.5px и моноширинный шрифт,
  таймер переподключения → `--warning-text` и точка `--warning`,
  кнопки precall → `--radius-lg` (9px), 14px и `--shadow-primary`;
- радиус контейнера модалки взят из того же токена: снаружи виден именно он;
- хардкод оставлен там, где он есть и в макете — сцена видео и слои поверх неё
  от темы не зависят.

Побочный эффект: карточка звонка у оператора теперь следует тёмной теме
internal-ui, как остальные поверхности. Проверено в обеих темах.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 03:57:32 +03:00
AndreyandClaude Opus 5 3461556446 🐛 fix(calls): окно аудиозвонка держит пропорцию и переживает обрыв
Вёрстка:
- мобильная сцена брала высоту как `height:100%` от родителя, у которого задан
  только `min-height` — процент схлопывался в высоту по контенту, и окно выходило
  412x427 вместо 412x915. Высота берётся от вьюпорта (vh/dvh), как у видеозвонка;
- на низком десктопном окне карточка 9:16 не влезала и кнопки приёма уезжали за
  фолд — ширина зажата по высоте вьюпорта, пропорция остаётся ровно 9:16;
- операторской модалке нужен `height:auto`, иначе мобильная ветка растягивала
  сцену на весь экран внутри antd Modal;
- пустая «пилюля» канала больше не рисуется без метки.

Жизненный цикл:
- токен из хеша сохраняется в sessionStorage: хеш срезается replaceState, а в
  пути лежит callId, поэтому после F5 звонок было не восстановить;
- обрабатывается CONNECTED от собеседника — UI выходит из «Переподключения»,
  когда обрыв был на его стороне;
- у режима reconnecting появился нижний бар: экран был без единой кнопки;
- клиенту не показывается «Позвонить снова», которого он не может сделать; на
  DECLINED/MISSED/EXPIRED это была единственная кнопка, и уйти было некуда;
- «Повторить» на упавшем соединении рестартует RTC, а не перепроверяет микрофон.

Чистка:
- TERMINAL_CALL_STATUSES стал единственным источником вместо трёх копий набора;
- волна в тишине спокойная: амплитуда растёт от idle к speak по громкости;
- сигналинг и track.enabled вынесены из updater'а setState.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 03:35:20 +03:00
AndreyandClaude Opus 5 ee9cbf0995 ⚡ perf(realtime): всплеск событий приводит к одному обновлению
В оживлённой организации поток событий плотнее прежнего опроса: пять сообщений
подряд дали бы пять перезапросов списка вместо одного за четыре секунды.
События схлопываются в окно 700 мс — обновление остаётся мгновенным для
человека и не превращается в шквал запросов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 02:34:39 +03:00
AndreyandClaude Opus 5 03825dfbd5 ⚡ perf(realtime): обновление по событию вместо опроса
Клиент опрашивал сервер: список раз в четыре секунды, карточку и дельту
истории — раз в три; на оператора выходило под сорок запросов в минуту, и новое
сообщение всё равно появлялось с задержкой.

- WebSocket-канал организации (channels уже стоял ради сигналинга звонков):
  аутентификация — сессией того же SPA, организация — в адресе, как в HTTP;
- событие несёт только повод обновиться, данные клиент забирает обычным
  запросом: проверка видимости остаётся в одном месте, и канал не может в ней
  ошибиться. Событие инбокса не содержит идентификаторов — сотрудник видит не
  все диалоги организации; на события диалога подписка отдельная, и сервер
  проверяет видимость перед ней;
- события шлют те же сигналы, что держат свежесть диалога: одно место на семь
  мест создания сообщений и на все изменения состояния;
- опрос остался запасным путём и замедляется до тридцати секунд, пока канал
  жив: при обрыве всё возвращается к прежнему поведению само.

Проверено сквозь шлюз: апгрейд проходит до консьюмера, анонимное соединение
отклоняется кодом 403.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 02:33:28 +03:00
AndreyandClaude Opus 5 f3cbf1658c ⚡ perf(db): порядок инбокса по индексу, страницы без N+1
Пагинация была правильной, но опиралась на то, что не ложится в индекс: список
диалогов сортировался по агрегату max(messages.created_at) — GROUP BY по всей
ленте на каждый запрос, — а превью строки и бейдж непрочитанных спрашивались
по два запроса на диалог, тридцать раз на страницу, каждые четыре секунды.

- Conversation.last_message_at: денормализованное поле вместо агрегата, держит
  сигнал на создание сообщения (одно место вместо семи), миграция заполняет
  существующие диалоги;
- индексы: conv_inbox_order (организация → свежесть) и conv_message_window
  (диалог → время → id) — под окно инбокса и окно истории; conv_contact_recent
  под подзапросы списка контактов; триграммы для поиска по имени и телефону;
- превью и бейдж считаются на страницу целиком (last_messages_for,
  pending_counts_for), а не построчно;
- список сотрудников: своя сортировка групп в payload отменяла prefetch, а
  организация тянулась на каждую строку — обе связи взяты в выборке;
- test_query_budget: число запросов сравнивается на разном объёме данных, так
  что вернуть N+1 незаметно больше нельзя;
- журнал аудита переведён на общий usePagedResource — он был последним списком
  со своей копией отложенного поиска и защиты от гонок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 02:12:34 +03:00
AndreyandClaude Opus 5 ed78617722 ✨ feat(pickers): поиск в выборе ответственного и передаче владения
Выборы работали списком без поиска: при большой команде найти человека в них
было нельзя, а справочник коллег отдавался целиком.

- /conversations/directory/ отдаёт ограниченную выдачу, ищет по имени и почте
  и сообщает hasMoreEmployees;
- выбор ответственного берёт справочник сам (сквозной проп employees убран из
  ChatPage и карточки контакта) и показывает строку поиска, когда коллег
  больше, чем помещается в выдачу;
- передача владения ищет кандидатов по имени, почте и должности; строка поиска
  появляется, только если администраторов больше страницы;
- в маленькой команде оба выбора выглядят как прежде — строка поиска не
  появляется.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 01:39:11 +03:00
AndreyandClaude Opus 5 776bc89514 ⚡ perf(knowledge): библиотека знаний — страница и фильтры на сервере
Список знаний отдавался целиком, ветку категорий и фильтр по агенту отбирал
браузер, а число прикреплённых агентов считалось перебором всех карточек
агентов организации — из-за этого приходилось грузить и их.

- список знаний отдаётся страницей; ветка категорий, агент, состояние и поиск
  применяются до среза;
- счётчики знания считаются подзапросами: фильтр по агенту идёт по той же
  связи и больше не урезает «прикреплено к N агентам»;
- материал несёт agentIds, карточка — список агентов с состоянием AI: диалогу
  прикрепления и рейке карточки больше не нужен весь набор агентов;
- /agents/directory/ — компактный справочник для выпадающих выборов с именем,
  состоянием AI и числом материалов;
- четвёртый подвал со страницами (KnowledgeTableFooter) заменён общим;
- подписи «Показано N из M» в подвалах сотрудников и агентов убраны: таких
  строк нет в макетах, а счётчик уже стоит в шапке экрана.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 01:30:02 +03:00
AndreyandClaude Opus 5 e6a6bf77b6 ⚡ perf(ui): списки отдаются страницами, фильтры считает база
Пагинация в порталах и контактах была нарисованной: сервер отдавал весь набор,
браузер резал его на страницы. У сотрудников и агентов не было и этого. Любой
из четырёх списков рос вместе с организацией и целиком уезжал клиенту.

Серверная часть — страницы и фильтры до среза:
- контакты: подзапросы вместо join-агрегатов (фильтр по каналу больше не
  урезает счётчики диалогов), поиск, каналы, агенты, «с открытым диалогом»
  и порядок — в SQL;
- сотрудники: роль, группа и поиск по имени, почте и должности;
- агенты: группа и поиск;
- порталы: статус и поиск, архивные последними;
- библиотека статей: категория с вложенными, язык, статус и поиск по последней
  редакции.

Клиент:
- один подвал со страницами на всё приложение вместо трёх разных
  (PortalTableFooter, SalesClientsPagination, TablePagination удалены);
- usePagedResource: страница принадлежит набору фильтров, гонки ответов
  отсекаются, сервер решает, какая страница существует;
- useDebounced вынесен в shared — поиск придерживает запрос;
- карточка сотрудника грузится по идентификатору, а передача владения сама
  запрашивает кандидатов: список постраничный, и нужного человека может не
  быть на открытой странице;
- App больше не тянет всех сотрудников на старте.

Выпадающие выборы (ответственный, передача владения, фильтр по агентам)
работают со справочниками; библиотеке знаний нужен серверный контракт со
счётчиками прикреплений — до него потолок в 100 карточек оставлен явным.

Контракт списков и лент записан в SPEC-CHATBALLS-0031 §8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:05:02 +03:00
AndreyandClaude Opus 5 0636ef5fa9 ⚡ perf(conversations): история и инбокс грузятся окнами, а не целиком
Открытие диалога тянуло все сообщения разом: карточка несла messages без
ограничения, а workspace перезапрашивал её каждые 3 секунды — тысяча реплик
уезжала клиенту двадцать раз в минуту. Список диалогов приходил так же целиком
и обновлялся раз в 4 секунды.

Серверная часть:
- api/pagination: единый контракт — страницы (page/pageSize/total/pageCount) и
  окно по курсору (keyset) с потолками размера;
- карточка диалога больше не несёт сообщений ни в одном режиме; факт запроса
  контакта считает сервер, ленте для этого история не нужна;
- GET /conversations/<id>/messages/ — окно истории: before — вверх по ленте,
  after — дельта после последнего показанного сообщения;
- инбокс отдаётся окном по курсору с серверной сортировкой (активность или
  «ждущие первыми») и total по текущим фильтрам.

Клиент:
- useConversationHistory: хвост переписки при открытии, догрузка вверх,
  обновление дельтой вместо перезапроса всей ленты;
- useHistoryScroll: лента держит низ, подгружает старые сообщения у верхней
  границы и сохраняет место чтения при вклейке;
- useConversationList: окно инбокса, догрузка прокруткой, обновление головы
  списка без сброса прокрутки;
- поиск, вкладка и сортировка списка переехали в параметры запроса —
  фильтровать окно в браузере было бы неправильно;
- клавиатурная навигация вынесена в useDialogKeyboardNav.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:00:08 +03:00
AndreyandClaude Opus 5 b601a72608 🚚 chore(repo): вернуть выпавшие при переносе корня файлы
При переносе корня в code/chatballs под версионным контролем остались
только CI-файлы старого корня. AGENTS.md, pack.bat, resources/ и
.claude/launch.json физически уцелели, но перестали версионироваться.

- AGENTS.md, pack.bat, resources/ и .claude/launch.json перенесены в корень;
- launch.json: cwd теперь «.», корень репозитория и есть корень приложения;
- pack.bat снова работает — он требует запуска из корня git-репозитория;
- README: после клона нужен «cd chatballs», вложенного пути больше нет;
- .gitignore: Chatballs.zip, архив pack.bat, снова игнорируется.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:51:41 +03:00
AndreyandClaude Opus 5 53129701a2 🚚 chore(repo)!: корень репозитория перенесён в code/chatballs
История переписана git filter-repo: пути code/hub.edevs.tech, code/custocrm
и code/chatballs подняты в корень. Приложение больше не лежит во вложенном
каталоге, поэтому инфраструктура переезжает следом:

- .gitlab-ci.yml, .gitlab/ci/*, .github/workflows/release.yml — APP_DIR
  теперь ".", все пути внутри джоб через него и остаются рабочими;
- .gitattributes — без него core.autocrlf=true отдавал бы .sh и CLI
  chatballs с CRLF, что ломает exec в Linux-контейнерах;
- .gitignore — вернулись правила data/, .env.bak-*, **/staticfiles/,
  жившие в прежнем корневом .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:11:05 +03:00
AndreyandClaude Opus 5 c01bf8aef6 🔥 chore(db): мёртвые поля отменённых доменов удалены из схемы
Колонки пережили решения, которыми были отменены:

- `Organization.tax_regime` и `vat_mode` — налоговый профиль организации,
  отменён вместе с управленческой картиной продаж (ADR-HUB-0010, отменён
  ADR-CHATBALLS-0041). Миграция identity/0032;
- `Channel.allow_sales_attribution` и `allow_checkout_actions` — коммерческие
  флаги канала; ADR-CHATBALLS-0045 §4 объявил их запрещёнными безусловно, но
  колонки остались. Миграция channels/0008.

Вместе с флагами ушёл аппарат инвариантов канала. P1-P2 были их единственным
содержимым, P3-P5 сняты с сущностью Product: после удаления полей
`policy_violations()` всегда возвращала бы пустой кортеж, `PolicyInvariantError`
не могла быть возбуждена никогда, а `require_valid_policy()` стала бы
вызовом-пустышкой. В `channels/policy.py` остались только состав полей и их
отображение в payload для двух живых флагов (`allow_anonymous_sessions`,
`allow_self_reported_contact`).

Удалён `channels/test_policy_migration.py`: он проверял приведение данных
исторической миграции channels/0005 по колонкам, которых больше нет. Отсюда
622 backend-теста вместо 624 — ровно два его метода, покрытие не потеряно.

Из `identity/admin.py` колонки убраны из `list_display`: иначе админка падает
проверкой admin.E108.

Django выполняет `DROP COLUMN ... CASCADE` и молча уносит зависящие вьюхи,
поэтому зависимости проверены до миграции: три вьюхи зависят от таблиц, но ни
одна не читает эти колонки; политик RLS, constraint'ов и индексов на них нет.
После применения все 10 вьюх схемы chatballs на месте, колонок в
information_schema нет.

Проверка: `manage.py check` и `makemigrations --check` чисты, локальная БД
смигрирована, полный check.ps1 зелёный (typecheck ×2, vitest 76, CLI 9, e2e 8,
backend 622).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 20:44:01 +03:00
AndreyandClaude Opus 5 2976856760 ✅ fix(scripts): check.ps1 действительно проверяет, а не делает вид
Скрипт был зелёным всегда. `$ErrorActionPreference = "Stop"` не влияет на код
возврата нативных команд, поэтому упавший pytest проходил как успех: проверено
на живом примере — прежняя схема при команде с кодом 3 доходила до конца и
возвращала 0. Теперь каждый шаг сверяется с $LASTEXITCODE, провалившиеся
перечисляются в конце, скрипт выходит с 1.

Кроме этого:

- явные `-f compose.yaml -f compose.dev.yaml`: сервисы frontend и web-chat
  (node) существуют только в dev-оверлее, в базовом compose frontend —
  production-образ nginx, где нет npm. Голый `docker compose` три шага из
  четырёх выполнить не мог в принципе;
- добавлены CLI-тесты и e2e — раньше их не было ни в одном скрипте;
- порядок от быстрых проверок к длинным: typecheck, vitest, CLI, e2e, backend
  (~20 минут). Дешёвые ошибки всплывают за секунды;
- скрипт не останавливается на первом падении: прогоняет всё и показывает
  список провалов, иначе каждая ошибка стоила бы отдельного круга.

playwright.config.ts: `webServer` поднимал только internal-ui на :5173, а
проект web-chat-mobile ходит на :5175 — без заранее поднятого dev-стека прогон
падал. Описаны оба сервера, e2e стали самодостаточными; без этого включать их
в check.ps1 не имело смысла.

Проверка: полный прогон скрипта — 6 шагов, все зелёные, exit code 0
(typecheck ×2, vitest 76, CLI 9, e2e 8, backend 624).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 20:10:00 +03:00
AndreyandClaude Opus 5 fd7687446c ✅ test(e2e): сценарии internal-ui переписаны под контакт-центр
Пять из шести сценариев проверяли допитовный интерфейс и падали с 2026-09-03:
ждали заголовок «Командный центр», секции сайдбара «Продажи»/«Поддержка»,
маршрут /departments/sales/dialogs, роль OPERATOR, отделы и колонку «ДОСТУП».
Всё это снесено ADR-CHATBALLS-0041 и ADR-CHATBALLS-0043. В check.ps1 e2e не
входят, поэтому падения никто не видел.

Что проверяется теперь:

- владелец после входа попадает в чат и видит ровно семь пунктов навигации
  (SPEC-CHATBALLS-0031 §4), а снесённых понятий — «Командный центр», «Отделы»,
  «Продажи», «Каналы», «Подключения», «Продукты» — в интерфейсе нет; это
  регрессионная страховка вместо прежних ожиданий;
- сотрудник попадает в тот же чат, но пунктов администрирования не получает
  (дерево диалогов тоже лежит в nav.hub-nav, поэтому проверяются именно
  button.hub-nav-item);
- сотрудник на менеджерском маршруте видит 403 — сценарий сохранён;
- организация берётся из адреса: запросы уходят только во вторую организацию,
  хотя членство есть в обеих;
- на минимальной ширине 1024px нет горизонтальной прокрутки;
- экран сотрудников: колонки списка, ящик создания, карточка с разделами
  «Должность и группы» и «Системная роль» (профилей доступа больше нет),
  передача владения в «Опасной зоне» карточки владельца, а не в меню строки.

Фикстуры приведены к текущим контрактам: membership с группами вместо отделов,
права выводятся из роли зеркалом `identity/capabilities.py` (пустой список
скрывал кнопку «Добавить сотрудника»). В списке появился администратор — без
него диалог передачи владения показывал пустое состояние, и проверять в нём
было нечего.

Прогон: 8 passed, 6 skipped, 0 failed (было 3 passed, 5 failed).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 19:41:28 +03:00
AndreyandClaude Opus 5 6a93cfcbd7 ✅ fix(tests): pytest-сьют backend снова запускается целиком
`docker compose run backend-app pytest` (то, что делает scripts/check.ps1) не
доходил до первого теста. Две независимые причины, обе давние.

1. Область сбора. В pytest.ini не было ни `testpaths`, ни `norecursedirs`, а
   compose монтирует `data/media` в `/app/apps/backend/media`. На установке с
   демо-данными это 741 каталог организаций, и сбор падал через 2:46 с
   `OSError: [Errno 12] Cannot allocate memory` на обходе медиа.

   `testpaths` перечисляет оба пакета с тестами: только `chatballs` было бы
   мало — в `chatballs_backend/test_surfaces.py` лежат проверки изоляции
   поверхностей (loopback-only admin, раздельные cookie, исключение
   gateway-эндпоинта из SSL-редиректа), и они бы молча выпали из прогона.
   `norecursedirs` добавлен сверх этого: на запуск с явным путём `testpaths`
   не распространяется.

2. Боевая функция, собираемая как тест. `ai/test_demo_provider.py` импортировал
   на уровне модуля `integrations.services.test_integration`; pytest считает
   тестом любое имя `test_*` в тестовом модуле и падал на ненайденных фикстурах
   `context` и `integration`.

   Функция не переименована намеренно: в `integrations/tests.py` и
   `integrations/test_email.py` она уже импортируется под алиасом
   `run_integration_test` — конвенция в проекте есть, в этом файле её забыли
   применить. Добавлен алиас и комментарий, зачем он.

Проверка: сбор — 625 позиций за 15 с вместо падения через 2:46; полный прогон —
624 passed, 0 failed (21 мин). Остальные наборы зелёные: internal-ui vitest
76/76, typecheck internal-ui и web-chat, CLI 9/9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 19:26:57 +03:00
AndreyandClaude Opus 5 b2bc951f68 🔧 fix(design): макет «Сотрудники» получил комплект дизайн-системы
Макет ссылался на _ds/design-system/ (раньше — на каталог с прежним именем),
но самого каталога в этой папке никогда не было: шесть ссылок на токены,
styles.css и бандл вели в пустоту, и страница открывалась без оформления.

Рядом положен тот же комплект, что и у соседних макетов, — их копии побайтно
идентичны, каждая папка базлайна самодостаточна.

Проверено в браузере: все шесть запросов отдают 200, консоль чистая, страница
рендерится со стилями. Битых ссылок в design/ больше нет (0 из 121).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 18:26:48 +03:00
AndreyandClaude Opus 5 90be94af43 🔥 chore: прежние имена продукта убраны из отслеживаемых файлов
Требование к open-source публикации: в репозитории не должно остаться
CustoCRM и eDevs Hub. В docs/ это сделано ранее, здесь — код и дизайн.

Дизайн-система (design/design-system):
- заголовки, брендинг и примерные данные переведены на Chatballs;
- переименованы глобали справочника: CUSTOCRM_ICON_SPRITE →
  CHATBALLS_ICON_SPRITE, CUSTOCRM_INVENTORY → CHATBALLS_INVENTORY,
  data-атрибут спрайта — обе стороны замкнуты внутри справочника;
- в README поправлены пути открытия: справочник живёт в design/,
  а не в apps/internal-ui/;
- личный адрес andrey@edevs.tech в примере таблицы заменён на example.com.

Макеты (design/baseline):
- каталоги _ds/edevs-hub-design-system-<uuid>/ переименованы в _ds/design-system/,
  ссылки в .dc.html обновлены;
- JS-namespace бандла EdevsHubDesignSystem_e4c9df → ChatballsDesignSystem_e4c9df
  (21 вхождение: бандл, манифест, все x-import в макетах);
- readme.md внутри бандлов удалён — это описание прежнего продукта
  (hub.edevs.tech, checkout, командный центр), к текущему отношения не имеет;
- assets/custocrm-mark.svg → chatballs-mark.svg;
- удалён каталог «Каналы обработки» целиком: макеты сущности, упразднённой
  ADR-CHATBALLS-0041. Восстанавливается из истории git, если понадобится.

Тесты: tests/cli/test_custocrm_cli.py → test_chatballs_cli.py, набор проходит
(9 passed).

Ссылки в макетах и справочнике проверены: из 121 ссылки битых 6, и все шесть —
в «Сотрудники/Сотрудники (новая модель).dc.html», где каталог _ds отсутствовал
и до этих правок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 18:22:56 +03:00
AndreyandClaude Opus 5 e088ead06f 📝 docs: идентификаторы проектных документов приведены к CHATBALLS
Документация переработана: действующее отделено от истории, отменённые
контуры (продажи, биллинг, managed AI, отделы, сущность Product) убраны из
действующих документов в архив.

Здесь — только кодовая часть: ссылки на документы в комментариях. Ссылки на
действующие документы переименованы ADR/SPEC/ARCH/BUS-HUB-NNNN →
*-CHATBALLS-NNNN (274 ссылки в 173 файлах). Ссылки на документы, ушедшие в
архив, намеренно сохранили прежний идентификатор: он совпадает с именем
архивного файла.

Логика не менялась — правки только в комментариях, докстрингах и одном
описании теста.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 18:13:33 +03:00
AndreyandClaude Opus 5 b09fcb6f10 🔥 chore: снесён скрипт переименования CustoCRM → Chatballs
Последний след .env в продукте: скрипт переезда переписывал .env старой
установки и оставлял рядом .env.bak-custocrm. Переименование состоялось,
установок со старыми именами не осталось, а инструмент тянул за собой файл,
которого у продукта нет.

Раздел README про этот переезд удалён вместе со скриптом — он и сам уже читался
бессмысленно: заголовок «Chatballs / hub → Chatballs» и команда
«mv /opt/chatballs /opt/chatballs» получились оттого, что скрипт переименования
однажды прошёлся по собственной документации.

Проверено: pytest tests/cli — 9 passed; bash -n чист по всем скриптам.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 17:25:38 +03:00
AndreyandClaude Opus 5 486492c1d3 🔒 fix(identity): ключ шифрования секретов в БД перестал следовать за SECRET_KEY
Ключ выводился из SECRET_KEY, а ошибка расшифровки глоталась молча. Значит
смена ключа подписи — обычное действие после утечки — делала нечитаемыми
секреты TOTP сотрудников, токены интеграций, пароль SMTP и ключи S3, и в логе
об этом не было ни строки: секреты просто становились пустыми.

Теперь ключ живёт своим файлом в томе секретов: его кладёт туда первый старт
стека, выводя из secret_key ровно тем же способом, каким это делал сам продукт.
Значение от этого не меняется, поэтому работающая установка ничего не теряет —
но ключ больше не привязан к SECRET_KEY, и подпись можно ротировать.

Человек ключ по-прежнему не вводит: файла с переменными у продукта нет.

Молчание убрано: не расшифровавшееся значение пишет предупреждение в лог, а
неверный ключ в настройке падает ImproperlyConfigured сразу, а не отдаёт пустой
секрет при первой расшифровке.

Проверено на живом томе: shell-вывод ключа совпадает с питоновским байт в байт
(сверено в образе pgvector/pgvector:pg16); после запуска скрипта сгенерированный
ключ равен действующему выводимому; приложение читает его из файла и
расшифровывает все пять сохранённых секретов интеграций. Четыре теста.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 17:22:43 +03:00
AndreyandClaude Opus 5 a2e6672424 🔥 chore: .env убран отовсюду — установка не читает файл с переменными
Переход на запуск без .env (41a4b0d) не был доведён: в дереве остались файл
`env.example`, чтение instance .env во всём deploy/cli и CI, который этот .env
сам же и создавал из `env.example`. Из-за остатков ломались две вещи.

`chatballs deploy` не работал на установке без .env: _normalize_schema_ownership
брал POSTGRES_USER и POSTGRES_DB из файла и падал с «POSTGRES_USER not set»,
хотя установка исправна. Теперь берёт те же значения по умолчанию, что compose.

`chatballs doctor` выдавал пять ложных ошибок подряд: искал в .env домены,
ACME-почту, POSTGRES_PASSWORD и CHATBALLS_SECRET_KEY. Первое задаёт владелец
в «Настройках», второе генерирует в том с секретами первый старт стека —
снаружи, с хоста, этого не видно, и проверки убраны.

Убрано: env.example (из репозитория, release bundle и README); instance_env_file()
и все его чтения в common/compose/deploy/doctor/status; --env-file instance .env
из вызова compose — остаётся только release.env с digest-пинами образов от CI;
создание .env в gateway:validate. Профиль calls и его адреса читаются из
переменных окружения — оттуда же, откуда их берёт сам compose.

Тесты CLI переведены с фиктивного .env на переменные окружения; три проверки
удалённого поведения doctor убраны. Девять оставшихся проходят вообще без .env
— это и есть проверка, что установка теперь обслуживается. Заодно у
test_custocrm_cli.py выправлены окончания строк: в файле были одиночные CR
внутри кода, отчего diff по нему больше содержательной правки.

Проверено: pytest tests/cli — 9 passed; docker compose config валиден с пустым
каталогом инстанса; bash -n чист по всем скриптам CLI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 17:22:29 +03:00
AndreyandClaude Opus 5 03914fba09 ⚡ perf(conversations): расшифровка ждёт провайдера без транзакции
TenantContextMiddleware заворачивает весь вызов view в транзакцию: RLS-контекст
ставится через SET LOCAL и живёт ровно столько же. Удобно и даёт запросу
атомарность, но пока идёт обращение наружу, запрос держит соединение из пула, а
пул на процесс небольшой. Расшифровка голосового ждёт провайдера до 90 секунд
(30 с таймаут на три попытки) — несколько операторов, нажавших «расшифровать»,
вставали поперёк всех остальных запросов процесса.

Появился отказ от общей транзакции: вьюха выставляет
`tenant_manages_own_transaction` и открывает tenant_atomic сама. Забытый блок
не опасен — без транзакции RLS-настройка пуста и строки просто не видны, то
есть ошибка проявится сразу, а не утечкой в чужую организацию.

`transcribe_voice_message` разложена на prepare → run → store, и вьюха держит
транзакцию только вокруг чтения и вокруг записи. Прежний вызов целиком остался
для ingest, где входящее сообщение обрабатывается одной транзакцией.

Тест на TransactionTestCase (обычный TestCase сам держит транзакцию на весь
тест) проверяет напрямую: во время вызова провайдера in_atomic_block — False,
RLS-контекст снят, результат сохранён.

Остальные держатели транзакции не тронуты намеренно. В ingest_inbound
обращения наружу чередуются с записью по всей длине, и общая транзакция даёт
там атомарность приёма — разносить её по фазам значит менять семантику, а не
рефакторить. Отправка файла и голосового оператором опирается на синхронный
502 от канала. Проверка интеграции для WEB-провайдера сама пишет в БД.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:58:39 +03:00
AndreyandClaude Opus 5 3659ce564a 🔒 fix(calls): сокет сигналинга не ждёт аутентификации вечно
Константа AUTH_TIMEOUT_CLOSE намекала на срок, но кода не было: соединение
принималось до аутентификации (иначе клиенту некуда прислать токен) и дальше
ждало первое сообщение сколько угодно долго. Неаутентифицированный клиент так
держал сокет и запись в channel layer.

Срок — 10 секунд, снимается при успешной аутентификации и при отключении.

Два теста.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:58:39 +03:00
AndreyandClaude Opus 5 037be19786 ⚡ perf(identity): перебор только живых сессий, воркер чистит просроченные
Владельца сессии хранит её подписанное содержимое, отдельной колонки у
django_session нет, поэтому счётчик сессий сотрудника расшифровывал строки и
сравнивал. Перебиралась вся таблица — а просроченные строки Django сам не
удаляет и `clearsessions` не звал никто, так что стоимость просмотра карточки
сотрудника росла вместе с историей входов за всё время. Перебор ограничен
живыми сессиями, а чистку просроченных ведёт maintenance-цикл воркера.

Там же исправлено голодание самого воркера: обработка события обрывала цикл
на `continue`, и при непрерывном потоке событий — а породить его может кто
угодно через публичный виджет — переставали забираться входящие сообщения и
истекать приглашения на звонки. Периодические работы теперь выполняются в
любом случае, а сон остался только когда работы нет: скорость разбора очереди
не пострадала.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:58:20 +03:00
AndreyandClaude Opus 5 642c49bc4a 🔒 fix(support): файлы статей — inline только картинки, тип не от клиента
Файл статьи отдавался inline с типом, который прислал загружавший, и на домене
портала. То есть `text/html` открывался как страница на чужом домене, а имя
файла не прогонялось через safe_filename. Спасала только CSP поверхности —
единственный слой, и он теряется, если установщик переопределит CHATBALLS_APP_CSP.

Теперь inline остались картинки и PDF, остальное уходит вложением; тип
приводится к известному, а незнакомый угадывается по имени файла — значение
идёт в заголовок ответа, брать его у клиента как есть нельзя. Плюс на каждый
такой ответ ставится своя `default-src 'none'; sandbox`: svg-картинка со
скриптом внутри не должна зависеть от того, не ослабят ли CSP установки.

Три теста.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:58:20 +03:00
AndreyandClaude Opus 5 9606be8bc9 🔒 fix(identity): код TOTP принимается один раз
Отметка `totp_last_used_at` писалась, но ни с чем не сравнивалась. Код живёт
30 секунд и принимается с окном ±1 интервал, значит подсмотренный код работал
второй раз ещё полторы минуты — RFC 6238 §5.2 требует запоминать интервал.
Появилось поле `totp_last_counter`: `verify_totp` возвращает номер принятого
интервала, и коды до него включительно больше не проходят. Тем же путём идёт
подтверждение при настройке 2FA — код, которым её включили, на входе не
сработает.

Заодно два соседних. Шаг с кодом доставал пользователя без `is_active`: пароль
приняли раньше, и сотрудник, отключённый между шагами, всё равно входил.
И у начатого входа не было срока — брошенная вкладка ждала код сколько угодно
долго; теперь пять минут.

Три теста.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:58:02 +03:00
AndreyandClaude Opus 5 936ddf38dd 🔒 fix(deploy): заголовки безопасности возвращены в location nginx
`X-Content-Type-Options: nosniff` и `Referrer-Policy` объявлены на уровне
server, но nginx не наследует add_header в location, где есть свой add_header,
— он заменяет весь набор. У `/`, `/chat/` и `/calls/` свои CSP, поэтому SPA и
страница виджета отдавались без nosniff, и вложение с чужим Content-Type
браузер додумывал сам.

Оба заголовка продублированы в каждую такую location, причина записана рядом.
Конфиг проверен `nginx -t` в сети compose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:57:50 +03:00
AndreyandClaude Opus 5 28909f6951 🐛 fix(events): X-Correlation-Id из запроса больше не валит запись аудита
Значение приходило заголовком и ложилось в CharField(max_length=128) записи
аудита и очереди событий как есть. Строка длиннее валила запись в БД, и первым
это ловил публичный /api/v1/auth/login/, где аудит пишется на каждую неудачную
попытку: вместо 401 приходило 500, а след входа терялся. Заголовок разрешён в
CORS, то есть это штатный клиентский ввод.

Теперь значение обрезается до 128 символов и чистится до печатного ASCII:
оно идёт и в строку лога, и в заголовок ответа.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:57:50 +03:00
AndreyandClaude Opus 5 64f94756a7 🔒 fix(http): по TLS сильнее защищённое имя cookie, Vary не затирается
Cookie с префиксом `__Host-` браузер принимает только с самого хоста, по TLS
и без Domain — в этом весь её смысл. Но при наличии обеих сторона выигрывало
обычное имя, а его может выставить сосед по родительскому домену: приоритет
у незащищённого имени сводил защиту на нет и позволял подменить и сессию, и
CSRF-токен. Теперь защищённое имя всегда сильнее, а обычное по TLS вовсе
игнорируется — сами мы его по TLS не выдаём, значит пришло оно не от нас.
Цена — один повторный вход в тот момент, когда перед установкой впервые
появился сертификат: cookie, выданные по http, дальше не в счёт.

`Vary` ставился присваиванием и сносил «Vary: Cookie», который Django
проставляет на ответах с данными пользователя. За общим кешем это позволяло
отдать ответ одного пользователя другому. Заменено на patch_vary_headers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:57:38 +03:00
AndreyandClaude Opus 5 cbb16e3862 🔒 fix(integrations): исходящие только по http/https и мимо внутренней сети
`base_url` и `proxy_url` сохранялись свободными строками, без проверки схемы.
Дальше ответ провайдера задавал адрес скачивания вложения, а `download_bytes`
открывал его голым `urllib.request.build_opener()` — у которого включены
обработчики file и ftp. То есть владелец организации, поставив `base_url` на
свой сервер, получал ответом ссылку `file:///run/chatballs/secrets/secret_key`
и содержимое секрета оседало вложением в диалоге. Тем же путём проверка
«Проверить» ходила по внутренним адресам и возвращала код ответа в UI.

Адреса разделены по источнику доверия (chatballs.integrations.outbound).
Настройку вводит владелец, и он вправе указать хост внутри своей сети —
self-hosted ставит рядом свой Bot API или LLM-сервер; у неё проверяется только
схема. А адрес из ответа провайдера — данные: он ограничен http/https и
публичными хостами, кроме того хоста, который владелец сам задал в `base_url`
этого подключения. При настроенном прокси проверка хоста снимается: до цели
хаб идёт не сам, а при socks5h локальный резолвер о ней и не знает.

Сбой DNS считается разрешением, а не отказом: соединение пойдёт через тот же
резолвер и упадёт там же, так что запрещать нечего, — иначе скачивание
вложений стало бы заложником доступности DNS. От DNS rebinding проверка не
спасает, и это записано в коде честно.

Второй рубеж — opener: `build_opener` штатные обработчики не убирает, но
предпочитает наследника, поэтому file, ftp и data подменены заглушками.
Это закрывает и редирект на ftp, который urllib пропускает сам.

13 тестов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:57:27 +03:00
AndreyandClaude Opus 5 6f422ac67c 🔒 fix(webchat): виджет открыт миру — origin по заголовкам, лимиты, срок сессии
Публичные endpoint'ы виджета принимают кого угодно без входа и без CSRF, и
до сих пор у них не было ни одного ограничителя. Три дыры разом.

Origin страницы-хозяина брался из поля `hostOrigin` тела запроса — раньше
заголовков. Поле заявляет клиент, поэтому ограничение по доменам виджета
(`allowed_origins`) обходилось строчкой в curl. Теперь решают заголовки,
которые ставит браузер, а `hostOrigin` читается только там, где они указывают
на нас самих: виджет живёт в iframe на нашем же origin, и узнать хозяина
оттуда больше нечем. Что это заявление, а не доказательство, записано в
докстринге `host_origin` — чтобы поверх не построили настоящий контроль.

Лимитов не было вовсе. Каждое сообщение — ход AI по ключу организации
(ingest зовёт провайдера прямо в запросе), каждый файл — место в хранилище:
один скрипт тратил чужие деньги и диск, а поток порождённых событий вдобавок
вытеснял из воркера поллинг мессенджеров. Контуров два. По адресу клиента —
для того, у кого сессии ещё нет; потолки нарочно высокие, потому что за одним
адресом мобильного оператора сидят тысячи живых посетителей. По токену сессии
— строго: за NAT адрес общий, и счёт по нему либо бьёт по соседям, либо
бесполезен. Токен для лимита достаётся из заголовка, а не из тела: иначе
двадцатимегабайтная загрузка доезжала бы до сервера прежде, чем её отобьют.

Сессия не истекала никогда. Токен лежит в localStorage: на общем компьютере
он открывал бы чужую переписку сколько угодно долго. Теперь срок считается от
последней активности (30 суток), отметка обновляется не чаще раза в час —
иначе опрос раз в 2.5 с писал бы в строку сессии четыре раза в минуту.
Виджет на 401 забывает токен и предлагает начать заново, а не висит молча.

Пять тестов. Для главного отдельно проверено, что он ловит прежнее поведение:
со старым `_origin` подделанный `hostOrigin` давал 201 вместо 404.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:57:10 +03:00
AndreyandClaude Opus 5 a9139c5c66 💄 fix(agents): «Выключить агента» и «Удалить» — в меню карточки
Под карточкой висел свой ряд кнопок: две самодельные `agent-footer-button`
и подпись про условие удаления. На странице уже есть меню действий агента,
и опасное действие рядом с обычной кнопкой — лишний повод промахнуться.

Оба пункта переехали в меню за ⋯: «Скопировать код», разделитель,
«Выключить агента» (или «Включить») и «Удалить агента» красным по общему
стандарту `button.danger`. Подпись про диалоги и подключения не потерялась —
она и так в подтверждении удаления, где её читают перед решением. Стили
`agent-footer*` удалены, переключатель AI в шапке не тронут.

Проверено на «Консультанте»: пункты в меню активны, разделитель на месте,
удаление красное, ряда кнопок под карточкой больше нет. Ничего не удалял и
не выключал. Typecheck зелёный.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 03:15:47 +03:00
AndreyandClaude Opus 5 cd11232fd6 💄 fix(agents): поля инструкций — семь строк, правила десять
Решение владельца 2026-09-08: персонализация и тон по семь строк, правила
десять. Классы теперь называют то же число, что стоит в поле (`is-rows-7`,
`is-rows-10`), а не остатки прежних 1/2/3 — иначе разметка обещает одно,
а CSS даёт другое.

Высоты посчитаны по строке: 20.15px (13px × 1.55) плюс 18px паддингов и 2px
рамки — 161px и 222px.

Проверено на «Консультанте» подстановкой текста: семь и десять строк входят
без прокрутки, следующая строка её вызывает. Правки не сохранялись — значения
полей восстановлены, панель несохранённых изменений не появлялась.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 03:11:49 +03:00
AndreyandClaude Opus 5 96615877bf 💄 fix(agents): поля инструкций выше — правила пишут абзацами
В макете поля инструкций были 44 / 60 / 88, и «Правила работы» помещали три
строки: реальные правила туда не влезают, текст читается через прокрутку.

По решению владельца 2026-09-08 высоты подняты кратно строке (13px × 1.55
плюс паддинги): тон 80 (3 строки), персонализация 100 (4), правила 160 (7).
Отклонение от базлайна намеренное, отмечено комментарием у правил. Ручное
растягивание по вертикали и статический вариант для роли без прав сохранены —
у статического поднят min-height теми же значениями.

Проверено на карточке «Консультант»: 100 / 80 / 160 по вычисленным стилям.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 03:08:43 +03:00
AndreyandClaude Opus 5 d1f3c73de9 💄 fix(contacts): правка карточки — поля стандарта форм в две колонки
По карандашу карточка подменяла всю шапку формой из контекст-панели чата.
Форма рассчитана на колонку 320px, а в карточке 1156px это выглядело как
поломка: `.sales-client-card` — flex, форма вставлена flex-элементом без
`flex: 1`, поэтому брала ширину по дефолтному размеру `<input>` — колонка
~160px у левого края и пустая карточка справа. Поля шли метриками чат-панели
(13px, паддинг 6/8) вместо 38px общего стандарта, без подписей: заполненное
поле «Гостиница «Волга»» невозможно опознать — компания это или город.
Описание — textarea в две строки с полосой прокрутки и ручкой ресайза,
кнопки — самодельные 28px мимо `Button`. Плюс на время правки исчезала вся
шапка: аватар, имя, CID, каналы и «Открыть диалог».

У `ContactEditForm` теперь две раскладки при одной логике сохранения:
`rail` — прежняя компактная колонка панели чата (её макет не трогаем),
`card` — `FormField`/`TextAreaField` с подписями в две колонки по ширине
карточки, описание на всю ширину, действия — `Button` secondary «Отмена» и
primary «Сохранить». Аватар, имя и CID при правке остаются на месте, форма
раскрывается под ними; карандаш и действия шапки на время правки скрыты.
Кадра для состояния правки в K3 нет, поэтому собрано из существующих
компонентов, без новых визуальных решений.

Проверено вживую в обеих темах: колонки 504/504, поля 38px, подписи на
местах, «Отмена» возвращает шапку; форма в контекст-панели чата осталась
прежней (273px, компактные поля). Typecheck и 76 тестов зелёные.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 03:04:48 +03:00
AndreyandClaude Opus 5 eb5b00b8f7 💄 fix(ui): стрелки «назад» и «вперёд» — свои глифы вместо поворота шеврона
Направленных шевронов в наборе не было, и всё приложение делало их поворотом
шеврона вниз: rotate(90deg) для «назад», rotate(-90deg) для «вперёд». Выглядело
похоже, но это восемь мест с одним хаком в CSS, каждое со своим селектором, и
любая правка иконки ломала половину из них — в редакторе знаний правило уже
выродилось в `rotate(0deg)`, и стрелка «База знаний» смотрела вниз.

Заведён `chevronRight` рядом с `chevronLeft` (путь из таблицы ICON макета),
повороты сняты, каждое место берёт нужный глиф:

- профиль — мобильная шапка «назад» и шевроны списка подэкранов;
- контакты — пагинация списка;
- настройки — «назад» в мобильной шапке и шеврон пункта субменю;
- порталы — возврат из редактора материала и пагинация таблицы;
- база знаний — возврат из редактора и пагинация таблицы.

Повороты, где шеврон честно меняет состояние, не тронуты: `is-up` у сортировки
контактов и свёрнутые секции в контекст-панели и сайдбаре — там анимация.

Проверено вживую: «‹ База знаний» в редакторе знаний рисуется левым глифом без
transform, шеврон пункта настроек — правым. Мобильные места (шапка профиля и
настроек, списки) проверены по коду: поворотов не осталось, глифы заменены;
typecheck и 76 тестов зелёные.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 02:55:28 +03:00
AndreyandClaude Opus 5 98ba6fcf1e 💄 fix(contacts): карточка контакта — счётчики вкладок, служебные коды и левый шеврон
Счётчик прилипал к подписи: вкладки читались как «Диалоги3» и
«Идентификаторы1». У `.ui-underline-tabs button` не было ни gap, ни своей
типографики счётчика, а компонент используется только здесь — правило
дописано в общий стандарт, как в кадре K3: gap 6px, счётчик 11.5/600 --n-4.

Вкладка «Аудит» показывала сырьё из БД: «contacts.merged», «Contact 1»,
«SUCCESS». Причина — свой словарь подписей на пять действий диалогов в
`conversations/clients.py` мимо общего каталога журнала. Теперь подписи,
типы объектов и результаты берутся из `identity/audit_catalog.py`: те же
«Объединение контактов» и «Выполнено», что в журнале действий. Пустая
подпись по-прежнему означает «её ещё нет» и показывает код — правило
каталога не меняется. Заголовки таблицы приведены к обычному регистру
(капслок делает CSS), «АКТОР» → «Сотрудник» как в журнале.

Из текстов убраны коды решений: «(ADR-0006)» стоял в подсказке об
идентичностях и в подписи про право объединять. Макет их содержит, но
служебная лексика из макета в продукт не переносится.

Карточка «Объединённые контакты» кадра не имеет — по решению владельца
убрана до появления кадра вместе с разъединением из интерфейса: ушли
самодельная кнопка «Разъединить» мимо `Button`, обрубки «0 идентичн. ·
0 диал.» и диалог разъединения. Серверные операции merge/unmerge целы.

Шеврон «назад» больше не поворачивается на 90° из шеврона вниз: в
`icons.tsx` заведён `chevronLeft` путём из таблицы ICON макета. Тот же
поворот остался ещё в шести местах — вынесено отдельно.

Проверено на живой карточке контакта в обеих темах: вкладки «Диалоги 3»,
«Идентификаторы 1», журнал читаемый, ADR-кодов на экране нет. Бэкенд —
10 тестов contacts_merge, включая новый на читаемость журнала карточки;
фронт — typecheck и 76 тестов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 02:49:23 +03:00
AndreyandClaude Opus 5 5a73f7ebb9 ✨ feat(ui): появление содержимого во всех разделах, а не только в Чате
Короткое появление было только у диалога: `.sales-conversation` с локальными
keyframes. Все остальные разделы подменялись мгновенно, поэтому переход по
сайдбару читался как рывок, а «список → карточка» — как подмена без связи с
предыдущим экраном.

- `surface-enter` и класс `.enter-surface` в base.css: прозрачность 0 → 1 и
  смещение 6px → 0 за 180ms ease-out — те же цифры, что уже были у диалога;
- обёртка контента получила `key={route}`, иначе узел переживает переход и
  анимация не перезапускается. Лишней ценой это не оборачивается: под каждый
  route и так рендерится свой компонент;
- диалог переведён на общий класс, локальные keyframes и своё правило
  reduced-motion удалены — keyframe появления в проекте теперь один;
- движется только контентная область: сайдбар, субменю и лента прокрутки
  остаются на месте.

Стандарт зафиксирован в SPEC-HUB-0005 §5.3 и критерием 12 в §8.

Проверено в живом дев-стеке: узел с классом получает `surface-enter 180ms` и
стартует с opacity 0, медиазапрос prefers-reduced-motion на месте. Typecheck и
vitest внутреннего UI зелёные (76 тестов).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 02:20:24 +03:00
AndreyandClaude Opus 5 082b6f7ae5 🔧 chore(migrations): состояние conversations догоняет модель — MessageKind.FILE
Вид сообщения «файл» добавили в модель без миграции, файл 0019 лежал в рабочем
дереве неотслеживаемым. Без него `makemigrations --check --dry-run` требует
ровно эту миграцию, то есть на чистом клоне проверка падает.

Только choices, схему БД не трогает.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 02:10:05 +03:00
AndreyandClaude Opus 5 324198d6df 💄 fix(notifications): шторка слипалась — ритм списка и интерлиньяж
Зазора между уведомлениями не было вовсе: измеренные 0px. Из-за этого два
непрочитанных подряд сливались подложками в один цветной блок и читались как
одно уведомление из четырёх строк. Внутри карточки было не лучше: заголовок,
текст и время шли в 2 и 4 пикселя друг от друга с line-height: normal.
При этом между секциями «Сегодня» и «Ранее» стояло 42px пустоты.

- 6px между уведомлениями плюс рамка у непрочитанного: подложки соседей больше
  не сливаются;
- интерлиньяж задан каждой строке (заголовок 1.35, текст 1.45), заголовок →
  текст 3px, время отбито 8px и выровнено с первой строкой заголовка;
- секции разделены линией --n-8 вместо воздуха, а сам воздух ужат с 42 до 14.
  Девятка на подложке карточки почти не видна — восьмёркой отбиты шапки таблиц;
- «Прочитать всё» получила подложку по наведению: раньше это был голый текст
  без признаков кнопки.

Проверено в обеих темах на копии шторки с реальным CSS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 02:04:16 +03:00
AndreyandClaude Opus 5 c512d5a0f5 ✅ test(audit): все три списка фильтров проверяются на повторы
К тесту про дубли сотрудников добавлена общая проверка: значения и подписи
уникальны во всех списках фильтров, а не только в том, где баг уже был.
Разделы и результаты приходят из каталога и продублироваться могут только
задвоенным ключом словаря — проверка ловит и это.

Заодно проверены остальные три места с distinct() в бэкенде: ai/selectors,
calls/maintenance и notifications/binding берут distinct по целым строкам либо
сортируются по полю той же строки, что и ключ, — там дублей быть не может.
Ломается только случай, когда поле сортировки не определяется выбранными
колонками, как было с временем события против полей сотрудника.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 02:04:03 +03:00