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 зелёные.
21 KiB
Дейл — единый 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(T4–T6, 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": "" }
Алиас containerId — stage. Ответ — созданная 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": "..." }→ обновлённая CardDELETE /api/cards/{cardId}/links/{linkId}→ обновлённая Card
Файлы
POST /api/cards/{cardId}/files—multipart/form-data, полеfiles(одно или несколько) → обновлённая CardGET /api/cards/{cardId}/files/{fileId}/download→ бинарный потокDELETE /api/cards/{cardId}/files/{fileId}→ обновлённая Card
Напоминания
POST /api/cards/{cardId}/reminder{ "at": 1727000000000 }→ обновлённая Card400 {detail:"Поле at (epoch-ms) обязательно"}DELETE /api/cards/{cardId}/reminder→ обновлённая CardPOST /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 |