Files
Deal/docs/api/api-map.md
T
Rustam Khalimov 27c7831910
ci / build-test (push) Canceled after 0s
Deal — единая кодовая база
SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/
Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue,
контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер),
Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог).

Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
2026-09-11 23:56:47 +03:00

53 KiB
Raw Blame History

Дейл (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) — 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}.

  • 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
  "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, 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. Запись в систему не производится.
  • 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_<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