Ревью rust: перенос в вики не должен удалять из репозитория то, что нужно агенту для работы (бэклог, статус, планы, код-стайл, спеки). Дублирование вики и репы разрешено и обязательно: вики — актуальные версии для людей, docs/ — зеркало для контекста агентов. README разведён по ролям.
53 KiB
Дейл (Deal) — карта API (Python/FastAPI → .NET)
Этап 9 «единая карточка»: карточки и колонки/стадии сведены в два домена —
/api/cardsи/api/containers; ручки/api/leads,/api/projects,/api/boards,/api/columnsудалены, SSEnew_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 (объект <containerId> → 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_<dialog>_<msg> — сообщение. Контейнеры-стадии/служебные зоны — без префикса (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: <name>\ndata: <json>\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": "<c_…>", "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: "<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: "<b_…>"}; 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: <int>} |
POST /containers/reorder |
Порядок контейнеров пространства | {space, order: ["<b_…>", …]} |
{ok: true}; 400 «Не указан порядок колонок» |
GET /containers/state |
Состояние колонок (свёрнутость/ширина, colState) |
— | { "<id>": {"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: "<containerId>"} |
→ карточка; 400 «Переносить можно только…» |
POST /cards/{cardId}/trash |
В корзину; учит ML spam |
— | {ok: true}; 404 |
POST /cards/{cardId}/restore |
Возврат из архива/корзины | — | {ok: true, col: "<inbox|b_…>"} |
DELETE /cards/{cardId} |
Удалить навсегда | — | {ok: true} |
POST /cards/clear-col |
Очистить корзину/архив целиком | {col: "trash"|"archive"} |
{ok: true, cleared: <int>}; 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: <dict pump_once>, 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 не активен…». Фронт: <img src="/api/tg/qr-image?t=N"> |
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: <epoch ms>} |
→ карточка; 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:<id> | skip |
{dialogId, msgId, action} |
{ok, learned: bool, moved: "trash"|"<board>"|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.
{
"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, "<containerId>": {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).
{
"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. Формы элементов:
{
"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)
{
"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{<id>:{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_<dialog>_<msg>.
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 ниже).
Замечания (актуальные):
- Ответы PATCH
/api/settings,POST /api/ml/resetи discoverygenerate-keywords«ошибочные» ветки: мягкие ошибки в HTTP 200 с полямиerror/reasonвместо{"detail"}(см. §6 п.7). - Тип диалога (
tg/dialogs.type/kind) хранится вперемешку («канал»/«группа»/«чат» после refresh противchannel/group/forumпосле discovery-вступления) — UI показывает как есть. - Превью-сообщения:
id— int (из Telegram) либо stringm_<dialog>_<msg>(фолбэк из БД) — ключи рендера неустойчивы. - Контейнер:
POST/PATCHотвечают{id}(не полный объект); после мутаций фронт перечитываетGET /api/containers.
6. Реализовано в Deal — расхождения с картой и SaaS-дополнения (этапы 7, 10)
Карта выше — контракт фронта Дейла (после этапа 9 — единый: карточки/контейнеры). Расхождения, влияющие на HTTP-семантику, и SaaS-ручки вне карты — ниже (контракт фронта они НЕ ломают).
Расхождения/решения этапа 7 (зафиксированы в коде; task-7-report.md):
- Вход приостановленного тенанта — HTTP 403
{detail: "Учётная запись приостановлена. Обратитесь к оператору"}, а не 401: учётка существует, доступ запрещён; 401 остаётся только для неверных учётных данных (статус не раскрывается). В аудит пишетсяtenant_login_failedс tenantId. - Смена статуса тенанта — не PATCH {status}, а явные
POST /api/operator/tenants/{id}/suspendиPOST …/unsuspend(аудитtenant_status_changed, идемпотентно). Отклонение приёмочного текста плана «PATCH … status» — осознанное. POST /api/operator/tenants(create) принимает{name, email?}безbudget?: бюджет задаётся отдельно (GET/PATCH …/tenants/{id}/limit); у нового тенанта — ленивый дефолт-бюджет (константаTokenBudgetDefaults/envDEAL_DEFAULT_AI_BUDGET). Поле-заглушка «принять и не применить» не вводилась.- «Отсутствующие» эндпоинты карты не реализованы сознательно (экономия; список — §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 |