# Дейл (Deal) — карта API (Python/FastAPI → .NET) > Этап 9 «единая карточка»: карточки и колонки/стадии сведены в два домена — `/api/cards` и > `/api/containers`; ручки `/api/leads`, `/api/projects`, `/api/boards`, `/api/columns` удалены, SSE > `new_lead` переименован в `new_card`. Точный контракт — `docs/architecture/2026-09-10-unified-api-contract.md`. Источники: `src/frontend/src/{api,store,data,utils}.js`, `src/frontend/src/store/*.js`, `src/frontend/src/views|components/*.vue`, `backend/app/main.py`, `backend/app/routers/*.py`, `backend/app/{auth,sse,constants}.py`, сервисы (`pipeline`, `processing`, `discovery`, `telegram`, `ml_client`, `rates`, `files`, `rules`, `suggest`). Фронт — высший авторитет по формам JSON; по карточкам/контейнерам источник истины — единый контракт этапа 9. --- ## 1. Общие правила | Правило | Значение | |---|---| | Base path | Все API-роуты под префиксом `/api`; домены карточек/колонок — `/api/cards` и `/api/containers` (плюс `/api/auth/...`, `/api/tg/...` и т.д.) | | Контент-типы | Запросы/ответы JSON (`application/json`), сериализация camelCase. Исключения: `POST /api/cards/{id}/files` — `multipart/form-data`, поле **`files`** (несколько файлов); `GET /api/tg/qr-image` — `image/svg+xml`; `GET /api/cards/{id}/files/{fileId}/download` — `application/octet-stream` (attachment); `GET /api/events` — `text/event-stream` | | Сессия | httpOnly-кука **`deal_session`** (в прототипе — `leadradar_session`); `HttpOnly`, `SameSite=Lax`, `max-age` 30 дней, `secure` — по конфигурации (`Cookies__Secure`, в проде true). Запросы идут с `credentials: 'include'`. Токен сессии — случайный, хранится в БД. При logout кука удаляется; смена пароля инвалидирует старые сессии | | Авторизация | Все роуты, кроме `POST /api/auth/login`, требуют валидной куки (`current_login`). Иначе **401** `{"detail": "Требуется авторизация"}`. Фронт на 401 разлогинивается (`setUnauthorizedHandler`) | | Ошибки | Всегда **`{"detail": "<текст>"}`** (без `error`/`message`-обёртки). Коды: `400` (неверное тело/правила), `401` (нет сессии), `403` (тенант приостановлен), `404` (не найдено), `410` (файл не сохранён). Фронт парсит `data.detail \|\| data.message`. ⚠ «мягкие» ошибки отдаются HTTP 200 с полями (`generate-keywords` → `{keywords:[], error}`; `suggest-*` → `{ok:false, reason}`; `reset` ML → `{ok:false, error}`) | | Оборачивание списков | `{"items": [...]}` — везде; исключение — `GET /api/containers/state` (объект ` → state`). Пагинация отсева: `{items, total, offset, limit}` | | Успех-без-данных | `{"ok": true}` (+ опциональные поля) | | Времена | epoch **миллисекунды** (int) в `receivedAt`, `createdAt`, `updatedAt`, `at`, `msgAt`, `queuedAt`, `rejectedAt`, `returnedAt`, `time` в превью-сообщениях; поле `card.time` — **строка** «только что»/«5 мин»/«3 ч»/«2 дн» | | Статические сегменты против `{id}` | Литералы объявляются до `{id}`: `/cards/counts`, `/cards/clear-col`, `/cards/clear-rejected`, `/cards/reclassify`, `/cards/mark-all-seen`, `/cards/mark-col-seen`, `/cards/take` — до `/cards/{cardId}`; `/containers/state`, `/containers/reorder` — до `/containers/{containerId}`; `/rejected/clear` — до `/rejected/{rejId}`. ASP.NET Core отдаёт приоритет литералам, порядок сохранён для читаемости | | Prefix'ы id | `c_` — карточка (единый для всех дашбордов), `b_` — контейнер-колонка, `p_` — строка очереди, `r_` — запись отсева, `cm_` — комментарий, `h_` — запись истории, `pf_` — файл, `pl_*` — ссылка, `dt_` — discovery-задача, `dl_` — запись лога, `m__` — сообщение. Контейнеры-стадии/служебные зоны — без префикса (`planned`…`rejected`, `inbox`/`archive`/`trash`) | | Служебные админ | `admin/tick`, `admin/fts/rebuild`, `admin/check-message` — служебные, фронтом не вызываются | | Проверка фильтра/тестера | `POST /api/admin/check-message` — сухой прогон текста по всему конвейеру (стоп-правила → ML → ИИ) без создания карточки | | Фоновые циклы (не API) | storage-тик (30 с): автоархив/очистка + напоминания; pipeline-воркер (2 с); discovery-воркер (5 с); ML outbox (10 с); suggest (180 с); tg sweep (30 с); rates (30 мин) | --- ## 2. SSE `GET /api/events` Поток `text/event-stream`, заголовки `Cache-Control: no-cache`, `X-Accel-Buffering: no`; каждые 15 с без событий — комментарий-пинг `: ping`. Формат события: `event: \ndata: \n\n` (все данные — JSON). **Что публикует бэкенд Дейла (4 именованных типа; прототип публиковал 7 — `pipeline_stats`/`boards_changed`/`leads_reclassified` в Дейле не реализованы):** | event | Payload | Кто шлёт / когда | |---|---|---| | `new_card` | **полный объект карточки** (см. §4.1 — тот же объект, что элемент `GET /api/cards`) | pipeline-воркер при создании карточки (ML/ИИ-путь) | | `toast` | `{"text": str, "icon": str}` — icon: `check`/`sparkles`/`clock`/`trash`/`x`/`send`/`logout`/`bell`/`refresh`/`restore` | автоархив/очистки, подключение/отключение Telegram, ИИ-предложения колонок, срабатывание ИИ-бюджета | | `reminder_due` | `{"id": "", "title": str, "containerId": "hold"}` | фоновый цикл правил хранения (30 с) — наступившие напоминания стадии `hold` при включённых напоминаниях | | `system_status` | полный объект `tg.status()` (см. §4.9) | telegram-service при изменении подключения | `new_lead` больше не публикуется (переименован в `new_card`). Фронтовый `openEvents()` (`api.js`) слушает `new_card`, `toast`, `reminder_due`, `system_status`. --- ## 3. Таблицы эндпоинтов Сокращения: «→ карточка» = полный объект карточки §4.1; «→ контейнер» = §4.2; «→ settings» = §4.6; «→ задача/кандидат» = §4.8. `(фронт не вызывает)` — эндпоинт есть, UI его не дёргает; `(не используется фронтом)` — поле в ответе есть, UI не читает. Счётчики в заголовках разделов — фактические строки таблиц (без строки-шапки); при добавлении/удалении ручки — обновлять. ### 3.1 Auth (auth_routes.py) — 4 эндпоинта | METHOD /api/… | Назначение | Request body | Response | |---|---|---|---| | `POST /auth/login` | Вход; ставит куку | `{login, password}` | `{ok: true, login: ""}`. 401 `{"detail":"Неверный логин или пароль"}` | | `POST /auth/logout` | Удалить сессию и куку | — | `{ok: true}` | | `GET /auth/me` | Проверка живой сессии | — | `{login, ok: true}` | | `POST /auth/change-password` | Смена пароля; перевыпуск куки | `{oldPassword, newPassword}` (min 8) | `{ok: true}`; 400 «Текущий пароль неверен»/«Пароль слишком короткий (минимум 8 символов)» | ### 3.2 Карточки, контейнеры, поиск, admin, ai (бывший Dashboard) **Контейнеры (8; бывшие доски + колонки):** | METHOD /api/… | Назначение | Request body | Response | |---|---|---|---| | `GET /containers?space=` | Список контейнеров пространства (`dashboard`/`selected`) — колонки/стадии/зоны со счётчиками | — | `{items: [→ контейнер]}` | | `POST /containers` | Создать контейнер (колонку-фильтр) | `{name, description?, color?, space?, kind?, suggested?, note?, rules?}` | `{id: ""}`; 400 «Укажите название колонки» | | `PATCH /containers/{id}` | Правка (`name/description/color/collapsed/suggested/note/rules/policy`; null — «не менять») | `{…}` | `{id}`; 404 «Контейнер не найден» | | `POST /containers/{id}/accept` | Принять ИИ-предложение (`suggested=false`) | — | → контейнер | | `DELETE /containers/{id}` | Удалить; карточки → inbox новыми | — | `{ok: true, movedToInbox: }` | | `POST /containers/reorder` | Порядок контейнеров пространства | `{space, order: ["", …]}` | `{ok: true}`; 400 «Не указан порядок колонок» | | `GET /containers/state` | Состояние колонок (свёрнутость/ширина, `colState`) | — | `{ "": {"collapsed": bool, "width": "sm"\|"md"\|"lg"} }` | | `PATCH /containers/{id}/state` | Сменить состояние колонки | `{collapsed?, width?}` | состояние **только этой** колонки | **Карточки (13; бывшие лиды + базовые операции Projects):** | METHOD /api/… | Назначение | Request body | Response | |---|---|---|---| | `GET /cards?containerId=` | Карточки (`containerId`/алиас `col`; без параметра — весь дашборд), свежие сверху | — | `{items: [→ карточка]}`; 400 «Неизвестный контейнер» | | `GET /cards/counts` | Плоские счётчики + счётчики обучения | — | см. §4.1 «counts» | | `GET /cards/{cardId}` | Одна карточка | — | → карточка; 404 «Карточка не найдена» | | `POST /cards/mark-all-seen` | Снять «новое» со всех | — | `{ok: true}` | | `POST /cards/mark-col-seen` | Снять «новое» с контейнера | `{col}` | `{ok: true}` | | `POST /cards/{cardId}/move` | Перенос карточки в контейнер; учит ML | `{to: ""}` | → карточка; 400 «Переносить можно только…» | | `POST /cards/{cardId}/trash` | В корзину; учит ML `spam` | — | `{ok: true}`; 404 | | `POST /cards/{cardId}/restore` | Возврат из архива/корзины | — | `{ok: true, col: ""}` | | `DELETE /cards/{cardId}` | Удалить навсегда | — | `{ok: true}` | | `POST /cards/clear-col` | Очистить корзину/архив целиком | `{col: "trash"\|"archive"}` | `{ok: true, cleared: }`; 400 | | `POST /cards/{cardId}/comments` | Добавить комментарий | `{text}` | `{comments: [{id, by:"Вы", text, time:"только что"}]}`; 400 «Пустой комментарий» | | `POST /cards/reclassify` | Переклассификация «Неразобранного» (реальный прогон; single-flight) | `{ids?: ["c_…"]}` (тело опционально; без `ids` — все `inbox`) | `{started, busy, attempted, reclassified, moved, kept, trashed, skipped, usedAi, reason}`; при занятом проходе `{started:false, busy:true}` | | `POST /cards/{cardId}/reclassify` | Переклассификация одной карточки | — | тот же объект ответа; 404 «Карточка не найдена» | **Поиск (1):** | METHOD /api/… | Назначение | Request | Response | |---|---|---|---| | `GET /search?q=` | Полнотекстовый+LIKE поиск, `limit=12` | query `q` (min 2 симв.) | `{cards: [→ карточка], messages: []}` — в Дейле `messages` всегда пуст | **Admin (3; в Дейле реализованы `tick`/`fts/rebuild`/`check-message`, остальные строки — только прототип, §6):** | METHOD /api/… | Назначение | Response | |---|---|---| | `POST /admin/tick` | Ручной тик: хранение+напоминания+разбор очереди (фронт зовёт раз в 60 с) | `{storage: {archived, purgedArchive, purgedTrash, purgedRejected}, reminders: [{id, title, containerId}] (уже «выстрелившие», после SSE), pipeline: , queue: int}` | | `POST /admin/fts/rebuild` | Пересобрать FTS-индекс | `{ok: bool, ready: bool}` | | `POST /admin/check-message` | Сухой прогон текста по конвейеру (стоп-правила → ML → ИИ) | см. §4.10 | | `POST /admin/wipe` | Полный сброс (карточки+ML+счётчики) | `{ok, cardsRemoved, ml: {ok}}` *(прототип)* | | `POST /admin/clear-cards` | Очистить карточки/очереди без сброса ML | `{ok, cardsRemoved}` *(прототип)* | | `POST /admin/pump-gate` | Шлагбаум воркера `{limit?}` | `{ok, limit, done}` *(прототип)* | **AI-действия (2):** | METHOD /api/… | Назначение | Response | |---|---|---| | `POST /ai/suggest-columns` | ИИ предлагает колонки по inbox (ручной запуск) | `{ok: true, created: int}` или `{ok: false, reason: str, cooldown?}`; при успехе шлёт `toast` | | `POST /ai/suggest-keywords` | ИИ предлагает общие ключи сферы | `{ok: true, keywords: [str]}` (≤60 шт., длина ≤40) или `{ok: false, reason}` | ### 3.3 Telegram (tg_routes.py) — 14 | METHOD /api/tg/… | Назначение | Request body | Response | |---|---|---|---| | `GET /status` | Статус аккаунта/фазы входа | — | §4.9 (status) | | `POST /start-phone` | Вход по телефону | `{phone}` | `{phase: "code"}`; 400 с текстом причины | | `POST /start-qr` | Начать QR-вход | — | `{phase: "qr", qrUrl: "https://t.me/…"}` | | `POST /send-code` | Отправить SMS-код | `{code}` | `{phase: "password"\|"done"}`; 400 | | `POST /send-password` | 2FA-пароль | `{password}` | `{phase: "done"}`; 400 | | `POST /logout` | Отключить аккаунт, удалить сессию | — | `{ok: true}` (+toast/`system_status` по SSE) | | `GET /qr-image` | SVG QR-кода (фаза qr) | — | `image/svg+xml`; 404 «QR не активен…». Фронт: `` | | `GET /dialogs` | Список диалогов из БД | — | `{items: [§4.11 диалог]}` | | `POST /dialogs/refresh` | Синхронизировать диалоги из Telegram | — | `{ok: true, count: int}` или `{ok: false, reason: "not-connected", count: 0}` | | `POST /dialogs/monitor-all` | Мониторинг всех каналов (первое включение → backfill в фоне) | `{enabled: bool}` | `{ok: true, count: int, enabled: bool}` | | `POST /dialogs/backfill-all` | Перечитать последние ~10 сообщений включённых каналов (фон) | — | `{ok: true, count: int}` | | `POST /dialogs/{dialog_id}/monitor` | Вкл/выкл мониторинг канала | `{enabled: bool}` | `{ok: true, enabled: bool}` | | `POST /dialogs/{dialog_id}/backfill` | Догнать сообщения одного диалога | — | `{ok: true, processed: int}` *(фронт не вызывает — только сервер)* | | `POST /dialogs/preview` | Последние сообщения диалога (свежие из TG, старые из БД) | `{dialogId, limit?=24 (clamp 1..50)}` | `{items: [§4.11 сообщение]}` | ### 3.4 Settings / rates / meta (settings_routes.py) — 6 | METHOD /api/… | Назначение | Request body | Response | |---|---|---|---| | `GET /settings` | Публичные настройки (секреты замаскированы) | — | §4.6 (полный settings) | | `PATCH /settings` | Частичное обновление (см. §4.6 список ключей). Инварианты: `archiveAfterDays` 1..30, `minLen` 10..500, `discJoinLimit` 1..200, `discJoinDelayMin/Max` 5..600 (min≤max), `discEvalSample` 3..30, `discEvalThreshold` 1..100; `aiConfigs` apiKey ≥8 → шифруется; `myPrompts` ≤100. ⚠ `tgKeys` удалён из настроек тенанта — ключи Telegram задаёт оператор глобально. ⚠ Ответ — **весь** public settings (фронт затирает локальное состояние ответом) | произвольный dict из публичных ключей | §4.6 | | `POST /ai/check` | Проверка подключения AI-провайдера | — | `{ok: bool, message: str}` + поля статуса провайдера | | `GET /rates` | Курсы валют | — | `{base: "RUB", rates: {CODE: num}, source: "cbr"\|"mock", updatedAt: ms\|null}` | | `POST /rates/refresh` | Принудительно обновить курсы (ЦБ/мок) | — | `{ok: bool, rates: {base, rates, source, updatedAt}}` — ⚠ фронт передаёт `r.rates` в `applyRates` | | `GET /meta/constants` | Валюты/стадии/палитра | — | `{currencies: [{code,name,symbol}], stages: [§4.4], palette: ["#…"]}` *(фронт не вызывает — зашиты в data.js)* | ### 3.5 Детальные операции карточки (бывший Projects, projects_routes.py) — 15 Все операции — над ресурсом `/api/cards/{cardId}` (см. §4.1); отдельного `/api/projects` больше нет. | METHOD /api/cards… | Назначение | Request body | Response | |---|---|---|---| | `POST ""` | Создать локальную карточку | `{title="", summary="", containerId?="planned", stack?, budget?, contact="", tzText=""}` (алиас `stage`) | → карточка | | `POST /take` | «Взять в работу»: карточка (**не клон**) → контейнер `planned` пространства `selected` | `{cardId}` (алиас `leadId`) | → карточка; 404 «Карточка не найдена» | | `POST /clear-rejected` | Очистить стадию «Отклонено» | — | `{ok: true, cleared: int}` | | `PATCH /{cardId}` | Правка полей (null — «не менять») | `{title?, summary?, stack?, budget?{from,to,cur}, contact?, tzText?}` | → карточка | | `POST /{cardId}/move` | Перенос по контейнерам/стадиям (+история; сброс reminder при уходе с hold) | `{to}` | → карточка; 400 «Переносить можно только…» | | `POST /{cardId}/comments` | Комментарий | `{text}` | `{comments: [...]}`; 400 «Пустой комментарий» | | `POST /{cardId}/links` | Добавить ссылку (`url` без схемы → префикс https://) | `{name="", url}` | → карточка; 400 «Пустая ссылка» | | `DELETE /{cardId}/links/{linkId}` | Удалить ссылку | — | → карточка | | `POST /{cardId}/files` | Загрузить файлы (multipart, поле `files`) | FormData `files` | → карточка (с обновлённым `files`) | | `GET /{cardId}/files/{fileId}/download` | Скачать (stream из MinIO/локального store) | — | `application/octet-stream`, `Content-Disposition: attachment`; 410/404 | | `DELETE /{cardId}/files/{fileId}` | Открепить файл | — | → карточка | | `GET /{cardId}/source` | Содержимое источника: провайдер по `source.kind` либо сохранённое в карточке | — | `SourceContent`; 404 «Карточка не найдена» | | `POST /{cardId}/reminder` | Напоминание карточке | `{at: }` | → карточка; 400 «Поле at (epoch-ms) обязательно» | | `DELETE /{cardId}/reminder` | Снять напоминание | — | → карточка | | `POST /{cardId}/reminder/snooze` | Отложить на +24 ч | — | → карточка | ### 3.6 Processing — очередь и отсев (processing_routes.py) — 6 | METHOD /api/pipeline… | Назначение | Request | Response | |---|---|---|---| | `GET /stats` | Сводка для синхронизации | — | `{queue: {new, ai, total}, rejected: int}` | | `GET /queue?limit=` | Сырые сообщения очереди (`limit` ≤500, дефолт 100; фронт шлёт 120) | query `limit` | `{items: [§4.5 очередь], counts: {new, ai, total}, rejected: int}` | | `GET /rejected?q=&offset=&limit=` | Отсев (поиск по q, страницы; лимит ≤500) | query | `{items: [§4.5 отсев], total: int, offset: int, limit: int}` | | `POST /rejected/clear` | Очистить отсев | — | `{ok: true, cleared: int}` | | `DELETE /rejected/{rej_id}` | Удалить запись отсева | — | `{ok: true}` | | `POST /rejected/{rej_id}/return` | Вернуть в обработку (`{reason}` помечается на записи; снимает у ML вес спама; повтор/dup → 400) | `{reason=""}` | `{id, returned: true, returnedAt: ms}`; 404/400 | ### 3.7 ML (ml_routes.py) — 7 | METHOD /api/ml… | Назначение | Request body | Response | |---|---|---|---| | `GET /status` | Статус ML-сервиса (форс-refresh) + локальная статистика | — | §4.10 (ml status) | | `POST /reset` | Сброс модели + очистка outbox | — | `{ok: true}` или `{ok: false, error: str}` (⚠ ошибка — HTTP 200) | | `POST /predict` | Проверка ML на тексте | `{text}` | `{text: <первые 200>, take: bool, label: str\|null, scores: {class: num}, hits, ready, margin, terms, type}`; 400 «Введите текст» | | `POST /learn` | Ручная разметка в outbox | `{text, label}` | `{ok: true, outbox: int}` *(фронт не вызывает — использует apply)* | | `POST /flush` | Немедленная отправка обучения | — | `{ok, flushed, outbox, service}` *(фронт не вызывает)* | | `POST /candidates` | Последние сообщения канала + мнение ML | `{dialogId, limit?=10 (clamp 1..60)}` | `{items: [{id, dialogId, text(≤600), time, lead, pred: {take, label, scores}}]}` | | `POST /apply` | Ручное решение: `action` = `spam` \| `board:` \| `skip` | `{dialogId, msgId, action}` | `{ok, learned: bool, moved: "trash"\|""\|null, leadId: str\|null}`; `skip` → `{ok, learned: false, moved: null}`; 400/404 | ### 3.8 Discovery (discovery_routes.py) — 13 | METHOD /api/discovery… | Назначение | Request body | Response | |---|---|---|---| | `GET /tasks` | Список задач (старые первыми) | — | `{items: [→ задача]}` | | `POST /tasks` | Создать (бюджет plan_joins ≤ discJoinLimit) | `{name, description?, keywords?[], minSubscribers?, lang? "ru"\|"any", threshold?, sampleSize?, planJoins?, autoJoin?}` | → задача; 400 (нет имени / бюджет) | | `PATCH /tasks/{task_id}` | Обновить задачу | те же поля, все optional | → задача; 404/400 | | `DELETE /tasks/{task_id}` | Удалить (с кандидатами и логом) | — | `{ok: true}` | | `POST /tasks/{task_id}/start` | Запуск поиска (draft/paused/done/failed → running) | — | → задача; 400 «Нет ключевых слов…» | | `POST /tasks/{task_id}/pause` | Пауза | — | → задача | | `POST /tasks/{task_id}/generate-keywords` | ИИ-генерация ключей по description | — | `{keywords: [str≤30×60]}`, ошибка — `{keywords: [], error: str}` (HTTP 200, ⚠) | | `GET /tasks/{task_id}/candidates?status=` | Кандидаты задачи, фильтр `new\|review\|joined\|rejected` | query `status` | `{items: [→ кандидат]}`; 404 | | `POST /candidates/{dialog_id}/join` | Ручное вступление (+в мониторинг, +backfill, −чёрный список) | — | → кандидат; 400/404 | | `POST /candidates/{dialog_id}/reject` | Отклонить → чёрный список | — | → кандидат; 400 (уже вступили)/404 | | `GET /blacklist` | Чёрный список | — | `{items: [{dialogId, name, reason, createdAt}]}` | | `DELETE /blacklist/{dialog_id}` | Убрать из чёрного списка | — | `{ok: true}` | | `GET /tasks/{task_id}/log` | Лог задачи | — | `{items: [{id, taskId, event, text, createdAt}]}`, event ∈ `search\|skip\|review\|join_auto\|join_manual\|reject\|done\|flood\|error` | ### 3.9 Прочее (main.py / events_routes.py) | METHOD /api/… | Назначение | Response | |---|---|---| | `GET /events` | SSE-поток (см. §2), авторизация обязательна | `text/event-stream` | | `GET /health` | Healthcheck | `{ok: true, service: "deal"}` *(фронт не вызывает)* | --- ## 4. Сущности: поля JSON, которые реально читает фронт ### 4.1 Карточка (card) — `GET /api/cards`, `GET /api/cards/{cardId}`, ответы всех мутаций и payload SSE `new_card` Единая сущность всех дашбордов (этап 9). Модульные поля (контакты/ссылки/файлы/ТЗ/история/напоминание) присутствуют всегда, но могут быть пустыми. Точный контракт — `docs/architecture/2026-09-10-unified-api-contract.md`. ```jsonc { "id": "c_1a2b3c4d5e6f", // string, префикс c_ — единый "containerId": "inbox", // контейнер карточки "col": "inbox", // алиас containerId (совместимость) "isNew": true, // «новое» (точка на карточке) "local": false, // создана локально, без внешнего источника "title": "Разработка интернет-магазина", // string ≤140 "summary": "Компания: …\nЗадача: …",// блок «О заявке» "source": { "kind": "telegram", "externalId": "4242", "displayName": "Канал заказов", "originRef": "123456789", "author": "…", "receivedAt": "2026-09-11T10:00:00+00:00", "extra": {"hue": "#8b8ff8"} }, "content": { "text": "Ищу разработчика…", "html": null, "author": "…", "subject": null, "data": [], "links": [], "contacts": [] }, "stack": ["vue", "dotnet"], "budget": {"from": 100000, "to": 200000, "cur": "RUB"}, "converted": {"from": 100000, "to": 200000, "cur": "RUB"}, "contact": "@client", "contacts": [{"type": "tg", "value": "@client"}], "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": "projects/c_…/pf_…_1726000000000_brief.pdf"}], "history": [{"id": "h_…", "at": 1726000000000, "type": "created"}, {"id": "h_…", "at": 1726003600000, "stage": "planned"}], "tzText": "Сделать каталог и корзину", "reminder": {"at": 1727000000000}, "prevCol": "inbox", // предыдущий контейнер (возврат из archive/trash) "isVacancy": false, "isVacancyKnown": false, "time": "5 мин", // human-метка от receivedAt "receivedAt": 1726000000000, "createdAt": 1726000000000, "updatedAt": 1726000000000 } ``` Ключевые поля: `id/containerId/(col)` — принадлежность; `source` (`SourceRef`: вид, внешний id, подпись, ссылка на оригинал, цвет в `extra.hue`) и `content` (`SourceContent`: текст, разметка, ссылки, контакты, вложения `data`) — происхождение; `stack/budget/converted/contact/contacts/matchHits` — данные заявки; `comments/ links/files/history/tzText/reminder` — модули; `isNew/prevCol/isVacancy/isVacancyKnown` — маркеры. Ровно один из полей `history[].type`/`history[].stage` задан. Строки очереди и отсева «Обработки» отдают тот же generic `source`/`content` (раньше — `ch`); решение отсева — `decidedBy`/`decidedByLabel` (stop|ml|ai|stale|dup). **counts** (`GET /api/cards/counts`): `{new: int, "": {count: int, new: int}, learning: int, ml: int, ai: int}` — плоская форма (совместима с прежним `/api/leads/counts`). ### 4.2 Контейнер (container) — `GET /api/containers`, `POST/PATCH` тела Единый реестр колонок/стадий/зон (этап 9): пользовательские колонки-фильтры (`kind: board`), стадии «Выбранных» (`stage`), служебные зоны (`service`: inbox/archive/trash), терминальные (`terminal`). ```jsonc { "id": "b_1a2b3c4d5e6f", // b_... | planned…rejected | inbox/archive/trash "name": "WPF", "description": "Заказы по WPF", "color": "#818cf8", "order": 0, "space": "dashboard", // dashboard | selected "kind": "board", // board | stage | service | terminal "collapsed": false, // свёрнута на дашборде "suggested": false, // ИИ-предложение ждёт решения "note": "", // заметка/обоснование ИИ "rules": { // правила попадания (null — фильтра нет) "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} // счётчики карточек контейнера } ``` `counts`/`policy` — только в ответе `GET`; `rules` — набор опциональных фильтров колонки (как раньше у доски). ### 4.3 Модульные поля карточки (бывшая «проектная карточка») Отдельной сущности/таблицы больше нет: модули (`comments`, `links`, `files`, `history`, `tzText`, `reminder`, `budget`) — поля той же карточки §4.1. Формы элементов: ```jsonc { "comments": [{"id":"cm_…","by":"Вы","text":"…","time":"только что"}], "links": [{"id":"pl_…","name":"сайт","url":"https://…"}], "files": [{"id":"pf_…","name":"tz.pdf","size":12345,"kind":"document","label":"Документ","objectKey":"…"}], "history": [{"id":"h_…","at":1757000000000,"type":"created"}, // type: "created"|"createdLocal" ИЛИ {"id":"h_…","at":…,"stage":"work"}], // stage — при переносе "tzText": "", // техническое задание "reminder": {"at": 1757000000000}, // object|null "createdAt": …, "updatedAt": … // int ms } ``` Загрузка файлов: multipart — ответ — обновлённая **карточка** (фронт берёт `files` из ответа). Скачивание: `GET /api/cards/{cardId}/files/{fileId}/download`. ### 4.4 Контейнеры по умолчанию (стадии «Выбранных» и зоны) Стадии «Выбранных» (`space: selected`, `kind: stage/terminal`): `planned` Запланировано / `reply` Отклик / `agree` Согласование / `work` В работе / `review` Проверка / `ready` Готово / `hold` Отложено (не terminal) / `finished` Выполнено (terminal) / `rejected` Отклонено (terminal). Служебные зоны дашборда: `inbox`, `archive`, `trash` (`space: dashboard`, `kind: service`). ### 4.5 Очередь и отсев (вкладка «Обработка») Очередь (`GET /pipeline/queue` item): `{id, source: SourceRef, content: SourceContent, text, status: "new"|"filtered", msgAt: ms, queuedAt: ms}` — UI показывает `text`, статус-бейдж и подпись источника (`source.displayName`, цвет `source.extra.hue`). Отсев (`GET /pipeline/rejected` item): `{id, source: SourceRef, content: SourceContent, text, stage, stageLabel, reason, kw, decidedBy, decidedByLabel, msgAt, rejectedAt, returned: bool, returnedAt: ms|null, returnReason: string}`. - `stage` ∈ `length|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup`; `stageLabel` — подпись («короткое сообщение», «стоп-фраза», «спам (ML)», …). - `decidedBy` ∈ `stop|ml|ai|stale|dup`; `decidedByLabel` ∈ «правила|ML|ИИ|система». - Фронт читает: `id, stageLabel, kw, reason, decidedBy, decidedByLabel, text, source, rejectedAt, returned, returnedAt, returnReason`. «Возврат» неактивен при `decidedBy==='dup'` или `returned`. ### 4.6 Настройки (settings) — все ключи ответа `GET/PATCH /api/settings` (camelCase; значения по умолчанию из `constants.DEFAULT_SETTINGS`) ```jsonc { "autoArchive": true, "archiveAfterDays": 14, "archiveClearDays": 90, "trashClearDays": 7, "minLen": 24, "stopPhrases": ["взаимный пиар", "…"], "mlEnabled": true, "aiEnabled": true, "aiFilterEnabled": true, "aiPrompt": "Ты — классификатор…{domain}…{keywords}…", "aiFilterPrompt": "…", "cardPrompt": "…", "wantedType": "both", // "both"|"vacancy"|"freelance" "budgetRequiredHire": false, "budgetRequiredOrder": false, "hireLabel": "вакансия", "orderLabel": "фриланс", "domainDescription": "", "domainKeywords": [], "hireMarkers": [], "levelTerms": [], "resumeMarkers": [], "blockResumes": true, "myPrompts": [{"id":"pp_…","name":"…","description":"…","prompt":"…"}], "remindersEnabled": true, "conversionOn": true, "targetCurrency": "RUB", "rateSource": "cbr", // cbr|mock "autoMonitorNew": true, "discJoinLimit": 50, "discJoinDelayMin": 50, "discJoinDelayMax": 70, "discEvalSample": 10, "discEvalThreshold": 40, // (не используется фронтом) "discPaused": false, "colState": {}, // colState — то же, что GET /columns/state "aiProvider": "deepseek", "aiConfigs": { "deepseek": {"baseUrl": "https://api.deepseek.com", "model": "…", "keySet": true, "keyMasked": "sk-12…3456"} }, "providers": [{"id":"deepseek","name":"DeepSeek","base":"…","local":false,"models":[…]}, …] } ``` Ключи, которые фронт шлёт в PATCH (по одному/группами): `aiProvider`, `aiConfigs{:{baseUrl,model,apiKey?}}`, `aiPrompt`, `cardPrompt`, `aiFilterPrompt`, `stopPhrases`, `domainDescription`, `domainKeywords`, `hireMarkers`, `levelTerms`, `resumeMarkers`, `blockResumes`, `myPrompts`, `autoArchive`, `archiveAfterDays`, `aiEnabled`, `aiFilterEnabled`, `minLen`, `conversionOn`, `targetCurrency`, `rateSource`, `remindersEnabled`, `mlEnabled`, `wantedType`, `budgetRequiredHire`, `budgetRequiredOrder`, `hireLabel`, `orderLabel`, `autoMonitorNew`, `discJoinLimit`, `discJoinDelayMin`, `discJoinDelayMax`, `discPaused`. ⚠ Ответ PATCH — **полный** settings: `schedulePersist`/`saveAiSettings`/`saveDiscQuota` применяют его целиком к локальному state (источник истины после клампов). ⚠ **Изменение (решение владельца, вариант A):** ключей Telegram (`api_id`/`api_hash`) в настройках тенанта больше нет — они задаются оператором глобально (ТЗ §4.1/§8.1), см. `docs/architecture/2026-09-10-operator-analytics-contract.md` (раздел «Операторские настройки»). Вкладка Telegram у тенанта остаётся (подключение аккаунта, `GET /api/tg/status`). ### 4.7 Промпты - `aiPrompt`, `aiFilterPrompt`, `cardPrompt` — plain string, редактируются на вкладке ИИ; содержат плейсхолдеры `{domain}`/`{keywords}`. - `myPrompts` — личная библиотека: `[{id, name(≤80), description(≤300), prompt}]`, ≤100; id генерирует и фронт (`pp_…`), и бэк при отсутствии. ### 4.8 Каналы/discovery **Диалог** (`GET /api/tg/dialogs` item): `{id: string, name, handle, type, hue, on: bool, last: {text, time}}` — фронт читает `id/name/handle/type/hue/on` (`last` не читает). ⚠ `type` нестабилен по значению: «канал»/«группа»/«чат» (из `refresh_dialogs`) либо `channel`/`group`/`forum` (после discovery-вступлений `add_dialog_monitored`). **Сообщение превью** (`POST /dialogs/preview` item): `{id, text, time: ms, lead: bool}`. ⚠ `id`: из Telegram — int; фолбэк из БД — string `m__`. **Discovery-задача** (`GET/POST/PATCH …/tasks`, ответы start/pause): `{id:"dt_…", name, description, keywords[], minSubscribers: int, lang: "ru"|"any", threshold: int(1..100), sampleSize: int, planJoins: int, autoJoin: bool, status: "draft"|"running"|"paused"|"done"|"failed", searchIdx: int, searchDone: bool, found: int, evaluated: int, joined: int, rejected: int, createdAt: ms, updatedAt: ms}`. **Кандидат** (`GET …/candidates` item, ответы join/reject): `{dialogId, taskId, name, username, kind: "channel"|"group"|"forum", hue, participants: int|null, langRu: bool|null, marks: string[], topics: [{topicId, title, fitCount, total, fitRatio, passed}] (форумы), fitRatio: 0..1|null, status: "new"|"review"|"joined"|"rejected", autoJoined: bool, joinFailures: int, createdAt: ms, updatedAt: ms}`. ### 4.9 Telegram-статус (`GET /api/tg/status`, payload `system_status`) `{phase: "idle"|"phone"|"code"|"password"|"qr"|"ready", connected: bool, listener: bool, account: string, monitored: int, keysSet: bool, error: string|null, qrUrl: string|null}`. Фронт: `connected→tgConnected`, `account`, `phase`→`tgState` (ready→done), `qrUrl` при phase='qr', `keysSet`. ### 4.10 Прочее - **`GET /api/ml/status`**: `{enabled: bool, service: {ready, classes: {label: n}, learned: int, eval: {count, correct, accuracy}}, reachable: bool, stats: {ml, ai, learning, ready, classes, learned, reachable, outbox}}`. Фронт читает: `reachable`, `service.ready/classes/learned/eval.{count,correct,accuracy}`, `stats.outbox`. - **`POST /api/admin/check-message`** (сухой прогон конвейера): `{text}` → `{passed, wouldCreateCard, targetContainer, matchHits, parsed, stages:[{stage, pass, skipped, reason, kw, label}]}`. Коды `stage`: `length|stop|resume|type|exclude|ml|ai|spam_ai|budget`; `skipped=true` — этап выключен настройкой. `parsed` — разбор текста (поля карточки) либо null. Запись в систему не производится. - **`POST /api/ai/check`**: `{ok: bool, message: string, local?, keySet?}`. - Комментарии карточки: `{id, by: string, text, time: string}` — `by` всегда «Вы», `time` «только что». --- ## 5. Сводка **Карточки и контейнеры (единый контракт этапа 9):** `GET/POST /api/cards`, `GET/DELETE /api/cards/{cardId}`, `/move`, `/trash`, `/restore`, `/comments`, `/links`, `/files`, `/reminder`, `/take`, `/clear-col`, `/clear-rejected`, `/mark-all-seen`, `/mark-col-seen`, `/reclassify`, `GET /api/search`; `GET/POST /api/containers`, `PATCH/DELETE /api/containers/{id}`, `/accept`, `/reorder`, `/state` — описаны в §3.2 и §3.5. **Прочие домены (этап 9 их не менял):** | Модуль (роутер) | Эндпоинты | |---|---:| | Auth `/api/auth` | 4 | | Telegram `/api/tg` | 14 | | Settings/rates/meta (`/api/settings`, `/api/ai/check`, `/api/rates`) | 5 | | Processing `/api/pipeline` (+ `/api/admin/check-message`) | 7 | | ML `/api/ml` | 5 | | Discovery `/api/discovery` | 13 | | Operator `/api/operator` + `/api/join` | 25 | | Events `/api/events` | 1 | | Health `/api/health` | 1 | **SSE-события:** `new_card`, `toast`, `reminder_due`, `system_status` — 4 именованных типа (см. §2). **Коды ошибок:** всегда `{"detail": "<текст>"}` — `400` (неверный ввод/правила), `401` (нет сессии), `403` (вход приостановленного тенанта), `404` (объект не найден), `410` (файл не сохранён), `422` (тело не разобрано). Исключения — «мягкие» ошибки в HTTP 200 с полями `error`/`reason` (см. п.1 ниже). **Замечания (актуальные):** 1. Ответы PATCH `/api/settings`, `POST /api/ml/reset` и discovery `generate-keywords` «ошибочные» ветки: мягкие ошибки в HTTP 200 с полями `error`/`reason` вместо `{"detail"}` (см. §6 п.7). 2. Тип диалога (`tg/dialogs.type`/`kind`) хранится вперемешку («канал»/«группа»/«чат» после refresh против `channel`/`group`/`forum` после discovery-вступления) — UI показывает как есть. 3. Превью-сообщения: `id` — int (из Telegram) либо string `m__` (фолбэк из БД) — ключи рендера неустойчивы. 4. Контейнер: `POST`/`PATCH` отвечают `{id}` (не полный объект); после мутаций фронт перечитывает `GET /api/containers`. --- ## 6. Реализовано в Deal — расхождения с картой и SaaS-дополнения (этапы 7, 10) Карта выше — контракт фронта Дейла (после этапа 9 — единый: карточки/контейнеры). Расхождения, влияющие на HTTP-семантику, и SaaS-ручки вне карты — ниже (контракт фронта они НЕ ломают). **Расхождения/решения этапа 7 (зафиксированы в коде; task-7-report.md):** 1. Вход приостановленного тенанта — **HTTP 403** `{detail: "Учётная запись приостановлена. Обратитесь к оператору"}`, а не 401: учётка существует, доступ запрещён; 401 остаётся только для неверных учётных данных (статус не раскрывается). В аудит пишется `tenant_login_failed` с tenantId. 2. Смена статуса тенанта — **не PATCH {status}**, а явные `POST /api/operator/tenants/{id}/suspend` и `POST …/unsuspend` (аудит `tenant_status_changed`, идемпотентно). Отклонение приёмочного текста плана «PATCH … status» — осознанное. 3. `POST /api/operator/tenants` (create) принимает `{name, email?}` **без `budget?`**: бюджет задаётся отдельно (`GET/PATCH …/tenants/{id}/limit`); у нового тенанта — ленивый дефолт-бюджет (константа `TokenBudgetDefaults`/env `DEAL_DEFAULT_AI_BUDGET`). Поле-заглушка «принять и не применить» не вводилась. 4. «Отсутствующие» эндпоинты карты не реализованы сознательно (экономия; список — §5): `/cards/{cardId}/seen` (снятие «новое» с одной карточки), `/meta/constants`, `admin/wipe|clear-cards|pump-gate`, `ml/learn|flush` (внутренние RPC/флашер MlOutbox), `/tg/dialogs/{id}/backfill` (сервер-only: backfill включается мониторингом/«Перечитать всё»). Демо-ручки `POST /api/demo/*` (флаг `DEAL_DEMO`) удалены. `POST /api/cards/reclassify` — **реальный проход** (этап 12): переклассификация «Неразобранного» через тот же конвейер, что и пайплайн (ИИ-фильтр → классификация → правила колонок) с локальным фолбэком при выключенном/недоступном ИИ; single-flight (`{started:false, busy:true}` при занятом проходе), есть и одиночная ручка `POST /api/cards/{cardId}/reclassify`. **Устойчивость и очистки (этап 12, пакет B).** Rate limiting и `LoginAttemptGuard` — store-backed на Postgres (таблица `public.rate_limit_counters`), т.е. работают при нескольких инстансах core; активные сессии приостановленного тенанта разлогиниваются сразу (проверка статуса в `AuthService.ResolveSessionAsync`, включая impersonation). Фоновый `DataRetentionScheduler` (раз в сутки) чистит `audit_log` по retention (дефолт 180 дней), сбрасывает накопительные поля `tenant_limits` прошедших периодов и удаляет завершившиеся окна счётчиков. **SaaS-ручки (этапы 7, 10).** С этапа 10 у операторских ручек есть **UI**: экран оператор-консоли `#/operator` (разделы «Тенанты», «Приглашения», «Лимиты ИИ», «Аудит», «Аналитика», «Состояние системы») и публичная страница активации инвайта `#/join?code=…`; основное приложение — `#/`. Операторская кука — `deal_operator_session` (12 ч, httpOnly, SameSite=Lax; отдельная от `deal_session`); `/api/join` — публичная (без куки). 401 на всех `/operator/*` без операторской сессии — «Требуется вход оператора». Тенантные `/api`-ручки операторских сессий не видят и наоборот (разные middleware). Подробнее — техдок §13.8 (контур) и §13.10 (консоль/аналитика). | METHOD /api/… | Назначение | Ответ | |---|---|---| | `POST /operator/auth/login` `{login,password}` | вход оператора (env `DEAL_OPERATOR_*`; dev-дефолт `operator`/`operator`) | `{ok, login}` + кука; 401; 429 (rate limit) | | `POST /operator/auth/logout`; `GET /operator/auth/me` | выход / проверка сессии | `{ok}`; `{login, ok}`; 401 | | `POST /join` `{code, email, name?, password}` | публичная активация инвайта (страница `#/join?code=…`): пользователь (Argon2id) и, при необходимости, тенант с провижинингом | `{ok: true, login}`; 400 `{detail}` | | `GET /operator/tenants` | список тенантов + счётчики пользователей | `{items:[{id,name,status,createdAt,usersCount}]}` | | `POST /operator/tenants` `{name, email?}` | создать тенанта (email → владелец с одноразовым паролем) | `{id,name,status,createdAt}` (+`ownerEmail`,`initialPassword`); 400/401 | | `GET /operator/tenants/{id}` | детали + пользователи | тенант; 404 | | `POST /operator/tenants/{id}/suspend`; `…/unsuspend` | приостановка/возобновление (см. п.2) | `{ok, status}`; 404 | | `POST /operator/tenants/{id}/impersonate` `{login?}` | вход от имени пользователя тенанта; **ставит httpOnly-куку `deal_session` ответом** — оператор сразу в тенанте | `{sessionToken, expiresAt, tenantId, login}`; 404/400 | | `GET /operator/invites`; `POST /operator/invites` `{email, tenantId?, name?}` | список / создание инвайта (код 16 симв., 72 ч) | `{items:[…]}`; `{code,email,tenantId,expiresAt,status}` | | `POST /operator/invites/{code}/revoke` | отзыв инвайта | `{ok:true}` | | `GET /operator/limits` | сводка ИИ-бюджетов по тенантам | `{items:[{tenantId,name,budget,period,used,percent,status}]}` | | `GET/PATCH /operator/tenants/{id}/limit` | детали/смена бюджета `{budget?, period?}` (сброс флагов порогов, аудит) | лимит; 400/404 | | `GET /operator/audit?eventType=&actorType=&actorId=&tenantId=&from=&to=&limit=&offset=` | лента аудита (append-only, At DESC, limit ≤500, пагинация) | `{items, total}` | | `GET /operator/analytics/overview?from=&to=` | сводка за период: тенанты, токены, события, входы/выходы/неудачные входы | `{tenantsTotal,tenantsActive,promptTokens,completionTokens,totalTokens,tokenEvents,events,logins,logouts,failedLogins,from,to}` | | `GET /operator/analytics/tokens?groupBy=&tenantId=&from=&to=` | агрегаты расхода токенов (`groupBy=day\|tenant\|provider\|model`) | `{groupBy,from,to,items:[{key,…}],total}`; 400 (неизвестная группировка) | | `GET /operator/analytics/activity?eventType=&actorType=&actorId=&tenantId=&from=&to=&limit=&offset=` | лента действий (аудит) с фильтрами и пагинацией | `{items,total,limit,offset}` | | `GET /operator/health` | health core/БД + сервисы ml/ai/telegram (UseLocal → `mode:local`); этап 12: глубины очередей и активные сессии | `{ok, core:{db}, services:[…], queues:{pipeline,mlOutbox}, sessions:{active}}` (всегда 200) | | `POST /operator/maintenance/tenants/migrate` | Пакетная миграция схем всех тенантов (идемпотентно, шардированный обход страницами + ограниченный параллелизм; этап 12, пакет C / BL-SCALE-1000) | `{ok,total,migrated,failed,failedSchemas,durationMs}` (`ok=false`, если хотя бы одна схема не мигрирована); 401 без операторской сессии | | `GET /operator/analytics/suspicious?from=&to=` | подозрительная активность по аудиту (всплеск неудачных входов по IP/логину, входы актора с множества IP, серии по тенанту; этап 12) | `{scanned,truncated,items:[…]}` | | `GET /operator/settings/telegram-keys` | глобальные ключи Telegram (задаёт оператор; тенант их не видит) | `{apiId, apiHash (маска), keysSet}` | | `PUT /operator/settings/telegram-keys` `{apiId?, apiHash?}` | задать/обновить ключи (частично: можно одно поле, второе сохраняется); `api_id` 5–9 цифр, `api_hash` непустой; hash шифруется | маска-форма; 400 `{detail}`; 401 |