# 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: нет.