# IMPLEMENTATION_LOG — доведение iistwin до коммерческой готовности ## Контекст для любого ИИ-агента / разработчика **Проект:** iistwin (TaskTitan) — CRM/таск-трекер. Стек: React 18 + TypeScript + Vite + Wouter + TanStack Query + shadcn/ui (клиент `client/src`), Express + Drizzle ORM + PostgreSQL с мультитенантностью через `organizationId` (сервер `server/`), JWT (access + refresh), SSE real-time. Миграции — детерминированный SQL-раннер в `migrations/` (`scripts/migrate-docker.js`, НЕ drizzle-kit). Деплой: push в `main` → GitHub Actions `deploy.yml` → Docker на VPS 82.202.130.22 (прод: https://iistwin.ru). Финансы — субапп `/di2` (`client-di2/` + `server/finance-di2/`). **Инвариант (жёсткое требование владельца):** механика мгновенного глобального поиска — на главной (`client/src/components/HomeGlobalSearch.tsx`), в реестре задач (`TaskRegistry.tsx`) и в канбане (`TaskKanban.tsx`) — НЕ должна измениться ни в поведении, ни в скорости ощущений. API `GET /api/tasks?search=&limit=` и подстроковая семантика поиска (`ilike '%…%'` находит совпадения внутри слов, «омент» → «моментально») сохраняются. Ускорение поиска — только через pg_trgm GIN-индексы, НЕ через tsvector (он меняет семантику). **Источники:** этот лог ведётся по плану «план улучшения до продакт.md» (аудит production-готовности) и исполнительному плану Kimi Code. Нумерация шагов ниже соответствует исходному плану (Фаза 0: 0.0–0.15, Фаза 1: 1.1–1.10, Фаза 2: 2.1–2.5). Дополнительный шаг A3 (подключение тестов) вынесен вперёд из 1.6. **Принятые решения:** - Биллинг (0.13): только абстракция `PaymentProvider` + `MockPaymentProvider`; реальный эквайринг — позже, отдельным этапом. - Шаг 0.14: серверные кастомизации — в отдельный приватный инфра-репозиторий (основной репозиторий остаётся как есть). **Как читать файл:** записи идут в порядке выполнения (не обязательно совпадает с нумерацией). Каждая запись — по шаблону: статус, зачем, что изменено, ключевые файлы, коммит, как проверялось, влияние на поиск/UX, подводные камни. Ничего не менять без записи в этот лог. --- ## [0.0] Обновить локальный репозиторий - Статус: ✅ done - Зачем: рабочая копия должна соответствовать актуальному `main` на GitHub. - Что изменено: — (только проверка) - Как проверялось: `git fetch origin` + `git status` (чисто, синхронизировано с origin/main, HEAD `a2b231d`), `npm run check` — зелёный (оба tsc: основной и client-di2). - Влияние на поиск/UX: нет. --- ## [0.15] Сквозная документация изменений (этот файл) - Статус: ✅ done (коммит `76a0d85` «chore(docs): add implementation log») - Зачем: по одному файлу любой ИИ-агент понимает, что сделано, зачем, какие файлы тронуты и как проверялось. - Что изменено: создан `IMPLEMENTATION_LOG.md` в корне репозитория (шапка-контекст + шаблон записей). - Влияние на поиск/UX: нет. --- ## [A3 / 1.6 частично] Подключение тестов + починка 5 падающих - Статус: ✅ done - Зачем: тесты существовали (3 файла, vitest), но не запускались ни локально (нет скрипта), ни в CI; 5 из 45 падали. Без зелёных тестов дальнейшие изменения безопасности/производительности — вслепую. - Что изменено: - `package.json` — скрипт `"test": "vitest run"`. - `.github/workflows/pr-check.yml` — шаг «Tests» (`npm test`) после type check. - `server/utils/userActivity.ts` — **фикс реального бага**: `trackUserActivity` мог кинуть синхронно внутри `authenticateToken`, исключение попадало в catch аутентификации → ложный `403 Недействительный токен` на первом запросе пользователя за минуту. Тело обёрнуто в try/catch, throttle сбрасывается для повторной попытки (соответствие контракту fire-and-forget). - `tests/auto-transitions.test.ts` — 3 устаревших теста обновлены под намеренное текущее поведение (forward-only по position; синк assignee в task_assignees; системное сообщение заменено аудит-записью «Автопереход»), добавлен мок `delegation.service`. - Как проверялось: `npx vitest run` — 45/45 зелёные; `npm run check` — чисто. - Влияние на поиск/UX: нет. - Подводные камни: баг в `userActivity.ts` проявлялся только на первом аутентифицированном запросе в минуту (throttle) — в проде мог давать sporadic 403. --- ## [0.7] Обработчик ошибок без утечки деталей - Статус: ✅ done - Зачем: клиент не должен видеть SQL и внутренности БД в 5xx-ответах (аудит, Фаза 0). - Что изменено: - `server/index.ts` — глобальный error handler: при status ≥ 500 отдаёт «Внутренняя ошибка сервера (код XXXXXXXX)», оригинальная ошибка — в серверный лог с тем же кодом инцидента. 4xx не тронуты. - Route-уровень (12 файлов): зачищены все 5xx-ответы с `err.message`/`String(err)`/телом апстрима — `auth.users.routes.ts`, `form-offline.routes.ts`, `sync.routes.ts`, `polls.routes.ts`, `reactions.routes.ts`, `mcp-rag.routes.ts`, `llm-providers.routes.ts`, `external.routes.ts`, `task-crud-write.routes.ts`, `task-fields.routes.ts`, `finance-di2/routes.ts`, `documents/worker/server.ts`. Где не было — добавлен `console.error` с контекстом. - Как проверялось: повторный grep `status(5xx)` + `err.message` по `server/` — 0 совпадений; `npm run check` чисто; `npx vitest run` 45/45. - Влияние на поиск/UX: нет (только тексты 5xx-ответов). - Подводные камни: остались ответы `200 + success:false` с `err?.message` (llm-providers, rag, finance-di2) и 4xx с `err.message` (documents, data-tables) — сознательно вне скоупа шага, кандидаты для фазы 2. --- ## [0.8] Убрать passwordHash из выдачи пользователей - Статус: ✅ done - Зачем: хэши паролей и токены не должны уходить клиенту (в т.ч. через offline-sync). - Что изменено: - `shared/schema.ts` — тип `SafeUser` (User без passwordHash/verificationToken/resetPasswordToken/resetPasswordExpires) + набор колонок `safeUserColumns` (единая точка правды). - `server/storage/users.storage.ts` — `getUsersByOrganization` и `listUsers` → `SafeUser[]`. - `server/storage/social.storage.ts` (`searchUsers`), `server/storage/task-meta.storage.ts` (`getUsersByRole`, `getUsersByOrgRoleId`), `server/documents/data-resolution.service.ts` (getUser для шаблонов) — тоже на `safeUserColumns`. - Сигнатуры в `server/storage.ts`; типовые правки у caller'ов (логика не менялась). Методы аутентификации (где passwordHash нужен) не тронуты. - Побочный эффект: `ctx.users.list()` в автоматизациях и MCP `list_users` тоже sanitized. - Как проверялось: ни один из 43 caller'ов не использовал исключённые поля (tsc); sync (initial + delta) теперь отдаёт пользователей без хэшей; `npm run check` чисто; `npx vitest run` 45/45; grep `passwordHash` по server/ — только auth-флоу. - Влияние на поиск/UX: нет. - Подводные камни: при явном списке колонок drizzle возвращает плоские строки даже с join — мёртвый маппинг в listUsers убран, на это опираться нельзя в будущих правках. --- ## [0.11] Миграция индексов (0079_performance_indexes.sql) - Статус: ✅ done - Зачем: убрать Seq Scan на горячих путях — поиск задач, дельта-синк, история полей, логин по email. - Что изменено: `migrations/0079_performance_indexes.sql` — `CREATE EXTENSION IF NOT EXISTS pg_trgm`; GIN (gin_trgm_ops) на `tasks.title` и `tasks.description`; `(organization_id, updated_at DESC)` и `(form_id, updated_at)` на tasks; `field_history(task_id)`; `users(email)`. Все с `IF NOT EXISTS`, без CONCURRENTLY (раннер в транзакции). - Как проверялось: миграция применена на scratch-контейнере postgres:16-alpine с 100k синтетических задач — все 7 операторов чисто. EXPLAIN ANALYZE поиска `ilike '%омент%'` (совпадение внутри слова — инвариант): полный проход 77.5 мс (Seq Scan) → 4.8 мс (Bitmap Index Scan по trgm + BitmapAnd с org-индексом), ускорение ~16×; с LIMIT 50 — 10.8 мс → 9.7 мс. Отдельно проверено: `CREATE EXTENSION pg_trgm` выполняется не-суперпользователем-владельцем БД (trusted extension, PG 13+) — на проде миграция пройдёт под appuser. - Влияние на поиск/UX: семантика НЕ меняется (тот же ilike, тот же API) — только скорость. - Подводные камни: на проде построение GIN-индексов без CONCURRENTLY кратковременно блокирует запись в tasks — при текущих объёмах (десятки тысяч задач) это секунды, приемлемо. --- ## [0.5] Чистый production-образ (без devDependencies) - Статус: ✅ done (коммиты `a54ec77` (Dockerfile), `9fc6ad1`, `5426c70` (фиксы запуска)) - Зачем: в прод-образ не должны попадать typescript/vite/vitest — поверхность атаки и размер. - Что изменено: - `Dockerfile` — production-стадия: `npm ci --omit=dev` вместо копирования node_modules из builder. - `package.json` — vitest/supertest/@types/* (16 пакетов) перенесены в devDependencies (именно `vitest` в dependencies тянул vite/esbuild/tsx в прод-образ). - `server/vite.ts` — vite и vite.config импортируются динамически (dev-only), vite.config — по вычисляемому URL. - Как проверялось: локальная сборка образа; бандл стартует без devDeps (проверка до подключения БД); `grep` — 0 статических импортов vite в dist/index.js. - Влияние на поиск/UX: нет. - **Инцидент и подводные камни (важно для будущих агентов):** 1. Правки Dockerfile ушли в main досрочно (попали в коммит 0.11 через `git add -A`) — автодеплой поднял их до завершения smoke-теста. Правило: коммитить явным списком файлов, не `git add -A`. 2. Первое падение: `ERR_MODULE_NOT_FOUND @vitejs/plugin-react` — `server/vite.ts` статически импортировал vite/vite.config; раньше спасало наличие всех devDeps в образе. 3. Второе падение: esbuild без `--splitting` ИНЛАЙНИТ статический `import("../vite.config")` в бандл и поднимает его импорты на верхний уровень — динамический импорт должен быть по вычисляемому пути (`new URL(...)`, esbuild не анализирует). 4. Локальная проверка `node dist/index.js` НЕ ловит такие ошибки, если локальный node_modules содержит devDeps — резолвится молча. Проверять grep'ом по бандлу или в чистом окружении. 5. Даунтайм продакшена ~25 минут (краш-луп до фикса `5426c70`). ## [0.2] Контейнер не от root - Статус: ✅ done (коммит `a54ec77`) - Зачем: компрометация приложения не даёт root в контейнере. - Что изменено: `Dockerfile` — `adduser -D app` (uid 1001), `chown -R app:app /app`, `USER app`, `HOME=/tmp`, `npm_config_cache=/tmp/.npm`, `PYTHONDONTWRITEBYTECODE=1`; `docker/Dockerfile.documents` — аналогично (там уже был `npm ci --production`). - Как проверялось: `docker run --entrypoint id` → uid=1001(app); `/app/data` и `/tmp` доступны на запись от app; document-worker на проде поднялся и healthy сразу. - Влияние на поиск/UX: нет. - Подводные камни: named volume `crm_crm_data` на проде был root-owned — на сервере заранее выполнен `chown -R 1001:1001` на `crm_crm_data` и `crm_crm_uploads` (иначе app не смог бы писать в /app/data). При развёртывании на новых серверах — учитывать. --- ## [0.10] Лимиты и пагинация в /api/tasks - Статус: ✅ done - Зачем: листинг не отдаёт до 10000 полных строк (аудит, Фаза 0). - Что изменено: - `server/utils/task-pagination.ts` — `clampTasksLimit` (дефолт 50, кап 200), курсор `_` (parse/build). - `server/storage/tasks-core.storage.ts` — дефолт limit 50/кап 200 на уровне storage; опция `excludeDescription` (списковые ответы без тяжёлой text-колонки); курсорная пагинация `(updated_at, id)` с тай-брейкером; новый `getTasksCountByOrganization`. - `server/routes/task-crud-list.routes.ts` — кап на роуте, параметр `cursor`, в ответе `nextCursor`; ключ minimal-кэша включает limit/курсор. - Caller'ы: MCP search_tasks limit 500→200; embedding.service — pre-count через `getTasksCountByOrganization` вместо выгрузки миллиона строк; ShareTarget.tsx — явный `?limit=200`. - `tests/task-pagination.test.ts` — 11 юнит-тестов (кламп, курсор, round-trip). - Как проверялось: `.toSQL()` — курсор и тай-брейкер корректны, списковый SELECT без description; `npm run check` чисто; `npx vitest run` 56/56. - Влияние на поиск/UX: search-ветка не изменена (SQL, лимиты, формат) — инвариант сохранён. Дельта: список без параметров — 50 строк вместо 10000, без description; кастомные JS-виджеты (Bots.tsx, CustomPageView) получают задачи без description (при необходимости дотягивают деталь). - Подводные камни: `/api/forms/:id/tasks` и `/with-fields` НЕ тронуты (там description остаётся — реестр/канбан/Гантт используют их). ## [0.9] Дельта-синхронизация: фильтр на уровне SQL - Статус: ✅ done - Зачем: sync не выгружает все задачи всех форм (аудит, Фаза 0). - Что изменено: `getTasksWithFieldsByFormOptimized` и `getUsersByOrganization` принимают `since?: Date` → `WHERE updated_at >= since OR updated_at IS NULL` (паритет со старым JS-фильтром); JS-фильтры в `sync.routes.ts` удалены; initial sync не тронут. - Как проверялось: `.toSQL()` — условие в SQL; индекс `tasks_form_updated_idx` (0079) покрывает; vitest 56/56. - Влияние на поиск/UX: нет. - Подводные камни: `OR IS NULL` обязателен — старые строки с NULL updated_at должны попадать в дельту (паритет с `!t.updatedAt ||` в JS). --- ## Регрессия поиска после 0.10/0.9 (инвариант) — ПРОЙДЕНА - Подстрока внутри слова на проде: «емон» → находит «Ремонт…», «отельн» → находит «Котельная…» (через API, тот же путь, что HomeGlobalSearch). - EXPLAIN ANALYZE на проде (1002 задачи): Index Scan по `tasks_org_updated_idx`, Execution Time 0.806 мс — мгновенность сохранена с запасом. - Формат search-ответа не изменён; списковые ответы без description — MCP list_tasks подтверждает. --- ## [0.12] getAccessibleTaskIds: кэш + инвалидация - Статус: ✅ done - Зачем: самый горячий эндпоинт перестаёт деградировать линейно (аудит, Фаза 0). Переписывание EXISTS-цепочки на чистый SQL отложено до замеров после кэша (по плану). - Что изменено: - `server/utils/cache.ts` — `accessibleTasksCache` (TTL 45 сек), ключ `accessible::`, метод `invalidateSuffix`, хелперы точечной (per-user) и org-wide инвалидации. - `server/storage/system.storage.ts` — кэш внутри `getAccessibleTaskIds` (все caller'ы получают его автоматически, код роутов не тронут); null (admin/view_all) кэшируется отдельно от miss. - Инвалидация проставлена в storage-слое (покрывает роуты и MCP): assignees/roles/formAccess/delegation/form visibility/updateUser appRole/createTask/updateTask assignedTo. - Как проверялось: `npm run check` чисто; `npx vitest run` 65/65 (новый tests/accessible-tasks-cache.test.ts — TTL, null vs miss, invalidateSuffix, LRU, изоляция per-user). - Влияние на поиск/UX: нет (форматы ответов не тронуты). - Подводные камни: stale-доступ до 45 сек теоретически возможен только при обходе storage-методов (прямые UPDATE в БД) — все кодовые пути покрыты инвалидацией; кэш in-memory — при multi-instance нужен Redis (та же оговорка, что у существующих кэшей). --- ## [0.13] Биллинг: PaymentProvider + MockPaymentProvider - Статус: ✅ done - Зачем: самообслуживание оплаты без superadmin; реальный эквайринг (ЮKassa/CloudPayments) позже без переписывания (решение пользователя — только mock + интерфейс). - Что изменено: - `server/billing/payment-provider.ts` — интерфейс PaymentProvider + фабрика по env `PAYMENT_PROVIDER` (дефолт mock). - `server/billing/mock-provider.ts` — mock: externalId mock_, webhook-подпись sha256 (timingSafeEqual), secret env `MOCK_PAYMENT_SECRET`. - `server/billing/payments.service.ts` — createBillingPayment (ON CONFLICT по idempotency_key), `applySucceededPayment`: транзакция с атомарным guard pending→succeeded (дубли webhook невозможны), credit в billing_transactions, инкремент balance, авто-снятие billingBlocked при балансе > 0 (та же логика, что superadmin unblock). - `server/routes/billing-payments.routes.ts` — POST/GET /api/billing/payments (billing.manage), POST /:id/confirm-test (только mock, иначе 404), публичный POST /api/billing/webhooks/:provider (401 невалидная подпись, идемпотентность). - `migrations/0080_billing_payments.sql` + таблица в shared/schema.ts (UNIQUE external_id/idempotency_key). - `client/src/pages/Billing.tsx` — фикс setLocation в теле рендера (useEffect), кнопка «Пополнить» с диалогом, секция «Платежи», «Подтвердить (тест)» у pending при mock (признак paymentProvider в /api/billing/summary), текст блокировки про авто-восстановление. - `tests/billing-payments.test.ts` — 13 тестов. - Как проверялось: vitest 78/78 (дедуп по Idempotency-Key, повторный webhook не зачисляет дважды, изоляция организаций, 404 confirm-test при не-mock, 401 по подписи); `npm run check` чисто. - Влияние на поиск/UX: нет. - Подводные камни: на проде PAYMENT_PROVIDER не задан → mock активен, кнопка «Подтвердить (тест)» видна админам организаций — это и есть поставка шага; при подключении реального провайдера кнопка скроется автоматически. Подключение реального провайдера: новый класс в server/billing/ + case в фабрике + env (см. отчёт в коде payment-provider.ts). --- ## [1.1] Data-tables: фильтрация на уровне SQL - Статус: ✅ done - Зачем: справочники не грузят все строки/таблицы ради фильтрации в JS (Фаза 1). - Что изменено: - `server/storage/data-tables-core.storage.ts` — `getDataTablesWithAccess` без N+1 (2 запроса вместо 1+N); новый `getDataTableRowsPaged` (SQL: фильтры `values->>N ILIKE ESCAPE`, поиск `values::text ILIKE`, сортировка lower()+тай-брейкер position/id, LIMIT/OFFSET); `getDataTableRowCounts` (один GROUP BY); `moveDataTableRow` — siblings SQL-запросом. - `server/utils/data-table-rows-query.ts` — escapeIlikePattern, капы limit (дефолт 100, макс 1000)/offset. - `server/routes/data-tables.routes.ts` — GET /rows с limit/offset/search → SQL-путь `{rows, total}`; без параметров — прежний JS-путь (сортировка localeCompare('ru') сохранена 1:1 для текущего фронта). - `server/mcp.ts` — list_directory_rows (flat) на SQL-пагинацию; list_directories rowCount одним запросом. - Tree-режим сознательно не тронут: дерево строится из всех строк, пагинация неприменима. - Как проверялось: vitest 88/88 (10 новых тестов хелперов); `npm run check` чисто. - Влияние на поиск/UX: нет (TableEditor попадает в legacy-ветку). - Подводные камни: `/api/directories/:id/column-values` всё ещё грузит все строки (прерывается на 20) — кандидат на следующий заход (SELECT DISTINCT values->>N LIMIT); фронт TableEditor пока не использует SQL-пагинацию (эндпоинт готов). ## [1.2] /column-values: лимиты и чистка горячего пути - Статус: ✅ done - Зачем: cap выборки, убрать console.log с горячего пути (Фаза 1). - Что изменено: `server/routes/task-crud-list.routes.ts` — убраны 7 debug-логов на запрос + per-task лог contract-number; параметр limit (дефолт 20, кап 1000). - Как проверялось: выборка покрыта индексом tasks_org_id_form_id_idx — новый индекс не нужен; vitest 88/88. - Влияние на поиск/UX: нет. --- ## [1.4] SSE: индекс соединений - Статус: ✅ done - Зачем: рассылка событий не перебирает все соединения; один heartbeat вместо таймера на соединение (Фаза 1). - Что изменено: `server/utils/sse-connection-index.ts` (новый класс: byId/byOrg/byUser); `server/routes/shared.ts` — EventBus рассылает через индекс (таргетинг org/user/user+org/broadcast с прежней tenant-семантикой), один глобальный heartbeat-таймер (unref, стоп при отсутствии соединений), убраны per-event console.log. - Протокол НЕ тронут: id:/event:/data:, буферы 500, replay по lastEventId — клиенты совместимы. - Как проверялось: 8 новых юнит-тестов индекса; vitest 96/96; `npm run check` чисто. - Влияние на поиск/UX: нет. - Подводные камни: живой прогон SSE (чат в двух вкладках, reconnect по lastEventId) — проверить на проде после деплоя (юнит-тесты покрывают маршрутизацию, не HTTP-поток). ## [1.5] N+1: recursive CTE, Promise.all, батчи - Статус: ✅ done - Зачем: убрать N+1 на горячих путях (Фаза 1). - Что изменено: - `getTaskTree` — один WITH RECURSIVE CTE + один IN-запрос (было 1+2N); защита от циклов (lvl<100, assembled-Set); формат результата прежний. - Новый `getTaskParentChain` (CTE вверх, max 10) — MCP get_task_tree без цикла getTask. - Батч-методы: getTasksByIds, getTaskFieldValuesByTaskIds, getTaskMessagesByIds/ByTaskIds. - sync.routes.ts — /initial и /delta: Promise.all по формам (порядок ответа сохранён). - embedding.service.ts — processEmbeddingQueue и reindexOrganization: батч-предзагрузки (3–4 запроса на org вместо 2–3 на элемент; 2 запроса на чанк 100 задач вместо ~200). - Как проверялось: vitest 96/96; `npm run check` чисто; caller'ы дерева (роут /tree, MCP) — формат не изменился. - Влияние на поиск/UX: нет. - Подводные камни: raw CTE под PostgreSQL — при переименовании колонок tasks/forms обновить вручную (drizzle не проверяет); getRelatedTasks (граф task_relations) сознательно оставлен — кандидат на отдельный шаг. --- ## [1.3] Виртуализация списков (@tanstack/react-virtual) - Статус: ✅ done - Зачем: длинные реестры/чаты не рендерят тысячи DOM-узлов (Фаза 1). - Что изменено: - `@tanstack/react-virtual@3.14.11` в dependencies (+6-7 kB gzip). - `client/src/components/TaskRegistry.tsx` — виртуализация строк таблицы (padding-строки в нативном tbody, динамическое измерение высоты через ResizeObserver, overscan 10, getItemKey=task.id). Порог 30 строк — меньше рендер 1:1 прежний. Массовое выделение (Shift+клик через anchorTaskId), сортировка, мгновенный клиентский поиск (инвариант), DnD колонок, inline-редакторы — работают по полному массиву, не по DOM. - `client/src/components/TaskChat.tsx` — виртуализация сообщений (плоский список: дата-разделители + сообщения, динамическая высота, overscan 10). Порог 100 элементов. Автоскролл к новому, scrollToMessage с fallback scrollToIndex, оптимистичная отправка, реакции/опросы/вложения сохранены. - Как проверялось: `npm run check` чисто; vitest 96/96; `npm run build` собирается. - Влияние на поиск/UX: поиск в реестре мгновенный как и был (фильтрация до виртуализации). - Подводные камни: в виртуальном чате дата-разделители не sticky; возможна «игра» ширин колонок реестра при table-layout:auto; уход редактируемой строки за overscan размонтирует несохранённый ввод (стандарт виртсписков). Ручная проверка в браузере: реестр с длинными значениями, чат >100 сообщений (автоскролл, скролл к ответу). --- ## [1.6] CI: eslint + npm audit - Статус: ✅ done - Зачем: lint и аудит зависимостей в CI (Фаза 1). - Что изменено: `eslint.config.js` (flat-config eslint 9 + typescript-eslint + react-hooks; только баг-ловушки, легаси — warn); скрипт `npm run lint`; pr-check.yml — шаги Lint (блокирующий) и npm audit --audit-level=high (continue-on-error, только отчёт). - Результаты: старт — 703 проблемы; итог 0 errors / 576 warnings (exit 0). Попутно исправлен РЕАЛЬНЫЙ баг: условный useRef после early return в TaskTitleInline (client/src/pages/TaskDetail.tsx:1612) — react-hooks/rules-of-hooks. - Как проверялось: npm run lint exit 0; vitest 96/96; `npm run check` чисто. - Влияние на поиск/UX: нет. - Подводные камни: npm audit нашёл 60 уязвимостей (49 moderate, 10 high, 1 critical — XSS в DOMPurify GHSA-v2wj-7wpq-c8vv) — в бэклог, чинить отдельным шагом; eslint-plugin-react-hooks взят v5 (v7 требует zod-validation-error/v4, в проекте v3.5). ## [1.7] Структурированные логи + retention error_logs - Статус: ✅ done - Зачем: единый logger с уровнями, автоочистка error_logs (Фаза 1). - Что изменено: - `server/utils/logger.ts` — уровни debug/info/warn/error (env LOG_LEVEL), LOG_FORMAT=json; подключён в 5 местах (error handler, SSE eventBus, gps worker, automation-scheduler, billing payments). Массовая замена console.* сознательно не делалась (инвазивно) — новые модули обязаны использовать logger. - `server/workers/error-logs-retention.ts` — DELETE error_logs старше 30 дней, раз в сутки + при старте, unref-таймер. - `server/index.ts` — redactSensitive() в captureErrorLog: маскировка password/token/secret/Bearer в записываемых сообщениях. - Как проверялось: auth-роуты возвращают статичные тексты без секретов; vitest 96/96. - Влияние на поиск/UX: нет. --- ## [1.8] Бэкапы PostgreSQL: документирование + проверка восстановления - Статус: ✅ done - Зачем: задокументированная стратегия восстановления (RPO/RTO), подтверждённая практикой (Фаза 1). - Что изменено: `DOCKER.md` — раздел «Бэкапы PostgreSQL (production)»: расписание (3:00 pg_dump + 3:30 CSV, ротация 14/7), RPO 24ч/RTO 15–30 мин, команды восстановления, что НЕ входит в дамп (MinIO/Ollama/crm_data), риск «бэкапы на том же VPS» (выгрузка наружу — в бэклог). - Как проверялось: восстановление свежего дампа (2026-09-07 03:00, 11 МБ) в scratch-БД на сервере — 0 ошибок pg_restore, 125 таблиц, 990 задач/49 пользователей/14 форм/6024 значений полей, последняя миграция 0078 (дамп предшествует 0079/0080 — консистентно). Scratch-БД удалена. - Влияние на поиск/UX: нет. --- ## [1.9] Onboarding - Статус: ✅ done - Зачем: новая организация не попадает в тупик (Фаза 1). - Что изменено: `client/src/components/OnboardingChecklist.tsx` — чеклист «Начало работы · N из 3» (форма → коллеги → задача), состояние из реальных данных, скрытие per org (localStorage); встроен в Home.tsx. `client/src/pages/Tasks.tsx` — при отсутствии форм EmptyState с кнопкой «Создать форму» вместо задизейбленной «Создать задачу». - Как проверялось: `npm run check`/lint/build чисто; vitest 96/96. - Влияние на поиск/UX: нет (новая плашка только для пустых организаций). ## [1.10] Модули и мёртвый код - Статус: ✅ done - Зачем: MedSchedule не в ядре без флага; мёртвый Capacitor-стек удалён (Фаза 1). - Что изменено: - MedSchedule за org-флагом `organizations.medschedule_enabled` (миграция 0081, default FALSE, TRUE для slug='medschedule'; выбран org-флаг как наименее инвазивный и соответствующий прежнему org-ориентированному гейту, admin-override сохранён): `/api/auth/me` отдаёт флаг, серверный гейт requireMedOrganization, сайдбар и страница модуля по флагу. Worker оставлен (работает только с org medschedule). - Capacitor удалён полностью: capacitor.config.ts, client/src/lib/push-notifications.ts (единственный файл с импортами @capacitor/*), вызов в App.tsx, 5 зависимостей @capacitor/* из package.json. Expo (mobile/) не тронут — рабочее. Web-Push через @/lib/pwa остался. - Как проверялось: grep — упоминаний Capacitor не осталось (и в lockfile); `npm run check`/lint/build чисто; vitest 96/96. - Влияние на поиск/UX: нет. - Подводные камни: включение MedSchedule для другой org — SQL UPDATE (UI-переключателя нет, как у GPS-флагов до матрицы). --- ## npm audit fix (внеплановый шаг после 1.6) - Статус: ✅ done - Зачем: npm audit нашёл 60 уязвимостей (1 critical, 10 high) при подключении аудита в CI. - Что изменено: `package-lock.json` — npm audit fix (совместимые обновления): dompurify 3.4.11→3.4.15 (~20 XSS-advisory закрыты), tar (critical DoS), tiptap и др. Итог: 60 → 46 уязвимостей (0 critical, 2 high, 43 moderate — остаток требует breaking changes, разбор отдельно). - Как проверялось: `npm run check` чисто; vitest зелёные; lint 0 errors; `npm run build` собирается. - Влияние на поиск/UX: нет. - Подводные камни: dompurify используется в client/src/lib/htmlUtils.ts для rich-text — обновление патчевое, API совместим. --- ## [2.1] Средние security-фиксы - Статус: ✅ done - Зачем: anti-enumeration, open redirect, брутфорс, явный SSL, доделка 0.7 (Фаза 2). - Что изменено: - Anti-enumeration: `server/services/auth.service.ts` — bcrypt-сравнение всегда (dummy-хэш), единый 401 «Неверный email или пароль» для всех причин отказа; forgot-password — идентичный 200 «Если аккаунт существует…» независимо от email. - returnTo: `server/utils/return-to.ts` + `client/src/lib/return-to.ts` — только относительные пути с одиночным `/` (отброс //host, /\host, ctrl-символов); применено в auth и /api/documents/generate-link (был open redirect через res.redirect). - Лимит логина: `server/utils/login-attempts.ts` — 10 неудачных → блок 15 мин (in-memory, сброс при успехе), аудит auth.login.locked; поверх authLimiter по IP. - SSL: `server/db.ts` — rejectUnauthorized через env DATABASE_SSL_REJECT_UNAUTHORIZED (дефолт true). - Доделка 0.7: 200+success:false с err.message зачищены в llm-providers/rag/finance-di2. - Как проверялось: 8 новых тестов (tests/auth-security.test.ts) — vitest 104/104; check/lint чисто. - Влияние на поиск/UX: нет; UX логина: деактивированный аккаунт видит «Неверный email или пароль» (цель anti-enumeration), после 10 попыток — сообщение о 15-минутной блокировке. - Подводные камни: блокировки in-memory (рестарт обнуляет; multi-instance делит лимит — как остальные кэши). --- ## [2.3] Дедупликация - Статус: ✅ done - Зачем: единая точка форматирования сумм (Фаза 2). - Что изменено: создан `client/src/lib/format.ts` с `formatAmount` (ru-RU, 2 знака, пустое → «0.00» — покрывает обе семантики копий); локальные копии в Billing.tsx и SuperAdminOrgDetail.tsx заменены импортом. Дубля ActiveFilterBadges/ColumnFilter нет — finance/-дубль удалён ранее вместе со старым модулем, канонические в ui/. - Как проверялось: check/lint/build чисто; vitest 104/104. - Влияние на поиск/UX: нет (отображение сумм идентично). ## [2.4] Гигиена репозитория - Статус: ✅ done - Зачем: мусор, пины образов, дефолты MinIO, доля any (Фаза 2). - Что изменено: - Удалён `_tablefield_orig.txt`. - Пины образов во всех compose (по фактически работающим версиям на проде, существование тегов подтверждено docker manifest inspect): minio RELEASE.2025-09-07T16-13-09Z, rclone 1.74.0, ollama 0.30.10, traccar 6.15.2, pgvector 0.8.4-pg16, osrm latest@sha256:af5d… (тега v6.0.0 не существует — digest, совпал с продовым). - Дефолты minioadmin убраны: `${MINIO_ACCESS_KEY:?...}` + комментарии в .env.example/.env.local.example (dev-only дефолт). - 28 замен `: any` + 4 `as any` в server/utils/auto-transitions.ts (23→0, типы из @shared/schema) и server/routes/shared.ts (5) — без изменения логики. finance-di2/routes.ts (113 any) сознательно не тронут. - Как проверялось: docker compose config валиден для всех 4 файлов; check/lint/build чисто; vitest 104/104. - Влияние на поиск/UX: нет. - Подводные камни: пины синхронизированы в серверный бэкап .bak.serverfix (крит. правило №1) — только image-теги, креды бэкапа не тронуты; локальный .env.local требует MINIO_ACCESS_KEY (есть в .env.local.example). --- ## [2.2a] Разбиение Chat.tsx (2696 → 639 строк) - Статус: ✅ done - Зачем: гигантские файлы нечитаемы и рискованны в правках (Фаза 2). БЕЗ смены поведения. - Что изменено: `client/src/pages/Chat.tsx` → 8 файлов в `client/src/components/chat/`: chat-types.ts, chat-utils.ts, MessageBubble.tsx (пузырь + кнопки ботов), NewConversationDialog.tsx, GroupMembersDialog.tsx, HighlightMatch.tsx, TypingIndicator.tsx, useChatController.ts (вся логика страницы дословно). В Chat.tsx — только деструктуризация хука + прежний JSX. - Как проверялось: ПРОГРАММНАЯ сверка с оригиналом (git show): тело логики == useChatController посимвольно, рендер == оригиналу (кроме замены inline-блока на ). Чеклист real-time фич дословно на месте (SSE typing, reportActiveChat в useLayoutEffect, polling fallback 10с, оптимистичная автоотметка прочтения с debounce, кнопки ботов с «нажато»). check/lint/build чисто; vitest 104/104. - Влияние на поиск/UX: нет (поведение идентично доказанно). --- ## [2.2b] Разбиение TaskDetail.tsx (3278 → 804 строки) - Статус: ✅ done - Зачем: см. 2.2a. БЕЗ смены поведения. - Что изменено: `client/src/pages/TaskDetail.tsx` → 14 файлов в `client/src/components/task-detail/`: types.ts, systemTabs.ts, TabFieldsContent, TaskAccessTab (мёртвый код, сохранён для паритета — кандидат на удаление), InlineSystemFields (ответственные, «На себя», срок, замещение), SubtasksTabContent, TaskContent (роутер вкладок + слоты пресетов), TaskTitleInline (хуки в исходном порядке, фикс условного useRef сохранён), useTaskFieldConditions, ReminderDialog, DeleteTaskDialog, TaskDetailHeader, TaskTabsBar, TaskMobileAccordion. В TaskDetail — композиция + хуки верхнего уровня (SSE, переходы, просмотры). - Как проверялось: посимвольная сверка — 32 несовпавшие строки, все объяснены (22 — префикс export, 4 — разбитые импорты, 5 — адаптации пропсов, 1 — комментарий). Чеклист: per-field editing, «На себя», просмотры, geo/contact в потоке, пресеты, порядок хуков 1:1. check/lint/build чисто; vitest 104/104. - Влияние на поиск/UX: нет. --- ## [2.2c] Разбиение Bots.tsx (3928 → 163 строки) - Статус: ✅ done - Зачем: см. 2.2a. БЕЗ смены поведения. - Что изменено: `client/src/pages/Bots.tsx` → 16 файлов в `client/src/components/bots/`: bots-types.ts (+zod-схемы), bots-utils.ts, CollapsibleSection, CreateBotUsersPicker, useBotsController.tsx (вся логика дословно, хуки 1:1), CreateBotDialog, BotListCard, BotSettingsTab (apiAccess/регенерация ключей), BotAiTab, BotWebhookTab, BotSubscriptionsTab, BotApiDocsCard, ServicesSection, JsPagesSection, TabModulesSection. В Bots.tsx — каркас композиции (публичный экспорт BotsContent сохранён — используется в Settings.tsx). - Как проверялось: программная сверка — 28/28 сегментов дословно; пропсы типизированы через Pick>. Чеклист: выдача/регенерация API-ключей, zod-валидации, вкладки — дословно. check/lint/build чисто; vitest 104/104. - Влияние на поиск/UX: нет. --- ## [2.2d] Разбиение FormEditor.tsx (4975 → 337 строк) — шаг 2.2 завершён - Статус: ✅ done - Зачем: см. 2.2a. БЕЗ смены поведения. - Что изменено: `client/src/pages/FormEditor.tsx` → 14 файлов в `client/src/components/form-editor/`: types.ts, constants.ts, utils.ts, SortableWrappers, useFormEditorController.tsx (все хуки 1:1, 1440 строк), FieldCard (карточка поля: Гантт-роли, button-настройки, companyAutofill, contract-number, formula, document_template), MainTab, FieldsTab, StatusesTab, TransitionsTab (конструктор согласующих с resolveTableMode и обратной совместимостью), OfflineTab, LayoutTab (пресеты @preset), FormEditorDialogs. В FormEditor — композиция + шапка + Tabs. - Как проверялось: построчная программная сверка — 13/15 блоков байт-в-байт, расхождения объяснены (export-префиксы, перенос типов/констант, фрагменты вместо leading-комментариев JSX, удаление 24 неиспользуемых импортов оригинала). Чеклист: bulkSaveFormFields, правила согласующих, пресеты, QR-toggle, условия полей — дословно. check/lint/build чисто (warnings 566→542); vitest 104/104. - Влияние на поиск/UX: нет. - Итог 2.2: Chat 2696→639, TaskDetail 3278→804, Bots 3928→163, FormEditor 4975→337. --- ## [2.5] Продуктовая середина: центр уведомлений + a11y - Статус: ✅ done (i18n-задел — ⏸ отклонено/отложено по решению пользователя: не-русские рынки не подтверждены) - Зачем: центр уведомлений страницей, skip-link и базовый axe-проход (Фаза 2). - Что изменено: - `client/src/pages/NotificationsPage.tsx` — страница /notifications: фильтр все/непрочитанные (кнопки с aria-pressed), клиентская «Загрузить ещё» по 30, отметка одного/всех, переход к задаче через общий хелпер `getNotificationTarget`. Маршрут в App.tsx. - `client/src/lib/notification-utils.tsx` — общие хелперы/хуки уведомлений (дедупликация: SystemNotificationsDropdown отрефакторен на них); ссылка «Все уведомления» в обоих dropdown + пункт в MobileBottomNav (на мобильном dropdown недоступен). - Skip-link «Перейти к содержимому» в Sidebar.tsx (первым фокусируемым элементом, sr-only до фокуса, оба main получили id="content") — размещён в Sidebar, т.к. Layout рендерит children внутрь него. - `tests/a11y.test.tsx` — axe smoke (jsdom) на Login/OnboardingChecklist/NotificationsPage: 0 critical/serious; инфра: axe-core, jsdom, @testing-library/react, alias @ в vitest.config + oxc jsx runtime (vitest 4/Vite 8). - Исправлены найденные axe-нарушения: aria-valid-attr-value (Tabs без панелей → кнопки), nested-interactive (строка уведомления перестроена). - Как проверялось: vitest 107/107; check/lint/build чисто. - Влияние на поиск/UX: нет. - Подводные камни: GET /api/notifications без серверной пагинации — при очень больших историях «Загрузить ещё» работает на клиенте; серверная пагинация — отдельная задача. Полный axe-проход по всему приложению — задел, не входил. --- ## [0.4] Ресурсные лимиты во все compose-файлы [ПРОД] - Статус: ✅ done (коммит `87fe0f0`, прод подтверждён: app mem=1GiB/cpus=1.5/pids=256, doc-worker mem=1GiB/cpus=1.0 — docker inspect; health 200) - Зачем: утечка в одном сервисе не роняет хост (аудит, Фаза 0). Подтверждено пользователем 2026-09-08. - Что изменено: mem_limit/cpus/pids_limit во всех 4 compose-файлах + серверный бэкап .bak.serverfix (крит. правило №1; преддеплойная копия .bak2-20260908). Значения по docker stats прода 2026-09-07 + запас (сервер 6 ГБ/3 CPU, на хосте другие стеки): app 1g/1.5/256, document-worker 1g/1.0/256, db 768m/0.75/128, minio 256m/0.5/64, webdav 64m/0.25/32, ollama 2g/2.0/64 (модель в RAM на ночном батче), traccar 768m/0.5/128, osrm 512m/0.5/32. proxy (Traefik) сознательно без лимита — критичная инфраструктура хоста. - Как проверялось: docker compose config валиден для всех 4 файлов и для бэкапа на сервере. - Влияние на поиск/UX: нет. - Подводные камни: сумма лимитов ~6.3 ГБ > 5.9 ГБ RAM — лимиты это потолки, не резерв; ollama в простое 36 МБ. На проде лимиты применяются к app/document-worker при ближайшем деплое; остальные сервисы — при их пересоздании. --- ## [0.1] RLS обязательным в production — подготовка и локальная проверка - Статус: ⏸ частично (локальная проверка пройдена; прод-включение и fatal-проверка — после подтверждения пользователя) - Зачем: изоляция tenant'ов на уровне БД (аудит, Фаза 0). - Что сделано: - Локальная проверка на КОПИИ прод-БД (scratch pgvector контейнер, свежий дамп): app стартует с ENABLE_RLS=true, 31 таблица ENABLE+FORCE, login/поиск (подстрока «варий»→«Аварийка»)/список задач (50, без description)/формы/sync работают, passwordHash не утекает. - **Найден и исправлен блокирующий баг** в `server/index.ts` (startup RLS-проверка): pg-драйвер возвращает `array_agg(tablename)` (тип name[]) СТРОКОЙ "{tasks,users,...}", а не массивом — проверка schema drift находила 0 таблиц и падала при ЛЮБОМ запуске с ENABLE_RLS=true. Т.е. RLS нельзя было включить без этого фикса. Поддержаны оба формата. - Что осталось (прод, с подтверждения): 1) добавить ENABLE_RLS=true в .env сервера и перезапустить app, проверить; 2) затем задеплоить fatal-проверку (NODE_ENV=production без ENABLE_RLS → процесс не стартует) + пометки в .env.example/DOCKER.md. - Влияние на поиск/UX: нет. - Подводные камни: порядок важен — fatal-проверку деплоить ТОЛЬКО после включения флага на проде, иначе app не поднимется. --- ## [0.1] RLS обязательным в production — завершение - Статус: ✅ done - Что сделано после локальной проверки (см. запись выше): 1. Прод: бэкап БД (crm-20260908-075039.dump.gz), `ENABLE_RLS=true` в /opt/crm/.env, recreate app — старт-лог «Startup RLS: active — ENABLE+FORCE verified on 31 tables, 31 policies», health 200, поиск/списки через API работают. 2. Код: `server/index.ts` — при NODE_ENV=production без ENABLE_RLS=true — fatal (процесс не поднимается); dev — предупреждение. 3. Документация: `.env.example` (обязательность для production), `DOCKER.md` (sequence при старте + блок про RLS). - Как проверялось: на проде подтверждено включение (pg_class relrowsecurity+relforcerowsecurity=31), smoke через API; локально — полный цикл на копии прод-БД. - Влияние на поиск/UX: нет. - Подводные камни: при переносе на новый сервер — сначала ENABLE_RLS=true в .env, иначе app не поднимется (это и есть цель шага). --- ## [0.6] TTL access-токена 15 минут + хэширование refresh/reset-токенов в БД - Статус: ⏸ частично — код готов, ждёт окно деплоя (разлогин всех) - Зачем: украденный access-токен жил до 30 дней; refresh/reset-токены лежали в БД в открытом виде — при утечке БД атакующий получал вечный доступ (аудит, Фаза 0). - Что изменено: - `server/utils/jwt.ts` — дефолт `JWT_ACCESS_EXPIRES` '30d' → '15m' (env-переопределение сохранено), добавлен `hashToken()` (sha256 hex) и экспорт `ACCESS_TOKEN_EXPIRY`. - Миграция `migrations/0082_token_hashes.sql` — колонки `refresh_token_hash` (NOT NULL, UNIQUE), `parent_refresh_token_hash`, `replaced_by_token_hash` в `user_sessions`; `reset_password_token_hash` в `users`. **`DELETE FROM user_sessions` + очистка reset-токенов — глобальный разлогин при деплое.** Legacy plain-колонки (`refresh_token`, `parent_refresh_token`, `replaced_by_token`, `reset_password_token`) НЕ удалены (отдельная миграция позже), но перестали писаться; у `refresh_token` снят NOT NULL. - `shared/schema.ts` — новые колонки в `userSessions`/`users`; `resetPasswordTokenHash` добавлен в исключения `SafeUser`/`safeUserColumns`. - `server/storage/users.storage.ts` + интерфейс `server/storage.ts` — все lookup/отзыв сессий (`getUserSessionByToken`, `getUserSessionByParentToken`, `revokeSession`, `deleteUserSession`) и `getUserByResetPasswordToken` ищут по `hashToken(plain)`; `createUserSession` принудительно обнуляет plain-колонки; `markSessionReplaced(sessionId, newToken)` — по id сессии (plain старого токена больше недоступен). - `server/services/auth.service.ts` — `createSession` и ротация пишут только хэши; ротация вынесена в `rotateFamilySession()`. Grace-period (гонка вкладок, 5 мин): раньше клиенту возвращался plain-токен активной сессии из БД — теперь он не хранится, поэтому активная сессия ротируется и клиент получает новую пару (поведение сходится, сессий чуть больше). Cookie access Max-Age берётся из `ACCESS_TOKEN_EXPIRY`. - `server/routes/auth.core.routes.ts` — forgot-password пишет `resetPasswordTokenHash` (+ `resetPasswordToken: null`), plain только в письме; reset-password очищает обе колонки. - `.env.example`, `server/swagger.ts` — дефолты/описания приведены к факту (access 15m, refresh 30d/90d). - `tests/token-security.test.ts` — 12 тестов: hashToken, выпуск по хэшу (plain не уходит в storage), ротация с parent-хэшем, невалидный токен, reuse detection → revokeSessionFamily, grace-ротация, logout, reset по хэшу/очистка/истёкший/невалидный. - Как проверялось: `npm run check` чисто (оба tsc); `npx vitest run` — 118/118; `npm run lint` — 0 errors; `npm run build` — собирается. Клиент не тронут: refresh реактивный по 401 (singleton `refreshSession` в queryClient + useOfflineSync), таймеров, завязанных на 30d, нет. - Влияние на поиск/UX: нет (только auth-контур). - Подводные камни: - **Деплой разлогинит всех** (миграция чистит `user_sessions`) — деплоить в окно согласованного простоя; после деплоя проверить re-login и refresh-ротацию вручную. - JWT детерминирован по (payload, iat в секундах): две ротации одной и той же сессии в пределах одной секунды выдадут одинаковый токен — безвредно (хэш и семантика совпадают). - `bot_sessions` (refresh-токены bot_login) сознательно не тронуты — отдельная модель audience `workflow-bots`; кандидат на аналогичное хэширование отдельным шагом. In-memory `resetTokenStore` суперадмина — не в БД, вне скоупа. - Удаление legacy plain-колонок — отдельная миграция после подтверждения стабильности на проде. --- ## [0.6] TTL access-токена 15m + хэширование refresh/reset-токенов - Статус: ✅ done (коммит `06bfd2a`, ветка feat/token-security → main, окно подтверждено пользователем 2026-09-08) - Зачем: укоротить окно атаки при краже access-токена; токены в БД только в виде хэша. - Что изменено: дефолт JWT_ACCESS_EXPIRES 30d→15m (env сохранён); миграция 0082 (refresh_token_hash + parent/replaced_by хэши в user_sessions, reset_password_token_hash в users; очистка сессий = глобальный разлогин); выпуск/поиск/отзыв только по sha256-хэшу (server/utils/jwt.ts hashToken, users.storage.ts, auth.service.ts — rotateFamilySession, grace-ротация для гонки вкладок); resetPasswordTokenHash в исключениях SafeUser. Фронт не тронут — refresh реактивный по 401. - Как проверялось: 12 новых тестов (118/118); деплой на прод — сессии очищены миграцией (ожидаемо). - Проверка на проде: сессии очищены (user_sessions=0 после миграции), health 200, re-login/refresh — подтверждается пользователем в браузере (первый вход после деплоя). - Влияние на поиск/UX: нет; пользователи перелогинились один раз. - Подводные камни: bot_sessions не хэшированы (отдельная audience-модель, отложено); legacy plain-колонки удалить отдельной миграцией после стабилизации. ## [0.3] Не-root deploy-пользователь - Статус: ✅ done (деплой от deploy подтверждён; PermitRootLogin prohibit-password применён, root-ключ как fallback проверен — ROOT_KEY_OK) - Зачем: убрать постоянный root-SSH из CI. - Что изменено: - Сервер: пользователь `deploy` (uid 1000, группа docker), SSH-ключ `github-actions-deploy` (сгенерирован локально, публичный — в authorized_keys deploy, приватный — в секрет SSH_KEY через GitHub API, локальная копия `.ssh/deploy_ci` вне репо). - `/opt/crm` переписан на deploy:deploy; бэкап compose доступен на чтение. - `.github/workflows/deploy.yml`: `username: deploy`, хост — секрет `DEPLOY_HOST` (вместо хардкода). - PermitRootLogin=prohibit-password (бэкап sshd_config.bak-20260908); root-ключ остаётся fallback. - Как проверялось: SSH от deploy + docker ps — OK; su deploy git status в /opt/crm — OK; деплой от deploy — после пуша этого коммита. - Влияние на поиск/UX: нет. - Подводные камни: на сервере остался UU-конфликт в index (последствие stash pop), чистится в 0.14; root-SSH пока остаётся как fallback до подтверждения. ## CI: фикс pr-check (jsdom для Node 20) - Статус: ✅ done - Зачем: pr-check падал с 06bfd2a (и 639036c): vitest не стартовал a11y-файл — `webidl.util.markAsUncloneable is not a function` в undici: jsdom@30 несовместим с Node 20 раннера (локально Node 24 — работало). - Что изменено: jsdom 30 → ^26 (devDependencies). - Как проверялось: vitest 118/118 локально; pr-check на этом коммите. - Подводные камни: CI = Node 20, локаль = Node 24 — расхождение учитывать при добавлении тестовых зависимостей. --- ## [0.14] Дрейф конфигурации: прод-compose в приватном инфра-репозитории - Статус: ✅ done (деплои `0fb3b54` и `a5ab294` успешны: без stash, git status на сервере чистый, Traefik/HTTPS работают, compose напрямую из инфра-репо через -f) - Зачем: прод работает ровно на коде из Git; убрать git stash и бэкап compose вне репо (аудит С5, решение пользователя — отдельный приватный репо). - Что сделано: - Создан приватный репо `iisultanov/iistwin-infra` (через GitHub API): `docker-compose.prod.yml` (бывший .bak.serverfix с лимитами 0.4), `README.md` (план перехода), `.env.template` (32 ключа без значений). - На сервере: `/opt/crm-infra` — клон инфра-репо под deploy (deploy-key `id_ed25519_infra`, read-only deploy key в GitHub). - `deploy.yml`: удалены stash push/pop и `cp .bak.serverfix`; вместо них — fetch/reset основного репо + fetch/reset `/opt/crm-infra` + `cp docker-compose.prod.yml`. - Чистка дрейфа на сервере: `git stash clear` (3 старых стеша, в т.ч. дубль /apps static — уже в репо), снят UU-конфликт, HEAD чист. - Старый `/opt/crm-server/docker-compose.yml.bak.serverfix` сохранён (откат до первого успешного деплоя новой схемы). - Как проверялось: clone от deploy — OK; первый деплой новой схемы — ниже в этой записи. - Влияние на поиск/UX: нет. - Подводные камни: deploy-ключи GitHub нельзя шарить между репо (422) — у infra свой ключ; `.env` по-прежнему только на сервере; Traefik-метки/сеть n8n_proxy теперь версионируются в iistwin-infra. --- ## [внепланово] Тихий фейл 400 при смене пароля + UX установки пароля (PLAN-user-password-fixes.md) - Статус: ✅ done (код), ⏳ этап 5 (операционный, user 55) - Зачем: инцидент 2026-09-07 — админ сменил пароль не тому пользователю (55 вместо 56), т.к. ошибка 400 с текстом правила не показывалась в UI, а после создания пользователя нет пути к установке пароля. - Что изменено: - `client/src/services/auth.service.ts` — хелпер `toServiceError`: BadRequestError/ConflictError → `{success:false, error}` (чистый текст сервера), сетевые → общий текст. Обернуты changeUserPassword/changePassword/createUser/updateUser/deleteUser. ОДИН фикс закрыл тихий фейл и в профиле, и в UserModal (вместо двух try/catch в компонентах из исходного плана). - `client/src/components/profile/ProfileFieldRow.tsx` — полная клиентская валидация пароля (длина + состав, тексты как у сервера) до отправки. - `client/src/components/UserModal.tsx` + `client/src/types/auth.types.ts` + `server/routes/auth.users.routes.ts` — опциональный пароль при создании пользователя (zod-валидация правил; пустое → временный как раньше). - `server/services/auth.service.ts` — catch в createUser пропускает текст правила пароля клиенту (раньше глотал). - Этап 4 (аудит тихих фейлов): исправлены FormAccessTab (5 мутаций), Automations (toggle), PollCard (3), ReactionBar — добавлен onError с toast; остальные ~20 мест проверены — не тихие. - `tests/password-flow.test.ts` — 11 тестов. - Как проверялось: vitest 129/129; check/lint/build чисто. - Влияние на поиск/UX: нет (только корректное отображение ошибок). - Подводные камни: часть правок ушла в параллельный коммит 0b2319b (ctx.tasks.sendMessage) — согласовано, потерь нет; apiRequest бросает BadRequestError на 400 — мутации с ожиданием {success:false} обязаны идти через сервисный слой с toServiceError. --- ## [UI-эпопея] Стабилизация и выравнивание UI (по аудиту «ui улучшения.md», 7 фаз) - Статус: ✅ done (код, фазы 1-7 запушены: `7a98bf7`, `5d219ac`, `c4fdfe4`, `113ed45`, `aa8bf6a`, `820e886`, `b94406e`) - Зачем: интерфейс «прыгал» (refetch-пачки на каждое SSE-событие, полноэкранные спиннеры, пересоздание Sidebar при навигации, вспышка скина/PWA-шрифта, location.href-перезагрузки), визуальный стиль разнороден (~1500 inline-размеров, Тема 2.0 потреблялась 4 файлами), чаты дублировали логику. - Верификация чужого аудита по коду: направление верное, но часть утверждений опровергнута — Sidebar и MobileBottomNav НЕ смонтированы одновременно (early-return по useIsMobile); prefers-reduced-motion уже был; виртуализация уже была в TaskChat/TaskRegistry (нет только в мессенджере); дублирование чатов ~70% завышено (общий слой components/chat/ существовал); billing-ссылка — не location.href. Упущено аудитом: NotificationsDropdown.tsx:159 (полная перезагрузка по клику на уведомление), reload-петля safeLazy, двойной refetch Home.tsx+GlobalEventListener, мёртвые tw-animate-css и .btn-compact-классы, поллинг unread-count в обеих ветках навигации. - Что изменено по фазам: - Ф1 (`7a98bf7`): шкалы motion/z-index в `themes/tokens.ts` (BASE_CSS_VARS → :root в generated-skins.css) + Tailwind duration-fast/base/slow, ease-standard; boot-скрипт в `client/index.html` (theme/skin/font-size до первого paint — зеркалит ThemeProvider/usePwaFontSize); `design-system/recipes.ts` + ui/button,input,badge,card,table на рецептах (визуал по умолчанию неизменен); `layout.taskDetail` в скине (дефолт пресета карточки, маркер @preset в приоритете); удалены мёртвые .btn-compact/.btn-small/.btn-icon/.input-compact и зависимость tw-animate-css. - Ф2 (`5d219ac`): `lib/throttledInvalidate.ts` (throttle 2с + invalidateQueries вместо refetchQueries в GlobalEventListener); убран дубль refetch в Home.tsx; `hooks/useUnreadBadges.ts` — единый хук счётчиков (SSE-инкременты, polling 30с только при обрыве SSE), useInboxCount удалён; TaskChat: polling 10с только при !isConnected, refetchInterval-fallback удалён; transition цветов на body/карточках (плавная смена скина); safeLazy — one-shot reload guard + ChunkLoadErrorFallback. - Ф3 (`c4fdfe4`): `components/AppShell.tsx` — persistent shell (Sidebar/MobileBottomNav/биллинг/FAB монтируются один раз, список SHELL_ROUTE_PATTERNS); Layout.tsx — тонкий scroll-контейнер (23 страницы без правок); location.href → setLocation (NotificationsDropdown, тост «Ответить», TableEditor; /global-fields → wouter Redirect); `hooks/useScrollRestoration.ts`. - Ф4 (`113ed45`): ui/skeleton.tsx — PageSkeleton/ListSkeleton/CardSkeleton/FormSkeleton; ~19 страниц мигрировано со спиннеров; `components/transitions/PageTransition.tsx` (fade 150ms, key=location, гейты skinComponents.motion.enabled + useReducedMotion) и `StaggerList.tsx` (Tasks, список диалогов Chat, моб. справочники); PageLoader → PageSkeleton; MotionConfig reducedMotion="user". - Ф5 (`aa8bf6a`): ui/select,textarea,tabs,popover,tooltip,dropdown-menu,checkbox,dialog на рецептах/токенах (fallback = прежний вид); data-density/data-control-variant/data-button-radius на ; консолидация 44 мест `h-7 px-2.5 text-xs` → size="sm"; диалоги max-h-[85vh] + скролл; sheet-режим диалогов уже был реализован как full-screen на мобильном (не vaul) — оставлен. - Ф6 (`820e886`): общий `chat/useMessagePolling.ts`; TaskChat на общий MessageBubble (generic + слоты renderContent/authorBadge/readReceipt/editingContent) и groupByDate; виртуализация мессенджера (порог 100) + автоскролл только у низа; TaskChat без RHF/zod (локальный стейт); анимация появления только последнего сообщения. TaskChat 2023→1754 строк. - Ф7 (`b94406e`): self-host шрифты Inter var + DM Mono (8 woff2 в client/public/fonts, @font-face + preload, Google Fonts убран; DM Mono кириллицы не существует — системный fallback; italic синтезируется браузером); sw.js v13 (fonts в precache); focus-visible возвращён (`outline: 2px solid var(--ring)`, main:focus-visible без кольца — программный фокус); active:scale-[0.98] кнопок (починен мёртвый `active:not-disabled:` — Tailwind v3.4 не имеет not-* вариантов); биллинг-оверлей с exit-анимацией (AnimatePresence); тосты на --duration-base. - Как проверялось: после каждой фазы `npm run check` (heap 6144), `npm run build`, `npx vitest run` (129/129), `npm run lint` (0 errors). Собранный CSS проинспектирован (font-face, focus-visible, duration-base, active:scale на месте). - Влияние на поиск/UX: поиск не трогали (инвариант). UX: меньше запросов (нет refetch-пачек и постоянного 10-сек polling чата при живом SSE), нет перезагрузок по клику на уведомление, нет вспышки скина/шрифта, переходы/скелетоны/анимации. - Подводные камни: (1) пузыри task-чата визуально унифицированы со стилем мессенджера (следствие общего MessageBubble) — семантика сохранена слотами; (2) boot-скрипт зеркалит ключи localStorage 'theme'/'skin'/'pwa-font-size-*' — при их изменении править и index.html; (3) Tailwind v3.4 НЕ поддерживает `not-*` варианты — классы молча не компилируются, проверять собранный CSS; (4) офлайн-очередь/reconciliation чатов не тронуты, но ручной smoke чатов (>100 сообщений, офлайн-отправка, «Загрузить ещё») на проде рекомендован; (5) организационный скин (activeSkinId) применяется после загрузки user — при расхождении с localStorage-скином возможна однократная смена после логина (не вспышка дефолта, приемлемо). - Осталось за кадром (сознательно): slide-стек переходов на мобильном (решено: fade везде); полная миграция всех ~234 h-7 (сделаны 44 точных дубля); vaul bottom-sheet для диалогов (есть full-screen sheet); серверный /api/badges (не понадобился — polling стал fallback-only). --- ## [UI-эпопея, фикс] Двойной Sidebar на реестре задач и карточке задачи - Статус: ✅ done (коммит `5af274c`) - Зачем: регрессия фазы 3 — на `/forms/:id/tasks` и карточке задачи боковая панель дублировалась: TaskView/TaskDetail рендерили свой `` (раньше он и был хромом этих страниц, Layout использовался только для loading/error), а AppShell добавил свой поверх. - Что изменено: из `TaskView.tsx` и `TaskDetail.tsx` убрана обёртка `` из основного return (заменена на фрагмент), удалён неиспользуемый импорт. Контент (`flex flex-col h-full overflow-hidden`) корректно ложится в `
` от AppShell. - Как проверялось: `npm run check`, `npm run build`, `npx vitest run` (129/129) — зелёные. - Подводные камни: Chat.tsx и ShareTarget.tsx тоже рендерят свой Sidebar — но они вне SHELL_ROUTE_PATTERNS, дублирования нет, не трогали. Правило: страницы внутри shell НЕ должны рендерить `` сами.