Deal — единая кодовая база
ci / build-test (push) Canceled after 0s

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 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-11 23:56:47 +03:00
commit 27c7831910
1383 changed files with 158436 additions and 0 deletions
@@ -0,0 +1,434 @@
# Дейл — единый 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 (карточка)
Единая сущность во всех дашбордах. Модульные поля (контакты/ссылки/файлы/ТЗ/история/напоминание)
присутствуют всегда, но могут быть пустыми.
```json
{
"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` принят для совместимости; без
параметра — все карточки дашборда (кроме стадий «Выбранных»).
```json
{ "items": [ /* Card... */ ] }
```
`400 {detail:"Неизвестный контейнер"}` — если контейнер не существует.
### `GET /api/cards/counts`
Плоские счётчики (совместимо с прежним `/api/leads/counts`).
```json
{ "new": 3, "inbox": { "count": 5, "new": 2 }, "learning": 12, "ml": 0, "ai": 0 }
```
### `GET /api/cards/{cardId}`
Карточка. `404 {detail:"Карточка не найдена"}`.
### `POST /api/cards`
Создание локальной карточки. Тело:
```json
{ "title": "Новый заказ", "summary": "", "containerId": "planned",
"stack": [], "budget": null, "contact": "", "tzText": "" }
```
Алиас `containerId``stage`. Ответ — созданная **Card**.
### `PATCH /api/cards/{cardId}`
Частичная правка. Null-поле = «не менять». Тело:
```json
{ "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}/files``multipart/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` и перечитывает
доску только по финальному событию.
```json
{
"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=`
```json
{ "cards": [ /* Card... */ ], "messages": [] }
```
---
## Container (колонка/стадия/зона)
```json
{
"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=`
```json
{ "items": [ /* Container... */ ] }
```
`space` (`dashboard`/`selected`) — опциональный фильтр.
### `POST /api/containers`
```json
{ "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`
```json
{ "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` — выборка по всем источникам тенанта (очередь/отсев/карточки).
```json
{ "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>`.
```json
{ "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` дополнен числовыми полями:
```json
{ "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` |