Commit Graph
411 Commits
Author SHA1 Message Date
AndreyandClaude Opus 5 0098e147ac 🐛 fix(updates): запускать установщик, а не второй цикл ожидания
Входная точка образа updater'а — сценарий ожидания запросов, поэтому
`docker run ... chatballs-updater-apply.sh` не запускал установку, а
передавал имя сценария аргументом в тот же цикл. Помощник поднимался без
compose-меток, уходил в вечный sleep и никогда не дописывал статус:
интерфейс крутил установку без конца, а новые запросы отбрасывались как
«уже идёт». Та же ошибка была во втором вызове — копировании compose.yaml
на хост.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:37:04 +03:00
AndreyandClaude Fable 5.1 d8420e4379 🔖 release: 1.6.1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
v1.6.1
2026-09-13 04:30:31 +03:00
AndreyandClaude Fable 5.1 21b8100598 ⚡ fix(updates): проверка канала релизов раз в 15 минут вместо шести часов
Обновление видно на установке в пределах четверти часа после публикации; кнопка «Проверить» по-прежнему делает запрос сразу. Интервал переопределяется CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 04:29:18 +03:00
AndreyandClaude Fable 5.1 1da84c9734 🔖 release: 1.6.0
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
v1.6.0
2026-09-13 04:15:14 +03:00
AndreyandClaude Fable 5.1 82805f7b1a ✨ feat(identity): организации из интерфейса — создание, выбор после входа, /join под ролью app
Кнопка «Добавить организацию» внизу переключателя у логотипа (A1) ведёт на
страницу /organizations/new: логотип, название, часовой пояс, валюта, язык.
Создавший становится владельцем и сразу переключается в новую организацию.
Право — у администратора установки и у владельца или администратора любой
организации; сервер проверяет то же (POST /api/v1/organizations/).

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

tenancy/0035: роль app вставляет организацию только в контексте заранее
выделенного id (как мастер первого запуска) вместо политики «только первая»;
security-barrier каталог invitation_directory — ссылка /join открывается без
контекста, и под ролью app приглашение раньше не находилось вовсе. Тем же
путём язык организации в профиле: членства читаются через каталог входа.

Тесты: создание организации по API, RLS под реальной ролью app (вставка
только в своём контексте, поиск приглашения по токену), e2e переключателя,
страницы создания и экрана выбора. UpdateState исключён из проверки покрытия
демо-набором — одна строка на установку.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 04:14:24 +03:00
AndreyandClaude Opus 5 eca0e52667 ♻️ refactor(ui): один селект и один скин полей на всё приложение
Карточка агента рисовала свои выпадающие селекты: «Группа» и «Провайдер»
жили на собственной вёрстке и своём CSS, а рядом, в модалке создания
агента, стоял общий SelectField. Два стандарта одного элемента успели
разойтись в высоте, подложке, размере текста, подписи и фокусе.

Общий SelectField научился тому, из-за чего карточка писала свой: метке
перед значением (точка группы), невалидному состоянию и режиму без прав
правки, где значение показывается текстом. Форм у него две — обычный
селект и бокс, который держит рамку, когда внутрь нужно поставить точку
или текст вместо контрола. Карточка перешла на него, локальный скин
удалён, кольцо фокуса кадра G2 сохранено.

Геометрия базлайна v2 (поле 36px на подложке n-10, текст 13.5px, подпись
12.5/600) стала дефолтом shared/form-controls.css. До этого дефолтом
были 38px на surface-card, а базлайн добирали поверх профиль, настройки
и модалка создания агента — вместе с карточкой четыре копии одного поля.
Копии сняты, мобильные 44px тоже общие. Заодно шеврон встал по центру
поля: его отступ считался от прежних 38px.

Справочник дизайн-системы обновлён следом, чтобы не описывать геометрию,
которой в приложении больше нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 03:59:10 +03:00
AndreyandClaude Fable 5.1 54c35e281a 🔖 release: 1.5.0
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
v1.5.0
2026-09-13 00:43:11 +03:00
AndreyandClaude Fable 5.1 5396dda8ad ✨ feat(updates): обновление установки из интерфейса
Установка раз в шесть часов и по кнопке проверяет страницу релизов
GitHub. Администратор установки видит баннер о новой версии и ставит
её одной кнопкой; карточка «Обновления» — в «Настройки → Платформа».

Установку выполняет отдельный сервис updater с Docker-сокетом:
backend-app общается с ним только файлами в томе chatballs-updates
(heartbeat, request.json, status.json). Updater принимает лишь релизы
своего репозитория с образами по digest и применяет compose.yaml в
одноразовом контейнере-помощнике, после чего кладёт файл в каталог
установки. Запрос установки попадает в аудит.

Релизный workflow собирает образ updater и пришпиливает его в
compose.yaml; версия бэкенда зашивается в образ (CHATBALLS_VERSION).
Миграции updates.0001 и tenancy.0034 (гранты на таблицу состояния).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:43:11 +03:00
AndreyandClaude Fable 5.1 29164db094 🔖 release: 1.4.0
SVG-логотип организации; воркер больше не засоряет журнал сбоями опроса
подключений и не опрашивает демо-подключения.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
v1.4.0
2026-09-13 00:03:55 +03:00
AndreyandClaude Fable 5.1 c5a53ccec5 ✨ feat(identity): SVG в качестве логотипа организации
К PNG, JPEG и WebP добавлен SVG. Файл проверяется при загрузке и
отклоняется целиком, если содержит скрипты, обработчики событий,
foreignObject, внешние ссылки в href или url() внутри стилей, DOCTYPE и
сущности — переписывать чужую графику молча хуже, чем попросить другой
файл. При отдаче логотип получает Content-Security-Policy с sandbox и
nosniff как второй рубеж: адрес можно открыть и напрямую.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:03:54 +03:00
AndreyandClaude Fable 5.1 049217505a 🔇 fix(worker): пауза после сбоя опроса подключения и тишина в журнале
Подключение с ненастоящим токеном или недоступным сервером писало
предупреждение на каждом цикле опроса, каждые три секунды, на каждое
подключение. Теперь сбой транспорта — исключение PollFailed, а слой пауз
(transports.backoff) пропускает подключение с удвоением паузы от шести
секунд до четверти часа и пишет в журнал только смену состояния: первый
сбой, выход на максимальную паузу и восстановление. Курсор при сбое не
двигается. Демо-подключения из демо-набора помечаются demoSeed и не
опрашиваются вовсе: их токены ненастоящие.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:03:54 +03:00
AndreyandClaude Fable 5.1 132c684630 🔖 release: 1.3.1
Исправления после установки на чистый хост: «Ошибка загрузки» сразу после
мастера первого запуска (кэш адреса установки в соседних процессах) и
пустое состояние «Базы знаний», прижатое к левому краю.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
v1.3.1
2026-09-12 23:18:43 +03:00
AndreyandClaude Fable 5.1 bfe90e9abd 💄 fix(knowledge): пустое состояние библиотеки снова по центру
Коммит 776bc895 при переносе библиотеки на серверную пагинацию удалил
правило контейнера .knowledge-empty, оставив только вложенный блок шириной
480px — он прижимался к левому краю рабочей области. Правило возвращено
как было: flex, по центру, отступ 40px (кадр KB1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:18:43 +03:00
AndreyandClaude Fable 5.1 5b49904d6a 🐛 fix(identity): адрес установки виден соседним процессам сразу, а не через 10 секунд
Кэш адреса установки живёт в каждом процессе gunicorn по 10 секунд. Мастер
первого запуска и смена адреса в «Настройках» сбрасывали его только в том
процессе, где выполнялись: соседний отвечал «Invalid host» на первый же
запрос после мастера, и приложение показывало «Ошибка загрузки» до
обновления страницы. Промах по хосту теперь перечитывает строку настроек,
но не чаще раза в секунду на процесс, чтобы поток запросов с чужим Host не
превращался в поток запросов к базе.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:18:43 +03:00
AndreyandClaude Fable 5.1 cd50cce5a8 ⬆️ ci(actions): версии actions под Node.js 24
GitHub снимает Node.js 20 с раннеров и уже принудительно запускает старые
версии actions на Node.js 24, о чём предупреждает в каждом прогоне. Все семь
actions подняты до мажорных версий, чей action.yml объявляет node24:
checkout v7, setup-python v7, upload-artifact v7, build-push-action v7,
login-action v4, setup-buildx-action v4, action-gh-release v3. Входы и выходы
шагов не менялись.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 18:55:51 +03:00
AndreyandClaude Fable 5.1 a4ca7e9373 🔖 release: 1.3.0
Несколько организаций на одной установке: переключатель организаций,
приглашения существующих учётных записей и владельца из провижининга,
администратор установки, минимальные привилегии процессов и три тома
секретов. Миграции tenancy 0031–0033, identity 0036–0037; настройки
установки переехали на /api/v1/instance/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
v1.3.0
2026-09-12 16:19:01 +03:00
AndreyandClaude Fable 5.1 145c44a17d ✨ feat(internal-ui): несколько организаций — переключатель, разделы администратора установки, приглашения
Переключатель организаций в сайдбаре (дизайн-базлайн v2, A1) показывает все
организации человека, текущая отмечена, выбор другой пересобирает Shell по
ключу организации. Вход без организации в адресе открывает последнюю
открытую или первую по списку вместо экрана «нет доступа»; предпочтение
переехало в localStorage.

Разделы «Платформа» и «Хранилище файлов» видны только администратору
установки, relay для звонков у остальных менеджеров только на чтение;
настройки установки запрашиваются с /api/v1/instance/.

Ссылка-приглашение /join: вошедший принимает приглашение и попадает в новую
организацию, гость без учётной записи задаёт имя и пароль полями мастера
первого запуска. Ожидающие приглашения показаны в списке сотрудников
строками «Приглашён» с меню «отправить ещё раз» и «отозвать» — теми же
элементами, что строка сотрудника.

e2e: проекту internal-ui задана русская локаль браузера, моки сессии отдают
язык установки — сценарии перестали зависеть от языка машины; добавлены
сценарии переключателя, строки приглашения и регистрации гостя.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:37:52 +03:00
AndreyandClaude Fable 5.1 ac824e192c 🔒 feat(deploy): три тома секретов и разрешение upstream в nginx на каждом запросе
Том chatballs-secrets целиком монтировался во все сервисы: публичный
backend-app читал пароли платформенной роли и роли миграций, которая обходит
RLS. Теперь томов три — общий, платформенной роли и роли миграций вместе с
паролем владельца кластера — и каждый процесс монтирует только свои.
Генератор переносит файлы существующих установок: копия, побайтная сверка,
только потом удаление из общего тома; повторный запуск ничего не трогает,
пароли не меняются. doctor на работающем стеке проверяет, что backend-app
паролей не видит. Воркер получает алиас platform флагом
CHATBALLS_DB_PLATFORM_ALIAS. README перечисляет три тома в бэкапе.

nginx (dev-шлюз и production-фронтенд) резолвил имя сервиса один раз при
старте: после пересоздания контейнеров старый адрес backend-app достался
backend-platform, и туда уходил WebSocket приложения. Upstream задан
переменной с resolver на DNS Docker. ADR-CHATBALLS-0048 §3, §5.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:37:38 +03:00
AndreyandClaude Fable 5.1 3e790af6e5 ✉️ feat(identity): приглашения в организацию — существующие учётные записи и владелец из провижининга
Учётная запись глобальная, а во вторую организацию было не попасть: форма
создания сотрудника отвечала «e-mail занят». Теперь занятый адрес из другой
организации получает приглашение с той же ролью, должностью, телефоном и
группами; членство появляется, когда человек принял его под своим входом.
Письмо отправляет воркер и выпускает токен в момент отправки — открытый
токен нигде не хранится. Повторное приглашение заменяет прежнее.

Приглашение владельца из платформенного провижининга доходит тем же
письмом. По ссылке /join предпросмотр говорит, есть ли учётная запись:
существующая идёт на вход и принимает приглашение, новая задаёт имя и пароль
(регистрация с теми же правилами, что в мастере) и активирует организацию.

Ожидающие приглашения отдаются в списке сотрудников блоком invitations с
действиями «отправить ещё раз» и «отозвать». Ответ на ошибки формы полями
переехал из мастера в auth/common — им пользуется и регистрация.
ADR-CHATBALLS-0047.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:37:25 +03:00
AndreyandClaude Fable 5.1 1390ccc426 🔒 fix(tenancy): гранты платформенной роли, backend-app только ролью app, организация видна в своём контексте
Провижининг организаций через платформенный API работал только в тестах:
роль chatballs_platform не имела прав на таблицы платформы, и уже проверка
токена падала с permission denied. Тесты ходят в базу владельцем кластера и
этого не видели — новый тест выполняет запрос под реальной runtime-ролью.

Публичный процесс backend-app держал platform-соединение ради каталогов
входа и мастера первого запуска. Теперь каталоги читаются ролью app
(SELECT на security-barrier вьюхи), а первую организацию мастер создаёт под
политикой «пока организаций нет» через SECURITY DEFINER-функцию; алиас
platform поднимается только у платформенной поверхности и воркера.

Роль app читала таблицу организаций целиком. Политика сужена до текущей
организации: входы без контекста находят id через каталог организаций и
читают строку уже внутри tenant_atomic (tenancy/lookup), воркер обходит
организации по каталогу, мастер выделяет id из последовательности заранее.
Должность владельца при провижининге берётся из словаря на языке
организации. ADR-CHATBALLS-0048.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:37:10 +03:00
AndreyandClaude Fable 5.1 2e52bdaea0 👥 feat(identity): администратор установки и настройки инсталляции на /api/v1/instance/
Настройки установки — адрес, почта, TURN, хранилище файлов, язык по
умолчанию — общие для всех организаций, а редактировал их любой владелец
любой организации через путь под организацией. Теперь у учётной записи есть
глобальный признак is_instance_admin: первым его получает владелец из
мастера первого запуска, существующим установкам — те, у кого был
is_superuser. Эндпоинты переехали на /api/v1/instance/ вне tenant
middleware: читает менеджер любой организации (ради карточки relay),
меняет только администратор установки. Аудит и событие переноса файлов —
уровня установки, без организации. Передача признака — командой
set_instance_admin и полем в Django admin; последнего активного
администратора отозвать нельзя.

Сессия отдаёт isInstanceAdmin. Старые пути под организацией отвечают 404.
ADR-CHATBALLS-0046.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:36:51 +03:00
AndreyandClaude Opus 5 5bf3d9b572 🔖 release: 1.2.1
Тег на зелёном коммите: в v1.2.0 проверки CI упали на порядке импортов.
Кода релиз не меняет — правка уже в main отдельным коммитом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v1.2.1
2026-09-12 03:36:56 +03:00
AndreyandClaude Opus 5 6d94aa1f9f 🚨 fix(lint): порядок импортов по конфигурации из корня репозитория
Проверка ruff в контейнере шла из /app/apps/backend, где корневого
pyproject.toml нет, — правило I (сортировка импортов) там просто не
применялось, и CI поймал то, что локальный прогон пропустил. Правки чисто
механические: порядок импортов и пустые строки, оставшиеся от удалённых
констант.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 03:20:50 +03:00
AndreyandClaude Opus 5 0e6a3db9a6 🌐 feat(i18n): язык ответов агента, английское демо и язык в мастере
Язык ответов агента. Поле AIAgent.answer_language: «как у клиента»
(по умолчанию), «язык организации» или конкретный язык. Системный промпт при
этом не переводится — его читает модель, а не человек; язык задаёт отдельная
директива, потому что русский промпт сам по себе тянет ответ в русский язык.
База знаний остаётся одноязычной: поиск ведёт семантическая ветка, а
эмбеддинги кроссязычные.

Язык уходящего наружу текста. Приглашение на звонок, просьба поделиться
контактом, подписи кнопок мессенджеров, тема письма, приветствие виджета и
ответы бота уведомлений берут язык организации (i18n.customer_language), а не
язык запроса: клиент не должен получать письмо на языке того сотрудника, кто
нажал кнопку, и половина такого текста рождается в воркере, где запроса нет.

Английское демо. Манифесты разложены по demo_seed/data/<язык>/, набор
выбирается языком организации. Английский набор повторяет русский по составу —
тот же каст, те же тринадцать диалогов, — переводился только текст. Свои
документы знаний и каталог тканей; аватары и голосовые общие. Демо-провайдер
стал двуязычным: наборы стоп-слов и ключевых слов на оба языка, язык
определяется по письму клиента.

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

Попутно исправлено:

- Демо-стенд нельзя было редактировать. configure_agent_provider принимал
  только OpenRouter и Custom, а демо ставит агентов на встроенный DEMO.
  providerIntegrationId уходит с каждым PATCH карточки, поэтому падало любое
  сохранение агента — даже правка инструкций.
- Список типов уведомлений отдавал ярлык TextChoices — захардкоженную русскую
  строку. Ярлык переехал ключом в реестр TYPE_META.
- Результат «Проверить» у подключений (20 строк) и ошибки политики исходящих
  адресов уходили владельцу по-русски.
- Правило data/ в .gitignore ловило demo_seed/data: русские манифесты лежали в
  индексе с прежних времён, а новые файлы туда молча не попадали бы.

Каталог бэкенда — 519 ключей, наборы ru и en совпадают. Полный прогон
бэкенда: 759 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v1.2.0
2026-09-12 03:12:18 +03:00
AndreyandClaude Opus 5 d7f87719de 🔖 release: 1.1.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v1.1.0
2026-09-10 00:37:21 +03:00
AndreyandClaude Opus 5 6c3dec7339 🌐 feat(i18n): язык интерфейса — русский и английский
Интерфейс был русским в коде: строки лежали прямо в JSX и в ответах API,
даты форматировались прибитым «ru-RU», склонение по числу писалось руками
в каждом файле. Теперь текста в коде нет — он живёт в словарях, а язык
выбирается человеком.

Цепочка одна на обе стороны: профиль сотрудника → организация → установка
→ браузер. Пустое значение на каждом уровне значит «как выше», а не
«русский»: тот, кто язык не трогал, поедет за организацией, когда владелец
её переключит, а выбравший явно — останется на своём.

Владелец задаёт язык организации («Настройки» → «Организация», рядом с
часовым поясом) и язык установки («Платформа») — на нём открываются вход,
сброс пароля и мастер первого запуска. Сотрудник переопределяет его в
профиле.

Русский каталог задаёт набор ключей, английский обязан его повторить:
во фронтенде это ловит tsc — пропущенный перевод становится ошибкой
сборки, — на бэкенде тест каталога, который заодно сверяет имена
параметров в фразах. Формы множественного числа берутся из CLDR через
Intl.PluralRules, даты и размеры — через Intl, а не через свои списки
месяцев.

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

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

gettext не взят намеренно: .po/.mo потребовали бы msgfmt в сборке образа
ради того же результата, что дают обычные словари, одинаковые на обеих
сторонах. Язык запроса при этом активируется штатным механизмом Django,
поэтому сообщения DRF переводятся тоже.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 00:36:52 +03:00
AndreyandClaude Fable 5.1 f91b3da27a 📄 docs(license): AGPL-3.0
Текст лицензии в LICENSE, раздел и бейдж в обоих README.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 19:52:11 +03:00
AndreyandClaude Fable 5.1 c19bf642c7 💚 fix(ci): digest базовых образов брался по шаблону, который печатал сам себя
Go-шаблон был обёрнут во второй слой фигурных скобок и выводил строку
«{{json .Manifest.Digest}}» вместо digest — сборка gateway падала на
невалидном FROM.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
v1.0.0
2026-09-09 19:24:08 +03:00
AndreyandClaude Fable 5.1 347447b827 🔖 release: 1.0.0
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 19:20:16 +03:00
AndreyandClaude Fable 5.1 a41da36cc4 📝 docs(readme): продуктовый README на английском и русском
Позиционирование, описание, установка одной командой, функции и
решение проблем. Основная версия — английская, русская рядом.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 19:09:57 +03:00
Andrey 5f7bd03cb5 🔀 merge: установка одной командой, разбор аудита, линтер в CI
Ветка feat/one-command-release целиком:

- установка на чистый хост одной командой (compose.yaml со страницы релиза);
- 19 правок по аудиту: 2FA, сессии при сбросе пароля, TLS на домене
  установки, WebSocket под TLS, права на файлы, дедупликация входящих,
  смена адреса, SSRF через редирект, домен портала, пароль прокси, health;
- код приведён к своей же конфигурации ruff, линтер включён в CI;
- переводы строк в .py нормализованы, GitLab и .claude убраны из репозитория.

Проверено: backend pytest 739 passed, playwright 8, vitest 82, CLI 22,
typecheck, ruff — всё зелёное; установка поднята и проверена через шлюз.
2026-09-09 18:09:08 +03:00
AndreyandClaude Opus 5 d0a338b26f 🔥 chore(repo): убрать GitLab и служебный каталог агента из репозитория
Канал релиза один — GitHub. Пайплайн GitLab оставался вторым источником
правды: он собирал те же образы своим способом и мог выдать релиз, разошедшийся
с тем, что публикует workflow. Такое расхождение уже стоило неработающего
релиза, поэтому второй канал убирается целиком, а не подпирается.

`.claude/launch.json` — локальные настройки запуска агента, а не часть
продукта: убран из индекса и добавлен в .gitignore. На диске файл остаётся.

Проверка python-линтера жила в стадии validate GitLab; она есть в
.github/workflows/checks.yml, так что ничего не теряется.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:08:50 +03:00
AndreyandClaude Opus 5 482fa73d95 🚨 fix(lint): привести код к своей же конфигурации ruff и включить её в CI
Линтер никогда не проверял то, что задумано. Конфигурация лежит в корневом
pyproject.toml (`select = ["E","F","I","UP","B","DJ"]`), а запускали ruff из
apps/backend — там конфигурации нет, и он молча брал правила по умолчанию.
Из корня та же команда показывает 1102 нарушения вместо четырнадцати.

Что с ними сделано:

- E501 (968 из 1102) вынесен в ignore с объяснением: длину строки правит
  форматтер, а `ruff format` в этом репозитории не принят — он переписал бы
  374 файла. Порога, до которого можно поднять line-length, нет: 398 строк
  длиннее 120 символов, 222 длиннее 140. Правило, которое всегда красное,
  просто перестают читать.
- Миграции исключены из проверки: их пишет Django.
- Остальные 104 исправлены. Содержательное: assertRaises(Exception) в тесте
  доставки приглашения заменён на конкретный CallInviteDeliveryError; убрана
  мёртвая переменная identity в демо-сиде (у Conversation нет такого поля —
  единственный источник identity это контакт); порядок членов у пяти моделей
  по Django Style Guide; __str__ двум синглтонам инстанса, которые в админке
  показывались бы как «object (1)»; isinstance с кортежем → оператор |.

CI: ruff добавлен в .github/workflows/checks.yml (push и pull request — до
этого на GitHub не проверялось ничего до первого тега), в стадию validate
GitLab и в scripts/check.ps1. Везде запуск из корня репозитория, иначе
повторится та же подмена правил. Версия берётся из requirements бэкенда,
чтобы CI и образ не разъезжались.

Из pre-commit убран ruff-format: висел хук, который при первой же установке
переписал бы 374 файла — это отдельное решение, а не побочный эффект.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 07:39:18 +03:00
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