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:
@@ -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` (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": "<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` |
|
||||
Reference in New Issue
Block a user