Переход на запуск без .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>
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>
Константа AUTH_TIMEOUT_CLOSE намекала на срок, но кода не было: соединение
принималось до аутентификации (иначе клиенту некуда прислать токен) и дальше
ждало первое сообщение сколько угодно долго. Неаутентифицированный клиент так
держал сокет и запись в channel layer.
Срок — 10 секунд, снимается при успешной аутентификации и при отключении.
Два теста.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Владельца сессии хранит её подписанное содержимое, отдельной колонки у
django_session нет, поэтому счётчик сессий сотрудника расшифровывал строки и
сравнивал. Перебиралась вся таблица — а просроченные строки Django сам не
удаляет и `clearsessions` не звал никто, так что стоимость просмотра карточки
сотрудника росла вместе с историей входов за всё время. Перебор ограничен
живыми сессиями, а чистку просроченных ведёт maintenance-цикл воркера.
Там же исправлено голодание самого воркера: обработка события обрывала цикл
на `continue`, и при непрерывном потоке событий — а породить его может кто
угодно через публичный виджет — переставали забираться входящие сообщения и
истекать приглашения на звонки. Периодические работы теперь выполняются в
любом случае, а сон остался только когда работы нет: скорость разбора очереди
не пострадала.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Файл статьи отдавался 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>
Отметка `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>
Значение приходило заголовком и ложилось в CharField(max_length=128) записи
аудита и очереди событий как есть. Строка длиннее валила запись в БД, и первым
это ловил публичный /api/v1/auth/login/, где аудит пишется на каждую неудачную
попытку: вместо 401 приходило 500, а след входа терялся. Заголовок разрешён в
CORS, то есть это штатный клиентский ввод.
Теперь значение обрезается до 128 символов и чистится до печатного ASCII:
оно идёт и в строку лога, и в заголовок ответа.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
`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>
Публичные 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>
Под карточкой висел свой ряд кнопок: две самодельные `agent-footer-button`
и подпись про условие удаления. На странице уже есть меню действий агента,
и опасное действие рядом с обычной кнопкой — лишний повод промахнуться.
Оба пункта переехали в меню за ⋯: «Скопировать код», разделитель,
«Выключить агента» (или «Включить») и «Удалить агента» красным по общему
стандарту `button.danger`. Подпись про диалоги и подключения не потерялась —
она и так в подтверждении удаления, где её читают перед решением. Стили
`agent-footer*` удалены, переключатель AI в шапке не тронут.
Проверено на «Консультанте»: пункты в меню активны, разделитель на месте,
удаление красное, ряда кнопок под карточкой больше нет. Ничего не удалял и
не выключал. Typecheck зелёный.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Решение владельца 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>
В макете поля инструкций были 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>
По карандашу карточка подменяла всю шапку формой из контекст-панели чата.
Форма рассчитана на колонку 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>
Направленных шевронов в наборе не было, и всё приложение делало их поворотом
шеврона вниз: rotate(90deg) для «назад», rotate(-90deg) для «вперёд». Выглядело
похоже, но это восемь мест с одним хаком в CSS, каждое со своим селектором, и
любая правка иконки ломала половину из них — в редакторе знаний правило уже
выродилось в `rotate(0deg)`, и стрелка «База знаний» смотрела вниз.
Заведён `chevronRight` рядом с `chevronLeft` (путь из таблицы ICON макета),
повороты сняты, каждое место берёт нужный глиф:
- профиль — мобильная шапка «назад» и шевроны списка подэкранов;
- контакты — пагинация списка;
- настройки — «назад» в мобильной шапке и шеврон пункта субменю;
- порталы — возврат из редактора материала и пагинация таблицы;
- база знаний — возврат из редактора и пагинация таблицы.
Повороты, где шеврон честно меняет состояние, не тронуты: `is-up` у сортировки
контактов и свёрнутые секции в контекст-панели и сайдбаре — там анимация.
Проверено вживую: «‹ База знаний» в редакторе знаний рисуется левым глифом без
transform, шеврон пункта настроек — правым. Мобильные места (шапка профиля и
настроек, списки) проверены по коду: поворотов не осталось, глифы заменены;
typecheck и 76 тестов зелёные.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Счётчик прилипал к подписи: вкладки читались как «Диалоги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>
Короткое появление было только у диалога: `.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>
Вид сообщения «файл» добавили в модель без миграции, файл 0019 лежал в рабочем
дереве неотслеживаемым. Без него `makemigrations --check --dry-run` требует
ровно эту миграцию, то есть на чистом клоне проверка падает.
Только choices, схему БД не трогает.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Зазора между уведомлениями не было вовсе: измеренные 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>
К тесту про дубли сотрудников добавлена общая проверка: значения и подписи
уникальны во всех списках фильтров, а не только в том, где баг уже был.
Разделы и результаты приходят из каталога и продублироваться могут только
задвоенным ключом словаря — проверка ловит и это.
Заодно проверены остальные три места с distinct() в бэкенде: ai/selectors,
calls/maintenance и notifications/binding берут distinct по целым строкам либо
сортируются по полю той же строки, что и ключ, — там дублей быть не может.
Ломается только случай, когда поле сортировки не определяется выбранными
колонками, как было с временем события против полей сотрудника.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Список сотрудников для фильтра собирался values().distinct(), но у AuditEvent
есть Meta.ordering, и поля сортировки Django подмешивает в SELECT перед
DISTINCT. Уникальность считалась по паре «сотрудник + время события», то есть
по каждому событию отдельно: сотрудник попадал в фильтр столько раз, сколько
совершил действий.
Лечится сбросом сортировки — .order_by() перед .values().distinct().
Регрессионный тест проверяет именно уникальность списка: на пяти событиях
одного сотрудника плюс одно системное он давал 6 пунктов вместо 2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Журнал был непригоден: последние 50 событий сплошной лентой, без фильтров и
страниц, а действие показывалось одной строкой. Из 67 действий, которые пишет
код, подписи имели 24 — остальные схлопывались в «Системное действие», то есть
две трети журнала не сообщали ничего.
Информативность:
- identity/audit_catalog.py — подписи на все действия, что пишутся, плюс два
правила против повторения: раздел выводится из префикса кода (новое действие
само попадает в свой раздел и в фильтр), а действие без подписи отдаётся с
пустым label и показывается сырым кодом моноширинным — видно, что произошло,
и видно, какую подпись дописать;
- наружу пошли поля, которые всё это время лежали в базе и не отдавались:
object_type/object_id, source_ip, correlation_id и payload. Строка таблицы
раскрывается и показывает их — разобраться в событии стало чем;
- в строке теперь подпись действия, раздел под ней, объект («Сотрудник · 12»)
и результат цветом. Дни отбиты заголовком «Сегодня»/«Вчера»/«5 сен» —
сплошной ленты больше нет.
Фильтры и страницы (всё на сервере, журнал растёт без предела):
- период пресетами (сегодня · 7 · 30 · 90 дней · всё время), считается в
часовом поясе организации, а не в UTC;
- раздел, сотрудник (включая «Систему»), результат, поиск по коду действия,
объекту, имени и почте сотрудника;
- page/pageSize со счётчиком; страница за пределами журнала отдаёт последнюю.
Списки для фильтров считаются по всей организации, а не по текущей выборке,
иначе фильтр схлопывается до одного значения и из него не выбраться;
- синонимы префиксов (conversation.* и conversations.*) ищутся вместе, иначе
раздел «Диалоги» терял половину событий.
Экран приведён в общий ритм списков раздела: своя лента и колонка 1220px по
центру, контролы — общие SearchInput/FilterDropdown/Segmented, а не свои копии.
Поиск отложен на 300 мс, ответ устаревшего запроса не затирает свежий, прошлая
страница на время загрузки гаснет, а не моргает пустотой.
Попутно: paginationItems переехал из features/sales в shared/ui-controls рядом
с TablePagination — один расчёт страниц на приложение. AdministrationPage и
administrationSection удалены: роут аудита теперь на своём AuditPage со своим
хуком, а useAdministration остался только под «Организацию» в Настройках.
Проверено: backend chatballs.identity 123 OK (6 новых тестов на фильтры,
страницы, синонимы префиксов и отсутствующую подпись), tsc + vitest 76 OK,
геометрия снята с копии экрана на реальном CSS — колонка 1220px, запас 110px
слева и справа поровну.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
.profile-page имела max-width: 1120px, а шелл .profile-page-shell — margin: 0,
то есть страница жалась к левому краю. В базлайне это не читалось: кадр 1440px,
область контента ровно 1220px, и запаса почти не оставалось.
Приведена к тому же правилу, что списки Агентов, Сотрудников, Контактов и
Порталов: 1220px и margin-inline: auto.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Контент «Настроек» был прибит к левому краю и менял ширину при переключении
пунктов субменю: 820px у форм, 1000px у табличных разделов (флаг wide).
У .settings-content-inner был max-width без margin: 0 auto — единственный
экран приложения без центрирования. Списки Агентов, Сотрудников, Контактов и
Порталов идут 1220px по центру («один ритм на все списки раздела»), общий
контейнер .hub-page — 1200px по центру.
В базлайне это не читалось: кадр 1440px, область контента ровно 980px, широкие
разделы заполняли её целиком, узкие оставляли 160px справа. На реальном
мониторе запас больше, и блок жался в угол.
Ширина унифицирована на 1000px (решение владельца): таблицы остаются как
нарисованы, формы становятся шире на 180px. Флаг wide и класс is-wide убраны
за ненадобностью.
Проверено на 1600px: область контента 1360px, блок 1000px, запас 180px слева и
справа поровну. tsc + vitest 77 OK.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Адрес установки, SMTP и TURN приехали в настройки с 41a4b0d и были приклеены к
разделу «Организация» второй формой — без прохода по дизайну. Кадр N1 базлайна
такой формы не содержит, лид раздела («название, логотип и региональные
параметры») её не описывает, а по высоте вложение было втрое больше того, во
что вложено: 977px против 358px.
Разложено по смыслу:
- новый раздел субменю «Платформа» (иконка globe) — последним перед
«Демо-данными», решение владельца. Внутри две отдельные карточки, «Адрес
установки» и «Исходящая почта», у каждой свой «Сохранить»; «Отправить
тестовое письмо» переехало в карточку почты;
- TURN — в «Голосовые и звонки»: включать звонки и чинить их прохождение
через строгий NAT это одна задача;
- «Организация» вернулась ровно к кадру N1 — одна карточка.
Бэкенд не тронут: PATCH .../administration/instance/ уже частичный (блоки email
и turn применяются, только если пришли в теле; publicHost/publicScheme
подставляются из текущей строки), поэтому три карточки в двух разделах делят
один эндпоинт и не затирают друг друга. Общий доступ вынесен в instance.ts,
InstanceAddressCard удалён.
Вёрстка — измерено на копии раздела с полным CSS проекта:
- зазор между соседними карточками 0px -> 18px (рамки складывались в двойную
линию), заголовок раздела -> первая карточка остался 22px как в N1;
- подзаголовок «TURN» стоял вплотную к полю «Отправитель» (0px), а
«Исходящая почта» — на 18px: теперь у каждого блока своя карточка;
- .settings-storage-head -> .settings-card-head: это общий стандарт заголовка
карточки раздела (N6), а не хранилищный. Обновлён и StorageSettingsCard;
- TLS переведён с сегмент-контрола темы (класс профиля .appearance-theme-options)
на SwitchButton — тот же стандарт, что у точек входа в «Голосовых и звонках»;
- ряды действий приведены к N1: метка «Сохранено» слева, primary справа. Была
обратная раскладка, а метка через .settings-section-note съезжала на 6.5px
ниже оси кнопок;
- на мобильном липкий ряд .administration-actions отключается, если в разделе
больше одной карточки: он висел бы под всеми, сохраняя свою, а отрицательный
margin вылезал за карточку и накрывал соседнюю;
- TextAreaField научился disabled — поле адресов TURN было единственным, что
редактировалось без прав.
У «Платформы» нет кадра в базлайне (там ровно N1–N7) — собрана по стандарту N6.
Проверено: tsc + vitest 77 OK. Живой экран не открывался — вход под паролем,
геометрия снята с копии раздела на реальном CSS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Product` оставался в схеме «скрытой технической осью» (ADR-HUB-0041 §7), но
из интерфейса не исчез: в настройках портала жил раздел «Продукты и поддержка»,
в списке порталов — колонка «Продукты». Решение владельца — убрать сущность
целиком вместе со всем контуром, который на ней держался.
Удалено:
- приложения `products` и `support` (контракты идентификации, снимки личности,
Product Support Token, публичные endpoints сессии);
- `Channel.product` и `requires_authenticated_product_identity` (инварианты
P3-P5; остались P1-P2), `LlmInvocation.product`, `SupportPortalProduct`;
- `WebChatWidget.mode` целиком: различать было нечего, у веб-виджета одна
анонимная точка входа. Ключ `mode` ушёл и из ответов API виджета;
- `mode=support` и `ChatballsChat.init` в лоадере, `SupportApp` в web-chat;
- capability `products.*`, аудит-действия `products.*`, продукты в карточке
контакта и в статистике (`byProduct`);
- раздел настроек портала и колонка списка — в коде и в дизайн-базлайне v2.
У диалога один источник identity — контакт: XOR заменён на
`conversation_requires_contact`. `PUT /portals/{id}/products/` удалён,
`GET /portals/{id}/support-channels/` → `GET /portals/{id}/widgets/`.
Миграции — по образцу сноса продаж: исторические миграции уцелевших приложений
вычищены от ссылок (чистая установка объектов не создаёт), существующие БД
чинит `tenancy/0029_drop_product_support`. Данные не экспортируются, но диалоги
со снимком личности не удаляются — каждому создаётся контакт с именем из снимка.
Проверено: backend 583 OK, фронт tsc + vitest 77 OK, сборки internal-ui и
web-chat OK, миграция прогнана на локальной БД, публичный портал открывается.
В коммит также вошли накопленные незакоммиченные правки рабочего дерева
(библиотека знаний, настройки, dev-compose и nginx, макеты базлайна).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Первичное требование: поднял докер — прошёл мастер — дальше всё в интерфейсе.
Ни одной переменной окружения задавать не нужно и негде.
Запуск и секреты:
- .env.example удалён, мёртвые переменные вычищены; секреты инстанса
генерирует одноразовый сервис secrets в именованный том, пароли БД —
через POSTGRES_*_PASSWORD_FILE;
- Caddy: catch-all :80 и on_demand TLS вместо хостов в конфиге — свежая
коробка отвечает по IP и по любому домену, до мастера дойти можно;
- адрес установки, SMTP и TURN переехали в настройки (InstanceSettings,
миграции 0027–0030), внешние ссылки строятся от него;
- deploy/cli больше не читает .env; сборка образов в ghcr через GitHub
Actions, release.env с digest-пинами;
- куки Secure/__Host- выставляются по факту TLS запроса, а не настройкой.
Порталы (дизайн-базлайн v2, кадры PT1–PT8):
- список, карточка портала, библиотека материалов, редактор статьи и
настройки — ширины и ритм как в остальных разделах;
- файлы статьи: изображение вставляется в текст своим механизмом, файл
прикрепляется вложением и выводится на портале списком с иконкой формата;
- ссылки на файлы приводятся к относительным: абсолютный хост резал CSP
портала и картинка не появлялась;
- «Опубликовать» сверяется с сервером и публикует то, что на экране, а не
ранее выбранную редакцию;
- колонка «Оценки» в списке статей и блок оценок в редакторе.
Первый запуск: полоса «демо-данные устанавливаются» — установка идёт в
worker, и без неё человек видел пустые разделы без объяснения.
Починен фон: вторичное хранилище стало best-effort — при живых остаточных
S3-ключах повторная загрузка файла падала в 500.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Агенты (кадры G1–G5, S1): список с порядком активные → на паузе →
отключённые, цвет плитки по id агента, подключения и знания на карточке,
диалог выбора знаний, стоимость и лимиты.
Сотрудники (кадры E1–E10): список, карточка, панель создания в четыре шага,
пароль первичного доступа (показать или письмом), завершение сессий,
передача владения. Бэкенд: employee_password.py, reset-password и
revoke-sessions вместо заглушек, password_changed_at.
Общие контролы: один поиск и один фильтр-селект на всё приложение —
SearchInput с реакцией на фокус и клавишей «/», FilterDropdown на antd
Dropdown вместо нативного select; копии в контактах и списке диалогов
удалены. Ширина списков Сотрудники/Контакты/Агенты ограничена колонкой
1220px как в макете вместо растяжки на всё окно.
Тем же коммитом уходит незакоммиченная работа предыдущих сессий этого дня:
Профиль отдельной страницей, субменю Настроек, интеграции, метаданные
сессий и TOTP, объединение контактов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Тема из архива владельца: синий акцент, тёмно-синие чернила, крупные
скругления, полная тёмная схема. Переопределяет 27 из 30 токенов контракта
(шрифт текста, моно и pill-скругление оставлены базовыми), все цветовые
токены продублированы в тёмной схеме.
Шрифт заголовков Nunito (переменное начертание 400–800, SIL OFL 1.1) скачан
с Google Fonts и положен в themes/ocean/fonts вместе с лицензией: раздача с
собственного домена, стороннего CDN на публичной странице нет. Исходный
@font-face ссылался на отсутствующий файл — заменён четырьмя субсетами
(cyrillic, cyrillic-ext, latin, latin-ext) с unicode-range, как у Google
Fonts, чтобы браузер тянул только нужный файл.
Проверено: тема видна и выбирается в «Настройки → Оформление», тесты
каталога проходят, сборка кладёт шрифты и CSS темы в отдельные чанки без
неразрешённых ссылок, токены и загрузка Nunito подтверждены в браузере на
светлой и тёмной схемах.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Тема портала — папка в apps/internal-ui/src/features/help-center/themes/ с
manifest.ts и theme.css; реестр собирается автообнаружением, регистрировать
тему больше нигде не нужно. Вёрстка Help Center переведена на контракт из 30
токенов --help-* (themes/contract.css): прямых --n-*, --surface-* и
литеральных цветов в ней не осталось, значения по умолчанию равны прежним —
вид не изменился, кроме ховера кнопки поддержки (#000 → color-mix, иначе
ломается тёмная схема) и скруглений скелетонов (8→7, 6→5 px).
Портал хранит theme, theme_scheme (LIGHT/DARK/SYSTEM) и theme_settings
(JSON, зарезервирован под параметры тем); каталог тем бэкенду неизвестен,
проверяется только формат идентификатора. Неизвестная тема деградирует до
classic на публичной странице и помечается недоступной в настройках. Схема
применяется атрибутом data-theme — тем же механизмом, что персональная тема
хаба; SYSTEM следует prefers-color-scheme. Тема применяется до первого кадра
контента, CSS темы грузится ленивым чанком.
Настройки: секция «Оформление» в карточке портала на стандартных
SelectField. Тесты каталога (registry.test.ts) заодно линтят правила для
авторов тем: id == имя папки, скоуп [data-portal-theme], префикс @keyframes,
только токены контракта. Сами темы не разрабатывались — поставляется
базовая classic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Порталы: заголовок «Порталы» и подпись без «отдела поддержки», счётчики со
склонением («1 портал», «7 материалов»), статусы статей в таблице и в
фильтре — одни подписи (Черновик · Опубликована · В архиве), поле «Адрес
портала» вместо «Адрес Chatballs». Знания: колонки «Агенты»/«Статус» больше
не налезают друг на друга, счётчик со склонением, агенты в карточке знания
окрашены как в чате (agentColorOf). StatusPill принимает подпись.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Контакты: список и карточка показывают фото контакта (как в чате); в шапке
карточки описание, компания и город, правка карандашом той же формой, что в
контекст-панели (общий ContactEditForm); PATCH /conversations/clients/<id>/
(customers.manage) с теми же полями и пределами, аудит; вкладка «Consent» →
«Согласие». Маршруты: /contacts, /contacts/<id>, /portals, /portals/<id>
(старые /departments/... по-прежнему открываются).
Агенты: подсказки ведут в «Настройки» (AI-провайдер, Интеграции), ссылка
«Открыть настройки». Сотрудники: цветные точки групп в карточке и мастере.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Пункт навигации «Доска» → «Порталы», иконка lucide globe.
- LogoSpinner: логотип «дышит» — круг делится на два с перемычкой и сходится
обратно (CSS keyframes, зациклено, reduced-motion выключает). Заменил все
текстовые «Загрузка…»: LoadingState/LoadingScreen, композер, дерево
категорий знаний, ключ TOTP, статус привязки бота уведомлений.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Просмотр фото: без карточки и полей — снимок по центру на затемнённом фоне
(до 90vw × 82vh), над ним одна строка: имя, размер, «Скачать», закрыть.
- В баре пользователя под именем только почта.
- Вкладки «Все · Мои · Ждут оператора»: overflow-y hidden — вертикальная
полоса появлялась из-за margin-bottom: -1px при overflow-x: auto.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Модель расшифровки голосовых — поле «Модель расшифровки голосовых» в
настройках AI-провайдера (config.transcription_model, по умолчанию whisper-1);
CHATBALLS_AI_TRANSCRIPTION_MODEL из settings/.env убран.
- «Расшифровка AI · Показать» — та же подпись и размер, что «… Скрыть», с
отступом 8px от плеера.
- Композер: поле растёт вверх под текст до 7 строк, дальше скролл.
- Меню пользователя без «Настроек» (настройки системы — в навигации).
- Фото в ленте открывается в попапе (antd Modal): имя, размер, «Скачать».
- Смена диалога — короткое появление ленты (180ms, уважает reduced-motion).
- Ширина сайдбара (180–320) и списка диалогов (280–520) тянется за край,
двойной клик сбрасывает, значение хранится в браузере.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
AI: входящее голосовое (TG/MAX/виджет/портал) расшифровывается BYOK-провайдером
при входе, AI отвечает текстом по стенограмме; стенограммы участвуют в истории
контекста. Без расшифровки (провайдер не умеет/недоступен, FAILED) — диалог
оператору с уведомлением, как прежде. Демо-провайдер честно отказывает
(ProviderError) вместо текста-заглушки. Кнопка «Расшифровать» использует тот
же helper (transcribe_voice_message).
Портал поддержки: POST /support/sessions/messages/ принимает multipart audio и
file (+подпись), отдача /messages/<id>/audio|attachment/?credential=; в poll —
kind/hasAudio/durationSeconds/attachment; старт сессии возвращает features.
Виджет support-режима: запись голосового, скрепка, плеер и карточки.
Настройки → «Голосовые и звонки»: матрица точек входа × Голосовые /
Аудиозвонки / Видеозвонки (Integration.voice_messages_enabled,
audio_calls_enabled, video_calls_enabled; integrations/0007;
GET/PATCH company/administration/communication/). Флаги отдаются в
conversation.connection и в config/сессии виджетов: композер прячет микрофон,
карточка контакта — кнопки звонка, бэкенд отклоняет запись/звонок (400/403).
Почта звонки не поддерживает («недоступно»).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Отправка голосового оператором подчиняется тем же правилам, что текст и файл:
первая реплика перехватывает диалог у AI/очереди (раньше — 403 без назначения),
user_id берётся из identity (MAX без chat_id), expected_responder → клиент.
Почта: голосовое уходит письмом с аудио-вложением (EMAIL в _VOICE_SEND),
микрофон в композере доступен во всех каналах.
Telegram: audio/video/video_note/animation принимаются файлами (voice — как
прежде голосовым); MAX: video-вложение файлом.
Список диалогов: иконка микрофона перед превью голосового (макет d.voice).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Сообщение kind=file с вложением (имя, тип, размер) в тенантном хранилище.
Приём: Telegram (document/photo через getFile), MAX (attachments file/image),
почта (вложения письма вместо заглушки «не поддерживаются»), web-виджет
(multipart file + подпись). Файл без текста уходит оператору, как голосовое.
Отправка оператором: POST /conversations/<id>/attachments/ (файл + подпись,
перехват диалога как у текста) — Telegram sendDocument/sendPhoto, MAX /uploads,
почта MIME-вложение, Web поллингом. Отдача: /messages/<id>/attachment/ (?inline
для фото/PDF) и /webchat/messages/<id>/attachment/ по токену сессии.
Ограничения: 20 МБ, исполняемые типы отклоняются.
UI: кнопка «Прикрепить» (32px, как в макете), чип файла над полем, вставка из
буфера; в ленте — превью фото или карточка файла со ссылкой «Скачать» (.link);
превью в списке «Фото / Файл · имя». Виджет: скрепка в композере, чип, карточки.
email.py: выправлены двойные CR в концах строк.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- tenancy.StorageSettings (одна строка на инстанс): тип хранилища, бакет, endpoint,
регион, адресация, ключи (шифруются Fernet); статус проверки и переноса
- динамические бэкенды DynamicTenantStorage/DynamicUserStorage: пишут в активное
хранилище, читают/удаляют из обоих (файлы до переключения доступны без переноса);
конфигурация из БД с кэшем 10 с — без .env и перезапуска
- API: GET/PATCH company/administration/storage/ (переключение на S3 только после
успешной пробной записи), POST …/check/ (быстрая проверка, без ретраев boto),
POST …/migrate/ — перенос локальных файлов в бакет фоновой задачей worker'а
- UI: секция «Хранилище файлов» в «Настройках» — переключатель, реквизиты,
«Проверить доступ», «Сохранить», «Перенести файлы с диска в S3» с прогрессом
- из .env убраны CHATBALLS_STORAGE_BACKEND/CHATBALLS_S3_*; production больше не
требует S3
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- нет зашитых доменов: help-домен по умолчанию localhost (задаёт установщик через
CHATBALLS_HELP_BASE_DOMAIN), фронт определяет портал помощи пробой /api/v1/help/,
ALLOWED_HOSTS без вендорских хостов, CI environment url — из CHATBALLS_APP_DOMAIN
- DEFAULT_FROM_EMAIL по умолчанию no-reply@localhost; JWT-аудитория support-токена
chatballs.support; загрузчик виджета: window.ChatballsChat, события chatballs-chat-*
- «Контакты»: продукты берутся из данных организации ({code, name}), цвет —
детерминированно по коду; зашитый список продуктов вендора убран
- npm-скоупы @chatballs/*, тема chatballsTheme/buildTheme
- тестовый bootstrap: организация demo, сотрудник staff.member@example.org,
продукты site/app; фикстуры на example.com
- убрана мёртвая привязка Shell к e-mail сотрудника вендора
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- каталог code/custocrm → code/chatballs; CLI custocrm → chatballs; пакеты
hub_platform → chatballs, hub_backend → chatballs_backend (app labels прежние)
- переменные CUS_* и CUSTOCRM_* → CHATBALLS_*; образы chatballs-*; compose-проект
и база chatballs (были edevs_hub); роли Postgres chatballs_* (были custocrm_*);
схема RLS chatballs и GUC chatballs.organization_id; cookie chatballs_*
- CI: APP_DIR code/chatballs, DEPLOY_ROOT /opt/chatballs
- deploy/migrate/rename-to-chatballs.sh — миграция существующей установки без
потери данных: остановка старого проекта, .env (с резервной копией),
переименование суперпользователя initdb через временную роль, остальных ролей,
базы, схемы и функции RLS; проверено на локальном стеке
- dev-стек хранит Postgres в bind-mount data/postgres, как prod (именованный том
compose.dev был устаревшим снимком и вводил в заблуждение)
- снятие демо удаляет объекты, созданные поверх демо-данных тестировавшим
(звонки по демо-диалогу), вместо падения на PROTECT
- реальные домены *.custocrm.ru и идентификаторы документов не тронуты
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- LogoIcon: два круга и перемычка с вырезами, fill currentColor; в сайдбаре,
рейке, экране входа/мастера, портале помощи — без акцентной/тёмной подложки,
цветом текста темы (--n-1); favicon без фона, цвет по prefers-color-scheme
- видимое имя сервиса — Chatballs (заголовки, тексты, title); внутренние ключи
(localStorage, postMessage, id виджета) пока прежние — переименование проекта позже
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- обои «Chatbolls — Lucide Pattern» (макет владельца): филлотаксис по золотому углу
с тремя вихрями, те же 25 иконок, размеры, прозрачности и цвета двух тем
(серо-зелёная #dedfd9/#5d635d, графитовая #25292e/#f1f2f3); на экранах входа и
за лентой чата; лента принимает фон обоев (--surface-feed)
- верхней панели нет ни у одной роли; заголовок «Настройки» — в самой странице;
«Знания» и «Аудит» — ссылки из «Настроек», навигация менеджера — ровно 6 пунктов
- фото сотрудников: Avatar с img, загрузка/удаление в «Настройки → Профиль»,
в сайдбаре, подписях сообщений, поле «Ответственный», поповере охвата
- карточка контакта (решение 5): описание, компания, город; карандаш у имени —
inline-правка имени/описания/телефона/компании/города
- цвет группы из настроек (свотчи в модалке группы) — точки в дереве, поповере,
строках списка и меню переноса
- композер: кнопка «Эмодзи» (antd Dropdown с сеткой), как в макете
- история контакта (кадр F): тема — первая реплика клиента, «вела Анна Ким» / «AI»
- список: превью без системных событий; сообщения с одинаковым временем — по id
- демо: длительности голосовых, история Марии в архиве (в списке ровно 7 строк),
Дмитрий ждёт 6 мин; БД: SET NULL на FK аудита/outbox к пользователю и права на
каскад удаления — снятие демо runtime-ролью
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- демо «Ателье Норд» повторяет кадры: сотрудники Елена Кузнецова и Анна Ким (фото),
группы Операторы/Поддержка/VIP-клиенты с цветами, агенты Консультант · Поддержка
сайта · Личный кабинет, семь видимых диалогов A–H с точными сообщениями, история
Марии (кадр F), метки строчными; покрытие остальных функций — в архиве и спаме
- голосовые из Mozilla Common Voice 26.0 (ru, CC0): 4 клипа OGG/Opus, расшифровки
совпадают с записью; README с источниками
- HumanUser.avatar (+ хранилище users/ вне тенантного ограждения), профиль:
GET/POST/DELETE /auth/profile/avatar/, коллегам — /employees/<id>/avatar/;
avatarUrl в сессии, сотрудниках, ответственном, авторе сообщения, счётчиках
- Contact.description/company/city, правка из диалога POST <id>/contact/
- EmployeeGroup.color (HEX) в API групп, счётчиках, справочнике, диалоге
- гость виджета называется «Гость · xxxxxx» (кадр M1); гость может представиться в сиде
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>