Files
Deal/docs/api/api-map.md
T
Rustam Khalimov 195faf1b1f
ci / build-test (pull_request) Successful in 2m56s
ci / build-test (push) Successful in 2m52s
Восстановить docs/ как зеркало для агентов (ревью МР #11)
Ревью rust: перенос в вики не должен удалять из репозитория то, что нужно
агенту для работы (бэклог, статус, планы, код-стайл, спеки). Дублирование
вики и репы разрешено и обязательно: вики — актуальные версии для людей,
docs/ — зеркало для контекста агентов. README разведён по ролям.
2026-09-12 23:54:28 +03:00

464 lines
53 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Дейл (Deal) — карта API (Python/FastAPI → .NET)
> Этап 9 «единая карточка»: карточки и колонки/стадии сведены в два домена — `/api/cards` и
> `/api/containers`; ручки `/api/leads`, `/api/projects`, `/api/boards`, `/api/columns` удалены, SSE
> `new_lead` переименован в `new_card`. Точный контракт — `docs/architecture/2026-09-10-unified-api-contract.md`.
Источники: `src/frontend/src/{api,store,data,utils}.js`, `src/frontend/src/store/*.js`, `src/frontend/src/views|components/*.vue`, `backend/app/main.py`, `backend/app/routers/*.py`, `backend/app/{auth,sse,constants}.py`, сервисы (`pipeline`, `processing`, `discovery`, `telegram`, `ml_client`, `rates`, `files`, `rules`, `suggest`). Фронт — высший авторитет по формам JSON; по карточкам/контейнерам источник истины — единый контракт этапа 9.
---
## 1. Общие правила
| Правило | Значение |
|---|---|
| Base path | Все API-роуты под префиксом `/api`; домены карточек/колонок — `/api/cards` и `/api/containers` (плюс `/api/auth/...`, `/api/tg/...` и т.д.) |
| Контент-типы | Запросы/ответы JSON (`application/json`), сериализация camelCase. Исключения: `POST /api/cards/{id}/files``multipart/form-data`, поле **`files`** (несколько файлов); `GET /api/tg/qr-image``image/svg+xml`; `GET /api/cards/{id}/files/{fileId}/download``application/octet-stream` (attachment); `GET /api/events``text/event-stream` |
| Сессия | httpOnly-кука **`deal_session`** (в прототипе — `leadradar_session`); `HttpOnly`, `SameSite=Lax`, `max-age` 30 дней, `secure` — по конфигурации (`Cookies__Secure`, в проде true). Запросы идут с `credentials: 'include'`. Токен сессии — случайный, хранится в БД. При logout кука удаляется; смена пароля инвалидирует старые сессии |
| Авторизация | Все роуты, кроме `POST /api/auth/login`, требуют валидной куки (`current_login`). Иначе **401** `{"detail": "Требуется авторизация"}`. Фронт на 401 разлогинивается (`setUnauthorizedHandler`) |
| Ошибки | Всегда **`{"detail": "<текст>"}`** (без `error`/`message`-обёртки). Коды: `400` (неверное тело/правила), `401` (нет сессии), `403` (тенант приостановлен), `404` (не найдено), `410` (файл не сохранён). Фронт парсит `data.detail \|\| data.message`. ⚠ «мягкие» ошибки отдаются HTTP 200 с полями (`generate-keywords``{keywords:[], error}`; `suggest-*``{ok:false, reason}`; `reset` ML → `{ok:false, error}`) |
| Оборачивание списков | `{"items": [...]}` — везде; исключение — `GET /api/containers/state` (объект `<containerId> → state`). Пагинация отсева: `{items, total, offset, limit}` |
| Успех-без-данных | `{"ok": true}` (+ опциональные поля) |
| Времена | epoch **миллисекунды** (int) в `receivedAt`, `createdAt`, `updatedAt`, `at`, `msgAt`, `queuedAt`, `rejectedAt`, `returnedAt`, `time` в превью-сообщениях; поле `card.time`**строка** «только что»/«5 мин»/«3 ч»/«2 дн» |
| Статические сегменты против `{id}` | Литералы объявляются до `{id}`: `/cards/counts`, `/cards/clear-col`, `/cards/clear-rejected`, `/cards/reclassify`, `/cards/mark-all-seen`, `/cards/mark-col-seen`, `/cards/take` — до `/cards/{cardId}`; `/containers/state`, `/containers/reorder` — до `/containers/{containerId}`; `/rejected/clear` — до `/rejected/{rejId}`. ASP.NET Core отдаёт приоритет литералам, порядок сохранён для читаемости |
| Prefix'ы id | `c_` — карточка (единый для всех дашбордов), `b_` — контейнер-колонка, `p_` — строка очереди, `r_` — запись отсева, `cm_` — комментарий, `h_` — запись истории, `pf_` — файл, `pl_*` — ссылка, `dt_` — discovery-задача, `dl_` — запись лога, `m_<dialog>_<msg>` — сообщение. Контейнеры-стадии/служебные зоны — без префикса (`planned``rejected`, `inbox`/`archive`/`trash`) |
| Служебные админ | `admin/tick`, `admin/fts/rebuild`, `admin/check-message` — служебные, фронтом не вызываются |
| Проверка фильтра/тестера | `POST /api/admin/check-message` — сухой прогон текста по всему конвейеру (стоп-правила → ML → ИИ) без создания карточки |
| Фоновые циклы (не API) | storage-тик (30 с): автоархив/очистка + напоминания; pipeline-воркер (2 с); discovery-воркер (5 с); ML outbox (10 с); suggest (180 с); tg sweep (30 с); rates (30 мин) |
---
## 2. SSE `GET /api/events`
Поток `text/event-stream`, заголовки `Cache-Control: no-cache`, `X-Accel-Buffering: no`; каждые 15 с без событий — комментарий-пинг `: ping`. Формат события: `event: <name>\ndata: <json>\n\n` (все данные — JSON).
**Что публикует бэкенд Дейла (4 именованных типа; прототип публиковал 7 — `pipeline_stats`/`boards_changed`/`leads_reclassified` в Дейле не реализованы):**
| event | Payload | Кто шлёт / когда |
|---|---|---|
| `new_card` | **полный объект карточки** (см. §4.1 — тот же объект, что элемент `GET /api/cards`) | pipeline-воркер при создании карточки (ML/ИИ-путь) |
| `toast` | `{"text": str, "icon": str}` — icon: `check`/`sparkles`/`clock`/`trash`/`x`/`send`/`logout`/`bell`/`refresh`/`restore` | автоархив/очистки, подключение/отключение Telegram, ИИ-предложения колонок, срабатывание ИИ-бюджета |
| `reminder_due` | `{"id": "<c_…>", "title": str, "containerId": "hold"}` | фоновый цикл правил хранения (30 с) — наступившие напоминания стадии `hold` при включённых напоминаниях |
| `system_status` | полный объект `tg.status()` (см. §4.9) | telegram-service при изменении подключения |
`new_lead` больше не публикуется (переименован в `new_card`). Фронтовый `openEvents()` (`api.js`) слушает `new_card`, `toast`, `reminder_due`, `system_status`.
---
## 3. Таблицы эндпоинтов
Сокращения: «→ карточка» = полный объект карточки §4.1; «→ контейнер» = §4.2; «→ settings» = §4.6; «→ задача/кандидат» = §4.8. `(фронт не вызывает)` — эндпоинт есть, UI его не дёргает; `(не используется фронтом)` — поле в ответе есть, UI не читает.
Счётчики в заголовках разделов — фактические строки таблиц (без строки-шапки); при добавлении/удалении ручки — обновлять.
### 3.1 Auth (auth_routes.py) — 4 эндпоинта
| METHOD /api/… | Назначение | Request body | Response |
|---|---|---|---|
| `POST /auth/login` | Вход; ставит куку | `{login, password}` | `{ok: true, login: "<login>"}`. 401 `{"detail":"Неверный логин или пароль"}` |
| `POST /auth/logout` | Удалить сессию и куку | — | `{ok: true}` |
| `GET /auth/me` | Проверка живой сессии | — | `{login, ok: true}` |
| `POST /auth/change-password` | Смена пароля; перевыпуск куки | `{oldPassword, newPassword}` (min 8) | `{ok: true}`; 400 «Текущий пароль неверен»/«Пароль слишком короткий (минимум 8 символов)» |
### 3.2 Карточки, контейнеры, поиск, admin, ai (бывший Dashboard)
**Контейнеры (8; бывшие доски + колонки):**
| METHOD /api/… | Назначение | Request body | Response |
|---|---|---|---|
| `GET /containers?space=` | Список контейнеров пространства (`dashboard`/`selected`) — колонки/стадии/зоны со счётчиками | — | `{items: [→ контейнер]}` |
| `POST /containers` | Создать контейнер (колонку-фильтр) | `{name, description?, color?, space?, kind?, suggested?, note?, rules?}` | `{id: "<b_…>"}`; 400 «Укажите название колонки» |
| `PATCH /containers/{id}` | Правка (`name/description/color/collapsed/suggested/note/rules/policy`; null — «не менять») | `{…}` | `{id}`; 404 «Контейнер не найден» |
| `POST /containers/{id}/accept` | Принять ИИ-предложение (`suggested=false`) | — | → контейнер |
| `DELETE /containers/{id}` | Удалить; карточки → inbox новыми | — | `{ok: true, movedToInbox: <int>}` |
| `POST /containers/reorder` | Порядок контейнеров пространства | `{space, order: ["<b_…>", …]}` | `{ok: true}`; 400 «Не указан порядок колонок» |
| `GET /containers/state` | Состояние колонок (свёрнутость/ширина, `colState`) | — | `{ "<id>": {"collapsed": bool, "width": "sm"\|"md"\|"lg"} }` |
| `PATCH /containers/{id}/state` | Сменить состояние колонки | `{collapsed?, width?}` | состояние **только этой** колонки |
**Карточки (13; бывшие лиды + базовые операции Projects):**
| METHOD /api/… | Назначение | Request body | Response |
|---|---|---|---|
| `GET /cards?containerId=` | Карточки (`containerId`/алиас `col`; без параметра — весь дашборд), свежие сверху | — | `{items: [→ карточка]}`; 400 «Неизвестный контейнер» |
| `GET /cards/counts` | Плоские счётчики + счётчики обучения | — | см. §4.1 «counts» |
| `GET /cards/{cardId}` | Одна карточка | — | → карточка; 404 «Карточка не найдена» |
| `POST /cards/mark-all-seen` | Снять «новое» со всех | — | `{ok: true}` |
| `POST /cards/mark-col-seen` | Снять «новое» с контейнера | `{col}` | `{ok: true}` |
| `POST /cards/{cardId}/move` | Перенос карточки в контейнер; учит ML | `{to: "<containerId>"}` | → карточка; 400 «Переносить можно только…» |
| `POST /cards/{cardId}/trash` | В корзину; учит ML `spam` | — | `{ok: true}`; 404 |
| `POST /cards/{cardId}/restore` | Возврат из архива/корзины | — | `{ok: true, col: "<inbox\|b_…>"}` |
| `DELETE /cards/{cardId}` | Удалить навсегда | — | `{ok: true}` |
| `POST /cards/clear-col` | Очистить корзину/архив целиком | `{col: "trash"\|"archive"}` | `{ok: true, cleared: <int>}`; 400 |
| `POST /cards/{cardId}/comments` | Добавить комментарий | `{text}` | `{comments: [{id, by:"Вы", text, time:"только что"}]}`; 400 «Пустой комментарий» |
| `POST /cards/reclassify` | Переклассификация «Неразобранного» (реальный прогон; single-flight) | `{ids?: ["c_…"]}` (тело опционально; без `ids` — все `inbox`) | `{started, busy, attempted, reclassified, moved, kept, trashed, skipped, usedAi, reason}`; при занятом проходе `{started:false, busy:true}` |
| `POST /cards/{cardId}/reclassify` | Переклассификация одной карточки | — | тот же объект ответа; 404 «Карточка не найдена» |
**Поиск (1):**
| METHOD /api/… | Назначение | Request | Response |
|---|---|---|---|
| `GET /search?q=` | Полнотекстовый+LIKE поиск, `limit=12` | query `q` (min 2 симв.) | `{cards: [→ карточка], messages: []}` — в Дейле `messages` всегда пуст |
**Admin (3; в Дейле реализованы `tick`/`fts/rebuild`/`check-message`, остальные строки — только прототип, §6):**
| METHOD /api/… | Назначение | Response |
|---|---|---|
| `POST /admin/tick` | Ручной тик: хранение+напоминания+разбор очереди (фронт зовёт раз в 60 с) | `{storage: {archived, purgedArchive, purgedTrash, purgedRejected}, reminders: [{id, title, containerId}] (уже «выстрелившие», после SSE), pipeline: <dict pump_once>, queue: int}` |
| `POST /admin/fts/rebuild` | Пересобрать FTS-индекс | `{ok: bool, ready: bool}` |
| `POST /admin/check-message` | Сухой прогон текста по конвейеру (стоп-правила → ML → ИИ) | см. §4.10 |
| `POST /admin/wipe` | Полный сброс (карточки+ML+счётчики) | `{ok, cardsRemoved, ml: {ok}}` *(прототип)* |
| `POST /admin/clear-cards` | Очистить карточки/очереди без сброса ML | `{ok, cardsRemoved}` *(прототип)* |
| `POST /admin/pump-gate` | Шлагбаум воркера `{limit?}` | `{ok, limit, done}` *(прототип)* |
**AI-действия (2):**
| METHOD /api/… | Назначение | Response |
|---|---|---|
| `POST /ai/suggest-columns` | ИИ предлагает колонки по inbox (ручной запуск) | `{ok: true, created: int}` или `{ok: false, reason: str, cooldown?}`; при успехе шлёт `toast` |
| `POST /ai/suggest-keywords` | ИИ предлагает общие ключи сферы | `{ok: true, keywords: [str]}` (≤60 шт., длина ≤40) или `{ok: false, reason}` |
### 3.3 Telegram (tg_routes.py) — 14
| METHOD /api/tg/… | Назначение | Request body | Response |
|---|---|---|---|
| `GET /status` | Статус аккаунта/фазы входа | — | §4.9 (status) |
| `POST /start-phone` | Вход по телефону | `{phone}` | `{phase: "code"}`; 400 с текстом причины |
| `POST /start-qr` | Начать QR-вход | — | `{phase: "qr", qrUrl: "https://t.me/…"}` |
| `POST /send-code` | Отправить SMS-код | `{code}` | `{phase: "password"\|"done"}`; 400 |
| `POST /send-password` | 2FA-пароль | `{password}` | `{phase: "done"}`; 400 |
| `POST /logout` | Отключить аккаунт, удалить сессию | — | `{ok: true}` (+toast/`system_status` по SSE) |
| `GET /qr-image` | SVG QR-кода (фаза qr) | — | `image/svg+xml`; 404 «QR не активен…». Фронт: `<img src="/api/tg/qr-image?t=N">` |
| `GET /dialogs` | Список диалогов из БД | — | `{items: [§4.11 диалог]}` |
| `POST /dialogs/refresh` | Синхронизировать диалоги из Telegram | — | `{ok: true, count: int}` или `{ok: false, reason: "not-connected", count: 0}` |
| `POST /dialogs/monitor-all` | Мониторинг всех каналов (первое включение → backfill в фоне) | `{enabled: bool}` | `{ok: true, count: int, enabled: bool}` |
| `POST /dialogs/backfill-all` | Перечитать последние ~10 сообщений включённых каналов (фон) | — | `{ok: true, count: int}` |
| `POST /dialogs/{dialog_id}/monitor` | Вкл/выкл мониторинг канала | `{enabled: bool}` | `{ok: true, enabled: bool}` |
| `POST /dialogs/{dialog_id}/backfill` | Догнать сообщения одного диалога | — | `{ok: true, processed: int}` *(фронт не вызывает — только сервер)* |
| `POST /dialogs/preview` | Последние сообщения диалога (свежие из TG, старые из БД) | `{dialogId, limit?=24 (clamp 1..50)}` | `{items: [§4.11 сообщение]}` |
### 3.4 Settings / rates / meta (settings_routes.py) — 6
| METHOD /api/… | Назначение | Request body | Response |
|---|---|---|---|
| `GET /settings` | Публичные настройки (секреты замаскированы) | — | §4.6 (полный settings) |
| `PATCH /settings` | Частичное обновление (см. §4.6 список ключей). Инварианты: `archiveAfterDays` 1..30, `minLen` 10..500, `discJoinLimit` 1..200, `discJoinDelayMin/Max` 5..600 (min≤max), `discEvalSample` 3..30, `discEvalThreshold` 1..100; `aiConfigs` apiKey ≥8 → шифруется; `myPrompts` ≤100. ⚠ `tgKeys` удалён из настроек тенанта — ключи Telegram задаёт оператор глобально. ⚠ Ответ — **весь** public settings (фронт затирает локальное состояние ответом) | произвольный dict из публичных ключей | §4.6 |
| `POST /ai/check` | Проверка подключения AI-провайдера | — | `{ok: bool, message: str}` + поля статуса провайдера |
| `GET /rates` | Курсы валют | — | `{base: "RUB", rates: {CODE: num}, source: "cbr"\|"mock", updatedAt: ms\|null}` |
| `POST /rates/refresh` | Принудительно обновить курсы (ЦБ/мок) | — | `{ok: bool, rates: {base, rates, source, updatedAt}}` — ⚠ фронт передаёт `r.rates` в `applyRates` |
| `GET /meta/constants` | Валюты/стадии/палитра | — | `{currencies: [{code,name,symbol}], stages: [§4.4], palette: ["#…"]}` *(фронт не вызывает — зашиты в data.js)* |
### 3.5 Детальные операции карточки (бывший Projects, projects_routes.py) — 15
Все операции — над ресурсом `/api/cards/{cardId}` (см. §4.1); отдельного `/api/projects` больше нет.
| METHOD /api/cards… | Назначение | Request body | Response |
|---|---|---|---|
| `POST ""` | Создать локальную карточку | `{title="", summary="", containerId?="planned", stack?, budget?, contact="", tzText=""}` (алиас `stage`) | → карточка |
| `POST /take` | «Взять в работу»: карточка (**не клон**) → контейнер `planned` пространства `selected` | `{cardId}` (алиас `leadId`) | → карточка; 404 «Карточка не найдена» |
| `POST /clear-rejected` | Очистить стадию «Отклонено» | — | `{ok: true, cleared: int}` |
| `PATCH /{cardId}` | Правка полей (null — «не менять») | `{title?, summary?, stack?, budget?{from,to,cur}, contact?, tzText?}` | → карточка |
| `POST /{cardId}/move` | Перенос по контейнерам/стадиям (+история; сброс reminder при уходе с hold) | `{to}` | → карточка; 400 «Переносить можно только…» |
| `POST /{cardId}/comments` | Комментарий | `{text}` | `{comments: [...]}`; 400 «Пустой комментарий» |
| `POST /{cardId}/links` | Добавить ссылку (`url` без схемы → префикс https://) | `{name="", url}` | → карточка; 400 «Пустая ссылка» |
| `DELETE /{cardId}/links/{linkId}` | Удалить ссылку | — | → карточка |
| `POST /{cardId}/files` | Загрузить файлы (multipart, поле `files`) | FormData `files` | → карточка (с обновлённым `files`) |
| `GET /{cardId}/files/{fileId}/download` | Скачать (stream из MinIO/локального store) | — | `application/octet-stream`, `Content-Disposition: attachment`; 410/404 |
| `DELETE /{cardId}/files/{fileId}` | Открепить файл | — | → карточка |
| `GET /{cardId}/source` | Содержимое источника: провайдер по `source.kind` либо сохранённое в карточке | — | `SourceContent`; 404 «Карточка не найдена» |
| `POST /{cardId}/reminder` | Напоминание карточке | `{at: <epoch ms>}` | → карточка; 400 «Поле at (epoch-ms) обязательно» |
| `DELETE /{cardId}/reminder` | Снять напоминание | — | → карточка |
| `POST /{cardId}/reminder/snooze` | Отложить на +24 ч | — | → карточка |
### 3.6 Processing — очередь и отсев (processing_routes.py) — 6
| METHOD /api/pipeline… | Назначение | Request | Response |
|---|---|---|---|
| `GET /stats` | Сводка для синхронизации | — | `{queue: {new, ai, total}, rejected: int}` |
| `GET /queue?limit=` | Сырые сообщения очереди (`limit` ≤500, дефолт 100; фронт шлёт 120) | query `limit` | `{items: [§4.5 очередь], counts: {new, ai, total}, rejected: int}` |
| `GET /rejected?q=&offset=&limit=` | Отсев (поиск по q, страницы; лимит ≤500) | query | `{items: [§4.5 отсев], total: int, offset: int, limit: int}` |
| `POST /rejected/clear` | Очистить отсев | — | `{ok: true, cleared: int}` |
| `DELETE /rejected/{rej_id}` | Удалить запись отсева | — | `{ok: true}` |
| `POST /rejected/{rej_id}/return` | Вернуть в обработку (`{reason}` помечается на записи; снимает у ML вес спама; повтор/dup → 400) | `{reason=""}` | `{id, returned: true, returnedAt: ms}`; 404/400 |
### 3.7 ML (ml_routes.py) — 7
| METHOD /api/ml… | Назначение | Request body | Response |
|---|---|---|---|
| `GET /status` | Статус ML-сервиса (форс-refresh) + локальная статистика | — | §4.10 (ml status) |
| `POST /reset` | Сброс модели + очистка outbox | — | `{ok: true}` или `{ok: false, error: str}` (⚠ ошибка — HTTP 200) |
| `POST /predict` | Проверка ML на тексте | `{text}` | `{text: <первые 200>, take: bool, label: str\|null, scores: {class: num}, hits, ready, margin, terms, type}`; 400 «Введите текст» |
| `POST /learn` | Ручная разметка в outbox | `{text, label}` | `{ok: true, outbox: int}` *(фронт не вызывает — использует apply)* |
| `POST /flush` | Немедленная отправка обучения | — | `{ok, flushed, outbox, service}` *(фронт не вызывает)* |
| `POST /candidates` | Последние сообщения канала + мнение ML | `{dialogId, limit?=10 (clamp 1..60)}` | `{items: [{id, dialogId, text(≤600), time, lead, pred: {take, label, scores}}]}` |
| `POST /apply` | Ручное решение: `action` = `spam` \| `board:<id>` \| `skip` | `{dialogId, msgId, action}` | `{ok, learned: bool, moved: "trash"\|"<board>"\|null, leadId: str\|null}`; `skip``{ok, learned: false, moved: null}`; 400/404 |
### 3.8 Discovery (discovery_routes.py) — 13
| METHOD /api/discovery… | Назначение | Request body | Response |
|---|---|---|---|
| `GET /tasks` | Список задач (старые первыми) | — | `{items: [→ задача]}` |
| `POST /tasks` | Создать (бюджет plan_joins ≤ discJoinLimit) | `{name, description?, keywords?[], minSubscribers?, lang? "ru"\|"any", threshold?, sampleSize?, planJoins?, autoJoin?}` | → задача; 400 (нет имени / бюджет) |
| `PATCH /tasks/{task_id}` | Обновить задачу | те же поля, все optional | → задача; 404/400 |
| `DELETE /tasks/{task_id}` | Удалить (с кандидатами и логом) | — | `{ok: true}` |
| `POST /tasks/{task_id}/start` | Запуск поиска (draft/paused/done/failed → running) | — | → задача; 400 «Нет ключевых слов…» |
| `POST /tasks/{task_id}/pause` | Пауза | — | → задача |
| `POST /tasks/{task_id}/generate-keywords` | ИИ-генерация ключей по description | — | `{keywords: [str≤30×60]}`, ошибка — `{keywords: [], error: str}` (HTTP 200, ⚠) |
| `GET /tasks/{task_id}/candidates?status=` | Кандидаты задачи, фильтр `new\|review\|joined\|rejected` | query `status` | `{items: [→ кандидат]}`; 404 |
| `POST /candidates/{dialog_id}/join` | Ручное вступление (+в мониторинг, +backfill, −чёрный список) | — | → кандидат; 400/404 |
| `POST /candidates/{dialog_id}/reject` | Отклонить → чёрный список | — | → кандидат; 400 (уже вступили)/404 |
| `GET /blacklist` | Чёрный список | — | `{items: [{dialogId, name, reason, createdAt}]}` |
| `DELETE /blacklist/{dialog_id}` | Убрать из чёрного списка | — | `{ok: true}` |
| `GET /tasks/{task_id}/log` | Лог задачи | — | `{items: [{id, taskId, event, text, createdAt}]}`, event ∈ `search\|skip\|review\|join_auto\|join_manual\|reject\|done\|flood\|error` |
### 3.9 Прочее (main.py / events_routes.py)
| METHOD /api/… | Назначение | Response |
|---|---|---|
| `GET /events` | SSE-поток (см. §2), авторизация обязательна | `text/event-stream` |
| `GET /health` | Healthcheck | `{ok: true, service: "deal"}` *(фронт не вызывает)* |
---
## 4. Сущности: поля JSON, которые реально читает фронт
### 4.1 Карточка (card) — `GET /api/cards`, `GET /api/cards/{cardId}`, ответы всех мутаций и payload SSE `new_card`
Единая сущность всех дашбордов (этап 9). Модульные поля (контакты/ссылки/файлы/ТЗ/история/напоминание)
присутствуют всегда, но могут быть пустыми. Точный контракт — `docs/architecture/2026-09-10-unified-api-contract.md`.
```jsonc
{
"id": "c_1a2b3c4d5e6f", // string, префикс c_ — единый
"containerId": "inbox", // контейнер карточки
"col": "inbox", // алиас containerId (совместимость)
"isNew": true, // «новое» (точка на карточке)
"local": false, // создана локально, без внешнего источника
"title": "Разработка интернет-магазина", // string ≤140
"summary": "Компания: …\nЗадача: …",// блок «О заявке»
"source": { "kind": "telegram", "externalId": "4242", "displayName": "Канал заказов", "originRef": "123456789", "author": "…", "receivedAt": "2026-09-11T10:00:00+00:00", "extra": {"hue": "#8b8ff8"} },
"content": { "text": "Ищу разработчика…", "html": null, "author": "…", "subject": null, "data": [], "links": [], "contacts": [] },
"stack": ["vue", "dotnet"],
"budget": {"from": 100000, "to": 200000, "cur": "RUB"},
"converted": {"from": 100000, "to": 200000, "cur": "RUB"},
"contact": "@client",
"contacts": [{"type": "tg", "value": "@client"}],
"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": "projects/c_…/pf_…_1726000000000_brief.pdf"}],
"history": [{"id": "h_…", "at": 1726000000000, "type": "created"}, {"id": "h_…", "at": 1726003600000, "stage": "planned"}],
"tzText": "Сделать каталог и корзину",
"reminder": {"at": 1727000000000},
"prevCol": "inbox", // предыдущий контейнер (возврат из archive/trash)
"isVacancy": false,
"isVacancyKnown": false,
"time": "5 мин", // human-метка от receivedAt
"receivedAt": 1726000000000, "createdAt": 1726000000000, "updatedAt": 1726000000000
}
```
Ключевые поля: `id/containerId/(col)` — принадлежность; `source` (`SourceRef`: вид, внешний id, подпись,
ссылка на оригинал, цвет в `extra.hue`) и `content` (`SourceContent`: текст, разметка, ссылки, контакты,
вложения `data`) — происхождение; `stack/budget/converted/contact/contacts/matchHits` — данные заявки; `comments/
links/files/history/tzText/reminder` — модули; `isNew/prevCol/isVacancy/isVacancyKnown` — маркеры. Ровно
один из полей `history[].type`/`history[].stage` задан.
Строки очереди и отсева «Обработки» отдают тот же generic `source`/`content` (раньше — `ch`); решение
отсева — `decidedBy`/`decidedByLabel` (stop|ml|ai|stale|dup).
**counts** (`GET /api/cards/counts`): `{new: int, "<containerId>": {count: int, new: int}, learning: int, ml: int, ai: int}` — плоская форма (совместима с прежним `/api/leads/counts`).
### 4.2 Контейнер (container) — `GET /api/containers`, `POST/PATCH` тела
Единый реестр колонок/стадий/зон (этап 9): пользовательские колонки-фильтры (`kind: board`),
стадии «Выбранных» (`stage`), служебные зоны (`service`: inbox/archive/trash), терминальные (`terminal`).
```jsonc
{
"id": "b_1a2b3c4d5e6f", // b_... | planned…rejected | inbox/archive/trash
"name": "WPF", "description": "Заказы по WPF", "color": "#818cf8",
"order": 0,
"space": "dashboard", // dashboard | selected
"kind": "board", // board | stage | service | terminal
"collapsed": false, // свёрнута на дашборде
"suggested": false, // ИИ-предложение ждёт решения
"note": "", // заметка/обоснование ИИ
"rules": { // правила попадания (null — фильтра нет)
"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} // счётчики карточек контейнера
}
```
`counts`/`policy` — только в ответе `GET`; `rules` — набор опциональных фильтров колонки (как раньше у доски).
### 4.3 Модульные поля карточки (бывшая «проектная карточка»)
Отдельной сущности/таблицы больше нет: модули (`comments`, `links`, `files`, `history`, `tzText`,
`reminder`, `budget`) — поля той же карточки §4.1. Формы элементов:
```jsonc
{
"comments": [{"id":"cm_…","by":"Вы","text":"…","time":"только что"}],
"links": [{"id":"pl_…","name":"сайт","url":"https://…"}],
"files": [{"id":"pf_…","name":"tz.pdf","size":12345,"kind":"document","label":"Документ","objectKey":"…"}],
"history": [{"id":"h_…","at":1757000000000,"type":"created"}, // type: "created"|"createdLocal" ИЛИ
{"id":"h_…","at":,"stage":"work"}], // stage — при переносе
"tzText": "", // техническое задание
"reminder": {"at": 1757000000000}, // object|null
"createdAt": , "updatedAt": // int ms
}
```
Загрузка файлов: multipart — ответ — обновлённая **карточка** (фронт берёт `files` из ответа). Скачивание: `GET /api/cards/{cardId}/files/{fileId}/download`.
### 4.4 Контейнеры по умолчанию (стадии «Выбранных» и зоны)
Стадии «Выбранных» (`space: selected`, `kind: stage/terminal`): `planned` Запланировано / `reply` Отклик /
`agree` Согласование / `work` В работе / `review` Проверка / `ready` Готово / `hold` Отложено (не terminal) /
`finished` Выполнено (terminal) / `rejected` Отклонено (terminal). Служебные зоны дашборда:
`inbox`, `archive`, `trash` (`space: dashboard`, `kind: service`).
### 4.5 Очередь и отсев (вкладка «Обработка»)
Очередь (`GET /pipeline/queue` item): `{id, source: SourceRef, content: SourceContent, text, status: "new"|"filtered", msgAt: ms, queuedAt: ms}` — UI показывает `text`, статус-бейдж и подпись источника (`source.displayName`, цвет `source.extra.hue`).
Отсев (`GET /pipeline/rejected` item): `{id, source: SourceRef, content: SourceContent, text, stage, stageLabel, reason, kw, decidedBy, decidedByLabel, msgAt, rejectedAt, returned: bool, returnedAt: ms|null, returnReason: string}`.
- `stage``length|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup`; `stageLabel` — подпись («короткое сообщение», «стоп-фраза», «спам (ML)», …).
- `decidedBy``stop|ml|ai|stale|dup`; `decidedByLabel` ∈ «правила|ML|ИИ|система».
- Фронт читает: `id, stageLabel, kw, reason, decidedBy, decidedByLabel, text, source, rejectedAt, returned, returnedAt, returnReason`. «Возврат» неактивен при `decidedBy==='dup'` или `returned`.
### 4.6 Настройки (settings) — все ключи ответа `GET/PATCH /api/settings` (camelCase; значения по умолчанию из `constants.DEFAULT_SETTINGS`)
```jsonc
{
"autoArchive": true, "archiveAfterDays": 14, "archiveClearDays": 90, "trashClearDays": 7,
"minLen": 24, "stopPhrases": ["взаимный пиар", "…"],
"mlEnabled": true, "aiEnabled": true, "aiFilterEnabled": true,
"aiPrompt": "Ты — классификатор…{domain}…{keywords}…", "aiFilterPrompt": "…", "cardPrompt": "…",
"wantedType": "both", // "both"|"vacancy"|"freelance"
"budgetRequiredHire": false, "budgetRequiredOrder": false,
"hireLabel": "вакансия", "orderLabel": "фриланс",
"domainDescription": "", "domainKeywords": [], "hireMarkers": [], "levelTerms": [], "resumeMarkers": [],
"blockResumes": true, "myPrompts": [{"id":"pp_…","name":"…","description":"…","prompt":"…"}],
"remindersEnabled": true,
"conversionOn": true, "targetCurrency": "RUB", "rateSource": "cbr", // cbr|mock
"autoMonitorNew": true,
"discJoinLimit": 50, "discJoinDelayMin": 50, "discJoinDelayMax": 70,
"discEvalSample": 10, "discEvalThreshold": 40, // (не используется фронтом)
"discPaused": false, "colState": {}, // colState — то же, что GET /columns/state
"aiProvider": "deepseek",
"aiConfigs": { "deepseek": {"baseUrl": "https://api.deepseek.com", "model": "…", "keySet": true, "keyMasked": "sk-12…3456"} },
"providers": [{"id":"deepseek","name":"DeepSeek","base":"…","local":false,"models":[]}, ]
}
```
Ключи, которые фронт шлёт в PATCH (по одному/группами): `aiProvider`, `aiConfigs{<id>:{baseUrl,model,apiKey?}}`, `aiPrompt`, `cardPrompt`, `aiFilterPrompt`, `stopPhrases`, `domainDescription`, `domainKeywords`, `hireMarkers`, `levelTerms`, `resumeMarkers`, `blockResumes`, `myPrompts`, `autoArchive`, `archiveAfterDays`, `aiEnabled`, `aiFilterEnabled`, `minLen`, `conversionOn`, `targetCurrency`, `rateSource`, `remindersEnabled`, `mlEnabled`, `wantedType`, `budgetRequiredHire`, `budgetRequiredOrder`, `hireLabel`, `orderLabel`, `autoMonitorNew`, `discJoinLimit`, `discJoinDelayMin`, `discJoinDelayMax`, `discPaused`.
⚠ Ответ PATCH — **полный** settings: `schedulePersist`/`saveAiSettings`/`saveDiscQuota` применяют его целиком к локальному state (источник истины после клампов).
**Изменение (решение владельца, вариант A):** ключей Telegram (`api_id`/`api_hash`) в настройках тенанта больше нет — они задаются оператором глобально (ТЗ §4.1/§8.1), см. `docs/architecture/2026-09-10-operator-analytics-contract.md` (раздел «Операторские настройки»). Вкладка Telegram у тенанта остаётся (подключение аккаунта, `GET /api/tg/status`).
### 4.7 Промпты
- `aiPrompt`, `aiFilterPrompt`, `cardPrompt` — plain string, редактируются на вкладке ИИ; содержат плейсхолдеры `{domain}`/`{keywords}`.
- `myPrompts` — личная библиотека: `[{id, name(≤80), description(≤300), prompt}]`, ≤100; id генерирует и фронт (`pp_…`), и бэк при отсутствии.
### 4.8 Каналы/discovery
**Диалог** (`GET /api/tg/dialogs` item): `{id: string, name, handle, type, hue, on: bool, last: {text, time}}` — фронт читает `id/name/handle/type/hue/on` (`last` не читает). ⚠ `type` нестабилен по значению: «канал»/«группа»/«чат» (из `refresh_dialogs`) либо `channel`/`group`/`forum` (после discovery-вступлений `add_dialog_monitored`).
**Сообщение превью** (`POST /dialogs/preview` item): `{id, text, time: ms, lead: bool}`. ⚠ `id`: из Telegram — int; фолбэк из БД — string `m_<dialog>_<msg>`.
**Discovery-задача** (`GET/POST/PATCH …/tasks`, ответы start/pause): `{id:"dt_…", name, description, keywords[], minSubscribers: int, lang: "ru"|"any", threshold: int(1..100), sampleSize: int, planJoins: int, autoJoin: bool, status: "draft"|"running"|"paused"|"done"|"failed", searchIdx: int, searchDone: bool, found: int, evaluated: int, joined: int, rejected: int, createdAt: ms, updatedAt: ms}`.
**Кандидат** (`GET …/candidates` item, ответы join/reject): `{dialogId, taskId, name, username, kind: "channel"|"group"|"forum", hue, participants: int|null, langRu: bool|null, marks: string[], topics: [{topicId, title, fitCount, total, fitRatio, passed}] (форумы), fitRatio: 0..1|null, status: "new"|"review"|"joined"|"rejected", autoJoined: bool, joinFailures: int, createdAt: ms, updatedAt: ms}`.
### 4.9 Telegram-статус (`GET /api/tg/status`, payload `system_status`)
`{phase: "idle"|"phone"|"code"|"password"|"qr"|"ready", connected: bool, listener: bool, account: string, monitored: int, keysSet: bool, error: string|null, qrUrl: string|null}`. Фронт: `connected→tgConnected`, `account`, `phase``tgState` (ready→done), `qrUrl` при phase='qr', `keysSet`.
### 4.10 Прочее
- **`GET /api/ml/status`**: `{enabled: bool, service: {ready, classes: {label: n}, learned: int, eval: {count, correct, accuracy}}, reachable: bool, stats: {ml, ai, learning, ready, classes, learned, reachable, outbox}}`. Фронт читает: `reachable`, `service.ready/classes/learned/eval.{count,correct,accuracy}`, `stats.outbox`.
- **`POST /api/admin/check-message`** (сухой прогон конвейера): `{text}`
`{passed, wouldCreateCard, targetContainer, matchHits, parsed, stages:[{stage, pass, skipped, reason, kw, label}]}`.
Коды `stage`: `length|stop|resume|type|exclude|ml|ai|spam_ai|budget`; `skipped=true` — этап выключен
настройкой. `parsed` — разбор текста (поля карточки) либо null. Запись в систему не производится.
- **`POST /api/ai/check`**: `{ok: bool, message: string, local?, keySet?}`.
- Комментарии карточки: `{id, by: string, text, time: string}``by` всегда «Вы», `time` «только что».
---
## 5. Сводка
**Карточки и контейнеры (единый контракт этапа 9):** `GET/POST /api/cards`, `GET/DELETE
/api/cards/{cardId}`, `/move`, `/trash`, `/restore`, `/comments`, `/links`, `/files`, `/reminder`,
`/take`, `/clear-col`, `/clear-rejected`, `/mark-all-seen`, `/mark-col-seen`, `/reclassify`,
`GET /api/search`; `GET/POST /api/containers`, `PATCH/DELETE /api/containers/{id}`, `/accept`, `/reorder`,
`/state` — описаны в §3.2 и §3.5.
**Прочие домены (этап 9 их не менял):**
| Модуль (роутер) | Эндпоинты |
|---|---:|
| Auth `/api/auth` | 4 |
| Telegram `/api/tg` | 14 |
| Settings/rates/meta (`/api/settings`, `/api/ai/check`, `/api/rates`) | 5 |
| Processing `/api/pipeline` (+ `/api/admin/check-message`) | 7 |
| ML `/api/ml` | 5 |
| Discovery `/api/discovery` | 13 |
| Operator `/api/operator` + `/api/join` | 25 |
| Events `/api/events` | 1 |
| Health `/api/health` | 1 |
**SSE-события:** `new_card`, `toast`, `reminder_due`, `system_status` — 4 именованных типа (см. §2).
**Коды ошибок:** всегда `{"detail": "<текст>"}``400` (неверный ввод/правила), `401` (нет сессии),
`403` (вход приостановленного тенанта), `404` (объект не найден), `410` (файл не сохранён), `422` (тело
не разобрано). Исключения — «мягкие» ошибки в HTTP 200 с полями `error`/`reason` (см. п.1 ниже).
**Замечания (актуальные):**
1. Ответы PATCH `/api/settings`, `POST /api/ml/reset` и discovery `generate-keywords` «ошибочные» ветки: мягкие ошибки в HTTP 200 с полями `error`/`reason` вместо `{"detail"}` (см. §6 п.7).
2. Тип диалога (`tg/dialogs.type`/`kind`) хранится вперемешку («канал»/«группа»/«чат» после refresh против `channel`/`group`/`forum` после discovery-вступления) — UI показывает как есть.
3. Превью-сообщения: `id` — int (из Telegram) либо string `m_<dialog>_<msg>` (фолбэк из БД) — ключи рендера неустойчивы.
4. Контейнер: `POST`/`PATCH` отвечают `{id}` (не полный объект); после мутаций фронт перечитывает `GET /api/containers`.
---
## 6. Реализовано в Deal — расхождения с картой и SaaS-дополнения (этапы 7, 10)
Карта выше — контракт фронта Дейла (после этапа 9 — единый: карточки/контейнеры). Расхождения,
влияющие на HTTP-семантику, и SaaS-ручки вне карты — ниже (контракт фронта они НЕ ломают).
**Расхождения/решения этапа 7 (зафиксированы в коде; task-7-report.md):**
1. Вход приостановленного тенанта — **HTTP 403** `{detail: "Учётная запись приостановлена. Обратитесь к оператору"}`, а не 401: учётка существует, доступ запрещён; 401 остаётся только для неверных учётных данных (статус не раскрывается). В аудит пишется `tenant_login_failed` с tenantId.
2. Смена статуса тенанта — **не PATCH {status}**, а явные `POST /api/operator/tenants/{id}/suspend` и `POST …/unsuspend` (аудит `tenant_status_changed`, идемпотентно). Отклонение приёмочного текста плана «PATCH … status» — осознанное.
3. `POST /api/operator/tenants` (create) принимает `{name, email?}` **без `budget?`**: бюджет задаётся отдельно (`GET/PATCH …/tenants/{id}/limit`); у нового тенанта — ленивый дефолт-бюджет (константа `TokenBudgetDefaults`/env `DEAL_DEFAULT_AI_BUDGET`). Поле-заглушка «принять и не применить» не вводилась.
4. «Отсутствующие» эндпоинты карты не реализованы сознательно (экономия; список — §5): `/cards/{cardId}/seen` (снятие «новое» с одной карточки), `/meta/constants`, `admin/wipe|clear-cards|pump-gate`, `ml/learn|flush` (внутренние RPC/флашер MlOutbox), `/tg/dialogs/{id}/backfill` (сервер-only: backfill включается мониторингом/«Перечитать всё»). Демо-ручки `POST /api/demo/*` (флаг `DEAL_DEMO`) удалены. `POST /api/cards/reclassify`**реальный проход** (этап 12): переклассификация «Неразобранного» через тот же конвейер, что и пайплайн (ИИ-фильтр → классификация → правила колонок) с локальным фолбэком при выключенном/недоступном ИИ; single-flight (`{started:false, busy:true}` при занятом проходе), есть и одиночная ручка `POST /api/cards/{cardId}/reclassify`.
**Устойчивость и очистки (этап 12, пакет B).** Rate limiting и `LoginAttemptGuard` — store-backed на Postgres (таблица `public.rate_limit_counters`), т.е. работают при нескольких инстансах core; активные сессии приостановленного тенанта разлогиниваются сразу (проверка статуса в `AuthService.ResolveSessionAsync`, включая impersonation). Фоновый `DataRetentionScheduler` (раз в сутки) чистит `audit_log` по retention (дефолт 180 дней), сбрасывает накопительные поля `tenant_limits` прошедших периодов и удаляет завершившиеся окна счётчиков.
**SaaS-ручки (этапы 7, 10).** С этапа 10 у операторских ручек есть **UI**: экран оператор-консоли `#/operator` (разделы «Тенанты», «Приглашения», «Лимиты ИИ», «Аудит», «Аналитика», «Состояние системы») и публичная страница активации инвайта `#/join?code=…`; основное приложение — `#/`. Операторская кука — `deal_operator_session` (12 ч, httpOnly, SameSite=Lax; отдельная от `deal_session`); `/api/join` — публичная (без куки). 401 на всех `/operator/*` без операторской сессии — «Требуется вход оператора». Тенантные `/api`-ручки операторских сессий не видят и наоборот (разные middleware). Подробнее — техдок §13.8 (контур) и §13.10 (консоль/аналитика).
| METHOD /api/… | Назначение | Ответ |
|---|---|---|
| `POST /operator/auth/login` `{login,password}` | вход оператора (env `DEAL_OPERATOR_*`; dev-дефолт `operator`/`operator`) | `{ok, login}` + кука; 401; 429 (rate limit) |
| `POST /operator/auth/logout`; `GET /operator/auth/me` | выход / проверка сессии | `{ok}`; `{login, ok}`; 401 |
| `POST /join` `{code, email, name?, password}` | публичная активация инвайта (страница `#/join?code=…`): пользователь (Argon2id) и, при необходимости, тенант с провижинингом | `{ok: true, login}`; 400 `{detail}` |
| `GET /operator/tenants` | список тенантов + счётчики пользователей | `{items:[{id,name,status,createdAt,usersCount}]}` |
| `POST /operator/tenants` `{name, email?}` | создать тенанта (email → владелец с одноразовым паролем) | `{id,name,status,createdAt}` (+`ownerEmail`,`initialPassword`); 400/401 |
| `GET /operator/tenants/{id}` | детали + пользователи | тенант; 404 |
| `POST /operator/tenants/{id}/suspend`; `…/unsuspend` | приостановка/возобновление (см. п.2) | `{ok, status}`; 404 |
| `POST /operator/tenants/{id}/impersonate` `{login?}` | вход от имени пользователя тенанта; **ставит httpOnly-куку `deal_session` ответом** — оператор сразу в тенанте | `{sessionToken, expiresAt, tenantId, login}`; 404/400 |
| `GET /operator/invites`; `POST /operator/invites` `{email, tenantId?, name?}` | список / создание инвайта (код 16 симв., 72 ч) | `{items:[…]}`; `{code,email,tenantId,expiresAt,status}` |
| `POST /operator/invites/{code}/revoke` | отзыв инвайта | `{ok:true}` |
| `GET /operator/limits` | сводка ИИ-бюджетов по тенантам | `{items:[{tenantId,name,budget,period,used,percent,status}]}` |
| `GET/PATCH /operator/tenants/{id}/limit` | детали/смена бюджета `{budget?, period?}` (сброс флагов порогов, аудит) | лимит; 400/404 |
| `GET /operator/audit?eventType=&actorType=&actorId=&tenantId=&from=&to=&limit=&offset=` | лента аудита (append-only, At DESC, limit ≤500, пагинация) | `{items, total}` |
| `GET /operator/analytics/overview?from=&to=` | сводка за период: тенанты, токены, события, входы/выходы/неудачные входы | `{tenantsTotal,tenantsActive,promptTokens,completionTokens,totalTokens,tokenEvents,events,logins,logouts,failedLogins,from,to}` |
| `GET /operator/analytics/tokens?groupBy=&tenantId=&from=&to=` | агрегаты расхода токенов (`groupBy=day\|tenant\|provider\|model`) | `{groupBy,from,to,items:[{key,…}],total}`; 400 (неизвестная группировка) |
| `GET /operator/analytics/activity?eventType=&actorType=&actorId=&tenantId=&from=&to=&limit=&offset=` | лента действий (аудит) с фильтрами и пагинацией | `{items,total,limit,offset}` |
| `GET /operator/health` | health core/БД + сервисы ml/ai/telegram (UseLocal → `mode:local`); этап 12: глубины очередей и активные сессии | `{ok, core:{db}, services:[…], queues:{pipeline,mlOutbox}, sessions:{active}}` (всегда 200) |
| `POST /operator/maintenance/tenants/migrate` | Пакетная миграция схем всех тенантов (идемпотентно, шардированный обход страницами + ограниченный параллелизм; этап 12, пакет C / BL-SCALE-1000) | `{ok,total,migrated,failed,failedSchemas,durationMs}` (`ok=false`, если хотя бы одна схема не мигрирована); 401 без операторской сессии |
| `GET /operator/analytics/suspicious?from=&to=` | подозрительная активность по аудиту (всплеск неудачных входов по IP/логину, входы актора с множества IP, серии по тенанту; этап 12) | `{scanned,truncated,items:[…]}` |
| `GET /operator/settings/telegram-keys` | глобальные ключи Telegram (задаёт оператор; тенант их не видит) | `{apiId, apiHash (маска), keysSet}` |
| `PUT /operator/settings/telegram-keys` `{apiId?, apiHash?}` | задать/обновить ключи (частично: можно одно поле, второе сохраняется); `api_id` 59 цифр, `api_hash` непустой; hash шифруется | маска-форма; 400 `{detail}`; 401 |