API карта
stepan edited this page 2026-09-15 21:59:43 +03:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

Перенесено из репозитория (docs/api/api-map.md). Актуальная версия — здесь, в вики.

Дейл (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}/filesmultipart/form-data, поле files (несколько файлов); GET /api/tg/qr-imageimage/svg+xml; GET /api/cards/{id}/files/{fileId}/downloadapplication/octet-stream (attachment); GET /api/eventstext/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> — сообщение. Контейнеры-стадии/служебные зоны — без префикса (plannedrejected, 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) — 5

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; myPrompts ≤100. ⚠ tgKeys, aiProvider и aiConfigs удалены из настроек тенанта — ключи Telegram и конфигурацию ИИ задаёт оператор глобально. ⚠ Ответ — весь public settings (фронт затирает локальное состояние ответом) произвольный dict из публичных ключей §4.6
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)

POST /ai/check удалён: проверка связи с провайдером ИИ теперь операторская — POST /api/operator/settings/ai-config/check (раздел «ИИ» консоли оператора).

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}.

  • stagelength|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup; stageLabel — подпись («короткое сообщение», «стоп-фраза», «спам (ML)», …).
  • decidedBystop|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
}

Ключи, которые фронт шлёт в PATCH (по одному/группами): 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/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). ⚠ Изменение (2026-09-14): настройки ИИ-провайдера (aiProvider, aiConfigs, providers) из настроек тенанта убраны — провайдера, модель, адрес и ключ задаёт оператор в консоли (раздел «ИИ», GET/PUT /api/operator/settings/ai-config); все ИИ-вызовы всех пользователей идут на эту конфигурацию.

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, phasetgState (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. Запись в систему не производится.
  • Комментарии карточки: {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/rates) 4
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_<dialog>_<msg> (фолбэк из БД) — ключи рендера неустойчивы.
  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 59 цифр, api_hash непустой; hash шифруется маска-форма; 400 {detail}; 401