Deal — единая кодовая база
ci / build-test (push) Canceled after 0s

SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/
Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue,
контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер),
Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог).

Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-11 23:56:47 +03:00
commit 27c7831910
1383 changed files with 158436 additions and 0 deletions
@@ -0,0 +1,272 @@
# Дейл (Deal) — архитектурный дизайн-док
> Исторический документ (архитектурный дизайн-черновик, 2026-09-05). Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
> Версия: 0.1 (черновик для согласования)
> Дата: 2026-09-05
> Статус: фиксирует согласованные решения по переписыванию LeadRadar в новый продукт «Дейл»
---
## 1. Контекст и цели
**LeadRadar** — рабочий прототип (Python/FastAPI/DuckDB/Vue), проверенный на тестовых данных.
**«Дейл»** — новая реализация: SaaS-продукт, который мониторит Telegram-каналы и группы клиентов,
отсеивает рекламу/скам/дубликаты и показывает **реальные заказы и клиентов**, совпадающих с
профилем пользователя (сфера, стек, бюджет). Клиенты подключают свои Telegram-аккаунты.
### Цели переписывания
1. Код, который владелец продукта может поддерживать сам (типизированный .NET вместо Python).
2. Стабильность и строгость типов, интерфейсов, слоёв — «как сеньор-архитектор».
3. Мультитенантный SaaS (схема на тенанта) — фундамент для роста до сотен/тысяч клиентов.
4. Безопасность «с первого дня» (публичный продукт).
5. Полная документация: ТЗ, инструкция пользователя, техдок — параллельно с кодом.
### Не-цели (сейчас)
- Переписывание фронтенда (Vue остаётся как есть).
- Kafka/кубер (отложены до реального масштаба; архитектура готова к ним).
- Биллинг-провайдер (лимиты в ядре, биллинг — позже).
- Саморегистрация тенантов (только инвайты).
---
## 2. Решения верхнего уровня (зафиксированы)
| # | Решение | Выбор |
|---|---|---|
| 1 | Стратегия | Big Bang: пишем новый бэкенд целиком; старые данные не мигрируем (тестовые) |
| 2 | Фронтенд | Vue не трогаем; HTTP-контракт `/api/...` — замороженная спецификация миграции |
| 3 | Архитектура | Модульный монолит в `core` (один процесс, одно sln); сервисы — отдельные процессы/sln |
| 4 | Стек | .NET (актуальная LTS), C# современный, Postgres |
| 5 | Мультитенантность | Одна Postgres-БД, **схема на тенанта** (`tenant_<id>.*`), системное в `public` |
| 6 | Владение данными | Каждый модуль владеет своими таблицами; межмодульно — интерфейсы/доменные события |
| 7 | Межпроцессно | gRPC + mTLS; шина событий за портом `IEventBus` (outbox → Kafka позже) |
| 8 | Telegram | Отдельный `telegram-service`: ферма сессий, 1 аккаунт/тенант, анти-бан; исполняет команды ядра, ничего не знает о бизнес-логике |
| 9 | ML | Отдельный `ml-service`: .NET + ONNX, пул моделей per-tenant, обучение на действиях |
| 10 | AI (LLM) | Отдельный `ai-service`: фасад провайдеров, промпты, учёт токенов |
| 11 | Клиенты SaaS | Подключают свои Telegram-аккаунты и настраивают обработку под свою сферу |
| 12 | Доступ тенантов | Инвайты: тенанта создаёт оператор, клиент по ссылке задаёт пароль |
| 13 | Аутентификация | Логин = email + пароль (email уникален глобально); `tenantId` в сессии/JWT |
| 14 | Название | «Дейл» (бренд), namespace `Deal` |
| 15 | Код-стайл | Документ пользователя + 5 адаптаций; 1 тип = 1 файл; `.editorconfig` + анализаторы |
| 16 | Наблюдаемость | Serilog + OpenTelemetry → Grafana + Loki + Promtail |
| 17 | Админка | Операторская (A): тенанты, лимиты, health, impersonation, аудит |
| 18 | Бэкапы | Ежедневные: Postgres + minio + сессии |
| 19 | Лимиты | Бюджет токенов на тенанта (LLM); fallback на ML/локальную обработку |
| 20 | Деплой | docker compose на своём VPS; Cloudflare перед origin; k8s позже |
---
## 3. Структура репозитория
```
src/
core/ # МОДУЛЬНЫЙ МОНОЛИТ — один процесс, один sln
Deal.sln
Deal.Api/ # host: Web API (/api-контракт), gRPC-сервер, SSE, DI
Deal.Modules.Pipeline/ # очередь → стоп-лист → дедуп → ML/ИИ → карточка
Deal.Modules.Kanban/ # карточки, колонки, правила, архив/корзина
Deal.Modules.Projects/ # «Выбранные» (проектный канбан)
Deal.Modules.Discovery/ # поиск каналов, вступление, чёрный список
Deal.Modules.Settings/ # настройки тенанта, промпты, валюты
Deal.Modules.Tenants/ # тенанты, инвайты, лимиты, админка
Deal.SharedKernel/ # Result, доменные события, время, tenant-контекст
Deal.Infrastructure/ # Postgres, миграции, outbox, IEventBus, файлы (MinIO)
Deal.Contracts/ # DTO для /api + gRPC-контракты наружу
tests/ # Deal.Tests.* (unit/integration модулей)
ml-service/ # Deal.Ml.sln — .NET + ONNX, обучение/предсказание per-tenant
ai-service/ # Deal.Ai.sln — LLM-фасад, промпты, учёт токенов
telegram-service/ # Deal.Telegram.sln — ферма сессий, анти-бан
contracts/ # общие .proto (gRPC): ml.proto, ai.proto, telegram.proto
frontend/ # Vue — переезжает как есть
docker-compose.yml # dev-подъём всех процессов
```
Правила:
- `core` — единственное место с бизнес-логикой и БД.
- Каждый сервис самодостаточен: свой sln, свой контейнер.
- `.proto` — единственный общий «язык» между процессами, лежит в `contracts/`.
---
## 4. Мультитенантность
### Модель БД
- Одна Postgres-БД, **схема на тенанта**: `tenant_<id>.*`.
- Системные таблицы (реестр тенантов, пользователи, инвайты, глобальные настройки,
ключи приложения Telegram) — в схеме `public`.
- DAL получает схему из tenant-контекста (claim в JWT / gRPC-метаданные);
пул соединений переключает `search_path`.
- Миграции применяются ко всем схемам тенантов (специальный механизм, см. §10).
- «Золотым» клиентам позже — выделенный инстанс: стратегия выбора схемы/БД в одном месте.
### Изоляция (критично)
- `tenantId` **только из сессии/JWT**, никогда из тела запроса.
- Каждый SQL-запрос исполняется в контексте схемы тенанта; модуль проверяет
принадлежность объекта тенанту (IDOR-защита).
- Интеграционные тесты на перекрёстный доступ тенантов — обязательны.
### Обработка per-tenant
- Настройки обработки (стоп-фразы, промпты, колонки/правила, ключи) — per-tenant.
- **ML-модель — per-tenant** (модель дизайнера не учится на действиях кровельщика):
`ml-service` держит пул моделей, core передаёт `tenantId` в каждом вызове.
---
## 5. Модули core и их границы
Модули заводятся сразу как отдельные проекты; **внутренние интерфейсы между ними
не выдумываются заранее** — появляются в момент реальной зависимости.
| Модуль | Ответственность | Владеет таблицами (в схеме тенанта) |
|---|---|---|
| Pipeline | очередь входящих → стоп-лист → дедуп → ML/ИИ → карточка; отсев; обработка | очередь, отсев, dedup |
| Kanban | карточки, колонки, правила, архив/корзина, комментарии, файлы | карточки, колонки |
| Projects | «Выбранные»: свой канбан, стадии, история, напоминания | проекты |
| Discovery | задачи поиска каналов, кандидаты, чёрный список, квоты | discovery-таблицы |
| Settings | настройки тенанта, промпты, валюты | настройки |
| Tenants | тенанты, пользователи, инвайты, лимиты, аудит, операторская админка | tenant-реестр (в `public`) |
Общие справочники (например, «колонки» нужны и Pipeline при создании карточки, и Kanban
при отрисовке) живут в модуле-владельце (Kanban); доступ — через его публичный интерфейс.
---
## 6. Контракты
### 6.1 `/api` — замороженный контракт миграции
- Фронтенд Vue продолжает ходить в `/api/...` без изменений.
- Снимаем точную карту с работающего LeadRadar (эндпоинты + формы ответов, которые
реально потребляет фронт) → фиксируем как OpenAPI-спецификацию.
- Новый `Deal.Api` обязан воспроизводить её 1:1.
- Ведём реестр «кривых мест»: если правка фронта на 1 строку убирает слой костылей —
выносим на решение владельца по одному (не молча).
### 6.2 gRPC-контракты (`contracts/`)
- `telegram.proto`: команды ядра (подключить аккаунт, слушать канал, перечитать,
вступить/выйти) + поток сырых сообщений → ядро.
- `ml.proto`: predict (текст → решение), train (действие → обучение), health.
- `ai.proto`: classify/filter/generate (текст → структура), учёт токенов.
- Каждый вызов несёт `tenantId`; сервисы проверяют принадлежность по своей модели
(сессии/модели), не доверяя полю на слово.
### 6.3 Шина событий
- Порт `IEventBus` в SharedKernel.
- Реализация сейчас: outbox в Postgres (транзакционно событие + эффект, фоновый диспетчер).
- Kafka — позже, сменой реализации без правки бизнес-логики.
---
## 7. Сервисы
### 7.1 telegram-service
- Отдельный процесс, свой sln. Ничего не знает о данных и бизнес-логике.
- **Сессии привязаны к тенанту** (`tenantId → session`, 1:1): команды исполняются только
на сессии своего тенанта; нет сессии для tenantId → отказ.
- Проверка принадлежности диалога: read/subscribe только для диалогов аккаунта тенанта.
- Join — только от имени тенанта, под его квотами и анти-баном.
- Исходящий поток сообщений помечен `tenantId` (источник определён на входе, в сервисе).
- Сервисная аутентификация (mTLS) + аудит команд `(tenantId, действие, диалог, результат)`.
- Один аккаунт на тенанта на старте (связь тенант→аккаунты уже таблицей — расширение позже).
### 7.2 ml-service
- .NET + ONNX (не ML.NET для онлайн-обучения): пул моделей по тенантам, обучение на
реальных действиях пользователя и результатах ИИ.
- Ничего не знает о домене: получает текст, отдаёт решение; обучение — по контракту.
- Экспорт/импорт моделей — по контракту (для переноса между инстансами).
### 7.3 ai-service
- Фасад LLM-провайдеров (DeepSeek и др., включая локальные OpenAI-совместимые),
библиотека промптов, классификация, генерация.
- **Учёт токенов**: каждый вызов оценивается в токенах и списывается с бюджета тенанта.
- При исчерпании бюджета — fallback на ML/локальную обработку + уведомление
(приём сообщений не блокируется).
---
## 8. Безопасность
### Слой приложения (core)
- SQL-инъекции: запрет конкатенации SQL; только параметризация (EF Core/Dapper);
анализаторы; Postgres-роль без DDL.
- Tenant-изоляция (IDOR): tenantId из сессии; проверка принадлежности; тесты.
- Аутентификация: Argon2id, лимит попыток, одноразовые инвайты с expiry.
- Сессии: httpOnly cookie + CSRF (не localStorage).
- XSS: экранирование на фронте (renderSourceMessage), CSP, запрет v-html без санитайзера.
- SSRF: ai/telegram не тянут произвольные URL от имени тенанта (allowlist).
- Валидация входа: DTO + FluentValidation, лимиты размеров.
- Аудит: входы, инвайты, impersonation, действия оператора — неизменяемый поток.
### Транспорт/сервисы
- TLS везде; mTLS между сервисами; service-token второй фактор.
### Инфраструктура
- Cloudflare (DDoS/WAF) → reverse proxy (TLS, rate limit по IP, security-заголовки).
- Rate limiting в приложении по тенанту (защита от «шумного соседа»).
- Docker: сервисы в изолированной сети, наружу — только прокси; non-root, read-only FS.
- Секреты: env/secret-хранилище; шифрование (enc); ничего в коде/репозитории.
### Процессы
- CI: сканирование зависимостей (NuGet/npm), SAST, trivy-скан образов.
- Обновления и алерты на CVE.
- Postgres: бэкапы ежедневные, тест восстановления.
---
## 9. Наблюдаемость, админка, бэкапы
### Observability
- Serilog (структурированные логи) + OpenTelemetry (метрики/трейсы) → Promtail → **Grafana + Loki**.
- Дашборды: health сервисов, pipeline, ML-качество, расход токенов по тенантам.
- За абстракцией экспорта — смена стека без правки кода.
### Операторская админка (только оператору)
- Создание тенантов и инвайтов, лимиты, health, impersonation (с полным аудитом),
подозрительная активность. Отдельный защищённый вход (оператор ≠ тенант).
### Бэкапы
- Ежедневно: Postgres (pg_dump), файлы MinIO, сессии telegram.
- Retention и внешняя выгрузка — уточнить на этапе деплоя.
---
## 10. Деплой
- docker compose на одном VPS: core, ml-service, ai-service, telegram-service,
postgres, minio, grafana/loki/promtail, reverse proxy.
- Сервисы compose = будущие k8s-деплойменты (никаких завязок на compose в коде).
- Миграции схем тенантов: механизм «миграция ко всем схемам» (список схем в `public`,
применение по очереди, версия миграции на схему) — детализировать в плане реализации.
---
## 11. Стандарты кода
- Код-стайл: `C:\telbase\Стиль_кода.docx` + согласованные адаптации
(без snake_case-хелперов и регионов, public-поля → свойства, XML-doc для public-контрактов,
настройки через `IOptions<T>`, комментарии на русском).
- 1 тип = 1 файл (класс/record/struct/enum/interface — отдельный файл).
- `.editorconfig` + Roslyn-анализаторы с ошибками на нарушения.
- Второй слой правил: скилы `agent-rules-books` (Clean Code, DDD, DDIA).
- .NET-эталоны: скил `dotnet-clean-architecture-skills` (адаптировать под проект).
---
## 12. Открытые вопросы / следующие шаги
1. **Карта `/api`**: снять точную спецификацию с работающего LeadRadar (отдельная задача).
2. **Детали лимитов**: механика «бюджет токенов» (период, пороги, уведомления) — спроектировать.
3. **Бэкапы**: точная схема retention/внешнего хранилища.
4. **Миграции на 1000 схем**: детальный механизм.
5. Порядок реализации: этап 0 (каркас) → Pipeline+Kanban → ai/ml/telegram → Projects/Discovery.
---
## Приложение: глоссарий
- **Тенант** — клиент SaaS (одна организация/пользователь), владеет схемой БД и настройками.
- **Канал/источник** — Telegram-канал/группа, который слушает аккаунт тенанта.
- **Карточка** — структурированная заявка (заказ/вакансия), созданная пайплайном.
- **Outbox** — паттерн надёжной доставки событий через таблицу в той же транзакции.
@@ -0,0 +1,92 @@
# Дейл — единая модель карточки (unified card)
> Исторический документ (дизайн этапа 9, 2026-09-09). Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
> Дата: 2026-09-09
> Статус: дизайн согласован с владельцем продукта (в чате), начало реализации.
> Связанные документы: `docs/spec/ТЗ-дейл-новая-архитектура.md`, `docs/architecture/2026-09-05-deal-architecture-design.md`.
## Проблема
Сейчас в системе **два «домена» карточек**, хотя по смыслу это одна сущность:
| | Канбан (`/api/leads`) | «Выбранные» (`/api/projects`) |
|---|---|---|
| Таблица | `Cards` | `ProjectCards` |
| Контейнер | колонка `inbox/board/archive/trash/taken` | стадия `planned…finished/rejected` |
| «Взять в работу» | `col=taken` + **копия полей** в `ProjectCards` | создание второй записи |
| Драйвер/карточка | `CardDto` | `ProjectCardDto` |
Переход «лид → проектная карточка» — это **клонирование в другую сущность**: у карточки меняется id, теряется связность истории, третий вид карточки/дашборда потребует третьей таблицы и третьего конвейера.
**Решение (согласовано):** карточка — **один агрегат** во всех дашбордах. Понятие «лид» упраздняется: сообщение из канала — это *входные данные*, из которых создаётся карточка. «Взял в работу» — это **переход карточки в другой контейнер** той же доски пространства «Выбранные», а не создание новой записи.
## Модель (C#)
### Ядро
```csharp
/// Единственное, что есть у любой карточки.
public interface ICard
{
string Id { get; }
string Title { get; }
ISource Source { get; } // откуда пришла (см. ниже)
}
/// Типизированная проекция для сценариев, которым нужен конкретный источник.
public interface ICard<TSource> : ICard where TSource : ISource
{
new TSource Source { get; }
}
```
### Источники (ISource) — иерархия, а не enum-свойство
- `ISource` — общее: `DisplayName`, `OriginRef`, `RawPayload`, `ReceivedAt`.
- Простые: `ILocalSource`, `IWebSource`, `IFileSource`.
- Сложные: `ITelegramSource` (dialogId/messageId/peer/topic), `IRowSource` (импорт колонки/строки), `IApiSource`, `IAiSource` (провайдер+модель+агент), `ICompositeSource { Origin, Pipeline[] }`.
### Модули-роли карточки (опциональные части одного агрегата)
`IContentCard` (блок «О заявке»), `IBudgetedCard`, `IContactCard`, `IAttributedCard` (стек/грейд/локация — настраиваемые атрибуты тенанта), `ICommentableCard`, `ILinkCard`, `IFileCard`, `ITzCard`, `ITraceableCard` (история), `IRemindableCard`, `ILocatedCard` (контейнер + prev + isNew).
Вид карточки = композиция модулей, **не класс-наследник**. Новый дашборд/вид — новая композиция + при необходимости новый модуль.
### Контейнеры (общая база колонок/стадий/зон)
```csharp
public interface IContainer
{
string Id { get; }
string Name { get; }
string Color { get; }
int Order { get; }
IContainerRules? Rules { get; } // фильтры попадания (пользовательские колонки)
IContainerPolicy Policy { get; } // поведение (роль, не enum)
}
```
Политики: возврат/очистка (корзина 7д, архив 90д), терминальность («Отклонено/Выполнено» — только ручная очистка), «выбранные не попадают в архив дашборда». Отсев пайплайна — **не карточка**, вне этой модели.
### Переходы
Один `ICardMover.MoveAsync(card, toContainerId, ctx)`; правила — в политиках контейнеров и «воротах» между пространствами; побочные эффекты карточка делает через свои модули (`ITraceableCard` пишет историю, `IRemindableCard` сбрасывает напоминание, `ILocatedCard.IsNew=false`).
## Терминология
- ~~лид, lead~~ → **карточка (card)**; входное сообщение → **сообщение-источник**.
- ~~проектная карточка~~ → карточка в контейнерах пространства «Выбранные».
- «Взять в работу» → переход в контейнер `planned`.
## Что меняется
- **БД**: таблицы `Cards` + `ProjectCards` → одна `Cards` (+ модульные данные); доски и стадии — единый реестр контейнеров; удаляется `ProjectCards`, перенос `LeadComments` в модуль карточки.
- **Бэк**: модули Kanban и Projects объединяются в один модуль карточки/контейнеров; порты/сервисы/адаптеры/DTO — единые.
- **Pipeline**: создаёт карточку (не «лид»), кладёт в контейнер по правилам.
- **API**: единый контракт `/api/cards` + `/api/containers`; `/api/leads`, `/api/projects` упраздняются (фронт переписывается).
- **Фронт**: один state-слайс карточек, один рендер карточки/драйвера, один канбан-компонент.
## Границы этапа
Данные тестовые — схема пересоздаётся, миграции данных нет. Вне рамок: Kafka, «третьи» дашборды (архитектура готова), разовые миграции.
@@ -0,0 +1,252 @@
# Дейл — контракт операторской аналитики и аудита действий (этап 10, T1–T3)
> Дата: 2026-09-10
> Статус: контракт для фронта (оператор-консоль, T4). Источник истины для `src/frontend`.
> Связанные документы: `docs/superpowers/plans/2026-09-10-deal-stage10-operator-analytics.md`,
> `docs/architecture/2026-09-10-unified-api-contract.md`.
## Общие правила
- **Только операторская сессия.** Все ручки `/api/operator/*` (включая аналитику) требуют разрешённой
операторской сессии (кука `deal_operator_session`). Без неё — `401 { "detail": "Требуется вход оператора" }`.
- Все ответы — JSON **camelCase**.
- **Время на wire в этих ручках — ISO-8601** (`DateTimeOffset`, UTC, напр. `2026-09-10T15:22:46.123Z`).
Query-параметры `from`/`to` и поля `at`/`from`/`to` используют ISO-8601 (как уже принято в
`GET /api/operator/audit`). Ключи агрегатов `groupBy=day` — строки `ГГГГ-ММ-ДД`.
- Диапазоны `from`/`to`**включительно**; не заданы — без границы.
- Ошибки — `{ "detail": "текст" }`. Коды: `400` (некорректный ввод, напр. неизвестный `groupBy`),
`401` (нет операторской сессии).
- Все ручки аналитики — **read-only** (ничего не меняют).
## Каталог событий аудита (для фильтров `eventType` / ленты действий)
К SaaS-событиям этапа 7 добавлены (этап 10, T1; актор `tenant` — действия пользователя тенанта):
| `eventType` | Когда |
|---|---|
| `tenant_logout` | выход пользователя тенанта (`POST /api/auth/logout`) |
| `operator_logout` | выход оператора (`POST /api/operator/auth/logout`) |
| `invite_joined` | активация инвайта (`POST /api/join`) |
| `card_created` | создание карточки |
| `card_moved` | перенос карточки между контейнерами |
| `card_trashed` | карточка отправлена в корзину |
| `card_restored` | карточка возвращена из корзины/архива |
| `card_deleted` | карточка удалена навсегда |
| `card_comment_added` | добавлен комментарий к карточке |
| `container_created` | создан контейнер/колонка |
| `container_updated` | изменён контейнер/колонка |
| `container_deleted` | удалён контейнер/колонка |
| `settings_updated` | сохранены настройки тенанта |
| `channel_enabled` | включён мониторинг канала Telegram |
| `channel_created` | канал добавлен в каталог (резерв каталога) |
| `telegram_linked` | аккаунт Telegram привязан (фаза `ready`) |
| `telegram_keys_changed` | оператор изменил глобальные ключи Telegram (`PUT /api/operator/settings/telegram-keys`) |
Типы акторов (`actorType`): `tenant`, `operator`, `system`. Секреты (пароли, токены, api-ключи) в
`detailJson` **не пишутся**.
---
## GET /api/operator/analytics/overview
Сводка за период: тенанты, расход токенов, события, входы/выходы/неудачные входы.
**Query**
| Параметр | Тип | Обяз. | Описание |
|---|---|---|---|
| `from` | ISO-8601 | нет | начало периода (включительно) |
| `to` | ISO-8601 | нет | конец периода (включительно) |
**200**
```json
{
"tenantsTotal": 12,
"tenantsActive": 10,
"promptTokens": 1250000,
"completionTokens": 320000,
"totalTokens": 1570000,
"tokenEvents": 842,
"events": 5012,
"logins": 320,
"logouts": 288,
"failedLogins": 17,
"from": "2026-09-01T00:00:00Z",
"to": "2026-10-01T00:00:00Z"
}
```
- `tokens*`/`tokenEvents` — сумма по событиям `public.token_usage_events` за период.
- `events` — число записей аудита за период.
- `logins` = `tenant_login_ok` + `operator_login_ok`; `logouts` = `tenant_logout` + `operator_logout`;
`failedLogins` = `tenant_login_failed` + `operator_login_failed`.
**Коды**: `200`, `401`.
---
## GET /api/operator/analytics/tokens
Серия/агрегаты расхода токенов.
**Query**
| Параметр | Тип | Обяз. | Описание |
|---|---|---|---|
| `groupBy` | enum | нет | `day` (дефолт) \| `tenant` \| `provider` \| `model` |
| `tenantId` | uuid | нет | фильтр по тенанту |
| `from` | ISO-8601 | нет | начало периода (включительно) |
| `to` | ISO-8601 | нет | конец периода (включительно) |
**200**
```json
{
"groupBy": "day",
"from": "2026-09-01T00:00:00Z",
"to": "2026-10-01T00:00:00Z",
"items": [
{ "key": "2026-09-10", "promptTokens": 1200, "completionTokens": 300, "totalTokens": 1500, "eventCount": 42 }
],
"total": { "key": "total", "promptTokens": 1250000, "completionTokens": 320000, "totalTokens": 1570000, "eventCount": 842 }
}
```
- `key` группы: `day``ГГГГ-ММ-ДД` (сутки UTC); `tenant` — Guid `D`; `provider` — id провайдера
(`deepseek`/`openai`/…, для ML — `local`); `model` — модель (`ml` для локальной ML-модели).
- Порядок `items`: `day` — по возрастанию даты; `tenant`/`provider`/`model` — по убыванию `totalTokens`.
- `total` — итог по всем строкам.
**Коды**: `200`; `400 { "detail": "Неизвестная группировка (day|tenant|provider|model)" }`; `401`.
---
## GET /api/operator/analytics/activity
Лента действий (аудит) с фильтрами и пагинацией.
**Query**
| Параметр | Тип | Обяз. | Описание |
|---|---|---|---|
| `eventType` | string | нет | тип события (см. каталог) |
| `actorType` | enum | нет | `tenant` \| `operator` \| `system` |
| `actorId` | uuid | нет | идентификатор актора |
| `tenantId` | uuid | нет | тенант |
| `from` | ISO-8601 | нет | нижняя граница `at` (включительно) |
| `to` | ISO-8601 | нет | верхняя граница `at` (включительно) |
| `limit` | int | нет | размер страницы (дефолт 100, кламп 1..500) |
| `offset` | int | нет | смещение (≥0) |
**200**
```json
{
"items": [
{
"eventType": "card_moved",
"actorType": "tenant",
"actorId": "1f2e3d4c-5b6a-7980-1234-56789abcdef0",
"tenantId": "aabbccdd-eeff-0011-2233-445566778899",
"ip": "203.0.113.7",
"detailJson": "{\"cardId\":\"c_1a2b3c4d5e6f\",\"to\":\"planned\"}",
"at": "2026-09-10T15:22:46.123Z",
"id": 1042
}
],
"total": 5012,
"limit": 100,
"offset": 0
}
```
- `items` — новые сверху (`at` DESC). `total` — полное число по фильтру (без `limit`/`offset`).
- `detailJson`**строка** JSON деталей события (без секретов), может быть `null`.
**Коды**: `200`, `401`.
---
## Расширение GET /api/operator/audit
К прежним фильтрам (`eventType`, `actorType`, `tenantId`, `from`, `to`, `limit`) добавлены:
| Параметр | Тип | Обяз. | Описание |
|---|---|---|---|
| `actorId` | uuid | нет | фильтр по идентификатору актора |
| `offset` | int | нет | смещение страницы (≥0, дефолт 0) |
Ответ — прежний `{ "items": [...], "total": n }` (поля `items`/`total` без изменений; форма записи —
как в ленте действий выше). **Коды**: `200`, `401`.
---
## Операторские настройки: глобальные ключи Telegram
Ключи приложения Telegram (`api_id`/`api_hash`) задаются оператором **глобально** (ТЗ §4.1/§8.1),
едины для всех тенантов. Тенант их не видит и не задаёт (ключ `tgKeys` удалён из `GET/PATCH /api/settings`).
Хранилище — системная таблица `public.global_settings` (ключ `telegramKeys`), `apiHash` хранится
зашифрованным и наружу не отдаётся.
### GET /api/operator/settings/telegram-keys
Маскированный снимок глобальных ключей.
**200**
```json
{
"apiId": "1234567",
"apiHash": "abcd…mnop",
"keysSet": true
}
```
- `apiId` — открыт (не секрет; пусто — ключи не заданы оператором).
- `apiHash`**маска** (пусто / `x…` / `1234…5678`); открытый секрет не возвращается никогда.
- `keysSet``true`, если заданы оба ключа; в `GET /api/tg/status` это же значение в поле `keysSet`.
**Коды**: `200`, `401`.
### PUT /api/operator/settings/telegram-keys
Сохранение/смена глобальных ключей. Поля можно передавать **по отдельности** (частичное обновление):
непереданное поле (`null` или отсутствие в JSON) сохраняет текущее значение. Если ключей ещё нет,
оба поля обязательны.
**Тело**
```json
{ "apiId": "1234567", "apiHash": "abcdefghijklmnop" } // полное обновление
```
```json
{ "apiId": "7654321" } // только apiId — apiHash сохраняется
```
```json
{ "apiHash": "newsecrethash12" } // только apiHash — apiId сохраняется
```
- `apiId` — если передан, строго 5–9 цифр; если не передан, берётся текущий (`null` = «не менялось»).
- `apiHash` — если передан, непустой секрет (не маска и без префикса `enc:`), шифруется перед сохранением;
если не передан, берётся текущий зашифрованный секрет.
- Явное пустое значение (`""`) считается невалидным, а не «не менялось».
**200** — маскированный снимок (форма как у GET).
**Ошибки**
- `400 { "detail": "Укажите api_id и api_hash" }` — не передано ни одного поля.
- `400 { "detail": "Ключи ещё не заданы — укажите и api_id, и api_hash" }` — частичное обновление,
но ключей ещё нет (нельзя дополнить отсутствующее значение).
- `400 { "detail": "api_id должен состоять из 5–9 цифр" }`
- `400 { "detail": "Укажите непустой api_hash" }`
- `401 { "detail": "Требуется вход оператора" }`
**Аудит**: событие `telegram_keys_changed` (актор `operator`, `tenantId: null`, детали `{apiId, apiHashSet}` — без секрета).
> Примечание для вкладки Telegram у тенанта: `GET /api/tg/status` остаётся (подключение аккаунта),
> поле `keysSet` отражает глобальные ключи; команды `start-phone`/`start-qr` без ключей отвечают
> `400 { "detail": "Ключи Telegram не заданы оператором" }`.
@@ -0,0 +1,434 @@
# Дейл — единый API-контракт этапа 9 (cards + containers)
> Дата: 2026-09-10
> Статус: контракт для портирования фронта (T6). Источник истины для `src/frontend`.
> Связанные документы: `docs/architecture/2026-09-09-unified-card.md`,
> `docs/superpowers/plans/2026-09-09-deal-stage9-unified-card.md` (T4T6, R5).
## Общие правила
- **Только два домена API**: `/api/cards` (карточки) и `/api/containers` (колонки/стадии/зоны).
Старые ручки `/api/leads`, `/api/projects`, `/api/boards` **удалены**.
- Все ответы и тела запросов — JSON **camelCase**.
- Время на wire — **epoch-ms** (`int64`, UTC). Внутри — `DateTimeOffset` (UTC).
- Ошибки — объект `{ "detail": "текст" }`. Коды: `400` (некорректный ввод), `401` (нет сессии),
`404` (объект не найден), `422` (тело не разобрано).
- Аутентификация — сессионная кука (как раньше). Без сессии — `401 {detail}`.
- Контейнер — единый реестр колонок/стадий/зон. Карточка ссылается на контейнер полем
`containerId` (алиас прежнего `col`). Пространства: `dashboard` (дашборд) и `selected`
(«Выбранные»). Карточка живёт в одном пространстве: её `containerId` однозначно определяет,
где она показана.
- Виды контейнеров (`kind`): `board` (пользовательская колонка-фильтр), `stage` (стадия
«Выбранных»), `service` (inbox/archive/trash), `terminal` (finished/rejected).
## SSE (`GET /api/events`)
Поток `text/event-stream`, канал тенанта сессии. Типы событий:
| `event` | `data` | Когда |
|---|---|---|
| `new_card` | объект **Card** (см. ниже) | создана карточка (пайплайн, демо, тик) |
| `reminder_due` | `{ "id", "title", "containerId" }` | наступило напоминание |
| `toast` | `{ "text", "icon" }` | статистика тика / служебное уведомление |
| `cards_reclassified` | промежуточный — `{ "progress": true, "done", "total", "moved", "kept", "trashed", "skipped" }`; финал — `{ "progress": false, "reclassified", "moved" }` | прогресс/завершение переклассификации «Неразобранного» |
`new_lead` больше не публикуется (переименован в `new_card`).
---
## Card (карточка)
Единая сущность во всех дашбордах. Модульные поля (контакты/ссылки/файлы/ТЗ/история/напоминание)
присутствуют всегда, но могут быть пустыми.
```json
{
"id": "c_1a2b3c4d5e6f",
"containerId": "inbox",
"col": "inbox",
"isNew": true,
"local": false,
"title": "Разработка интернет-магазина",
"summary": "Компания: ...\nЗадача: ...",
"source": {
"kind": "telegram",
"displayName": "Канал заказов",
"originRef": "123456789",
"receivedAt": 1726000000000
},
"sourceMsg": "Ищу разработчика...",
"sourceDialogId": "123456789",
"sourceMsgId": 4242,
"stack": ["vue", "dotnet"],
"budget": { "from": 100000, "to": 200000, "cur": "RUB" },
"converted": { "from": 100000, "to": 200000, "cur": "RUB" },
"contact": "@client",
"contacts": [{ "type": "tg", "value": "@client" }],
"channel": { "name": "Канал заказов", "handle": "@orders", "hue": "#8b8ff8" },
"matchHits": [{ "label": "Стек", "term": "vue", "word": null }],
"comments": [{ "id": "cm_...", "by": "Вы", "text": "Позвонил", "time": "5 мин" }],
"links": [{ "id": "pl_...", "name": "Бриф", "url": "https://example.com" }],
"files": [
{ "id": "pf_...", "name": "brief.pdf", "size": 10240, "kind": "document",
"label": "Документ", "objectKey": "cards/c_.../pf_..." }
],
"history": [
{ "id": "h_...", "at": 1726000000000, "type": "created", "stage": null },
{ "id": "h_...", "at": 1726003600000, "type": null, "stage": "planned" }
],
"tzText": "Сделать каталог и корзину",
"reminder": { "at": 1727000000000 },
"prevCol": "inbox",
"isVacancy": false,
"isVacancyKnown": false,
"time": "5 мин",
"receivedAt": 1726000000000,
"createdAt": 1726000000000,
"updatedAt": 1726000000000
}
```
Поля:
| Поле | Тип | Описание |
|---|---|---|
| `id` | string | короткий id карточки, префикс `c_` |
| `containerId` | string | контейнер карточки (`inbox`/`archive`/`trash`/стадия/`b_...`) |
| `col` | string | **алиас** `containerId` (совместимость со старым фронтом) |
| `isNew` | bool | точка «новое» (снимается просмотром/переносом) |
| `local` | bool | карточка создана локально (без внешнего источника) |
| `title` / `summary` | string | заголовок / блок «О заявке» |
| `source` | object | происхождение: `kind` (`local`/`telegram`/`web`/`file`/`row`/`api`/`ai`/`composite`/`other`), `displayName`, `originRef`, `receivedAt` |
| `sourceMsg` / `sourceDialogId` / `sourceMsgId` | string / string / int64? | исходное сообщение (текст, диалог, id) |
| `stack` | string[] | стек/направления |
| `budget` | object? | `{from,to,cur}` по исходному сообщению |
| `converted` | object? | `{from,to,cur}` бюджет в целевой валюте |
| `contact` | string | «быстрый» контакт |
| `contacts` | object[] | `{type,value}` |
| `channel` | object | `{name,handle,hue}` (прежний `ch`) |
| `matchHits` | object[] | `{label,term,word?}` — почему карточка в контейнере |
| `comments` | object[] | `{id,by,text,time}` |
| `links` | object[] | `{id,name,url}` |
| `files` | object[] | `{id,name,size,kind,label,objectKey}` |
| `history` | object[] | `{id,at,type\|stage}` — ровно один из `type`/`stage` |
| `tzText` | string | техническое задание |
| `reminder` | object? | `{at}` (epoch-ms) |
| `prevCol` | string | предыдущий контейнер (возврат из archive/trash) |
| `isVacancy` / `isVacancyKnown` | bool | маркер/подтверждение «найм» |
| `time` | string | human-метка от `receivedAt` |
| `receivedAt` / `createdAt` / `updatedAt` | int64 | epoch-ms |
### `GET /api/cards?containerId=`
Список карточек. `containerId` — фильтр по контейнеру; алиас `col` принят для совместимости; без
параметра — все карточки дашборда (кроме стадий «Выбранных»).
```json
{ "items": [ /* Card... */ ] }
```
`400 {detail:"Неизвестный контейнер"}` — если контейнер не существует.
### `GET /api/cards/counts`
Плоские счётчики (совместимо с прежним `/api/leads/counts`).
```json
{ "new": 3, "inbox": { "count": 5, "new": 2 }, "learning": 12, "ml": 0, "ai": 0 }
```
### `GET /api/cards/{cardId}`
Карточка. `404 {detail:"Карточка не найдена"}`.
### `POST /api/cards`
Создание локальной карточки. Тело:
```json
{ "title": "Новый заказ", "summary": "", "containerId": "planned",
"stack": [], "budget": null, "contact": "", "tzText": "" }
```
Алиас `containerId``stage`. Ответ — созданная **Card**.
### `PATCH /api/cards/{cardId}`
Частичная правка. Null-поле = «не менять». Тело:
```json
{ "title": "...", "summary": "...", "contact": "...", "tzText": "...",
"stack": ["..."], "budget": { "from": 1, "to": 2, "cur": "RUB" } }
```
Ответ — обновлённая **Card**.
### `POST /api/cards/{cardId}/move` `{ "to": "<containerId>" }`
Перенос карточки. Ответ — обновлённая **Card**.
`400 {"Переносить можно только в существующий контейнер или в «Неразобранное»"}` (несуществующий
контейнер/служебный источник), `404`.
### `POST /api/cards/{cardId}/trash` → `{ "ok": true }`
### `POST /api/cards/{cardId}/restore` → `{ "ok": true, "col": "inbox" }`
### `DELETE /api/cards/{cardId}` → `{ "ok": true }`
### `POST /api/cards/clear-col` `{ "col": "trash"|"archive" }` → `{ "ok": true, "cleared": 4 }`
### `POST /api/cards/clear-rejected` → `{ "ok": true, "cleared": 0 }`
### `POST /api/cards/mark-all-seen` → `{ "ok": true }`
### `POST /api/cards/mark-col-seen` `{ "col": "<containerId>" }` → `{ "ok": true }`
### `POST /api/cards/take` `{ "cardId": "<cardId>" }`
«Взять в работу»: карточка (не клон) переносится в контейнер `planned` пространства
`selected`. Ответ — обновлённая **Card**. Алиас поля — `leadId`. `404` — карточки нет.
### Комментарии
`POST /api/cards/{cardId}/comments` `{ "text": "..." }``{ "comments": [ /* ... */ ] }`
`400 {detail:"Пустой комментарий"}`, `404`.
### Ссылки
- `POST /api/cards/{cardId}/links` `{ "url": "...", "name": "..." }` → обновлённая **Card**
- `DELETE /api/cards/{cardId}/links/{linkId}` → обновлённая **Card**
### Файлы
- `POST /api/cards/{cardId}/files``multipart/form-data`, поле `files` (одно или несколько)
→ обновлённая **Card**
- `GET /api/cards/{cardId}/files/{fileId}/download` → бинарный поток
- `DELETE /api/cards/{cardId}/files/{fileId}` → обновлённая **Card**
### Напоминания
- `POST /api/cards/{cardId}/reminder` `{ "at": 1727000000000 }` → обновлённая **Card**
`400 {detail:"Поле at (epoch-ms) обязательно"}`
- `DELETE /api/cards/{cardId}/reminder` → обновлённая **Card**
- `POST /api/cards/{cardId}/reminder/snooze` → обновлённая **Card**
### `POST /api/cards/{cardId}/reclassify` и `POST /api/cards/reclassify`
Переклассификация карточки/«Неразобранного»: повторный прогон через тот же конвейер, что и пайплайн
(ИИ-фильтр → классификация → сборка контента → правила колонок; без создания новой карточки).
- Single: `{cardId}` — любая карточка с исходным текстом; `404 {detail:"Карточка не найдена"}`.
- Batch: тело `{ "ids": ["c_..."] }` опционально; без `ids` — все карточки `inbox`.
- При включённом ИИ используется порт `IAiClassifier`; при выключенном (`aiEnabled=false`) или недоступности
сервиса — детерминированный локальный разбор (без кредов сервис не падает). `usedAi` показывает путь.
- Одна переклассификация за раз (single-flight): при занятом проходе `{ "started": false, "busy": true }`.
- Аудит — событие `card_reclassified` (только при `reclassified > 0`).
- Во время пакетного прохода публикуются промежуточные SSE `cards_reclassified` с `{ "progress": true,
"done", "total", "moved", "kept", "trashed", "skipped" }` (каждые 5 карточек и на последней); по завершении —
финальное `{ "progress": false, "reclassified", "moved" }`; фронт показывает `done/total` и перечитывает
доску только по финальному событию.
```json
{
"started": true,
"busy": false,
"attempted": 3,
"reclassified": 3,
"moved": 1,
"kept": 1,
"trashed": 1,
"skipped": 0,
"usedAi": false,
"reason": null
}
```
| Поле | Тип | Описание |
|---|---|---|
| `started` | bool | Проход выполнен (target непуст); `false` — пусто/занято |
| `busy` | bool | Проход уже выполняется другим запросом |
| `attempted` | int | Сколько карточек отобрано (batch — inbox, либо `ids ∩ inbox`) |
| `reclassified` | int | Успешно обработано (`moved + kept + trashed`) |
| `moved` | int | Ушло в смысловую колонку |
| `kept` | int | Осталось в «Неразобранном» |
| `trashed` | int | Отправлено в корзину (спам/не прошло ИИ-фильтр) |
| `skipped` | int | Пропущено (нет исходного текста) |
| `usedAi` | bool | True — разбор хотя бы одной карточки через порт ИИ; false — локальный разбор |
| `reason` | string? | Причина, если проход не выполнен/пусто; иначе `null` |
### `GET /api/search?q=`
```json
{ "cards": [ /* Card... */ ], "messages": [] }
```
---
## Container (колонка/стадия/зона)
```json
{
"id": "b_1a2b3c4d5e6f",
"name": "WPF",
"description": "Заказы по WPF",
"color": "#818cf8",
"order": 0,
"space": "dashboard",
"kind": "board",
"collapsed": false,
"suggested": false,
"note": "",
"rules": {
"mode": "any",
"direction": [],
"keywords": ["wpf"],
"stack": [],
"grade": [],
"exclude": [],
"budget": { "from": 0, "to": 0, "cur": "RUB" }
},
"policy": { "canRestore": true, "isTerminal": false, "retentionDays": null },
"counts": { "total": 4, "new": 1 }
}
```
| Поле | Тип | Описание |
|---|---|---|
| `id` | string | `b_...` (board), `planned…rejected` (stage/terminal), `inbox`/`archive`/`trash` (service) |
| `name` | string | имя для отображения |
| `description` | string | описание (подсказка ИИ/ML) |
| `color` | string | hex |
| `order` | int | позиция в пространстве |
| `space` | string | `dashboard` / `selected` |
| `kind` | string | `board` / `stage` / `service` / `terminal` |
| `collapsed` | bool | свёрнутость колонки на дашборде |
| `suggested` | bool | ИИ-предложение, ждёт решения пользователя |
| `note` | string | заметка/обоснование ИИ |
| `rules` | object? | правила попадания (null — фильтра нет) |
| `policy` | object | `{canRestore,isTerminal,retentionDays}` |
| `counts` | object | `{total,new}` — счётчики карточек контейнера |
`rules` (объект фильтров колонки): `mode` (`all`/`any`), `direction`, `keywords`, `stack`, `grade`,
`exclude`, `budget` (`{from,to,cur}`) и добавленные этапом 12 группы `levels` (уровень), `locations`
(локация/язык), `types` (`vacancy`/`freelance`/`announcement`), `prices` (`{from,to,cur}`). Все группы
опциональны; старый сохранённый `rules` без новых групп разбирается как прежде (обратная совместимость).
---
### `GET /api/containers?space=`
```json
{ "items": [ /* Container... */ ] }
```
`space` (`dashboard`/`selected`) — опциональный фильтр.
### `POST /api/containers`
```json
{ "name": "WPF", "description": "", "color": null,
"space": "dashboard", "kind": "board", "suggested": false, "note": "",
"rules": { "mode": "any", "keywords": ["wpf"] } }
```
`400 {detail:"Укажите название колонки"}` при отсутствующем/null `name`.
Ответ — `{ "id": "b_..." }`.
### `PATCH /api/containers/{containerId}`
Null-поле = «не менять». Тело: `name`, `description`, `color`, `collapsed`, `suggested`,
`note`, `rules`, `policy`. Ответ — `{ "id": "..." }`, `404 {detail:"Контейнер не найден"}`.
### `POST /api/containers/{containerId}/accept`
Принять ИИ-предложение (`suggested=false`), ответ — обновлённый **Container**.
### `DELETE /api/containers/{containerId}`
Удаление контейнера; его карточки переносятся в `inbox` новыми.
Ответ — `{ "ok": true, "movedToInbox": 4 }`.
### `POST /api/containers/reorder`
```json
{ "space": "dashboard", "order": ["b_...", "b_...", "inbox"] }
```
Ответ — `{ "ok": true }`.
### Состояние колонок (UI)
- `GET /api/containers/state` → `{ "<containerId>": { "collapsed": true, "width": "md" } }`
- `PATCH /api/containers/{containerId}/state` `{ "collapsed": true }` → `{ "collapsed": true }`
(только не-null поля после merge).
---
## ML (проверка на сообщении/канале, §8)
Все ручки — под сессией тенанта (`401 {detail:"Требуется авторизация"}`).
### `POST /api/ml/candidates`
Тело: `{ "dialogId": "d_...", "limit": 10 }` — `limit` клампится `1..60` (дефолт 10);
пустой `dialogId` — выборка по всем источникам тенанта (очередь/отсев/карточки).
```json
{ "items": [
{ "id": 12345, "dialogId": "d_...", "text": "исходный текст (до 600 симв.)",
"time": 1757500000000, "lead": true, "verdict": "card", "col": "b_...",
"stage": null, "reason": null,
"pred": { "take": true, "label": "b_...", "scores": { "b_...": 0.83 } } }
] }
```
| Поле | Тип | Описание |
|---|---|---|
| `id` | int | id исходного сообщения (`msgId`) — его принимает `/apply` |
| `dialogId` | string | id диалога-источника |
| `text` | string | исходный текст (до 600 символов) |
| `time` | int? | время сообщения, epoch-ms (null — неизвестно) |
| `lead` | bool | по сообщению уже есть карточка |
| `verdict` | string | `card` / `rejected` / `queued` — текущее состояние |
| `col` | string? | колонка карточки (для `verdict=card`) |
| `stage` | string? | этап отсева / статус очереди |
| `reason` | string? | причина отсева (для `verdict=rejected`) |
| `pred` | object? | мнение ML `{take,label,scores}` (null — не ответил/не готов) |
### `POST /api/ml/apply`
Тело: `{ "dialogId": "d_...", "msgId": 12345, "action": "spam" }` —
`action`: `skip` | `spam` | `board:<containerId>`.
```json
{ "ok": true, "learned": true, "moved": "trash", "leadId": "c_..." }
```
- `skip` — ничего не меняет (`learned:false`, `moved:null`);
- `spam` — учит ML; карточку → в корзину (`moved:"trash"`), сообщение из очереди → в отсев;
- `board:<id>` — учит ML; карточку переносит в колонку (`moved:"<id>"`), уже в колонке — только учит.
Ошибки: `404 {detail:"Исходное сообщение не найдено"}` — сообщение не найдено ни в карточках, ни в
отсеве, ни в очереди; `400 {detail:"Неизвестная доска"}` (нет такого контейнера);
`400 {detail:"Неизвестное действие"}`.
---
## Операторский health (глубины очередей, §10.2)
`GET /api/operator/health` дополнен числовыми полями:
```json
{ "ok": true, "core": { "db": "ok" },
"services": [ /* ... */ ],
"queues": { "pipeline": 12, "mlOutbox": 3 },
"sessions": { "active": 5 } }
```
`queues.pipeline` — суммарная глубина очереди обработки (new+filtered), `queues.mlOutbox` — очередь
обучения ML по всем тенантам; `sessions.active` — активные непросроченные сессии.
---
## Удалённые ручки
| Было | Стало |
|---|---|
| `GET/POST /api/leads`, `/api/leads/{id}`, `/counts`, `/move`, `/trash`, `/restore`, `/comments`, `/mark-*-seen`, `/clear-col`, `/reclassify` | `/api/cards...` |
| `GET/POST /api/projects`, `/api/projects/{id}`, `/take`, `/move`, `/comments`, `/links`, `/files`, `/reminder`, `/clear-rejected` | `/api/cards...` |
| `GET/POST/PATCH/DELETE /api/boards`, `/reorder` | `/api/containers...` |
| `GET /api/columns/state`, `PATCH /api/columns/{id}/state` | `/api/containers/state`, `/api/containers/{id}/state` |
| SSE `new_lead` | SSE `new_card` |