Files
Deal/docs/architecture/2026-09-10-unified-api-contract.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

21 KiB
Raw Blame History

Дейл — единый API-контракт этапа 9 (cards + containers)

Дата: 2026-09-10 Статус: контракт для портирования фронта (T6). Источник истины для src/frontend. Связанные документы: docs/architecture/2026-09-09-unified-card.md, docs/superpowers/plans/2026-09-09-deal-stage9-unified-card.md (T4T6, R5).

Общие правила

  • Только два домена API: /api/cards (карточки) и /api/containers (колонки/стадии/зоны). Старые ручки /api/leads, /api/projects, /api/boards удалены.
  • Все ответы и тела запросов — JSON camelCase.
  • Время на wire — epoch-ms (int64, UTC). Внутри — DateTimeOffset (UTC).
  • Ошибки — объект { "detail": "текст" }. Коды: 400 (некорректный ввод), 401 (нет сессии), 404 (объект не найден), 422 (тело не разобрано).
  • Аутентификация — сессионная кука (как раньше). Без сессии — 401 {detail}.
  • Контейнер — единый реестр колонок/стадий/зон. Карточка ссылается на контейнер полем containerId (алиас прежнего col). Пространства: dashboard (дашборд) и selected («Выбранные»). Карточка живёт в одном пространстве: её containerId однозначно определяет, где она показана.
  • Виды контейнеров (kind): board (пользовательская колонка-фильтр), stage (стадия «Выбранных»), service (inbox/archive/trash), terminal (finished/rejected).

SSE (GET /api/events)

Поток text/event-stream, канал тенанта сессии. Типы событий:

event data Когда
new_card объект Card (см. ниже) создана карточка (пайплайн, демо, тик)
reminder_due { "id", "title", "containerId" } наступило напоминание
toast { "text", "icon" } статистика тика / служебное уведомление
cards_reclassified промежуточный — { "progress": true, "done", "total", "moved", "kept", "trashed", "skipped" }; финал — { "progress": false, "reclassified", "moved" } прогресс/завершение переклассификации «Неразобранного»

new_lead больше не публикуется (переименован в new_card).


Card (карточка)

Единая сущность во всех дашбордах. Модульные поля (контакты/ссылки/файлы/ТЗ/история/напоминание) присутствуют всегда, но могут быть пустыми.

{
  "id": "c_1a2b3c4d5e6f",
  "containerId": "inbox",
  "col": "inbox",
  "isNew": true,
  "local": false,
  "title": "Разработка интернет-магазина",
  "summary": "Компания: ...\nЗадача: ...",
  "source": {
    "kind": "telegram",
    "displayName": "Канал заказов",
    "originRef": "123456789",
    "receivedAt": 1726000000000
  },
  "sourceMsg": "Ищу разработчика...",
  "sourceDialogId": "123456789",
  "sourceMsgId": 4242,
  "stack": ["vue", "dotnet"],
  "budget": { "from": 100000, "to": 200000, "cur": "RUB" },
  "converted": { "from": 100000, "to": 200000, "cur": "RUB" },
  "contact": "@client",
  "contacts": [{ "type": "tg", "value": "@client" }],
  "channel": { "name": "Канал заказов", "handle": "@orders", "hue": "#8b8ff8" },
  "matchHits": [{ "label": "Стек", "term": "vue", "word": null }],
  "comments": [{ "id": "cm_...", "by": "Вы", "text": "Позвонил", "time": "5 мин" }],
  "links": [{ "id": "pl_...", "name": "Бриф", "url": "https://example.com" }],
  "files": [
    { "id": "pf_...", "name": "brief.pdf", "size": 10240, "kind": "document",
      "label": "Документ", "objectKey": "cards/c_.../pf_..." }
  ],
  "history": [
    { "id": "h_...", "at": 1726000000000, "type": "created", "stage": null },
    { "id": "h_...", "at": 1726003600000, "type": null, "stage": "planned" }
  ],
  "tzText": "Сделать каталог и корзину",
  "reminder": { "at": 1727000000000 },
  "prevCol": "inbox",
  "isVacancy": false,
  "isVacancyKnown": false,
  "time": "5 мин",
  "receivedAt": 1726000000000,
  "createdAt": 1726000000000,
  "updatedAt": 1726000000000
}

Поля:

Поле Тип Описание
id string короткий id карточки, префикс c_
containerId string контейнер карточки (inbox/archive/trash/стадия/b_...)
col string алиас containerId (совместимость со старым фронтом)
isNew bool точка «новое» (снимается просмотром/переносом)
local bool карточка создана локально (без внешнего источника)
title / summary string заголовок / блок «О заявке»
source object происхождение: kind (local/telegram/web/file/row/api/ai/composite/other), displayName, originRef, receivedAt
sourceMsg / sourceDialogId / sourceMsgId string / string / int64? исходное сообщение (текст, диалог, id)
stack string[] стек/направления
budget object? {from,to,cur} по исходному сообщению
converted object? {from,to,cur} бюджет в целевой валюте
contact string «быстрый» контакт
contacts object[] {type,value}
channel object {name,handle,hue} (прежний ch)
matchHits object[] {label,term,word?} — почему карточка в контейнере
comments object[] {id,by,text,time}
links object[] {id,name,url}
files object[] {id,name,size,kind,label,objectKey}
history object[] {id,at,type|stage} — ровно один из type/stage
tzText string техническое задание
reminder object? {at} (epoch-ms)
prevCol string предыдущий контейнер (возврат из archive/trash)
isVacancy / isVacancyKnown bool маркер/подтверждение «найм»
time string human-метка от receivedAt
receivedAt / createdAt / updatedAt int64 epoch-ms

GET /api/cards?containerId=

Список карточек. containerId — фильтр по контейнеру; алиас col принят для совместимости; без параметра — все карточки дашборда (кроме стадий «Выбранных»).

{ "items": [ /* Card... */ ] }

400 {detail:"Неизвестный контейнер"} — если контейнер не существует.

GET /api/cards/counts

Плоские счётчики (совместимо с прежним /api/leads/counts).

{ "new": 3, "inbox": { "count": 5, "new": 2 }, "learning": 12, "ml": 0, "ai": 0 }

GET /api/cards/{cardId}

Карточка. 404 {detail:"Карточка не найдена"}.

POST /api/cards

Создание локальной карточки. Тело:

{ "title": "Новый заказ", "summary": "", "containerId": "planned",
  "stack": [], "budget": null, "contact": "", "tzText": "" }

Алиас containerIdstage. Ответ — созданная Card.

PATCH /api/cards/{cardId}

Частичная правка. Null-поле = «не менять». Тело:

{ "title": "...", "summary": "...", "contact": "...", "tzText": "...",
  "stack": ["..."], "budget": { "from": 1, "to": 2, "cur": "RUB" } }

Ответ — обновлённая Card.

POST /api/cards/{cardId}/move { "to": "<containerId>" }

Перенос карточки. Ответ — обновлённая Card. 400 {"Переносить можно только в существующий контейнер или в «Неразобранное»"} (несуществующий контейнер/служебный источник), 404.

POST /api/cards/{cardId}/trash{ "ok": true }

POST /api/cards/{cardId}/restore{ "ok": true, "col": "inbox" }

DELETE /api/cards/{cardId}{ "ok": true }

POST /api/cards/clear-col { "col": "trash"|"archive" }{ "ok": true, "cleared": 4 }

POST /api/cards/clear-rejected{ "ok": true, "cleared": 0 }

POST /api/cards/mark-all-seen{ "ok": true }

POST /api/cards/mark-col-seen { "col": "<containerId>" }{ "ok": true }

POST /api/cards/take { "cardId": "<cardId>" }

«Взять в работу»: карточка (не клон) переносится в контейнер planned пространства selected. Ответ — обновлённая Card. Алиас поля — leadId. 404 — карточки нет.

Комментарии

POST /api/cards/{cardId}/comments { "text": "..." }{ "comments": [ /* ... */ ] } 400 {detail:"Пустой комментарий"}, 404.

Ссылки

  • POST /api/cards/{cardId}/links { "url": "...", "name": "..." } → обновлённая Card
  • DELETE /api/cards/{cardId}/links/{linkId} → обновлённая Card

Файлы

  • POST /api/cards/{cardId}/filesmultipart/form-data, поле files (одно или несколько) → обновлённая Card
  • GET /api/cards/{cardId}/files/{fileId}/download → бинарный поток
  • DELETE /api/cards/{cardId}/files/{fileId} → обновлённая Card

Напоминания

  • POST /api/cards/{cardId}/reminder { "at": 1727000000000 } → обновлённая Card 400 {detail:"Поле at (epoch-ms) обязательно"}
  • DELETE /api/cards/{cardId}/reminder → обновлённая Card
  • POST /api/cards/{cardId}/reminder/snooze → обновлённая Card

POST /api/cards/{cardId}/reclassify и POST /api/cards/reclassify

Переклассификация карточки/«Неразобранного»: повторный прогон через тот же конвейер, что и пайплайн (ИИ-фильтр → классификация → сборка контента → правила колонок; без создания новой карточки).

  • Single: {cardId} — любая карточка с исходным текстом; 404 {detail:"Карточка не найдена"}.
  • Batch: тело { "ids": ["c_..."] } опционально; без ids — все карточки inbox.
  • При включённом ИИ используется порт IAiClassifier; при выключенном (aiEnabled=false) или недоступности сервиса — детерминированный локальный разбор (без кредов сервис не падает). usedAi показывает путь.
  • Одна переклассификация за раз (single-flight): при занятом проходе { "started": false, "busy": true }.
  • Аудит — событие card_reclassified (только при reclassified > 0).
  • Во время пакетного прохода публикуются промежуточные SSE cards_reclassified с { "progress": true, "done", "total", "moved", "kept", "trashed", "skipped" } (каждые 5 карточек и на последней); по завершении — финальное { "progress": false, "reclassified", "moved" }; фронт показывает done/total и перечитывает доску только по финальному событию.
{
  "started": true,
  "busy": false,
  "attempted": 3,
  "reclassified": 3,
  "moved": 1,
  "kept": 1,
  "trashed": 1,
  "skipped": 0,
  "usedAi": false,
  "reason": null
}
Поле Тип Описание
started bool Проход выполнен (target непуст); false — пусто/занято
busy bool Проход уже выполняется другим запросом
attempted int Сколько карточек отобрано (batch — inbox, либо ids ∩ inbox)
reclassified int Успешно обработано (moved + kept + trashed)
moved int Ушло в смысловую колонку
kept int Осталось в «Неразобранном»
trashed int Отправлено в корзину (спам/не прошло ИИ-фильтр)
skipped int Пропущено (нет исходного текста)
usedAi bool True — разбор хотя бы одной карточки через порт ИИ; false — локальный разбор
reason string? Причина, если проход не выполнен/пусто; иначе null

GET /api/search?q=

{ "cards": [ /* Card... */ ], "messages": [] }

Container (колонка/стадия/зона)

{
  "id": "b_1a2b3c4d5e6f",
  "name": "WPF",
  "description": "Заказы по WPF",
  "color": "#818cf8",
  "order": 0,
  "space": "dashboard",
  "kind": "board",
  "collapsed": false,
  "suggested": false,
  "note": "",
  "rules": {
    "mode": "any",
    "direction": [],
    "keywords": ["wpf"],
    "stack": [],
    "grade": [],
    "exclude": [],
    "budget": { "from": 0, "to": 0, "cur": "RUB" }
  },
  "policy": { "canRestore": true, "isTerminal": false, "retentionDays": null },
  "counts": { "total": 4, "new": 1 }
}
Поле Тип Описание
id string b_... (board), planned…rejected (stage/terminal), inbox/archive/trash (service)
name string имя для отображения
description string описание (подсказка ИИ/ML)
color string hex
order int позиция в пространстве
space string dashboard / selected
kind string board / stage / service / terminal
collapsed bool свёрнутость колонки на дашборде
suggested bool ИИ-предложение, ждёт решения пользователя
note string заметка/обоснование ИИ
rules object? правила попадания (null — фильтра нет)
policy object {canRestore,isTerminal,retentionDays}
counts object {total,new} — счётчики карточек контейнера

rules (объект фильтров колонки): mode (all/any), direction, keywords, stack, grade, exclude, budget ({from,to,cur}) и добавленные этапом 12 группы levels (уровень), locations (локация/язык), types (vacancy/freelance/announcement), prices ({from,to,cur}). Все группы опциональны; старый сохранённый rules без новых групп разбирается как прежде (обратная совместимость).


GET /api/containers?space=

{ "items": [ /* Container... */ ] }

space (dashboard/selected) — опциональный фильтр.

POST /api/containers

{ "name": "WPF", "description": "", "color": null,
  "space": "dashboard", "kind": "board", "suggested": false, "note": "",
  "rules": { "mode": "any", "keywords": ["wpf"] } }

400 {detail:"Укажите название колонки"} при отсутствующем/null name. Ответ — { "id": "b_..." }.

PATCH /api/containers/{containerId}

Null-поле = «не менять». Тело: name, description, color, collapsed, suggested, note, rules, policy. Ответ — { "id": "..." }, 404 {detail:"Контейнер не найден"}.

POST /api/containers/{containerId}/accept

Принять ИИ-предложение (suggested=false), ответ — обновлённый Container.

DELETE /api/containers/{containerId}

Удаление контейнера; его карточки переносятся в inbox новыми. Ответ — { "ok": true, "movedToInbox": 4 }.

POST /api/containers/reorder

{ "space": "dashboard", "order": ["b_...", "b_...", "inbox"] }

Ответ — { "ok": true }.

Состояние колонок (UI)

  • GET /api/containers/state{ "<containerId>": { "collapsed": true, "width": "md" } }
  • PATCH /api/containers/{containerId}/state { "collapsed": true }{ "collapsed": true } (только не-null поля после merge).

ML (проверка на сообщении/канале, §8)

Все ручки — под сессией тенанта (401 {detail:"Требуется авторизация"}).

POST /api/ml/candidates

Тело: { "dialogId": "d_...", "limit": 10 }limit клампится 1..60 (дефолт 10); пустой dialogId — выборка по всем источникам тенанта (очередь/отсев/карточки).

{ "items": [
  { "id": 12345, "dialogId": "d_...", "text": "исходный текст (до 600 симв.)",
    "time": 1757500000000, "lead": true, "verdict": "card", "col": "b_...",
    "stage": null, "reason": null,
    "pred": { "take": true, "label": "b_...", "scores": { "b_...": 0.83 } } }
] }
Поле Тип Описание
id int id исходного сообщения (msgId) — его принимает /apply
dialogId string id диалога-источника
text string исходный текст (до 600 символов)
time int? время сообщения, epoch-ms (null — неизвестно)
lead bool по сообщению уже есть карточка
verdict string card / rejected / queued — текущее состояние
col string? колонка карточки (для verdict=card)
stage string? этап отсева / статус очереди
reason string? причина отсева (для verdict=rejected)
pred object? мнение ML {take,label,scores} (null — не ответил/не готов)

POST /api/ml/apply

Тело: { "dialogId": "d_...", "msgId": 12345, "action": "spam" }action: skip | spam | board:<containerId>.

{ "ok": true, "learned": true, "moved": "trash", "leadId": "c_..." }
  • skip — ничего не меняет (learned:false, moved:null);
  • spam — учит ML; карточку → в корзину (moved:"trash"), сообщение из очереди → в отсев;
  • board:<id> — учит ML; карточку переносит в колонку (moved:"<id>"), уже в колонке — только учит.

Ошибки: 404 {detail:"Исходное сообщение не найдено"} — сообщение не найдено ни в карточках, ни в отсеве, ни в очереди; 400 {detail:"Неизвестная доска"} (нет такого контейнера); 400 {detail:"Неизвестное действие"}.


Операторский health (глубины очередей, §10.2)

GET /api/operator/health дополнен числовыми полями:

{ "ok": true, "core": { "db": "ok" },
  "services": [ /* ... */ ],
  "queues": { "pipeline": 12, "mlOutbox": 3 },
  "sessions": { "active": 5 } }

queues.pipeline — суммарная глубина очереди обработки (new+filtered), queues.mlOutbox — очередь обучения ML по всем тенантам; sessions.active — активные непросроченные сессии.


Удалённые ручки

Было Стало
GET/POST /api/leads, /api/leads/{id}, /counts, /move, /trash, /restore, /comments, /mark-*-seen, /clear-col, /reclassify /api/cards...
GET/POST /api/projects, /api/projects/{id}, /take, /move, /comments, /links, /files, /reminder, /clear-rejected /api/cards...
GET/POST/PATCH/DELETE /api/boards, /reorder /api/containers...
GET /api/columns/state, PATCH /api/columns/{id}/state /api/containers/state, /api/containers/{id}/state
SSE new_lead SSE new_card