# Дейл — единый 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 (карточка) Единая сущность во всех дашбордах. Модульные поля (контакты/ссылки/файлы/ТЗ/история/напоминание) присутствуют всегда, но могут быть пустыми. ```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": "" }` Перенос карточки. Ответ — обновлённая **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": "" }` → `{ "ok": true }` ### `POST /api/cards/take` `{ "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` → `{ "": { "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:`. ```json { "ok": true, "learned": true, "moved": "trash", "leadId": "c_..." } ``` - `skip` — ничего не меняет (`learned:false`, `moved:null`); - `spam` — учит ML; карточку → в корзину (`moved:"trash"`), сообщение из очереди → в отсев; - `board:` — учит ML; карточку переносит в колонку (`moved:""`), уже в колонке — только учит. Ошибки: `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` |