{"openapi":"3.1.0","info":{"title":"Metodox API","version":"1.10.0","summary":"REST и WebSocket API рабочего пространства Metodox","contact":{"name":"Metodox","url":"https://metodox.ru","email":"no-reply@metodox.ru"},"description":"Metodox — рабочее пространство с канбан-досками, командной работой, Wiki, заметками, мотивацией и ИИ-сотрудниками. Этот справочник описывает **весь HTTP API** (все маршруты из исходного кода), протокол **WebSocket** и **модель данных** PostgreSQL.\n\nОдин и тот же API обслуживает веб-приложение [app.metodox.ru](https://app.metodox.ru), Android-приложение и будущий desktop-клиент.\n\n## Быстрый старт\n\nБазовый адрес — `https://app.metodox.ru/api/v1`. Все тела запросов и ответов — JSON в UTF-8, все идентификаторы — UUID.\n\n**1. Получите токены.** Нативные клиенты передают `client: \"desktop\"` и получают токены в теле ответа; браузер получает HttpOnly-cookie.\n\n```bash\ncurl -s https://app.metodox.ru/api/v1/auth/login \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"email\":\"anna@example.com\",\"password\":\"correct-horse-battery\",\"client\":\"desktop\"}'\n```\n\n```json\n{\n  \"user\": { \"id\": \"8f1c2d4e-5a6b-4c7d-8e9f-0a1b2c3d4e5f\", \"email\": \"anna@example.com\", \"name\": \"Анна\" },\n  \"accessToken\": \"…\",\n  \"refreshToken\": \"…\",\n  \"expiresIn\": 900,\n  \"tokenType\": \"Bearer\"\n}\n```\n\n**2. Вызывайте API с access-токеном.**\n\n```bash\ncurl -s https://app.metodox.ru/api/v1/boards \\\n  -H \"Authorization: Bearer $ACCESS_TOKEN\"\n```\n\n**3. Обновляйте токен до истечения 15 минут** через `POST /api/v1/auth/refresh` с `refreshToken`. Refresh-токен одноразовый: в ответе приходит новая пара.\n\n## Разделы\n\n| Группа | Что внутри |\n|---|---|\n| Аккаунт | Регистрация, вход, сессии, подтверждение почты, восстановление пароля, профиль |\n| Организации и задачи | Личные и корпоративные пространства, роли, приглашения, доски, колонки, задачи, вложения, подзадачи и зависимости |\n| Общение | Обсуждения задач с ветками и упоминаниями, реакции, пересылка, друзья и личные чаты, уведомления |\n| TEAM и Wiki | Проекты и этапы, бэклог, спринты, журнал команды, внутренняя и публичная Wiki |\n| Личное пространство | Заметки, планер недели, XP, монеты, достижения, магазин оформления, личный сейф со сквозным шифрованием |\n| ИИ и автоматизация | ИИ-сотрудники (OpenAI, Anthropic, Ollama), цепочки агентов, сейф организации и SSH-операции |\n| Модели | Объекты ответов API и все таблицы базы данных (`db.*`) с колонками, ограничениями и связями |\n\n## Версии и источники\n\n- Версия API — в пути (`/api/v1`). Несовместимые изменения выпускаются под новым префиксом.\n- Справочник собирается из исходного кода: список маршрутов извлекается из декораторов NestJS, схема БД — из базы после всех миграций. Сборка падает, если какой-то маршрут не описан.\n- Спецификацию можно скачать кнопкой загрузки на этой странице или по адресу [`/docs/openapi.json`](/docs/openapi.json) и импортировать в Postman, Insomnia или генератор клиентов.\n\n## Аутентификация\n\nОдин API обслуживает веб-приложение и нативные клиенты: десктоп и мобильное приложение. Сессии у них устроены одинаково, различается только передача токенов. Режим выбирает поле `client` в `POST /api/v1/auth/register`, `/auth/login` и `/auth/refresh`.\n\n| | `client: \"web\"` (по умолчанию) | `client: \"desktop\"` |\n|---|---|---|\n| Кто использует | браузер | десктоп и мобильное приложение; другого значения нет |\n| Токены после входа | HttpOnly-cookie; в теле только `user` и `expiresIn` | в теле: `accessToken`, `refreshToken`, `expiresIn: 900`, `tokenType: \"Bearer\"` |\n| Access-токен в запросах | cookie отправляет браузер | заголовок `Authorization: Bearer <accessToken>` |\n| Refresh-токен при обновлении | из cookie | поле `refreshToken` в теле `POST /auth/refresh` |\n| `Origin` в изменяющих запросах | обязателен, если запрос несёт cookie | не нужен с заголовком `Authorization: Bearer`; если передан, должен входить в `WEB_ORIGIN` |\n\nТокен — 32 случайных байта в base64url, 43 символа. В базе хранится только его SHA-256 (таблица [sessions](#модели/dbsessions)). Одна пара токенов — одна сессия. Каждый вход создаёт новую сессию и не закрывает остальные.\n\n### Cookie веб-клиента\n\nИмена и часть атрибутов зависят от окружения (`cookieNames()` в `apps/api/src/security.ts`):\n\n| Окружение | Access-cookie | Refresh-cookie | `Path` | `Secure` |\n|---|---|---|---|---|\n| `NODE_ENV=production` | `__Host-md_access` | `__Host-md_refresh` | `/` | да |\n| остальные | `md_access` | `md_refresh` | `/api/v1` | нет |\n\nОбщие атрибуты:\n- `HttpOnly`, `SameSite=Lax`, атрибута `Domain` нет;\n- `Max-Age`: 900 секунд (15 минут) у access-cookie и 2 592 000 секунд (30 дней) у refresh-cookie.\n\nПрефикс `__Host-` требует `Secure`, `Path=/` и отсутствия `Domain`. Поэтому в production cookie привязаны к одному хосту, `app.metodox.ru`, и не передаются на другие поддомены.\n\nСкрипты страницы токены не видят. Срок жизни access-токена клиент узнаёт из `expiresIn`, данные пользователя — из `GET /api/v1/auth/me`.\n\n### Как сервер находит токен\n\nAccess-токен выбирает функция `accessToken(req)`:\n\n1. Если заголовок `Authorization` начинается с `Bearer ` (с пробелом, с учётом регистра), токен — остаток заголовка. Cookie в этом случае не читается, даже если токен в заголовке неверный.\n2. Иначе берётся access-cookie.\n3. Значение длиннее 200 символов считается отсутствующим.\n\nRefresh-токен из заголовков не читается никогда. Веб-клиент передаёт его в cookie, нативный — в поле `refreshToken` тела `POST /api/v1/auth/refresh`.\n\nЗапрос аутентифицирован, если найдена сессия с этим access-токеном и у неё не истекли **оба** срока, access и refresh. Иначе ответ 401:\n- «Войдите в аккаунт» — токена нет;\n- «Сессия истекла» — токен не найден или срок истёк.\n\n### Сроки жизни\n\n| Что | Срок | Где видно |\n|---|---|---|\n| Access-токен | 15 минут | `expiresIn: 900` в ответах входа и обновления |\n| Refresh-токен | 30 дней с момента выдачи пары | `expiresAt` в `GET /api/v1/profile/sessions` |\n| Ссылка подтверждения email | 24 часа | — |\n| Ссылка сброса пароля | 30 минут | — |\n\nКаждое обновление выдаёт новую пару с новым 30-дневным сроком refresh-токена. Пока клиент обновляет токены хотя бы раз в 30 дней, сессия не заканчивается.\n\n### Обновление токенов\n\n`POST /api/v1/auth/refresh` выполняет ротацию в одной транзакции: удаляет старую сессию, создаёт новую и запоминает SHA-256 использованного refresh-токена на 30 дней (таблица [rotated_refresh_tokens](#модели/dbrotated-refresh-tokens)). Из этого следует:\n\n- старые access- и refresh-токены перестают работать сразу, `id` сессии меняется;\n- из параллельных запросов с одним refresh-токеном успешен только один, остальные получают 401;\n- **повтор уже обменянного токена** в течение 30 секунд после ротации получает обычный 401 `Unauthorized` и ничего не меняет: так сервер прощает параллельные обновления (две вкладки, realtime-клиент и запрос одновременно);\n- **повтор позже** (до 30 дней) считается кражей токена. Сервер закрывает **все** сессии пользователя на всех устройствах, стирает сохранённые хеши его ротаций, веб-клиенту очищает cookie и отвечает 401 «Сессия завершена из соображений безопасности. Войдите снова.». Нужен новый вход.\n\nТокены сессий, закрытых выходом, отзывом в профиле, сменой или сбросом пароля, не запоминаются: их повтор — обычный 401.\n\nНативный клиент, шаг за шагом:\n\n```bash\nAPI=https://app.metodox.ru/api/v1\n\n# 1. Вход: токены приходят в теле\ncurl -s \"$API/auth/login\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"email\":\"anna@example.com\",\"password\":\"correct-horse-42\",\"client\":\"desktop\"}'\n# 201 {\"user\":{\"id\":\"9480fab5-…\",\"email\":\"anna@example.com\",\"name\":\"Анна Смирнова\"},\n#      \"accessToken\":\"<A1>\",\"refreshToken\":\"<R1>\",\"expiresIn\":900,\"tokenType\":\"Bearer\"}\n\n# 2. Обычные запросы — с access-токеном в заголовке\ncurl -s \"$API/auth/me\" -H \"Authorization: Bearer <A1>\"\n\n# 3. Access-токен истёк (401) или вот-вот истечёт: обмен refresh-токена\ncurl -s \"$API/auth/refresh\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"client\":\"desktop\",\"refreshToken\":\"<R1>\"}'\n# 201 {\"accessToken\":\"<A2>\",\"refreshToken\":\"<R2>\",\"expiresIn\":900,\"tokenType\":\"Bearer\"}\n# <A1> и <R1> больше не действуют. Сохраните <A2> и <R2> сразу.\n\n# 4. Повтор с <R1> отклоняется.\n# В первые 30 секунд после шага 3:\n# 401 {\"message\":\"Unauthorized\",\"statusCode\":401}\n# Позже — все сессии пользователя закрыты, <A2> и <R2> тоже больше не действуют:\n# 401 {\"message\":\"Сессия завершена из соображений безопасности. Войдите снова.\",\"error\":\"Unauthorized\",\"statusCode\":401}\n```\n\nВеб-режим через curl (браузер делает то же сам). Cookie хранятся в файле. Изменяющий запрос с cookie обязан нести `Origin`:\n\n```bash\nORIGIN=https://app.metodox.ru   # адрес должен входить в WEB_ORIGIN сервера\n\ncurl -s -c jar.txt \"$API/auth/login\" -H \"Origin: $ORIGIN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"email\":\"anna@example.com\",\"password\":\"correct-horse-42\"}'\n# 201 {\"user\":{…},\"expiresIn\":900}\n# Set-Cookie: __Host-md_access=…; Set-Cookie: __Host-md_refresh=…\n\ncurl -s -b jar.txt -c jar.txt -X POST \"$API/auth/refresh\" -H \"Origin: $ORIGIN\"\n# 201 {\"expiresIn\":900} и новые Set-Cookie\n```\n\nРекомендуемый цикл: получили 401 на обычный запрос — один раз вызовите refresh и повторите запрос. Если refresh тоже вернул 401, сессии больше нет и нужен вход. Веб-приложение Metodox работает именно так: параллельные 401 ждут одного общего refresh. Для самих `/auth/login`, `/auth/register` и `/auth/refresh` оно refresh не вызывает.\n\n### Выход и отзыв сессий\n\n- `POST /api/v1/auth/logout` удаляет сессию по access-токену (заголовок или cookie) или по refresh-cookie и очищает обе cookie. Срок токена не проверяется: просроченный access-токен тоже закрывает свою сессию, если она ещё существует. Ответ всегда `{ \"ok\": true }`.\n- `DELETE /api/v1/profile/sessions/{id}` закрывает другую сессию пользователя. Текущую так закрыть нельзя.\n- `POST /api/v1/profile/password` закрывает все сессии, кроме текущей, и отзывает ранее выданные ссылки сброса пароля.\n- `POST /api/v1/account/reset` закрывает все сессии пользователя и заодно подтверждает email: ссылка пришла на этот адрес.\n- Повтор обменянного refresh-токена позже 30 секунд после ротации закрывает все сессии пользователя (см. «Обновление токенов»).\n\n### Realtime и смена токенов\n\nWebSocket-подключение (`/api/v1/realtime`, socket.io) проходит ту же проверку сессии. Браузер передаёт cookie, нативный клиент — access-токен в `auth.token` при рукопожатии.\n\nСервер раз в 30 секунд перепроверяет сессию каждого подключения. Если она закрыта или истекла, клиент получает событие `session_expired` и подключение разрывается. После каждого обновления токенов старая сессия удалена, поэтому переподключайтесь с новым access-токеном.\n\nВеб-клиент восстанавливается после `session_expired` тихо: вызывает `GET /api/v1/auth/me` обычным запросом (на 401 срабатывает общий refresh, один на все запросы страницы) и, если сессия жива, один раз переподключает сокет. Отдельный refresh ради сокета он не делает: cookie, уже обновлённые другой вкладкой, используются как есть, а лишняя ротация разорвала бы сокет той вкладки. Подробности и пример — в разделе «Realtime».\n\n### Подтверждение email\n\nРегистрация создаёт аккаунт с `emailVerified: false`. При включённой почте (`MAIL_ENABLED=true`) на адрес сразу уходит ссылка подтверждения. Повторить её можно через `POST /api/v1/account/request-verification`, состояние видно в `GET /api/v1/account/status`.\n\nПодтверждение обязательно, если `EMAIL_VERIFICATION_REQUIRED=true`, а также в production, если этот флаг не выставлен в `false`. Тогда неподтверждённый аккаунт получает 403 «Сначала подтвердите email в настройках безопасности» в таких случаях:\n\n| Действие | Маршрут |\n|---|---|\n| Создать организацию | `POST /api/v1/workspaces` |\n| Посмотреть входящие приглашения | `GET /api/v1/workspaces/invitations` |\n| Принять приглашение в организацию | `POST /api/v1/workspaces/invitations/{inviteId}/accept` |\n| Отклонить приглашение | `DELETE /api/v1/workspaces/invitations/{inviteId}` |\n| Передать владение организацией (нужно и новому владельцу) | `POST /api/v1/workspaces/{id}/transfer` |\n| Создать личный сейф | `POST /api/v1/vault/config` |\n\nСброс пароля по ссылке из письма тоже подтверждает email: ссылка пришла на этот адрес.\n\nНезависимо от флага подтверждённый email нужен для редактирования публичной Wiki (адреса из `WIKI_ADMIN_EMAILS`).\n\n### Рекомендации для нативных клиентов\n\n- **Храните refresh-токен в защищённом хранилище ОС**: Keychain, Android Keystore или их обёртках. Мобильное приложение Metodox использует `expo-secure-store`. Access-токен держите только в памяти: он живёт 15 минут, а после перезапуска его легко получить через refresh.\n- **Обновляйте токены в одном потоке.** Если несколько запросов одновременно получили 401, refresh должен выполниться один раз, а остальные — дождаться результата. Второй параллельный refresh с тем же токеном получит 401, и клиент ошибочно разлогинит пользователя.\n- **Заменяйте пару целиком и сразу.** После успешного refresh старые токены недействительны. Сохраните новый refresh-токен до того, как использовать новый access-токен. Не повторяйте старый refresh-токен «на всякий случай»: повтор позже 30 секунд после ротации закроет все сессии пользователя.\n- **401 от refresh означает конец сессии.** Удалите сохранённые токены и покажите экран входа.\n- **Не отправляйте `Origin`** или убедитесь, что он входит в `WEB_ORIGIN`. Изменяющий запрос с чужим `Origin` получит 403 даже с Bearer-токеном. WebView и некоторые HTTP-клиенты подставляют этот заголовок сами.\n- **Учитывайте лимиты.** `POST /auth/register`, `/auth/login`, `/auth/refresh` и `/auth/logout` ограничены 15 запросами в минуту, письма и ссылки `/account/*` — пятью, `POST /profile/password` и `POST /profile/delete` — тоже пятью. `GET /auth/me` и `GET /account/status` подчиняются общему лимиту (120 в минуту), но опрашивать их по таймеру не нужно.\n- **Выход:** вызовите `POST /api/v1/auth/logout` с access-токеном в заголовке, затем удалите токены локально. Refresh-токен для выхода не принимается.\n- **Переподключайте realtime** с новым access-токеном после каждого обновления.\n\n## Общие правила\n\n### Базовый URL и версия\n\n- Все HTTP-маршруты начинаются с префикса `/api/v1` (`app.setGlobalPrefix(\"api/v1\")`). Рабочий сервер — `https://app.metodox.ru`, полный базовый адрес — `https://app.metodox.ru/api/v1`.\n- Публичная Wiki продукта доступна и на `https://metodox.ru`. Там открыты только маршруты чтения `GET /api/v1/wiki/public…`, остальной API работает на `app.metodox.ru`.\n- Версия задаётся только префиксом пути. Заголовков версии и согласования формата нет.\n- Realtime работает на том же хосте: `/api/v1/realtime`, socket.io, только транспорт WebSocket.\n- Сервер ждёт полного получения запроса до 10 минут (`requestTimeout = 600000`). Это важно для загрузки больших файлов на медленном канале.\n\n### CSRF и Origin\n\nВеб-клиент аутентифицируется cookie, поэтому изменяющие запросы защищены проверкой `Origin`. Её выполняет промежуточный обработчик в `apps/api/src/main.ts` для всех методов, кроме `GET`, `HEAD` и `OPTIONS`.\n\n| Условие | Ответ |\n|---|---|\n| Запрос несёт access- или refresh-cookie, в нём нет `Origin` и нет заголовка `Authorization: Bearer …` | 403 «Для изменений с входом по cookie нужен заголовок Origin» |\n| `Origin` передан и не совпадает точно ни с одним адресом из `WEB_ORIGIN` | 403 «Запросы с этого адреса (Origin) не разрешены» |\n\nОсобенности:\n- Проверку отсутствующего `Origin` пропускает только заголовок `Authorization`, начинающийся с `Bearer `: такой запрос аутентифицируется токеном и cookie не читает. С любой другой схемой (`Basic …` и т.п.) запрос по-прежнему аутентифицируется cookie, и `Origin` для него обязателен.\n- Второе правило действует для всех клиентов, в том числе с Bearer-токеном. Нативному клиенту проще не отправлять `Origin` вовсе.\n- `WEB_ORIGIN` — список точных адресов через запятую. В production каждый адрес обязан начинаться с `https://`, иначе сервер не запустится.\n- Ответ имеет обычный формат `Error`: `{ \"statusCode\": 403, \"message\": \"…\", \"error\": \"Forbidden\" }`.\n- CORS включён только для адресов из `WEB_ORIGIN` и с `credentials: true`. Страницы других сайтов не могут читать ответы API и не могут отправлять запросы с нестандартными заголовками.\n- Дополнительно cookie выставляются с `SameSite=Lax`.\n- `GET`, `HEAD` и `OPTIONS` эта проверка не затрагивает.\n\n### Формат ошибок\n\nОшибки возвращаются в JSON. Сообщения для пользователя написаны по-русски. Тексты валидации и системные сообщения фреймворка — на английском.\n\n**Ошибка приложения** (схема `Error`) — исключение NestJS с текстом:\n\n```json\n{ \"message\": \"Доска не найдена\", \"error\": \"Not Found\", \"statusCode\": 404 }\n```\n\nЕсли исключение создано без текста, поля `error` нет: `{ \"message\": \"Not Found\", \"statusCode\": 404 }`.\n\n**Ошибка валидации** (схема `ValidationError`, ответ `BadRequest`). Возникает, когда тело, параметр пути или query не прошли zod-схему. Поля `statusCode` в этом ответе нет.\n\n```json\n{\n  \"message\": \"Проверьте поля формы\",\n  \"errors\": {\n    \"formErrors\": [],\n    \"fieldErrors\": { \"password\": [\"Too small: expected string to have >=10 characters\"] }\n  }\n}\n```\n\nОшибки отдельных полей объекта попадают в `fieldErrors`. Ошибки значения целиком — в `formErrors`. Например, неверный UUID в пути даёт `formErrors: [\"Invalid UUID\"]`, а тело без `Content-Type: application/json` — `formErrors: [\"Invalid input: expected object, received undefined\"]`.\n\n**Другие ответы.**\n\n| Ситуация | Ответ |\n|---|---|\n| JSON не разбирается | 400 `{ \"message\": \"<текст SyntaxError>\", \"error\": \"Bad Request\", \"statusCode\": 400 }` |\n| Тело JSON больше 512 KiB (у `POST /vault/rekey` — больше 8 MiB) | 413 `{ \"statusCode\": 413, \"message\": \"request entity too large\" }` |\n| Превышен лимит частоты | 429 `{ \"statusCode\": 429, \"message\": \"ThrottlerException: Too Many Requests\" }` |\n| База данных недоступна (`GET /health`) | 503 `{ \"statusCode\": 503, \"message\": \"База данных недоступна\", \"error\": \"Service Unavailable\" }` |\n| Необработанная ошибка | 500 `{ \"statusCode\": 500, \"message\": \"Internal server error\" }` |\n\n**404 вместо 403.** Ресурс, который пользователю не виден, отвечает 404, а не 403. Так ответ не раскрывает, что ресурс существует (`apps/api/src/access.ts`):\n\n| Ситуация | Ответ |\n|---|---|\n| Доска без доступа | 404 «Доска не найдена» |\n| Организация, где пользователь не участник | 404 «Организация не найдена» |\n| Задача с ограниченной видимостью для постороннего | 404 «Задача не найдена» |\n| Скрытый профиль другого пользователя | 404 |\n\n403 приходит, только когда ресурс виден, но прав мало. Например: «Недостаточно прав на доске», «Недостаточно прав на задаче», «Нужны права администратора организации». Некорректный UUID в пути проверяется раньше прав и даёт 400.\n\n### Ограничение частоты\n\nЛимиты считает глобальный `ThrottlerGuard` (`@nestjs/throttler`).\n\n- **Значение по умолчанию** — 120 запросов за 60 секунд.\n- **Счётчик отдельный** для каждой пары «маршрут (метод контроллера) + сессия». Запрос с действующим access-токеном (Bearer или cookie) считается по сессии, поэтому коллеги в одном офисе за общим IP не мешают друг другу. Запросы без токена или с несуществующим токеном (вход, регистрация, восстановление пароля) считаются по IP клиента; IPv6-адреса группируются по сети `/64`. Это лимит на маршрут, а не на весь API.\n- **IP** — это `req.ip`. За прокси учитывается `TRUST_PROXY_HOPS` (0–3).\n- **Счётчики хранятся в памяти процесса.** После перезапуска они обнуляются, между экземплярами сервера не делятся.\n- **Заголовки:** запрос, прошедший проверку лимита, получает в ответе `X-RateLimit-Limit`, `X-RateLimit-Remaining` и `X-RateLimit-Reset` (секунды до сброса). Ответ 429 содержит `Retry-After` в секундах.\n\nМаршруты с собственным лимитом:\n\n| Маршруты | Лимит |\n|---|---|\n| `POST /auth/register`, `POST /auth/login`, `POST /auth/refresh`, `POST /auth/logout` | 15 за 60 с |\n| `POST /account/request-verification`, `POST /account/forgot-password`, `POST /account/verify`, `POST /account/reset` | 5 за 60 с |\n| `POST /profile/password`, `POST /profile/delete`, `DELETE /workspaces/{id}` | 5 за 60 с |\n| `POST /vault/rekey` | 10 за 60 с |\n| все остальные, включая `GET /auth/me` и `GET /account/status` | 120 за 60 с |\n\n`GET /auth/me` и `GET /account/status` клиенты вызывают на каждом экране, поэтому строгий лимит их контроллеров к ним не применяется.\n\nЕсть ограничения, не связанные с частотой:\n- **Файлы.** Одновременно обрабатывается не больше 2 передач крупнее 10 MiB и 12 передач поменьше, на весь процесс. Сверх этого ответ 503 «Сервер обрабатывает другие файлы. Повторите через несколько секунд.».\n- **Realtime.** Не больше 12 подключений на пользователя.\n\n### Оптимистичная блокировка\n\nИзменяемые совместно ресурсы содержат целое поле `version`. Клиент отправляет в запросе изменения последнюю известную ему версию. Если на сервере версия другая, ответ — 409 с текстом, который можно показать пользователю. Изменения при этом не применяются. После успешного изменения сервер увеличивает `version` на 1. Новое значение берите из ответа операции, если оно там есть, или перечитайте ресурс.\n\n| Ресурс | Операции с `version` | Допустимые значения |\n|---|---|---|\n| Задача доски | `PATCH /boards/{id}/tasks/{taskId}` | целое ≥ 1 |\n| Заметка | `PATCH /notes/{id}` | целое ≥ 1 |\n| Запись личного сейфа | `PATCH /vault/{id}` | целое ≥ 1 |\n| Статья Wiki | `PATCH /wiki/pages/{id}` | целое ≥ 1 |\n| Идея бэклога TEAM | `PATCH /team/{space}/backlog/{id}`, `POST /team/{space}/backlog/{id}/convert` | целое ≥ 1 |\n| Спринт | `PATCH /team/{space}/sprints/{id}` | целое ≥ 1 |\n| Проект и этап | `PATCH /team/{space}/projects/{id}`, `PATCH /team/{space}/projects/{id}/phases/{phase}` | целое ≥ 1 |\n\nВерсия растёт и при косвенных изменениях. Например, у задач — когда исполнителя снимают при удалении участника, у заметок — при удалении в корзину и восстановлении. Статус 409 используется и для других конфликтов: дубликатов, недопустимых переходов состояния, уже запущенных процессов. Поэтому ориентируйтесь на текст ответа.\n\n### Идентификаторы и даты\n\n- **Идентификаторы** — UUID в канонической записи, в ответах всегда в нижнем регистре. Большинство ID создаёт сервер (`randomUUID`, версия 4). Исключение — запись личного сейфа: её `id` передаёт клиент в `POST /vault` (и в `POST /vault/rekey`); сервер приводит его к нижнему регистру. Некорректный UUID в пути, query или теле даёт 400 `ValidationError`.\n- **Моменты времени** (`timestamptz`) приходят строкой ISO 8601 в UTC с миллисекундами: `\"2026-10-02T08:00:12.345Z\"`.\n- **Даты без времени** (сроки задач, дни планера, даты спринтов) приходят и принимаются строкой `YYYY-MM-DD`.\n- **Курсоры** — непрозрачные строки из ответа (поле `cursor` записи или `nextCursor`), см. «Пагинация». Разбирать или собирать их на клиенте не нужно.\n- **Локальная дата пользователя** («сегодня», просрочка, неделя планера) считается по часовому поясу из профиля (`users.timezone`). Неделя начинается с понедельника.\n\n### Размер запросов и файлы\n\n**JSON.** Тело разбирается, только если передан `Content-Type: application/json`. Предел — 512 KiB; исключение — `POST /vault/rekey` (до 8 MiB): смена мастер-пароля сейфа присылает все перешифрованные записи одним запросом. Верхним уровнем могут быть только объект или массив (`strict: true`).\n\n**Загрузка файлов.** Файл отправляется отдельным запросом `multipart/form-data` с единственной частью `file`:\n\n| Назначение | Маршрут | Лимит на контейнер |\n|---|---|---|\n| Вложение задачи | `POST /boards/{id}/tasks/{taskId}/attachments` | 1 ГБ на задачу |\n| Файл личного чата | `POST /social/friends/{id}/files` | 1 ГБ на переписку |\n| Медиа статьи Wiki | `POST /wiki/pages/{id}/files` | 1 ГБ на статью |\n\nПравила загрузки:\n- Ответ содержит `id` файла. Вложение задачи и файл чата затем прикрепляются к комментарию или сообщению через `attachmentIds` (не больше 10). Медиа Wiki сразу принадлежит статье, но читателям без прав редактирования оно доступно, только если на него ссылается содержимое статьи.\n- Один файл — до 100 MiB. Больше — 413 `File too large`.\n- Пустой файл (0 байт) отклоняется: 400 «Файл пустой. Выберите файл с содержимым».\n- Вторая файловая часть, лишние текстовые поля или часть с другим именем дают 400.\n- Имя файла берётся из параметра `filename` заголовка части и разбирается как UTF-8 (`defParamCharset: \"utf8\"`), поэтому кириллица («Отчёт.pdf») сохраняется как есть; `filename*=UTF-8''…` (RFC 5987) тоже поддерживается. Сервер нормализует имя в NFC, обрезает пробелы по краям, заменяет на `_` управляющие символы, `/`, `\\` и символы управления направлением текста (U+202A–U+202E, U+2066–U+2069) и обрезает имя до 200 символов (по кодовым точкам, а не UTF-16). Если имя пустое, подставляется `attachment`, `file` или `media` в зависимости от маршрута.\n- Объявленный клиентом MIME-тип игнорируется.\n\n**Определение типа.** Тип определяется по сигнатуре содержимого:\n\n| Тип | Как распознаётся |\n|---|---|\n| Изображения | PNG, JPEG, GIF, WebP |\n| PDF | сигнатура `%PDF-` |\n| Видео | `video/mp4` (контейнер ISO BMFF, `ftyp`), `video/webm` (EBML) |\n| Аудио | `audio/mpeg` (ID3 или кадр MPEG) |\n| Документы Office и ZIP | ZIP-сигнатура плюс расширение `docx`, `xlsx`, `pptx`, `odt`, `ods`, `zip`; OLE2-сигнатура плюс `doc`, `xls`, `ppt` |\n| Текст | расширение `txt`, `md`, `csv`, `json`, если содержимое — корректный UTF-8 без управляющих символов, кроме табуляции и переводов строк |\n\nВсё остальное, например SVG, HTML и исполняемые файлы, отклоняется с 400. Текст ошибки зависит от маршрута. Пустой запрос без файла даёт 400 «Выберите файл».\n\n**Скачивание.** `GET` по адресу файла отдаёт байты с сохранённым MIME-типом и заголовками:\n- `Cache-Control: private, no-store`, `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; sandbox`;\n- `Content-Disposition: inline` для изображений, видео и аудио, а для PDF — только с `?preview=1`. Остальное отдаётся как `attachment`. `?download=1` всегда даёт `attachment`. Имя передаётся в `filename*=UTF-8''…`;\n- `Accept-Ranges: bytes`. Поддерживается один диапазон `Range: bytes=начало-конец`, `bytes=начало-` или `bytes=-N` с ответом 206 и `Content-Range`. Некорректный или неудовлетворимый диапазон, а также несколько диапазонов дают 416 с `Content-Range: bytes */<размер>`.\n\n**Хранение.** Зависит от `STORAGE_DRIVER`:\n- `database` (по умолчанию) — байты лежат в PostgreSQL;\n- `s3` — каждый файл шифруется AES-256-GCM ключом сервера `STORAGE_ENCRYPTION_KEY`. Конверт: метка `MDX1`, 12 байт IV, 16 байт тега, затем шифротекст; ID файла входит в AAD. Объект записывается как `application/octet-stream`. Объекты, не привязанные к записи за час, удаляются фоновой очисткой.\n\nВ обоих режимах файл при скачивании целиком читается в память и только потом отдаётся.\n\n### Пагинация\n\nКурсоры есть только у лент, которые растут без ограничений:\n\n| Маршрут | Курсор в query | Размер страницы | Ответ |\n|---|---|---|---|\n| `GET /boards/{board}/tasks/{task}/comments` | `before` — ID корневого комментария | 30 | `{ items, hasOlder }`, внутри страницы — от старых к новым |\n| `GET /boards/{board}/tasks/{task}/comments/{comment}/thread` | `before` или `after` — ID ответа; оба сразу — 400 | 50 | `{ root, items, hasOlder, hasNewer }` |\n| `GET /team/{space}/activity`, `GET /boards/{id}/activity` | `before` — значение `nextCursor` вида `<ISO-время>~<UUID>` (время с микросекундами) | 50 | `{ items, nextCursor }`; `nextCursor: null` — конец ленты |\n| `GET /social/friends/{id}/messages` | `before` — поле `cursor` самого старого полученного сообщения (`<ISO-время>~<UUID>`); для старых клиентов принимается и момент времени ISO 8601 — тогда возвращаются сообщения строго раньше него | 100 | по умолчанию массив от старых к новым; с `?format=page` — `{ items, hasOlder, nextCursor }` |\n| `GET /notes` | `before` — поле `cursor` последней заметки страницы (принимается и её `id`) | 300 | массив; страница короче 300 — последняя |\n\nКурсор `<время>~<id>` указывает на позицию в порядке «время, затем id» с полной точностью PostgreSQL (микросекунды), поэтому записи с одинаковым временем на границе страниц не теряются и не повторяются. Неверный курсор даёт 400.\n\nОстальные списки возвращаются одним ответом, часто с фиксированным потолком:\n\n| Список | Потолок |\n|---|---|\n| `GET /team/{space}/tasks`, `GET /team/{space}/backlog` | 500 записей; при обрезке `truncated: true` |\n| `GET /wiki/spaces/{space}` | 500 статей; при обрезке `truncated: true` |\n| `GET /wiki/public` | 500 статей; при обрезке заголовок ответа `X-Truncated: true` (тело остаётся массивом) |\n| `GET /planner` | 300 записей |\n| `GET /planner/today` | 200 задач в каждом из четырёх списков |\n| `GET /notifications`, `GET /people`, участники задачи для упоминаний, журнал сейфа организации, комментарии и события в карточке задачи | 100 записей |\n| версии статьи Wiki, спринты в обзоре TEAM | 50 записей |\n| история монет в `GET /motivation` | 30 записей |\n| цепочки агентов задачи | 20 записей |\n\nПотолок сам по себе не сообщает, что данные обрезаны. Если флага `truncated` (или заголовка `X-Truncated`) нет, считайте ответ полным только когда записей меньше потолка. В планере, уведомлениях, задачах TEAM и списке участников для упоминаний права проверяются в SQL до `LIMIT`, поэтому недоступные записи не «съедают» потолок.\n\n### Кеширование и заголовки безопасности\n\n- Каждый ответ API, включая ошибки и файлы, несёт `Cache-Control: private, no-store`. Кешировать ответы на стороне клиента или прокси не предполагается.\n- `helmet()` с настройками по умолчанию добавляет `Content-Security-Policy` (`default-src 'self'; …`), `Strict-Transport-Security: max-age=31536000; includeSubDomains`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: no-referrer`, `Cross-Origin-Opener-Policy: same-origin`, `Cross-Origin-Resource-Policy: same-origin`, `Origin-Agent-Cluster: ?1`, `X-DNS-Prefetch-Control: off`, `X-Download-Options: noopen`, `X-Permitted-Cross-Domain-Policies: none`, `X-XSS-Protection: 0`. Заголовок `X-Powered-By` удаляется.\n- Для скачиваемых файлов политика CSP заменяется на `default-src 'none'; sandbox`. Активное содержимое файла не выполняется даже при открытии во вкладке.\n- JSON-ответы формирует Express. С настройками по умолчанию он добавляет к ним слабый `ETag` и на совпадающий `If-None-Match` в `GET` может ответить 304. Файлы отдаются без `ETag`.\n\n## Realtime (WebSocket)\n\nСервер не передаёт данные по сокету: он присылает только **сигналы инвалидации** — «в таких-то областях что-то изменилось». Получив сигнал, клиент заново запрашивает нужные данные через REST, и там действуют обычные проверки прав. В событии нет названий, текстов или ID (это проверяет тест `apps/api/test/realtime.test.ts`), поэтому подписка не может раскрыть содержимое.\n\nРеализация: `apps/api/src/realtime.ts` (Socket.IO 4, сервер `RealtimeService`, подключается к тому же HTTP-серверу, что и API).\n\n### Подключение\n\n| Параметр | Значение | Где в коде |\n|---|---|---|\n| URL | хост API, путь **`/api/v1/realtime`**, пространство имён по умолчанию `/` | `realtime.ts:34` |\n| Транспорт | **только `websocket`**: HTTP long-polling отключён, клиент обязан указать `transports: [\"websocket\"]` | `realtime.ts:35` |\n| Клиентская библиотека | сервер её не раздаёт (`serveClient: false`), используйте пакет `socket.io-client` | `realtime.ts:36` |\n| Максимальный размер входящего сообщения | **16 384 байта** (`maxHttpBufferSize`); при превышении Socket.IO закрывает соединение | `realtime.ts:37` |\n| Тайм-аут подключения | 10 000 мс (`connectTimeout`) | `realtime.ts:38` |\n| Подключений на пользователя | не больше **12** одновременно на один процесс API | `realtime.ts:67-70` |\n| Повторная проверка сессии | каждые **30 секунд**, а также перед каждой доставкой события и при каждом `watch` | `realtime.ts:114-118` |\n\n### Аутентификация рукопожатия\n\nMiddleware (`realtime.ts:40-79`) выполняет проверки по порядку:\n\n1. **Origin.** Если заголовок `Origin` есть, он должен точно совпадать с одним из адресов в `WEB_ORIGIN` (список через запятую, по умолчанию `http://localhost:3000`). Иначе — `Origin not allowed`.\n2. **Origin или токен.** Если нет ни `Origin`, ни `auth.token`, подключение отклоняется с `Origin required`. Браузер всегда шлёт `Origin`; клиенту вне браузера (Node, десктоп, мобильный) нужен токен.\n3. **Формат токена.** `auth.token`, если передан, — строка не длиннее 200 символов. Иначе — `Unauthorized`.\n4. **Источник токена доступа.** Сервер собирает «запрос» только из двух частей рукопожатия:\n   - `auth.token` из `io(url, { auth: { token } })` — используется как `Authorization: Bearer <token>` и **имеет приоритет**;\n   - иначе — access-cookie из заголовка `Cookie`: `__Host-md_access` при `NODE_ENV=production`, `md_access` в остальных окружениях (`security.ts:5-8`).\n\n   Заголовок `Authorization` рукопожатия (например, из `extraHeaders`) **не читается**. Refresh-cookie для сокета не используется.\n5. **Сессия.** Та же проверка, что и в REST (`AuthService.user`): в `sessions` есть строка с этим access-токеном, у которой `access_expires > now()` и `refresh_expires > now()`.\n6. **Лимит подключений.** Если у пользователя уже 12 сокетов в этом процессе — `Too many connections`.\n\nЛюбая другая ошибка превращается в `Unauthorized`. Сообщение приходит клиенту в событии `connect_error`:\n\n| `error.message` | Причина | Что делать клиенту |\n|---|---|---|\n| `Origin not allowed` | `Origin` не входит в `WEB_ORIGIN` | исправить адрес приложения или конфигурацию сервера |\n| `Origin required` | нет ни `Origin`, ни `auth.token` | передать `auth.token` |\n| `Unauthorized` | нет или истёк токен, нет сессии | обновить токены (`POST /api/v1/auth/refresh`) и подключиться заново |\n| `Too many connections` | открыто 12 подключений | закрыть лишние вкладки или сокеты |\n\n> **Сокет запоминает токен из рукопожатия.** Access-токен живёт 15 минут (`access_expires = now() + 15 minutes`, `auth.ts:122`), а при обновлении старая строка сессии удаляется (`auth.ts:108-114`). Поэтому самое позднее через ~15 минут, или сразу после refresh/выхода (в том числе в другой вкладке), сокет получит `session_expired`. Клиент должен убедиться, что сессия жива (при необходимости обновить токены), и переподключиться: в cookie-режиме браузер пришлёт новую cookie, в Bearer-режиме передайте новый `auth.token`.\n\n### Проверка сессии и отзыв доступа\n\n- **Сессия.** Раз в 30 секунд, перед каждой доставкой `changed` и при каждом `watch` сервер заново проверяет сохранённый токен. Если сессия недействительна, сервер отправляет `session_expired` и сразу разрывает соединение (`disconnect(true)`, `realtime.ts:121-130`). Разрыв по инициативе сервера (`io server disconnect`) `socket.io-client` сам не восстанавливает: вызовите `socket.connect()` после обновления токенов. Отклонение в middleware (`connect_error`) тоже не переподключается автоматически.\n- **Доски и организации.** Перед доставкой каждой пачки событий для каждой доски и организации из подписок сокета, к которым относятся события, сервер заново проверяет доступ: `AccessService.board` (любая роль на доске) и `AccessService.workspace` (членство с любой ролью). Если доступ отозван, подписка тихо удаляется (`realtime.ts:187-210`). Текущая пачка всё равно приходит **последним** `changed`: клиент перезапрашивает данные, получает `404` и закрывает экран. Дальнейшие события этой доски или организации сокету уже не доставляются.\n- **Личные события** (адресованные через `userId`/`users`, см. ниже) повторно по ACL не проверяются: адресат определяется триггером в БД.\n\n### События клиент → сервер\n\nСервер обрабатывает только одно событие — `watch`: подписку на доски и организации.\n\n```ts\n// Схема payload (realtime.ts:90-95)\nz.object({\n  boards: z.array(z.uuid()).max(30).default([]),\n  workspaces: z.array(z.uuid()).max(10).default([]),\n});\n// Подтверждение (ack): { ok: true } | { ok: false }\n```\n\n- Каждый `watch` **полностью заменяет** набор подписок. Пустые массивы отменяют все подписки.\n- Без `watch` сокет получает только личные события: уведомления, друзья и события, где пользователь указан в `userId`.\n- `{ ok: false }` приходит, если:\n  - предыдущий `watch`, прошедший этот фильтр частоты, был меньше 150 мс назад — вызов игнорируется (веб-клиент поэтому откладывает `watch` на 180 мс);\n  - сессия недействительна;\n  - payload не проходит схему: не объект, больше 30 досок или 10 организаций, не UUID;\n  - нет доступа хотя бы к одной доске или организации.\n\n  В любом из этих случаев **прежние подписки сохраняются** без изменений.\n- Ack необязателен; без функции подтверждения ошибки не видны.\n\n### События сервер → клиент\n\n| Событие | Payload | Когда |\n|---|---|---|\n| `changed` | `{ areas: string[] }` | после пачки изменений в БД (см. ниже) и при (пере)подключении сервера к PostgreSQL |\n| `session_expired` | — | сессия больше недействительна; сразу за ним сервер разрывает соединение |\n| `connect_error` (стандартное) | `Error` с `message` из таблицы выше | отказ в рукопожатии |\n\nИзменения копятся **100 мс**, одинаковые payload схлопываются, и каждый сокет получает не больше одного `changed` на пачку (`realtime.ts`, обработчик `notification`).\n\nВеб-клиент дополнительно копит сигналы **400 мс** и вызывает каждого подписчика один раз на пачку с объединением областей (`apps/web/app/realtime.ts`, `schedule`/`flush`). Перемещение карточки, которое пишет задачу, журнал и доску, приводит к одному перезапросу на экран, а не к трём. Сторонним клиентам стоит делать так же.\n\nКогда сервер устанавливает (или восстанавливает) `LISTEN` в PostgreSQL, он рассылает **всем** подключённым сокетам `changed` со всеми восемью областями: события за время разрыва могли потеряться (`realtime.ts:159-171`). Если соединение с БД потеряно, повторная попытка — через 3 секунды.\n\nПропущенные во время разрыва WebSocket события сервер не досылает. После каждого `connect` клиенту стоит перезапросить видимые данные — так делает веб-клиент (`apps/web/app/realtime.ts`, обработчик `connect`).\n\n### Восстановление после `session_expired`\n\nВеб-клиент (`apps/web/app/realtime.ts`, функция `recover`) восстанавливается тихо, без перезагрузки страницы и без мигания индикатора:\n\n1. На `session_expired` (и на `connect_error` с `Unauthorized`) он вызывает обычный `GET /api/v1/auth/me`. Если access-cookie истекла, этот запрос получает 401, и общий клиент API (`apps/web/app/lib.ts`) выполняет **один** `POST /api/v1/auth/refresh` на все параллельные запросы страницы, затем повторяет `/auth/me`.\n2. Если сессия жива (`/auth/me` ответил успешно), сокет переподключается один раз.\n3. Если `/auth/me` всё равно вернул 401, сессии нет: сокет остаётся закрытым, а обычная обработка 401 в приложении ведёт на вход.\n4. Повторная попытка восстановления — не раньше чем через 5 секунд.\n\nПочему не отдельный refresh ради сокета: cookie, которые уже обновила другая вкладка, подходят как есть, а лишняя ротация разорвала бы сокет той вкладки, и вкладки начали бы «перебрасывать» сессию друг другу. Кроме того, из двух параллельных refresh с одной cookie один получает 401, и это могло бы разлогинить страницу. Повтор старого refresh-токена позже чем через 30 секунд после ротации сервер считает кражей и закрывает все сессии пользователя (см. «Аутентификация»), поэтому refresh должен быть один на клиент.\n\n### Области (areas) и что перезапрашивать\n\nСервер переводит `source` (и для журнала — `scope`) события в области функцией `areasFor` (`realtime.ts`):\n\n| `source` | `scope` | Области в `changed` |\n|---|---|---|\n| `notifications` | — | `notifications` |\n| `direct_messages`, `friendships`, `mood_entries` | — | `friends` |\n| `boards` (строка доски, реакция на комментарий) | — | `boards`, `tasks`, `team` |\n| `agent_steps` | — | `agents` |\n| `workspace_members` | — | `boards`, `tasks`, `team`, `wiki`, `agents`: вступление, выход и смена роли меняют доступ ко всему |\n| `workspace_events` | `task`, `board` | `boards`, `tasks`, `team` |\n| `workspace_events` | `wiki` | `wiki` |\n| `workspace_events` | `team`, `admin`, `backlog`, `sprint`, `project` | `team` |\n| любой другой, а также `workspace_events` без `scope` | — | все пять командных областей |\n\n> **Изменение с миграции 027.** Раньше любое командное событие приносило все пять областей сразу, и правка Wiki перезагружала доски, карточки и TEAM. Названия областей не изменились, поэтому клиент, подписанный на широкий набор (например, `boards`+`tasks`+`team`+`wiki`+`agents`), по-прежнему получает все относящиеся к нему события — просто без лишних. Событие без `scope` (старый триггер, пока миграция не применена) обрабатывается по-старому.\n\nКлиенту стоит перезапрашивать только экраны, открытые сейчас:\n\n| Область | Что перезапросить |\n|---|---|\n| `boards` | `GET /api/v1/boards`, `GET /api/v1/boards/{id}` |\n| `tasks` | карточка `GET /api/v1/boards/{id}/tasks/{taskId}`, обсуждение `GET …/comments` и `GET …/comments/{comment}/thread` |\n| `team` | `GET /api/v1/team/{space}`, `…/tasks`, `…/backlog`, `…/activity`, `GET /api/v1/boards/{id}/activity` |\n| `wiki` | `GET /api/v1/wiki/spaces/{space}`, `GET /api/v1/wiki/pages/{id}` |\n| `agents` | `GET /api/v1/boards/{board}/tasks/{task}/agents` |\n| `friends` | `GET /api/v1/social/friends`, открытая переписка `GET /api/v1/social/friends/{id}/messages` |\n| `notifications` | `GET /api/v1/notifications` (счётчик и лента) |\n\n### Как изменения в БД доходят до получателей\n\nТриггеры PostgreSQL вызывают `pg_notify('metodox_changes', <json>)`. API держит отдельное соединение с `LISTEN metodox_changes`. Payload:\n\n```json\n{ \"source\": \"workspace_events\", \"scope\": \"task|board|backlog|sprint|project|wiki|team|admin|null\", \"boardId\": \"uuid|null\", \"workspaceId\": \"uuid|null\", \"userId\": \"uuid|null\", \"users\": [\"uuid\", \"uuid\"] }\n```\n\n`scope` заполняется только для `workspace_events` (миграция 027) и до клиента не доходит: по нему сервер выбирает области.\n\nСокет считается получателем события, если выполнено **хотя бы одно** условие (`realtime.ts`, `broadcast`):\n\n- `userId` — это пользователь сокета;\n- пользователь сокета есть в массиве `users`;\n- `boardId` есть в подписках сокета (`watch.boards`);\n- `workspaceId` есть в подписках сокета (`watch.workspaces`).\n\nЗатем проверяются сессия и права (см. выше), и сокету отправляется `changed` с объединением областей всех подходящих событий.\n\nКакие таблицы порождают события (миграции 011, 014, 016, 020, 027):\n\n| Таблица (триггер) | Операции | `source` | `boardId` | `workspaceId` | `userId` | `users` | Области |\n|---|---|---|---|---|---|---|---|\n| `workspace_events` (`realtime_workspace_events`) | INSERT | `workspace_events` + `scope` | `board_id` | `workspace_id` | `actor_id` | — | по `scope`, см. выше |\n| `boards` (`realtime_boards`) | INSERT, UPDATE, DELETE | `boards` | `id` | `workspace_id` | `owner_id` | — | `boards`, `tasks`, `team` |\n| `agent_steps` (`realtime_agents`) | INSERT, UPDATE | `agent_steps` | доска задачи workflow | организация доски | владелец доски | — | `agents` |\n| `workspace_members` (`realtime_members`) | INSERT, UPDATE, DELETE | `workspace_members` | — | `workspace_id` | `user_id` участника | — | 5 командных |\n| `message_reactions` на комментарий (`realtime_reactions`) | INSERT, DELETE | `boards` | доска задачи | организация доски | владелец доски | — | `boards`, `tasks`, `team` |\n| `notifications` (`realtime_notifications`) | INSERT, UPDATE, DELETE | `notifications` | — | — | `user_id` | — | `notifications` |\n| `direct_messages` (`realtime_direct_messages`) | INSERT | `direct_messages` | — | — | — | оба участника дружбы | `friends` |\n| `message_reactions` на личное сообщение (`realtime_reactions`) | INSERT, DELETE | `direct_messages` | — | — | — | оба участника дружбы | `friends` |\n| `friendships` (`realtime_friendships`) | INSERT, UPDATE, DELETE | `friendships` | — | — | — | `requester_id`, `recipient_id` | `friends` |\n| `mood_entries` (`realtime_moods`) | INSERT, UPDATE | `mood_entries` | — | — | владелец записи | все принятые друзья владельца | `friends` |\n\n«5 командных» — `boards`, `tasks`, `team`, `wiki`, `agents`.\n\nОткуда берутся `workspace_events`:\n\n- каждая запись в `task_events` (создание и изменение задач, комментарии `commented`, шаги агентов и т.д.) зеркалируется в `workspace_events` триггером `mirror_workspace_task_event` (миграция 011), в том числе для личных досок без организации (`workspace_id = NULL`);\n- функция `teamEvent()` (`team-audit.ts`) пишет события из `boards.ts`, `projects.ts`, `team.ts`, `wiki.ts`, `workspaces.ts`.\n\nСами по себе **не порождают** событий: вставки в `comments` (сигнал идёт через `task_events`), `attachments`, `direct_files` (файл виден собеседнику только после сообщения, а оно порождает событие), удаление `direct_messages` (только каскадом вместе с `friendships`, о чём сообщает триггер `friendships`).\n\nКому событие приходит через `userId`: для `boards`, `agent_steps` и реакций на комментарии — владельцу доски, для `workspace_events` — автору действия. Остальные участники доски получают её события только через подписку `watch`.\n\n### Пример: браузер, cookie-сессия\n\nВеб-клиент работает так же (`apps/web/app/realtime.ts`). Браузер сам отправляет access-cookie и `Origin`, который должен входить в `WEB_ORIGIN`.\n\n```js\nimport { io } from \"socket.io-client\";\n\n// В продакшене API и сайт на одном origin; в разработке API слушает http://localhost:4000.\nconst socket = io(window.location.origin, {\n  path: \"/api/v1/realtime\",\n  transports: [\"websocket\"],\n  withCredentials: true,\n  autoConnect: false,\n});\n\nlet subscriptions = { boards: [\"8f1c2d4e-1a2b-4c3d-8e9f-0123456789ab\"], workspaces: [] };\n\nsocket.on(\"connect\", async () => {\n  try {\n    const ack = await socket.timeout(2000).emitWithAck(\"watch\", subscriptions);\n    if (!ack.ok) console.warn(\"Подписка отклонена: нет доступа или слишком частые вызовы\");\n  } catch {\n    console.warn(\"Сервер не подтвердил подписку\");\n  }\n  refetchVisibleScreens(); // события за время разрыва не досылаются\n});\n\nsocket.on(\"changed\", ({ areas }) => {\n  if (areas.includes(\"tasks\")) refetchTask();\n  if (areas.includes(\"notifications\")) refetchNotifications();\n  if (areas.includes(\"friends\")) refetchFriends();\n});\n\n// apiFetch — общий клиент API страницы: на 401 он выполняет ОДИН общий\n// POST /api/v1/auth/refresh для всех параллельных запросов и повторяет запрос.\nlet recovering = false, lastRecovery = 0;\nasync function recover() {\n  if (recovering || Date.now() - lastRecovery < 5000) return;\n  recovering = true;\n  try {\n    // Проверяем сессию обычным запросом, а не отдельным refresh: cookie, обновлённые\n    // другой вкладкой, подходят как есть.\n    const r = await apiFetch(\"/api/v1/auth/me\");\n    if (r.ok) socket.connect();\n    else if (r.status === 401) redirectToLogin();\n  } catch {\n    // Нет сети: рукопожатие при следующей попытке socket.io повторит само.\n  } finally {\n    recovering = false;\n    lastRecovery = Date.now();\n  }\n}\n\nsocket.on(\"session_expired\", recover);\nsocket.on(\"connect_error\", (error) => {\n  if (error.message === \"Unauthorized\") recover();\n});\n\nsocket.connect();\n```\n\n### Пример: Bearer-токен (десктоп, мобильный клиент, Node)\n\nТокены выдаёт `POST /api/v1/auth/login` с `\"client\": \"desktop\"` — в ответе есть `user`, `accessToken`, `refreshToken`, `expiresIn: 900`, `tokenType: \"Bearer\"`. Обновляет их `POST /api/v1/auth/refresh` с телом `{ \"client\": \"desktop\", \"refreshToken\": \"…\" }`. Токен передаётся **только** через `auth.token`; функция в `auth` вызывается при каждом подключении, поэтому после refresh уйдёт новый токен.\n\n```js\nimport { io } from \"socket.io-client\";\n\nconst API = \"https://metodox.example\"; // хост API\nlet tokens = await login(); // { accessToken, refreshToken } из POST /api/v1/auth/login, client: \"desktop\"\n\nconst socket = io(API, {\n  path: \"/api/v1/realtime\",\n  transports: [\"websocket\"],\n  auth: (cb) => cb({ token: tokens.accessToken }),\n  autoConnect: false,\n});\n\n// Один refresh на весь клиент: его же вызывает REST-слой при 401.\nlet refreshing = null;\nfunction refresh() {\n  refreshing ??= (async () => {\n    const r = await fetch(API + \"/api/v1/auth/refresh\", {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify({ client: \"desktop\", refreshToken: tokens.refreshToken }),\n    });\n    if (!r.ok) throw new Error(\"Сессия завершена, нужен повторный вход\");\n    tokens = await r.json(); // сохраните пару сразу: старый refresh-токен больше недействителен\n  })().finally(() => (refreshing = null));\n  return refreshing;\n}\n\nasync function reconnect() {\n  // REST-слой мог уже обновить токены: тогда сокету достаточно переподключиться\n  // с новым accessToken. Лишний refresh здесь не нужен.\n  const me = await fetch(API + \"/api/v1/auth/me\", {\n    headers: { Authorization: \"Bearer \" + tokens.accessToken },\n  });\n  if (me.status === 401) await refresh();\n  socket.connect();\n}\n\nsocket.on(\"connect\", () => {\n  socket.emit(\"watch\", { boards: [], workspaces: [\"2b7c9e10-4d3f-4a8b-9c1d-5e6f7a8b9c0d\"] }, (ack) => {\n    if (!ack.ok) console.warn(\"Подписка отклонена\");\n  });\n});\nsocket.on(\"changed\", ({ areas }) => console.log(\"Обновить:\", areas));\nsocket.on(\"session_expired\", () => void reconnect().catch(showLogin));\nsocket.on(\"connect_error\", (error) => {\n  if (error.message === \"Unauthorized\") void reconnect().catch(showLogin);\n});\n\nsocket.connect();\n```\n\nЕсли клиент работает в браузере, `Origin` всё равно проверяется и должен входить в `WEB_ORIGIN`. У Node-клиента без `Origin` достаточно токена.\n\n## Модель данных\n\nСхема `public` складывается из базового файла `apps/api/src/schema.sql` (users, sessions, boards, columns, tasks) и нумерованных миграций `002`–`031` из `apps/api/src/migrations/` (номеров `025` и `026` нет). Их применяет `apps/api/src/migrate.ts` в одной транзакции под `pg_advisory_xact_lock(729041)`; имена применённых файлов записываются в `schema_migrations`. ORM нет — NestJS API работает с таблицами SQL-запросами через `pg`. Первичные ключи сущностей — `uuid`, который генерирует приложение (`randomUUID()`), значений по умолчанию для `id` в БД нет; исключения — текстовые коды каталогов (`achievement_catalog`, `cosmetic_catalog`), составные PK таблиц связей и PK `user_id` у таблиц «одна строка на пользователя». Моменты времени — `timestamptz` с `DEFAULT now()`, календарные дни — `date`, как правило в часовом поясе пользователя (`users.timezone`).\n\nКолонка `version integer` (`tasks`, `notes`, `vault_records`, `team_backlog`, `team_sprints`, `workspace_projects`, `wiki_pages`) — оптимистичная блокировка: клиент присылает ожидаемую версию, API сравнивает её в `UPDATE … WHERE version=$n` или после `SELECT … FOR UPDATE` и при расхождении отвечает 409; `workspace_secrets.version` (растёт триггером `workspace_secret_version` при изменении секрета и явно при смене выдач) фиксируется в `agent_steps.secret_version` и сверяется перед SSH-операцией. Удаление почти везде физическое с `ON DELETE CASCADE`/`SET NULL`; мягкое удаление — только корзина заметок (`notes.deleted_at`), «надгробия» удалённых комментариев (`comments.deleted_at`: строка остаётся, чтобы ответы в ветке сохранили корень) и архивы (`team_backlog.archived_at`, `workspace_projects.archived_at`, статус `archived` у `wiki_pages`). Журналы `workspace_events` (`board_id`, `task_id`, `entity_id`) и `completion_records.task_id` намеренно без внешних ключей, чтобы история переживала удаление досок и задач.\n\nСерверные секреты шифруются AES-256-GCM конвертом `MDX1 | iv(12) | tag(16) | ciphertext` (`server-crypto.ts`) с AAD-контекстом, привязанным к строке: `ai_employees.api_key_cipher` — `AI_SECRET_KEY`, `mail_outbox.payload` — `MAIL_ENCRYPTION_KEY`, `workspace_secrets.cipher` — `WORKSPACE_SECRET_KEY`, объекты файлов в S3 — `STORAGE_ENCRYPTION_KEY` (байты в `bytea` при `storage='database'` приложением не шифруются). `vault_configs`/`vault_records` шифрует браузер (E2EE: PBKDF2-SHA256, 600 000 итераций, AES-GCM-256); сервер ключа не знает. Токены сессий и ссылок хранятся только как SHA-256, пароли — scrypt. Realtime: триггеры `metodox_realtime_event` и `reaction_changed` вызывают `pg_notify('metodox_changes', json)` только с идентификаторами (`source`, `boardId`, `workspaceId`, `userId`, `users`); `RealtimeService` (`realtime.ts`) слушает канал, перепроверяет права и отправляет по Socket.IO (`/api/v1/realtime`) событие `changed` со списком областей для перезагрузки. Изменения задач и комментариев попадают в realtime через `task_events` → зеркало в `workspace_events`.\n\nСхема: 57 таблиц, 408 колонок, миграции до `032_accent_colors.sql`.\n\n### Аккаунты и сессии\n\n`users` — единая таблица людей и ИИ-сотрудников (`account_kind`). Вход создаёт строку `sessions` с хешами access- (15 минут) и refresh-токена (30 дней); обновление удаляет старую сессию и выдаёт новую (ротация), а хеш использованного refresh-токена переносит в `rotated_refresh_tokens`. Повтор такого токена позже 30 секунд после ротации считается кражей и закрывает все сессии пользователя. Сброс пароля удаляет все сессии пользователя, смена пароля — все, кроме текущей. `auth_tokens` хранит одноразовые токены писем подтверждения email и сброса пароля; выдаются только при `MAIL_ENABLED=true` и погашаются атомарным `DELETE … RETURNING`. Просроченные строки `sessions`, `auth_tokens`, `invitations` и `rotated_refresh_tokens` раз в час удаляет `CleanupService` (`cleanup.ts`).\n\n| Таблица | Назначение |\n|---|---|\n| [db.users](#модели/dbusers) | Учётная запись человека (регистрация в `auth.ts`) или ИИ-сотрудника (создаётся в `ai.ts` с `account_kind='ai'`, служебным email и случайным паролем; вход для неё запрещён). |\n| [db.sessions](#модели/dbsessions) | Сессия входа: пара токенов access (15 минут) и refresh (30 дней), в БД — только их SHA-256. |\n| [db.rotated_refresh_tokens](#модели/dbrotated-refresh-tokens) | Журнал обменянных refresh-токенов для обнаружения их повторного использования (миграция 028). |\n| [db.auth_tokens](#модели/dbauth-tokens) | Одноразовые токены ссылок из писем. |\n\n### Организации и доступ\n\nОрганизация (`workspaces`) создаётся вместе с членством создателя в роли `owner`. Новые участники приходят через `invitations` по email: принятие (нужен подтверждённый email) удаляет приглашение и добавляет `workspace_members`. Роль организации задаёт базовый доступ: `owner`/`admin` — администраторы всех досок организации, `member` видит доски с `visibility='workspace'`, `guest` — только явно выданные доски. При удалении участника API снимает его с `board_members`, `task_members` и с исполнителей задач организации.\n\n| Таблица | Назначение |\n|---|---|\n| [db.workspaces](#модели/dbworkspaces) | Организация (команда). |\n| [db.workspace_members](#модели/dbworkspace-members) | Членство и роль пользователя (человека или ИИ-сотрудника) в организации. |\n| [db.invitations](#модели/dbinvitations) | Ожидающее приглашение по email. |\n\n### Доски и задачи\n\nДоска (`boards`) бывает личной (`workspace_id IS NULL`, доступ только у владельца) или доской организации, при желании привязанной к проекту и этапу. Колонки (`columns`) упорядочены по `position`; последняя — завершающая. Задачи (`tasks`) ссылаются на колонку той же доски составным FK `(column_id, board_id)`. Права на задачу вычисляет `AccessService`: роль на доске (`board_members` или роль организации), затем видимость задачи (`board`/`restricted`) и индивидуальные роли `task_members`.\n\n| Таблица | Назначение |\n|---|---|\n| [db.boards](#модели/dbboards) | Канбан-доска. |\n| [db.board_members](#модели/dbboard-members) | Явные роли на досках организации. |\n| [db.columns](#модели/dbcolumns) | Колонки доски. |\n| [db.tasks](#модели/dbtasks) | Задача на доске; колонка обязана принадлежать той же доске (составной FK). |\n| [db.task_counters](#модели/dbtask-counters) | Счётчик номеров задач по доскам: одна строка на доску с последним выданным номером. |\n| [db.task_members](#модели/dbtask-members) | Индивидуальный доступ к задаче — прежде всего к задачам с `visibility='restricted'`. |\n\n### Граф задач, история и планирование\n\n`task_links` строит граф подзадач и блокировок внутри доски; API запрещает циклы и не даёт завершить задачу, пока не завершены её подзадачи и блокирующие задачи. Любое изменение задачи пишет `task_events`, а триггер `mirror_workspace_task_event` копирует событие в общую ленту `workspace_events`, куда также пишет `teamEvent()` (доски, организации, бэклог, спринты, проекты, Wiki). `task_plans` — личный недельный план пользователя, `completion_records` — журнал завершений для серий, достижений и тепловой карты.\n\n| Таблица | Назначение |\n|---|---|\n| [db.task_links](#модели/dbtask-links) | Связи между задачами одной доски (составные FK на `tasks(id, board_id)`). |\n| [db.task_events](#модели/dbtask-events) | История задачи (вкладка «История» в карточке). |\n| [db.workspace_events](#модели/dbworkspace-events) | Единая лента активности TEAM и истории доски. |\n| [db.task_plans](#модели/dbtask-plans) | Личный недельный планировщик: пользователь ставит доступную ему задачу на конкретный день и оценивает время. |\n| [db.completion_records](#модели/dbcompletion-records) | Журнал завершений для серий, достижений, тепловой карты и планировщика. |\n\n### Обсуждения, реакции и вложения задач\n\nКомментарии (`comments`) образуют ветки: триггер `comment_thread` вычисляет `root_id` по `reply_to`. Автор может изменить свой комментарий в течение 24 часов (`edited_at`); удаление оставляет строку «надгробием» (`deleted_at`, пустой текст) без вложений, реакций и упоминаний, чтобы ответы ветки сохранили своё место. Явные упоминания профиля сохраняются в `comment_mentions` и дают уведомления `mention`. `message_reactions` — общие эмодзи-реакции для комментариев и личных сообщений. Файлы задачи (`attachments`) могут быть привязаны к комментарию; хранение в БД или S3, удаление ставит объект в очередь очистки.\n\n| Таблица | Назначение |\n|---|---|\n| [db.comments](#модели/dbcomments) | Комментарии к задаче, включая ответы ИИ-сотрудников и пересланные сообщения. |\n| [db.comment_mentions](#модели/dbcomment-mentions) | Пользователи, упомянутые в комментарии. |\n| [db.message_reactions](#модели/dbmessage-reactions) | Эмодзи-реакции на комментарий задачи или личное сообщение: заполнено ровно одно из `comment_id`/`message_id` (CHECK `num_nonnulls = 1`). |\n| [db.attachments](#модели/dbattachments) | Файлы задачи. |\n\n### Друзья и личные чаты\n\n`friendships` — заявка и дружба между двумя людьми с публичными профилями; принятая дружба открывает личный чат (`direct_messages`) и показ настроения дня. Вложения чата (`direct_files`) сначала загружаются черновиком (`message_id IS NULL`, видны только автору) и привязываются к сообщению при отправке; неотправленные черновики живут 24 часа и не входят в лимит переписки. Удаление дружбы каскадно удаляет переписку и её файлы.\n\n| Таблица | Назначение |\n|---|---|\n| [db.friendships](#модели/dbfriendships) | Связь двух людей: заявка (`pending`) от `requester_id` к `recipient_id`, принять может только получатель. |\n| [db.direct_messages](#модели/dbdirect-messages) | Сообщения личного чата между друзьями (только при `status='accepted'`). |\n| [db.direct_files](#модели/dbdirect-files) | Вложения личного чата. |\n\n### Файловое хранилище S3 (служебные таблицы)\n\nПри `STORAGE_DRIVER=s3` загрузка идёт в два шага: `storage_objects` резервирует ключ (`pending`), затем после записи зашифрованного объекта в бакет строка файла фиксируется и ключ помечается `attached`. Удаление файлов (`attachments`, `direct_files`, `wiki_files`) через триггер `queue_attachment_cleanup` кладёт ключ в `storage_cleanup`; туда же попадают ключи, застрявшие в `pending` дольше часа. Воркер `StorageService` раз в минуту удаляет до 50 объектов из бакета и затем строки обеих таблиц.\n\n| Таблица | Назначение |\n|---|---|\n| [db.storage_objects](#модели/dbstorage-objects) | Реестр S3-ключей для надёжной загрузки (используется только при `STORAGE_DRIVER=s3`). |\n| [db.storage_cleanup](#модели/dbstorage-cleanup) | Очередь удаления S3-объектов. |\n\n### Уведомления и почта\n\n`notifications` наполняется в транзакциях назначения исполнителя, комментариев и изменения срока задачи, а также фоновым генератором сроков `NotificationService` (раз в минуту под advisory lock; чтение списка уведомления не создаёт); дубли исключает `UNIQUE(user_id, dedup_key)`. Устаревшие напоминания, недоступные задачи и уведомления об удалённых комментариях отфильтровываются в SQL при чтении, до `LIMIT`. `mail_outbox` — транзакционная очередь писем (подтверждение email, сброс пароля, приглашения) с зашифрованным содержимым, которую отправляет `MailService`.\n\n| Таблица | Назначение |\n|---|---|\n| [db.notifications](#модели/dbnotifications) | Центр уведомлений внутри приложения. |\n| [db.mail_outbox](#модели/dbmail-outbox) | Транзакционная очередь писем (outbox): письмо ставится в той же транзакции, что и событие. |\n\n### Настроение дня\n\n`mood_entries` хранит одну оценку настроения на пользователя за локальный день; при `share_with_friends` её видят друзья. `task_mood_rewards` — отметка «радости» (1–5) за задачу, которую пользователь завершил сам.\n\n| Таблица | Назначение |\n|---|---|\n| [db.mood_entries](#модели/dbmood-entries) | Настроение дня: одна оценка на пользователя за локальный день, повторное сохранение перезаписывает её. |\n| [db.task_mood_rewards](#модели/dbtask-mood-rewards) | Отметка «радости от задачи»: насколько приятной (1–5) оказалась задача, которую пользователь завершил сам. |\n\n### Заметки\n\nЛичные заметки владельца с папками, закреплением и корзиной: удаление ставит `deleted_at`, восстановление очищает его, удаление из корзины (по одной или очисткой корзины) стирает строку навсегда. Каждая правка увеличивает `version`, конфликт правок из двух окон даёт 409.\n\n| Таблица | Назначение |\n|---|---|\n| [db.notes](#модели/dbnotes) | Личные заметки владельца. |\n\n### Мотивация, достижения и оформление\n\nЗавершение задачи (`recordCompletion`) начисляет человеку-получателю 10 монет и 10 XP через `coin_ledger` с идемпотентным `source_key` и пишет `completion_records`; затем `unlockAchievements` открывает достижения из `achievement_catalog`, награду за которые пользователь забирает отдельно (`user_achievements.claimed_at`). Монеты тратятся на предметы `cosmetic_catalog` (`user_cosmetics`), надетые предметы — по одному на слот в `equipped_cosmetics`. Баланс кэшируется в `users.coin_balance` и меняется в той же транзакции, что и журнал.\n\n| Таблица | Назначение |\n|---|---|\n| [db.coin_ledger](#модели/dbcoin-ledger) | Журнал движения монет. |\n| [db.achievement_catalog](#модели/dbachievement-catalog) | Справочник достижений. |\n| [db.user_achievements](#модели/dbuser-achievements) | Открытые пользователем достижения. |\n| [db.cosmetic_catalog](#модели/dbcosmetic-catalog) | Справочник оформления профиля, покупаемого за монеты. |\n| [db.user_cosmetics](#модели/dbuser-cosmetics) | Купленные предметы оформления. |\n| [db.equipped_cosmetics](#модели/dbequipped-cosmetics) | Надетое оформление: не более одного предмета на слот. |\n\n### Личное хранилище (E2EE)\n\nЛичное хранилище паролей и данных шифруется в браузере: ключ выводится из мастер-пароля (PBKDF2-SHA256, 600 000 итераций, соль из `vault_configs`), записи — AES-GCM-256 конверты в `vault_records`. Сервер хранит только соль, проверочный конверт, тип записи и шифротекст; восстановить данные без мастер-пароля невозможно.\n\n| Таблица | Назначение |\n|---|---|\n| [db.vault_configs](#модели/dbvault-configs) | Параметры личного зашифрованного хранилища (E2EE). |\n| [db.vault_records](#модели/dbvault-records) | Записи личного хранилища. |\n\n### Хранилище доступов организации\n\nСекреты организации (`workspace_secrets`) шифрует сервер ключом `WORKSPACE_SECRET_KEY`; управляет ими только владелец организации. `secret_grants` разрешает конкретным ИИ-сотрудникам использовать секрет в агентных цепочках, а `secret_audit` журналирует создание, показ, выдачу/отзыв, удаление и каждое выполнение SSH-операции. SSH возможен только к IP из `AGENT_SSH_ALLOWED_HOSTS` и только командами, заданными в самом секрете.\n\n| Таблица | Назначение |\n|---|---|\n| [db.workspace_secrets](#модели/dbworkspace-secrets) | Хранилище доступов организации (отдельно от личного E2EE). |\n| [db.secret_grants](#модели/dbsecret-grants) | Разрешение ИИ-сотруднику использовать секрет организации в агентных цепочках. |\n| [db.secret_audit](#модели/dbsecret-audit) | Журнал действий с секретами организации; владелец видит последние 100 записей. |\n\n### ИИ-сотрудники и агентные цепочки\n\nИИ-сотрудник — пользователь с `account_kind='ai'`, членство в организации и строка `ai_employees` с провайдером, моделью, зашифрованным ключом и дневными лимитами. Одиночный запуск по задаче и шаги цепочек пишут `ai_runs` и резервируют бюджет в `ai_usage`. Цепочка (`agent_workflows`) из 1–6 шагов (`agent_steps`) выполняется воркером по порядку; отчёт каждого шага публикуется комментарием задачи от имени ИИ-сотрудника.\n\n| Таблица | Назначение |\n|---|---|\n| [db.ai_employees](#модели/dbai-employees) | Настройки ИИ-сотрудника; PK совпадает с его учёткой в `users` (`account_kind='ai'`) и членством в организации. |\n| [db.ai_usage](#модели/dbai-usage) | Суточный учёт расхода ИИ-сотрудника (день по UTC). |\n| [db.ai_runs](#модели/dbai-runs) | Запуски модели по задаче: одиночный запуск и шаги агентных цепочек. |\n| [db.agent_workflows](#модели/dbagent-workflows) | Цепочка ИИ-шагов по задаче (1–6 шагов). |\n| [db.agent_steps](#модели/dbagent-steps) | Шаг цепочки: ИИ-сотрудник, инструкция и, при подтверждении владельца, одна разрешённая SSH-операция по секрету. |\n\n### TEAM — проекты, бэклог и спринты\n\nПроект (`workspace_projects`) с этапами (`project_phases`) группирует доски организации. `team_backlog` — идеи до переноса: перенос создаёт задачу на выбранной доске и помечает запись `transferred`. Спринты (`team_sprints`) собирают задачи с оценками (`team_sprint_tasks`); в организации одновременно активен не более одного спринта, при старте и закрытии фиксируются плановые и фактические итоги. Связь с проектом проверяется составными FK `(project_id, workspace_id)`.\n\n| Таблица | Назначение |\n|---|---|\n| [db.workspace_projects](#модели/dbworkspace-projects) | Проект организации — группа досок, записей бэклога и спринтов. |\n| [db.project_phases](#модели/dbproject-phases) | Этапы проекта по порядку (например запуск, доработка, реклама). |\n| [db.team_backlog](#модели/dbteam-backlog) | Бэклог идей организации до переноса на доску. |\n| [db.team_sprints](#модели/dbteam-sprints) | Спринты организации. |\n| [db.team_sprint_tasks](#модели/dbteam-sprint-tasks) | Состав спринта с оценками. |\n\n### Wiki\n\n`wiki_pages` с `workspace_id IS NULL` — публичная Wiki продукта (редакторы — подтверждённые адреса из `WIKI_ADMIN_EMAILS`), остальные — внутренняя Wiki организации (гостям недоступна). Каждое создание и сохранение статьи пишет неизменяемый снимок в `wiki_versions`; медиа статьи хранится в `wiki_files` по тем же правилам database/s3, что и вложения задач.\n\n| Таблица | Назначение |\n|---|---|\n| [db.wiki_pages](#модели/dbwiki-pages) | Статьи Wiki: `workspace_id IS NULL` — публичная Wiki продукта, иначе внутренняя Wiki организации. |\n| [db.wiki_versions](#модели/dbwiki-versions) | Неизменяемые снимки статьи после создания и каждого сохранения; `version` совпадает с `wiki_pages.version` на момент снимка. |\n| [db.wiki_files](#модели/dbwiki-files) | Медиа статьи Wiki; ссылки в `body` вида `/api/v1/wiki/files/<id>` должны указывать на файлы этой же статьи. |\n\n### Служебные таблицы\n\n`schema_migrations` создаёт и ведёт `migrate.ts`: файл миграции с записанным именем повторно не выполняется.\n\n| Таблица | Назначение |\n|---|---|\n| [db.schema_migrations](#модели/dbschema-migrations) | Применённые миграции. |\n\n### Функции и триггеры\n\n| Функция | Назначение |\n|---|---|\n| `assign_comment_thread()` | BEFORE INSERT на `comments` (триггер `comment_thread`): при `reply_to` ставит `root_id` = `COALESCE(root_id, id)` родителя той же задачи, иначе ошибка 23514; без `reply_to` — `root_id = NULL`. |\n| `assign_task_number()` | BEFORE INSERT на `tasks` (триггер `tasks_number`): берёт следующий номер доски из `task_counters` и записывает его в `tasks.number`. |\n| `metodox_realtime_event()` | Общий realtime-триггер: по `TG_TABLE_NAME` собирает `boardId`, `workspaceId`, `userId`, `users` и вызывает `pg_notify('metodox_changes', …)`. Используется триггерами `realtime_*` на `workspace_events`, `boards`, `notifications`, `direct_messages`, `friendships`, `mood_entries`, `agent_steps`, `workspace_members`. |\n| `mirror_workspace_task_event()` | AFTER INSERT на `task_events` (одноимённый триггер): вставляет в `workspace_events` строку с тем же id, scope `task`, action `task.<action>` и снимками названий задачи и доски. |\n| `queue_attachment_cleanup()` | AFTER DELETE на `attachments` (`attachment_cleanup`), `direct_files` (`direct_files_cleanup`) и `wiki_files` (`wiki_file_cleanup`): при `storage='s3'` кладёт `object_key` в `storage_cleanup`. |\n| `reaction_changed()` | AFTER INSERT/DELETE на `message_reactions` (`realtime_reactions`): для личного сообщения уведомляет обоих участников чата (`source: direct_messages`), для комментария — доску задачи (`source: boards`). |\n| `workspace_secret_version()` | BEFORE UPDATE на `workspace_secrets` (одноимённый триггер, миграция 030): если изменились `cipher`, `title` или `kind`, а `version` в запросе не менялась, увеличивает `version` на 1. Выдачи доступа увеличивают версию явно в `agent-secrets.ts`. |"},"servers":[{"url":"https://app.metodox.ru","description":"Production"},{"url":"http://localhost:4000","description":"Локальная разработка (npm run dev)"}],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"tags":[{"name":"Аутентификация","description":"Регистрация, вход, обновление и закрытие сессии. Один механизм сессий обслуживает браузер и нативные клиенты; различается только то, как передаются токены.\n\n| | `client: \"web\"` (по умолчанию) | `client: \"desktop\"` |\n|---|---|---|\n| Кто использует | веб-приложение в браузере | десктоп и мобильное приложение (другого значения нет) |\n| Токены после входа | HttpOnly-cookie, в теле только `expiresIn` | в теле: `accessToken`, `refreshToken`, `expiresIn`, `tokenType` |\n| Access-токен в запросах | cookie отправляет браузер | `Authorization: Bearer <accessToken>` |\n| Refresh-токен при обновлении | из cookie | из поля `refreshToken` в теле |\n| `Origin` в изменяющих запросах | обязателен, если запрос несёт cookie | не нужен с заголовком `Authorization: Bearer`; если передан, должен входить в `WEB_ORIGIN` |\n\n**Сессия.** Одна пара токенов — одна строка [sessions](#модели/dbsessions). Токены — 32 случайных байта в base64url (43 символа); в базе хранится только их SHA-256. Каждый вход создаёт новую сессию, остальные не закрываются. Значение длиннее 200 символов считается отсутствующим токеном.\n\n**Сроки.** Access-токен живёт 15 минут (`expiresIn: 900`), refresh-токен — 30 дней с момента выдачи пары. Запрос проходит, только пока не истекли оба срока.\n\n**Ротация.** `POST /api/v1/auth/refresh` в одной транзакции удаляет старую сессию, создаёт новую с новыми сроками (скользящие 30 дней) и запоминает SHA-256 использованного refresh-токена на 30 дней в [rotated_refresh_tokens](#модели/dbrotated-refresh-tokens). Старые токены перестают работать сразу.\n\n**Повторное использование refresh-токена.** Уже обменянный токен, предъявленный снова:\n- в течение **30 секунд** после ротации — обычный 401 без последствий. Так сервер прощает параллельные обновления (две вкладки, realtime-клиент и запрос одновременно);\n- позже (и пока не прошли 30 дней) — признак того, что токен скопирован. Сервер закрывает **все** сессии пользователя, стирает сохранённые хеши его ротированных токенов и отвечает 401 «Сессия завершена из соображений безопасности. Войдите снова.»; веб-клиенту очищает cookie. Нужен новый вход.\n\nТокены сессий, закрытых выходом, отзывом или сменой пароля, в журнал ротаций не попадают: их повтор — обычный 401.\n\n**Пароли.** Хеш scrypt (N=32768, r=8, p=3, 64 байта, соль 16 байт) в формате `s2$соль$хеш`. Старый формат `соль:хеш` (N=16384, r=8, p=1) перехешируется при первом успешном входе. Одновременно вычисляется не больше 4 хешей, остальные запросы ждут в очереди.\n\n**Защита от подбора.** Для несуществующего email пароль проверяется против фиктивного хеша, поэтому время ответа не показывает, есть ли аккаунт. Ответ одинаковый: 401 «Неверный email или пароль». Так же отклоняется вход в аккаунт ИИ-сотрудника (`account_kind = 'ai'`). Занятость адреса при этом видна через регистрацию (409).\n\n**Лимит.** `POST /auth/register`, `/auth/login`, `/auth/refresh` и `/auth/logout` ограничены 15 запросами в минуту. `GET /auth/me` вызывается на каждом экране и подчиняется общему лимиту — 120 в минуту. Счётчик свой у каждого маршрута; запрос с действующим access-токеном считается по сессии, без него — по IP.\n\nCookie, порядок обновления токенов и рекомендации для нативных клиентов описаны во введении, в разделе «Аутентификация».\n"},{"name":"Почта и восстановление","description":"Подтверждение email и сброс пароля по ссылке из письма.\n\n**Включение.** Письма отправляются, только если на сервере `MAIL_ENABLED=true`. Без этого флага:\n- регистрация не ставит письмо в очередь;\n- `request-verification` отвечает 400;\n- `forgot-password` отвечает как обычно, но ничего не отправляет.\n\nПризнак `mailEnabled` возвращает `GET /api/v1/account/status`.\n\n**Ссылки.** Письмо содержит `{APP_URL}/account/verify#token=…` или `{APP_URL}/account/reset#token=…`. Если `APP_URL` не задан, берётся первый адрес из `WEB_ORIGIN`. Токен стоит во фрагменте URL, поэтому браузер не отправляет его серверу при открытии страницы и не передаёт в `Referer`. Веб-страница `/account/{action}` читает фрагмент, убирает его из адресной строки и передаёт токен в теле `POST /api/v1/account/verify` или `/account/reset`.\n\n| Токен | Срок | Повторное использование |\n|---|---|---|\n| Подтверждение email (`verify`) | 24 часа | нет: удаляется после успеха |\n| Сброс пароля (`reset`) | 30 минут | нет: удаляется после успеха |\n\nНовый запрос письма удаляет предыдущий токен того же типа и отменяет ещё не отправленные письма этого типа (`status = 'cancelled'`). Действует только последняя ссылка. Токены хранятся как SHA-256 в [auth_tokens](#модели/dbauth-tokens) и выдаются только аккаунтам людей (`account_kind = 'human'`).\n\n**Очередь писем.** Письма лежат в [mail_outbox](#модели/dbmail-outbox) в зашифрованном виде (AES-256-GCM, ключ `MAIL_ENCRYPTION_KEY`). Обработчик раз в 15 секунд отправляет до 10 писем. На письмо даётся до 5 попыток с интервалом 5 минут. После отправки содержимое письма стирается.\n\n**Сброс пароля** закрывает все сессии пользователя и не открывает новую. Ссылка пришла на этот адрес, поэтому сброс заодно подтверждает email (`email_verified = true`), удаляет все токены пользователя (и подтверждения, и сброса) и отменяет его неотправленные письма этих типов.\n\n**Смена пароля** в профиле (`POST /api/v1/profile/password`) удаляет ранее выданные ссылки сброса и отменяет неотправленные письма сброса: старая ссылка не перезапишет новый пароль.\n\n**Анти-перечисление.** `forgot-password` всегда отвечает 201 одним и тем же текстом, есть такой аккаунт или нет. Поиск аккаунта и постановка письма выполняются уже после ответа, поэтому и время ответа от адреса не зависит.\n\n**Где нужен подтверждённый email.** Если `EMAIL_VERIFICATION_REQUIRED=true` или `NODE_ENV=production` (и флаг не равен `false`), неподтверждённый аккаунт получает 403 «Сначала подтвердите email в настройках безопасности» в таких случаях:\n- создание организации: `POST /api/v1/workspaces`;\n- входящие приглашения: список `GET /api/v1/workspaces/invitations`, принятие `POST /api/v1/workspaces/invitations/{inviteId}/accept` и отказ `DELETE /api/v1/workspaces/invitations/{inviteId}` — аккаунт, не доказавший владение адресом, не видит и не трогает приглашения на этот адрес;\n- передача владения организацией: `POST /api/v1/workspaces/{id}/transfer` (подтверждённый email нужен и новому владельцу — иначе 403 «Новый владелец должен сначала подтвердить email»);\n- создание личного сейфа: `POST /api/v1/vault/config`.\n\nНезависимо от флага подтверждённый email нужен редакторам публичной Wiki (`WIKI_ADMIN_EMAILS`).\n\n**Лимит.** Действия с письмами и ссылками (`request-verification`, `forgot-password`, `verify`, `reset`) ограничены 5 запросами в минуту. `GET /account/status` экраны настроек читают при каждом открытии, поэтому для него действует общий лимит — 120 в минуту. Счётчик свой у каждого маршрута; запрос с действующим access-токеном считается по сессии, без него — по IP.\n"},{"name":"Профиль","description":"Собственный профиль, видимость для других пользователей, активные сессии и смена пароля. Все операции требуют сессии и работают только с данными текущего пользователя.\n\n- **Поля профиля** (`PATCH /api/v1/profile`): имя, «о себе», должность, часовой пояс, цвет аватара. Обновление полное: все поля обязательны.\n- **Только для чтения:** email, монеты (`balance`), опыт (`xp`), активный предмет оформления (`activeCosmetic`). Email через API не меняется.\n- **Видимость** (`publicProfile`) влияет на поиск людей, карточку профиля для других пользователей и возможность получить заявку в друзья. Публичность — по согласию (152-ФЗ, ст. 10.1): регистрация создаёт аккаунт со скрытым профилем (`false`), открыть его пользователь может сам.\n- **Сессии:** список и закрытие отдельных сессий. Каждое обновление токенов пересоздаёт сессию, поэтому её `id` меняется.\n- **Смена пароля** закрывает все сессии, кроме текущей, и отзывает ранее выданные ссылки сброса пароля.\n\n**Лимиты.** `POST /profile/password` и `POST /profile/delete` — 5 запросов в минуту: с украденной сессией здесь подбирали бы пароль. Остальные операции подчиняются общему лимиту 120 запросов в минуту на маршрут и сессию.\n"},{"name":"Служебное","description":"Проверка работоспособности для мониторинга и балансировщиков.\n\n`GET /api/v1/health` не требует сессии и проверяет только доступность PostgreSQL: без базы — 503 «База данных недоступна».\n\n**Swagger UI.** Вне production (`NODE_ENV` ≠ `production`) API-сервер отдаёт автоматически сгенерированный Swagger UI по пути `/api/docs`, вне префикса `/api/v1` (`SwaggerModule.setup` в `main.ts`). Тела запросов в нём берутся из zod-схем обработчиков, но описаний ответов и ошибок там нет, поэтому ориентируйтесь на этот справочник. В production Swagger не поднимается: `GET /api/docs` и `/api/docs/*` перенаправляют (302) на `{APP_URL}/docs`, а `GET /api/docs-json` и `/api/docs-yaml` — на `{APP_URL}/docs/openapi.json` (по умолчанию `APP_URL` = `https://app.metodox.ru`).\n"},{"name":"Организации","description":"Организация (workspace) — общее пространство команды: участники, корпоративные доски, проекты, вики.\nТаблицы: [workspaces](#модели/dbworkspaces), [workspace_members](#модели/dbworkspace-members), [invitations](#модели/dbinvitations).\n\n### Личное пространство и организации\n\n| | Личное пространство | Организация |\n|---|---|---|\n| Как появляется | есть у каждого аккаунта: это доски с `workspaceId: null` | `POST /api/v1/workspaces`, создатель получает роль `owner` |\n| Кто видит доски | только владелец доски | участники организации по правилам раздела «Доски» |\n| Явные роли на доске | недоступны (`POST /boards/{id}/members` → 403) | доступны |\n| Видимость доски | всегда `private`, переданное значение не сохраняется | `private` или `workspace` |\n| Проекты и этапы | недоступны (400 «Проекты доступны в организации») | доступны |\n| Исполнитель задачи | только сам владелец: исполнитель должен иметь доступ к доске | любой, у кого есть доступ к доске |\n\n### Роли в организации\n\nРоль хранится в `workspace_members.role`. Проверку делает `AccessService.workspace`: если пользователь не участник — 404 «Организация не найдена»; если операции нужна роль `owner`/`admin`, а её нет — 403 «Нужны права администратора организации».\n\n| Действие | owner | admin | member | guest |\n|---|:-:|:-:|:-:|:-:|\n| Видеть организацию и список участников | да | да | да | да |\n| Видеть email других участников (`GET /workspaces/{id}`, `GET /boards/{id}/people`) | да | да | да | нет, только свой |\n| Видеть активные приглашения организации | да | да | нет | нет |\n| Менять название и описание | да | да | нет | нет |\n| Приглашать с ролью `member` или `guest` | да | да | нет | нет |\n| Приглашать с ролью `admin` | да | нет | нет | нет |\n| Отменять приглашения | да | да | нет | нет |\n| Переводить участника между `member` и `guest` | да | да | нет | нет |\n| Назначать и снимать роль `admin` | да | нет | нет | нет |\n| Удалять участников с ролью `member` или `guest` | да | да | нет | нет |\n| Удалять участников с ролью `admin` | да | нет | нет | нет |\n| Передать владение организацией | да | нет | нет | нет |\n| Покинуть организацию (`POST /workspaces/{id}/leave`) | нет | да | да | да |\n| Удалить организацию (`DELETE /workspaces/{id}`) | да | нет | нет | нет |\n| Создавать доски в организации | да | да | да | нет |\n| Доступ к любой доске организации, включая приватные | да, как `admin` | да, как `admin` | нет | нет |\n| Доступ к доскам с видимостью `workspace` | да, как `admin` | да, как `admin` | да, как `viewer` (или по явной роли) | только по явной роли |\n| Доступ к приватной доске по явной роли участника доски | — | — | да | да |\n\n### Правила управления участниками\n\n- Роль `owner` получает создатель организации. Методы ролей и приглашений её не выдают (допустимые роли: `admin`, `member`, `guest`), не меняют и не снимают, а владельца нельзя удалить.\n- Единственный способ сменить владельца — `POST /workspaces/{id}/transfer`. Новым владельцем может стать только участник-человек с ролью `admin` или `member`; в production его email должен быть подтверждён. Прежний владелец в той же транзакции становится `admin`. У организации всегда ровно один владелец.\n- Никто не может изменить свою роль или удалить себя этими методами. Чтобы уйти самому, участник вызывает `POST /workspaces/{id}/leave`; владельцу сначала нужно передать владение или удалить организацию.\n- Всё, что касается роли `admin`, доступно только владельцу: приглашение админа (в том числе изменение и продление активного приглашения админа), назначение, понижение и удаление админа.\n- При удалении участника и при выходе из организации в одной транзакции удаляются его явные роли на досках организации и в задачах этих досок, а сам он снимается с исполнителей всех задач организации. `version` таких задач увеличивается. Доски организации, которые он создал (`boards.owner_id`), переходят владельцу организации.\n- Удаление участника, выход и смена роли сначала берут строку организации `FOR SHARE`, а передача владения и удаление организации — `FOR UPDATE`. Поэтому эти операции не перекрываются и не взаимоблокируются.\n- Кроме приглашений, участники появляются при создании ИИ-сотрудников (аккаунты с `kind: ai`) — это методы раздела ИИ-сотрудников.\n- Организацию удаляет только владелец: `DELETE /workspaces/{id}` с точным названием в `confirmName`. Вместе с ней удаляются доски, задачи, проекты, Wiki, бэклог, спринты, приглашения, журнал и ИИ-сотрудники организации.\n\n### Жизненный цикл приглашения\n\n1. Владелец или админ вызывает `POST /workspaces/{id}/invitations` с email (приводится к нижнему регистру) и ролью. Если человек с этим email уже участник — 403.\n2. На пару «организация + email» хранится одно приглашение. Повторный вызов меняет роль и продлевает срок, ID приглашения при этом не меняется. Активное приглашение с ролью `admin` может отправить только владелец, поэтому и изменить или продлить его может только он: админ получит 403. Истёкшее приглашение админ перезаписать может.\n3. Срок жизни — 7 дней. Истёкшие приглашения не показываются и не принимаются. Автоматического удаления в коде нет: запись остаётся до повторного приглашения, отмены или отказа.\n4. Если включена почта (`MAIL_ENABLED=true`), в очередь [mail_outbox](#модели/dbmail-outbox) ставится письмо со ссылкой на `/settings/organizations`.\n5. Приглашённый видит приглашение в `GET /workspaces/invitations`. Приглашение находится по email аккаунта, поэтому зарегистрироваться нужно с тем же адресом. Если проверка email включена, сначала нужно подтвердить адрес.\n6. `POST /workspaces/invitations/{inviteId}/accept` удаляет приглашение и добавляет пользователя с ролью из приглашения. Если он уже участник, роль не меняется.\n7. `DELETE /workspaces/invitations/{inviteId}` — отказ приглашённого. `DELETE /workspaces/{id}/invitations/{inviteId}` — отмена владельцем или админом.\n\n### Подтверждение email\n\nСоздание организации, просмотр приглашений, их принятие и отказ от них, а также передача владения требуют подтверждённого email (`requireVerified`), иначе 403 «Сначала подтвердите email в настройках безопасности». Приглашение адресовано email, поэтому аккаунт, который ещё не доказал владение адресом, не должен видеть, куда приглашён владелец адреса, и удалять эти приглашения. Проверка включена, если `EMAIL_VERIFICATION_REQUIRED=true`, а также в production (`NODE_ENV=production`), если явно не задано `EMAIL_VERIFICATION_REQUIRED=false`. По тому же правилу при передаче владения проверяется email нового владельца.\n\n### Аудит и realtime\n\nДействия пишутся в журнал [workspace_events](#модели/dbworkspace-events): `team.created`, `team.updated`, `team.ownership_transferred`, `invitation.sent`, `invitation.cancelled`, `invitation.declined`, `member.joined`, `member.role_changed`, `member.removed`, `member.left`. Удаление организации в журнал не пишется: журнал удаляется вместе с ней. Записи журнала и изменения в `workspace_members` отправляют realtime-событие `changed` подписчикам организации, а также автору действия (для записи журнала) или затронутому участнику (для `workspace_members`).\n"},{"name":"Доски","description":"Доска — набор колонок с задачами. Принадлежит личному пространству (`workspaceId: null`) или организации.\nТаблицы: [boards](#модели/dbboards), [columns](#модели/dbcolumns), [board_members](#модели/dbboard-members).\n\n### Как вычисляется роль на доске\n\nРоль заново вычисляется в `AccessService.board` при каждом запросе:\n\n1. Личная доска: владелец (`owner_id`) — `admin`, остальным доска не видна.\n2. Доска организации, пользователь не участник организации — доска не видна.\n3. `owner` или `admin` организации — всегда `admin` доски. Явная роль в `board_members` для них не учитывается.\n4. Иначе — явная роль из `board_members`, если она есть. Это единственный путь к доске для гостя.\n5. Иначе, если у доски видимость `workspace`, а роль в организации `member`, — `viewer`.\n6. Иначе доска не видна.\n\nНедоступная доска возвращает 404 «Доска не найдена», существование доски не раскрывается. Если роли не хватает — 403 «Недостаточно прав на доске».\n\n### Права ролей на доске\n\n| Действие | admin | editor | viewer |\n|---|:-:|:-:|:-:|\n| Видеть доску, колонки, видимые задачи и людей доски | да | да | да |\n| Создавать задачи и подзадачи | да | да | нет |\n| Добавлять, переименовывать, удалять и переставлять колонки | да | да | нет |\n| Менять название, описание, видимость, проект и этап | да | нет | нет |\n| Удалять доску | да | нет | нет |\n| Выдавать и отзывать роли на доске | да | нет | нет |\n| Видеть все задачи, включая `restricted`, и управлять любой задачей | да | нет | нет |\n\nПрава на отдельную задачу описаны в разделе «Задачи». Например, `viewer`, назначенный исполнителем, может редактировать свою задачу, а `editor` управляет задачами, которые создал сам.\n\n### Видимость\n\n- `private` — только админы и владелец организации плюс пользователи с явной ролью на доске.\n- `workspace` — дополнительно все участники организации с ролью `member`, как `viewer`. Гость видит такую доску только по явной роли.\n\nУ личной доски видимость всегда `private`. Если передать другое значение, оно не сохраняется.\n\nЗакрытие доски (`workspace` → `private` в `PATCH /boards/{id}`) отнимает доступ у участников с ролью `member`, у которых нет явной роли на доске. В той же транзакции у них удаляются роли в задачах доски, и они снимаются с исполнителей (см. «Явные роли на доске»).\n\n### Колонки\n\nНовая доска организации получает 4 колонки: «В планах», «В работе», «На проверке», «Готово». Личная доска — 3: «Нужно сделать», «В работе», «Готово». Позиции идут подряд с 0. Последняя колонка (с наибольшим `position`) — колонка завершения: задача в ней считается выполненной. Новая колонка вставляется перед последней, поэтому колонка завершения остаётся последней.\n\n- **Удаление** (`DELETE /boards/{id}/columns/{columnId}`). Единственную и последнюю колонку удалить нельзя (409). Задачи удаляемой колонки переносятся в колонку `moveTo`. Если это последняя колонка, задачи завершаются по общим правилам (раздел «Задачи»). После удаления позиции пересчитываются подряд с 0.\n- **Перестановка** (`PUT /boards/{id}/columns/order`). Клиент передаёт все колонки доски в новом порядке. Последняя колонка списка становится колонкой завершения. Задачи новой последней колонки сразу считаются выполненными там, где выполненность определяется по колонке: правила графа, поле `done` в связях, напоминания о сроках. Задачи прежней последней колонки — открытыми. Отметки `completed_at` и `completed_by` и награды при этом не меняются: их пишут только переносы задач. Правило графа задач проверяется для всех задач новой последней колонки.\n\n### Явные роли на доске\n\n- Есть только на досках организации и выдаются только её участникам, в том числе гостям. Можно выдать любую роль, включая `admin`.\n- Создатель доски организации автоматически получает явную роль `admin`.\n- Нельзя выдать или отозвать роль самому себе.\n- Отзыв роли удаляет запись в `board_members`. Если после этого пользователь больше не может открыть доску, в той же транзакции у него удаляются роли в задачах доски и он снимается с исполнителей её задач (`version` растёт, в историю пишется `updated` с полем `assigneeId`). Кто сохраняет доступ через роль `owner`/`admin` в организации или как `member` при видимости `workspace`, сохраняет и роли в задачах, и назначения. Ответ сообщает об этом полем `accessRemains`.\n- Та же очистка выполняется при закрытии доски. Она касается всех, кто больше не может открыть доску, а не только пользователя из запроса.\n\n### Проект и этап\n\nДоску организации можно привязать к проекту (`projectId`) и этапу (`phaseId`). Проект должен принадлежать организации и не быть в архиве, этап — принадлежать проекту. Если сменить проект и не передать `phaseId`, этап сбрасывается. Перенести доску в другой проект или отвязать её от проекта нельзя, пока её задачи входят в незавершённые спринты другого проекта (409).\n\n### Аудит и realtime\n\nВ [workspace_events](#модели/dbworkspace-events) пишутся (со `scope: board`):\n\n| `action` | `details` |\n|---|---|\n| `board.created`, `board.deleted`, `column.created`, `column.renamed` | `{}` |\n| `board.updated` | `{ \"fields\": [...] }` — переданные поля; при закрытии доски ещё `unassigned` — число снятых назначений, если оно больше 0 |\n| `column.deleted` | `{ \"moveTo\": \"<id>\" \\| null, \"moved\": 3 }` |\n| `column.reordered` | `{ \"columnIds\": [...] }`, а если сменилась последняя колонка — ещё `finalColumnId` |\n| `board.access_granted` | `{ \"userId\": \"…\", \"role\": \"editor\" }` |\n| `board.access_revoked` | `{ \"userId\": \"…\", \"role\": \"<прежняя роль>\", \"accessRemains\": true, \"unassigned\": 0 }`; пишется, только если явная роль была |\n\nДля личных досок запись делается без организации. Изменения таблицы `boards` и записи журнала отправляют realtime-событие `changed` (области `boards`, `tasks`, `team`) подписчикам доски и организации, а также автору действия (для записи журнала) или владельцу доски (для `boards`).\n"},{"name":"Задачи","description":"Задача живёт на одной доске и всегда находится в одной из её колонок.\nТаблицы: [tasks](#модели/dbtasks), [task_members](#модели/dbtask-members), [task_events](#модели/dbtask-events), [attachments](#модели/dbattachments), [comments](#модели/dbcomments).\n\n### Поля\n\n| Поле | Тип и ограничения | По умолчанию |\n|---|---|---|\n| `number` | номер задачи на доске (1, 2, 3…), уникален в пределах доски. Назначается триггером при создании, не меняется и не используется повторно, даже после удаления задачи | присваивает сервер |\n| `title` | строка 1–200 символов после обрезки пробелов | обязательно |\n| `description` | строка до 100 000 символов | `\"\"` |\n| `columnId` | колонка той же доски | обязательно при создании |\n| `priority` | `low`, `medium` или `high` | `medium` |\n| `label` | одна метка, до 40 символов после обрезки пробелов | `\"\"` |\n| `dueDate` | дата `YYYY-MM-DD` или `null` | `null` |\n| `assigneeId` | пользователь с доступом к доске или `null` | `null` |\n| `visibility` | `board` или `restricted` | `board` |\n| `position` | порядок внутри колонки. Ни один метод API его не меняет, поэтому сейчас он всегда 0, и задачи фактически упорядочены по времени создания | 0 |\n| `version` | номер версии для оптимистичной блокировки | 1 |\n\n### Права на задачу\n\nСначала проверяется доступ к доске (раздел «Доски»), затем `AccessService.task` вычисляет права:\n\n- **Управление (`canManage`)** есть у `admin` доски и у автора задачи, если у него роль `editor` на доске. Управление даёт удаление задачи, смену видимости и исполнителя, выдачу и отзыв ролей в задаче.\n- **Роль в задаче** определяется по порядку:\n  1. есть `canManage` или пользователь — исполнитель задачи → `editor`;\n  2. есть явная роль из `task_members` (`editor`, `commenter`, `viewer`) → она. Явная роль важнее роли на доске, даже если она ниже;\n  3. видимость задачи `board` и роль `editor` на доске → `editor`;\n  4. иначе → `viewer`.\n\n| Возможность | editor | commenter | viewer |\n|---|:-:|:-:|:-:|\n| Читать карточку, комментарии, историю, вложения и связи | да | да | да |\n| Комментировать и загружать вложения | да | да | нет |\n| Изменять свой комментарий (24 часа после отправки, кроме пересланных) | да | да | нет |\n| Удалять свой комментарий | да | да | нет |\n| Удалять свои вложения | да | да | нет |\n| Удалять чужие вложения | да | нет | нет |\n| Менять поля, переносить между колонками, создавать и удалять связи | да | нет | нет |\n\n`viewer` доски без явной роли в задаче комментировать не может. Создавать задачи и подзадачи могут только `editor` и `admin` доски. Чужие комментарии может удалять только `admin` доски, изменять — никто.\n\n### Видимость `restricted`\n\nЗадачу с видимостью `restricted` видят только `admin` доски, автор, исполнитель и пользователи с явной ролью в задаче. Остальным она не приходит в `GET /boards/{id}`, а запросы к ней возвращают 404 «Задача не найдена», как будто её нет. Подзадача наследует видимость родителя.\n\n### Исполнитель\n\n- Исполнитель должен иметь доступ к доске (подойдёт любая роль), иначе 403 «Участнику сначала нужен доступ к доске».\n- При создании исполнителя задаёт автор задачи. В `PATCH` менять исполнителя и видимость может только пользователь с `canManage`.\n- Исполнитель получает роль `editor` в своей задаче.\n- Назначение создаёт уведомление `assigned`, если исполнитель не сам автор действия и у него включены уведомления о назначениях.\n- Исполнитель снимается автоматически, когда он уходит или его удаляют из организации, а также когда он теряет доступ к доске из-за отзыва явной роли или закрытия доски (`workspace` → `private`). Если доступ остаётся через организацию, назначение сохраняется.\n\n### Оптимистичная блокировка\n\nВ `PATCH` обязательно передаётся `version`, полученная при чтении (`GET /boards/{id}` или `GET /boards/{id}/tasks/{taskId}`). Если версия в базе другая, сервер отвечает 409, и задачу нужно перечитать. Версия растёт:\n\n- при каждом успешном `PATCH`, даже если ни одно значение не изменилось;\n- при переносе задач удаляемой колонки в другую (`DELETE /boards/{id}/columns/{columnId}`);\n- когда пользователя снимают с исполнителей: при уходе или удалении из организации, отзыве роли на доске, закрытии доски.\n\n### История\n\n`GET /boards/{id}/tasks/{taskId}` возвращает последние 100 записей [task_events](#модели/dbtask-events), новые первыми.\n\n| `action` | `details` |\n|---|---|\n| `created` | `{}`; у задач, созданных из бэклога, — `{ \"source\": \"backlog\" }` |\n| `updated` | `{ \"fields\": [...], \"changes\": { \"<поле>\": { \"from\": …, \"to\": … } } }`. В `changes` попадают только поля, значение которых действительно изменилось; если не изменилось ничего, запись не создаётся. `dueDate` записывается как `YYYY-MM-DD`. Кроме `PATCH`, такую запись создают перенос задач удаляемой колонки (`fields: [\"columnId\"]`) и снятие исполнителя, потерявшего доступ к доске (`fields: [\"assigneeId\"]`, `to: null`) |\n| `commented` | `{}` |\n| `comment_edited` | `{ \"commentId\": \"…\" }` — текст комментария изменён |\n| `comment_deleted` | `{ \"commentId\": \"…\", \"attachments\": 2 }` — комментарий удалён вместе с этим числом вложений. Если удалил не автор, есть ещё `name` — имя автора («Участник», если аккаунт удалён) |\n| `attached`, `attachment_removed` | `{ \"name\": \"<имя файла>\" }` |\n| `access_granted` | `{ \"name\": \"<имя>\", \"role\": \"editor\" }` |\n| `access_revoked` | `{ \"name\": \"<имя>\" }`; пишется, только если явная роль была |\n| `relation_added`, `relation_removed` | `{ \"kind\": \"subtask\" }` — без ID и названий связанных задач |\n| `sprint_added`, `sprint_removed` | `{ \"name\": \"<спринт>\", \"points\": 3 }` |\n| `agent_workflow_started`, `agent_step_completed` | формат задаёт модуль агентов |\n\nКаждая запись копируется триггером в журнал [workspace_events](#модели/dbworkspace-events) с действием `task.<action>`, что отправляет realtime-событие `changed` подписчикам доски и организации.\n\n### Вложения\n\n- Один файл — до 100 МБ (иначе 413), все вложения задачи — до 1 ГБ суммарно (иначе 400). Пустой файл (0 байт) — 400.\n- Параметр `filename` в заголовке части multipart читается как UTF-8, поэтому имена вроде «Отчёт.pdf» сохраняются без искажений. Сервер приводит имя к форме NFC, заменяет на `_` управляющие символы, `/`, `\\` и символы управления направлением текста (U+202A–U+202E, U+2066–U+2069), обрезает пробелы по краям и оставляет первые 200 символов (кодовых точек). Если имя оказалось пустым, сохраняется `attachment`.\n- Тип определяется по содержимому файла, заголовок `Content-Type` не учитывается: PNG, JPEG, GIF, WebP, PDF, MP4 (любой файл с сигнатурой `ftyp`), WebM, MP3. ZIP-контейнеры принимаются по расширению `docx`, `xlsx`, `pptx`, `odt`, `ods`, `zip`, старые форматы Office — `doc`, `xls`, `ppt`. Текстовые файлы `txt`, `md`, `csv`, `json` принимаются, если это корректный UTF-8 без управляющих символов.\n- Один процесс API одновременно обрабатывает не больше 2 передач файлов крупнее 10 МБ и 12 передач поменьше (загрузки и скачивания вместе). Сверх этого — 503, запрос нужно повторить.\n- Файлы хранятся в базе или, при `STORAGE_DRIVER=s3`, в S3, зашифрованными серверным ключом.\n- Загрузить файл может тот, кто может комментировать. Удалить — `editor` задачи или автор вложения, если у него есть право комментировать. Поле `canDelete` в карточке задачи считается по тому же правилу.\n- Загруженный файл можно прикрепить к комментарию через `attachmentIds`: только свой, из той же задачи и ещё не прикреплённый.\n- При удалении комментария его вложения удаляются вместе с ним.\n\n### Завершение\n\nЗадача выполнена, когда она стоит в последней колонке доски. Перенос в эту колонку через `PATCH`:\n\n- запрещён (409), пока не выполнены все предпосылки по графу: подзадачи и блокирующие задачи, транзитивно, включая скрытые от пользователя (раздел «Граф задач»);\n- проставляет `completed_at` (если он ещё не стоит) и `completed_by` (автор запроса);\n- начисляет награду получателю — исполнителю, а если его нет, автору действия. Если получатель — человек (`account_kind = human`), он один раз за задачу получает 10 монет и 10 XP ([coin_ledger](#модели/dbcoin-ledger)). Создаётся запись [completion_records](#модели/dbcompletion-records): «в срок», если `dueDate` задан и не раньше текущей даты в часовом поясе получателя. Затем проверяются достижения. Итог возвращается в поле `reward` ответа `PATCH`.\n\nПри переносе задачи в любую другую колонку `completed_at` и `completed_by` очищаются. Монеты не списываются и при повторном завершении не начисляются.\n\nПо тем же правилам завершаются:\n\n- задача, созданная сразу в последней колонке, — итог приходит в поле `reward` ответа на создание;\n- задачи, перенесённые в последнюю колонку при удалении колонки (`DELETE /boards/{id}/columns/{columnId}` с `moveTo`), — награды начисляются, но в ответе не возвращаются.\n\nПерестановка колонок (`PUT /boards/{id}/columns/order`) отметок завершения не пишет и не очищает (раздел «Доски»).\n\n### Уведомления\n\nУведомления хранятся в [notifications](#модели/dbnotifications). Доступ к задаче проверяется заново, когда пользователь запрашивает список уведомлений.\n\n- `assigned` — при назначении исполнителя (см. «Исполнитель»).\n- `comment`, `reply`, `mention` — при новом комментарии. Получают автор задачи, исполнитель, участники с явными ролями, прежние комментаторы и упомянутые, кроме автора комментария, если у них включены уведомления о комментариях. Упомянутый получает `mention`, автор комментария, на который ответили, — `reply`, остальные — `comment`.\n- `mention` — ещё и при изменении комментария, но только тем, кого упомянули впервые в новом тексте.\n- `due_soon`, `overdue` — для невыполненных задач со сроком не позже завтрашнего дня в часовом поясе получателя, если у него включены уведомления о сроках. Получатель — исполнитель, а если его нет, автор задачи. Уведомление создаётся сразу при создании задачи со сроком и при `PATCH`, который меняет срок, исполнителя или колонку. Смену дат отслеживает фоновая задача раз в минуту.\n- Уведомления об удалённом комментарии перестают показываться в списке уведомлений.\n\nЕсли включена почта (`MAIL_ENABLED=true`), для `assigned`, `mention`, `reply`, `due_soon` и `overdue` дополнительно ставится письмо в [mail_outbox](#модели/dbmail-outbox). Условия: получатель — человек с подтверждённым email, у него включена соответствующая email-настройка, и он может прочитать задачу. Письма по одной задаче объединяются и ограничиваются по частоте (раздел «Уведомления»).\n"},{"name":"Граф задач","description":"Связи между задачами одной доски хранятся в [task_links](#модели/dbtask-links) с видом `kind`: `subtask` или `blocks`. В API связь описывается с точки зрения текущей задачи T (`{taskId}` в пути) полем `type`. X — другая задача.\n\n| `type` | Смысл | Хранится как | source → target | Предпосылка (выполняется первой) |\n|---|---|---|---|---|\n| `child` | X — подзадача T | `subtask` | T → X | X |\n| `parent` | X — родитель T | `subtask` | X → T | T |\n| `blocking` | T блокирует X | `blocks` | T → X | T |\n| `blockedBy` | X блокирует T | `blocks` | X → T | X |\n\n### Единый граф предпосылок\n\nОба вида связей образуют один ориентированный граф «предпосылка → зависимая задача». У `subtask` предпосылка — подзадача (target), у `blocks` — блокирующая задача (source). При создании связи проверяются инварианты, нарушение даёт 409:\n\n- задача не может ссылаться сама на себя;\n- у задачи не больше одного родителя (дополнительно защищено уникальным индексом);\n- одинаковая связь (тот же source, target и kind) не создаётся повторно;\n- граф остаётся ациклическим. Рекурсивный запрос проверяет, достижима ли предпосылка из зависимой задачи. Например, нельзя сделать задачу подзадачей её собственной подзадачи или заблокировать задачу, которая блокирует текущую;\n- нельзя сделать невыполненную задачу предпосылкой для уже выполненной.\n\nСвязывать можно только задачи одной доски. Задачу с другой доски сервер ищет на текущей доске и отвечает 404.\n\n### Правила завершения\n\n- Перенести задачу в последнюю колонку нельзя (409), пока не выполнена хотя бы одна её предпосылка, прямая или транзитивная, в том числе скрытая от пользователя. Текст ошибки советует обратиться к администратору доски, если такие задачи скрыты.\n- Вернуть задачу из последней колонки нельзя (409), пока выполнена хотя бы одна прямо зависящая от неё задача: родитель или заблокированная задача.\n- Массовые изменения колонки завершения проверяют первое правило для всех задач, которые оказываются в последней колонке: удаление колонки с переносом задач в последнюю и перестановка колонок, после которой последней становится другая колонка. Если у такой задачи подзадача или блокирующая задача стоит вне последней колонки, ответ 409 и ничего не меняется.\n- Поле `canComplete` в `GET …/relations` показывает, выполнены ли все предпосылки.\n\n### Конкурентность\n\nСоздание и удаление связей, создание задач и подзадач, изменение задачи (`PATCH`), добавление, удаление и перестановка колонок, а также отзыв роли на доске блокируют строку доски (`SELECT … FROM boards … FOR UPDATE`) до конца транзакции. Изменения графа и проверки завершения на одной доске идут последовательно, поэтому параллельные запросы не могут обойти проверку циклов или правила завершения. Колонку нельзя удалить между проверкой и вставкой новой задачи.\n\n### Скрытые задачи\n\n- `GET …/relations` возвращает только связи с задачами, которые пользователь может видеть. О существовании остальных говорит лишь флаг `hasHiddenLinks`, без ID, названий и количества.\n- `GET /boards/{id}` отдаёт в `links` только связи, у которых видны обе задачи.\n- В историю пишется только вид связи (`kind`), без ID и названий: права на задачи могут разойтись позже.\n- Чтобы создать или удалить связь, нужно право редактировать обе задачи. Скрытая вторая задача даёт 404.\n"},{"name":"Обсуждения задач","description":"Комментарии задачи организованы в **плоские ветки**: корневой комментарий и ответы к нему.\n\n**Модель веток.** У комментария есть `reply_to` (на что отвечаем) и `root_id` (корень ветки).\n`root_id` вычисляет триггер `assign_comment_thread` (миграция 017): ответ на любой комментарий ветки\nпопадает в ту же ветку (`root_id = COALESCE(parent.root_id, parent.id)`), поэтому вложенность всегда\nдвухуровневая. Поле `reply` в ответе API — цитата непосредственного родителя (первые 160 символов).\nОтвет на комментарий из другой задачи невозможен: контроллер отвечает\n`400 «Сообщение для ответа недоступно»`, а триггер дополнительно бросает ошибку `23514`.\n\n**Создание, изменение и удаление комментариев** выполняются в разделе задач:\n`POST /api/v1/boards/{id}/tasks/{taskId}/comments` (тело до 20 000 символов после `trim`, до 10 `attachmentIds`,\n`replyTo`), `PATCH …/comments/{commentId}` и `DELETE …/comments/{commentId}`. Здесь описано только чтение веток\nи поиск участников для упоминаний.\n\n**Изменение и удаление (что видно в ответах).**\n- `editedAt` — время последнего изменения текста; `null`, если комментарий не меняли.\n- `canEdit` — текущий пользователь может изменить комментарий: он автор, у него есть право комментировать задачу,\n  комментарий не удалён, не является пересылкой и отправлен меньше 24 часов назад (`COMMENT_EDIT_WINDOW`).\n- `canDelete` — может удалить: комментарий не удалён, и пользователь — администратор доски (роль `admin`)\n  либо автор с правом комментировать.\n- Удалённый комментарий остаётся в ленте «надгробием», чтобы ответы ветки сохранили корень и место:\n  `deleted: true`, `body: \"\"`, `forwarded: null`, реакции, упоминания и вложения удалены. Автор, время и `replyCount`\n  сохраняются. В цитате ответа на удалённый комментарий `reply.deleted = true`, а `reply.body` пустой.\n\n**Постраничная навигация.**\n\n| Что | Размер страницы | Курсор |\n|---|---|---|\n| Корневые комментарии | 30 | `before` = ID самого старого загруженного корня этой задачи |\n| Ответы в ветке | 50 | `before` / `after` = ID ответа этой ветки |\n\nСортировка — по `(created_at, id)`, внутри страницы элементы всегда идут от старых к новым.\nКурсор, который не относится к этой задаче или ветке, — `400` с пояснением (а не пустая страница).\n\n**@Упоминания.** Упоминание — это только явная markdown-ссылка на профиль вида\n`[@Имя](/people/{userId})` (имя 1–100 символов без `]` и переводов строки). Простой текст `@Имя` и любые\nдругие ссылки никого не упоминают. Ссылки внутри блоков кода (```` ``` ````, `~~~`) и инлайн-кода игнорируются.\nОграничения при создании комментария (`validateMentions`, discussion.ts):\n- не больше **20** уникальных упоминаний → иначе `400 «Не больше 20 упоминаний в комментарии»`;\n- каждый упомянутый должен иметь право **чтения** задачи → иначе `400 «Упомянутый участник больше не имеет доступа к задаче»`.\n\nТе же правила действуют при изменении комментария и при пересылке сообщения в задачу (`POST /messages/forward`).\n\nУпоминания сохраняются в [comment_mentions](#модели/dbcomment-mentions) и порождают уведомление вида `mention`.\nКандидатов для автодополнения отдаёт `GET …/participants`: список уже отфильтрован по праву чтения задачи.\n\n**Доступ.** Все операции требуют права чтения задачи (`AccessService.task`): роль на доске плюс правила\nвидимости задачи (`restricted` видят только менеджеры, автор, исполнитель и участники задачи).\nНет доступа → `404` (существование доски/задачи не раскрывается).\n\n**Realtime.** Новый комментарий пишет событие `commented` в [task_events](#модели/dbtask-events), оно\nзеркалируется в [workspace_events](#модели/dbworkspace-events) и приходит подписчикам доски как\n`changed` с областями `boards`, `tasks`, `team` (событие журнала со `scope: task`). Реакции на комментарии приходят с теми же областями.\n\nТаблицы: [comments](#модели/dbcomments), [comment_mentions](#модели/dbcomment-mentions),\n[message_reactions](#модели/dbmessage-reactions).\n"},{"name":"Действия с сообщениями","description":"Общие действия для двух видов сообщений: **комментариев задач** и **личных сообщений**.\n\n**Ссылка на сообщение** `MessageActionRef = { kind, id }`, где `kind` — `task` или `chat`. Смысл `id` зависит от роли:\n\n| Роль | `kind: task` | `kind: chat` |\n|---|---|---|\n| `source` (что реагируем / пересылаем) | ID комментария (`comments.id`) | ID личного сообщения (`direct_messages.id`) |\n| `target` (куда пересылаем) | ID задачи (`tasks.id`) | ID дружбы-переписки (`friendships.id`) |\n\n**Права.**\n- `chat` — пользователь участник дружбы со статусом `accepted`, иначе `404 «Переписка недоступна»`.\n- `task` — для чтения источника пересылки достаточно права чтения задачи; для реакции и для\n  цели пересылки нужно право **комментировать** (роль задачи `editor` или `commenter`), иначе\n  `403 «Недостаточно прав на задаче»`.\n- Удалённый комментарий (`deleted: true`) нельзя ни переслать, ни отметить реакцией: `404 «Сообщение удалено»`.\n\n**Реакции.** Допустим любой эмодзи, а не фиксированный список (`reactionInput`, message-actions.ts):\n- строка 1–32 UTF-16 кодовых единиц, которая целиком — **ровно один** RGI-эмодзи (`^\\p{RGI_Emoji}$` с флагом `v`):\n  одиночные эмодзи, флаги, keycap `1️⃣`, модификаторы тона кожи и ZWJ-последовательности;\n- `©`, `®` и `™` не принимаются даже с `U+FE0F`;\n- если строка становится эмодзи только после добавления `U+FE0F`, сервер добавляет его сам: `❤` и `❤️` — одна реакция;\n- проверка выполняется только при `active: true`, поэтому снять можно и старую реакцию, сохранённую до ужесточения\n  правила. Ошибка — поле `emoji`, «Выберите одно эмодзи».\n\nОдин пользователь может поставить на сообщение **несколько разных** эмодзи, но каждый — один раз (уникальность\n`(comment_id|message_id, user_id, emoji)`). Новый эмодзи добавляется к уже поставленным, а не заменяет их.\nПереключение идемпотентно.\n\n**Пересылка** создаёт новое сообщение в цели от имени пересылающего с меткой `forwarded`\n(`{ author, createdAt }` исходного сообщения; при пересылке уже пересланного сохраняется исходная метка).\nВложения **копируются независимо**: у копии новый ID, отдельная строка в БД и отдельно зашифрованный\nобъект в хранилище, поэтому получателям в цели не нужен доступ к источнику. Ссылки на исходные\nвложения в тексте переписываются на URL копий. Пересылка в задачу — обычный комментарий:\n@упоминания в тексте проверяются по правилам тега «Обсуждения задач» и уведомляют упомянутых.\n\n| Ограничение | Значение |\n|---|---|\n| Вложений в пересылаемом сообщении | не больше 10 |\n| Длина текста для `target.kind = chat` | 4 000 символов |\n| Длина текста для `target.kind = task` | 20 000 символов |\n| Упоминаний для `target.kind = task` | не больше 20, у каждого — право чтения задачи-цели |\n| Суммарный объём файлов цели | 1 ГБ (`SPACE_LIMIT`): у задачи — все вложения, у переписки — только отправленные файлы |\n| Одновременные передачи файлов на сервер | 2 «больших» (> 10 МБ) и 12 малых, иначе `503`; слот выбирается по суммарному размеру копий |\n\nТаблицы: [message_reactions](#модели/dbmessage-reactions), [comments](#модели/dbcomments),\n[direct_messages](#модели/dbdirect-messages), [attachments](#модели/dbattachments),\n[direct_files](#модели/dbdirect-files).\n"},{"name":"Друзья и чаты","description":"Друзья, личная переписка 1:1 с вложениями, настроение дня и «радость» от выполненных задач.\n\n**Дружба.** На пару пользователей существует не больше одной записи [friendships](#модели/dbfriendships)\n(уникальный индекс по `LEAST/GREATEST(requester_id, recipient_id)`), статусы `pending` → `accepted`.\n\n| Действие | Кто может | Что происходит |\n|---|---|---|\n| Заявка `POST /social/friends` | любой | только к **открытому** профилю человека (`public_profile`, `account_kind = human`); ответ — `{ ok, id, status, outgoing }` |\n| Встречная заявка | — | если собеседник уже отправил вам заявку (`pending`, входящая), ваш `POST /social/friends` её **принимает**: статус становится `accepted` |\n| Повторная заявка | — | если исходящая заявка или дружба уже есть, ничего не меняется; ответ описывает существующую запись |\n| Принять `POST …/accept` | только получатель заявки | статус становится `accepted` |\n| Удалить `DELETE …/{id}` | любая сторона | отклонить, отозвать заявку или удалить друга; нет записи — `404 «Контакт не найден»` |\n\nПараллельные заявки одной пары (в любом направлении) сериализуются транзакционной advisory-блокировкой.\n\nУдаление дружбы **необратимо**: каскадом удаляются вся переписка ([direct_messages](#модели/dbdirect-messages)),\nфайлы ([direct_files](#модели/dbdirect-files)) и реакции на сообщения; объекты в S3 ставятся в очередь на удаление.\n\n**Личные сообщения** доступны только при статусе `accepted`. Текст до 4 000 символов (после `trim`), до 10 вложений,\nможно ответить на сообщение той же переписки. История — страницами по 100 сообщений. Курсор — поле `cursor`\nсообщения вида `<время с микросекундами>~<id>` (например `2026-10-02T07:41:09.330412Z~2d3e4f5a-…`): сообщения\nс одинаковым временем упорядочены по `id`, поэтому на границе страниц ничего не теряется. С `?format=page` ответ —\n[SocialDirectMessagePage](#модели/socialdirectmessagepage) с `hasOlder` и `nextCursor`; без него — прежний массив.\n\n**Вложения чата** отправляются в два шага:\n1. `POST /social/friends/{id}/files` — загрузка одного файла (`multipart/form-data`, поле `file`, до 100 МБ, не пустой).\n   Файл становится «неотправленным» (черновиком): его видит и может удалить только автор.\n2. `POST /social/friends/{id}/messages` с `attachmentIds` — файлы привязываются к сообщению и становятся\n   видны собеседнику. После отправки удалить файл через API нельзя.\n\n| Ограничение | Значение |\n|---|---|\n| Отправленные файлы одной переписки | не больше **1 ГБ**; проверяется при загрузке и при отправке сообщения |\n| Неотправленные файлы одного автора (во всех переписках) | не больше **1 ГБ** за последние 24 часа |\n| Срок жизни неотправленного файла | **24 часа** (`UNSENT_TTL`): после этого его нельзя отправить (`403`) и скачать (`404`) |\n\nПросроченные черновики удаляет фоновая очистка раз в час и перед каждой загрузкой (до 200 файлов за проход;\nобъекты S3 ставятся в очередь удаления). Файлы хранятся в БД или в S3 (зашифрованы серверным ключом).\n\n**Имя файла.** Заголовки частей multipart декодируются как UTF-8 (`defParamCharset: utf8`), поэтому кириллица в\n`filename=\"Отчёт.pdf\"` приходит без искажений. Пустой файл (0 байт) отклоняется с `400`\n«Файл пустой. Выберите файл с содержимым». Затем имя нормализуется в NFC, управляющие символы, `/`, `\\` и символы\nуправления направлением текста (`U+202A–U+202E`, `U+2066–U+2069`)\nзаменяются на `_`, пробелы по краям обрезаются, длина ограничивается 200 символами (кодовыми точками Unicode).\nПустое имя превращается в `file` (`uploadName`, media.ts).\n\nПоддерживаемые форматы (`detectAttachment`, tasks.ts) — по сигнатуре содержимого:\nPNG, JPEG, GIF, WebP, PDF, MP4 (`ftyp`), WebM, MP3; по расширению при ZIP-сигнатуре: `docx`, `xlsx`, `pptx`,\n`odt`, `ods`, `zip`; при OLE-сигнатуре: `doc`, `xls`, `ppt`; текст в UTF-8 без управляющих символов:\n`txt`, `md`, `csv`, `json`. Остальное → `400` с сообщением `ATTACHMENT_FORMATS`:\n«Поддерживаются изображения, видео, аудио, PDF, Office, ZIP и текстовые файлы (TXT, MD, CSV, JSON)».\n\n**Настроение дня.** Одна запись [mood_entries](#модели/dbmood-entries) на пользователя и **локальный** день\n(часовой пояс из профиля, `users.timezone`): оценка 1–5, заметка до 1 000 символов, флаг `shareWithFriends`.\nДрузья (только `accepted`) видят в списке друзей оценку и заметку **за сегодняшний день друга** и только при\n`shareWithFriends = true`. История за 28 дней видна только владельцу.\n\n**Радость от задачи.** Баллы 1–5 за задачу, которую завершили вы (`tasks.completed_by`). Одна оценка на задачу,\nповторная запись игнорируется. Хранится в [task_mood_rewards](#модели/dbtask-mood-rewards).\n\n**Уведомления и realtime.** Заявки и личные сообщения **не создают** записей в ленте уведомлений\n(строка [notifications](#модели/dbnotifications) всегда привязана к задаче, `task_id NOT NULL`).\nИзменения `direct_messages`, `friendships`, `mood_entries` и реакции на личные сообщения рассылаются\nобоим участникам (для настроения — владельцу и его друзьям) как `changed` с областью `friends`.\n"},{"name":"Уведомления","description":"Лента уведомлений пользователя о задачах. Таблица [notifications](#модели/dbnotifications).\n\n**Виды уведомлений** (`CHECK` из миграции 017):\n\n| `kind` | Когда создаётся | Получатель | Ключ дедупликации `dedup_key` | Настройка |\n|---|---|---|---|---|\n| `assigned` | задачу создали с исполнителем или сменили исполнителя (boards.ts) | новый исполнитель, если он не автор изменения | случайный UUID — каждое назначение даёт новое уведомление | `assignments` |\n| `mention` | новый комментарий содержит упоминание получателя; при изменении комментария — только **новые** упоминания | упомянутый | ID комментария; для упоминаний, добавленных изменением, — `{commentId}:mention` | `comments` |\n| `reply` | новый комментарий — ответ (`replyTo`) на комментарий получателя | автор непосредственного родителя | ID комментария | `comments` |\n| `comment` | любой другой новый комментарий | исполнитель, создатель задачи, участники задачи (`task_members`), все прежние комментаторы задачи | ID комментария | `comments` |\n| `due_soon` | срок задачи сегодня или завтра (по часовому поясу получателя) | исполнитель, а если его нет — создатель | `{taskId}:due_soon:{YYYY-MM-DD}` | `deadlines` |\n| `overdue` | срок задачи прошёл | исполнитель, а если его нет — создатель | `{taskId}:overdue:{YYYY-MM-DD}` | `deadlines` |\n\n- Уникальность `(user_id, dedup_key)`: на один комментарий пользователь получает не больше одного уведомления,\n  приоритет вида — `mention` > `reply` > `comment`. Автор комментария себе уведомлений не получает.\n  Упомянутый, добавленный при изменении комментария, получает отдельное `mention` с ключом `{commentId}:mention`\n  (не больше одного на комментарий), даже если уже получил `comment` или `reply` по этому комментарию.\n- Комментарные уведомления создаются при обычном комментарии, при пересылке в задачу\n  (`POST /messages/forward`, включая `mention` для упомянутых в пересланном тексте), а также при комментариях\n  ИИ-сотрудника (ai.ts) и шагов агентов (agent-workflows.ts).\n- Сроки проверяет **фоновая** задача: при запуске API и затем каждые 60 секунд; advisory-блокировка\n  не даёт нескольким экземплярам API сканировать одновременно. Кроме того, напоминание создаётся сразу, в той же\n  транзакции, когда задачу создают со сроком или меняют её срок, исполнителя или колонку (boards.ts).\n  `GET /notifications` уведомления **не генерирует**. Задачи в последней колонке доски (по `position`)\n  и без срока не учитываются.\n- Отключение настройки останавливает создание новых уведомлений этого типа; уже созданные остаются.\n\n**Фильтрация при чтении.** Лента скрывает (не удаляя) устаревшие записи: `assigned`, если получатель больше\nне исполнитель; `due_soon`/`overdue`, если задача в последней колонке, срок изменился, получатель сменился, а также\n`due_soon`, когда срок уже просрочен; уведомления об удалённых комментариях; любые уведомления по задачам,\nк которым у пользователя больше нет доступа. Фильтры применяются в SQL **до** `LIMIT 100`, поэтому лента отдаёт\n100 последних видимых записей (меньше — только если видимых меньше), а `unreadCount` считается по всем видимым\nнепрочитанным, а не только по возвращённым.\n\n**Глубокие ссылки.** У комментарных уведомлений есть `commentId`: передайте его в\n`GET /api/v1/boards/{boardId}/tasks/{taskId}/comments/{commentId}/thread` — сервер вернёт ветку,\nстраница которой заканчивается этим ответом.\n\n**Realtime.** Любая вставка, изменение или удаление строки `notifications` (включая отметку о прочтении)\nотправляет владельцу `changed` с областью `notifications`, так что счётчик синхронизируется между вкладками.\n"},{"name":"TEAM","description":"Командное пространство организации: сводка, поиск задач, бэклог идей и спринты. Все пути начинаются с `/api/v1/team/{space}`, где `space` — UUID организации.\n\n### Кому доступно\n\n| Роль в организации | Сводка, задачи, бэклог | Спринты: создание, состав, запуск и завершение |\n|---|---|---|\n| `owner`, `admin` | да, все доски организации | да |\n| `member` | да, в пределах видимых досок и задач | нет, 403 «Нужны права администратора организации» |\n| `guest` | нет, 403 «TEAM доступен сотрудникам организации. Гостю доступны назначенные доски.» | нет, 403 |\n| не участник | 404 «Организация не найдена» | 404 |\n\nГость продолжает работать с явно назначенными досками через API досок. Невалидный UUID в `space` даёт 400.\n\n### Видимость данных\n\nСводка, список задач и состав спринтов строятся по одному набору видимых досок и задач. Правила те же, что при проверке доступа к доске и задаче:\n\n- **Доска видна**, если пользователь — `owner` или `admin` организации, участник доски ([board_members](#модели/dbboard-members)) или доска имеет `visibility: workspace`, а пользователь — `member`.\n- **Роль на доске:** у `owner` и `admin` организации — `admin`, у остальных — роль из `board_members`, по умолчанию `viewer`.\n- **Задача видна**, если у неё `visibility: board` или пользователь — `admin` доски, автор, исполнитель либо участник задачи.\n\nЗакрытые доски и задачи не попадают в чужие счётчики.\n\n### Иерархия\n\nОрганизация → проект → этап → доска → задача. Проекты и этапы описаны в разделе «Проекты». Доска может не входить в проект: у независимой доски `projectId: null`. Доска проекта может не входить в этап: тогда `phaseId: null`. Привязка доски задаётся в API досок через поля `projectId` и `phaseId`.\n\n### Бэклог\n\nЗаписи [team_backlog](#модели/dbteam-backlog) хранят идеи до появления задачи.\n\n- **Статусы:** `idea`, `ready`, `parked`. Статус `transferred` ставит только сервер при переносе на доску.\n- **Видимость:** `team` — запись видят все сотрудники; `private` — только автор и `owner`/`admin`. Для остальных чужая запись `private` не существует: `PATCH` и `convert` отвечают 404 «Запись бэклога не найдена», а не 403.\n- **Права:** изменять и переносить запись может её автор или `owner`/`admin`. Другой сотрудник получает 403 с пояснением только для записи `team`.\n- **Архив:** обратим, достаточно передать `archived: false`. `PATCH` без поля `archived` архив не меняет.\n- **Частичное обновление:** `PATCH` меняет только переданные поля, остальные сохраняются.\n- **Перенос** (`convert`) создаёт ровно одну задачу. Строка бэклога блокируется `FOR UPDATE`, поэтому повторный или параллельный перенос получает 409.\n\n### Спринты\n\nСпринты хранятся в [team_sprints](#модели/dbteam-sprints), их задачи — в [team_sprint_tasks](#модели/dbteam-sprint-tasks). Спринт проходит путь `planned` → `active` → `completed`.\n\n- **Один активный спринт на организацию.** Это обеспечивают уникальный частичный индекс `one_active_team_sprint` и блокировка строки организации.\n- **Задача входит максимум в один незавершённый спринт** организации.\n- **Оценка** задачи в спринте — `points` от 0 до 100.\n- **Запуск** фиксирует `committedTasks` и `committedPoints`.\n- **Завершение** фиксирует признак выполнения каждой задачи и итоги `completedTasks` и `completedPoints`. Незавершённые задачи можно перенести в запланированный спринт через `carryTo`. Статусы и сроки самих задач не меняются.\n- **Итоговые показатели** видят только `owner` и `admin`, остальным приходит `null`.\n\n### Лимиты\n\n- `GET …/tasks` и `GET …/backlog` возвращают не больше 500 элементов. Если есть ещё, в ответе `truncated: true`.\n- Сводка содержит последние 50 спринтов по дате создания.\n- Счётчики сводки считаются по всем видимым задачам, без ограничения.\n\n### Версии и тела запросов\n\nИзменяемые записи несут `version`. Запрос с устаревшей версией получает 409, успешный — новую версию. Неизвестные поля JSON сервер молча отбрасывает (zod-объекты без `.strict()`).\n\n### Побочные эффекты\n\nИзменения пишут событие в журнал [workspace_events](#модели/dbworkspace-events) в той же транзакции; см. раздел «Журнал активности». Вставка события запускает PostgreSQL `NOTIFY`. WebSocket-клиенты организации получают сигнал `changed` со списком областей (`boards`, `tasks`, `team`, `wiki`, `agents`), без данных, и перезапрашивают данные по HTTP.\n"},{"name":"Проекты","description":"Проекты ([workspace_projects](#модели/dbworkspace-projects)) и их этапы ([project_phases](#модели/dbproject-phases)) группируют доски организации: организация → проект → этап → доска.\n\n### Права\n\n- **Список** видят все участники организации. Гостю приходит пустой массив, не участнику — 404.\n- **Создавать и изменять** проекты и этапы могут только `owner` и `admin`. Остальные получают 403 «Нужны права администратора организации».\n\n### Модель\n\n- **Статусы проекта:** `active` (по умолчанию), `paused`, `completed`.\n- **Статусы этапа:** `planned` (по умолчанию), `active`, `completed`.\n- **Порядок этапов:** этап получает `position = max + 1` внутри проекта. Операций для перестановки этапов, удаления проекта или удаления этапа в API нет.\n- **Архив:** `archived: true` ставит `archivedAt`; доски проекта при этом не меняются. В архивном проекте нельзя создать или изменить этап. К нему нельзя привязать доску, запись бэклога или спринт: ответ 400 «Проект недоступен или архивирован». Чтобы вернуть проект из архива, отправьте `PATCH` с `archived: false`. `PATCH` без поля `archived` архив не меняет.\n- **Одна версия на проект и этапы.** Любое изменение проекта или его этапов увеличивает `version` проекта. В `PATCH` этапа передаётся `version` проекта: у этапов своей версии нет.\n- **Целостность связей:** связь доски с проектом и этапом защищена составными внешними ключами. Проект другой организации не принимается (400 «Проект недоступен или архивирован»), этап другого проекта — тоже (400 «Этап другой принадлежности»).\n- **Привязка доски** задаётся при создании доски или меняется администратором доски в API досок (`projectId`, `phaseId`). Перенос доски в другой проект запрещён, пока её задачи стоят в незавершённом спринте другого проекта.\n\n### Побочные эффекты\n\nКаждая операция записывает событие `project.created`, `project.updated`, `project.archived`, `phase.created` или `phase.updated` с `scope: project`.\n"},{"name":"Журнал активности","description":"Лента событий организации и отдельной доски из таблицы [workspace_events](#модели/dbworkspace-events).\n\n### Откуда берутся события\n\n- **Задачи.** События `task.*`, например `task.created`, `task.commented`, `task.sprint_added`, копирует триггер `mirror_workspace_task_event` из [task_events](#модели/dbtask-events) в той же транзакции. Миграция 011 перенесла старые события задач с исходными датами.\n- **Остальные действия**, а также `task.deleted`, сервер пишет напрямую: `board.*`, `column.*`, `team.*`, `member.*`, `invitation.*`, `backlog.*`, `sprint.*`, `project.*`, `phase.*`, `wiki.*`.\n- **Удаление** задачи или доски не удаляет её события.\n- **Публичная Wiki** не попадает ни в один журнал: её события пишутся с `workspace_id = NULL`.\n\n### Видимость в журнале организации\n\n| `scope` | Кто видит |\n|---|---|\n| `team`, `sprint`, `project` | все сотрудники (`owner`, `admin`, `member`) |\n| `admin` (приглашения, роли) | только `owner` и `admin` |\n| `backlog` | запись с видимостью `team`, свою запись; `owner` и `admin` — все |\n| `wiki` | опубликованную статью или свою статью (по текущему статусу); `owner` и `admin` — все |\n| `board` | события доступных досок; `owner` и `admin` — все, включая удалённые доски |\n| `task` | события видимых задач. События удалённых задач видят `owner`/`admin` организации и `admin` доски |\n\nГость получает 403 «Гостю доступна история назначенных досок». Ему открыт журнал отдельной доски.\n\n### Постраничная загрузка\n\nОба журнала (организации и доски) листаются одинаково (`cursorAt` и `parseCursor`, team-activity.ts):\n\n- **Сортировка:** `created_at DESC, id DESC`, по 50 событий на страницу.\n- **Курсор:** `nextCursor` имеет вид `<время UTC с микросекундами>~<UUID события>`, например `2026-09-30T14:05:11.482113Z~2c3d4e5f-8bad-4ecf-8a6c-7d8e9f0a1b2c`. Время форматирует PostgreSQL с полной точностью, поэтому события одной транзакции или одной миллисекунды не теряются и не повторяются на границе страниц. Передайте `nextCursor` в `before`, чтобы получить следующую страницу: вернутся события строго раньше по паре `(created_at, id)`. `nextCursor: null` — страниц больше нет.\n- **Не собирайте курсор сами** из `createdAt` и `id`: `createdAt` в JSON округлён до миллисекунд, и такой курсор может пропустить события. Отдельного поля курсора у событий нет, используйте `nextCursor`.\n- **Ошибки:** `before` должен состоять ровно из двух частей через `~`: дата-время ISO 8601 (с `Z` или смещением) и UUID. Иначе — 400 `ValidationError` (неверное время или UUID, лишний `~`).\n"},{"name":"Wiki","description":"На одном API работают две базы знаний: публичная Wiki продукта Metodox и внутренняя Wiki каждой организации.\n\n| | Публичная Wiki продукта | Внутренняя Wiki организации |\n|---|---|---|\n| `space` в пути | `public` | UUID организации |\n| `workspace_id` статьи | `NULL` | ID организации |\n| Чтение опубликованного | без входа — через раздел «Публичная Wiki» (сайт https://metodox.ru/wiki); любой вошедший пользователь — через этот раздел | сотрудники организации (`owner`, `admin`, `member`); гость — 403, посторонний — 404 |\n| Создание статей | редакторы платформы | любой сотрудник |\n| Правка черновиков | редакторы платформы | автор, пока статья не опубликована; `owner` и `admin` |\n| Публикация и правка опубликованного | редакторы платформы | `owner` и `admin` |\n\n**Редактор платформы** — пользователь с подтверждённым email (`users.email_verified = true`), который входит в переменную окружения `WIKI_ADMIN_EMAILS`. Переменная содержит список через запятую, регистр не учитывается. В приложении редактор открыт по адресу `/knowledge`.\n\n### Статусы\n\n- **Значения:** `draft` (при создании), `published`, `archived`. Статус меняется только через `PATCH /api/v1/wiki/pages/{id}`.\n- **Видимость:** статьи в статусе `draft` и `archived` видят только те, кто может их редактировать. Для остальных такой статьи не существует: 404 на чтение, сохранение, историю версий, загрузку и скачивание медиа (а не 403).\n- **Архив** не удаляет версии и медиа. Операций удаления статей и файлов в API нет.\n\n### Адрес статьи (slug)\n\n- **Формат:** регулярное выражение `^[a-z0-9]+(?:-[a-z0-9]+)*$`, не длиннее 120 символов. Допустимы строчные латинские буквы и цифры, группы разделяются одним дефисом; дефис в начале, в конце или два подряд не принимаются.\n- **Уникальность:** slug уникален внутри одной Wiki, то есть внутри организации или внутри публичной Wiki. Повтор даёт 409 «Такой адрес статьи уже существует».\n- **Изменение:** slug можно поменять при сохранении статьи.\n\n### Документ (Tiptap JSON)\n\n`body` — JSON-документ редактора Tiptap. Сервер не хранит его как есть: он строит новый документ только из разрешённых узлов и атрибутов (`validWikiBody`). HTML не принимается.\n\n**Узлы**\n\n| Узел | Что остаётся после нормализации | Ошибка 400 |\n|---|---|---|\n| `doc` | атрибуты удаляются; узел обязан быть корнем | «Нужен документ», если корень другого типа |\n| `paragraph`, `bulletList`, `listItem`, `blockquote`, `horizontalRule`, `hardBreak`, `taskList` | атрибуты удаляются | — |\n| `text` | `text` — обязательная строка | «Bad Request», если `text` не строка |\n| `heading` | `level`: 2, 3 или 4; любое другое значение, включая 1, заменяется на 2 | — |\n| `orderedList` | `start`: целое от 1 до 9999, иначе 1 | — |\n| `taskItem` | `checked`: `true`, только если передано именно `true`, иначе `false` | — |\n| `codeBlock` | `language` всегда `null` | — |\n| `image` | `src` — только `/api/v1/wiki/files/{id}`; `alt` приводится к строке и обрезается до 300 символов; `title: null` | «Загрузите изображение в эту статью», если `src` другой (внешние изображения запрещены) |\n\n**Метки (marks)**\n\n| Метка | Что остаётся | Ошибка 400 |\n|---|---|---|\n| `bold`, `italic`, `strike`, `underline`, `code` | атрибуты удаляются | — |\n| `link` | `href` не длиннее 2000 символов, без пробелов и управляющих символов (0x00–0x20). Значение начинается с `http://`, `https://` или `mailto:` (регистр не важен) либо равно `/api/v1/wiki/files/{id}`. Сервер всегда ставит `target: \"_blank\"` и `rel: \"noopener noreferrer\"` | «Недопустимая ссылка» |\n\n**Общие правила**\n\n- **Поля узла.** Сохраняются только `type`, `text`, `attrs`, `marks`, `content`; остальные поля отбрасываются. Атрибуты, которых нет в таблице, удаляются.\n- **Неизвестные типы.** Неизвестный тип узла даёт «Неподдерживаемый блок», неизвестная метка — «Неподдерживаемое форматирование». Метка, которая не является объектом, даёт «Некорректное форматирование».\n- **Массивы.** `marks` — массив не длиннее 10 элементов, `content` — массив. Иначе ответ 400 «Bad Request».\n- **Размеры документа.** Глубина вложенности — не больше 20, у корня глубина 0. Всего узлов — не больше 10 000. Узел, который не является объектом, тоже ошибка. Во всех трёх случаях ответ «Некорректный документ».\n- **Объём.** `JSON.stringify(body)` — не больше 180 000 символов, иначе «Статья слишком большая». Всё тело запроса ограничено JSON-парсером в 512 КБ.\n- **Что не проверяется.** Сервер не проверяет, какие узлы вложены в какие, и не запрещает метки на нетекстовых узлах.\n- **Медиа.** Каждая медиа-ссылка в документе (`image.src` или `link.href` вида `/api/v1/wiki/files/{id}`) должна вести на файл этой же статьи, иначе ответ 400 «Медиа должно принадлежать этой статье». ID проверяется как UUID.\n\n### Версии\n\nКаждое создание и каждое сохранение статьи пишет снимок в [wiki_versions](#модели/dbwiki-versions): номер версии, заголовок, описание, документ, статус и автора. Отдельной операции восстановления нет. Старую редакцию восстанавливают обычным сохранением её `body`.\n\n### Медиа\n\nФайлы хранятся в [wiki_files](#модели/dbwiki-files) и привязаны к одной статье.\n\n- **Лимиты:** от 1 байта до 100 МиБ на файл (пустой файл — 400 «Файл пустой. Выберите файл с содержимым»; в БД — `CHECK(size>0 AND size<=104857600)` для новых строк), 1 ГиБ на все файлы статьи.\n- **Имя файла** читается как UTF-8 (`defParamCharset: utf8`), поэтому кириллица в `filename` сохраняется. Затем имя нормализуется в NFC, управляющие символы, `/`, `\\` и символы управления направлением текста (`U+202A–U+202E`, `U+2066–U+2069`) заменяются на `_`, пробелы по краям обрезаются, длина — до 200 кодовых точек. Пустое имя заменяется на `media` (`uploadName`, media.ts).\n- **Типы** определяются по содержимому, той же функцией, что у вложений задач и чатов:\n  - по сигнатуре: PNG, JPEG, GIF, WebP, PDF, MP4, WebM, MP3;\n  - по сигнатуре ZIP и расширению: DOCX, XLSX, PPTX, ODT, ODS, ZIP;\n  - по сигнатуре OLE и расширению: DOC, XLS, PPT;\n  - по расширению: TXT, MD, CSV, JSON, если содержимое — корректный UTF-8 без управляющих символов (табуляция и переводы строк допустимы).\n- **Хранение:** при `STORAGE_DRIVER=s3` файл шифруется на сервере и кладётся в S3, иначе хранится в БД.\n- **Нагрузка:** число одновременных передач медиа ограничено общим лимитом процесса API (вместе с файлами задач и чатов): не больше 2 файлов крупнее 10 МиБ и 12 файлов поменьше. Сверх лимита — 503.\n- **Кто получает файлы.** Читатель статьи получает только файлы, на которые ссылается её текущий `body`. Редактор получает любые файлы статьи.\n"},{"name":"Публичная Wiki","description":"Чтение опубликованной публичной Wiki Metodox без входа в аккаунт.\n\n**Домены.** Эти маршруты работают и на домене приложения, и на основном сайте **https://metodox.ru**. На основном сайте Caddy проксирует в API только `/api/v1/wiki/public*`, остальные `/api/*` там отвечают 404.\n\n**Что отдаётся.** Только статьи с `workspace_id IS NULL` и `status = 'published'`. Внутренние Wiki организаций через эти маршруты недоступны, даже опубликованные. Черновики и архив не отдаются.\n\n**Ссылки на медиа в `body`.** В документе они хранятся как `/api/v1/wiki/files/{id}`, а этот маршрут требует входа. Поэтому статья отдаётся ещё и с полем `publicBody` — тем же документом, где `image.src` и `link.href` вида `/api/v1/wiki/files/{id}` заменены на анонимный `/api/v1/wiki/public/files/{id}`. Для показа без входа используйте `publicBody`; `body` оставлен для старых клиентов.\n\n**Список и усечение.** `GET /api/v1/wiki/public` отдаёт массив (для совместимости) не больше чем из 500 статей и ставит заголовок ответа `X-Truncated: true`, если статей больше, иначе `X-Truncated: false`.\n\n**Кэширование.** Глобальный middleware ставит на все ответы `Cache-Control: private, no-store`.\n\nУправление публичной Wiki описано в разделе «Wiki» (`space = public`).\n"},{"name":"Заметки","description":"Личные заметки пользователя. Заметку видит и меняет только её владелец\n([notes](#модели/dbnotes)): общего доступа, realtime-событий и уведомлений у заметок нет.\n\n### Поля\n\n| Поле | Тип | Ограничения (zod в `notes.ts`) |\n|---|---|---|\n| `title` | string | обязательно; `trim()`, затем 1–200 символов. Строка из одних пробелов не пройдёт |\n| `body` | string | до **100 000** символов (длина JS-строки), по умолчанию `\"\"`. Хранится как есть; веб-клиент показывает его как Markdown |\n| `folder` | string | `trim()`, до 60 символов, по умолчанию `\"\"` («Без папки») |\n| `pinned` | boolean | по умолчанию `false` |\n| `version` | integer | счётчик оптимистической блокировки, начинается с 1 |\n\nОграничения и значения по умолчанию в таблице относятся к созданию. В `PATCH` все поля, кроме\n`version`, необязательны: пропущенное поле сохраняет текущее значение.\n\nЛишние поля в теле запроса молча отбрасываются (схема не `.strict()`). Общий лимит JSON-тела — 512 КБ.\n\n### Папки\nОтдельной сущности «папка» нет: это просто строковое поле заметки. Создать, переименовать или\nудалить папку через API нельзя. Веб-клиент собирает список папок из уже загруженных заметок\n(уникальные значения `folder`). Чтобы «переименовать папку», обновите каждую заметку в ней.\n\n### Закреплённые\n`pinned: true` поднимает заметку в начало списка. Порядок ответа `GET /api/v1/notes`:\n`pinned DESC, updatedAt DESC, id DESC`.\n\n### Поиск\nСерверного поиска **нет**: у `GET /api/v1/notes` есть только параметры `trash` и `before`. Веб-клиент\nфильтрует загруженный список сам: подстрока в `title + \" \" + body` без учёта регистра плюс фильтр по папке.\n\n### Пагинация\nСписок отдаётся страницами по **300** заметок, отдельно для активных и для корзины. Каждая заметка\nв списке несёт непрозрачный `cursor`; следующая страница — `GET /api/v1/notes?before=<cursor последней\nзаметки>` (с тем же `trash`). Курсор кодирует позицию в порядке списка (`pinned`, `updatedAt`\nс микросекундами, `id`), поэтому заметки на границе страниц не теряются и не повторяются. Вместо курсора\nпринимается и `id` последней заметки. Страница короче 300 — последняя.\n\n### Корзина и восстановление\n- `DELETE /api/v1/notes/{id}` — мягкое удаление: у заметки ставится `deleted_at`, и она попадает в корзину (`GET /api/v1/notes?trash=true`). `updatedAt` получает момент переноса, поэтому в корзине недавно удалённые идут первыми.\n- `POST /api/v1/notes/{id}/restore` возвращает заметку из корзины.\n- `DELETE /api/v1/notes/{id}/permanent` удаляет одну заметку из корзины навсегда; активную так удалить нельзя (409).\n- `DELETE /api/v1/notes/trash` очищает корзину целиком.\n- Автоочистки корзины нет: заметки в ней лежат, пока их не удалят навсегда или пока не удалён аккаунт (`ON DELETE CASCADE`).\n- Заметку в корзине нельзя изменить: `PATCH` вернёт 404.\n\n### Версии\n**История версий не хранится.** `version` — только счётчик для оптимистической блокировки. Он\nрастёт на 1 при каждом `PATCH`, переносе в корзину (`DELETE`) и `restore`.\n- В `PATCH` нужно передать `version`, которую клиент видел последней. Если заметку уже изменили в другом окне или на другом устройстве, сервер ответит **409**: «Заметка изменена в другом окне. Скопируйте текст и откройте актуальную версию.»\n- `DELETE` и `restore` отвечают `{ ok: true }` без новой версии. После них перечитайте список, иначе следующий `PATCH` получит 409 или 404.\n\n### Частичное изменение\n`PATCH` меняет только переданные поля; пропущенные сохраняют текущие значения. Значений по\nумолчанию у схемы изменения нет, поэтому `{ pinned: true, version }` не сбросит текст или папку.\n"},{"name":"Планер","description":"Личный недельный план по задачам с досок. Сами задачи планер не меняет: он хранит, на какой день\nи на сколько минут пользователь поставил задачу ([task_plans](#модели/dbtask-plans)), и личную\nдневную цель (`users.daily_goal`).\n\n### Какие задачи попадают в план (`GET /api/v1/planner`)\n«Мои» задачи — те, где пользователь исполнитель, и задачи без исполнителя, которые он создал.\n- **Незавершённые** задачи (`completed_at IS NULL`): мои и те, которые пользователь сам запланировал.\n- **Завершённые** задачи — только если пользователь запланировал их на день внутри запрошенной недели.\n- Доступ проверяется в самом SQL-запросе по тем же правилам, что и открытие карточки задачи (`AccessService.board` + `AccessService.task`, уровень read): доска доступна по роли в организации, явной роли на доске или видимости `workspace`, `restricted`-задача — только админам доски, автору, исполнителю и участникам задачи. Задачи, к которым доступа больше нет (ушёл с доски, скрытая задача), в ответ не попадают.\n- Сортировка: `dueDate` (задачи без срока в конце), затем более новые. `LIMIT 300` применяется **после** фильтра доступа, поэтому недоступные задачи не занимают места в ответе.\n\n### Неделя\nПараметр `week` — любая дата `YYYY-MM-DD`. Окно плана — `week … week+6`; переданная дата **не**\nвыравнивается по понедельнику. Без параметра окно начинается с **понедельника текущей недели**\nпо часовому поясу пользователя (`date_trunc('week', …)`), и `week` в ответе — этот понедельник.\n\n### День (`GET /api/v1/planner/today`)\nЭкран «Сегодня»: четыре списка задач на календарный день пользователя (по `users.timezone`).\nЗдесь задача считается выполненной, пока стоит в последней колонке своей доски.\n- `overdue` — мои невыполненные задачи со сроком раньше сегодняшнего;\n- `today` — мои невыполненные задачи со сроком на сегодня;\n- `planned` — невыполненные задачи, которые пользователь поставил на сегодня в личном плане, кроме попавших в первые два списка;\n- `doneToday` — задачи в последней колонке, завершённые (`completed_at`) сегодня, если они мои или запланированы на сегодня.\n\nКаждая задача попадает не больше чем в один список. В списке — не больше **200** задач; порядок:\nу `doneToday` сначала недавно завершённые, затем у всех — срок (без срока в конце), приоритет\n(`high` → `low`), время создания. Доступ проверяется так же, как в недельном плане.\n\n### Назначение на день (`PATCH /api/v1/planner/tasks/{id}`)\n- План личный: первичный ключ `(user_id, task_id)`. Другие участники его не видят, `version` задачи не нужна и не меняется.\n- `day: null` снимает задачу с плана.\n- `minutes` — оценка времени, **5–480**, по умолчанию 30. Если не передать `minutes` при переносе на другой день, оценка сбросится на 30.\n- Достаточно права **чтения** задачи. Запланировать можно любую задачу, которую пользователь видит; после этого она появляется в его плане.\n\n### Дневная цель (`PATCH /api/v1/planner/goal`)\nЦелое **1–20**, по умолчанию 3. Возвращается как `dailyGoal` в `GET /api/v1/planner` и как\n`game.dailyGoal` в `GET /api/v1/motivation`. Сервер с ней ничего не сравнивает: клиент сам\nсопоставляет её с `completions` за день («2/3»).\n\n### Права и зависимости\nДля каждой задачи планер отдаёт `canEdit`, `done`, `version` и `doneColumnId` (последняя колонка\nдоски по `position`). Завершить задачу из планера можно только через доску:\n`PATCH /api/v1/boards/{boardId}/tasks/{id}` с телом `{ version, columnId: doneColumnId }`.\nВеб-клиент показывает кнопку только при `canEdit && !done`. Этот вызов проверяет:\n- право редактирования задачи (403 «Недостаточно прав на задаче»);\n- граф зависимостей. Нельзя завершить задачу, пока не завершены её подзадачи и блокирующие задачи: 409 «Сначала завершите подзадачи и блокирующие задачи…». Нельзя вернуть в работу задачу, от которой зависят уже завершённые: 409 «Сначала верните в работу завершённую родительскую или зависимую задачу.»;\n- `version` задачи (409 при устаревшей версии).\n\nНаграды за завершение описаны в разделе «Мотивация и люди».\n\n### Серии и календарь активности\nЗавершения записываются в [completion_records](#модели/dbcompletion-records): **одна запись на задачу**\n(первичный ключ `task_id`). Запись создаётся, когда задача впервые попадает в последнюю колонку,\nи принадлежит получателю награды (исполнителю, а если его нет — тому, кто завершил; только\nлюдям, не ИИ).\n- `local_day` — дата завершения в часовом поясе получателя (`users.timezone`, по умолчанию `Asia/Vladivostok`; меняется через `PATCH /api/v1/profile`).\n- `on_time` — у задачи есть срок, и он не раньше `local_day`.\n- Если задачу вернуть в работу и завершить снова, запись не меняется.\n\nГде используются записи:\n- `completions` в `GET /api/v1/planner` — сколько записей пришлось на каждый день окна.\n- Серия (`game.streak` в `GET /api/v1/motivation`) считается по уникальным `local_day`. `current` — длина последней цепочки дней подряд, если она закончилась сегодня или вчера (по поясу пользователя), иначе 0. `longest` — самая длинная цепочка за всё время.\n- `heatmap` — 28 дней по `local_day`, последний из них — «сегодня» пользователя.\n- `activity` (14 дней) и `stats.overdue` в `GET /api/v1/motivation` считаются по `tasks.completed_at` и `tasks.due_date`, но тоже в часовом поясе пользователя: дни совпадают с днями планера и `heatmap`.\n"},{"name":"Мотивация и люди","description":"Игровой слой поверх задач: монеты, опыт (XP), уровни, достижения, магазин оформления профиля\nи каталог публичных профилей. Таблицы: [coin_ledger](#модели/dbcoin-ledger),\n[achievement_catalog](#модели/dbachievement-catalog), [user_achievements](#модели/dbuser-achievements),\n[cosmetic_catalog](#модели/dbcosmetic-catalog), [user_cosmetics](#модели/dbuser-cosmetics),\n[equipped_cosmetics](#модели/dbequipped-cosmetics), [completion_records](#модели/dbcompletion-records).\n\n### Монеты и опыт\n- **Завершение задачи** (она впервые попала в последнюю колонку доски) приносит **+10 монет и +10 XP**. Получатель — исполнитель задачи, а если его нет — пользователь, который её завершил.\n- Начисляется только людям: если получатель — ИИ-сотрудник (`account_kind ≠ 'human'`), награды и записи о завершении нет.\n- **Только за первое завершение.** В журнал пишется строка с `source_key = task:<taskId>`, а этот ключ уникален. Если вернуть задачу в работу и завершить снова, ничего не начислится.\n- Награда за задачу приходит в ответе `PATCH /api/v1/boards/{id}/tasks/{taskId}` при смене колонки, в поле `reward`: `{ coins, xp, unlocked: [названия новых достижений], forActor }`. `forActor: false` значит, что награду получил исполнитель, а не тот, кто завершил. Задача, созданная сразу в последней колонке, завершается при создании: награда начисляется и приходит в поле `reward` ответа `POST /api/v1/boards/{id}/tasks`. Если задача не завершена или получатель — ИИ-сотрудник, поля `reward` в ответе нет.\n- **Достижения** разблокируются автоматически при завершении задач (`unlockedAt`). Монеты и XP за них начисляются только по `POST /api/v1/motivation/achievements/{id}/claim`.\n- **Уровень:** `level = floor(xp / 100) + 1`. XP никогда не тратится, покупки списывают только монеты.\n\n### Журнал монет\nКаждое движение монет — строка в `coin_ledger` с уникальным `source_key` (`UNIQUE`). Это не даёт\nначислить дважды даже при гонке запросов.\n\n| `reason` | `source_key` | `amount` |\n|---|---|---|\n| `task_completed` | `task:<taskId>` | `+10` |\n| `purchase` | `purchase:<userId>:<itemId>` | `−price` |\n| `achievement` | `achievement:<userId>:<achievementId>` | `+coins` достижения |\n\n`GET /api/v1/motivation` отдаёт последние 30 строк (`ledger`).\n\n### Блокировка кошелька\nВсё, что меняет баланс или оформление, выполняется в одной транзакции, которая сначала берёт\nстроку пользователя через `SELECT … FROM users … FOR UPDATE`:\n- завершение задачи блокирует строку получателя;\n- так же работают покупка, смена оформления и получение награды за достижение.\n\nБаланс не уходит в минус: `CHECK (coin_balance >= 0)`. Если отправить две покупки одного предмета\nодновременно, одна вернёт 201, другая — 409.\n\n### Каталог достижений\nСид в `migrations/007_notes_planning_rewards.sql`; `achievements.ts` только считает метрики.\n\n| id | Название | Условие | metric ≥ target | Награда |\n|---|---|---|---|---|\n| `first-step` | Первый шаг | Завершить первую задачу | `completed` ≥ 1 | 10 монет, 20 XP |\n| `momentum` | Набирая ход | Завершить 5 задач | `completed` ≥ 5 | 25 монет, 40 XP |\n| `builder` | Создатель результата | Завершить 25 задач | `completed` ≥ 25 | 80 монет, 100 XP |\n| `rhythm` | Свой ритм | Завершать задачи 3 дня подряд | `streak` ≥ 3 | 40 монет, 60 XP |\n| `steady` | Семь дней движения | Завершать задачи 7 дней подряд | `streak` ≥ 7 | 100 монет, 150 XP |\n| `on-time` | Точно в срок | Завершить 5 задач не позднее срока | `onTime` ≥ 5 | 35 монет, 50 XP |\n\nМетрики (`achievementProgress`):\n- `completed` — число записей `completion_records` пользователя;\n- `onTime` — сколько из них с `on_time = true`;\n- `streak` — **самая длинная** серия дней подряд (`longest`), а не текущая. Достижение за серию, выполненное однажды, остаётся выполненным.\n\n### Магазин оформления\nСид в `migrations/005_workspace_data.sql` и `007_notes_planning_rewards.sql`. В\n`GET /api/v1/motivation` каталог упорядочен по `slot`, затем по `price`.\n\n| itemId | Название | Слот | Цена, монет |\n|---|---|---|---|\n| `lime-frame` | Лаймовая рамка | `frame` | 50 |\n| `ocean-frame` | Океан | `frame` | 70 |\n| `ember-frame` | Искра | `frame` | 90 |\n| `graphite-frame` | Графит | `frame` | 110 |\n| `aurora-cover` | Северное сияние | `cover` | 120 |\n| `sunset-cover` | Золотой час | `cover` | 150 |\n| `cosmos-cover` | Дальний космос | `cover` | 180 |\n| `navigator-badge` | Навигатор | `badge` | 130 |\n| `architect-badge` | Архитектор идей | `badge` | 160 |\n| `night-badge` | Создатель движения | `badge` | 200 |\n\n### Покупка и оформление\n- **Покупка** проверяет по порядку: предмет есть в каталоге (иначе 404), ещё не куплен (иначе 409 «Уже приобретено»), монет хватает (иначе 409 «Недостаточно монет»). Купленный предмет остаётся навсегда, повторно его не купить.\n- **Оформление:** в каждом слоте (`frame`, `cover`, `badge`) надет не больше одного предмета (первичный ключ `(user_id, slot)`).\n  - `{ itemId }` надевает купленный предмет в **его** слот из каталога (поле `slot` в запросе при этом не используется) и заменяет предыдущий предмет этого слота.\n  - `{ itemId: null, slot }` снимает предмет с указанного слота.\n  - `{ itemId: null }` без `slot` снимает всё.\n- `activeCosmetic` — устаревшее поле «одного предмета», которое следует за надетым: при надевании становится этим предметом; при снятии одного слота сохраняется, если этот предмет всё ещё надет, иначе переходит на другой надетый предмет (порядок слотов `frame`, `cover`, `badge`); `null` — только когда не надето ничего. Полный список надетого — массив `cosmetics` в профиле.\n\n### Люди\n- `GET /api/v1/people` — каталог публичных профилей. Попадают только пользователи с `public_profile = true` и `account_kind = 'human'`, ИИ-сотрудники скрыты. Поиск по подстроке имени без учёта регистра: `q` обрезается до 80 символов и ищется **буквально** — `%`, `_` и `\\` экранируются и шаблонами не работают. Не больше **100** записей, сортировка по XP (по убыванию), затем по имени. Себя пользователь видит в каталоге, если его профиль публичный.\n- `GET /api/v1/people/{id}` — публичный профиль или свой собственный. Email, баланс и часовой пояс не раскрываются.\n- У ИИ-сотрудников публичного профиля нет: `GET /api/v1/people/{id}` для них отвечает 404, как для скрытого профиля.\n\n### Видимость профиля\nВидимость меняется через `PATCH /api/v1/profile/visibility` с телом `{ publicProfile: boolean }`.\nНовый аккаунт создаётся со скрытым профилем (публичность — по согласию). Скрытый профиль пропадает\nиз каталога, а `GET /api/v1/people/{id}` отвечает 404 «Профиль скрыт или не найден» всем, кроме владельца.\n"},{"name":"Личный сейф","description":"Личное хранилище паролей, аккаунтов, карт, контактов и секретных заметок со **сквозным\nшифрованием**. **Сервер никогда не получает ни мастер-пароль, ни открытые данные.** Он хранит\nтолько соль, проверочный конверт ([vault_configs](#модели/dbvault-configs)) и зашифрованные\nконверты записей ([vault_records](#модели/dbvault-records)). Ключ выводится и используется только\nна клиенте.\n\nВ открытом виде сервер видит метаданные: тип записи (`kind`), число записей, их `id`, `version`\nи `updatedAt`.\n\nНиже — контракт, который должен соблюдать нативный клиент, чтобы читать и писать те же данные,\nчто и веб-клиент (`apps/web/app/vault-crypto.ts`, `apps/web/app/vault/vault-page.tsx`).\n\n### 1. Вывод ключа\n| Параметр | Значение |\n|---|---|\n| KDF | **PBKDF2-HMAC-SHA256** |\n| Итерации | **600 000** (`KDF_ITERATIONS`) |\n| Пароль | мастер-пароль в UTF-8 (`TextEncoder`), без нормализации и `trim` |\n| Соль | 16 случайных байт → стандартный Base64 с паддингом, **ровно 24 символа** (например `kH83tpxNPSfspvfAojeOaQ==`) |\n| Длина ключа | 256 бит, ключ **AES-256-GCM** |\n\nВеб-клиент делает ключ неизвлекаемым (`extractable: false`) и держит его только в памяти.\nМастер-пароль на клиенте: 12–1024 символа, при создании — с подтверждением. Сервер эти правила\nне проверяет и проверить не может.\n\n### 2. Шифрование конверта\n```\niv         = 12 случайных байт, новые при каждом шифровании\nplaintext  = UTF-8( JSON.stringify(value) )\naad        = UTF-8( context )\nsealed     = AES-256-GCM(key, iv, plaintext, aad)   // шифртекст || 16-байтный тег (как в WebCrypto)\nenvelope   = { \"iv\": base64(iv), \"ciphertext\": base64(sealed) }\n```\n- Base64 — стандартный алфавит `A–Z a–z 0–9 + /` с паддингом `=`. URL-safe вариант, переводы строк и пробелы не пройдут (`^[A-Za-z0-9+/]+={0,2}$`).\n- `iv` — **ровно 16 символов** (12 байт).\n- `ciphertext` — **от 24 до 100 000 символов**. Значит, открытый JSON не может быть длиннее ≈ 74 984 байт (75 000 − 16 байт тега).\n- Тег аутентификации — 128 бит, дописывается **в конец** шифртекста. Если ваша библиотека (например, CryptoKit `combined`) кладёт nonce в начало, разберите результат и отправляйте `iv` отдельно.\n\n### 3. Контексты AAD\n| Что шифруется | `context` (AAD) | `value` |\n|---|---|---|\n| Проверочный конверт | `metodox-vault:<userId>` | строка `\"metodox-vault-v1\"`: после `JSON.stringify` открытый текст — 18 байт **с кавычками** |\n| Запись | `metodox-record:<recordId>:<kind>` | JSON-объект строковых полей (ниже) |\n\n- `userId` — `id` из `GET /api/v1/profile`. `recordId` — `id` записи.\n- Подставляйте UUID **в нижнем регистре**. Сервер принимает `id` и в верхнем регистре (например `UUID().uuidString` в iOS), но сам приводит его к нижнему: так он сохраняется и так возвращается в ответе `POST /api/v1/vault` и в `GET /api/v1/vault`. Если клиент зашифровал запись с AAD в верхнем регистре, после перечитывания AAD не совпадёт и запись не расшифруется.\n- `id` и `kind` входят в AAD. Конверт нельзя перенести в другую запись, а тип записи нельзя поменять: `PATCH` и не принимает `kind`. Чтобы сменить тип, удалите запись и создайте новую.\n\n### 4. Создание и разблокировка сейфа\n1. `GET /api/v1/vault/config`. Поле `initialized` есть всегда: `{ initialized: false }` — сейфа ещё нет, `{ initialized: true, salt, verifier }` — сейф создан.\n2. **Создание:** сгенерируйте соль, выведите ключ, зашифруйте проверочный конверт и отправьте `POST /api/v1/vault/config { salt, verifier }`. Создать сейф можно один раз; повторный вызов вернёт 409. Требуется подтверждённый email (403), см. операцию.\n3. **Разблокировка:** выведите ключ из введённого пароля и `salt`, расшифруйте `verifier` с контекстом `metodox-vault:<userId>`, проверьте, что `JSON.parse(plaintext) === \"metodox-vault-v1\"`. Ошибка тега GCM (`OperationError` в WebCrypto) значит неверный пароль. Затем `GET /api/v1/vault` и расшифровка каждой записи.\n\n### 4а. Смена мастер-пароля (`POST /api/v1/vault/rekey`)\nСервер ключа не знает, поэтому перешифровывает всё клиент:\n1. Перечитайте `GET /api/v1/vault` и расшифруйте все записи старым ключом.\n2. Сгенерируйте новую соль, выведите новый ключ, зашифруйте новый проверочный конверт и **каждую** запись (новый `iv`, те же AAD `metodox-record:<id>:<kind>`).\n3. Отправьте одним запросом `{ salt, verifier, records: [{ id, envelope, version }] }`, где `version` — текущая версия каждой записи.\n\nСервер в одной транзакции заменяет соль, проверочный конверт и конверты всех записей; версия каждой записи растёт на 1. Набор должен совпасть **ровно** с текущими записями пользователя и их версиями: если запись добавили, изменили или удалили в другом окне, ничего не меняется и приходит 409 — иначе такая запись осталась бы зашифрованной старым паролем. Тело может быть до 8 MiB (не больше 10 000 записей).\n\nСбросить или удалить сейф целиком через API нельзя (не определено в коде). Восстановить забытый\nмастер-пароль невозможно. Сейф удаляется только вместе с аккаунтом (`ON DELETE CASCADE`).\n\n### 5. Типы записей и поля\n`kind` ∈ `password`, `account`, `card`, `contact`, `note`. Содержимое — JSON-объект **строк**.\nВеб-клиент сохраняет все поля формы, пустые — как `\"\"`. Поле `title` есть всегда: по нему\nстроится и ищется список.\n\n| kind | Подпись в UI | Поля объекта |\n|---|---|---|\n| `password` | Пароль | `title`, `login`, `password`, `url`, `notes` |\n| `account` | Аккаунт | `title`, `service`, `login`, `password`, `url`, `notes` |\n| `card` | Платёжная карта | `title`, `holder`, `number`, `expiry`, `bank`, `notes` |\n| `contact` | Контакт | `title`, `person`, `email`, `phone`, `company`, `notes` |\n| `note` | Заметка | `title`, `notes` |\n\nЛимиты полей задаёт только веб-форма: `title` до 200 символов, обычное поле до 1000, `notes` до\n4000. Для карт веб-клиент просит не хранить CVV/CVC и PIN. Поля, которых нет в этих списках,\nвеб-клиент при следующем сохранении записи потеряет: объект собирается заново из полей формы.\n\n### 6. Версии и конфликты\nУ каждой записи есть `version`; при создании она равна 1 и растёт на 1 при каждом `PATCH` и при смене мастер-пароля (`rekey`).\n`PATCH` принимает только `{ envelope, version }` с текущей версией. Если версия устарела —\n409 «Запись изменена. Перезагрузите хранилище.» Каждое сохранение шифрует данные заново\nс новым `iv`. Удаление окончательное, корзины нет.\n\n### 7. Автоблокировка (на клиенте)\nНа сервере понятия «разблокирован» нет: каждый запрос проверяется обычной сессией. Веб-клиент\nблокирует сейф сам:\n- после **5 минут** (300 000 мс) без событий `pointerdown`/`keydown`;\n- по кнопке «Заблокировать»;\n- при перезагрузке страницы, потому что ключ живёт только в памяти.\n\nПри блокировке из памяти стираются ключ, расшифрованные записи и открытая форма. WebCrypto работает\nтолько на HTTPS или `localhost`. Нативному клиенту рекомендуется так же держать ключ только в\nпамяти и сбрасывать его по таймеру бездействия.\n"},{"name":"ИИ-сотрудники","description":"ИИ-сотрудник — служебный участник организации, от имени которого языковая модель публикует ответ в обсуждении задачи. Сотрудником управляют владелец и администраторы организации; запускает его любой, кто может редактировать задачу.\n\n### ИИ-сотрудник как пользователь\n\n- При создании в [users](#модели/dbusers) появляется запись с `account_kind='ai'`, адресом `ai-<id>@metodox.invalid`, `public_profile=false` и хешем случайного пароля (32 случайных байта), который никому не сообщается.\n- Вход под такой учётной записью невозможен: вход по email и паролю отклоняется для любого `account_kind='ai'` с ответом «Неверный email или пароль». Поэтому ИИ-сотрудник никогда сам не вызывает API — его права проверяются только тогда, когда сервер действует от его имени (публикует комментарий).\n- Настройки хранятся в [ai_employees](#модели/dbai-employees) (ключ — `user_id`), членство — в [workspace_members](#модели/dbworkspace-members), расход — в [ai_usage](#модели/dbai-usage), запуски — в [ai_runs](#модели/dbai-runs).\n- Идентификатор ИИ-сотрудника — это `users.id`. Тот же `id` используется как исполнитель задачи (`assigneeId`), как `employeeId` этапа цепочки и в доступах сейфа организации.\n- Операции удаления ИИ-сотрудника в этом разделе нет. Чтобы остановить сотрудника, сохраните его с `enabled: false`.\n\n### Роли и права\n\n| Действие | Кто может |\n|---|---|\n| Список сотрудников, создание и изменение сотрудника с ролью `member` или `guest` | владелец (`owner`) и администратор (`admin`) организации |\n| Назначить роль `admin` или изменить ИИ-администратора | только владелец |\n| Сменить провайдера, модель, ключ, инструкции или специализацию сотрудника, которому выдан доступ к серверу в сейфе организации | только владелец |\n| Прямой запуск `POST /ai/{id}/run` | пользователь с правом редактирования задачи; ИИ должен быть исполнителем задачи |\n| Запуск цепочки | см. тег «ИИ-цепочки» |\n\n- Роль ИИ в организации — `admin`, `member` или `guest`; сделать ИИ владельцем через API нельзя.\n- Роль определяет доступ ИИ к доскам по обычной модели Metodox: владелец и администратор организации получают права администратора на всех её досках; `member` видит доски с видимостью `workspace` (как наблюдатель) и доски, где у него есть роль участника доски; `guest` — только доски, куда его явно добавили.\n- Прямой запуск требует, чтобы у самого ИИ было право редактирования задачи; исполнитель задачи получает его автоматически, если у ИИ есть доступ к доске. Если у ИИ нет доступа к доске или задаче, запуск отклоняется с 400 «У ИИ-сотрудника нет доступа к этой доске или задаче…»: ошибка называет сотрудника, а не вызывающего, и подсказывает, какую роль выдать.\n- Специализация (`specialty`: `assistant`, `developer`, `tester`, `analyst`) прав не даёт. Она используется только в системном промпте и заголовке отчёта цепочки.\n\n### Провайдеры и модели\n\n| | `openai` | `anthropic` | `ollama` |\n|---|---|---|---|\n| API | OpenAI Responses API | Anthropic Messages API | Ollama chat |\n| Запрос | `POST https://api.openai.com/v1/responses` | `POST https://api.anthropic.com/v1/messages` | `POST <OLLAMA_URL>/api/chat`, по умолчанию `http://127.0.0.1:11434/api/chat` |\n| Аутентификация | `Authorization: Bearer <apiKey>` | `x-api-key: <apiKey>`, `anthropic-version: 2023-06-01` | нет |\n| Тело запроса | `model`, `instructions` (системный промпт), `input` (текст задачи), `max_output_tokens`, `store: false` | `model`, `system`, `max_tokens`, `messages: [{ role: \"user\", content }]` | `model`, `stream: false`, `messages` (system + user), `options.num_predict` |\n| Текст ответа | все `output[].content[]` с `type: \"output_text\"`, через перевод строки | все `content[]` с `type: \"text\"`, через перевод строки | `message.content` |\n| Учёт токенов | `usage.total_tokens` | `usage.input_tokens + usage.output_tokens` | `eval_count + prompt_eval_count` |\n| Ключ для запуска | обязателен | обязателен | не нужен |\n\nОбщие правила для всех провайдеров:\n\n- **Провайдер** — только `openai`, `anthropic` или `ollama` (`z.enum([\"openai\", \"anthropic\", \"ollama\"])` и CHECK в БД).\n- **Модель** — произвольная строка, списка допустимых моделей нет: правило `z.string().trim().min(1).max(100)`. Название проверяет только сам провайдер при вызове; его отказ превращается в ответ 502. Модели по умолчанию нет — поле обязательно.\n- **Лимит ответа** — `maxOutputTokens`, целое 128–4096, по умолчанию 1024. Передаётся как `max_output_tokens` (OpenAI), `max_tokens` (Anthropic) или `options.num_predict` (Ollama).\n- **Температура** и другие параметры генерации не передаются — действуют значения провайдера по умолчанию.\n- **Таймаут** запроса к провайдеру — 60 с. Редиректы запрещены, потоковой передачи нет.\n- Ответ обрезается до 20 000 символов. Ответ провайдера с кодом не 2xx — ошибка 502 «Провайдер ответил HTTP N…», пустой текст — 502 «Модель не вернула текст…».\n- **Адрес Ollama** задаёт только переменная окружения сервера `OLLAMA_URL` (по умолчанию `http://127.0.0.1:11434`); через API или настройки организации его не изменить. Допускается адрес `http://` или `https://` без логина и пароля. Запрос уходит на `<origin><путь без завершающего />/api/chat`, query-строка отбрасывается. С неверным `OLLAMA_URL` API не запускается. Запрос отправляет процесс API, поэтому Ollama должна быть доступна по этому адресу из его сетевого окружения.\n\n### Что получает модель\n\n- **Системный промпт** прямого запуска — поле `instructions` сотрудника. Если оно пустое, используется встроенный текст: сотрудник команды готовит полезный результат по задаче, не заявляет о невыполненных действиях, у него нет доступа к секретам и внешним инструментам.\n- **Сообщение** прямого запуска — только название и описание задачи: `Задача: <название>`, пустая строка, описание. Комментарии, вложения, история и связанные задачи не передаются.\n- В цепочках промпт и сообщение другие — см. тег «ИИ-цепочки».\n\n### Публикация ответа\n\n- Ответ проходит тот же фильтр секретов, что и отчёты цепочек (см. «Фильтр вывода» в теге «Сейф организации»), и публикуется комментарием в [comments](#модели/dbcomments) от имени ИИ-сотрудника. В историю задачи пишется событие `commented`, участникам задачи создаются обычные уведомления о комментарии.\n- Прямой запуск синхронный: HTTP-ответ приходит после ответа модели, то есть до ~60 с.\n\n### Дневные лимиты\n\n- Расход учитывается по паре «сотрудник + календарный день UTC». Прямые запуски и этапы цепочек тратят один бюджет.\n- **Резервирование до вызова.** До вызова модели в одной транзакции засчитывается 1 запрос и резерв токенов: длина в байтах UTF-8 сообщения для модели и фактически отправляемого системного промпта + `maxOutputTokens` + 2048 (у этапа цепочки с SSH-операцией — + 40 000 вместо 2048). Системный промпт прямого запуска — `instructions` сотрудника или встроенный текст, если они пусты; у этапа цепочки — собранный промпт этапа. Это оценка по байтам, а не подсчёт токенизатором.\n- Запуск отклоняется, если запросов за день уже `≥ dailyRequests` или `токены за день + резерв > dailyTokens`.\n- После успешного ответа резерв заменяется расходом, который сообщил провайдер (если провайдер его не сообщил, остаётся резерв).\n- **Возврата при ошибке нет:** если вызов не удался, засчитанный запрос и резерв токенов остаются в расходе дня.\n- `dailyRequests` — 1–1000, по умолчанию 20; `dailyTokens` — 1000–2 000 000, по умолчанию 100 000. В списке сотрудников `usedRequests`/`usedTokens` показывают расход за текущий день UTC вместе с резервами.\n\n### Один запуск на задачу\n\n- В задаче может быть только одна запись `ai_runs` со статусом `running` (частичный уникальный индекс в БД).\n- Прямой запуск отклоняется, пока в задаче есть цепочка в статусе `queued` или `running`, и наоборот.\n- Во время проверок прямой запуск переводит в `failed` все записи `running` старше 5 минут — во всех задачах. Так снимаются запуски, зависшие после сбоя процесса. Изменение выполняется в транзакции запуска и сохраняется, только если сам запуск прошёл все проверки.\n\n### Ключи провайдеров\n\n- Ключ шифруется AES-256-GCM серверным ключом `AI_SECRET_KEY` (32 байта в base64). Формат хранения — `v2.` + base64 конверта. Дополнительные аутентифицированные данные (AAD) — `ai:<id сотрудника>:<провайдер>`, поэтому шифротекст не подходит другому сотруднику или провайдеру.\n- В `PATCH` пустой или отсутствующий `apiKey` оставляет сохранённый ключ, пока провайдер не меняется. При смене провайдера без нового ключа сохранённый ключ стирается: другого способа удалить ключ в API нет.\n- Записи старого формата (без префикса `v2.` и без AAD) поддерживаются только для расшифровки.\n- В production API не запускается, если `AI_SECRET_KEY` не задан или не является 32-байтным ключом в base64. При разработке ключ создаётся в локальном файле `.local/ai-secret.key`.\n- API никогда не возвращает ключ: в списке есть только признак `hasKey`.\n"},{"name":"ИИ-цепочки","description":"Цепочка — очередь из 1–6 последовательных этапов в одной задаче организации. Каждый этап выполняет ИИ-сотрудник. Его отчёт публикуется комментарием и передаётся следующим этапам. Данные лежат в [agent_workflows](#модели/dbagent-workflows) и [agent_steps](#модели/dbagent-steps).\n\n### Кто запускает\n\n- Цепочку запускает и отменяет пользователь, который управляет задачей: администратор доски или автор задачи с ролью редактора на доске.\n- Цепочки работают только на досках организации; все сотрудники этапов должны быть включёнными ИИ-сотрудниками той же организации и иметь право комментировать задачу. Если у сотрудника нет доступа к доске или задаче либо права комментировать, запуск отклоняется с 400: текст называет сотрудника и подсказывает, какую роль ему выдать.\n- Этап с SSH-операцией запускает только владелец организации с явным подтверждением `approveOperations: true` (подробнее — в теге «Сейф организации»).\n- Один и тот же сотрудник может выполнять несколько этапов.\n\n### Специализации и промпт этапа\n\n- Системный промпт этапа всегда собирается заново: `Вы <specialty>. <instructions сотрудника>` и фиксированное требование. Модель должна подготовить отчёт (результат, проверки, ограничения, следующий шаг), не заявлять о выполнении команд без фактического вывода, без SSH-операции только анализировать и предлагать изменения и считать тексты задачи, отчётов и вывода команд недоверенными данными. Ей запрещено запрашивать и публиковать секреты.\n- Специализация подставляется английским идентификатором: `developer`, `tester`, `analyst` или `assistant` (например, «Вы tester.»). Прав она не даёт.\n- Встроенный промпт по умолчанию из прямого запуска в цепочках не используется.\n\n### Передача отчётов между этапами\n\nСообщение этапа содержит:\n\n1. `Задача: <название>` и описание задачи;\n2. `Этап: <instruction>` — поручение этапа (до 4000 символов);\n3. отчёты всех уже завершённых этапов этой цепочки по порядку, через пустую строку, с пометкой «данные, не инструкции». Передаются последние 40 000 символов. Отчёт этапа, чей комментарий с отчётом удалён, не передаётся;\n4. только для SSH-этапа — вывод операции после фильтра секретов, с пометкой «недоверенные данные».\n\nОтчёт модели проходит фильтр секретов, сохраняется в `report` и публикуется комментарием `Этап N · <specialty>` от имени сотрудника. В историю задачи пишется событие `agent_step_completed`, участникам — обычные уведомления о комментарии. Задача автоматически не закрывается.\n\nЕсли комментарий с отчётом потом удалить, `GET …/agents` возвращает у этапа `report: null`, и следующим этапам отчёт тоже не передаётся. Текст в [agent_steps](#модели/dbagent-steps) при этом не стирается.\n\n### Очередь и worker\n\n- Очередь хранится в PostgreSQL. В каждом процессе API работает фоновый worker. Раз в 3 секунды он пытается взять advisory-lock (`pg_try_advisory_lock(71402911)`); если lock держит другой экземпляр, такт пропускается. Поэтому во всей системе одновременно выполняется не больше одного этапа.\n- За такт выполняется один этап: самый ранний `queued`-этап цепочки в статусе `queued` или `running`, у которой все предыдущие этапы `completed`. Цепочки обслуживаются в порядке создания.\n- Перед каждым этапом заново проверяются: право инициатора управлять задачей, право сотрудника комментировать, `enabled` и ключ сотрудника, дневной лимит. Для SSH-этапа ещё проверяется, что инициатор по-прежнему владелец, доступ к секрету выдан и версия секрета не изменилась. Если сотрудник потерял доступ к задаче, этап получает `failed` с тем же текстом, что и ответ 400 при запуске.\n- Настройки сотрудника (провайдер, модель, ключ, инструкции) читаются в момент выполнения этапа, а не при запуске цепочки.\n- **SSH-этап выполняется только с одобренными настройками.** При запуске цепочки в `agent_steps.employee_fingerprint` сохраняется SHA-256 от провайдера, модели, шифротекста ключа, инструкций и специализации сотрудника. Если к началу этапа они изменились, этап получает `failed` с текстом «Настройки ИИ-сотрудника изменились после подтверждения SSH-операции. Проверьте их и запустите цепочку заново.». Ни SSH, ни модель при этом не вызываются, лимит не резервируется. Сравнивается та же копия настроек, с которой затем вызывается модель, поэтому окна между проверкой и вызовом нет.\n- Лимиты общие с прямым запуском. Резерв этапа — байты сообщения этапа (без вывода SSH) и собранного системного промпта + `maxOutputTokens` + 2048, у SSH-этапа + 40 000. При ошибке резерв не возвращается.\n\n### Статусы цепочки\n\n| Переход | Когда |\n|---|---|\n| → `queued` | цепочка создана (`POST …/agents`) |\n| `queued` → `running` | worker зарезервировал лимит для первого этапа |\n| `running` → `completed` | все этапы в статусе `completed` |\n| `queued` / `running` → `failed` | этап завершился ошибкой или выполнение прервал перезапуск процесса |\n| `queued` / `running` → `cancelled` | вызвана отмена (`POST …/agents/{id}/cancel`) |\n\n`completed`, `failed` и `cancelled` — конечные статусы: повторного запуска нет, нужна новая цепочка.\n\n### Статусы этапа\n\n| Статус | Значение |\n|---|---|\n| `queued` | ждёт очереди. Этапы после упавшего остаются `queued` навсегда — цепочка уже `failed` |\n| `running` | лимит зарезервирован, выполняются SSH-операция и/или запрос к модели |\n| `completed` | отчёт сохранён в `report` и опубликован комментарием |\n| `failed` | ошибка; текст (до 300 символов) — в `error` |\n| `cancelled` | цепочку отменили до начала этапа или во время его выполнения; во втором случае отчёт не сохраняется и не публикуется. Если этап остановила отмена (до резервирования лимита или до SSH-команды), в `error` — причина, например «Цепочка отменена до запуска SSH-операции» |\n\n### Прерывание процесса\n\n- Если процесс API остановился во время этапа, на следующем такте worker, получивший lock, переводит все этапы `running` в `failed` с текстом «Процесс прерван. Проверьте результат на сервере перед новым запуском.», а их цепочки — в `failed`.\n- Тем же SQL-запросом в `failed` переводится запись [ai_runs](#модели/dbai-runs) прерванного этапа (тот же сотрудник и та же задача, статус `running`). Поэтому после восстановления в задаче сразу можно запустить новую цепочку или прямой запуск, без 409.\n- Прерванный этап не повторяется: SSH-команда могла уже выполниться на сервере. Резерв лимита не возвращается.\n\n### Отмена\n\n- Отмена переводит цепочку в `cancelled`, а её этапы в статусе `queued` — тоже в `cancelled`.\n- Статус цепочки проверяется при резервировании лимита, непосредственно перед SSH-командой и после ответа модели.\n- **Перед SSH-командой** worker в одной транзакции блокирует строку цепочки (`FOR UPDATE`), проверяет, что цепочка `queued`/`running`, а этап `running`, проверяет доступ к секрету и пишет `execute:<operation>` в журнал сейфа. Отмена ждёт ту же блокировку. Поэтому отмена либо успевает раньше, и тогда команда не запускается, а этап получает `cancelled` с текстом «Цепочка отменена до запуска SSH-операции». Либо отмена приходит, когда команда уже отправлена.\n- Уже запущенные запрос к модели и SSH-команда не прерываются и доходят до конца. Потом этап получает `cancelled`, отчёт отбрасывается, комментарий не публикуется, токены учитываются по фактическому расходу.\n- Если этап остановлен отменой до SSH-команды, резерв лимита не возвращается, как и при других ошибках этапа.\n- Отмена уже завершённой или несуществующей цепочки ничего не меняет и всё равно возвращает `{ ok: true }`.\n\n### Ограничения\n\n- В задаче может быть только одна активная (`queued`/`running`) цепочка — это гарантирует уникальный индекс. Пока в задаче идёт прямой запуск ИИ, цепочку запустить нельзя.\n- `GET …/agents` возвращает 20 последних цепочек задачи. Для этапов он предлагает только ИИ-сотрудников, которые сейчас состоят в организации.\n- Изменения этапов и событий задачи рассылаются по realtime-каналу событием `changed` (область `agents`).\n"},{"name":"Сейф организации","description":"Сейф организации хранит инфраструктурные доступы: SSH-ключи серверов, пароли и учётные записи. Это отдельное хранилище, не связанное с личным сейфом пользователя. Шифрование серверное, **не сквозное (не E2EE)**: сервер может расшифровать любую запись. Данные лежат в [workspace_secrets](#модели/dbworkspace-secrets), [secret_grants](#модели/dbsecret-grants) и [secret_audit](#модели/dbsecret-audit).\n\n### Кто имеет доступ\n\n- Все операции раздела доступны только владельцу организации (`owner`).\n- Администратор получает 403 «Доступами инфраструктуры управляет владелец организации», участник и гость — 403 «Нужны права администратора организации», пользователь вне организации — 404 «Организация не найдена».\n- ИИ-сотрудники не читают сейф через API (войти они не могут). SSH-секреты использует только worker цепочек, когда выполняет этап, подтверждённый владельцем.\n\n### Виды секретов\n\n| `kind` | Обязательно | Как используется |\n|---|---|---|\n| `ssh` | `host` — IP-адрес (IPv4 или IPv6, DNS-имена не принимаются); `username`; `value` — приватный ключ, строка должна содержать `PRIVATE KEY`; `fingerprint` — `SHA256:` и 43 символа base64. `port` по умолчанию 22 | именованные операции в этапах ИИ-цепочек |\n| `password` | непустой `value` | хранится и раскрывается владельцу; автоматического использования в коде нет |\n| `account` | непустой `value` | то же, что `password` |\n\n- `operations` — до 10 пар «имя → команда». Имя соответствует `^[a-zA-Z0-9_-]{1,40}$`, команда — 1–2000 символов, имена не повторяются. Операции принимаются для любого вида, но выполняются только у `ssh`.\n- Поля для пароля (passphrase) приватного ключа нет: ключ передаётся SSH-клиенту как есть.\n- Изменить сам секрет нельзя — только удалить и создать заново.\n- `version` начинается с 1 и увеличивается на 1 при каждом изменении доступов: выдаче нового доступа или отзыве существующего. Повторная выдача или отзыв отсутствующего доступа версию не меняют. Триггер БД также увеличивает версию при изменении `cipher`, `title` или `kind`, но операции, которая их меняет, в API нет. SSH-этапы, подтверждённые со старой версией, не выполняются (см. «Доступ ИИ-сотрудникам»).\n\n### Шифрование и `WORKSPACE_SECRET_KEY`\n\n- Весь проверенный объект секрета — вместе с `value`, `host`, `username`, `fingerprint` и `operations` — сериализуется в JSON и шифруется AES-256-GCM ключом `WORKSPACE_SECRET_KEY` (32 байта в base64).\n- AAD — `workspace:<id организации>:secret:<id секрета>`, поэтому шифротекст нельзя перенести в другую организацию или запись.\n- В открытом виде хранятся только `title`, `kind`, `version`, автор и дата создания.\n- В production API не запускается, если `WORKSPACE_SECRET_KEY` не задан или не является 32-байтным ключом в base64. При разработке ключ создаётся в локальном файле `.local/workspace-secret.key`.\n- Если запись больше не расшифровывается (ключ сменили или запись повреждена), список сейфа и панель цепочек показывают её с признаком `unreadable: true` вместо ошибки. Такой секрет остаётся только удалить и создать заново: раскрыть его или запустить с ним цепочку нельзя (ответ 500).\n\n### Чтение и раскрытие\n\n- Список никогда не содержит `value`, но возвращает остальные расшифрованные поля: адрес, порт, пользователя, fingerprint, операции вместе с командами и выданные доступы.\n- У секрета, который не расшифровывается, в списке есть только `id`, `title`, `kind`, `version` и `grants`. Остальные поля пустые (`host`, `username`, `fingerprint` — `\"\"`, `operations` — `[]`, `port` — `null`), добавлен `unreadable: true`.\n- `POST …/reveal` возвращает `value` в открытом виде (у `ssh` — приватный ключ) и пишет в журнал событие `revealed`. **Сам API время показа не ограничивает:** значение через 30 секунд скрывает веб-интерфейс. Как и все ответы API, ответ идёт с `Cache-Control: private, no-store`.\n\n### Доступ ИИ-сотрудникам\n\n- Доступ выдаётся только ИИ-сотрудникам этой же организации. Человеку выдать доступ нельзя — ответ 404.\n- Выдача доступа ничего не запускает. SSH-операцию в этап цепочки ставит владелец и подтверждает запуск флагом `approveOperations: true`.\n- Отзыв доступа действует на этапы, которые ещё не начались: перед SSH worker заново проверяет выдачу.\n- Любое изменение доступов секрета увеличивает его `version`. Поэтому ещё не начавшиеся SSH-этапы с этим секретом завершатся ошибкой «Доступ к секрету отозван или секрет изменён» у любого сотрудника, а не только у того, чей доступ изменился. Такую цепочку нужно запустить заново.\n\n### Журнал\n\n- Действия в `action`: `created`, `revealed`, `grant:<employeeId>`, `revoke:<employeeId>`, `deleted:<secretId>` и `execute:<operation>`. Событие `execute:` пишет worker перед SSH-командой, его автор — ИИ-сотрудник.\n- `GET …/audit/events` возвращает 100 последних событий; пагинации нет.\n- У событий `deleted:` и у событий уже удалённых секретов `title` равен `null`.\n\n### SSH-операции\n\n| Ограничение | Как реализовано |\n|---|---|\n| Только именованные команды | выполняется `command` сохранённой операции, имя которой указано в этапе. Текст задачи и ответ модели в команду не подставляются |\n| Разрешённые адреса | `AGENT_SSH_ALLOWED_HOSTS` — IP-адреса через запятую; `host` секрета должен совпасть с элементом списка как строка. Пустая переменная блокирует все подключения. Проверка выполняется при запуске цепочки, в признаке `ready` и перед самим подключением |\n| Ключ сервера | fingerprint обязателен. SHA-256 ключа хоста (base64 без завершающих `=`) сравнивается с сохранённым до аутентификации; при несовпадении подключение отклоняется |\n| Таймауты | подключение — 15 с, вся операция — 60 с, keepalive — каждые 10 с (не больше 2 пропусков) |\n| Вывод | stdout и stderr склеиваются. Больше 32 КБ (32 768 байт) — ошибка этапа, соединение закрывается |\n| Код выхода | ненулевой код — этап `failed`, следующие этапы не запускаются |\n| Режим | только `exec` без PTY; SFTP, туннели и переадресация не используются |\n| Подтверждение | SSH-этап запускает только владелец с `approveOperations: true`; перед выполнением worker проверяет, что инициатор всё ещё владелец, доступ выдан, версия секрета (она меняется и при изменении доступов) и настройки ИИ-сотрудника (`employee_fingerprint`) не изменились, а цепочку не отменили |\n| Повторы | автоматических повторов нет; таймаут или разрыв не гарантируют, что команда на сервере остановлена |\n\n### Фильтр вывода\n\nПеред передачей модели вывод SSH проходит фильтр. Тот же фильтр (без списка секретов) применяется к отчёту этапа цепочки и к ответу прямого запуска `POST /ai/{id}/run` перед публикацией.\n\n- PEM-блоки `-----BEGIN … PRIVATE KEY-----` … `-----END … PRIVATE KEY-----` заменяются на `[скрытый ключ]`.\n- Ключи в кавычках (JSON, YAML, Python): `\"password\": \"…\"`, `'api_key': '…'`, `\"Authorization\": \"…\"`. Скрывается строковое значение, ключ и кавычки остаются: `\"password\": \"[скрыто]\"`. Регистр не важен. Ключом считается любое имя, содержащее `password`, `passwd`, `token`, `secret` или `api_key` / `api-key` / `apikey`, например `db_password`, `accessToken`, `client_secret`.\n- Заголовок `Authorization:` или `authorization=` скрывается целиком вместе со схемой: `Authorization: Bearer <token>` → `Authorization: [скрыто]`. Так же обрабатываются `Basic`, `Digest` и `Token`.\n- Отдельно стоящее `Bearer <credential>` (от 16 символов) → `Bearer [скрыто]`.\n- Значение после `password`, `passwd`, `token`, `secret`, `api_key` / `api-key` / `apikey` со знаком `:` или `=` заменяется на `[скрыто]`. Значение — строка в кавычках или текст до пробела, запятой или `;`. Сюда же попадают переменные окружения вроде `GITHUB_TOKEN=…`.\n- Точные вхождения `value` самого секрета (приватного ключа) заменяются на `[скрыто]`.\n- Результат обрезается до 20 000 символов.\n\n**Фильтр эвристический.** Числовые значения в JSON (`\"token\": 12345`) и секреты под нестандартными именами не распознаются. Обычный текст тоже может пострадать: во фразе «password: at least 12 characters» скроется слово `at`. Не выводите секреты операциями: отфильтрованный вывод уходит выбранному провайдеру модели, а отчёт модели публикуется в обсуждении задачи.\n"}],"x-tagGroups":[{"name":"Аккаунт","tags":["Аутентификация","Почта и восстановление","Профиль","Служебное"]},{"name":"Организации и задачи","tags":["Организации","Доски","Задачи","Граф задач"]},{"name":"Общение","tags":["Обсуждения задач","Действия с сообщениями","Друзья и чаты","Уведомления"]},{"name":"TEAM и Wiki","tags":["TEAM","Проекты","Журнал активности","Wiki","Публичная Wiki"]},{"name":"Личное пространство","tags":["Заметки","Планер","Мотивация и люди","Личный сейф"]},{"name":"ИИ и автоматизация","tags":["ИИ-сотрудники","ИИ-цепочки","Сейф организации"]}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"Access-токен из ответа `POST /api/v1/auth/login` с `client: \"desktop\"` (desktop и мобильные клиенты).\nЖивёт 15 минут, обновляется через `POST /api/v1/auth/refresh`.\n"},"cookieAuth":{"type":"apiKey","in":"cookie","name":"__Host-md_access","description":"HttpOnly-cookie веб-клиента (в разработке — `md_access`). Изменяющие запросы с cookie\nобязаны передавать заголовок `Origin` из списка `WEB_ORIGIN`.\n"}},"schemas":{"Uuid":{"type":"string","format":"uuid","example":"8f1c2d4e-5a6b-4c7d-8e9f-0a1b2c3d4e5f"},"AccentColor":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$","description":"Акцентный цвет проекта, этапа или доски в виде `#rrggbb`; сохраняется в нижнем регистре.\nПока открыта доска, веб-клиент окрашивает интерфейс в её `accent`: собственный цвет доски,\nиначе цвет этапа, иначе цвет проекта. `null` — наследовать (или стандартный цвет Metodox).\n","example":"#7c9cff"},"Timestamp":{"type":"string","format":"date-time","description":"Момент времени в ISO 8601 (PostgreSQL `timestamptz`).","example":"2026-10-01T09:30:00.000Z"},"Ok":{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean","const":true}},"example":{"ok":true}},"Error":{"type":"object","description":"Стандартная ошибка NestJS. `message` — текст для пользователя (обычно на русском).","required":["statusCode","message"],"properties":{"statusCode":{"type":"integer","example":404},"message":{"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}],"example":"Задача не найдена"},"error":{"type":"string","example":"Not Found"}}},"ValidationError":{"type":"object","description":"Ответ `parse()` при ошибке zod-валидации тела, query или параметров.","required":["message","errors"],"properties":{"message":{"type":"string","example":"Проверьте поля формы"},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"}},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}}}}}},"example":{"message":"Проверьте поля формы","errors":{"formErrors":[],"fieldErrors":{"title":["Too small: expected string to have >=1 characters"]}}}},"AuthClientMode":{"type":"string","enum":["web","desktop"],"default":"web","description":"Как доставлять токены. `web`: HttpOnly-cookie, токены в теле не возвращаются. `desktop`: токены в теле ответа, дальше — заголовок `Authorization: Bearer`. Мобильное приложение тоже передаёт `desktop`.\n"},"AuthUser":{"type":"object","description":"Краткие данные пользователя в ответе на регистрацию и вход.","required":["id","email","name"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"email":{"type":"string","format":"email","description":"Email в нижнем регистре."},"name":{"type":"string","description":"Отображаемое имя."}}},"AuthLoginRequest":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string","format":"email","maxLength":254,"description":"Сервер приводит email к нижнему регистру. Пробелы по краям не обрезаются, и адрес с ними не проходит проверку.\n"},"password":{"type":"string","minLength":10,"maxLength":128},"name":{"type":"string","minLength":2,"maxLength":80,"description":"Необязательно. Поле проверяется (2–80 символов после обрезки пробелов), но при входе не используется.\n"},"client":{"$ref":"#/components/schemas/AuthClientMode"}}},"AuthRegisterRequest":{"type":"object","required":["email","password","name","acceptTerms"],"properties":{"acceptTerms":{"type":"boolean","const":true,"description":"Пользователь принял [пользовательское соглашение](https://metodox.ru/terms), [политику обработки персональных данных](https://metodox.ru/privacy) и дал [согласие на обработку данных](https://metodox.ru/consent). Без `true` — 400 «Примите соглашение и дайте согласие на обработку данных». Сервер записывает версию документов и время согласия.\n"},"email":{"type":"string","format":"email","maxLength":254,"description":"Уникальный email. Сервер приводит его к нижнему регистру."},"password":{"type":"string","minLength":10,"maxLength":128},"name":{"type":"string","minLength":2,"maxLength":80,"description":"Отображаемое имя; пробелы по краям обрезаются до проверки длины."},"client":{"$ref":"#/components/schemas/AuthClientMode"}}},"AuthWebTokens":{"type":"object","description":"Ответ в режиме `web`. Сами токены приходят в заголовках `Set-Cookie`.","required":["expiresIn"],"additionalProperties":false,"properties":{"expiresIn":{"type":"integer","const":900,"description":"Срок жизни access-токена в секундах."}}},"AuthNativeTokens":{"type":"object","description":"Ответ в режиме `desktop`. Пара токенов в теле.","required":["accessToken","refreshToken","expiresIn","tokenType"],"properties":{"accessToken":{"type":"string","description":"Access-токен, 43 символа base64url. Передаётся в `Authorization: Bearer`."},"refreshToken":{"type":"string","description":"Одноразовый refresh-токен, 43 символа base64url. Передаётся только в теле `POST /api/v1/auth/refresh`."},"expiresIn":{"type":"integer","const":900,"description":"Срок жизни access-токена в секундах."},"tokenType":{"type":"string","const":"Bearer"}}},"AuthWebLoginResponse":{"type":"object","description":"Регистрация или вход в режиме `web`.","required":["user","expiresIn"],"additionalProperties":false,"properties":{"user":{"$ref":"#/components/schemas/AuthUser"},"expiresIn":{"type":"integer","const":900,"description":"Срок жизни access-токена в секундах."}}},"AuthNativeLoginResponse":{"type":"object","description":"Регистрация или вход в режиме `desktop`.","required":["user","accessToken","refreshToken","expiresIn","tokenType"],"properties":{"user":{"$ref":"#/components/schemas/AuthUser"},"accessToken":{"type":"string","description":"Access-токен, 43 символа base64url."},"refreshToken":{"type":"string","description":"Одноразовый refresh-токен, 43 символа base64url."},"expiresIn":{"type":"integer","const":900},"tokenType":{"type":"string","const":"Bearer"}}},"AuthRefreshRequest":{"type":"object","description":"Тело можно не передавать, тогда действует `client = \"web\"`.","properties":{"refreshToken":{"type":"string","maxLength":200,"description":"Обязателен по смыслу для `client = \"desktop\"`; при `web` игнорируется."},"client":{"$ref":"#/components/schemas/AuthClientMode"}}},"AuthCurrentUser":{"type":"object","required":["id","name","email","emailVerified","consentRequired"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"name":{"type":"string"},"email":{"type":"string","format":"email"},"emailVerified":{"type":"boolean","description":"Подтверждён ли email по ссылке из письма."},"consentRequired":{"type":"boolean","description":"`true`, если пользователь ещё не подтвердил текущую версию документов. Клиент показывает форму согласия и вызывает `POST /api/v1/profile/consent`."}}},"AccountStatus":{"type":"object","required":["emailVerified","mailEnabled"],"properties":{"emailVerified":{"type":"boolean","description":"Подтверждён ли email текущего пользователя."},"mailEnabled":{"type":"boolean","description":"Включена ли на сервере отправка писем (`MAIL_ENABLED=true`)."}}},"AccountForgotPasswordRequest":{"type":"object","required":["email"],"properties":{"email":{"type":"string","format":"email","maxLength":254,"description":"Сервер приводит email к нижнему регистру."}}},"AccountForgotPasswordResponse":{"type":"object","required":["message"],"properties":{"message":{"type":"string","description":"Всегда один и тот же текст."}}},"AccountVerifyRequest":{"type":"object","required":["token"],"properties":{"token":{"type":"string","minLength":32,"maxLength":200,"description":"Значение `token` из фрагмента ссылки `#token=…`."}}},"AccountResetRequest":{"type":"object","required":["token","password"],"properties":{"token":{"type":"string","minLength":32,"maxLength":200,"description":"Значение `token` из фрагмента ссылки `#token=…`."},"password":{"type":"string","minLength":10,"maxLength":128,"description":"Новый пароль."}}},"OwnProfile":{"type":"object","description":"Профиль текущего пользователя.","required":["id","name","email","bio","jobTitle","timezone","avatarColor","publicProfile","balance","xp","activeCosmetic","investmentsAvailable","consentRequired"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"name":{"type":"string","description":"Отображаемое имя"},"email":{"type":"string","format":"email"},"bio":{"type":"string","description":"О себе. По умолчанию пустая строка."},"jobTitle":{"type":"string","description":"Должность. По умолчанию пустая строка."},"timezone":{"type":"string","description":"Часовой пояс IANA. По умолчанию `Asia/Vladivostok`."},"avatarColor":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$","description":"Цвет аватара. При регистрации выбирается случайно из 12 цветов палитры, чтобы коллег было легко различать на карточках."},"publicProfile":{"type":"boolean","description":"Виден ли профиль другим пользователям. Новый аккаунт создаётся со значением `false` (публичность — по согласию)."},"balance":{"type":"integer","minimum":0,"description":"Баланс монет (`users.coin_balance`)."},"xp":{"type":"integer","description":"Накопленный опыт."},"activeCosmetic":{"type":["string","null"],"description":"Идентификатор активного предмета оформления или `null`."},"investmentsAvailable":{"type":"boolean","description":"Служебный флаг закрытого раздела; для обычных аккаунтов всегда `false`.\n"},"consentRequired":{"type":"boolean","description":"Нужно ли подтвердить текущую версию документов (`POST /api/v1/profile/consent`)."}}},"OwnProfileUpdate":{"type":"object","required":["name","bio","jobTitle","timezone","avatarColor"],"properties":{"name":{"type":"string","minLength":2,"maxLength":80,"description":"Пробелы по краям обрезаются до проверки длины."},"bio":{"type":"string","maxLength":1000},"jobTitle":{"type":"string","maxLength":100},"timezone":{"type":"string","maxLength":80,"pattern":"^(UTC|[A-Z][A-Za-z_-]+(/[A-Za-z0-9_+-]+){1,2})$","description":"Только `UTC` или имя IANA вида `Область/Место` (`Europe/Moscow`, `America/Argentina/Buenos_Aires`, `Etc/GMT-3`). Проверки по порядку:\n- формат: иначе 400 «Выберите часовой пояс из списка, например Europe/Moscow» в `fieldErrors.timezone`. Голые смещения вида `+03:00` отклоняются: PostgreSQL в `AT TIME ZONE` читает их как POSIX-зону с обратным знаком. Однословные имена (`CET`, `EST`, `Japan`) тоже: PostgreSQL может принять их за аббревиатуру с фиксированным смещением;\n- пояс известен `Intl.DateTimeFormat`: иначе 400 «Неизвестный часовой пояс» в `fieldErrors.timezone`;\n- пояс известен PostgreSQL (`now() AT TIME ZONE …`): иначе 400 «Неизвестный часовой пояс» в формате `Error`.\n\nМиграция 028 перевела сохранённые ранее смещения в целый час в соответствующие зоны `Etc/GMT∓N` (UTC+3 → `Etc/GMT-3`), `+00:00` — в `UTC`.\n"},"avatarColor":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$"}}},"ProfileVisibilityUpdate":{"type":"object","required":["publicProfile"],"properties":{"publicProfile":{"type":"boolean","description":"`true` — профиль открыт, `false` — скрыт."}}},"ProfileSession":{"type":"object","required":["id","createdAt","expiresAt","current"],"properties":{"id":{"$ref":"#/components/schemas/Uuid","description":"ID сессии. Меняется при каждом обновлении токенов."},"createdAt":{"$ref":"#/components/schemas/Timestamp","description":"Когда выдана текущая пара токенов (вход или последнее обновление)."},"expiresAt":{"$ref":"#/components/schemas/Timestamp","description":"Когда истекает refresh-токен."},"current":{"type":"boolean","description":"`true` для сессии, чьим access-токеном выполнен запрос."}}},"ProfilePasswordChange":{"type":"object","required":["currentPassword","newPassword"],"properties":{"currentPassword":{"type":"string","minLength":1,"maxLength":128},"newPassword":{"type":"string","minLength":10,"maxLength":128}}},"HealthStatus":{"type":"object","required":["status"],"properties":{"status":{"type":"string","const":"ok"}}},"WorkspaceMemberRole":{"type":"string","enum":["owner","admin","member","guest"],"description":"Роль в организации. `owner` получает создатель организации; сменить владельца можно только передачей владения."},"BoardMemberRole":{"type":"string","enum":["admin","editor","viewer"],"description":"Роль на доске."},"TaskMemberRole":{"type":"string","enum":["editor","commenter","viewer"],"description":"Явная роль пользователя в задаче."},"BoardVisibility":{"type":"string","enum":["private","workspace"],"description":"`private` — только явные участники и админы организации; `workspace` — ещё и все участники организации с ролью `member` (как `viewer`)."},"TaskPriority":{"type":"string","enum":["low","medium","high"],"description":"Приоритет задачи."},"TaskVisibility":{"type":"string","enum":["board","restricted"],"description":"`board` — видят все, кто видит доску; `restricted` — только admin доски, автор, исполнитель и явные участники задачи."},"WorkspaceInput":{"type":"object","required":["name"],"properties":{"name":{"type":"string","minLength":2,"maxLength":80,"description":"Название организации. Пробелы по краям обрезаются до проверки длины."},"description":{"type":"string","maxLength":500,"default":"","description":"Описание. Если поле не передано, сохраняется пустая строка."}}},"WorkspaceUpdateInput":{"type":"object","description":"Частичное изменение организации. Пропущенное поле сохраняет текущее значение. Лишние поля отбрасываются.","properties":{"name":{"type":"string","minLength":2,"maxLength":80,"description":"Новое название. Пробелы по краям обрезаются до проверки длины."},"description":{"type":"string","maxLength":500,"description":"Новое описание. `\"\"` очищает его."}}},"WorkspaceTransferInput":{"type":"object","required":["userId"],"additionalProperties":false,"properties":{"userId":{"$ref":"#/components/schemas/Uuid","description":"ID участника, который станет владельцем. Только человек с ролью `admin` или `member`."}}},"WorkspaceDeleteInput":{"type":"object","required":["confirmName"],"additionalProperties":false,"properties":{"confirmName":{"type":"string","maxLength":200,"description":"Название организации, как в `GET /workspaces/{id}`. Сравнивается после обрезки пробелов по краям, с учётом регистра."}}},"WorkspaceTransferred":{"type":"object","required":["ok","ownerId","role"],"properties":{"ok":{"type":"boolean","const":true},"ownerId":{"$ref":"#/components/schemas/Uuid","description":"Новый владелец."},"role":{"type":"string","const":"admin","description":"Новая роль пользователя, который передал владение."}}},"WorkspaceSummary":{"type":"object","required":["id","name","description","role"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"name":{"type":"string","description":"Название"},"description":{"type":"string","description":"Описание"},"role":{"$ref":"#/components/schemas/WorkspaceMemberRole"}}},"WorkspaceCreated":{"type":"object","required":["id","name","description","role"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"name":{"type":"string"},"description":{"type":"string"},"role":{"type":"string","const":"owner","description":"Создатель всегда получает роль `owner`."}}},"WorkspaceMember":{"type":"object","required":["id","name","kind","email","jobTitle","avatarColor","role"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"name":{"type":"string","description":"Имя"},"kind":{"type":"string","enum":["human","ai"],"description":"Тип аккаунта (`users.account_kind`)."},"email":{"type":["string","null"],"format":"email","description":"Email участника. Гость видит только свой email, у остальных участников ему приходит `null`."},"jobTitle":{"type":"string","description":"Должность из профиля, может быть пустой строкой."},"avatarColor":{"type":"string","description":"Цвет аватара, например `#cfb39a`."},"role":{"$ref":"#/components/schemas/WorkspaceMemberRole"}}},"WorkspaceInvitation":{"type":"object","required":["id","email","role","expiresAt"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"email":{"type":"string","format":"email","description":"Email приглашённого, в нижнем регистре."},"role":{"type":"string","enum":["admin","member","guest"]},"expiresAt":{"$ref":"#/components/schemas/Timestamp","description":"Когда истекает приглашение."}}},"Workspace":{"type":"object","required":["id","name","description","role","members","invitations"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"name":{"type":"string"},"description":{"type":"string"},"role":{"$ref":"#/components/schemas/WorkspaceMemberRole","description":"Роль текущего пользователя."},"members":{"type":"array","description":"Все участники по старшинству роли (`owner`, `admin`, `member`, `guest`), внутри роли — по имени, затем по ID.","items":{"$ref":"#/components/schemas/WorkspaceMember"}},"invitations":{"type":"array","description":"Неистёкшие приглашения. Для `member` и `guest` всегда пустой массив.","items":{"$ref":"#/components/schemas/WorkspaceInvitation"}}}},"IncomingInvitation":{"type":"object","required":["id","role","name","expiresAt"],"description":"Приглашение, адресованное email текущего пользователя. ID организации не возвращается; он приходит в ответе на принятие.","properties":{"id":{"$ref":"#/components/schemas/Uuid"},"role":{"type":"string","enum":["admin","member","guest"],"description":"Роль, которую пользователь получит после принятия."},"name":{"type":"string","description":"Название организации."},"expiresAt":{"$ref":"#/components/schemas/Timestamp"}}},"InvitationAccepted":{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"$ref":"#/components/schemas/Uuid"}}},"WorkspaceInvitationInput":{"type":"object","required":["email","role"],"properties":{"email":{"type":"string","format":"email","maxLength":254,"description":"Email приглашённого. Приводится к нижнему регистру."},"role":{"type":"string","enum":["admin","member","guest"],"description":"Роль после принятия. `admin` может пригласить только владелец. Активное приглашение с ролью `admin` может изменить на другую роль или продлить тоже только владелец."}}},"WorkspaceRoleInput":{"type":"object","required":["role"],"properties":{"role":{"type":"string","enum":["admin","member","guest"],"description":"Новая роль. Выдать или снять `admin` может только владелец."}}},"BoardSummary":{"type":"object","required":["id","title","description","workspaceId","visibility","projectId","phaseId","projectTitle","phaseTitle","color","projectColor","phaseColor","accent"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"title":{"type":"string","description":"Название"},"description":{"type":"string","description":"Описание"},"workspaceId":{"type":["string","null"],"format":"uuid","description":"Организация; `null` — личная доска."},"visibility":{"$ref":"#/components/schemas/BoardVisibility"},"projectId":{"type":["string","null"],"format":"uuid","description":"Проект организации."},"phaseId":{"type":["string","null"],"format":"uuid","description":"Этап проекта."},"projectTitle":{"type":["string","null"],"description":"Название проекта."},"phaseTitle":{"type":["string","null"],"description":"Название этапа."},"color":{"anyOf":[{"$ref":"#/components/schemas/AccentColor"},{"type":"null"}],"description":"Собственный цвет доски; `null` — наследует этап или проект."},"projectColor":{"anyOf":[{"$ref":"#/components/schemas/AccentColor"},{"type":"null"}],"description":"Цвет проекта."},"phaseColor":{"anyOf":[{"$ref":"#/components/schemas/AccentColor"},{"type":"null"}],"description":"Цвет этапа."},"accent":{"anyOf":[{"$ref":"#/components/schemas/AccentColor"},{"type":"null"}],"description":"Итоговый цвет: `color`, иначе цвет этапа, иначе проекта; `null` — стандартный."}}},"BoardInput":{"type":"object","required":["title"],"properties":{"title":{"type":"string","minLength":1,"maxLength":100,"description":"Название. Пробелы по краям обрезаются."},"description":{"type":"string","maxLength":500,"default":""},"workspaceId":{"type":["string","null"],"format":"uuid","description":"Организация. Если не передано или `null`, создаётся личная доска."},"projectId":{"type":["string","null"],"format":"uuid","description":"Проект организации (не архивный). Для личной доски — 400."},"phaseId":{"type":["string","null"],"format":"uuid","description":"Этап выбранного проекта. Без `projectId` — 400."},"visibility":{"allOf":[{"$ref":"#/components/schemas/BoardVisibility"}],"default":"private","description":"Для личной доски всегда сохраняется `private`."},"color":{"anyOf":[{"$ref":"#/components/schemas/AccentColor"},{"type":"null"}],"description":"Собственный цвет доски. Без поля или `null` — цвет наследуется от этапа или проекта."}}},"BoardCreated":{"type":"object","required":["id","title","description","workspaceId","visibility","projectId","phaseId","color"],"description":"ID и принятые поля после обработки (`title` без пробелов по краям, `description` по умолчанию `\"\"`).\n`visibility`, `projectId` и `phaseId` — значения, записанные в базу: у личной доски `visibility` всегда `private`, непереданные проект и этап — `null`.\n","properties":{"id":{"$ref":"#/components/schemas/Uuid"},"title":{"type":"string"},"description":{"type":"string"},"workspaceId":{"type":["string","null"],"format":"uuid","description":"Организация; `null` — личная доска."},"projectId":{"type":["string","null"],"format":"uuid"},"phaseId":{"type":["string","null"],"format":"uuid"},"visibility":{"$ref":"#/components/schemas/BoardVisibility"},"color":{"anyOf":[{"$ref":"#/components/schemas/AccentColor"},{"type":"null"}],"description":"Записанный собственный цвет доски."}}},"BoardUpdateInput":{"type":"object","description":"Все поля необязательны. Непереданные поля не меняются.","properties":{"title":{"type":"string","minLength":1,"maxLength":100,"description":"Название. Пробелы по краям обрезаются."},"description":{"type":"string","maxLength":500},"visibility":{"allOf":[{"$ref":"#/components/schemas/BoardVisibility"}],"description":"На личной доске игнорируется, всегда сохраняется `private`."},"projectId":{"type":["string","null"],"format":"uuid","description":"Новый проект или `null`, чтобы отвязать. Если проект меняется, а `phaseId` не передан, этап сбрасывается."},"phaseId":{"type":["string","null"],"format":"uuid","description":"Этап проекта или `null`."},"color":{"anyOf":[{"$ref":"#/components/schemas/AccentColor"},{"type":"null"}],"description":"Собственный цвет доски; `null` — снять и наследовать от этапа или проекта."}}},"Column":{"type":"object","required":["id","title","position"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"title":{"type":"string","description":"Название"},"position":{"type":"integer","description":"Порядок слева направо. Колонка с наибольшим значением — колонка завершения."}}},"ColumnInput":{"type":"object","required":["title"],"properties":{"title":{"type":"string","minLength":1,"maxLength":60,"description":"Название колонки. Пробелы по краям обрезаются."}}},"ColumnCreated":{"type":"object","required":["id","title"],"description":"Позиция в ответе не возвращается. Колонка встаёт перед последней.","properties":{"id":{"$ref":"#/components/schemas/Uuid"},"title":{"type":"string"}}},"ColumnDeleteInput":{"type":"object","description":"Тело можно не передавать, если колонка пустая. Лишние поля отбрасываются.","properties":{"moveTo":{"type":["string","null"],"format":"uuid","description":"Колонка этой же доски, куда перенести задачи удаляемой колонки. Обязательна, если в колонке есть задачи, и не может совпадать с удаляемой.\nЕсли это последняя колонка доски, перенесённые задачи завершаются.\n"}}},"ColumnDeleted":{"type":"object","required":["ok","moved","columns"],"properties":{"ok":{"type":"boolean","const":true},"moved":{"type":"integer","minimum":0,"description":"Сколько задач перенесено в `moveTo`."},"columns":{"type":"array","description":"Оставшиеся колонки доски по возрастанию `position`; позиции пересчитаны подряд с 0.","items":{"$ref":"#/components/schemas/Column"}}}},"ColumnOrderInput":{"type":"object","required":["columnIds"],"description":"Лишние поля отбрасываются.","properties":{"columnIds":{"type":"array","minItems":1,"maxItems":100,"uniqueItems":true,"items":{"type":"string","format":"uuid"},"description":"Все колонки доски в новом порядке слева направо, каждая ровно один раз. Последняя станет колонкой завершения.\nID приводятся к нижнему регистру до сравнения; повтор ID — 400 «Колонки в списке повторяются».\n"}}},"ColumnOrderResult":{"type":"object","required":["ok","columns"],"properties":{"ok":{"type":"boolean","const":true},"columns":{"type":"array","description":"Колонки доски по возрастанию `position` после перестановки (позиции 0…n−1).","items":{"$ref":"#/components/schemas/Column"}}}},"TaskLink":{"type":"object","required":["id","sourceId","targetId","kind"],"description":"Связь в том виде, в каком она хранится: для `subtask` source — родитель, target — подзадача; для `blocks` source блокирует target.","properties":{"id":{"$ref":"#/components/schemas/Uuid"},"sourceId":{"$ref":"#/components/schemas/Uuid"},"targetId":{"$ref":"#/components/schemas/Uuid"},"kind":{"type":"string","enum":["subtask","blocks"]}}},"Task":{"type":"object","description":"Карточка задачи в составе доски (`GET /boards/{id}`).","required":["id","number","title","description","columnId","priority","label","dueDate","position","version","visibility","assigneeId","assigneeKind","assigneeName","assigneeColor","creatorId","commentCount","attachmentCount","canManage","canEdit"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"number":{"type":"integer","minimum":1,"description":"Номер задачи на доске; не меняется и не используется повторно."},"title":{"type":"string"},"description":{"type":"string","description":"Полное описание, до 100 000 символов."},"columnId":{"$ref":"#/components/schemas/Uuid"},"priority":{"$ref":"#/components/schemas/TaskPriority"},"label":{"type":"string","description":"Метка, может быть пустой строкой."},"dueDate":{"type":["string","null"],"format":"date","description":"Срок, `YYYY-MM-DD`."},"position":{"type":"integer","description":"Сейчас всегда 0 (см. описание раздела)."},"version":{"type":"integer","minimum":1,"description":"Версия для `PATCH`."},"visibility":{"$ref":"#/components/schemas/TaskVisibility"},"assigneeId":{"type":["string","null"],"format":"uuid"},"assigneeKind":{"type":["string","null"],"enum":["human","ai",null],"description":"Тип аккаунта исполнителя."},"assigneeName":{"type":["string","null"]},"assigneeColor":{"type":["string","null"],"description":"Цвет аватара исполнителя."},"creatorId":{"type":["string","null"],"format":"uuid","description":"Автор; `null`, если аккаунт удалён."},"commentCount":{"type":"integer","minimum":0,"description":"Число комментариев, включая ответы. Удалённые комментарии не считаются."},"attachmentCount":{"type":"integer","minimum":0,"description":"Число всех вложений задачи, включая прикреплённые к комментариям."},"canManage":{"type":"boolean","description":"Может ли текущий пользователь управлять задачей: `admin` доски или автор задачи с ролью `editor` на доске."},"canEdit":{"type":"boolean","description":"Может ли текущий пользователь редактировать задачу (роль `editor` в задаче, правила в описании раздела «Задачи»)."}}},"Board":{"type":"object","required":["id","links","title","description","workspaceId","projectId","phaseId","projectTitle","phaseTitle","color","phaseColor","projectColor","accent","visibility","role","columns","tasks"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"title":{"type":"string"},"description":{"type":"string"},"workspaceId":{"type":["string","null"],"format":"uuid"},"projectId":{"type":["string","null"],"format":"uuid"},"phaseId":{"type":["string","null"],"format":"uuid"},"projectTitle":{"type":["string","null"]},"phaseTitle":{"type":["string","null"]},"color":{"anyOf":[{"$ref":"#/components/schemas/AccentColor"},{"type":"null"}],"description":"Собственный цвет доски."},"phaseColor":{"anyOf":[{"$ref":"#/components/schemas/AccentColor"},{"type":"null"}],"description":"Цвет этапа."},"projectColor":{"anyOf":[{"$ref":"#/components/schemas/AccentColor"},{"type":"null"}],"description":"Цвет проекта."},"accent":{"anyOf":[{"$ref":"#/components/schemas/AccentColor"},{"type":"null"}],"description":"Цвет, в который клиент окрашивает интерфейс: `color ?? phaseColor ?? projectColor`."},"visibility":{"$ref":"#/components/schemas/BoardVisibility"},"role":{"$ref":"#/components/schemas/BoardMemberRole","description":"Вычисленная роль текущего пользователя."},"columns":{"type":"array","description":"Колонки по возрастанию `position`.","items":{"$ref":"#/components/schemas/Column"}},"tasks":{"type":"array","description":"Задачи, видимые текущему пользователю, по `position`, затем по времени создания.","items":{"$ref":"#/components/schemas/Task"}},"links":{"type":"array","description":"Связи, у которых видны обе задачи.","items":{"$ref":"#/components/schemas/TaskLink"}}}},"BoardPerson":{"type":"object","required":["id","name","email","role"],"description":"Человек с доступом к доске. Для личной доски возвращается только сам владелец, и только поля `id`, `name`, `email`, `role`.\n","properties":{"id":{"$ref":"#/components/schemas/Uuid"},"name":{"type":"string"},"kind":{"type":"string","enum":["human","ai"],"description":"Только для досок организации."},"email":{"type":["string","null"],"format":"email","description":"Email. Если текущий пользователь — гость организации, у всех, кроме него самого, приходит `null`."},"avatarColor":{"type":"string","description":"Только для досок организации."},"role":{"$ref":"#/components/schemas/BoardMemberRole","description":"Действующая роль: `admin` для владельца и админов организации, иначе явная роль или `viewer`."},"explicitRole":{"type":["string","null"],"enum":["admin","editor","viewer",null],"description":"Явная роль из `board_members` или `null`. Только для досок организации."}}},"BoardMemberInput":{"type":"object","required":["userId","role"],"properties":{"userId":{"type":"string","format":"uuid","description":"Участник организации. Не может быть текущим пользователем."},"role":{"$ref":"#/components/schemas/BoardMemberRole"}}},"BoardRoleRevoked":{"type":"object","required":["ok","accessRemains"],"properties":{"ok":{"type":"boolean","const":true},"accessRemains":{"type":"boolean","description":"Может ли пользователь по-прежнему открыть доску: через роль `owner`/`admin` в организации или как `member` при видимости `workspace`.\nЕсли явная роль была и `accessRemains: false`, роли пользователя в задачах доски удалены, а назначения сняты. На личной доске всегда `false`.\n"}}},"TaskCreateInput":{"type":"object","required":["title","columnId"],"properties":{"title":{"type":"string","minLength":1,"maxLength":200,"description":"Пробелы по краям обрезаются."},"description":{"type":"string","maxLength":100000,"default":""},"columnId":{"type":"string","format":"uuid","description":"Колонка этой доски."},"priority":{"allOf":[{"$ref":"#/components/schemas/TaskPriority"}],"default":"medium"},"label":{"type":"string","maxLength":40,"default":"","description":"Пробелы по краям обрезаются."},"dueDate":{"type":["string","null"],"format":"date","default":null,"description":"`YYYY-MM-DD`."},"assigneeId":{"type":["string","null"],"format":"uuid","default":null,"description":"Должен иметь доступ к доске."},"visibility":{"allOf":[{"$ref":"#/components/schemas/TaskVisibility"}],"default":"board"}}},"TaskCreated":{"type":"object","required":["id","number","title","description","columnId","priority","label","dueDate","assigneeId","visibility","version"],"description":"ID, номер и принятые поля с подставленными значениями по умолчанию.","properties":{"id":{"$ref":"#/components/schemas/Uuid"},"number":{"type":"integer","minimum":1,"description":"Номер задачи на доске."},"title":{"type":"string"},"description":{"type":"string"},"columnId":{"$ref":"#/components/schemas/Uuid"},"priority":{"$ref":"#/components/schemas/TaskPriority"},"label":{"type":"string"},"dueDate":{"type":["string","null"],"format":"date"},"assigneeId":{"type":["string","null"],"format":"uuid"},"visibility":{"$ref":"#/components/schemas/TaskVisibility"},"version":{"type":"integer","const":1},"reward":{"allOf":[{"$ref":"#/components/schemas/CompletionReward"}],"description":"Есть, только если задача создана сразу в последней колонке, а получатель награды (исполнитель или автор) — человек.\nВ остальных случаях поле отсутствует.\n"}}},"TaskUpdateInput":{"type":"object","required":["version"],"description":"Частичное обновление: непереданные поля не меняются. `null` в `dueDate` и `assigneeId` очищает значение.\nМенять `visibility` и `assigneeId` (на отличное от текущего значение) может только пользователь с `canManage`.\n","properties":{"title":{"type":"string","minLength":1,"maxLength":200,"description":"Пробелы по краям обрезаются."},"description":{"type":"string","maxLength":100000},"columnId":{"type":"string","format":"uuid","description":"Перенос в колонку этой доски. Перенос в последнюю колонку или из неё проверяется по графу задач."},"priority":{"$ref":"#/components/schemas/TaskPriority"},"label":{"type":"string","maxLength":40,"description":"Пробелы по краям обрезаются."},"dueDate":{"type":["string","null"],"format":"date"},"assigneeId":{"type":["string","null"],"format":"uuid","description":"Новый исполнитель должен иметь доступ к доске."},"visibility":{"$ref":"#/components/schemas/TaskVisibility"},"version":{"type":"integer","minimum":1,"description":"Версия, полученная при чтении задачи."}}},"CompletionReward":{"type":"object","required":["coins","xp","unlocked","forActor"],"description":"Итог завершения задачи для получателя награды (исполнителя, а если его нет — автора действия).","properties":{"coins":{"type":"integer","enum":[0,10],"description":"10 при первом завершении задачи, иначе 0."},"xp":{"type":"integer","enum":[0,10],"description":"10 при первом завершении задачи, иначе 0."},"unlocked":{"type":"array","items":{"type":"string"},"description":"Названия достижений, открытых этим завершением."},"forActor":{"type":"boolean","description":"`true`, если награду получил сам автор запроса."}}},"TaskUpdateResult":{"type":"object","required":["id","version"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"version":{"type":"integer","description":"Новая версия задачи."},"reward":{"allOf":[{"$ref":"#/components/schemas/CompletionReward"}],"description":"Есть, только если задача перенесена в последнюю колонку, а получатель награды — человек.\nВ остальных случаях поле отсутствует.\n"}}},"TaskDetailComment":{"type":"object","required":["id","body","forwarded","createdAt","authorId","authorKind","authorName","avatarColor","editedAt","deleted","reply","reactions","canEdit","canDelete"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"body":{"type":"string","description":"Текст комментария. Может быть пустым, если есть вложения; у удалённого комментария — всегда `\"\"`."},"forwarded":{"type":["object","null"],"description":"Для пересланного сообщения — откуда оно, иначе `null`. У удалённого комментария — `null`.","properties":{"author":{"type":"string","description":"Имя автора исходного сообщения."},"createdAt":{"$ref":"#/components/schemas/Timestamp"}}},"createdAt":{"$ref":"#/components/schemas/Timestamp"},"authorId":{"type":["string","null"],"format":"uuid","description":"ID автора; `null`, если аккаунт удалён."},"authorKind":{"type":["string","null"],"enum":["human","ai",null]},"authorName":{"type":["string","null"],"description":"`null`, если аккаунт автора удалён."},"avatarColor":{"type":["string","null"]},"editedAt":{"type":["string","null"],"format":"date-time","description":"Когда автор последний раз изменил текст; `null`, если не менял."},"deleted":{"type":"boolean","description":"Комментарий удалён. Строка остаётся, чтобы ответы сохранили место в ветке: текст пустой, реакций, упоминаний и вложений нет."},"reply":{"type":["object","null"],"description":"Комментарий, на который это ответ, или `null`.","required":["id","body","authorName","deleted"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"body":{"type":"string","maxLength":160,"description":"Первые 160 символов. У удалённого комментария — `\"\"`."},"authorName":{"type":["string","null"]},"deleted":{"type":"boolean","description":"Удалён ли комментарий","на который ответили.":null}}},"reactions":{"type":"array","description":"Реакции, сгруппированные по эмодзи.","items":{"type":"object","required":["emoji","count","mine","names"],"properties":{"emoji":{"type":"string"},"count":{"type":"integer"},"mine":{"type":"boolean","description":"Есть ли среди них реакция текущего пользователя."},"names":{"type":"array","items":{"type":"string"},"description":"Имена поставивших, по алфавиту."}}}},"canEdit":{"type":["boolean","null"],"description":"Может ли текущий пользователь изменить комментарий (`PATCH …/comments/{commentId}`): свой, не удалённый, не пересланный, отправлен меньше 24 часов назад, и есть право комментировать задачу.\n`null` (то же, что `false`) приходит у свежего комментария удалённого аккаунта: условие вычисляется в SQL.\n"},"canDelete":{"type":"boolean","description":"Может ли текущий пользователь удалить комментарий (`DELETE …/comments/{commentId}`): комментарий не удалён, и пользователь — `admin` доски или автор с правом комментировать задачу."}}},"TaskHistoryEvent":{"type":"object","required":["id","action","details","createdAt","actorName"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"action":{"type":"string","description":"Код действия (таблица в описании раздела «Задачи»)."},"details":{"type":"object","additionalProperties":true,"description":"Подробности действия (таблица в описании раздела «Задачи»). Даты в `updated.changes.dueDate` — `YYYY-MM-DD` или `null`."},"createdAt":{"$ref":"#/components/schemas/Timestamp"},"actorName":{"type":["string","null"],"description":"Имя автора действия; `null`, если аккаунт удалён."}}},"TaskAttachment":{"type":"object","required":["id","name","mime","size","commentId","authorId","createdAt","authorName","canDelete"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"name":{"type":"string","maxLength":200,"description":"Имя файла после очистки: NFC, управляющие символы, `/`, `\\` и символы управления направлением текста заменены на `_`, не больше 200 символов (раздел «Задачи», «Вложения»)."},"mime":{"type":"string","description":"MIME-тип, определённый по содержимому."},"size":{"type":"integer","minimum":1,"maximum":104857600,"description":"Размер в байтах."},"commentId":{"type":["string","null"],"format":"uuid","description":"Комментарий, к которому прикреплён файл; `null` — вложение задачи."},"authorId":{"type":["string","null"],"format":"uuid"},"createdAt":{"$ref":"#/components/schemas/Timestamp"},"authorName":{"type":["string","null"]},"canDelete":{"type":"boolean","description":"Может ли текущий пользователь удалить вложение: есть право комментировать задачу, и он — `editor` задачи или автор вложения. То же правило, что у `DELETE …/attachments/{attachmentId}`."}}},"TaskMember":{"type":"object","required":["id","name","email","role"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"name":{"type":"string"},"email":{"type":"string","format":"email"},"role":{"$ref":"#/components/schemas/TaskMemberRole"}}},"TaskDetailFields":{"type":"object","description":"Текущие значения полей задачи из базы на момент запроса.","required":["title","description","columnId","priority","label","dueDate","assigneeId","visibility","version"],"properties":{"title":{"type":"string"},"description":{"type":"string"},"columnId":{"$ref":"#/components/schemas/Uuid"},"priority":{"$ref":"#/components/schemas/TaskPriority"},"label":{"type":"string"},"dueDate":{"type":["string","null"],"format":"date","description":"`YYYY-MM-DD`."},"assigneeId":{"type":["string","null"],"format":"uuid"},"visibility":{"$ref":"#/components/schemas/TaskVisibility"},"version":{"type":"integer","minimum":1}}},"TaskDetail":{"type":"object","description":"Права, текущие значения полей, обсуждение, история, вложения и явные участники задачи.\nПоле `task` позволяет открытой карточке подхватить правки коллег; номер, автор, данные исполнителя и счётчики приходят только в `GET /boards/{id}`.\n","required":["canEdit","canManage","canComment","version","task","comments","commentCount","history","attachments","members"],"properties":{"canEdit":{"type":"boolean"},"canManage":{"type":"boolean"},"canComment":{"type":"boolean"},"version":{"type":"integer","description":"Текущая версия задачи для `PATCH`."},"task":{"description":"Текущие значения полей. `null`, только если задачу удалили во время запроса.","anyOf":[{"$ref":"#/components/schemas/TaskDetailFields"},{"type":"null"}]},"comments":{"type":"array","description":"Последние 100 комментариев задачи (вместе с ответами и удалёнными), от старых к новым.","items":{"$ref":"#/components/schemas/TaskDetailComment"}},"commentCount":{"type":"integer","minimum":0,"description":"Число всех строк комментариев задачи, включая удалённые (в отличие от `commentCount` в `GET /boards/{id}`)."},"history":{"type":"array","description":"Последние 100 событий, новые первыми.","items":{"$ref":"#/components/schemas/TaskHistoryEvent"}},"attachments":{"type":"array","description":"Все вложения задачи, включая прикреплённые к комментариям, новые первыми.","items":{"$ref":"#/components/schemas/TaskAttachment"}},"members":{"type":"array","description":"Пользователи с явной ролью в задаче, по имени.","items":{"$ref":"#/components/schemas/TaskMember"}}}},"TaskAttachmentUploaded":{"type":"object","required":["id","name","mime","size"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"name":{"type":"string","maxLength":200,"description":"Сохранённое имя файла после очистки."},"mime":{"type":"string","description":"MIME-тип, определённый по содержимому."},"size":{"type":"integer","minimum":1,"maximum":104857600,"description":"Размер в байтах."}}},"TaskCommentInput":{"type":"object","description":"Нужен непустой `body` (после обрезки пробелов) или хотя бы одно вложение, иначе 400 «Напишите текст комментария или прикрепите файл». Лишние поля отбрасываются.","properties":{"body":{"type":"string","maxLength":20000,"default":"","description":"Текст. Пробелы по краям обрезаются. Упоминание — markdown-ссылка вида `[@Имя](/people/<userId>)` вне блоков кода.\nНе больше 20 упоминаний, и каждый упомянутый должен иметь доступ к задаче.\n"},"attachmentIds":{"type":"array","maxItems":10,"default":[],"items":{"type":"string","format":"uuid"},"description":"Свои ещё не прикреплённые вложения этой задачи. Повтор ID приводит к 400."},"replyTo":{"type":["string","null"],"format":"uuid","default":null,"description":"Комментарий этой же задачи, на который это ответ."}}},"TaskCommentCreated":{"type":"object","required":["id"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"}}},"TaskCommentEditInput":{"type":"object","required":["body"],"description":"Лишние поля отбрасываются. Вложения, ответ и метка пересылки этим методом не меняются.","properties":{"body":{"type":"string","maxLength":20000,"description":"Новый текст. Пробелы по краям обрезаются. Пустая строка допустима, только если у комментария есть вложения.\nУпоминания — как при создании: `[@Имя](/people/<userId>)` вне блоков кода, не больше 20, каждый упомянутый должен иметь доступ к задаче.\n"}}},"TaskCommentEdited":{"type":"object","required":["id","body","editedAt"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"body":{"type":"string","description":"Сохранённый текст."},"editedAt":{"type":["string","null"],"format":"date-time","description":"Момент правки. Если текст не изменился, возвращается прежнее значение — `null`, если комментарий раньше не меняли."}}},"TaskMemberInput":{"type":"object","required":["userId","role"],"properties":{"userId":{"type":"string","format":"uuid","description":"Пользователь с доступом к доске."},"role":{"$ref":"#/components/schemas/TaskMemberRole"}}},"TaskRelation":{"type":"object","required":["id","taskId","title","columnId","status","done","type"],"properties":{"id":{"$ref":"#/components/schemas/Uuid","description":"ID связи (для удаления)."},"taskId":{"$ref":"#/components/schemas/Uuid","description":"ID связанной задачи."},"title":{"type":"string","description":"Название связанной задачи."},"columnId":{"$ref":"#/components/schemas/Uuid"},"status":{"type":"string","description":"Название колонки связанной задачи."},"done":{"type":"boolean","description":"Стоит ли связанная задача в последней колонке."},"type":{"type":"string","enum":["child","parent","blockedBy","blocking"],"description":"Кем связанная задача приходится текущей (таблица в описании раздела)."}}},"TaskRelationList":{"type":"object","required":["links","hasHiddenLinks","canComplete"],"properties":{"links":{"type":"array","description":"Видимые связи текущей задачи в порядке создания.","items":{"$ref":"#/components/schemas/TaskRelation"}},"hasHiddenLinks":{"type":"boolean","description":"Есть ли связи с задачами, которые пользователь не видит."},"canComplete":{"type":"boolean","description":"Выполнены ли все предпосылки, транзитивно и включая скрытые, то есть можно ли перенести задачу в последнюю колонку."}}},"TaskRelationInput":{"type":"object","required":["taskId","type"],"properties":{"taskId":{"type":"string","format":"uuid","description":"Другая задача этой доски."},"type":{"type":"string","enum":["child","parent","blockedBy","blocking"],"description":"Кем станет другая задача для текущей."}}},"TaskRelationCreated":{"type":"object","required":["id","sourceId","targetId","kind"],"description":"Связь в форме хранения (см. `TaskLink`).","properties":{"id":{"$ref":"#/components/schemas/Uuid"},"sourceId":{"$ref":"#/components/schemas/Uuid"},"targetId":{"$ref":"#/components/schemas/Uuid"},"kind":{"type":"string","enum":["subtask","blocks"]}}},"SubtaskInput":{"type":"object","required":["title"],"properties":{"title":{"type":"string","minLength":1,"maxLength":200,"description":"Название подзадачи. Пробелы по краям обрезаются."}}},"SubtaskCreated":{"type":"object","required":["id"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"}}},"MessageReactionSummary":{"type":"object","description":"Сводка по одному эмодзи на сообщении. Массив `reactions` отсортирован по `emoji`.","required":["emoji","count","mine","names"],"properties":{"emoji":{"type":"string","minLength":1,"maxLength":32,"description":"Эмодзи реакции"},"count":{"type":"integer","minimum":1,"description":"Сколько пользователей поставили этот эмодзи"},"mine":{"type":"boolean","description":"Есть ли среди них текущий пользователь"},"names":{"type":"array","description":"Имена всех поставивших (по алфавиту), без ограничения длины списка","items":{"type":"string"}}}},"MessageForwardedFrom":{"type":"object","description":"Метка пересланного сообщения. При повторной пересылке сохраняется исходная метка.","required":["author","createdAt"],"properties":{"author":{"type":"string","description":"Имя автора исходного сообщения или «Удалённый участник»"},"createdAt":{"$ref":"#/components/schemas/Timestamp"}}},"DiscussionReplyPreview":{"type":"object","description":"Цитата комментария, на который ответили (непосредственный родитель).","required":["id","body","authorName","deleted"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"body":{"type":"string","maxLength":160,"description":"Первые 160 символов текста родителя; пустая строка, если родитель удалён"},"authorName":{"type":["string","null"],"description":"Имя автора родителя; `null`, если пользователь удалён"},"deleted":{"type":"boolean","description":"Родительский комментарий удалён (осталось «надгробие»)"}}},"DiscussionComment":{"type":"object","description":"Комментарий задачи с правами текущего пользователя. Удалённый комментарий остаётся в списке как «надгробие»:\n`deleted: true`, `body: \"\"`, `forwarded: null`, `reactions: []`, `canEdit` и `canDelete` — `false`.\n","required":["id","body","rootId","authorId","forwarded","createdAt","authorName","authorKind","avatarColor","editedAt","deleted","canEdit","canDelete","reply","replyCount","reactions"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"body":{"type":"string","description":"Текст комментария (markdown; может быть пустым, если есть вложения, и всегда пустой у удалённого). Пользовательский ввод ограничен 20 000 символами"},"rootId":{"type":["string","null"],"format":"uuid","description":"ID корня ветки; `null` у корневого комментария"},"authorId":{"type":["string","null"],"format":"uuid","description":"ID автора; `null`, если пользователь удалён"},"forwarded":{"anyOf":[{"$ref":"#/components/schemas/MessageForwardedFrom"},{"type":"null"}]},"createdAt":{"$ref":"#/components/schemas/Timestamp"},"authorName":{"type":["string","null"]},"authorKind":{"type":["string","null"],"enum":["human","ai",null],"description":"`ai` — комментарий ИИ-сотрудника"},"avatarColor":{"type":["string","null"],"description":"Цвет аватара `#rrggbb`"},"editedAt":{"anyOf":[{"$ref":"#/components/schemas/Timestamp"},{"type":"null"}],"description":"Время последнего изменения текста автором; `null`, если комментарий не меняли"},"deleted":{"type":"boolean","description":"Комментарий удалён (`comments.deleted_at` заполнен)"},"canEdit":{"type":"boolean","description":"Текущий пользователь может изменить текст (`PATCH …/comments/{commentId}`): он автор и может комментировать задачу,\nкомментарий не удалён, не пересылка и отправлен меньше 24 часов назад\n"},"canDelete":{"type":"boolean","description":"Текущий пользователь может удалить комментарий (`DELETE …/comments/{commentId}`): комментарий не удалён, и пользователь —\nадминистратор доски либо автор с правом комментировать задачу\n"},"reply":{"anyOf":[{"$ref":"#/components/schemas/DiscussionReplyPreview"},{"type":"null"}]},"replyCount":{"type":"integer","minimum":0,"description":"Число ответов в ветке, включая удалённые (у ответов всегда 0)"},"reactions":{"type":"array","items":{"$ref":"#/components/schemas/MessageReactionSummary"}}}},"DiscussionRootsPage":{"type":"object","required":["items","hasOlder"],"properties":{"items":{"type":"array","maxItems":30,"description":"Корневые комментарии, от старых к новым","items":{"$ref":"#/components/schemas/DiscussionComment"}},"hasOlder":{"type":"boolean","description":"Есть ли более старые корневые комментарии"}}},"DiscussionThreadPage":{"type":"object","required":["root","items","hasOlder","hasNewer"],"properties":{"root":{"$ref":"#/components/schemas/DiscussionComment"},"items":{"type":"array","maxItems":50,"description":"Ответы ветки, от старых к новым","items":{"$ref":"#/components/schemas/DiscussionComment"}},"hasOlder":{"type":"boolean","description":"Есть ли ответы старше первого элемента `items`"},"hasNewer":{"type":"boolean","description":"Есть ли ответы новее последнего элемента `items`"}}},"DiscussionParticipant":{"type":"object","required":["id","name","avatarColor","kind"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"name":{"type":"string"},"avatarColor":{"type":"string","description":"Цвет аватара `#rrggbb`"},"kind":{"type":"string","enum":["human","ai"],"description":"Тип аккаунта (`users.account_kind`)"}}},"MessageActionRef":{"type":"object","required":["kind","id"],"properties":{"kind":{"type":"string","enum":["chat","task"],"description":"`task` — комментарий/задача, `chat` — личное сообщение/переписка"},"id":{"type":"string","format":"uuid","description":"Для `source`: ID сообщения; для `target`: ID задачи или дружбы"}}},"MessageReactionInput":{"type":"object","required":["source","emoji","active"],"properties":{"source":{"$ref":"#/components/schemas/MessageActionRef"},"emoji":{"type":"string","minLength":1,"maxLength":32,"description":"Ровно один RGI-эмодзи (одиночный, флаг, keycap, с тоном кожи или ZWJ-последовательность): проверка\n`^\\p{RGI_Emoji}$` с флагом `v`; `©`, `®`, `™` не принимаются. Если строка становится эмодзи только с `U+FE0F`,\nсервер добавляет его (`❤` сохраняется как `❤️`). Проверяется только при `active: true`; при несовпадении —\nошибка поля `emoji` «Выберите одно эмодзи». Для `active: false` годится любая строка 1–32 символа.\n"},"active":{"type":"boolean","description":"`true` — поставить реакцию (добавляется к уже поставленным другим эмодзи), `false` — снять"}}},"MessageForwardInput":{"type":"object","required":["source","target"],"properties":{"source":{"$ref":"#/components/schemas/MessageActionRef"},"target":{"$ref":"#/components/schemas/MessageActionRef"}}},"MessageForwardResult":{"type":"object","required":["id"],"properties":{"id":{"type":"string","format":"uuid","description":"ID нового комментария (`target.kind = task`) или личного сообщения (`chat`)"}}},"SocialFriendship":{"type":"object","description":"Запись дружбы с данными собеседника (с точки зрения текущего пользователя).","required":["id","status","lastMessage","lastAt","outgoing","userId","name","avatarColor","jobTitle","mood","moodNote","moodDay"],"properties":{"id":{"type":"string","format":"uuid","description":"ID дружбы — он же ID переписки в путях `/social/friends/{id}/…`"},"status":{"type":"string","enum":["pending","accepted"]},"lastMessage":{"type":["string","null"],"maxLength":120,"description":"Первые 120 символов последнего сообщения"},"lastAt":{"type":["string","null"],"format":"date-time","description":"Время последнего сообщения"},"outgoing":{"type":"boolean","description":"`true` — заявку отправил текущий пользователь"},"userId":{"type":"string","format":"uuid","description":"ID собеседника"},"name":{"type":"string"},"avatarColor":{"type":"string","description":"Цвет аватара `#rrggbb`"},"jobTitle":{"type":"string","description":"Должность из профиля (может быть пустой строкой)"},"mood":{"type":["integer","null"],"minimum":1,"maximum":5,"description":"Оценка дня собеседника; только при `accepted`, если он поделился ей сегодня (по своему часовому поясу)"},"moodNote":{"type":["string","null"],"description":"Заметка к настроению при тех же условиях"},"moodDay":{"type":["string","null"],"format":"date","description":"День записи настроения"}}},"SocialFriendInviteInput":{"type":"object","required":["userId"],"properties":{"userId":{"type":"string","format":"uuid","description":"ID пользователя с открытым профилем"}}},"SocialFriendRequestResult":{"type":"object","description":"Запись дружбы пары после заявки — новая, принятая встречная или уже существовавшая.","required":["ok","id","status","outgoing"],"properties":{"ok":{"type":"boolean","const":true},"id":{"type":"string","format":"uuid","description":"ID дружбы — он же ID переписки в путях `/social/friends/{id}/…`"},"status":{"type":"string","enum":["pending","accepted"],"description":"`pending` — заявка ждёт ответа; `accepted` — встречная заявка принята или вы уже друзья"},"outgoing":{"type":"boolean","description":"`true` — запись создал текущий пользователь (исходная заявка его)"}}},"SocialDirectReplyPreview":{"type":"object","required":["id","body","mine"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"body":{"type":"string","maxLength":160,"description":"Первые 160 символов сообщения, на которое ответили"},"mine":{"type":"boolean","description":"Автор цитируемого сообщения — текущий пользователь"}}},"SocialDirectFile":{"type":"object","required":["id","name","mime","size","url"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"name":{"type":"string","minLength":1,"maxLength":200,"description":"Имя файла: UTF-8 (кириллица сохраняется), NFC; управляющие символы, `/`, `\\` и символы управления направлением текста заменены на `_`; до 200 кодовых точек; пустое — `file`"},"mime":{"type":"string","description":"Тип, определённый сервером по содержимому, например `image/png`"},"size":{"type":"integer","minimum":1,"maximum":104857600,"description":"Размер в байтах"},"url":{"type":"string","description":"Относительный URL скачивания `/api/v1/social/friends/{id}/files/{file}`"}}},"SocialDirectMessage":{"type":"object","required":["id","body","forwarded","reactions","senderId","mine","createdAt","cursor","reply","files"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"body":{"type":"string","maxLength":4000,"description":"Текст (может быть пустым, если есть вложения)"},"forwarded":{"anyOf":[{"$ref":"#/components/schemas/MessageForwardedFrom"},{"type":"null"}]},"reactions":{"type":"array","items":{"$ref":"#/components/schemas/MessageReactionSummary"}},"senderId":{"$ref":"#/components/schemas/Uuid"},"mine":{"type":"boolean","description":"Отправитель — текущий пользователь"},"createdAt":{"$ref":"#/components/schemas/Timestamp"},"cursor":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{6}Z~[0-9a-f-]{36}$","description":"Курсор сообщения `<created_at в UTC с микросекундами>~<id>` для параметра `before`. В отличие от `createdAt`\n(миллисекунды) не теряет точность, поэтому сообщения с одинаковым временем на границе страниц не пропадают\n","example":"2026-10-02T07:41:09.330412Z~2d3e4f5a-6b7c-4d8e-9f0a-1b2c3d4e5f6a"},"reply":{"anyOf":[{"$ref":"#/components/schemas/SocialDirectReplyPreview"},{"type":"null"}]},"files":{"type":"array","maxItems":10,"items":{"$ref":"#/components/schemas/SocialDirectFile"}}}},"SocialDirectMessagePage":{"type":"object","description":"Страница истории переписки при `?format=page`.","required":["items","hasOlder","nextCursor"],"properties":{"items":{"type":"array","maxItems":100,"description":"Сообщения, от старых к новым","items":{"$ref":"#/components/schemas/SocialDirectMessage"}},"hasOlder":{"type":"boolean","description":"Есть ли сообщения старше первого элемента `items`"},"nextCursor":{"type":["string","null"],"description":"`cursor` самого старого сообщения страницы (`items[0].cursor`) для следующего запроса в `before`; `null`, если `hasOlder: false`"}}},"SocialDirectMessageInput":{"type":"object","description":"Нужно хотя бы одно из двух: непустой `body` (после `trim`) или непустой `attachmentIds`;\nиначе `400 «Напишите сообщение или прикрепите файл»`. Лишние поля игнорируются.\n","properties":{"body":{"type":"string","maxLength":4000,"default":"","description":"Текст; пробелы по краям обрезаются"},"attachmentIds":{"type":"array","maxItems":10,"default":[],"description":"ID своих неотправленных файлов этой переписки (из `POST /social/friends/{id}/files`), загруженных меньше 24 часов назад","items":{"type":"string","format":"uuid"}},"replyTo":{"type":["string","null"],"format":"uuid","default":null,"description":"ID сообщения этой же переписки, на которое отвечаем"}}},"SocialMessageCreated":{"type":"object","required":["id"],"properties":{"id":{"type":"string","format":"uuid","description":"ID созданного сообщения"}}},"SocialMoodEntry":{"type":"object","required":["day","score","note","shareWithFriends"],"properties":{"day":{"type":"string","format":"date"},"score":{"type":"integer","minimum":1,"maximum":5},"note":{"type":"string","maxLength":1000},"shareWithFriends":{"type":"boolean"}}},"SocialJoyDay":{"type":"object","required":["day","points"],"properties":{"day":{"type":"string","format":"date"},"points":{"type":"integer","minimum":1,"description":"Сумма баллов радости за день"}}},"SocialJoyTask":{"type":"object","required":["id","boardId","title","points"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"boardId":{"$ref":"#/components/schemas/Uuid"},"title":{"type":"string"},"points":{"type":["integer","null"],"minimum":1,"maximum":5,"description":"Поставленные баллы; `null`, если задачу ещё не оценили"}}},"SocialDay":{"type":"object","required":["day","entries","rewards","tasks"],"properties":{"day":{"type":"string","format":"date","description":"Сегодняшний день по часовому поясу пользователя"},"entries":{"type":"array","description":"Записи настроения за последние 28 дней (включая сегодня), по возрастанию дня","items":{"$ref":"#/components/schemas/SocialMoodEntry"}},"rewards":{"type":"array","description":"Суммы баллов радости по дням за те же 28 дней (дни без баллов отсутствуют)","items":{"$ref":"#/components/schemas/SocialJoyDay"}},"tasks":{"type":"array","maxItems":30,"description":"Последние задачи, завершённые пользователем, к которым у него сохранился доступ (новые сначала)","items":{"$ref":"#/components/schemas/SocialJoyTask"}}}},"SocialMoodInput":{"type":"object","required":["score"],"description":"Лишние поля игнорируются.","properties":{"score":{"type":"integer","minimum":1,"maximum":5,"description":"Оценка дня"},"note":{"type":"string","maxLength":1000,"default":"","description":"Заметка; пробелы по краям обрезаются"},"shareWithFriends":{"type":"boolean","default":false,"description":"Показывать ли оценку и заметку друзьям сегодня"}}},"SocialTaskJoyInput":{"type":"object","required":["boardId","taskId","points"],"properties":{"boardId":{"type":"string","format":"uuid"},"taskId":{"type":"string","format":"uuid"},"points":{"type":"integer","minimum":1,"maximum":5}}},"SocialFileUpload":{"type":"object","required":["file"],"description":"Ровно один файл в поле `file`; любые другие поля формы запрещены.","properties":{"file":{"type":"string","format":"binary","description":"Содержимое файла: от 1 байта до 100 МБ (104 857 600 байт). Имя части (`filename`) — в UTF-8"}}},"NotificationKind":{"type":"string","enum":["assigned","comment","reply","mention","due_soon","overdue"],"description":"- `assigned` — вас назначили исполнителем;\n- `comment` — новый комментарий в задаче, где вы участвуете;\n- `reply` — ответ на ваш комментарий;\n- `mention` — вас упомянули;\n- `due_soon` — срок сегодня или завтра;\n- `overdue` — срок прошёл.\n"},"NotificationItem":{"type":"object","required":["id","kind","commentId","readAt","createdAt","taskId","boardId","title","dueDate","actorName","boardTitle"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"kind":{"$ref":"#/components/schemas/NotificationKind"},"commentId":{"type":["string","null"],"format":"uuid","description":"Комментарий-источник для `comment`/`reply`/`mention`; иначе `null`"},"readAt":{"type":["string","null"],"format":"date-time","description":"Когда прочитано; `null` — не прочитано"},"createdAt":{"$ref":"#/components/schemas/Timestamp"},"taskId":{"$ref":"#/components/schemas/Uuid"},"boardId":{"$ref":"#/components/schemas/Uuid"},"title":{"type":"string","description":"Текущее название задачи"},"dueDate":{"type":["string","null"],"format":"date","description":"Текущий срок задачи"},"actorName":{"type":["string","null"],"description":"Кто вызвал уведомление; `null` для сроков и удалённых пользователей"},"boardTitle":{"type":"string"}}},"NotificationFeed":{"type":"object","required":["items","unreadCount"],"properties":{"items":{"type":"array","maxItems":100,"description":"100 последних видимых уведомлений (после фильтрации), новые сначала","items":{"$ref":"#/components/schemas/NotificationItem"}},"unreadCount":{"type":"integer","minimum":0,"description":"Число непрочитанных среди **всех** видимых уведомлений пользователя (может быть больше длины `items`)"}}},"NotificationPreferences":{"type":"object","required":["assignments","comments","deadlines","emailAssignments","emailMentions","emailDeadlines"],"description":"Все шесть флагов по умолчанию `true`. Первые три управляют созданием уведомлений в ленте, `email*` — письмами\nна email (правила — в `GET /api/v1/notifications/preferences`). Письмо уходит только вместе с новым уведомлением,\nпоэтому выключенный флаг ленты отключает и письма того же вида.\n","properties":{"assignments":{"type":"boolean","description":"Уведомления `assigned` (`users.notify_assignments`)"},"comments":{"type":"boolean","description":"Уведомления `comment`, `reply`, `mention` (`users.notify_comments`)"},"deadlines":{"type":"boolean","description":"Уведомления `due_soon`, `overdue` (`users.notify_deadlines`)"},"emailAssignments":{"type":"boolean","description":"Письма о назначении исполнителем, `assigned` (`users.email_assignments`)"},"emailMentions":{"type":"boolean","description":"Письма об упоминаниях и ответах, `mention` и `reply` (`users.email_mentions`); обычные комментарии писем не дают"},"emailDeadlines":{"type":"boolean","description":"Письма о сроках, `due_soon` и `overdue` (`users.email_deadlines`)"}}},"NotificationPreferencesUpdate":{"type":"object","minProperties":1,"description":"Частичное обновление: переданные флаги меняются, остальные сохраняются. Нужен хотя бы один из шести флагов,\nиначе `400 «Проверьте поля формы»` с `formErrors: [\"Укажите хотя бы одну настройку\"]`; лишние поля игнорируются\nи не засчитываются. Значения — только `true`/`false` (`null` — `400`).\n","properties":{"assignments":{"type":"boolean","description":"Уведомления `assigned`"},"comments":{"type":"boolean","description":"Уведомления `comment`, `reply`, `mention`"},"deadlines":{"type":"boolean","description":"Уведомления `due_soon`, `overdue`"},"emailAssignments":{"type":"boolean","description":"Письма о назначении исполнителем"},"emailMentions":{"type":"boolean","description":"Письма об упоминаниях и ответах"},"emailDeadlines":{"type":"boolean","description":"Письма о сроках"}}},"TeamBoardSummary":{"type":"object","description":"Видимая доска организации со счётчиками по видимым задачам.","required":["id","title","description","visibility","role","projectId","phaseId","color","accent","total","done","overdue"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"title":{"type":"string","description":"Название доски"},"description":{"type":"string","description":"Описание доски"},"visibility":{"type":"string","enum":["private","workspace"],"description":"`workspace` — видна всем `member` организации"},"role":{"type":"string","enum":["admin","editor","viewer"],"description":"Роль текущего пользователя на доске"},"projectId":{"type":["string","null"],"format":"uuid","description":"Проект; `null` — независимая доска"},"phaseId":{"type":["string","null"],"format":"uuid","description":"Этап проекта; `null` — доска без этапа"},"color":{"anyOf":[{"$ref":"#/components/schemas/AccentColor"},{"type":"null"}],"description":"Собственный цвет доски"},"accent":{"anyOf":[{"$ref":"#/components/schemas/AccentColor"},{"type":"null"}],"description":"Итоговый цвет: доски, иначе этапа, иначе проекта"},"total":{"type":"integer","description":"Видимых задач на доске"},"done":{"type":"integer","description":"Из них завершено"},"overdue":{"type":"integer","description":"Незавершённых с `due_date` раньше сегодняшней даты в часовом поясе пользователя"}}},"TeamStats":{"type":"object","description":"Счётчики по всем видимым задачам организации.","required":["total","open","done","unassigned","overdue","doneThisWeek","backlog"],"properties":{"total":{"type":"integer","description":"Всего видимых задач"},"open":{"type":"integer","description":"Незавершённых"},"done":{"type":"integer","description":"Завершённых"},"unassigned":{"type":"integer","description":"Незавершённых без исполнителя"},"overdue":{"type":"integer","description":"Незавершённых с просроченным сроком (по часовому поясу пользователя)"},"doneThisWeek":{"type":"integer","description":"Завершённых за последние 7 дней"},"backlog":{"type":"integer","description":"Видимых пользователю записей бэклога: без архива и без статуса `transferred`"}}},"TeamWorkloadMember":{"type":"object","description":"Участник организации, включая гостей, и его нагрузка по видимым текущему пользователю задачам.","required":["id","name","kind","avatarColor","role","open","done","overdue"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"name":{"type":"string"},"kind":{"type":"string","enum":["human","ai"],"description":"Тип аккаунта (`users.account_kind`)"},"avatarColor":{"type":"string","description":"Цвет аватара, `#RRGGBB`"},"role":{"type":"string","enum":["owner","admin","member","guest"],"description":"Роль в организации"},"open":{"type":"integer","description":"Незавершённых видимых задач на исполнителе"},"done":{"type":"integer","description":"Завершено за последние 7 дней"},"overdue":{"type":"integer","description":"Из незавершённых — с `due_date` раньше сегодняшней даты в часовом поясе текущего пользователя"}}},"TeamSprintTask":{"type":"object","description":"Задача спринта. Входит в ответ, только если видима текущему пользователю.","required":["sprintId","id","number","title","boardId","boardTitle","columnTitle","priority","dueDate","assigneeId","assigneeName","assigneeColor","points","done","completedDay","canEdit"],"properties":{"sprintId":{"$ref":"#/components/schemas/Uuid"},"id":{"$ref":"#/components/schemas/Uuid"},"number":{"type":"integer","description":"Постоянный номер задачи внутри доски (`MX–NN`)"},"title":{"type":"string"},"boardId":{"$ref":"#/components/schemas/Uuid"},"boardTitle":{"type":"string"},"columnTitle":{"type":"string","description":"Текущая колонка задачи на доске (статус)"},"priority":{"type":"string","enum":["low","medium","high"]},"dueDate":{"type":["string","null"],"format":"date"},"assigneeId":{"type":["string","null"],"format":"uuid"},"assigneeName":{"type":["string","null"]},"assigneeColor":{"type":["string","null"],"description":"Цвет аватара исполнителя, `#RRGGBB`"},"points":{"type":"integer","minimum":0,"maximum":100,"description":"Оценка задачи в этом спринте"},"done":{"type":"boolean","description":"Для завершённого спринта — зафиксированный при закрытии признак (`completed_at_close`), для остальных — завершена ли задача сейчас"},"completedDay":{"type":["string","null"],"format":"date","description":"День текущего `completed_at` в часовом поясе пользователя; `null`, если задача не в последней колонке. Для burndown: остаток SP на день — сумма `points` задач без `completedDay` или с более поздним днём"},"canEdit":{"type":"boolean","description":"Может ли текущий пользователь редактировать задачу"}}},"TeamSprint":{"type":"object","required":["id","projectId","title","goal","startDate","endDate","status","version","startedAt","finishedAt","committedTasks","committedPoints","completedTasks","completedPoints","tasks"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"projectId":{"type":["string","null"],"format":"uuid","description":"Проект; `null` — спринт всей команды"},"title":{"type":"string"},"goal":{"type":"string"},"startDate":{"type":"string","format":"date"},"endDate":{"type":"string","format":"date"},"status":{"type":"string","enum":["planned","active","completed"]},"version":{"type":"integer"},"startedAt":{"anyOf":[{"$ref":"#/components/schemas/Timestamp"},{"type":"null"}]},"finishedAt":{"anyOf":[{"$ref":"#/components/schemas/Timestamp"},{"type":"null"}]},"committedTasks":{"type":["integer","null"],"description":"Задач на момент запуска. Не для `owner`/`admin` всегда `null`"},"committedPoints":{"type":["integer","null"],"description":"Сумма `points` на момент запуска. Не для `owner`/`admin` всегда `null`"},"completedTasks":{"type":["integer","null"],"description":"Выполненных задач на момент завершения. Не для `owner`/`admin` всегда `null`"},"completedPoints":{"type":["integer","null"],"description":"Сумма `points` выполненных задач на момент завершения. Не для `owner`/`admin` всегда `null`"},"tasks":{"type":"array","description":"Видимые задачи спринта в порядке добавления","items":{"$ref":"#/components/schemas/TeamSprintTask"}}}},"TeamOverview":{"type":"object","required":["id","name","description","role","boards","stats","workload","sprints"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"name":{"type":"string","description":"Название организации"},"description":{"type":"string"},"role":{"type":"string","enum":["owner","admin","member"],"description":"Роль текущего пользователя"},"boards":{"type":"array","description":"Все видимые доски, по названию. Без лимита.","items":{"$ref":"#/components/schemas/TeamBoardSummary"}},"stats":{"$ref":"#/components/schemas/TeamStats"},"workload":{"type":"array","description":"Все участники организации, по убыванию `open`, затем по имени","items":{"$ref":"#/components/schemas/TeamWorkloadMember"}},"sprints":{"type":"array","description":"Последние 50 спринтов по дате создания, новые первыми","maxItems":50,"items":{"$ref":"#/components/schemas/TeamSprint"}}}},"TeamTaskItem":{"type":"object","required":["id","number","title","boardId","boardTitle","projectId","columnTitle","priority","dueDate","assigneeId","assigneeName","assigneeColor","done","completedAt","overdue","canEdit","version"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"number":{"type":"integer","description":"Постоянный номер задачи внутри доски (`MX–NN`)"},"title":{"type":"string"},"boardId":{"$ref":"#/components/schemas/Uuid"},"boardTitle":{"type":"string"},"projectId":{"type":["string","null"],"format":"uuid","description":"Проект доски; `null` — независимая доска"},"columnTitle":{"type":"string"},"priority":{"type":"string","enum":["low","medium","high"]},"dueDate":{"type":["string","null"],"format":"date"},"assigneeId":{"type":["string","null"],"format":"uuid"},"assigneeName":{"type":["string","null"]},"assigneeColor":{"type":["string","null"],"description":"Цвет аватара исполнителя, `#RRGGBB`"},"done":{"type":"boolean","description":"Задача в последней колонке доски (`completed_at` заполнен)"},"completedAt":{"anyOf":[{"$ref":"#/components/schemas/Timestamp"},{"type":"null"}]},"overdue":{"type":"boolean","description":"Не завершена и `due_date` раньше сегодняшней даты в часовом поясе пользователя — как в `stats.overdue`"},"canEdit":{"type":"boolean"},"version":{"type":"integer","description":"Версия задачи для `PATCH` в API досок"}}},"TeamTaskList":{"type":"object","required":["items","truncated"],"properties":{"items":{"type":"array","maxItems":500,"description":"Сначала незавершённые, затем по убыванию `updated_at`","items":{"$ref":"#/components/schemas/TeamTaskItem"}},"truncated":{"type":"boolean","description":"`true`, если подходящих задач больше 500"}}},"BacklogItem":{"type":"object","required":["id","projectId","title","description","priority","status","visibility","points","version","creatorId","creatorName","taskId","boardId","createdAt","archivedAt","canEdit"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"projectId":{"type":["string","null"],"format":"uuid"},"title":{"type":"string"},"description":{"type":"string"},"priority":{"type":"string","enum":["low","medium","high"]},"status":{"type":"string","enum":["idea","ready","parked","transferred"]},"visibility":{"type":"string","enum":["team","private"]},"points":{"type":["integer","null"],"minimum":0,"maximum":100,"description":"Оценка в story points"},"version":{"type":"integer"},"creatorId":{"type":["string","null"],"format":"uuid","description":"`null`, если автор удалён"},"creatorName":{"type":["string","null"]},"taskId":{"type":["string","null"],"format":"uuid","description":"Задача после переноса. `null`, если переноса не было, задача удалена или недоступна текущему пользователю"},"boardId":{"type":["string","null"],"format":"uuid","description":"Доска задачи; обнуляется вместе с `taskId`"},"createdAt":{"$ref":"#/components/schemas/Timestamp"},"archivedAt":{"anyOf":[{"$ref":"#/components/schemas/Timestamp"},{"type":"null"}]},"canEdit":{"type":"boolean","description":"Автор или `owner`/`admin`. Признак не учитывает, что перенесённую запись изменить нельзя"}}},"BacklogList":{"type":"object","required":["items","truncated"],"properties":{"items":{"type":"array","maxItems":500,"description":"По приоритету (high → medium → low), затем новые первыми","items":{"$ref":"#/components/schemas/BacklogItem"}},"truncated":{"type":"boolean","description":"`true`, если записей больше 500"}}},"BacklogInput":{"type":"object","description":"Поля записи бэклога (`backlogInput`). Неизвестные поля отбрасываются.","required":["title"],"properties":{"title":{"type":"string","minLength":1,"maxLength":200,"description":"Название; пробелы по краям обрезаются"},"description":{"type":"string","maxLength":10000,"default":""},"priority":{"type":"string","enum":["low","medium","high"],"default":"medium"},"status":{"type":"string","enum":["idea","ready","parked"],"default":"idea"},"visibility":{"type":"string","enum":["team","private"],"default":"team","description":"`private` — видят только автор и `owner`/`admin`"},"projectId":{"type":["string","null"],"format":"uuid","default":null,"description":"Проект этой организации, не в архиве"},"points":{"type":["integer","null"],"minimum":0,"maximum":100,"default":null}}},"BacklogUpdateInput":{"type":"object","description":"Частичное обновление (`backlogUpdate` в `team.ts`). Обязателен только `version`. Пропущенное поле сохраняет текущее значение, значений по умолчанию нет. Явный `null` очищает `projectId` и `points`. Неизвестные поля отбрасываются.\n","required":["version"],"properties":{"title":{"type":"string","minLength":1,"maxLength":200,"description":"Название; пробелы по краям обрезаются"},"description":{"type":"string","maxLength":10000},"priority":{"type":"string","enum":["low","medium","high"]},"status":{"type":"string","enum":["idea","ready","parked"]},"visibility":{"type":"string","enum":["team","private"],"description":"`private` — видят только автор и `owner`/`admin`"},"projectId":{"type":["string","null"],"format":"uuid","description":"Проект этой организации не в архиве; `null` отвязывает запись от проекта"},"points":{"type":["integer","null"],"minimum":0,"maximum":100,"description":"`null` убирает оценку"},"archived":{"type":"boolean","description":"`true` — в архив (`archivedAt = now()`, если записи там ещё нет), `false` — из архива. Без поля архив не меняется"},"version":{"type":"integer","minimum":1,"description":"Текущая версия записи"}}},"BacklogConvertInput":{"type":"object","required":["boardId","version"],"properties":{"boardId":{"$ref":"#/components/schemas/Uuid"},"version":{"type":"integer","minimum":1,"description":"Текущая версия записи бэклога"}}},"BacklogConvertResult":{"type":"object","required":["taskId","boardId"],"properties":{"taskId":{"$ref":"#/components/schemas/Uuid"},"boardId":{"$ref":"#/components/schemas/Uuid"}}},"TeamCreated":{"type":"object","required":["id","version"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"version":{"type":"integer","const":1}}},"TeamVersion":{"type":"object","required":["version"],"properties":{"version":{"type":"integer","description":"Новая версия записи"}}},"SprintCreateInput":{"type":"object","description":"`sprintInput`. Неизвестные поля отбрасываются.","required":["title","startDate","endDate"],"properties":{"title":{"type":"string","minLength":1,"maxLength":100,"description":"Пробелы по краям обрезаются"},"goal":{"type":"string","maxLength":2000,"default":""},"projectId":{"type":["string","null"],"format":"uuid","default":null,"description":"Проект этой организации, не в архиве. `null` — спринт всей команды"},"startDate":{"type":"string","format":"date"},"endDate":{"type":"string","format":"date","description":"Не раньше `startDate`, иначе 400 «Дата окончания раньше начала»"}}},"SprintTaskInput":{"type":"object","required":["taskId","boardId"],"properties":{"taskId":{"$ref":"#/components/schemas/Uuid"},"boardId":{"type":"string","format":"uuid","description":"Доска задачи"},"points":{"type":"integer","minimum":0,"maximum":100,"default":0,"description":"Оценка в спринте. Для уже добавленной задачи перезаписывается"},"remove":{"type":"boolean","default":false,"description":"`true` — убрать задачу из спринта"}}},"SprintChangeInput":{"type":"object","required":["action","version"],"properties":{"action":{"type":"string","enum":["start","complete"]},"version":{"type":"integer","minimum":1,"description":"Текущая версия спринта"},"carryTo":{"type":["string","null"],"format":"uuid","default":null,"description":"Только для `complete`: запланированный спринт этой организации, куда переносятся незавершённые задачи. Для `start` игнорируется"}}},"TeamProjectPhase":{"type":"object","required":["id","title","status","position","color"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"title":{"type":"string"},"status":{"type":"string","enum":["planned","active","completed"]},"position":{"type":"integer","description":"Порядок внутри проекта, с 0"},"color":{"anyOf":[{"$ref":"#/components/schemas/AccentColor"},{"type":"null"}],"description":"Цвет этапа; `null` — как у проекта"}}},"TeamProject":{"type":"object","required":["id","title","description","status","version","color","archivedAt","phases"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"title":{"type":"string"},"description":{"type":"string"},"status":{"type":"string","enum":["active","paused","completed"]},"version":{"type":"integer","description":"Общая версия проекта и его этапов"},"color":{"anyOf":[{"$ref":"#/components/schemas/AccentColor"},{"type":"null"}],"description":"Цвет проекта; `null` — стандартный"},"archivedAt":{"anyOf":[{"$ref":"#/components/schemas/Timestamp"},{"type":"null"}]},"phases":{"type":"array","description":"Этапы по `position`","items":{"$ref":"#/components/schemas/TeamProjectPhase"}}}},"ProjectCreateInput":{"type":"object","required":["title"],"properties":{"title":{"type":"string","minLength":1,"maxLength":100,"description":"Пробелы по краям обрезаются"},"description":{"type":"string","maxLength":2000,"default":""},"color":{"anyOf":[{"$ref":"#/components/schemas/AccentColor"},{"type":"null"}],"default":null},"phases":{"type":"array","maxItems":20,"default":[],"description":"Названия этапов (1–100 символов) — создаются сразу, по порядку, со статусом `planned`","items":{"type":"string","minLength":1,"maxLength":100}}}},"ProjectCreated":{"type":"object","required":["id","phases"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"phases":{"type":"array","description":"ID созданных этапов в порядке `phases` запроса","items":{"$ref":"#/components/schemas/Uuid"}}}},"ProjectUpdateInput":{"type":"object","description":"Частичное обновление. Обязателен только `version`, пропущенные поля сохраняют текущие значения.","required":["version"],"properties":{"title":{"type":"string","minLength":1,"maxLength":100,"description":"Пробелы по краям обрезаются"},"description":{"type":"string","maxLength":2000},"status":{"type":"string","enum":["active","paused","completed"]},"archived":{"type":"boolean","description":"`true` — в архив, `false` — из архива. Без поля архив не меняется"},"color":{"anyOf":[{"$ref":"#/components/schemas/AccentColor"},{"type":"null"}],"description":"Новый цвет; `null` — снять. Без поля цвет не меняется"},"version":{"type":"integer","minimum":1}}},"PhaseCreateInput":{"type":"object","required":["title"],"properties":{"title":{"type":"string","minLength":1,"maxLength":100},"color":{"anyOf":[{"$ref":"#/components/schemas/AccentColor"},{"type":"null"}],"default":null}}},"PhaseUpdateInput":{"type":"object","description":"Частичное обновление. Обязателен только `version`, пропущенные поля не меняются.","required":["version"],"properties":{"title":{"type":"string","minLength":1,"maxLength":100},"status":{"type":"string","enum":["planned","active","completed"]},"color":{"anyOf":[{"$ref":"#/components/schemas/AccentColor"},{"type":"null"}],"description":"Цвет этапа; `null` — снять (как у проекта)"},"version":{"type":"integer","minimum":1,"description":"**Версия проекта**, а не этапа"}}},"TeamIdResult":{"type":"object","required":["id"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"}}},"ActivityEvent":{"type":"object","required":["id","scope","action","title","details","createdAt","boardId","taskId","boardTitle","actorName","avatarColor","taskExists"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"scope":{"type":"string","enum":["team","admin","board","task","backlog","sprint","project","wiki"]},"action":{"type":"string","description":"Код действия: `task.<действие>` для событий задач (например, `task.created`, `task.commented`, `task.sprint_added`), `backlog.created`, `sprint.started`, `project.archived`, `phase.created`, `wiki.published` и т.п."},"title":{"type":"string","description":"Снимок названия объекта на момент события. Для этапа — «Проект / Этап»"},"details":{"type":"object","additionalProperties":true,"description":"Детали события, произвольный JSON"},"createdAt":{"$ref":"#/components/schemas/Timestamp"},"boardId":{"type":["string","null"],"format":"uuid"},"taskId":{"type":["string","null"],"format":"uuid"},"boardTitle":{"type":["string","null"],"description":"Текущее название доски, а если её удалили — снимок `board_title`, сохранённый при записи события (у событий задач — триггером из `task_events`, у остальных событий с доской — `teamEvent`; у событий, записанных `teamEvent` до появления снимка, его нет). `null` — событие не связано с доской или доска удалена без снимка"},"actorName":{"type":["string","null"],"description":"`null`, если автор удалён"},"avatarColor":{"type":["string","null"]},"taskExists":{"type":"boolean","description":"Существует ли задача сейчас"}}},"ActivityPage":{"type":"object","required":["items","nextCursor"],"properties":{"items":{"type":"array","maxItems":50,"items":{"$ref":"#/components/schemas/ActivityEvent"}},"nextCursor":{"type":["string","null"],"pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{6}Z~[0-9a-f-]{36}$","description":"Курсор следующей страницы для `before`: `<created_at в UTC с микросекундами>~<id>` последнего события страницы; `null` — больше нет"}}},"WikiMark":{"type":"object","required":["type"],"properties":{"type":{"type":"string","enum":["bold","italic","strike","underline","code","link"]},"attrs":{"type":"object","description":"Только у `link`: `href`, `target: _blank`, `rel: noopener noreferrer`","properties":{"href":{"type":"string","maxLength":2000},"target":{"type":"string","const":"_blank"},"rel":{"type":"string","const":"noopener noreferrer"}}}}},"WikiNode":{"type":"object","description":"Узел документа Tiptap после серверной нормализации. Правила — в описании раздела «Wiki».","required":["type"],"properties":{"type":{"type":"string","enum":["doc","paragraph","text","heading","bulletList","orderedList","listItem","blockquote","codeBlock","horizontalRule","hardBreak","image","taskList","taskItem"]},"text":{"type":"string","description":"Только у `text`"},"attrs":{"type":"object","description":"`heading`: `level`; `orderedList`: `start`; `taskItem`: `checked`; `codeBlock`: `language: null`; `image`: `src`, `alt`, `title: null`","additionalProperties":true},"marks":{"type":"array","maxItems":10,"items":{"$ref":"#/components/schemas/WikiMark"}},"content":{"type":"array","items":{"$ref":"#/components/schemas/WikiNode"}}}},"WikiPageListItem":{"type":"object","required":["id","slug","title","summary","status","version","createdAt","updatedAt","authorName","creatorId","creatorName","updatedById","updatedByName","canEdit"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"slug":{"type":"string"},"title":{"type":"string"},"summary":{"type":"string"},"status":{"type":"string","enum":["draft","published","archived"]},"version":{"type":"integer"},"createdAt":{"$ref":"#/components/schemas/Timestamp"},"updatedAt":{"$ref":"#/components/schemas/Timestamp"},"authorName":{"type":["string","null"],"deprecated":true,"description":"Оставлено для старых клиентов: имя **последнего редактора**, то же, что `updatedByName`, а не создателя"},"creatorId":{"type":["string","null"],"format":"uuid","description":"Создатель статьи (`creator_id`); `null`, если пользователь удалён"},"creatorName":{"type":["string","null"],"description":"Имя создателя; `null`, если пользователь удалён"},"updatedById":{"type":["string","null"],"format":"uuid","description":"Последний редактор (`updated_by`); `null`, если пользователь удалён"},"updatedByName":{"type":["string","null"],"description":"Имя последнего редактора; `null`, если пользователь удалён"},"canEdit":{"type":"boolean"}}},"WikiSpace":{"type":"object","required":["pages","truncated","canCreate","canPublish"],"properties":{"pages":{"type":"array","maxItems":500,"description":"Не больше 500 статей, сначала недавно изменённые (`updated_at DESC`, затем `id`)","items":{"$ref":"#/components/schemas/WikiPageListItem"}},"truncated":{"type":"boolean","description":"`true`, если статей больше 500 и список обрезан"},"canCreate":{"type":"boolean","description":"Организация — любой сотрудник; `public` — редактор платформы"},"canPublish":{"type":"boolean","description":"Организация — `owner`/`admin`; `public` — редактор платформы"}}},"WikiPageCreateInput":{"type":"object","required":["title","slug"],"properties":{"title":{"type":"string","minLength":1,"maxLength":180,"description":"Пробелы по краям обрезаются"},"slug":{"type":"string","maxLength":120,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$"},"summary":{"type":"string","maxLength":500,"default":"","description":"Пробелы по краям обрезаются"}}},"WikiPageSaveInput":{"type":"object","required":["title","slug","version","status","body"],"properties":{"title":{"type":"string","minLength":1,"maxLength":180},"slug":{"type":"string","maxLength":120,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$"},"summary":{"type":"string","maxLength":500,"default":""},"version":{"type":"integer","minimum":1,"description":"Текущая версия статьи"},"status":{"type":"string","enum":["draft","published","archived"]},"body":{"description":"Документ Tiptap с корнем `doc`. Zod принимает любое значение (`z.unknown()`), проверяет нормализатор. Без `body` ответ 400 «Некорректный документ»","$ref":"#/components/schemas/WikiNode"}}},"WikiPageDetail":{"type":"object","description":"Статья с документом. Основные поля — в camelCase (`workspaceId`, `creatorId`, `creatorName`, `updatedById`,\n`updatedByName`, `createdAt`, `updatedAt`, `canEdit`, `canPublish`). Для старых клиентов в ответе остаются и\nколонки строки [wiki_pages](#модели/dbwiki-pages) в snake_case (`workspace_id`, `creator_id`, `updated_by`,\n`created_at`, `updated_at`) с теми же значениями — новые клиенты должны читать camelCase.\n","required":["id","slug","title","summary","body","status","version","workspaceId","creatorId","creatorName","updatedById","updatedByName","createdAt","updatedAt","canEdit","canPublish","workspace_id","creator_id","updated_by","created_at","updated_at"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"slug":{"type":"string"},"title":{"type":"string"},"summary":{"type":"string"},"body":{"$ref":"#/components/schemas/WikiNode"},"status":{"type":"string","enum":["draft","published","archived"]},"version":{"type":"integer"},"workspaceId":{"type":["string","null"],"format":"uuid","description":"Организация; `null` — публичная Wiki"},"creatorId":{"type":["string","null"],"format":"uuid","description":"Создатель статьи; `null`, если пользователь удалён"},"creatorName":{"type":["string","null"],"description":"Имя создателя; `null`, если пользователь удалён"},"updatedById":{"type":["string","null"],"format":"uuid","description":"Последний редактор; `null`, если пользователь удалён"},"updatedByName":{"type":["string","null"],"description":"Имя последнего редактора; `null`, если пользователь удалён"},"createdAt":{"$ref":"#/components/schemas/Timestamp"},"updatedAt":{"$ref":"#/components/schemas/Timestamp"},"canEdit":{"type":"boolean"},"canPublish":{"type":"boolean"},"workspace_id":{"type":["string","null"],"format":"uuid","deprecated":true,"description":"Устаревшее: то же, что `workspaceId`"},"creator_id":{"type":["string","null"],"format":"uuid","deprecated":true,"description":"Устаревшее: то же, что `creatorId`"},"updated_by":{"type":["string","null"],"format":"uuid","deprecated":true,"description":"Устаревшее: то же, что `updatedById`"},"created_at":{"$ref":"#/components/schemas/Timestamp","deprecated":true,"description":"Устаревшее: то же, что `createdAt`"},"updated_at":{"$ref":"#/components/schemas/Timestamp","deprecated":true,"description":"Устаревшее: то же, что `updatedAt`"}}},"WikiVersion":{"type":"object","required":["id","version","title","summary","body","status","createdAt","authorName"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"version":{"type":"integer","description":"Версия статьи после этого сохранения"},"title":{"type":"string"},"summary":{"type":"string"},"body":{"$ref":"#/components/schemas/WikiNode"},"status":{"type":"string","enum":["draft","published","archived"]},"createdAt":{"$ref":"#/components/schemas/Timestamp"},"authorName":{"type":["string","null"]}}},"WikiFileUploaded":{"type":"object","required":["id","name","mime","url"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"name":{"type":"string","minLength":1,"maxLength":200,"description":"Имя файла: UTF-8 (кириллица сохраняется), NFC; управляющие символы, `/`, `\\` и символы управления направлением текста заменены на `_`; до 200 кодовых точек. Пустое имя заменяется на `media`"},"mime":{"type":"string","description":"Тип","определённый сервером по содержимому":null},"url":{"type":"string","description":"`/api/v1/wiki/files/{id}` — используйте в `image.src` или `link.href`"}}},"PublicWikiPageListItem":{"type":"object","required":["slug","title","summary","updatedAt"],"properties":{"slug":{"type":"string"},"title":{"type":"string"},"summary":{"type":"string"},"updatedAt":{"$ref":"#/components/schemas/Timestamp"}}},"PublicWikiPage":{"type":"object","required":["slug","title","summary","body","publicBody","updatedAt"],"properties":{"slug":{"type":"string"},"title":{"type":"string"},"summary":{"type":"string"},"body":{"$ref":"#/components/schemas/WikiNode","description":"Документ как хранится: медиа-ссылки вида `/api/v1/wiki/files/{id}` (маршрут требует входа). Оставлен для старых клиентов"},"publicBody":{"$ref":"#/components/schemas/WikiNode","description":"Тот же документ, где `image.src` и `link.href` вида `/api/v1/wiki/files/{id}` заменены на анонимный `/api/v1/wiki/public/files/{id}`. Используйте его для показа без входа"},"updatedAt":{"$ref":"#/components/schemas/Timestamp"}}},"Note":{"type":"object","description":"Заметка в ответе на изменение.","required":["id","title","body","folder","pinned","version","updatedAt"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"title":{"type":"string","minLength":1,"maxLength":200,"description":"Заголовок"},"body":{"type":"string","maxLength":100000,"description":"Текст заметки (веб-клиент показывает его как Markdown)"},"folder":{"type":"string","maxLength":60,"description":"Папка; пустая строка — «Без папки»"},"pinned":{"type":"boolean","description":"Закреплена ли заметка вверху списка"},"version":{"type":"integer","minimum":1,"description":"Текущая версия для оптимистической блокировки"},"updatedAt":{"$ref":"#/components/schemas/Timestamp"}}},"NoteListItem":{"type":"object","description":"Заметка в списке `GET /api/v1/notes` — поля [Note](#модели/note) и курсор страницы.","required":["id","title","body","folder","pinned","version","updatedAt","cursor"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"title":{"type":"string","minLength":1,"maxLength":200,"description":"Заголовок"},"body":{"type":"string","maxLength":100000,"description":"Текст заметки"},"folder":{"type":"string","maxLength":60,"description":"Папка; пустая строка — «Без папки»"},"pinned":{"type":"boolean","description":"Закреплена ли заметка вверху списка"},"version":{"type":"integer","minimum":1,"description":"Текущая версия для оптимистической блокировки"},"updatedAt":{"$ref":"#/components/schemas/Timestamp"},"cursor":{"type":"string","description":"Непрозрачный курсор позиции заметки в списке (base64url). Передайте курсор последней заметки\nстраницы в `before`, чтобы получить следующую. Разбирать или собирать его на клиенте не нужно.\n"}}},"NotesTrashEmptied":{"type":"object","required":["ok","deleted"],"properties":{"ok":{"type":"boolean","const":true},"deleted":{"type":"integer","minimum":0,"description":"Сколько заметок удалено навсегда (0 — корзина была пуста)"}}},"NoteCreated":{"type":"object","description":"Ответ на создание заметки. Поля `updatedAt` в нём нет.","required":["id","title","body","folder","pinned","version"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"title":{"type":"string"},"body":{"type":"string"},"folder":{"type":"string"},"pinned":{"type":"boolean"},"version":{"type":"integer","const":1}}},"NoteInput":{"type":"object","description":"Тело создания заметки. Лишние поля отбрасываются.","required":["title"],"properties":{"title":{"type":"string","minLength":1,"maxLength":200,"description":"Заголовок; сначала `trim()`, потом проверка длины"},"body":{"type":"string","maxLength":100000,"default":"","description":"Текст заметки"},"folder":{"type":"string","maxLength":60,"default":"","description":"Папка; `trim()`"},"pinned":{"type":"boolean","default":false}}},"NoteUpdateInput":{"type":"object","description":"Тело изменения заметки — частичное обновление. Обязателен только `version`. Поле, которого нет\nв запросе, сохраняет текущее значение. Чтобы очистить текст или папку, передайте `\"\"`.\n","required":["version"],"properties":{"title":{"type":"string","minLength":1,"maxLength":200,"description":"Заголовок; `trim()`"},"body":{"type":"string","maxLength":100000,"description":"Текст; `\"\"` очищает его"},"folder":{"type":"string","maxLength":60,"description":"Папка; `trim()`, `\"\"` — «Без папки»"},"pinned":{"type":"boolean"},"version":{"type":"integer","minimum":1,"description":"Версия","которую клиент видел последней":null}}},"PlannerTask":{"type":"object","description":"Задача в недельном плане.","required":["id","boardId","title","version","priority","columnId","dueDate","day","minutes","boardTitle","done","doneColumnId","canEdit"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"boardId":{"$ref":"#/components/schemas/Uuid"},"title":{"type":"string","description":"Название задачи"},"version":{"type":"integer","description":"Версия задачи — передаётся в PATCH задачи на доске"},"priority":{"type":"string","enum":["low","medium","high"]},"columnId":{"$ref":"#/components/schemas/Uuid"},"dueDate":{"type":["string","null"],"format":"date","description":"Срок задачи"},"day":{"type":["string","null"],"format":"date","description":"День в личном плане; `null` — не запланирована"},"minutes":{"type":["integer","null"],"minimum":5,"maximum":480,"description":"Оценка в минутах; `null` — не запланирована (веб-клиент тогда показывает 30)"},"boardTitle":{"type":"string","description":"Название доски"},"done":{"type":"boolean","description":"Задача завершена (`completed_at IS NOT NULL`)"},"doneColumnId":{"$ref":"#/components/schemas/Uuid"},"canEdit":{"type":"boolean","description":"Может ли пользователь редактировать задачу (и","значит":null,"завершить её)":null}}},"DailyCompletionCount":{"type":"object","description":"Число завершённых задач за день.","required":["day","count"],"properties":{"day":{"type":"string","format":"date"},"count":{"type":"integer","minimum":0}}},"PlannerWeek":{"type":"object","required":["tasks","today","dailyGoal","week","completions"],"properties":{"tasks":{"type":"array","maxItems":300,"items":{"$ref":"#/components/schemas/PlannerTask"}},"today":{"type":"string","format":"date","description":"Сегодняшняя дата в часовом поясе пользователя"},"dailyGoal":{"type":"integer","minimum":1,"maximum":20,"description":"Дневная цель"},"week":{"type":"string","format":"date","description":"Первый день окна: `week` из запроса, без него — понедельник текущей недели по часовому поясу пользователя"},"completions":{"type":"array","description":"Дни окна, в которые были завершения (по `completion_records.local_day`). Дни без завершений отсутствуют.","items":{"$ref":"#/components/schemas/DailyCompletionCount"}}}},"PlannerDayTask":{"type":"object","description":"Задача в одном из списков экрана «Сегодня».","required":["id","boardId","boardTitle","boardAccent","number","title","dueDate","priority","columnId","firstColumnId","lastColumnId","version","canEdit","plannedDay"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"boardId":{"$ref":"#/components/schemas/Uuid"},"boardTitle":{"type":"string","description":"Название доски"},"boardAccent":{"anyOf":[{"$ref":"#/components/schemas/AccentColor"},{"type":"null"}],"description":"Цвет доски (доски, иначе этапа, иначе проекта) для метки"},"number":{"type":"integer","minimum":1,"description":"Постоянный номер задачи на доске (показывается как MX-12)"},"title":{"type":"string","description":"Название задачи"},"dueDate":{"type":["string","null"],"format":"date","description":"Срок задачи"},"priority":{"type":"string","enum":["low","medium","high"]},"columnId":{"$ref":"#/components/schemas/Uuid"},"firstColumnId":{"$ref":"#/components/schemas/Uuid","description":"Первая колонка доски (по `position`) — куда вернуть задачу из выполненных"},"lastColumnId":{"$ref":"#/components/schemas/Uuid","description":"Последняя колонка доски — перенос в неё завершает задачу"},"version":{"type":"integer","minimum":1,"description":"Версия задачи — передаётся в PATCH задачи на доске"},"canEdit":{"type":"boolean","description":"Может ли пользователь редактировать (и завершить) задачу"},"plannedDay":{"type":["string","null"],"format":"date","description":"День в личном плане пользователя; `null` — задача не запланирована"}}},"PlannerToday":{"type":"object","description":"Экран «Сегодня». Каждая задача попадает не больше чем в один список, в каждом списке — до 200 задач.","required":["date","overdue","today","planned","doneToday"],"properties":{"date":{"type":"string","format":"date","description":"Сегодняшняя дата в часовом поясе пользователя"},"overdue":{"type":"array","maxItems":200,"description":"Мои невыполненные задачи со сроком раньше сегодняшнего","items":{"$ref":"#/components/schemas/PlannerDayTask"}},"today":{"type":"array","maxItems":200,"description":"Мои невыполненные задачи со сроком на сегодня","items":{"$ref":"#/components/schemas/PlannerDayTask"}},"planned":{"type":"array","maxItems":200,"description":"Невыполненные задачи, поставленные на сегодня в личном плане (кроме попавших в `overdue` и `today`)","items":{"$ref":"#/components/schemas/PlannerDayTask"}},"doneToday":{"type":"array","maxItems":200,"description":"Задачи в последней колонке, завершённые сегодня, — мои или запланированные на сегодня. Сначала недавно завершённые","items":{"$ref":"#/components/schemas/PlannerDayTask"}}}},"PlannerPlanInput":{"type":"object","required":["boardId","day"],"properties":{"boardId":{"$ref":"#/components/schemas/Uuid"},"day":{"type":["string","null"],"format":"date","description":"День плана; `null` снимает задачу с плана. Ключ обязателен"},"minutes":{"type":"integer","minimum":5,"maximum":480,"default":30,"description":"Оценка в минутах"}}},"PlannerGoal":{"type":"object","required":["dailyGoal"],"properties":{"dailyGoal":{"type":"integer","minimum":1,"maximum":20,"description":"Сколько задач в день пользователь хочет завершать"}}},"MotivationStats":{"type":"object","description":"Счётчики по задачам, где пользователь — исполнитель, тот, кто завершил, или автор задачи без\nисполнителя. Считаются по таблице `tasks`, а не по `completion_records`, поэтому могут\nрасходиться с `game.metrics`.\n","required":["completed","month","active","overdue"],"properties":{"completed":{"type":"integer","description":"Завершено за всё время"},"month":{"type":"integer","description":"Завершено за последние 30 дней"},"active":{"type":"integer","description":"Не завершено"},"overdue":{"type":"integer","description":"Не завершено и срок раньше сегодняшнего дня пользователя (`users.timezone`)"}}},"MotivationShopItem":{"type":"object","required":["id","title","description","price","slot","owned","equipped"],"properties":{"id":{"type":"string","description":"ID предмета"},"title":{"type":"string"},"description":{"type":"string"},"price":{"type":"integer","minimum":1,"description":"Цена в монетах"},"slot":{"type":"string","enum":["frame","cover","badge"]},"owned":{"type":"boolean","description":"Куплен пользователем"},"equipped":{"type":"boolean","description":"Надет сейчас"}}},"MotivationLedgerEntry":{"type":"object","required":["amount","reason","createdAt"],"properties":{"amount":{"type":"integer","description":"Изменение баланса: положительное — начисление, отрицательное — покупка"},"reason":{"type":"string","enum":["task_completed","purchase","achievement"]},"createdAt":{"$ref":"#/components/schemas/Timestamp"}}},"MotivationGame":{"type":"object","required":["metrics","streak","today","todayCount","dailyGoal"],"properties":{"metrics":{"type":"object","description":"Метрики для условий достижений.","required":["completed","onTime","streak"],"properties":{"completed":{"type":"integer","description":"Число записей о завершении"},"onTime":{"type":"integer","description":"Из них завершено не позднее срока"},"streak":{"type":"integer","description":"Самая длинная серия дней подряд"}}},"streak":{"type":"object","required":["current","longest"],"properties":{"current":{"type":"integer","description":"Текущая серия; 0, если последнее завершение было раньше вчерашнего дня"},"longest":{"type":"integer","description":"Самая длинная серия"}}},"today":{"type":"string","format":"date","description":"Сегодня в часовом поясе пользователя"},"todayCount":{"type":"integer","description":"Завершено сегодня"},"dailyGoal":{"type":"integer","minimum":1,"maximum":20}}},"MotivationAchievement":{"type":"object","required":["id","title","description","metric","target","coins","xp","unlockedAt","claimedAt","progress","eligible"],"properties":{"id":{"type":"string","description":"ID достижения"},"title":{"type":"string"},"description":{"type":"string"},"metric":{"type":"string","enum":["completed","streak","onTime"]},"target":{"type":"integer","description":"Порог метрики"},"coins":{"type":"integer","description":"Награда в монетах"},"xp":{"type":"integer","description":"Награда в XP"},"unlockedAt":{"type":["string","null"],"format":"date-time","description":"Когда достижение разблокировано"},"claimedAt":{"type":["string","null"],"format":"date-time","description":"Когда получена награда; `null` — ещё не получена"},"progress":{"type":"integer","description":"`min(target, значение метрики)`"},"eligible":{"type":"boolean","description":"Метрика ≥ target. Остаётся `true` и после получения награды — проверяйте `claimedAt`"}}},"MotivationOverview":{"type":"object","required":["balance","xp","activeCosmetic","level","stats","activity","catalog","ledger","game","achievements","heatmap"],"properties":{"balance":{"type":"integer","minimum":0,"description":"Баланс монет"},"xp":{"type":"integer","minimum":0},"activeCosmetic":{"type":["string","null"],"description":"Один из надетых предметов (устаревшее поле, см. «Покупка и оформление»); `null` — ничего не надето"},"level":{"type":"integer","minimum":1,"description":"`floor(xp / 100) + 1`"},"stats":{"$ref":"#/components/schemas/MotivationStats"},"activity":{"type":"array","description":"14 дней до сегодняшнего включительно, по `tasks.completed_at` в часовом поясе пользователя.","items":{"$ref":"#/components/schemas/DailyCompletionCount"}},"catalog":{"type":"array","items":{"$ref":"#/components/schemas/MotivationShopItem"}},"ledger":{"type":"array","maxItems":30,"items":{"$ref":"#/components/schemas/MotivationLedgerEntry"}},"game":{"$ref":"#/components/schemas/MotivationGame"},"achievements":{"type":"array","description":"Все достижения каталога, отсортированные по `target`, затем по `coins`.","items":{"$ref":"#/components/schemas/MotivationAchievement"}},"heatmap":{"type":"array","description":"28 дней по `completion_records.local_day`, последний — `game.today`.","items":{"$ref":"#/components/schemas/DailyCompletionCount"}}}},"MotivationPurchaseInput":{"type":"object","required":["itemId"],"properties":{"itemId":{"type":"string","maxLength":50,"description":"ID предмета из каталога"}}},"MotivationAppearanceInput":{"type":"object","required":["itemId"],"properties":{"itemId":{"type":["string","null"],"maxLength":50,"description":"Предмет, который надеть; `null` — снять. Ключ обязателен"},"slot":{"type":"string","enum":["frame","cover","badge"],"description":"Используется только при `itemId: null`: какой слот снять. Без него снимается всё"}}},"MotivationClaimResult":{"type":"object","required":["reward"],"properties":{"reward":{"type":"object","required":["coins","xp","unlocked","forActor"],"properties":{"coins":{"type":"integer"},"xp":{"type":"integer"},"unlocked":{"type":"array","items":{"type":"string"},"description":"Название полученного достижения"},"forActor":{"type":"boolean","const":true}}}}},"MotivationTeam":{"type":"object","required":["summary","members"],"properties":{"summary":{"type":"object","description":"Все задачи на досках организации.","required":["total","completed","overdue"],"properties":{"total":{"type":"integer"},"completed":{"type":"integer"},"overdue":{"type":"integer","description":"Не завершены, срок раньше сегодняшнего дня по часовому поясу запросившего пользователя"}}},"members":{"type":"array","description":"Участники организации, отсортированные по `completed` (по убыванию), затем по имени.","items":{"type":"object","required":["id","name","kind","completed","active"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"name":{"type":"string"},"kind":{"type":"string","enum":["human","ai"],"description":"Человек или ИИ-сотрудник"},"completed":{"type":"integer","description":"Завершено за 30 дней на досках организации"},"active":{"type":"integer","description":"Не завершено на досках организации"}}}}}},"PeopleCard":{"type":"object","required":["id","name","jobTitle","avatarColor","xp","activeCosmetic","cosmetics"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"name":{"type":"string"},"jobTitle":{"type":"string","description":"Должность; может быть `\"\"`"},"avatarColor":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$"},"xp":{"type":"integer"},"activeCosmetic":{"type":["string","null"]},"cosmetics":{"type":"array","items":{"type":"string"},"description":"ID надетых предметов в порядке слотов; пустой массив, если ничего не надето"}}},"PeopleProfile":{"type":"object","required":["id","name","bio","jobTitle","avatarColor","xp","activeCosmetic","publicProfile","cosmetics","level","completed"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"name":{"type":"string"},"bio":{"type":"string"},"jobTitle":{"type":"string"},"avatarColor":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$"},"xp":{"type":"integer"},"activeCosmetic":{"type":["string","null"]},"publicProfile":{"type":"boolean","description":"Для чужого профиля всегда `true`"},"cosmetics":{"type":"array","items":{"type":"string"},"description":"ID надетых предметов (пустой массив, если ничего не надето)"},"level":{"type":"integer","minimum":1,"description":"`floor(xp / 100) + 1`"},"completed":{"type":"integer","description":"Завершённые задачи, где пользователь — исполнитель, тот, кто завершил, или автор задачи без исполнителя"}}},"PersonalVaultEnvelope":{"type":"object","description":"Зашифрованный конверт AES-256-GCM. Формат описан в теге «Личный сейф».","required":["iv","ciphertext"],"properties":{"iv":{"type":"string","minLength":16,"maxLength":16,"pattern":"^[A-Za-z0-9+/]+={0,2}$","description":"Base64 от 12 случайных байт"},"ciphertext":{"type":"string","minLength":24,"maxLength":100000,"pattern":"^[A-Za-z0-9+/]+={0,2}$","description":"Base64 от шифртекста с 16-байтным тегом в конце"}}},"PersonalVaultConfig":{"type":"object","description":"Параметры уже созданного сейфа.","required":["initialized","salt","verifier"],"properties":{"initialized":{"type":"boolean","const":true},"salt":{"type":"string","minLength":24,"maxLength":24,"pattern":"^[A-Za-z0-9+/]+={0,2}$","description":"Base64 от 16 байт соли PBKDF2"},"verifier":{"$ref":"#/components/schemas/PersonalVaultEnvelope"}}},"PersonalVaultUninitialized":{"type":"object","description":"Сейф ещё не создан.","required":["initialized"],"properties":{"initialized":{"type":"boolean","const":false}}},"PersonalVaultConfigInput":{"type":"object","required":["salt","verifier"],"properties":{"salt":{"type":"string","minLength":24,"maxLength":24,"pattern":"^[A-Za-z0-9+/]+={0,2}$","description":"Base64 от 16 случайных байт"},"verifier":{"$ref":"#/components/schemas/PersonalVaultEnvelope"}}},"PersonalVaultRecord":{"type":"object","required":["id","kind","envelope","version","updatedAt"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"kind":{"type":"string","enum":["password","account","card","contact","note"]},"envelope":{"$ref":"#/components/schemas/PersonalVaultEnvelope"},"version":{"type":"integer","minimum":1},"updatedAt":{"$ref":"#/components/schemas/Timestamp"}}},"PersonalVaultRecordInput":{"type":"object","required":["id","kind","envelope"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"kind":{"type":"string","enum":["password","account","card","contact","note"],"description":"Тип записи; входит в AAD и потом не меняется"},"envelope":{"$ref":"#/components/schemas/PersonalVaultEnvelope"}}},"PersonalVaultRecordUpdate":{"type":"object","required":["envelope","version"],"properties":{"envelope":{"$ref":"#/components/schemas/PersonalVaultEnvelope"},"version":{"type":"integer","minimum":1,"description":"Текущая версия записи"}}},"PersonalVaultRekeyInput":{"type":"object","description":"Новая соль, новый проверочный конверт и все записи, перешифрованные ключом из нового мастер-пароля.\n`records` должен совпадать с текущими записями пользователя ровно: те же `id`, те же `version`.\n","required":["salt","verifier","records"],"properties":{"salt":{"type":"string","minLength":24,"maxLength":24,"pattern":"^[A-Za-z0-9+/]+={0,2}$","description":"Новая соль PBKDF2: base64 от 16 случайных байт"},"verifier":{"$ref":"#/components/schemas/PersonalVaultEnvelope"},"records":{"type":"array","maxItems":10000,"description":"Все записи сейфа (пустой массив, если записей нет). `id` не должны повторяться: иначе 400 «Записи в списке повторяются»","items":{"type":"object","required":["id","envelope","version"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"envelope":{"$ref":"#/components/schemas/PersonalVaultEnvelope"},"version":{"type":"integer","minimum":1,"description":"Текущая версия записи"}}}}}},"PersonalVaultRekeyResult":{"type":"object","required":["ok","records"],"properties":{"ok":{"type":"boolean","const":true},"records":{"type":"array","description":"Новые версии записей (у каждой — на 1 больше). Порядок не гарантирован.","items":{"type":"object","required":["id","version"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"version":{"type":"integer","minimum":2}}}}}},"AiProvider":{"type":"string","enum":["openai","anthropic","ollama"],"description":"Провайдер модели. `ollama` — сервер Ollama по адресу из переменной окружения `OLLAMA_URL` (по умолчанию `http://127.0.0.1:11434`)."},"AiSpecialty":{"type":"string","enum":["assistant","developer","tester","analyst"],"description":"Специализация. Прав не даёт, подставляется в промпт этапов цепочки."},"AiEmployee":{"type":"object","description":"ИИ-сотрудник в списке организации.","required":["id","name","specialty","provider","model","instructions","enabled","dailyRequests","dailyTokens","maxOutputTokens","hasKey","role","usedRequests","usedTokens"],"properties":{"id":{"$ref":"#/components/schemas/Uuid","description":"ID ИИ-сотрудника (`users.id`)"},"name":{"type":"string","description":"Имя (`users.name`)"},"specialty":{"$ref":"#/components/schemas/AiSpecialty"},"provider":{"$ref":"#/components/schemas/AiProvider"},"model":{"type":"string","description":"Название модели у провайдера"},"instructions":{"type":"string","description":"Системные инструкции сотрудника; пустая строка — встроенный промпт при прямом запуске"},"enabled":{"type":"boolean","description":"Включён ли сотрудник"},"dailyRequests":{"type":"integer","description":"Лимит запросов за день UTC"},"dailyTokens":{"type":"integer","description":"Лимит токенов за день UTC"},"maxOutputTokens":{"type":"integer","description":"Лимит длины ответа модели"},"hasKey":{"type":"boolean","description":"Сохранён ли ключ провайдера. Сам ключ API не возвращает"},"role":{"type":"string","enum":["admin","member","guest"],"description":"Роль в организации (`workspace_members.role`)"},"usedRequests":{"type":"integer","minimum":0,"description":"Запросов за текущий день UTC, включая неудачные"},"usedTokens":{"type":"integer","minimum":0,"description":"Токенов за текущий день UTC вместе с резервами незавершённых и неудачных запусков"}}},"AiEmployeeInput":{"type":"object","description":"Полная конфигурация ИИ-сотрудника при создании (`configInput`). Не переданные необязательные поля получают значения по умолчанию, лишние поля отбрасываются. Для изменения используется [AiEmployeeUpdateInput](#модели/aiemployeeupdateinput).\n","required":["name","provider","model"],"properties":{"name":{"type":"string","minLength":2,"maxLength":80,"description":"Имя; пробелы по краям обрезаются"},"provider":{"$ref":"#/components/schemas/AiProvider"},"model":{"type":"string","minLength":1,"maxLength":100,"description":"Название модели. Списка допустимых значений нет — модель проверяет провайдер при вызове. Пробелы по краям обрезаются"},"instructions":{"type":"string","maxLength":8000,"default":"","description":"Системные инструкции"},"apiKey":{"type":"string","maxLength":1000,"writeOnly":true,"description":"Ключ провайдера. Шифруется на сервере и никогда не возвращается. Пустая строка равносильна отсутствию поля. При изменении сотрудника без ключа сохранённый ключ остаётся, если провайдер не меняется, и стирается, если меняется.\n"},"role":{"type":"string","enum":["admin","member","guest"],"default":"member","description":"Роль в организации. `admin` назначает только владелец"},"specialty":{"$ref":"#/components/schemas/AiSpecialty","default":"assistant"},"enabled":{"type":"boolean","default":true},"dailyRequests":{"type":"integer","minimum":1,"maximum":1000,"default":20,"description":"Запросов за день UTC"},"dailyTokens":{"type":"integer","minimum":1000,"maximum":2000000,"default":100000,"description":"Токенов за день UTC"},"maxOutputTokens":{"type":"integer","minimum":128,"maximum":4096,"default":1024,"description":"Лимит длины ответа модели"}}},"AiEmployeeUpdateInput":{"type":"object","description":"Частичное изменение ИИ-сотрудника (`configUpdate`). Все поля необязательны и не имеют значений по умолчанию: пропущенное поле сохраняет текущее значение. Лишние поля отбрасываются.\n","properties":{"name":{"type":"string","minLength":2,"maxLength":80,"description":"Имя; пробелы по краям обрезаются"},"provider":{"$ref":"#/components/schemas/AiProvider"},"model":{"type":"string","minLength":1,"maxLength":100,"description":"Название модели; пробелы по краям обрезаются"},"instructions":{"type":"string","maxLength":8000,"description":"Системные инструкции; `\"\"` очищает их"},"apiKey":{"type":"string","maxLength":1000,"writeOnly":true,"description":"Новый ключ провайдера. Пустая строка равносильна отсутствию поля: сохранённый ключ остаётся, если провайдер не меняется, и стирается, если меняется.\n"},"role":{"type":"string","enum":["admin","member","guest"],"description":"Роль в организации. Без поля роль не меняется. `admin` назначает только владелец"},"specialty":{"$ref":"#/components/schemas/AiSpecialty"},"enabled":{"type":"boolean"},"dailyRequests":{"type":"integer","minimum":1,"maximum":1000,"description":"Запросов за день UTC"},"dailyTokens":{"type":"integer","minimum":1000,"maximum":2000000,"description":"Токенов за день UTC"},"maxOutputTokens":{"type":"integer","minimum":128,"maximum":4096,"description":"Лимит длины ответа модели"}}},"AiEmployeeCreated":{"type":"object","required":["id"],"properties":{"id":{"$ref":"#/components/schemas/Uuid","description":"ID созданного ИИ-сотрудника (`users.id`)"}}},"AiRunInput":{"type":"object","required":["boardId","taskId"],"properties":{"boardId":{"$ref":"#/components/schemas/Uuid"},"taskId":{"$ref":"#/components/schemas/Uuid"}}},"AiRunResult":{"type":"object","required":["id","status","commentId"],"properties":{"id":{"$ref":"#/components/schemas/Uuid","description":"ID запуска (`ai_runs.id`)"},"status":{"type":"string","const":"completed"},"commentId":{"$ref":"#/components/schemas/Uuid","description":"ID комментария с ответом модели"}}},"AgentRunStatus":{"type":"string","enum":["queued","running","completed","failed","cancelled"],"description":"Статус цепочки или этапа"},"AgentStep":{"type":"object","required":["id","name","employeeId","status","instruction","operation","report","error"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"name":{"type":["string","null"],"description":"Имя сотрудника; `null`, если сотрудник удалён"},"employeeId":{"type":["string","null"],"format":"uuid","description":"ID сотрудника; `null`, если сотрудник удалён"},"status":{"$ref":"#/components/schemas/AgentRunStatus"},"instruction":{"type":"string","description":"Поручение этапа"},"operation":{"type":["string","null"],"description":"Имя SSH-операции или `null`"},"report":{"type":["string","null"],"description":"Отчёт после фильтра секретов; только у `completed`. `null`, если комментарий с отчётом удалён"},"error":{"type":["string","null"],"maxLength":300,"description":"Текст ошибки у `failed`"}}},"AgentWorkflow":{"type":"object","required":["id","status","createdAt","steps"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"status":{"$ref":"#/components/schemas/AgentRunStatus"},"createdAt":{"$ref":"#/components/schemas/Timestamp"},"steps":{"type":"array","description":"Этапы по порядку","items":{"$ref":"#/components/schemas/AgentStep"}}}},"AgentEmployeeOption":{"type":"object","description":"ИИ-сотрудник организации, которого можно выбрать для этапа","required":["id","name","specialty","enabled","provider","model"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"name":{"type":"string"},"specialty":{"$ref":"#/components/schemas/AiSpecialty"},"enabled":{"type":"boolean"},"provider":{"$ref":"#/components/schemas/AiProvider"},"model":{"type":"string"}}},"AgentSshResource":{"type":"object","description":"SSH-секрет организации без секретных полей (только для владельца)","required":["id","title","operations","grants","ready"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"title":{"type":"string"},"operations":{"type":"array","description":"Имена разрешённых операций (без команд); `[]` у секрета с `unreadable: true`","items":{"type":"string"}},"grants":{"type":"array","description":"ID ИИ-сотрудников с доступом","items":{"$ref":"#/components/schemas/Uuid"}},"ready":{"type":"boolean","description":"`host` есть в `AGENT_SSH_ALLOWED_HOSTS`; `false` у секрета с `unreadable: true`"},"unreadable":{"type":"boolean","const":true,"description":"Есть только у секрета, который не удаётся расшифровать (сменился `WORKSPACE_SECRET_KEY` или запись повреждена). Такой секрет нельзя использовать в этапе — удалите его и создайте заново"}}},"AgentWorkflowState":{"type":"object","required":["flows","employees","resources","owner"],"properties":{"flows":{"type":"array","description":"20 последних цепочек задачи, новые первыми","items":{"$ref":"#/components/schemas/AgentWorkflow"}},"employees":{"type":"array","description":"ИИ-сотрудники организации доски, которые сейчас состоят в ней, включая выключенных, по имени. Исключённые из участников не попадают. Для личной доски — пустой массив","items":{"$ref":"#/components/schemas/AgentEmployeeOption"}},"resources":{"type":"array","description":"SSH-секреты организации; заполняется только для владельца, остальным — пустой массив","items":{"$ref":"#/components/schemas/AgentSshResource"}},"owner":{"type":"boolean","description":"Является ли пользователь владельцем организации доски"}}},"AgentStepInput":{"type":"object","required":["employeeId"],"properties":{"employeeId":{"$ref":"#/components/schemas/Uuid","description":"Включённый ИИ-сотрудник организации доски"},"instruction":{"type":"string","maxLength":4000,"default":"","description":"Поручение этапа; пробелы по краям обрезаются"},"secretId":{"type":["string","null"],"format":"uuid","default":null,"description":"SSH-секрет для операции. Требует `operation`, роль владельца и `approveOperations`"},"operation":{"type":["string","null"],"maxLength":40,"default":null,"description":"Имя операции из `operations` секрета"}}},"AgentWorkflowInput":{"type":"object","required":["steps"],"properties":{"steps":{"type":"array","minItems":1,"maxItems":6,"description":"Этапы в порядке выполнения","items":{"$ref":"#/components/schemas/AgentStepInput"}},"approveOperations":{"type":"boolean","default":false,"description":"Подтверждение владельца, что SSH-операции будут выполнены, а их вывод уйдёт провайдеру модели. Обязательно, если в этапах есть `secretId` или `operation`"}}},"AgentWorkflowStarted":{"type":"object","required":["id","status"],"properties":{"id":{"$ref":"#/components/schemas/Uuid","description":"ID цепочки"},"status":{"type":"string","const":"queued"}}},"CompanySecretKind":{"type":"string","enum":["ssh","password","account"],"description":"Вид секрета"},"CompanySecretOperation":{"type":"object","required":["name","command"],"properties":{"name":{"type":"string","pattern":"^[a-zA-Z0-9_-]{1,40}$","description":"Имя операции, уникальное в секрете"},"command":{"type":"string","minLength":1,"maxLength":2000,"description":"Команда, которая выполняется на сервере через SSH `exec`; пробелы по краям обрезаются"}}},"CompanySecretInput":{"type":"object","description":"Дополнительные правила проверки (ошибка попадает в `errors.formErrors`):\n\n- для `kind: ssh` — `host` является IP-адресом, `username` не пуст, `fingerprint` соответствует `^SHA256:[A-Za-z0-9+/]{43}=?$`, `value` содержит `PRIVATE KEY`. Иначе — «Укажите IP, SSH-пользователя, приватный ключ и SHA256 fingerprint сервера»;\n- для остальных видов `value` не пуст после обрезки пробелов. Иначе — «Введите секрет»;\n- имена операций различаются. Иначе — «Названия операций должны различаться».\n\nЛишние поля отбрасываются.\n","required":["title","kind"],"properties":{"title":{"type":"string","minLength":1,"maxLength":100,"description":"Название; хранится в открытом виде"},"kind":{"$ref":"#/components/schemas/CompanySecretKind"},"value":{"type":"string","maxLength":20000,"default":"","writeOnly":true,"description":"Секрет — пароль, данные учётной записи или приватный SSH-ключ"},"host":{"type":"string","maxLength":100,"default":"","description":"IP-адрес сервера (для `ssh`)"},"port":{"type":"integer","minimum":1,"maximum":65535,"default":22},"username":{"type":"string","maxLength":80,"default":"","description":"SSH-пользователь"},"fingerprint":{"type":"string","maxLength":100,"default":"","description":"Fingerprint ключа сервера `SHA256:…` (для `ssh`)"},"operations":{"type":"array","maxItems":10,"default":[],"items":{"$ref":"#/components/schemas/CompanySecretOperation"}}}},"CompanySecret":{"type":"object","description":"Секрет организации без `value`. Если запись не удаётся расшифровать, приходит с `unreadable: true`: `host`, `username`, `fingerprint` — пустые строки, `operations` — `[]`, `port` — `null`.\n","required":["id","title","kind","version","host","port","username","fingerprint","operations","grants"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"title":{"type":"string"},"kind":{"$ref":"#/components/schemas/CompanySecretKind"},"version":{"type":"integer","minimum":1,"description":"Версия записи: 1 при создании, +1 при каждом изменении доступов. SSH-этап выполняется, только если версия не изменилась с момента запуска цепочки"},"host":{"type":"string","description":"Пустая строка у секретов без адреса"},"port":{"type":["integer","null"],"description":"Порт SSH; `null` у секрета с `unreadable: true`"},"username":{"type":"string"},"fingerprint":{"type":"string"},"operations":{"type":"array","items":{"$ref":"#/components/schemas/CompanySecretOperation"}},"grants":{"type":"array","description":"ID ИИ-сотрудников с доступом","items":{"$ref":"#/components/schemas/Uuid"}},"unreadable":{"type":"boolean","const":true,"description":"Есть только у секрета, который не удаётся расшифровать (сменился `WORKSPACE_SECRET_KEY` или запись повреждена). Его можно только удалить"}}},"CompanySecretCreated":{"type":"object","required":["id"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"}}},"CompanySecretGrantInput":{"type":"object","required":["employeeId","allow"],"properties":{"employeeId":{"$ref":"#/components/schemas/Uuid","description":"ИИ-сотрудник этой организации"},"allow":{"type":"boolean","description":"`true` — выдать доступ, `false` — отозвать"}}},"CompanySecretValue":{"type":"object","required":["value"],"properties":{"value":{"type":"string","description":"Расшифрованное значение секрета"}}},"CompanySecretAuditEvent":{"type":"object","required":["id","action","createdAt","actor","title"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"action":{"type":"string","description":"`created`, `revealed`, `grant:<employeeId>`, `revoke:<employeeId>`, `deleted:<secretId>` или `execute:<operation>`"},"createdAt":{"$ref":"#/components/schemas/Timestamp"},"actor":{"type":["string","null"],"description":"Имя автора действия (у `execute:` — ИИ-сотрудник); `null`, если пользователь удалён"},"title":{"type":["string","null"],"description":"Название секрета; `null` у `deleted:` и у удалённых секретов"}}},"db.achievement_catalog":{"title":"Таблица achievement_catalog","type":"object","description":"Справочник достижений. Строки засеяны миграцией `007_notes_planning_rewards.sql`: `first-step`, `momentum`, `builder`, `rhythm`, `steady`, `on-time`. API только читает каталог.\n\n**Первичный ключ:** `id`\n\n**На таблицу ссылаются:** [db.user_achievements](#модели/dbuser-achievements) (achievement_id)\n\n**Используется в коде:** `achievements.ts`, `motivation.ts`","required":["id","title","description","metric","target","coins","xp"],"properties":{"id":{"type":"string","description":"Текстовый код достижения, например `first-step`.\n\n`text` · NOT NULL"},"title":{"type":"string","description":"Название для интерфейса.\n\n`text` · NOT NULL"},"description":{"type":"string","description":"Условие получения человеческим языком.\n\n`text` · NOT NULL"},"metric":{"type":"string","description":"`completed` — число завершённых задач; `streak` — самая длинная серия дней с завершениями; `onTime` — завершения не позже срока.\n\n`text` · NOT NULL"},"target":{"type":"integer","format":"int32","description":"Порог метрики, при котором достижение открывается.\n\n`integer` · NOT NULL"},"coins":{"type":"integer","format":"int32","description":"Монеты, начисляемые при получении награды.\n\n`integer` · NOT NULL"},"xp":{"type":"integer","format":"int32","description":"Опыт, начисляемый при получении награды.\n\n`integer` · NOT NULL"}},"x-db-table":"achievement_catalog"},"db.agent_steps":{"title":"Таблица agent_steps","type":"object","description":"Шаг цепочки: ИИ-сотрудник, инструкция и, при подтверждении владельца, одна разрешённая SSH-операция по секрету. Отчёт шага публикуется комментарием задачи; шаг стартует только после завершения всех предыдущих. Изменения вызывают realtime по доске задачи.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `employee_id` → [db.ai_employees](#модели/dbai-employees) (user_id), при удалении: SET NULL\n- `secret_id` → [db.workspace_secrets](#модели/dbworkspace-secrets) (id), при удалении: SET NULL\n- `workflow_id` → [db.agent_workflows](#модели/dbagent-workflows) (id), при удалении: CASCADE\n\n**Уникальность:**\n- `UNIQUE (workflow_id, \"position\")`\n\n**Триггеры:**\n- `realtime_agents`: AFTER INSERT OR UPDATE ON agent_steps FOR EACH ROW EXECUTE FUNCTION metodox_realtime_event()\n\n**Используется в коде:** `agent-workflows.ts`, `realtime.ts`","required":["id","workflow_id","position","instruction","status"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID шага.\n\n`uuid` · NOT NULL"},"workflow_id":{"type":"string","format":"uuid","description":"Цепочка шага.\n\n`uuid` · NOT NULL"},"employee_id":{"type":["string","null"],"format":"uuid","description":"ИИ-сотрудник, выполняющий шаг.\n\n`uuid` · NULL"},"position":{"type":"integer","format":"int32","description":"Порядковый номер с 0 (в интерфейсе +1); уникален в цепочке.\n\n`integer` · NOT NULL"},"instruction":{"type":"string","description":"Инструкция шага, до 4000 символов.\n\n`text` · NOT NULL · по умолчанию `''::text`"},"secret_id":{"type":["string","null"],"format":"uuid","description":"SSH-секрет организации для операции; NULL — шаг без инфраструктуры.\n\n`uuid` · NULL"},"operation":{"type":["string","null"],"description":"Имя разрешённой операции секрета; сама команда берётся только из секрета.\n\n`text` · NULL"},"secret_version":{"type":["integer","null"],"format":"int32","description":"Версия секрета при постановке; если секрет изменён или доступ отозван, шаг падает.\n\n`integer` · NULL"},"status":{"type":"string","enum":["queued","running","completed","failed","cancelled"],"description":"`queued` — ждёт; `running` — выполняется; `completed`; `failed`; `cancelled` — отменён вместе с цепочкой.\n\n`text` · NOT NULL · по умолчанию `'queued'::text`"},"report":{"type":["string","null"],"description":"Отчёт модели с маскировкой секретов; также публикуется комментарием.\n\n`text` · NULL"},"error":{"type":["string","null"],"description":"Текст ошибки шага (в том числе при прерывании процесса).\n\n`text` · NULL"},"started_at":{"type":["string","null"],"format":"date-time","description":"Момент начала выполнения.\n\n`timestamp with time zone` · NULL"},"finished_at":{"type":["string","null"],"format":"date-time","description":"Момент завершения.\n\n`timestamp with time zone` · NULL"},"employee_fingerprint":{"type":["string","null"],"description":"SHA-256 конфигурации ИИ-сотрудника (провайдер, модель, инструкции, специализация, ключ) на момент запуска SSH-шага; при расхождении шаг не выполняется.\n\n`text` · NULL"}},"x-db-table":"agent_steps"},"db.agent_workflows":{"title":"Таблица agent_workflows","type":"object","description":"Цепочка ИИ-шагов по задаче (1–6 шагов). На задачу не больше одной активной цепочки (частичный уникальный индекс `workflow_one_active`), и она не запускается параллельно с одиночным запуском. Воркер `AgentWorker` каждые 3 секунды под advisory lock 71402911 берёт следующий шаг.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `requested_by` → [db.users](#модели/dbusers) (id), при удалении: SET NULL\n- `task_id` → [db.tasks](#модели/dbtasks) (id), при удалении: CASCADE\n- `workspace_id` → [db.workspaces](#модели/dbworkspaces) (id), при удалении: CASCADE\n\n**На таблицу ссылаются:** [db.agent_steps](#модели/dbagent-steps) (workflow_id)\n\n**Индексы:**\n- `workflow_one_active`: `UNIQUE btree (task_id) WHERE (status = ANY (ARRAY['queued'::text, 'running'::text]))`\n\n**Используется в коде:** `agent-workflows.ts`, `ai.ts`","required":["id","task_id","workspace_id","status","created_at"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID цепочки.\n\n`uuid` · NOT NULL"},"task_id":{"type":"string","format":"uuid","description":"Задача, по которой работает цепочка.\n\n`uuid` · NOT NULL"},"workspace_id":{"type":"string","format":"uuid","description":"Организация доски задачи.\n\n`uuid` · NOT NULL"},"requested_by":{"type":["string","null"],"format":"uuid","description":"Кто запустил цепочку.\n\n`uuid` · NULL"},"status":{"type":"string","enum":["queued","running","completed","failed","cancelled"],"description":"`queued` — ждёт; `running` — выполняется; `completed` — все шаги готовы; `failed` — шаг упал или процесс прерван; `cancelled` — отменена.\n\n`text` · NOT NULL · по умолчанию `'queued'::text`"},"created_at":{"type":"string","format":"date-time","description":"Момент постановки; задаёт порядок обработки.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"},"finished_at":{"type":["string","null"],"format":"date-time","description":"Момент завершения, отмены или сбоя.\n\n`timestamp with time zone` · NULL"}},"x-db-table":"agent_workflows"},"db.ai_employees":{"title":"Таблица ai_employees","type":"object","description":"Настройки ИИ-сотрудника; PK совпадает с его учёткой в `users` (`account_kind='ai'`) и членством в организации. Ключ провайдера хранится зашифрованным; смена провайдера без нового ключа стирает ключ. Лимиты ограничивают запросы и токены за сутки.\n\n**Первичный ключ:** `user_id`\n\n**Внешние ключи:**\n- `user_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n- `workspace_id` → [db.workspaces](#модели/dbworkspaces) (id), при удалении: CASCADE\n\n**На таблицу ссылаются:** [db.agent_steps](#модели/dbagent-steps) (employee_id), [db.ai_runs](#модели/dbai-runs) (employee_id), [db.ai_usage](#модели/dbai-usage) (user_id), [db.secret_grants](#модели/dbsecret-grants) (employee_id)\n\n**Используется в коде:** `admin.ts`, `agent-secrets.ts`, `agent-workflows.ts`, `ai.ts`, `profile.ts`, `workspaces.ts`","required":["user_id","workspace_id","provider","model","instructions","enabled","daily_requests","daily_tokens","max_output_tokens","created_at","specialty"],"properties":{"user_id":{"type":"string","format":"uuid","description":"Учётка ИИ-сотрудника в `users`.\n\n`uuid` · NOT NULL"},"workspace_id":{"type":"string","format":"uuid","description":"Организация сотрудника.\n\n`uuid` · NOT NULL"},"provider":{"type":"string","enum":["openai","anthropic","ollama"],"description":"`openai` — Responses API; `anthropic` — Messages API; `ollama` — сервер из переменной окружения `OLLAMA_URL` (по умолчанию `http://127.0.0.1:11434`; не из настроек организации), ключ не нужен.\n\n`text` · NOT NULL"},"model":{"type":"string","description":"Идентификатор модели провайдера, 1–100 символов.\n\n`text` · NOT NULL"},"instructions":{"type":"string","description":"Системные инструкции, до 8000 символов; пусто — встроенная инструкция.\n\n`text` · NOT NULL · по умолчанию `''::text`"},"api_key_cipher":{"type":["string","null"],"description":"Ключ провайдера: `v2.` + base64 конверта MDX1 (`AI_SECRET_KEY`, AAD `ai:<id>:<provider>`); старые значения без префикса.\n\n`text` · NULL"},"enabled":{"type":"boolean","description":"Выключенного сотрудника нельзя запустить или поставить в цепочку.\n\n`boolean` · NOT NULL · по умолчанию `true`"},"daily_requests":{"type":"integer","format":"int32","description":"Лимит запусков за сутки (UTC), 1–1000.\n\n`integer` · NOT NULL · по умолчанию `20`"},"daily_tokens":{"type":"integer","format":"int32","description":"Лимит токенов за сутки (UTC), 1000–2 000 000.\n\n`integer` · NOT NULL · по умолчанию `100000`"},"max_output_tokens":{"type":"integer","format":"int32","description":"Максимум токенов ответа модели, 128–4096.\n\n`integer` · NOT NULL · по умолчанию `1024`"},"created_at":{"type":"string","format":"date-time","description":"Момент создания.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"},"specialty":{"type":"string","enum":["assistant","developer","tester","analyst"],"description":"Роль в цепочках: `assistant`, `developer`, `tester`, `analyst`; подставляется в инструкции шага.\n\n`text` · NOT NULL · по умолчанию `'assistant'::text`"}},"x-db-table":"ai_employees"},"db.ai_runs":{"title":"Таблица ai_runs","type":"object","description":"Запуски модели по задаче: одиночный запуск и шаги агентных цепочек. На задачу одновременно не больше одного `running` (частичный уникальный индекс `ai_one_active_task`); запуски, висящие дольше 5 минут, помечаются `failed` при следующем одиночном запуске.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `employee_id` → [db.ai_employees](#модели/dbai-employees) (user_id), при удалении: SET NULL\n- `requested_by` → [db.users](#модели/dbusers) (id), при удалении: SET NULL\n- `task_id` → [db.tasks](#модели/dbtasks) (id), при удалении: CASCADE\n\n**Индексы:**\n- `ai_one_active_task`: `UNIQUE btree (task_id) WHERE (status = 'running'::text)`\n\n**Используется в коде:** `admin.ts`, `agent-workflows.ts`, `ai.ts`","required":["id","status","usage_tokens","created_at"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID запуска.\n\n`uuid` · NOT NULL"},"employee_id":{"type":["string","null"],"format":"uuid","description":"ИИ-сотрудник; NULL после его удаления.\n\n`uuid` · NULL"},"task_id":{"type":["string","null"],"format":"uuid","description":"Задача запуска.\n\n`uuid` · NULL"},"requested_by":{"type":["string","null"],"format":"uuid","description":"Кто запустил (для шагов цепочки — автор цепочки).\n\n`uuid` · NULL"},"status":{"type":"string","description":"`running` — выполняется; `completed` — ответ опубликован; `failed` — ошибка или таймаут. CHECK нет.\n\n`text` · NOT NULL"},"usage_tokens":{"type":"integer","format":"int32","description":"Токены: сначала резерв, после ответа — фактический расход.\n\n`integer` · NOT NULL · по умолчанию `0`"},"created_at":{"type":"string","format":"date-time","description":"Момент запуска.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"},"finished_at":{"type":["string","null"],"format":"date-time","description":"Момент завершения.\n\n`timestamp with time zone` · NULL"}},"x-db-table":"ai_runs"},"db.ai_usage":{"title":"Таблица ai_usage","type":"object","description":"Суточный учёт расхода ИИ-сотрудника (день по UTC). Перед запуском токены резервируются оценкой, после ответа корректируются фактическим расходом; при неуспешном запуске резерв сохраняется, чтобы не превысить бюджет.\n\n**Первичный ключ:** `user_id`, `day`\n\n**Внешние ключи:**\n- `user_id` → [db.ai_employees](#модели/dbai-employees) (user_id), при удалении: CASCADE\n\n**Используется в коде:** `agent-workflows.ts`, `ai.ts`","required":["user_id","day","requests","tokens"],"properties":{"user_id":{"type":"string","format":"uuid","description":"ИИ-сотрудник.\n\n`uuid` · NOT NULL"},"day":{"type":"string","format":"date","description":"Сутки по UTC.\n\n`date` · NOT NULL"},"requests":{"type":"integer","format":"int32","description":"Число запусков за сутки.\n\n`integer` · NOT NULL · по умолчанию `0`"},"tokens":{"type":"integer","format":"int32","description":"Зарезервированные и фактически израсходованные токены за сутки.\n\n`integer` · NOT NULL · по умолчанию `0`"}},"x-db-table":"ai_usage"},"db.attachments":{"title":"Таблица attachments","type":"object","description":"Файлы задачи. Хранение либо в БД (`storage='database'`, байты в `content`), либо в S3 (`storage='s3'`, `object_key`) — CHECK требует ровно один вариант. Лимиты: 100 МиБ на файл (CHECK) и 1 ГиБ на задачу (API). Удаление строки ставит S3-объект в очередь `storage_cleanup`; старые файлы из БД переносит в S3 `storage-migrate.ts`.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `author_id` → [db.users](#модели/dbusers) (id), при удалении: SET NULL\n- `comment_id` → [db.comments](#модели/dbcomments) (id), при удалении: SET NULL\n- `task_id` → [db.tasks](#модели/dbtasks) (id), при удалении: CASCADE\n\n**Проверки:**\n- `CHECK (storage = 'database'::text AND content IS NOT NULL AND object_key IS NULL OR storage = 's3'::text AND content IS NULL AND object_key IS NOT NULL)`\n- `CHECK (size > 0 AND size <= 104857600)`\n\n**Индексы:**\n- `attachments_task_idx`: `btree (task_id)`\n\n**Триггеры:**\n- `attachment_cleanup`: AFTER DELETE ON attachments FOR EACH ROW EXECUTE FUNCTION queue_attachment_cleanup()\n\n**Используется в коде:** `admin.ts`, `boards.ts`, `message-actions.ts`, `storage-migrate.ts`, `storage.ts`, `tasks.ts`","required":["id","task_id","name","mime","size","created_at","storage"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID файла; входит в ключ S3-объекта и в AAD шифрования (`attachment:<id>`).\n\n`uuid` · NOT NULL"},"task_id":{"type":"string","format":"uuid","description":"Задача файла.\n\n`uuid` · NOT NULL"},"author_id":{"type":["string","null"],"format":"uuid","description":"Кто загрузил; без прав редактора удалить файл может только он.\n\n`uuid` · NULL"},"name":{"type":"string","description":"Имя файла без управляющих символов и слешей, до 200 символов.\n\n`text` · NOT NULL"},"mime":{"type":"string","description":"MIME-тип, определённый сервером по содержимому и расширению (`detectAttachment`).\n\n`text` · NOT NULL"},"size":{"type":"integer","format":"int32","description":"Размер в байтах, 1–104 857 600 (100 МиБ).\n\n`integer` · NOT NULL"},"content":{"type":["string","null"],"format":"binary","description":"Байты файла при `storage='database'` (без шифрования приложением); NULL при `s3`.\n\n`bytea` · NULL"},"created_at":{"type":"string","format":"date-time","description":"Момент загрузки.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"},"storage":{"type":"string","enum":["database","s3"],"description":"`database` — байты в `content`; `s3` — объект в бакете, зашифрованный `STORAGE_ENCRYPTION_KEY`.\n\n`text` · NOT NULL · по умолчанию `'database'::text`"},"object_key":{"type":["string","null"],"description":"Ключ объекта `<S3_PREFIX>/attachments/<id>` при `storage='s3'`; иначе NULL.\n\n`text` · NULL"},"comment_id":{"type":["string","null"],"format":"uuid","description":"Комментарий, к которому прикреплён файл; NULL — файл задачи вне комментариев.\n\n`uuid` · NULL"}},"x-db-table":"attachments"},"db.auth_tokens":{"title":"Таблица auth_tokens","type":"object","description":"Одноразовые токены ссылок из писем. При выдаче нового токена прежние токены того же вида у пользователя удаляются, а неотправленные письма того же вида отменяются; использование — атомарный `DELETE … RETURNING`. Успешный сброс пароля удаляет все токены пользователя (обоих видов), смена пароля в профиле — токены `reset`.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `user_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n\n**Уникальность:**\n- `UNIQUE (token_hash)`\n\n**Индексы:**\n- `auth_tokens_user_idx`: `btree (user_id, kind)`\n\n**Используется в коде:** `cleanup.ts`, `mail.ts`, `profile.ts`","required":["id","user_id","kind","token_hash","expires_at"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID записи.\n\n`uuid` · NOT NULL"},"user_id":{"type":"string","format":"uuid","description":"Пользователь, которому выдана ссылка (только `human`).\n\n`uuid` · NOT NULL"},"kind":{"type":"string","enum":["verify","reset"],"description":"`verify` — подтверждение email, живёт 24 часа; `reset` — сброс пароля, живёт 30 минут, отзывает все сессии и подтверждает email.\n\n`text` · NOT NULL"},"token_hash":{"type":"string","description":"SHA-256 секрета из fragment ссылки `#token=…`; сам секрет не хранится. Уникален.\n\n`text` · NOT NULL"},"expires_at":{"type":"string","format":"date-time","description":"Момент истечения ссылки.\n\n`timestamp with time zone` · NOT NULL"}},"x-db-table":"auth_tokens"},"db.board_members":{"title":"Таблица board_members","type":"object","description":"Явные роли на досках организации. Создатель доски организации получает `admin`. Владельцы и администраторы организации являются администраторами всех её досок независимо от этой таблицы.\n\n**Первичный ключ:** `board_id`, `user_id`\n\n**Внешние ключи:**\n- `board_id` → [db.boards](#модели/dbboards) (id), при удалении: CASCADE\n- `user_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n\n**Используется в коде:** `access.ts`, `boards.ts`, `discussion.ts`, `mail.ts`, `notifications.ts`, `planner.ts`, `team.ts`, `workspaces.ts`","required":["board_id","user_id","role"],"properties":{"board_id":{"type":"string","format":"uuid","description":"Доска.\n\n`uuid` · NOT NULL"},"user_id":{"type":"string","format":"uuid","description":"Участник организации, которому выдана роль.\n\n`uuid` · NOT NULL"},"role":{"type":"string","enum":["admin","editor","viewer"],"description":"`admin` — управление доской и всеми задачами; `editor` — создание и правка задач; `viewer` — только просмотр.\n\n`text` · NOT NULL"}},"x-db-table":"board_members"},"db.boards":{"title":"Таблица boards","type":"object","description":"Канбан-доска. Личная доска (`workspace_id IS NULL`) всегда `private` и доступна только владельцу. Доска организации может принадлежать проекту и его этапу: CHECK и составные FK гарантируют, что этап входит в проект, а проект — в ту же организацию. Удаление каскадно удаляет колонки, задачи и их данные.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `phase_id`, `project_id` → [db.project_phases](#модели/dbproject-phases) (id, project_id), при удалении: NO ACTION\n- `project_id`, `workspace_id` → [db.workspace_projects](#модели/dbworkspace-projects) (id, workspace_id), при удалении: NO ACTION\n- `owner_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n- `workspace_id` → [db.workspaces](#модели/dbworkspaces) (id), при удалении: CASCADE\n\n**На таблицу ссылаются:** [db.board_members](#модели/dbboard-members) (board_id), [db.columns](#модели/dbcolumns) (board_id), [db.task_counters](#модели/dbtask-counters) (board_id), [db.task_links](#модели/dbtask-links) (board_id), [db.tasks](#модели/dbtasks) (board_id)\n\n**Проверки:**\n- `CHECK (phase_id IS NULL OR project_id IS NOT NULL)`\n- `CHECK (project_id IS NULL OR workspace_id IS NOT NULL)`\n- `CHECK (color ~ '^#[0-9a-f]{6}$'::text)`\n\n**Индексы:**\n- `boards_owner_idx`: `btree (owner_id)`\n- `boards_workspace_idx`: `btree (workspace_id)`\n\n**Триггеры:**\n- `realtime_boards`: AFTER INSERT OR DELETE OR UPDATE ON boards FOR EACH ROW EXECUTE FUNCTION metodox_realtime_event()\n\n**Используется в коде:** `access.ts`, `admin.ts`, `agent-workflows.ts`, `boards.ts`, `discussion.ts`, `mail.ts`, `main.ts`, `message-actions.ts`, `motivation.ts`, `notifications.ts`, `planner.ts`, `profile.ts`, `realtime.ts`, `seed-demo.ts`, `starter.ts`, `task-graph.ts`, `tasks.ts`, `team-activity.ts`, `team-audit.ts`, `team.ts`, `workspaces.ts`","required":["id","owner_id","title","description","created_at","visibility"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID доски.\n\n`uuid` · NOT NULL"},"owner_id":{"type":"string","format":"uuid","description":"Создатель; для личной доски — её единственный администратор.\n\n`uuid` · NOT NULL"},"title":{"type":"string","description":"Название, 1–100 символов.\n\n`text` · NOT NULL"},"description":{"type":"string","description":"Описание, до 500 символов.\n\n`text` · NOT NULL · по умолчанию `''::text`"},"created_at":{"type":"string","format":"date-time","description":"Момент создания.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"},"workspace_id":{"type":["string","null"],"format":"uuid","description":"Организация доски; NULL — личная доска.\n\n`uuid` · NULL"},"visibility":{"type":"string","enum":["private","workspace"],"description":"`private` — только админы организации и явные `board_members`; `workspace` — участники `member` видят доску как viewer.\n\n`text` · NOT NULL · по умолчанию `'private'::text`"},"project_id":{"type":["string","null"],"format":"uuid","description":"Проект организации (`workspace_projects`); NULL — доска вне проектов.\n\n`uuid` · NULL"},"phase_id":{"type":["string","null"],"format":"uuid","description":"Этап проекта (`project_phases`); допустим только при заданном `project_id`.\n\n`uuid` · NULL"},"color":{"type":["string","null"],"description":"Собственный акцентный цвет `#rrggbb` (нижний регистр); NULL — наследовать цвет этапа, затем проекта. Пока доска открыта, веб-клиент окрашивает интерфейс в итоговый цвет.\n\n`text` · NULL"}},"x-db-table":"boards"},"db.coin_ledger":{"title":"Таблица coin_ledger","type":"object","description":"Журнал движения монет. Уникальный `source_key` делает начисления идемпотентными (повторное завершение задачи монет не даёт); `users.coin_balance` меняется в той же транзакции.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `user_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n\n**Уникальность:**\n- `UNIQUE (source_key)`\n\n**Используется в коде:** `motivation.ts`","required":["id","user_id","amount","reason","source_key","created_at"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID операции.\n\n`uuid` · NOT NULL"},"user_id":{"type":"string","format":"uuid","description":"Чей баланс изменился.\n\n`uuid` · NOT NULL"},"amount":{"type":"integer","format":"int32","description":"Изменение баланса: +10 за задачу, +`coins` за достижение, минус цена при покупке.\n\n`integer` · NOT NULL"},"reason":{"type":"string","description":"Причина: `task_completed`, `achievement` или `purchase`.\n\n`text` · NOT NULL"},"source_key":{"type":"string","description":"Ключ идемпотентности: `task:<taskId>`, `achievement:<userId>:<id>`, `purchase:<userId>:<itemId>`.\n\n`text` · NOT NULL"},"created_at":{"type":"string","format":"date-time","description":"Момент операции.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"}},"x-db-table":"coin_ledger"},"db.columns":{"title":"Таблица columns","type":"object","description":"Колонки доски. При создании доски организации API добавляет четыре: «В планах», «В работе», «На проверке», «Готово»; личной доски — три: «Нужно сделать», «В работе», «Готово». Колонка с наибольшим `position` — завершающая: перенос туда завершает задачу. Новая колонка вставляется перед последней. Колонку можно удалить (кроме последней и единственной; задачи из неё переносятся в выбранную колонку) и переставить (`PUT /boards/{id}/columns/order`: новая последняя колонка становится завершающей, записи о завершении не переписываются). После удаления позиции снова идут `0…n-1`. `UNIQUE(id, board_id)` нужен для составного FK из `tasks`.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `board_id` → [db.boards](#модели/dbboards) (id), при удалении: CASCADE\n\n**На таблицу ссылаются:** [db.tasks](#модели/dbtasks) (column_id, board_id)\n\n**Уникальность:**\n- `UNIQUE (id, board_id)`\n\n**Индексы:**\n- `columns_board_idx`: `btree (board_id)`\n\n**Используется в коде:** `boards.ts`, `discussion.ts`, `mail.ts`, `main.ts`, `motivation.ts`, `notifications.ts`, `planner.ts`, `seed-demo.ts`, `starter.ts`, `storage-migrate.ts`, `task-graph.ts`, `team.ts`, `wiki.ts`","required":["id","board_id","title","position"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID колонки.\n\n`uuid` · NOT NULL"},"board_id":{"type":"string","format":"uuid","description":"Доска колонки.\n\n`uuid` · NOT NULL"},"title":{"type":"string","description":"Название, 1–60 символов.\n\n`text` · NOT NULL"},"position":{"type":"integer","format":"int32","description":"Порядок слева направо, начиная с 0; максимальное значение — колонка завершения. Меняется при перестановке и удалении колонок.\n\n`integer` · NOT NULL"}},"x-db-table":"columns"},"db.comment_mentions":{"title":"Таблица comment_mentions","type":"object","description":"Пользователи, упомянутые в комментарии. Учитываются только явные ссылки на профиль вне блоков кода, не более 20; каждый упомянутый должен иметь доступ к задаче. Основа уведомлений `mention`.\n\n**Первичный ключ:** `comment_id`, `user_id`\n\n**Внешние ключи:**\n- `comment_id` → [db.comments](#модели/dbcomments) (id), при удалении: CASCADE\n- `user_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n\n**Используется в коде:** `message-actions.ts`, `notifications.ts`, `tasks.ts`","required":["comment_id","user_id"],"properties":{"comment_id":{"type":"string","format":"uuid","description":"Комментарий с упоминанием.\n\n`uuid` · NOT NULL"},"user_id":{"type":"string","format":"uuid","description":"Упомянутый пользователь.\n\n`uuid` · NOT NULL"}},"x-db-table":"comment_mentions"},"db.comments":{"title":"Таблица comments","type":"object","description":"Комментарии к задаче, включая ответы ИИ-сотрудников и пересланные сообщения. Триггер `comment_thread` перед вставкой вычисляет `root_id` и отклоняет ответ на комментарий другой задачи. Первые уровни веток выбираются индексом `comments_task_roots`, ответы — `comments_thread`, выборки журнала по периоду — `comments_created` (миграция 030). Автор может изменить свой непересланный комментарий в течение 24 часов (`edited_at`). Удаление (автор с правом комментирования или администратор доски) не стирает строку: текст и `forwarded` очищаются, ставится `deleted_at`, вложения, реакции и упоминания удаляются — «надгробие» сохраняет ветку ответов. В историю задачи пишутся `comment_edited` и `comment_deleted`.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `author_id` → [db.users](#модели/dbusers) (id), при удалении: SET NULL\n- `reply_to` → [db.comments](#модели/dbcomments) (id), при удалении: SET NULL\n- `root_id` → [db.comments](#модели/dbcomments) (id), при удалении: SET NULL\n- `task_id` → [db.tasks](#модели/dbtasks) (id), при удалении: CASCADE\n\n**На таблицу ссылаются:** [db.attachments](#модели/dbattachments) (comment_id), [db.comment_mentions](#модели/dbcomment-mentions) (comment_id), [db.comments](#модели/dbcomments) (reply_to), [db.comments](#модели/dbcomments) (root_id), [db.message_reactions](#модели/dbmessage-reactions) (comment_id), [db.notifications](#модели/dbnotifications) (comment_id)\n\n**Индексы:**\n- `comments_created`: `btree (created_at)`\n- `comments_task_idx`: `btree (task_id, created_at)`\n- `comments_task_roots`: `btree (task_id, created_at, id) WHERE (root_id IS NULL)`\n- `comments_thread`: `btree (root_id, created_at, id)`\n\n**Триггеры:**\n- `comment_thread`: BEFORE INSERT ON comments FOR EACH ROW EXECUTE FUNCTION assign_comment_thread()\n\n**Используется в коде:** `admin.ts`, `agent-workflows.ts`, `ai.ts`, `boards.ts`, `discussion.ts`, `mail.ts`, `main.ts`, `message-actions.ts`, `notifications.ts`, `tasks.ts`","required":["id","task_id","body","created_at"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID комментария.\n\n`uuid` · NOT NULL"},"task_id":{"type":"string","format":"uuid","description":"Задача обсуждения.\n\n`uuid` · NOT NULL"},"author_id":{"type":["string","null"],"format":"uuid","description":"Автор (человек или ИИ-сотрудник); NULL после удаления пользователя.\n\n`uuid` · NULL"},"body":{"type":"string","description":"Текст до 20 000 символов, может быть пустым при вложениях; у удалённого комментария — пустая строка. Упоминание — ссылка `[@Имя](/people/<uuid>)`.\n\n`text` · NOT NULL"},"created_at":{"type":"string","format":"date-time","description":"Момент публикации.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"},"reply_to":{"type":["string","null"],"format":"uuid","description":"Комментарий той же задачи, на который дан ответ.\n\n`uuid` · NULL"},"forwarded":{"description":"Для пересланных — JSON `{author, createdAt}` исходного сообщения (сохраняется по цепочке пересылок); NULL — обычный комментарий.\n\n`jsonb` · NULL"},"root_id":{"type":["string","null"],"format":"uuid","description":"Корень ветки, вычисляет триггер `assign_comment_thread`; NULL у корневых комментариев.\n\n`uuid` · NULL"},"edited_at":{"type":["string","null"],"format":"date-time","description":"Момент последнего изменения текста автором (в течение 24 часов после публикации); NULL — не изменялся. Сохранение того же текста его не меняет.\n\n`timestamp with time zone` · NULL"},"deleted_at":{"type":["string","null"],"format":"date-time","description":"Момент удаления комментария; строка остаётся «надгробием» с пустым `body`, чтобы ответы сохранили корень и место в ветке. NULL — комментарий не удалён. Уведомления об удалённом комментарии скрываются при чтении.\n\n`timestamp with time zone` · NULL"}},"x-db-table":"comments"},"db.completion_records":{"title":"Таблица completion_records","type":"object","description":"Журнал завершений для серий, достижений, тепловой карты и планировщика. Одна запись на задачу (PK `task_id` без FK — запись переживает удаление задачи); пишется при первом завершении, только для человека-получателя. Миграция 008 заполнила историю из `coin_ledger` и `tasks`.\n\n**Первичный ключ:** `task_id`\n\n**Внешние ключи:**\n- `user_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n\n**Индексы:**\n- `completion_records_user_day`: `btree (user_id, local_day)`\n\n**Используется в коде:** `achievements.ts`, `admin.ts`, `boards.ts`, `motivation.ts`, `planner.ts`","required":["task_id","user_id","completed_at","local_day","on_time"],"properties":{"task_id":{"type":"string","format":"uuid","description":"Завершённая задача (без FK на `tasks`).\n\n`uuid` · NOT NULL"},"user_id":{"type":"string","format":"uuid","description":"Кому засчитано: исполнитель, а если его нет — завершивший; только `human`.\n\n`uuid` · NOT NULL"},"completed_at":{"type":"string","format":"date-time","description":"Момент записи завершения.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"},"local_day":{"type":"string","format":"date","description":"День завершения в часовом поясе пользователя.\n\n`date` · NOT NULL"},"on_time":{"type":"boolean","description":"Завершена не позднее `due_date` (по локальному дню пользователя); без срока — `false`.\n\n`boolean` · NOT NULL · по умолчанию `false`"}},"x-db-table":"completion_records"},"db.cosmetic_catalog":{"title":"Таблица cosmetic_catalog","type":"object","description":"Справочник оформления профиля, покупаемого за монеты. Строки засеяны миграциями `005_workspace_data.sql` (3 предмета) и `007_notes_planning_rewards.sql` (колонка `slot` и ещё 7 предметов). API только читает каталог.\n\n**Первичный ключ:** `id`\n\n**На таблицу ссылаются:** [db.equipped_cosmetics](#модели/dbequipped-cosmetics) (item_id), [db.user_cosmetics](#модели/dbuser-cosmetics) (item_id)\n\n**Проверки:**\n- `CHECK (price > 0)`\n\n**Используется в коде:** `motivation.ts`","required":["id","title","description","price","slot"],"properties":{"id":{"type":"string","description":"Текстовый код предмета, например `lime-frame`.\n\n`text` · NOT NULL"},"title":{"type":"string","description":"Название предмета.\n\n`text` · NOT NULL"},"description":{"type":"string","description":"Описание предмета.\n\n`text` · NOT NULL"},"price":{"type":"integer","format":"int32","description":"Цена в монетах (> 0).\n\n`integer` · NOT NULL"},"slot":{"type":"string","enum":["frame","cover","badge"],"description":"`frame` — рамка аватара; `cover` — обложка публичного профиля; `badge` — подпись в профиле.\n\n`text` · NOT NULL · по умолчанию `'badge'::text`"}},"x-db-table":"cosmetic_catalog"},"db.direct_files":{"title":"Таблица direct_files","type":"object","description":"Вложения личного чата. Файл загружается черновиком (`message_id IS NULL`, виден и удаляем только автором) и привязывается к сообщению при отправке. Хранение `database`/`s3` — как у `attachments`; лимиты 100 МиБ на файл и 1 ГиБ на отправленные файлы чата. Неотправленные черновики живут 24 часа, в лимит чата не входят и ограничены отдельно — 1 ГиБ на автора по всем чатам; просроченные удаляет `sweepUnsentFiles` (`direct-media.ts`) раз в час и перед каждой загрузкой, до 200 строк за проход. Частичный индекс `direct_files_unsent` (миграция 029) обслуживает этот лимит и очистку.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `author_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n- `friendship_id` → [db.friendships](#модели/dbfriendships) (id), при удалении: CASCADE\n- `message_id` → [db.direct_messages](#модели/dbdirect-messages) (id), при удалении: CASCADE\n\n**Проверки:**\n- `CHECK (storage = 'database'::text AND content IS NOT NULL AND object_key IS NULL OR storage = 's3'::text AND content IS NULL AND object_key IS NOT NULL)`\n- `CHECK (size > 0 AND size <= 104857600)`\n\n**Индексы:**\n- `direct_files_thread`: `btree (friendship_id, message_id)`\n- `direct_files_unsent`: `btree (author_id, created_at) WHERE (message_id IS NULL)`\n\n**Триггеры:**\n- `direct_files_cleanup`: AFTER DELETE ON direct_files FOR EACH ROW EXECUTE FUNCTION queue_attachment_cleanup()\n\n**Используется в коде:** `admin.ts`, `direct-media.ts`, `message-actions.ts`, `social.ts`, `storage-migrate.ts`","required":["id","friendship_id","author_id","name","mime","size","storage","created_at"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID файла; входит в ключ S3-объекта и AAD шифрования.\n\n`uuid` · NOT NULL"},"friendship_id":{"type":"string","format":"uuid","description":"Чат, в который загружен файл.\n\n`uuid` · NOT NULL"},"author_id":{"type":"string","format":"uuid","description":"Кто загрузил файл.\n\n`uuid` · NOT NULL"},"message_id":{"type":["string","null"],"format":"uuid","description":"Сообщение с вложением; NULL — загружен, но ещё не отправлен (черновик живёт 24 часа).\n\n`uuid` · NULL"},"name":{"type":"string","description":"Имя файла без управляющих символов, до 200 символов.\n\n`text` · NOT NULL"},"mime":{"type":"string","description":"MIME-тип, определённый сервером.\n\n`text` · NOT NULL"},"size":{"type":"integer","format":"int32","description":"Размер в байтах, 1–104 857 600.\n\n`integer` · NOT NULL"},"storage":{"type":"string","enum":["database","s3"],"description":"`database` — байты в `content`; `s3` — зашифрованный объект в бакете.\n\n`text` · NOT NULL"},"content":{"type":["string","null"],"format":"binary","description":"Байты при `storage='database'`; NULL при `s3`.\n\n`bytea` · NULL"},"object_key":{"type":["string","null"],"description":"Ключ `<S3_PREFIX>/attachments/<id>` при `storage='s3'`.\n\n`text` · NULL"},"created_at":{"type":"string","format":"date-time","description":"Момент загрузки.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"}},"x-db-table":"direct_files"},"db.direct_messages":{"title":"Таблица direct_messages","type":"object","description":"Сообщения личного чата между друзьями (только при `status='accepted'`). Текст может быть пустым, если к сообщению приложены файлы. Вставка вызывает realtime для обоих участников.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `friendship_id` → [db.friendships](#модели/dbfriendships) (id), при удалении: CASCADE\n- `reply_to` → [db.direct_messages](#модели/dbdirect-messages) (id), при удалении: SET NULL\n- `sender_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n\n**На таблицу ссылаются:** [db.direct_files](#модели/dbdirect-files) (message_id), [db.direct_messages](#модели/dbdirect-messages) (reply_to), [db.message_reactions](#модели/dbmessage-reactions) (message_id)\n\n**Проверки:**\n- `CHECK (length(body) <= 4000)`\n\n**Индексы:**\n- `direct_messages_created`: `btree (created_at)`\n- `direct_messages_thread`: `btree (friendship_id, created_at DESC)`\n\n**Триггеры:**\n- `realtime_direct_messages`: AFTER INSERT ON direct_messages FOR EACH ROW EXECUTE FUNCTION metodox_realtime_event()\n\n**Используется в коде:** `admin.ts`, `message-actions.ts`, `realtime.ts`, `social.ts`","required":["id","friendship_id","sender_id","body","created_at"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID сообщения.\n\n`uuid` · NOT NULL"},"friendship_id":{"type":"string","format":"uuid","description":"Чат (дружба).\n\n`uuid` · NOT NULL"},"sender_id":{"type":"string","format":"uuid","description":"Отправитель.\n\n`uuid` · NOT NULL"},"body":{"type":"string","description":"Текст до 4000 символов (CHECK).\n\n`text` · NOT NULL"},"created_at":{"type":"string","format":"date-time","description":"Момент отправки; основа курсорной пагинации.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"},"reply_to":{"type":["string","null"],"format":"uuid","description":"Сообщение того же чата, на которое дан ответ.\n\n`uuid` · NULL"},"forwarded":{"description":"Для пересланных — JSON `{author, createdAt}` исходного сообщения; NULL — обычное сообщение.\n\n`jsonb` · NULL"}},"x-db-table":"direct_messages"},"db.equipped_cosmetics":{"title":"Таблица equipped_cosmetics","type":"object","description":"Надетое оформление: не более одного предмета на слот. Надеть можно только купленный предмет. Миграция 007 перенесла сюда прежнее `users.active_cosmetic`.\n\n**Первичный ключ:** `user_id`, `slot`\n\n**Внешние ключи:**\n- `item_id` → [db.cosmetic_catalog](#модели/dbcosmetic-catalog) (id), при удалении: NO ACTION\n- `user_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n\n**Используется в коде:** `motivation.ts`","required":["user_id","slot","item_id"],"properties":{"user_id":{"type":"string","format":"uuid","description":"Пользователь.\n\n`uuid` · NOT NULL"},"slot":{"type":"string","description":"Слот — копия `cosmetic_catalog.slot`: `frame`, `cover` или `badge` (CHECK на этой колонке нет).\n\n`text` · NOT NULL"},"item_id":{"type":"string","description":"Надетый предмет каталога.\n\n`text` · NOT NULL"}},"x-db-table":"equipped_cosmetics"},"db.friendships":{"title":"Таблица friendships","type":"object","description":"Связь двух людей: заявка (`pending`) от `requester_id` к `recipient_id`, принять может только получатель. Уникальный индекс по неупорядоченной паре исключает встречные дубли. Удаление строки (отказ или разрыв дружбы) каскадно удаляет переписку и её файлы.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `recipient_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n- `requester_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n\n**На таблицу ссылаются:** [db.direct_files](#модели/dbdirect-files) (friendship_id), [db.direct_messages](#модели/dbdirect-messages) (friendship_id)\n\n**Проверки:**\n- `CHECK (requester_id <> recipient_id)`\n\n**Индексы:**\n- `friendship_pair`: `UNIQUE btree (LEAST(requester_id, recipient_id), GREATEST(requester_id, recipient_id))`\n\n**Триггеры:**\n- `realtime_friendships`: AFTER INSERT OR DELETE OR UPDATE ON friendships FOR EACH ROW EXECUTE FUNCTION metodox_realtime_event()\n\n**Используется в коде:** `direct-media.ts`, `message-actions.ts`, `realtime.ts`, `social.ts`","required":["id","requester_id","recipient_id","status","created_at"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID; одновременно идентификатор личного чата.\n\n`uuid` · NOT NULL"},"requester_id":{"type":"string","format":"uuid","description":"Кто отправил заявку; приглашать можно только публичные профили людей.\n\n`uuid` · NOT NULL"},"recipient_id":{"type":"string","format":"uuid","description":"Кому адресована заявка; только он может её принять.\n\n`uuid` · NOT NULL"},"status":{"type":"string","enum":["pending","accepted"],"description":"`pending` — заявка ожидает ответа; `accepted` — друзья: доступны личный чат и настроение дня.\n\n`text` · NOT NULL · по умолчанию `'pending'::text`"},"created_at":{"type":"string","format":"date-time","description":"Момент отправки заявки.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"}},"x-db-table":"friendships"},"db.invitations":{"title":"Таблица invitations","type":"object","description":"Ожидающее приглашение по email. Повторное приглашение того же адреса обновляет роль и продлевает срок; принятие удаляет строку и создаёт членство, отказ или отмена — просто удаляют её. При `MAIL_ENABLED=true` в той же транзакции ставится письмо в `mail_outbox`.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `workspace_id` → [db.workspaces](#модели/dbworkspaces) (id), при удалении: CASCADE\n\n**Уникальность:**\n- `UNIQUE (workspace_id, email)`\n\n**Используется в коде:** `admin.ts`, `cleanup.ts`, `main.ts`, `profile.ts`, `workspaces.ts`","required":["id","workspace_id","email","role","expires_at"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID приглашения.\n\n`uuid` · NOT NULL"},"workspace_id":{"type":"string","format":"uuid","description":"Организация, в которую приглашают.\n\n`uuid` · NOT NULL"},"email":{"type":"string","description":"Email приглашённого в нижнем регистре; принять может только пользователь с этим email.\n\n`text` · NOT NULL"},"role":{"type":"string","enum":["admin","member","guest"],"description":"Роль после принятия: `admin` (приглашает только владелец), `member` или `guest`.\n\n`text` · NOT NULL"},"expires_at":{"type":"string","format":"date-time","description":"Срок действия: момент отправки + 7 дней.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `(now() + '7 days'::interval)`"}},"x-db-table":"invitations"},"db.mail_outbox":{"title":"Таблица mail_outbox","type":"object","description":"Транзакционная очередь писем (outbox): письмо ставится в той же транзакции, что и событие. Воркер `MailService` каждые 15 секунд берёт до 10 писем через `FOR UPDATE SKIP LOCKED`; не больше 5 попыток с паузой 5 минут, доставка at-least-once. Содержимое зашифровано и стирается после отправки.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `task_id` → [db.tasks](#модели/dbtasks) (id), при удалении: SET NULL\n- `user_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n\n**Индексы:**\n- `mail_outbox_dedup`: `UNIQUE btree (user_id, dedup_key) WHERE (dedup_key IS NOT NULL)`\n- `mail_outbox_task_recent`: `btree (user_id, created_at DESC) WHERE starts_with(kind, 'task_'::text)`\n\n**Используется в коде:** `admin.ts`, `mail.ts`, `profile.ts`","required":["id","kind","payload","status","attempts","next_attempt","created_at"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID письма; входит в AAD шифрования и в Message-ID `<id@metodox.local>`.\n\n`uuid` · NOT NULL"},"user_id":{"type":["string","null"],"format":"uuid","description":"Пользователь-получатель; NULL для приглашений по email.\n\n`uuid` · NULL"},"kind":{"type":"string","description":"Тип письма: `verify`, `reset`, `invitation` или письмо о задаче — `task_assigned`, `task_mention`, `task_reply`, `task_due_soon`, `task_overdue`; `task_digest` — в ещё не отправленное письмо добавились события той же задачи.\n\n`text` · NOT NULL"},"payload":{"type":"string","description":"base64 конверта MDX1 (`MAIL_ENCRYPTION_KEY`, AAD `mail:<id>`) с `{to, subject, text}`, у писем о задачах также `html` и события для объединения; после отправки — пустая строка.\n\n`text` · NOT NULL"},"status":{"type":"string","description":"`pending` — ждёт отправки; `sent` — принято SMTP; `cancelled` — заменено новым письмом того же вида или задача удалена до отправки. CHECK нет.\n\n`text` · NOT NULL · по умолчанию `'pending'::text`"},"attempts":{"type":"integer","format":"int32","description":"Число начатых попыток отправки; после 5 письмо больше не берётся.\n\n`integer` · NOT NULL · по умолчанию `0`"},"next_attempt":{"type":"string","format":"date-time","description":"Не отправлять раньше этого момента; при каждой попытке сдвигается на 5 минут.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"},"created_at":{"type":"string","format":"date-time","description":"Момент постановки в очередь; задаёт порядок отправки.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"},"sent_at":{"type":["string","null"],"format":"date-time","description":"Момент успешной передачи SMTP-серверу.\n\n`timestamp with time zone` · NULL"},"task_id":{"type":["string","null"],"format":"uuid","description":"Задача письма `task_*`; NULL у остальных писем и после удаления задачи (`ON DELETE SET NULL`: строка остаётся в дневном лимите, а ждущее письмо воркер отменяет).\n\n`uuid` · NULL"},"dedup_key":{"type":["string","null"],"description":"Ключ дедупликации уведомления, породившего письмо о задаче; уникален вместе с `user_id` (частичный индекс `mail_outbox_dedup`). NULL у остальных писем.\n\n`text` · NULL"}},"x-db-table":"mail_outbox"},"db.message_reactions":{"title":"Таблица message_reactions","type":"object","description":"Эмодзи-реакции на комментарий задачи или личное сообщение: заполнено ровно одно из `comment_id`/`message_id` (CHECK `num_nonnulls = 1`). Пользователь ставит конкретный эмодзи на сообщение один раз. Триггер `realtime_reactions` (`reaction_changed`) оповещает участников чата или доски.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `comment_id` → [db.comments](#модели/dbcomments) (id), при удалении: CASCADE\n- `message_id` → [db.direct_messages](#модели/dbdirect-messages) (id), при удалении: CASCADE\n- `user_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n\n**Уникальность:**\n- `UNIQUE (comment_id, user_id, emoji)`\n- `UNIQUE (message_id, user_id, emoji)`\n\n**Проверки:**\n- `CHECK (num_nonnulls(comment_id, message_id) = 1)`\n- `CHECK (length(emoji) >= 1 AND length(emoji) <= 32)`\n\n**Триггеры:**\n- `realtime_reactions`: AFTER INSERT OR DELETE ON message_reactions FOR EACH ROW EXECUTE FUNCTION reaction_changed()\n\n**Используется в коде:** `discussion.ts`, `message-actions.ts`, `tasks.ts`","required":["id","user_id","emoji"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID реакции.\n\n`uuid` · NOT NULL"},"comment_id":{"type":["string","null"],"format":"uuid","description":"Комментарий задачи, если реакция в обсуждении задачи.\n\n`uuid` · NULL"},"message_id":{"type":["string","null"],"format":"uuid","description":"Личное сообщение, если реакция в чате.\n\n`uuid` · NULL"},"user_id":{"type":"string","format":"uuid","description":"Кто поставил реакцию.\n\n`uuid` · NOT NULL"},"emoji":{"type":"string","description":"Эмодзи, 1–32 символа; API проверяет, что это пиктограмма Unicode.\n\n`text` · NOT NULL"}},"x-db-table":"message_reactions"},"db.mood_entries":{"title":"Таблица mood_entries","type":"object","description":"Настроение дня: одна оценка на пользователя за локальный день, повторное сохранение перезаписывает её. При `share_with_friends` друзья видят сегодняшнее настроение в списке друзей; триггер `realtime_moods` оповещает их.\n\n**Первичный ключ:** `user_id`, `day`\n\n**Внешние ключи:**\n- `user_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n\n**Проверки:**\n- `CHECK (score >= 1 AND score <= 5)`\n\n**Триггеры:**\n- `realtime_moods`: AFTER INSERT OR UPDATE ON mood_entries FOR EACH ROW EXECUTE FUNCTION metodox_realtime_event()\n\n**Используется в коде:** `realtime.ts`, `social.ts`","required":["user_id","day","score","note","share_with_friends","updated_at"],"properties":{"user_id":{"type":"string","format":"uuid","description":"Автор записи.\n\n`uuid` · NOT NULL"},"day":{"type":"string","format":"date","description":"Локальный день пользователя (по `users.timezone`).\n\n`date` · NOT NULL"},"score":{"type":"integer","format":"int32","description":"Оценка настроения, 1–5.\n\n`integer` · NOT NULL"},"note":{"type":"string","description":"Заметка к настроению, до 1000 символов.\n\n`text` · NOT NULL · по умолчанию `''::text`"},"share_with_friends":{"type":"boolean","description":"Показывать сегодняшнее настроение друзьям.\n\n`boolean` · NOT NULL · по умолчанию `false`"},"updated_at":{"type":"string","format":"date-time","description":"Момент последнего сохранения.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"}},"x-db-table":"mood_entries"},"db.notes":{"title":"Таблица notes","type":"object","description":"Личные заметки владельца. Корзина реализована мягким удалением (`deleted_at`) с восстановлением; окончательно строка удаляется только из корзины — по одной (`DELETE /notes/{id}/permanent`) или очисткой корзины (`DELETE /notes/trash`). Правка требует совпадения `version` и запрещена для заметок в корзине. Список читается страницами по 300 с курсором по `(pinned, updated_at, id)`.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `owner_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n\n**Индексы:**\n- `notes_owner_idx`: `btree (owner_id, updated_at DESC)`\n\n**Используется в коде:** `admin.ts`, `main.ts`, `notes.ts`","required":["id","owner_id","title","body","folder","pinned","version","created_at","updated_at"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID заметки.\n\n`uuid` · NOT NULL"},"owner_id":{"type":"string","format":"uuid","description":"Владелец; заметки видит только он.\n\n`uuid` · NOT NULL"},"title":{"type":"string","description":"Заголовок, 1–200 символов (по умолчанию «Новая заметка»).\n\n`text` · NOT NULL · по умолчанию `'Новая заметка'::text`"},"body":{"type":"string","description":"Текст заметки, до 100 000 символов.\n\n`text` · NOT NULL · по умолчанию `''::text`"},"folder":{"type":"string","description":"Папка, до 60 символов; пустая строка — без папки.\n\n`text` · NOT NULL · по умолчанию `''::text`"},"pinned":{"type":"boolean","description":"Закреплена в начале списка.\n\n`boolean` · NOT NULL · по умолчанию `false`"},"version":{"type":"integer","format":"int32","description":"Оптимистичная блокировка; +1 при правке, переносе в корзину и восстановлении.\n\n`integer` · NOT NULL · по умолчанию `1`"},"created_at":{"type":"string","format":"date-time","description":"Момент создания.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"},"updated_at":{"type":"string","format":"date-time","description":"Момент последней правки, переноса в корзину или восстановления; ключ сортировки и курсора списка.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"},"deleted_at":{"type":["string","null"],"format":"date-time","description":"Момент переноса в корзину; NULL — заметка активна.\n\n`timestamp with time zone` · NULL"}},"x-db-table":"notes"},"db.notifications":{"title":"Таблица notifications","type":"object","description":"Центр уведомлений внутри приложения. `UNIQUE(user_id, dedup_key)` исключает дубли. При чтении API в SQL, до `LIMIT 100`, скрывает устаревшие строки (исполнитель сменился, задача завершена, срок изменился), задачи, к которым больше нет доступа, и уведомления об удалённых комментариях; счётчик непрочитанных считается по всем видимым строкам. Само чтение уведомлений не создаёт: напоминания о сроках пишет фоновый генератор раз в минуту и сразу при создании или изменении задачи.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `actor_id` → [db.users](#модели/dbusers) (id), при удалении: SET NULL\n- `comment_id` → [db.comments](#модели/dbcomments) (id), при удалении: SET NULL\n- `task_id` → [db.tasks](#модели/dbtasks) (id), при удалении: CASCADE\n- `user_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n\n**Уникальность:**\n- `UNIQUE (user_id, dedup_key)`\n\n**Индексы:**\n- `notifications_user_created`: `btree (user_id, created_at DESC)`\n\n**Триггеры:**\n- `realtime_notifications`: AFTER INSERT OR DELETE OR UPDATE ON notifications FOR EACH ROW EXECUTE FUNCTION metodox_realtime_event()\n\n**Используется в коде:** `agent-workflows.ts`, `ai.ts`, `boards.ts`, `mail.ts`, `main.ts`, `message-actions.ts`, `notifications.ts`, `realtime.ts`, `tasks.ts`","required":["id","user_id","task_id","kind","dedup_key","created_at"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID уведомления.\n\n`uuid` · NOT NULL"},"user_id":{"type":"string","format":"uuid","description":"Получатель.\n\n`uuid` · NOT NULL"},"task_id":{"type":"string","format":"uuid","description":"Задача, к которой относится уведомление.\n\n`uuid` · NOT NULL"},"actor_id":{"type":["string","null"],"format":"uuid","description":"Кто вызвал уведомление; NULL для напоминаний о сроках.\n\n`uuid` · NULL"},"kind":{"type":"string","enum":["assigned","comment","reply","mention","due_soon","overdue"],"description":"`assigned` — назначен исполнителем; `comment` — комментарий; `reply` — ответ вам; `mention` — упоминание; `due_soon` — срок сегодня или завтра; `overdue` — срок прошёл.\n\n`text` · NOT NULL"},"dedup_key":{"type":"string","description":"Ключ дедупликации: id комментария; `<taskId>:<kind>:<дата>` для сроков; случайный UUID для назначений.\n\n`text` · NOT NULL"},"due_date":{"type":["string","null"],"format":"date","description":"Дата срока, на которую создано напоминание; при смене срока старое напоминание скрывается.\n\n`date` · NULL"},"read_at":{"type":["string","null"],"format":"date-time","description":"Момент прочтения; NULL — не прочитано.\n\n`timestamp with time zone` · NULL"},"created_at":{"type":"string","format":"date-time","description":"Момент создания.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"},"comment_id":{"type":["string","null"],"format":"uuid","description":"Комментарий для прямой ссылки и подсветки сообщения.\n\n`uuid` · NULL"}},"x-db-table":"notifications"},"db.project_phases":{"title":"Таблица project_phases","type":"object","description":"Этапы проекта по порядку (например запуск, доработка, реклама). Доска может принадлежать одному этапу своего проекта — составной FK `(phase_id, project_id)` из `boards`. Правки этапа проверяют `version` проекта.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `project_id` → [db.workspace_projects](#модели/dbworkspace-projects) (id), при удалении: CASCADE\n\n**На таблицу ссылаются:** [db.boards](#модели/dbboards) (phase_id, project_id)\n\n**Уникальность:**\n- `UNIQUE (id, project_id)`\n\n**Проверки:**\n- `CHECK (color ~ '^#[0-9a-f]{6}$'::text)`\n\n**Используется в коде:** `access.ts`, `boards.ts`, `planner.ts`, `projects.ts`, `team.ts`","required":["id","project_id","title","status","position"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID этапа.\n\n`uuid` · NOT NULL"},"project_id":{"type":"string","format":"uuid","description":"Проект этапа.\n\n`uuid` · NOT NULL"},"title":{"type":"string","description":"Название, 1–100 символов.\n\n`text` · NOT NULL"},"status":{"type":"string","enum":["planned","active","completed"],"description":"`planned` — запланирован; `active` — идёт; `completed` — завершён.\n\n`text` · NOT NULL · по умолчанию `'planned'::text`"},"position":{"type":"integer","format":"int32","description":"Порядок с 0; новый этап получает максимальный + 1.\n\n`integer` · NOT NULL"},"color":{"type":["string","null"],"description":"Акцентный цвет этапа `#rrggbb`; NULL — как у проекта. Наследуется досками этапа без своего цвета.\n\n`text` · NULL"}},"x-db-table":"project_phases"},"db.rotated_refresh_tokens":{"title":"Таблица rotated_refresh_tokens","type":"object","description":"Журнал обменянных refresh-токенов для обнаружения их повторного использования (миграция 028). Ротация в `auth.ts` в той же транзакции, что и замена сессии, записывает сюда SHA-256 использованного токена и хранит его 30 дней. Если этот токен предъявят снова позже чем через 30 секунд после ротации (короткое окно прощает параллельные обновления из двух вкладок или realtime-клиента), значит, у кого-то есть его копия: API удаляет все сессии пользователя и все его строки здесь и отвечает 401 «Сессия завершена из соображений безопасности. Войдите снова.». Самих токенов таблица не хранит. Просроченные строки раз в час удаляет `cleanup.ts`.\n\n**Первичный ключ:** `hash`\n\n**Внешние ключи:**\n- `user_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n\n**Индексы:**\n- `rotated_refresh_tokens_expires`: `btree (expires_at)`\n- `rotated_refresh_tokens_user`: `btree (user_id)`\n\n**Используется в коде:** `auth.ts`, `cleanup.ts`","required":["hash","user_id","rotated_at","expires_at"],"properties":{"hash":{"type":"string","description":"SHA-256 (hex) обменянного refresh-токена; первичный ключ.\n\n`text` · NOT NULL"},"user_id":{"type":"string","format":"uuid","description":"Владелец сессии, из которой выпущен токен; по нему при повторе удаляются все сессии и записи пользователя.\n\n`uuid` · NOT NULL"},"rotated_at":{"type":"string","format":"date-time","description":"Момент ротации; повтор раньше `rotated_at + 30 секунд` — обычный 401 без отзыва сессий.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"},"expires_at":{"type":"string","format":"date-time","description":"До какого момента повтор считается кражей: ротация + 30 дней (срок жизни refresh-токена). После него строку удаляет очистка.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `(now() + '30 days'::interval)`"}},"x-db-table":"rotated_refresh_tokens"},"db.schema_migrations":{"title":"Таблица schema_migrations","type":"object","description":"Применённые миграции. Таблицу создаёт `migrate.ts` (после `schema.sql`); файл, имя которого уже записано, больше не выполняется.\n\n**Первичный ключ:** `name`\n\n**Используется в коде:** `admin.ts`, `migrate.ts`","required":["name","applied_at"],"properties":{"name":{"type":"string","description":"Имя файла миграции, например `020_sandbox_executor.sql`.\n\n`text` · NOT NULL"},"applied_at":{"type":"string","format":"date-time","description":"Момент применения.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"}},"x-db-table":"schema_migrations"},"db.secret_audit":{"title":"Таблица secret_audit","type":"object","description":"Журнал действий с секретами организации; владелец видит последние 100 записей. Записи сохраняются после удаления секрета (`secret_id` становится NULL, id остаётся в `action`).\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `actor_id` → [db.users](#модели/dbusers) (id), при удалении: SET NULL\n- `secret_id` → [db.workspace_secrets](#модели/dbworkspace-secrets) (id), при удалении: SET NULL\n- `workspace_id` → [db.workspaces](#модели/dbworkspaces) (id), при удалении: CASCADE\n\n**Используется в коде:** `agent-secrets.ts`, `agent-workflows.ts`","required":["id","workspace_id","action","created_at"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID записи.\n\n`uuid` · NOT NULL"},"workspace_id":{"type":"string","format":"uuid","description":"Организация.\n\n`uuid` · NOT NULL"},"secret_id":{"type":["string","null"],"format":"uuid","description":"Секрет; NULL после его удаления.\n\n`uuid` · NULL"},"actor_id":{"type":["string","null"],"format":"uuid","description":"Владелец организации или ИИ-сотрудник (для `execute:`).\n\n`uuid` · NULL"},"action":{"type":"string","description":"`created`, `revealed`, `grant:<employeeId>`, `revoke:<employeeId>`, `deleted:<secretId>`, `execute:<операция>`.\n\n`text` · NOT NULL"},"created_at":{"type":"string","format":"date-time","description":"Момент действия.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"}},"x-db-table":"secret_audit"},"db.secret_grants":{"title":"Таблица secret_grants","type":"object","description":"Разрешение ИИ-сотруднику использовать секрет организации в агентных цепочках. Выдаёт и отзывает владелец; каждое действие пишется в `secret_audit`, а фактическое изменение выдач увеличивает `workspace_secrets.version`, и уже поставленные SSH-шаги по этому секрету не выполнятся.\n\n**Первичный ключ:** `secret_id`, `employee_id`\n\n**Внешние ключи:**\n- `employee_id` → [db.ai_employees](#модели/dbai-employees) (user_id), при удалении: CASCADE\n- `secret_id` → [db.workspace_secrets](#модели/dbworkspace-secrets) (id), при удалении: CASCADE\n\n**Используется в коде:** `agent-secrets.ts`, `agent-workflows.ts`, `ai.ts`","required":["secret_id","employee_id"],"properties":{"secret_id":{"type":"string","format":"uuid","description":"Секрет.\n\n`uuid` · NOT NULL"},"employee_id":{"type":"string","format":"uuid","description":"ИИ-сотрудник той же организации.\n\n`uuid` · NOT NULL"}},"x-db-table":"secret_grants"},"db.sessions":{"title":"Таблица sessions","type":"object","description":"Сессия входа: пара токенов access (15 минут) и refresh (30 дней), в БД — только их SHA-256. Обновление по refresh удаляет строку и создаёт новую (ротация), хеш старого refresh-токена переносится в `rotated_refresh_tokens`; выход, отзыв сессии в профиле, смена и сброс пароля удаляют строки, повтор украденного refresh-токена удаляет все сессии пользователя. Строки с истёкшим `refresh_expires` раз в час удаляет `cleanup.ts` (индекс `sessions_refresh_expires`).\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `user_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n\n**Уникальность:**\n- `UNIQUE (access_hash)`\n- `UNIQUE (refresh_hash)`\n\n**Индексы:**\n- `sessions_refresh_expires`: `btree (refresh_expires)`\n- `sessions_user_idx`: `btree (user_id)`\n\n**Используется в коде:** `admin.ts`, `auth.ts`, `cleanup.ts`, `mail.ts`, `profile.ts`, `throttle.ts`","required":["id","user_id","access_hash","refresh_hash","access_expires","refresh_expires","created_at"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID сессии; используется для показа и отзыва сессий в профиле.\n\n`uuid` · NOT NULL"},"user_id":{"type":"string","format":"uuid","description":"Владелец сессии.\n\n`uuid` · NOT NULL"},"access_hash":{"type":"string","description":"SHA-256 (hex) access-токена (32 случайных байта, base64url); уникален.\n\n`text` · NOT NULL"},"refresh_hash":{"type":"string","description":"SHA-256 (hex) refresh-токена; уникален.\n\n`text` · NOT NULL"},"access_expires":{"type":"string","format":"date-time","description":"Срок действия access-токена: момент выдачи + 15 минут.\n\n`timestamp with time zone` · NOT NULL"},"refresh_expires":{"type":"string","format":"date-time","description":"Срок действия refresh-токена и всей сессии: момент выдачи + 30 дней.\n\n`timestamp with time zone` · NOT NULL"},"created_at":{"type":"string","format":"date-time","description":"Момент выдачи пары токенов.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"}},"x-db-table":"sessions"},"db.storage_cleanup":{"title":"Таблица storage_cleanup","type":"object","description":"Очередь удаления S3-объектов. Заполняется триггером `queue_attachment_cleanup` при удалении файлов (в том числе каскадном) и фоновой очисткой просроченных `pending`-ключей. Воркер `StorageService` раз в минуту удаляет до 50 объектов, затем строки здесь и в `storage_objects`; при ошибке повторяет на следующем проходе.\n\n**Первичный ключ:** `object_key`\n\n**Используется в коде:** `storage.ts`","required":["object_key","created_at"],"properties":{"object_key":{"type":"string","description":"Ключ S3-объекта, который нужно удалить.\n\n`text` · NOT NULL"},"created_at":{"type":"string","format":"date-time","description":"Момент постановки в очередь; задаёт порядок обработки.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"}},"x-db-table":"storage_cleanup"},"db.storage_objects":{"title":"Таблица storage_objects","type":"object","description":"Реестр S3-ключей для надёжной загрузки (используется только при `STORAGE_DRIVER=s3`). Перед записью в бакет ключ резервируется со `state='pending'`; после фиксации строки файла — `attached`. Ключи, оставшиеся `pending` дольше часа, переносятся в `storage_cleanup`.\n\n**Первичный ключ:** `object_key`\n\n**Используется в коде:** `direct-media.ts`, `message-actions.ts`, `storage-migrate.ts`, `storage.ts`, `tasks.ts`, `wiki.ts`","required":["object_key","state","created_at"],"properties":{"object_key":{"type":"string","description":"Ключ объекта `<S3_PREFIX>/attachments/<uuid файла>`.\n\n`text` · NOT NULL"},"state":{"type":"string","description":"`pending` — загрузка начата, файл ещё не зафиксирован; `attached` — объект принадлежит файлу. CHECK нет.\n\n`text` · NOT NULL · по умолчанию `'pending'::text`"},"created_at":{"type":"string","format":"date-time","description":"Время резервирования; обновляется при повторной попытке с тем же ключом.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"}},"x-db-table":"storage_objects"},"db.task_counters":{"title":"Таблица task_counters","type":"object","description":"Счётчик номеров задач по доскам: одна строка на доску с последним выданным номером. Триггер `tasks_number` увеличивает его при каждой вставке задачи под блокировкой строки, поэтому номера не повторяются даже при параллельном создании.\n\n**Первичный ключ:** `board_id`\n\n**Внешние ключи:**\n- `board_id` → [db.boards](#модели/dbboards) (id), при удалении: CASCADE\n\n**Проверки:**\n- `CHECK (last_number >= 0)`","required":["board_id","last_number"],"properties":{"board_id":{"type":"string","format":"uuid","description":"Доска (`boards`), удаляется вместе с ней.\n\n`uuid` · NOT NULL"},"last_number":{"type":"integer","format":"int32","description":"Последний выданный номер задачи на доске; следующая задача получит last_number + 1.\n\n`integer` · NOT NULL"}},"x-db-table":"task_counters"},"db.task_events":{"title":"Таблица task_events","type":"object","description":"История задачи (вкладка «История» в карточке). Пишется функцией `event()` в той же транзакции, что и изменение; заголовки связанных задач намеренно не сохраняются. Триггер `mirror_workspace_task_event` копирует каждую строку в `workspace_events`.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `actor_id` → [db.users](#модели/dbusers) (id), при удалении: SET NULL\n- `task_id` → [db.tasks](#модели/dbtasks) (id), при удалении: CASCADE\n\n**Индексы:**\n- `task_events_created`: `btree (created_at)`\n- `task_events_task_idx`: `btree (task_id, created_at)`\n\n**Триггеры:**\n- `mirror_workspace_task_event`: AFTER INSERT ON task_events FOR EACH ROW EXECUTE FUNCTION mirror_workspace_task_event()\n\n**Используется в коде:** `access.ts`, `tasks.ts`","required":["id","task_id","action","details","created_at"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID события; тот же id получает зеркальная строка в `workspace_events`.\n\n`uuid` · NOT NULL"},"task_id":{"type":"string","format":"uuid","description":"Задача события.\n\n`uuid` · NOT NULL"},"actor_id":{"type":["string","null"],"format":"uuid","description":"Кто действовал: человек или ИИ-сотрудник.\n\n`uuid` · NULL"},"action":{"type":"string","description":"Код действия: `created`, `updated`, `commented`, `comment_edited`, `comment_deleted`, `attached`, `attachment_removed`, `access_granted`, `relation_added`, `sprint_added`, `agent_step_completed` и др.\n\n`text` · NOT NULL"},"details":{"description":"JSON-подробности: для `updated` — `fields` и `changes {from, to}`; для вложений и доступа — имя; для связей — `kind`; для `comment_edited` — `commentId`; для `comment_deleted` — `commentId`, число удалённых вложений `attachments` и, если удалил не автор, `name` автора.\n\n`jsonb` · NOT NULL · по умолчанию `'{}'::jsonb`"},"created_at":{"type":"string","format":"date-time","description":"Момент события.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"}},"x-db-table":"task_events"},"db.task_links":{"title":"Таблица task_links","type":"object","description":"Связи между задачами одной доски (составные FK на `tasks(id, board_id)`). Для `subtask` у задачи не больше одного родителя (частичный уникальный индекс `task_single_parent`); дубликаты связей и связь задачи с собой запрещены. Циклы и связи, противоречащие уже завершённым задачам, отклоняет API.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `board_id` → [db.boards](#модели/dbboards) (id), при удалении: CASCADE\n- `created_by` → [db.users](#модели/dbusers) (id), при удалении: SET NULL\n- `source_id`, `board_id` → [db.tasks](#модели/dbtasks) (id, board_id), при удалении: CASCADE\n- `target_id`, `board_id` → [db.tasks](#модели/dbtasks) (id, board_id), при удалении: CASCADE\n\n**Уникальность:**\n- `UNIQUE (source_id, target_id, kind)`\n\n**Проверки:**\n- `CHECK (source_id <> target_id)`\n\n**Индексы:**\n- `task_links_source`: `btree (source_id)`\n- `task_links_target`: `btree (target_id)`\n- `task_single_parent`: `UNIQUE btree (target_id) WHERE (kind = 'subtask'::text)`\n\n**Используется в коде:** `boards.ts`, `task-graph.ts`","required":["id","board_id","source_id","target_id","kind","created_at"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID связи.\n\n`uuid` · NOT NULL"},"board_id":{"type":"string","format":"uuid","description":"Доска обеих задач.\n\n`uuid` · NOT NULL"},"source_id":{"type":"string","format":"uuid","description":"Для `subtask` — родительская задача, для `blocks` — блокирующая.\n\n`uuid` · NOT NULL"},"target_id":{"type":"string","format":"uuid","description":"Для `subtask` — подзадача, для `blocks` — блокируемая задача.\n\n`uuid` · NOT NULL"},"kind":{"type":"string","enum":["subtask","blocks"],"description":"`subtask` — подзадача target входит в родителя source; `blocks` — source нужно завершить раньше target.\n\n`text` · NOT NULL"},"created_by":{"type":["string","null"],"format":"uuid","description":"Кто создал связь.\n\n`uuid` · NULL"},"created_at":{"type":"string","format":"date-time","description":"Момент создания связи.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"}},"x-db-table":"task_links"},"db.task_members":{"title":"Таблица task_members","type":"object","description":"Индивидуальный доступ к задаче — прежде всего к задачам с `visibility='restricted'`. Выдаёт автор или администратор доски; получатель уже должен иметь доступ к доске.\n\n**Первичный ключ:** `task_id`, `user_id`\n\n**Внешние ключи:**\n- `task_id` → [db.tasks](#модели/dbtasks) (id), при удалении: CASCADE\n- `user_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n\n**Используется в коде:** `access.ts`, `boards.ts`, `discussion.ts`, `mail.ts`, `notifications.ts`, `planner.ts`, `task-graph.ts`, `tasks.ts`, `team-activity.ts`, `team.ts`, `workspaces.ts`","required":["task_id","user_id","role"],"properties":{"task_id":{"type":"string","format":"uuid","description":"Задача.\n\n`uuid` · NOT NULL"},"user_id":{"type":"string","format":"uuid","description":"Пользователь с индивидуальной ролью.\n\n`uuid` · NOT NULL"},"role":{"type":"string","enum":["editor","commenter","viewer"],"description":"`editor` — правка задачи; `commenter` — комментарии и вложения; `viewer` — только просмотр.\n\n`text` · NOT NULL"}},"x-db-table":"task_members"},"db.task_mood_rewards":{"title":"Таблица task_mood_rewards","type":"object","description":"Отметка «радости от задачи»: насколько приятной (1–5) оказалась задача, которую пользователь завершил сам. Одна отметка на задачу, повторная игнорируется.\n\n**Первичный ключ:** `user_id`, `task_id`\n\n**Внешние ключи:**\n- `task_id` → [db.tasks](#модели/dbtasks) (id), при удалении: CASCADE\n- `user_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n\n**Проверки:**\n- `CHECK (points >= 1 AND points <= 5)`\n\n**Используется в коде:** `social.ts`","required":["user_id","task_id","points","day","created_at"],"properties":{"user_id":{"type":"string","format":"uuid","description":"Пользователь, который сам завершил задачу (`completed_by`).\n\n`uuid` · NOT NULL"},"task_id":{"type":"string","format":"uuid","description":"Завершённая задача.\n\n`uuid` · NOT NULL"},"points":{"type":"integer","format":"int32","description":"Баллы радости, 1–5.\n\n`integer` · NOT NULL"},"day":{"type":"string","format":"date","description":"Локальный день отметки.\n\n`date` · NOT NULL"},"created_at":{"type":"string","format":"date-time","description":"Момент отметки.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"}},"x-db-table":"task_mood_rewards"},"db.task_plans":{"title":"Таблица task_plans","type":"object","description":"Личный недельный планировщик: пользователь ставит доступную ему задачу на конкретный день и оценивает время. Одна запись на пару «пользователь — задача»; снятие с плана удаляет строку.\n\n**Первичный ключ:** `user_id`, `task_id`\n\n**Внешние ключи:**\n- `task_id` → [db.tasks](#модели/dbtasks) (id), при удалении: CASCADE\n- `user_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n\n**Проверки:**\n- `CHECK (minutes >= 5 AND minutes <= 480)`\n\n**Используется в коде:** `planner.ts`","required":["user_id","task_id","day","minutes"],"properties":{"user_id":{"type":"string","format":"uuid","description":"Пользователь, который планирует.\n\n`uuid` · NOT NULL"},"task_id":{"type":"string","format":"uuid","description":"Запланированная задача.\n\n`uuid` · NOT NULL"},"day":{"type":"string","format":"date","description":"День, на который поставлена задача.\n\n`date` · NOT NULL"},"minutes":{"type":"integer","format":"int32","description":"Оценка времени в минутах, 5–480, по умолчанию 30.\n\n`integer` · NOT NULL · по умолчанию `30`"}},"x-db-table":"task_plans"},"db.tasks":{"title":"Таблица tasks","type":"object","description":"Задача на доске; колонка обязана принадлежать той же доске (составной FK). Каждая правка проверяет и увеличивает `version`. Перенос в последнюю колонку заполняет `completed_at`/`completed_by` и начисляет награду, перенос обратно очищает их.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `assignee_id` → [db.users](#модели/dbusers) (id), при удалении: SET NULL\n- `board_id` → [db.boards](#модели/dbboards) (id), при удалении: CASCADE\n- `column_id`, `board_id` → [db.columns](#модели/dbcolumns) (id, board_id), при удалении: CASCADE\n- `completed_by` → [db.users](#модели/dbusers) (id), при удалении: SET NULL\n- `creator_id` → [db.users](#модели/dbusers) (id), при удалении: SET NULL\n\n**На таблицу ссылаются:** [db.agent_workflows](#модели/dbagent-workflows) (task_id), [db.ai_runs](#модели/dbai-runs) (task_id), [db.attachments](#модели/dbattachments) (task_id), [db.comments](#модели/dbcomments) (task_id), [db.mail_outbox](#модели/dbmail-outbox) (task_id), [db.notifications](#модели/dbnotifications) (task_id), [db.task_events](#модели/dbtask-events) (task_id), [db.task_links](#модели/dbtask-links) (source_id, board_id), [db.task_links](#модели/dbtask-links) (target_id, board_id), [db.task_members](#модели/dbtask-members) (task_id), [db.task_mood_rewards](#модели/dbtask-mood-rewards) (task_id), [db.task_plans](#модели/dbtask-plans) (task_id), [db.team_backlog](#модели/dbteam-backlog) (task_id), [db.team_sprint_tasks](#модели/dbteam-sprint-tasks) (task_id)\n\n**Индексы:**\n- `tasks_board_idx`: `btree (board_id, column_id, \"position\")`\n- `tasks_board_number`: `UNIQUE btree (board_id, number)`\n- `tasks_id_board_unique`: `UNIQUE btree (id, board_id)`\n\n**Триггеры:**\n- `tasks_number`: BEFORE INSERT ON tasks FOR EACH ROW EXECUTE FUNCTION assign_task_number()\n\n**Используется в коде:** `access.ts`, `admin.ts`, `agent-workflows.ts`, `ai.ts`, `boards.ts`, `direct-media.ts`, `discussion.ts`, `mail.ts`, `main.ts`, `message-actions.ts`, `motivation.ts`, `notifications.ts`, `planner.ts`, `realtime.ts`, `seed-demo.ts`, `social.ts`, `starter.ts`, `task-graph.ts`, `tasks.ts`, `team-activity.ts`, `team.ts`, `wiki.ts`, `workspaces.ts`","required":["id","board_id","column_id","title","description","priority","label","position","version","created_at","updated_at","visibility","number"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID задачи.\n\n`uuid` · NOT NULL"},"board_id":{"type":"string","format":"uuid","description":"Доска задачи.\n\n`uuid` · NOT NULL"},"column_id":{"type":"string","format":"uuid","description":"Текущая колонка той же доски.\n\n`uuid` · NOT NULL"},"title":{"type":"string","description":"Заголовок, 1–200 символов.\n\n`text` · NOT NULL"},"description":{"type":"string","description":"Описание, до 100 000 символов.\n\n`text` · NOT NULL · по умолчанию `''::text`"},"priority":{"type":"string","enum":["low","medium","high"],"description":"Приоритет: `low`, `medium` (по умолчанию) или `high`.\n\n`text` · NOT NULL · по умолчанию `'medium'::text`"},"label":{"type":"string","description":"Короткая метка, до 40 символов.\n\n`text` · NOT NULL · по умолчанию `''::text`"},"due_date":{"type":["string","null"],"format":"date","description":"Срок — календарная дата без времени; основа напоминаний `due_soon`/`overdue` и признака «в срок».\n\n`date` · NULL"},"position":{"type":"integer","format":"int32","description":"Порядок внутри колонки; текущий API оставляет 0 и сортирует по `position`, затем по `created_at`.\n\n`integer` · NOT NULL · по умолчанию `0`"},"version":{"type":"integer","format":"int32","description":"Счётчик оптимистичной блокировки; +1 при каждом изменении, в том числе при снятии исполнителя.\n\n`integer` · NOT NULL · по умолчанию `1`"},"created_at":{"type":"string","format":"date-time","description":"Момент создания.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"},"updated_at":{"type":"string","format":"date-time","description":"Момент последней правки через PATCH.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"},"creator_id":{"type":["string","null"],"format":"uuid","description":"Автор задачи; автор с ролью editor управляет видимостью и исполнителем.\n\n`uuid` · NULL"},"assignee_id":{"type":["string","null"],"format":"uuid","description":"Исполнитель (человек или ИИ-сотрудник); должен иметь доступ к доске.\n\n`uuid` · NULL"},"visibility":{"type":"string","enum":["board","restricted"],"description":"`board` — видят все с доступом к доске; `restricted` — только админы доски, автор, исполнитель и `task_members`.\n\n`text` · NOT NULL · по умолчанию `'board'::text`"},"completed_at":{"type":["string","null"],"format":"date-time","description":"Момент завершения (задача в последней колонке); NULL — не завершена.\n\n`timestamp with time zone` · NULL"},"completed_by":{"type":["string","null"],"format":"uuid","description":"Кто перенёс задачу в завершающую колонку.\n\n`uuid` · NULL"},"number":{"type":"integer","format":"int32","description":"Постоянный номер задачи внутри доски (показывается как MX–12). Назначается триггером `tasks_number` из `task_counters`, не переиспользуется после удаления.\n\n`integer` · NOT NULL"}},"x-db-table":"tasks"},"db.team_backlog":{"title":"Таблица team_backlog","type":"object","description":"Бэклог идей организации до переноса на доску. Перенос создаёт задачу в первой колонке выбранной доски (`private` → `restricted`), ставит `status='transferred'` и `task_id`; после этого запись не редактируется. Архивирование — через `archived_at`.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `project_id`, `workspace_id` → [db.workspace_projects](#модели/dbworkspace-projects) (id, workspace_id), при удалении: NO ACTION\n- `creator_id` → [db.users](#модели/dbusers) (id), при удалении: SET NULL\n- `task_id` → [db.tasks](#модели/dbtasks) (id), при удалении: SET NULL\n- `workspace_id` → [db.workspaces](#модели/dbworkspaces) (id), при удалении: CASCADE\n\n**Проверки:**\n- `CHECK (points >= 0 AND points <= 100)`\n\n**Индексы:**\n- `team_backlog_space`: `btree (workspace_id, created_at DESC)`\n\n**Используется в коде:** `team-activity.ts`, `team.ts`","required":["id","workspace_id","title","description","priority","status","visibility","version","created_at","updated_at"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID записи.\n\n`uuid` · NOT NULL"},"workspace_id":{"type":"string","format":"uuid","description":"Организация.\n\n`uuid` · NOT NULL"},"creator_id":{"type":["string","null"],"format":"uuid","description":"Автор; править может он или администратор организации.\n\n`uuid` · NULL"},"title":{"type":"string","description":"Заголовок, 1–200 символов.\n\n`text` · NOT NULL"},"description":{"type":"string","description":"Описание, до 10 000 символов.\n\n`text` · NOT NULL · по умолчанию `''::text`"},"priority":{"type":"string","enum":["low","medium","high"],"description":"Приоритет: `low`, `medium`, `high`; переносится в задачу.\n\n`text` · NOT NULL · по умолчанию `'medium'::text`"},"status":{"type":"string","enum":["idea","ready","parked","transferred"],"description":"`idea` — идея; `ready` — готово к работе; `parked` — отложено; `transferred` — перенесено на доску.\n\n`text` · NOT NULL · по умолчанию `'idea'::text`"},"visibility":{"type":"string","enum":["team","private"],"description":"`team` — видят все участники; `private` — только автор и администраторы организации.\n\n`text` · NOT NULL · по умолчанию `'team'::text`"},"points":{"type":["integer","null"],"format":"int32","description":"Оценка в story points, 0–100; NULL — без оценки.\n\n`integer` · NULL"},"version":{"type":"integer","format":"int32","description":"Оптимистичная блокировка; +1 при правке и переносе.\n\n`integer` · NOT NULL · по умолчанию `1`"},"task_id":{"type":["string","null"],"format":"uuid","description":"Задача, созданная из записи; NULL до переноса или после удаления задачи.\n\n`uuid` · NULL"},"archived_at":{"type":["string","null"],"format":"date-time","description":"Момент архивирования; NULL — запись активна.\n\n`timestamp with time zone` · NULL"},"created_at":{"type":"string","format":"date-time","description":"Момент создания.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"},"updated_at":{"type":"string","format":"date-time","description":"Момент последнего изменения.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"},"project_id":{"type":["string","null"],"format":"uuid","description":"Проект организации; при переносе доска должна принадлежать этому проекту.\n\n`uuid` · NULL"}},"x-db-table":"team_backlog"},"db.team_sprint_tasks":{"title":"Таблица team_sprint_tasks","type":"object","description":"Состав спринта с оценками. Задача может входить только в один незавершённый спринт организации (проверка API); в закрытый спринт задачи не добавляются.\n\n**Первичный ключ:** `sprint_id`, `task_id`\n\n**Внешние ключи:**\n- `sprint_id` → [db.team_sprints](#модели/dbteam-sprints) (id), при удалении: CASCADE\n- `task_id` → [db.tasks](#модели/dbtasks) (id), при удалении: CASCADE\n\n**Проверки:**\n- `CHECK (points >= 0 AND points <= 100)`\n\n**Используется в коде:** `boards.ts`, `team.ts`","required":["sprint_id","task_id","points","added_at"],"properties":{"sprint_id":{"type":"string","format":"uuid","description":"Спринт.\n\n`uuid` · NOT NULL"},"task_id":{"type":"string","format":"uuid","description":"Задача доски организации.\n\n`uuid` · NOT NULL"},"points":{"type":"integer","format":"int32","description":"Оценка задачи в спринте, 0–100.\n\n`integer` · NOT NULL · по умолчанию `0`"},"completed_at_close":{"type":["boolean","null"],"description":"Снимок при закрытии: была ли задача завершена; NULL — спринт ещё не закрыт.\n\n`boolean` · NULL"},"added_at":{"type":"string","format":"date-time","description":"Момент добавления в спринт.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"}},"x-db-table":"team_sprint_tasks"},"db.team_sprints":{"title":"Таблица team_sprints","type":"object","description":"Спринты организации. В организации не больше одного активного спринта (частичный уникальный индекс `one_active_team_sprint`). При старте фиксируются `committed_*`, при закрытии — `completed_*`; незавершённые задачи можно перенести в запланированный спринт.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `project_id`, `workspace_id` → [db.workspace_projects](#модели/dbworkspace-projects) (id, workspace_id), при удалении: NO ACTION\n- `created_by` → [db.users](#модели/dbusers) (id), при удалении: SET NULL\n- `workspace_id` → [db.workspaces](#модели/dbworkspaces) (id), при удалении: CASCADE\n\n**На таблицу ссылаются:** [db.team_sprint_tasks](#модели/dbteam-sprint-tasks) (sprint_id)\n\n**Проверки:**\n- `CHECK (end_date >= start_date)`\n\n**Индексы:**\n- `one_active_team_sprint`: `UNIQUE btree (workspace_id) WHERE (status = 'active'::text)`\n\n**Используется в коде:** `boards.ts`, `team.ts`","required":["id","workspace_id","title","goal","start_date","end_date","status","version","created_at"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID спринта.\n\n`uuid` · NOT NULL"},"workspace_id":{"type":"string","format":"uuid","description":"Организация.\n\n`uuid` · NOT NULL"},"title":{"type":"string","description":"Название, 1–100 символов.\n\n`text` · NOT NULL"},"goal":{"type":"string","description":"Цель спринта, до 2000 символов.\n\n`text` · NOT NULL · по умолчанию `''::text`"},"start_date":{"type":"string","format":"date","description":"Дата начала.\n\n`date` · NOT NULL"},"end_date":{"type":"string","format":"date","description":"Дата окончания (CHECK: не раньше `start_date`).\n\n`date` · NOT NULL"},"status":{"type":"string","enum":["planned","active","completed"],"description":"`planned` — формируется; `active` — идёт; `completed` — закрыт.\n\n`text` · NOT NULL · по умолчанию `'planned'::text`"},"version":{"type":"integer","format":"int32","description":"Оптимистичная блокировка; +1 при старте, закрытии и изменении состава.\n\n`integer` · NOT NULL · по умолчанию `1`"},"created_by":{"type":["string","null"],"format":"uuid","description":"Кто создал спринт.\n\n`uuid` · NULL"},"created_at":{"type":"string","format":"date-time","description":"Момент создания.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"},"started_at":{"type":["string","null"],"format":"date-time","description":"Момент старта.\n\n`timestamp with time zone` · NULL"},"finished_at":{"type":["string","null"],"format":"date-time","description":"Момент закрытия.\n\n`timestamp with time zone` · NULL"},"committed_tasks":{"type":["integer","null"],"format":"int32","description":"Число задач в спринте на момент старта.\n\n`integer` · NULL"},"committed_points":{"type":["integer","null"],"format":"int32","description":"Сумма баллов задач на момент старта.\n\n`integer` · NULL"},"completed_tasks":{"type":["integer","null"],"format":"int32","description":"Число задач, завершённых к закрытию.\n\n`integer` · NULL"},"completed_points":{"type":["integer","null"],"format":"int32","description":"Сумма баллов задач, завершённых к закрытию.\n\n`integer` · NULL"},"project_id":{"type":["string","null"],"format":"uuid","description":"Проект спринта: задачи берутся только с его досок; NULL — вся организация.\n\n`uuid` · NULL"}},"x-db-table":"team_sprints"},"db.user_achievements":{"title":"Таблица user_achievements","type":"object","description":"Открытые пользователем достижения. Строка появляется, когда метрика достигает порога (`unlocked_at`); монеты и XP начисляются отдельным действием «получить» (`claimed_at`), повторно — нельзя.\n\n**Первичный ключ:** `user_id`, `achievement_id`\n\n**Внешние ключи:**\n- `achievement_id` → [db.achievement_catalog](#модели/dbachievement-catalog) (id), при удалении: NO ACTION\n- `user_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n\n**Используется в коде:** `achievements.ts`, `motivation.ts`","required":["user_id","achievement_id","unlocked_at"],"properties":{"user_id":{"type":"string","format":"uuid","description":"Пользователь.\n\n`uuid` · NOT NULL"},"achievement_id":{"type":"string","description":"Достижение из каталога.\n\n`text` · NOT NULL"},"unlocked_at":{"type":"string","format":"date-time","description":"Момент открытия достижения.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"},"claimed_at":{"type":["string","null"],"format":"date-time","description":"Момент получения награды; NULL — награда ещё не забрана.\n\n`timestamp with time zone` · NULL"}},"x-db-table":"user_achievements"},"db.user_cosmetics":{"title":"Таблица user_cosmetics","type":"object","description":"Купленные предметы оформления. Покупка в одной транзакции проверяет баланс, добавляет строку, списывает `users.coin_balance` и пишет `coin_ledger`.\n\n**Первичный ключ:** `user_id`, `item_id`\n\n**Внешние ключи:**\n- `item_id` → [db.cosmetic_catalog](#модели/dbcosmetic-catalog) (id), при удалении: NO ACTION\n- `user_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n\n**Используется в коде:** `motivation.ts`","required":["user_id","item_id","purchased_at"],"properties":{"user_id":{"type":"string","format":"uuid","description":"Покупатель.\n\n`uuid` · NOT NULL"},"item_id":{"type":"string","description":"Купленный предмет каталога.\n\n`text` · NOT NULL"},"purchased_at":{"type":"string","format":"date-time","description":"Момент покупки.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"}},"x-db-table":"user_cosmetics"},"db.users":{"title":"Таблица users","type":"object","description":"Учётная запись человека (регистрация в `auth.ts`) или ИИ-сотрудника (создаётся в `ai.ts` с `account_kind='ai'`, служебным email и случайным паролем; вход для неё запрещён). Email уникален и хранится в нижнем регистре; баланс монет не может быть отрицательным, дневная цель — от 1 до 20.\n\n**Первичный ключ:** `id`\n\n**На таблицу ссылаются:** [db.agent_workflows](#модели/dbagent-workflows) (requested_by), [db.ai_employees](#модели/dbai-employees) (user_id), [db.ai_runs](#модели/dbai-runs) (requested_by), [db.attachments](#модели/dbattachments) (author_id), [db.auth_tokens](#модели/dbauth-tokens) (user_id), [db.board_members](#модели/dbboard-members) (user_id), [db.boards](#модели/dbboards) (owner_id), [db.coin_ledger](#модели/dbcoin-ledger) (user_id), [db.comment_mentions](#модели/dbcomment-mentions) (user_id), [db.comments](#модели/dbcomments) (author_id), [db.completion_records](#модели/dbcompletion-records) (user_id), [db.direct_files](#модели/dbdirect-files) (author_id), [db.direct_messages](#модели/dbdirect-messages) (sender_id), [db.equipped_cosmetics](#модели/dbequipped-cosmetics) (user_id), [db.friendships](#модели/dbfriendships) (recipient_id), [db.friendships](#модели/dbfriendships) (requester_id), [db.mail_outbox](#модели/dbmail-outbox) (user_id), [db.message_reactions](#модели/dbmessage-reactions) (user_id), [db.mood_entries](#модели/dbmood-entries) (user_id), [db.notes](#модели/dbnotes) (owner_id), [db.notifications](#модели/dbnotifications) (actor_id), [db.notifications](#модели/dbnotifications) (user_id), [db.rotated_refresh_tokens](#модели/dbrotated-refresh-tokens) (user_id), [db.secret_audit](#модели/dbsecret-audit) (actor_id), [db.sessions](#модели/dbsessions) (user_id), [db.task_events](#модели/dbtask-events) (actor_id), [db.task_links](#модели/dbtask-links) (created_by), [db.task_members](#модели/dbtask-members) (user_id), [db.task_mood_rewards](#модели/dbtask-mood-rewards) (user_id), [db.task_plans](#модели/dbtask-plans) (user_id), [db.tasks](#модели/dbtasks) (assignee_id), [db.tasks](#модели/dbtasks) (completed_by), [db.tasks](#модели/dbtasks) (creator_id), [db.team_backlog](#модели/dbteam-backlog) (creator_id), [db.team_sprints](#модели/dbteam-sprints) (created_by), [db.user_achievements](#модели/dbuser-achievements) (user_id), [db.user_cosmetics](#модели/dbuser-cosmetics) (user_id), [db.vault_configs](#модели/dbvault-configs) (user_id), [db.wiki_pages](#модели/dbwiki-pages) (creator_id), [db.wiki_pages](#модели/dbwiki-pages) (updated_by), [db.wiki_versions](#модели/dbwiki-versions) (author_id), [db.workspace_events](#модели/dbworkspace-events) (actor_id), [db.workspace_members](#модели/dbworkspace-members) (user_id), [db.workspace_secrets](#модели/dbworkspace-secrets) (created_by), [db.workspaces](#модели/dbworkspaces) (owner_id)\n\n**Уникальность:**\n- `UNIQUE (email)`\n\n**Проверки:**\n- `CHECK (coin_balance >= 0)`\n- `CHECK (daily_goal >= 1 AND daily_goal <= 20)`\n\n**Используется в коде:** `achievements.ts`, `admin.ts`, `agent-secrets.ts`, `agent-workflows.ts`, `ai.ts`, `auth.ts`, `boards.ts`, `discussion.ts`, `mail.ts`, `message-actions.ts`, `motivation.ts`, `notifications.ts`, `planner.ts`, `profile.ts`, `realtime.ts`, `sandbox-engine.ts`, `social.ts`, `tasks.ts`, `team-activity.ts`, `team.ts`, `wiki.ts`, `workspaces.ts`","required":["id","email","name","password_hash","created_at","bio","job_title","timezone","avatar_color","notify_assignments","notify_comments","notify_deadlines","account_kind","public_profile","coin_balance","xp","email_verified","daily_goal","email_assignments","email_mentions","email_deadlines"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID пользователя; генерирует API при регистрации или создании ИИ-сотрудника (тогда равен `ai_employees.user_id`).\n\n`uuid` · NOT NULL"},"email":{"type":"string","description":"Email в нижнем регистре, уникален. У ИИ-сотрудников — служебный `ai-<uuid>@metodox.invalid`.\n\n`text` · NOT NULL"},"name":{"type":"string","description":"Отображаемое имя, 2–80 символов; меняется в профиле, у ИИ — в настройках сотрудника.\n\n`text` · NOT NULL"},"password_hash":{"type":"string","description":"scrypt-хеш `s2$<соль hex>$<хеш hex>` (N=32768, r=8, p=3); старый формат `соль:хеш` переписывается при входе.\n\n`text` · NOT NULL"},"created_at":{"type":"string","format":"date-time","description":"Момент создания учётной записи.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"},"bio":{"type":"string","description":"Текст «о себе», до 1000 символов; задаёт пользователь в профиле.\n\n`text` · NOT NULL · по умолчанию `''::text`"},"job_title":{"type":"string","description":"Должность, до 100 символов; показывается в профиле и списках участников.\n\n`text` · NOT NULL · по умолчанию `''::text`"},"timezone":{"type":"string","description":"Часовой пояс: `UTC` или имя IANA вида `Область/Место` (`Europe/Moscow`, `Etc/GMT-3`); API проверяет формат, `Intl` и `AT TIME ZONE` PostgreSQL. Задаёт локальный день для сроков, наград, настроения и планировщика. Миграция 028 перевела сохранённые смещения в целый час (`+03:00`) в зоны `Etc/GMT∓N`, `+00:00` — в `UTC`.\n\n`text` · NOT NULL · по умолчанию `'Asia/Vladivostok'::text`"},"avatar_color":{"type":"string","description":"Цвет аватара в формате `#RRGGBB`; при регистрации выбирается случайно из 12 цветов палитры (та же палитра, что в миграции 023).\n\n`text` · NOT NULL · по умолчанию `'#cfb39a'::text`"},"notify_assignments":{"type":"boolean","description":"Создавать уведомления `assigned` о назначении исполнителем.\n\n`boolean` · NOT NULL · по умолчанию `true`"},"notify_comments":{"type":"boolean","description":"Создавать уведомления `comment`, `reply` и `mention`.\n\n`boolean` · NOT NULL · по умолчанию `true`"},"notify_deadlines":{"type":"boolean","description":"Создавать напоминания `due_soon` и `overdue` о сроках задач.\n\n`boolean` · NOT NULL · по умолчанию `true`"},"account_kind":{"type":"string","enum":["human","ai"],"description":"`human` — человек; `ai` — учётка ИИ-сотрудника: не входит паролем, не получает монеты и достижения.\n\n`text` · NOT NULL · по умолчанию `'human'::text`"},"public_profile":{"type":"boolean","description":"Профиль виден в поиске людей и доступен для заявок в друзья. Регистрация записывает `false` (публичность — по отдельному согласию, 152-ФЗ ст. 10.1), значение по умолчанию колонки `true` API не использует; у ИИ всегда `false`.\n\n`boolean` · NOT NULL · по умолчанию `true`"},"coin_balance":{"type":"integer","format":"int32","description":"Текущий баланс монет (CHECK ≥ 0); кэш суммы `coin_ledger`, меняется в той же транзакции.\n\n`integer` · NOT NULL · по умолчанию `0`"},"xp":{"type":"integer","format":"int32","description":"Опыт; уровень = floor(xp / 100) + 1. Начисляется +10 за задачу и `xp` достижения.\n\n`integer` · NOT NULL · по умолчанию `0`"},"active_cosmetic":{"type":["string","null"],"description":"Устаревшее поле одного оформления (id `cosmetic_catalog`, без FK); при надевании перезаписывается id предмета или NULL. Основной источник — `equipped_cosmetics`.\n\n`text` · NULL"},"email_verified":{"type":"boolean","description":"Email подтверждён по ссылке `verify` или сбросом пароля по ссылке `reset` (она тоже пришла на этот адрес). Нужен для Wiki-админа, а при включённом требовании — для организаций, приглашений и хранилища.\n\n`boolean` · NOT NULL · по умолчанию `false`"},"daily_goal":{"type":"integer","format":"int32","description":"Цель планировщика — сколько задач завершать в день, 1–20 (по умолчанию 3).\n\n`integer` · NOT NULL · по умолчанию `3`"},"consent_version":{"type":["string","null"],"description":"Версия принятых документов (`/terms`, `/privacy`, `/consent`), записывается при регистрации или `POST /profile/consent`; NULL — согласие не зафиксировано.\n\n`text` · NULL"},"consent_at":{"type":["string","null"],"format":"date-time","description":"Момент последнего согласия на обработку персональных данных (152-ФЗ).\n\n`timestamp with time zone` · NULL"},"email_assignments":{"type":"boolean","description":"Дублировать на email новые уведомления `assigned` (по умолчанию `true`). Письмо уходит только при `MAIL_ENABLED=true` и `email_verified`.\n\n`boolean` · NOT NULL · по умолчанию `true`"},"email_mentions":{"type":"boolean","description":"Дублировать на email новые уведомления `mention` и `reply` (по умолчанию `true`).\n\n`boolean` · NOT NULL · по умолчанию `true`"},"email_deadlines":{"type":"boolean","description":"Дублировать на email новые напоминания `due_soon` и `overdue` (по умолчанию `true`); созданные ночью уходят в 09:00 по `timezone`.\n\n`boolean` · NOT NULL · по умолчанию `true`"}},"x-db-table":"users"},"db.vault_configs":{"title":"Таблица vault_configs","type":"object","description":"Параметры личного зашифрованного хранилища (E2EE). Создаётся один раз; сервер хранит соль и проверочный конверт, но не мастер-пароль и не ключ. Смена мастер-пароля (`POST /vault/rekey`) в одной транзакции заменяет `salt`, `verifier` и конверты всех записей, перешифрованные браузером. Потеря мастер-пароля делает записи нечитаемыми.\n\n**Первичный ключ:** `user_id`\n\n**Внешние ключи:**\n- `user_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n\n**На таблицу ссылаются:** [db.vault_records](#модели/dbvault-records) (user_id)\n\n**Используется в коде:** `vault.ts`","required":["user_id","salt","verifier","created_at"],"properties":{"user_id":{"type":"string","format":"uuid","description":"Владелец хранилища.\n\n`uuid` · NOT NULL"},"salt":{"type":"string","description":"Соль PBKDF2 (16 случайных байт в base64), генерирует браузер.\n\n`text` · NOT NULL"},"verifier":{"description":"Проверочный конверт `{iv, ciphertext}` (AES-GCM-256 на ключе из мастер-пароля) для проверки введённого пароля.\n\n`jsonb` · NOT NULL"},"created_at":{"type":"string","format":"date-time","description":"Момент создания хранилища.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"}},"x-db-table":"vault_configs"},"db.vault_records":{"title":"Таблица vault_records","type":"object","description":"Записи личного хранилища. Содержимое шифрует браузер, сервер видит только тип и конверт; id генерирует клиент. Правка требует совпадения `version`.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `user_id` → [db.vault_configs](#модели/dbvault-configs) (user_id), при удалении: CASCADE\n\n**Индексы:**\n- `vault_records_user_idx`: `btree (user_id)`\n\n**Используется в коде:** `vault.ts`","required":["id","user_id","kind","envelope","version","updated_at"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID записи, генерирует клиент; API приводит его к нижнему регистру, потому что `id` входит в AAD шифрования на клиенте.\n\n`uuid` · NOT NULL"},"user_id":{"type":"string","format":"uuid","description":"Владелец; FK на `vault_configs` — без созданного хранилища запись невозможна.\n\n`uuid` · NOT NULL"},"kind":{"type":"string","enum":["password","account","card","contact","note"],"description":"Тип записи для интерфейса: `password`, `account`, `card`, `contact`, `note`.\n\n`text` · NOT NULL"},"envelope":{"description":"Клиентский конверт `{iv (12 байт), ciphertext}`: AES-GCM-256, ключ из мастер-пароля (PBKDF2-SHA256, 600 000 итераций).\n\n`jsonb` · NOT NULL"},"version":{"type":"integer","format":"int32","description":"Оптимистичная блокировка; +1 при каждой правке и при смене мастер-пароля.\n\n`integer` · NOT NULL · по умолчанию `1`"},"updated_at":{"type":"string","format":"date-time","description":"Момент последнего изменения.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"}},"x-db-table":"vault_records"},"db.wiki_files":{"title":"Таблица wiki_files","type":"object","description":"Медиа статьи Wiki; ссылки в `body` вида `/api/v1/wiki/files/<id>` должны указывать на файлы этой же статьи. Хранение `database`/`s3` — как у `attachments`; лимиты 100 МиБ на файл и 1 ГиБ на статью проверяет API (пустой файл — 400). Миграция 029 добавила CHECK размера `1…104 857 600` как `NOT VALID`: он проверяет только новые строки. Файлы публичной статьи отдаются без входа, только если она опубликована.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `page_id` → [db.wiki_pages](#модели/dbwiki-pages) (id), при удалении: CASCADE\n\n**Проверки:**\n- `CHECK (storage = 'database'::text AND content IS NOT NULL AND object_key IS NULL OR storage = 's3'::text AND content IS NULL AND object_key IS NOT NULL)`\n- `CHECK (size > 0 AND size <= 104857600) NOT VALID`\n\n**Триггеры:**\n- `wiki_file_cleanup`: AFTER DELETE ON wiki_files FOR EACH ROW EXECUTE FUNCTION queue_attachment_cleanup()\n\n**Используется в коде:** `admin.ts`, `storage-migrate.ts`, `wiki.ts`","required":["id","page_id","name","mime","size","storage","created_at"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID файла; входит в ключ S3-объекта и AAD шифрования.\n\n`uuid` · NOT NULL"},"page_id":{"type":"string","format":"uuid","description":"Статья.\n\n`uuid` · NOT NULL"},"name":{"type":"string","description":"Имя файла без управляющих символов, до 200 символов.\n\n`text` · NOT NULL"},"mime":{"type":"string","description":"MIME-тип, определённый сервером.\n\n`text` · NOT NULL"},"size":{"type":"integer","format":"int32","description":"Размер в байтах, 1–104 857 600 (100 МиБ): проверяет API и CHECK `wiki_files_size_check` (`NOT VALID`, только для строк после миграции 029).\n\n`integer` · NOT NULL"},"storage":{"type":"string","enum":["database","s3"],"description":"`database` — байты в `content`; `s3` — зашифрованный объект в бакете.\n\n`text` · NOT NULL"},"content":{"type":["string","null"],"format":"binary","description":"Байты при `storage='database'`; NULL при `s3`.\n\n`bytea` · NULL"},"object_key":{"type":["string","null"],"description":"Ключ `<S3_PREFIX>/attachments/<id>` при `storage='s3'`.\n\n`text` · NULL"},"created_at":{"type":"string","format":"date-time","description":"Момент загрузки.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"}},"x-db-table":"wiki_files"},"db.wiki_pages":{"title":"Таблица wiki_pages","type":"object","description":"Статьи Wiki: `workspace_id IS NULL` — публичная Wiki продукта, иначе внутренняя Wiki организации. `slug` уникален в пределах пространства (индекс по `COALESCE(workspace_id, нулевой UUID)`). Каждое сохранение пишет снимок в `wiki_versions`. Миграция 013 засеяла публичную статью `struktura-rabochego-prostranstva`.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `creator_id` → [db.users](#модели/dbusers) (id), при удалении: SET NULL\n- `updated_by` → [db.users](#модели/dbusers) (id), при удалении: SET NULL\n- `workspace_id` → [db.workspaces](#модели/dbworkspaces) (id), при удалении: CASCADE\n\n**На таблицу ссылаются:** [db.wiki_files](#модели/dbwiki-files) (page_id), [db.wiki_versions](#модели/dbwiki-versions) (page_id)\n\n**Индексы:**\n- `wiki_page_slug`: `UNIQUE btree (COALESCE(workspace_id, '00000000-0000-0000-0000-000000000000'::uuid), slug)`\n\n**Используется в коде:** `admin.ts`, `team-activity.ts`, `wiki.ts`","required":["id","slug","title","summary","body","status","version","created_at","updated_at"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID статьи.\n\n`uuid` · NOT NULL"},"workspace_id":{"type":["string","null"],"format":"uuid","description":"Организация; NULL — публичная Wiki продукта.\n\n`uuid` · NULL"},"slug":{"type":"string","description":"Адрес из `a-z0-9` через дефис, до 120 символов; уникален в пространстве.\n\n`text` · NOT NULL"},"title":{"type":"string","description":"Заголовок, 1–180 символов.\n\n`text` · NOT NULL"},"summary":{"type":"string","description":"Краткое описание, до 500 символов.\n\n`text` · NOT NULL · по умолчанию `''::text`"},"body":{"description":"Документ TipTap/ProseMirror в JSON (`{type: doc, content}`), очищенный сервером; медиа — только файлы этой статьи.\n\n`jsonb` · NOT NULL"},"status":{"type":"string","enum":["draft","published","archived"],"description":"`draft` — черновик; `published` — опубликована (публикует администратор пространства); `archived` — в архиве.\n\n`text` · NOT NULL · по умолчанию `'draft'::text`"},"creator_id":{"type":["string","null"],"format":"uuid","description":"Автор; в Wiki организации может править свою неопубликованную статью.\n\n`uuid` · NULL"},"updated_by":{"type":["string","null"],"format":"uuid","description":"Кто сохранил последнюю версию.\n\n`uuid` · NULL"},"version":{"type":"integer","format":"int32","description":"Оптимистичная блокировка; номер совпадает с последним снимком `wiki_versions`.\n\n`integer` · NOT NULL · по умолчанию `1`"},"created_at":{"type":"string","format":"date-time","description":"Момент создания.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"},"updated_at":{"type":"string","format":"date-time","description":"Момент последнего сохранения.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"}},"x-db-table":"wiki_pages"},"db.wiki_versions":{"title":"Таблица wiki_versions","type":"object","description":"Неизменяемые снимки статьи после создания и каждого сохранения; `version` совпадает с `wiki_pages.version` на момент снимка. API показывает последние 50.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `author_id` → [db.users](#модели/dbusers) (id), при удалении: SET NULL\n- `page_id` → [db.wiki_pages](#модели/dbwiki-pages) (id), при удалении: CASCADE\n\n**Уникальность:**\n- `UNIQUE (page_id, version)`\n\n**Используется в коде:** `wiki.ts`","required":["id","page_id","version","title","summary","body","status","created_at"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID снимка.\n\n`uuid` · NOT NULL"},"page_id":{"type":"string","format":"uuid","description":"Статья.\n\n`uuid` · NOT NULL"},"version":{"type":"integer","format":"int32","description":"Номер версии статьи (уникален в пределах статьи).\n\n`integer` · NOT NULL"},"title":{"type":"string","description":"Заголовок на момент снимка.\n\n`text` · NOT NULL"},"summary":{"type":"string","description":"Краткое описание на момент снимка.\n\n`text` · NOT NULL"},"body":{"description":"JSON-документ на момент снимка.\n\n`jsonb` · NOT NULL"},"status":{"type":"string","description":"Статус на момент снимка: `draft`, `published` или `archived` (без CHECK).\n\n`text` · NOT NULL"},"author_id":{"type":["string","null"],"format":"uuid","description":"Кто сохранил версию; NULL для засеянных миграцией.\n\n`uuid` · NULL"},"created_at":{"type":"string","format":"date-time","description":"Момент снимка.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"}},"x-db-table":"wiki_versions"},"db.workspace_events":{"title":"Таблица workspace_events","type":"object","description":"Единая лента активности TEAM и истории доски. Пишется `teamEvent()` (`team-audit.ts`) и триггером-зеркалом из `task_events`. `board_id`, `task_id` и `entity_id` без FK, поэтому история остаётся после удаления объектов; видимость строк фильтрует API по текущим правам. Вставка вызывает realtime-уведомление.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `actor_id` → [db.users](#модели/dbusers) (id), при удалении: SET NULL\n- `workspace_id` → [db.workspaces](#модели/dbworkspaces) (id), при удалении: CASCADE\n\n**Индексы:**\n- `workspace_events_board`: `btree (board_id, created_at DESC, id)`\n- `workspace_events_created`: `btree (created_at)`\n- `workspace_events_space`: `btree (workspace_id, created_at DESC, id)`\n\n**Триггеры:**\n- `realtime_workspace_events`: AFTER INSERT ON workspace_events FOR EACH ROW EXECUTE FUNCTION metodox_realtime_event()\n\n**Используется в коде:** `admin.ts`, `realtime.ts`, `team-activity.ts`, `team-audit.ts`","required":["id","scope","action","title","details","created_at"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID события (для зеркальных строк совпадает с `task_events.id`).\n\n`uuid` · NOT NULL"},"workspace_id":{"type":["string","null"],"format":"uuid","description":"Организация; NULL — событие личной доски или публичной Wiki.\n\n`uuid` · NULL"},"board_id":{"type":["string","null"],"format":"uuid","description":"Доска события (без FK).\n\n`uuid` · NULL"},"task_id":{"type":["string","null"],"format":"uuid","description":"Задача события (без FK); после удаления задачи запись сохраняется.\n\n`uuid` · NULL"},"entity_id":{"type":["string","null"],"format":"uuid","description":"Id объекта для scope `backlog`, `sprint`, `project`, `wiki` (без FK).\n\n`uuid` · NULL"},"actor_id":{"type":["string","null"],"format":"uuid","description":"Кто выполнил действие.\n\n`uuid` · NULL"},"scope":{"type":"string","enum":["team","admin","board","task","backlog","sprint","project","wiki"],"description":"`team` — организация; `admin` — приглашения и роли (видят админы); `board`; `task`; `backlog`; `sprint`; `project`; `wiki`.\n\n`text` · NOT NULL"},"action":{"type":"string","description":"Код вида `board.created`, `member.role_changed`, `sprint.completed`, `wiki.published`; для зеркала — `task.<action>`.\n\n`text` · NOT NULL"},"title":{"type":"string","description":"Снимок названия объекта на момент события.\n\n`text` · NOT NULL · по умолчанию `''::text`"},"board_title":{"type":["string","null"],"description":"Снимок названия доски на момент события: заполняют зеркалирование `task_events` и `teamEvent()` (явное значение или название из строки доски, поэтому событие удаления доски пишется до удаления). Показывается, если доска удалена.\n\n`text` · NULL"},"details":{"description":"JSON-подробности, например email и роль приглашения или старая и новая роль участника.\n\n`jsonb` · NOT NULL · по умолчанию `'{}'::jsonb`"},"created_at":{"type":"string","format":"date-time","description":"Момент события; вместе с `id` образует курсор пагинации.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"}},"x-db-table":"workspace_events"},"db.workspace_members":{"title":"Таблица workspace_members","type":"object","description":"Членство и роль пользователя (человека или ИИ-сотрудника) в организации. Роль `owner` нельзя сменить или снять, свою роль менять нельзя, назначать и снимать администраторов может только владелец.\n\n**Первичный ключ:** `workspace_id`, `user_id`\n\n**Внешние ключи:**\n- `user_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n- `workspace_id` → [db.workspaces](#модели/dbworkspaces) (id), при удалении: CASCADE\n\n**Индексы:**\n- `workspace_members_user_idx`: `btree (user_id)`\n\n**Триггеры:**\n- `realtime_members`: AFTER INSERT OR DELETE OR UPDATE ON workspace_members FOR EACH ROW EXECUTE FUNCTION metodox_realtime_event()\n\n**Используется в коде:** `access.ts`, `admin.ts`, `agent-workflows.ts`, `ai.ts`, `boards.ts`, `discussion.ts`, `mail.ts`, `motivation.ts`, `notifications.ts`, `planner.ts`, `profile.ts`, `realtime.ts`, `tasks.ts`, `team.ts`, `workspaces.ts`","required":["workspace_id","user_id","role"],"properties":{"workspace_id":{"type":"string","format":"uuid","description":"Организация.\n\n`uuid` · NOT NULL"},"user_id":{"type":"string","format":"uuid","description":"Участник: человек или ИИ-сотрудник.\n\n`uuid` · NOT NULL"},"role":{"type":"string","enum":["owner","admin","member","guest"],"description":"`owner` — владелец; `admin` — управляет организацией и всеми её досками; `member` — видит доски `workspace`; `guest` — только выданные доски.\n\n`text` · NOT NULL"}},"x-db-table":"workspace_members"},"db.workspace_projects":{"title":"Таблица workspace_projects","type":"object","description":"Проект организации — группа досок, записей бэклога и спринтов. Архивирование (`archived_at`) не удаляет доски, но к архивному проекту нельзя привязывать новые доски, этапы и записи. `version` увеличивается и при изменении этапов.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `workspace_id` → [db.workspaces](#модели/dbworkspaces) (id), при удалении: CASCADE\n\n**На таблицу ссылаются:** [db.boards](#модели/dbboards) (project_id, workspace_id), [db.project_phases](#модели/dbproject-phases) (project_id), [db.team_backlog](#модели/dbteam-backlog) (project_id, workspace_id), [db.team_sprints](#модели/dbteam-sprints) (project_id, workspace_id)\n\n**Уникальность:**\n- `UNIQUE (id, workspace_id)`\n\n**Проверки:**\n- `CHECK (color ~ '^#[0-9a-f]{6}$'::text)`\n\n**Используется в коде:** `access.ts`, `boards.ts`, `planner.ts`, `projects.ts`, `team.ts`","required":["id","workspace_id","title","description","status","version","created_at"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID проекта; `UNIQUE(id, workspace_id)` нужен для составных FK.\n\n`uuid` · NOT NULL"},"workspace_id":{"type":"string","format":"uuid","description":"Организация проекта.\n\n`uuid` · NOT NULL"},"title":{"type":"string","description":"Название, 1–100 символов.\n\n`text` · NOT NULL"},"description":{"type":"string","description":"Описание, до 2000 символов.\n\n`text` · NOT NULL · по умолчанию `''::text`"},"status":{"type":"string","enum":["active","paused","completed"],"description":"`active` — в работе; `paused` — приостановлен; `completed` — завершён.\n\n`text` · NOT NULL · по умолчанию `'active'::text`"},"archived_at":{"type":["string","null"],"format":"date-time","description":"Момент архивирования; NULL — проект активен.\n\n`timestamp with time zone` · NULL"},"version":{"type":"integer","format":"int32","description":"Оптимистичная блокировка проекта и его этапов.\n\n`integer` · NOT NULL · по умолчанию `1`"},"created_at":{"type":"string","format":"date-time","description":"Момент создания.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"},"color":{"type":["string","null"],"description":"Акцентный цвет проекта `#rrggbb`; NULL — стандартный. Наследуется этапами и досками без своего цвета.\n\n`text` · NULL"}},"x-db-table":"workspace_projects"},"db.workspace_secrets":{"title":"Таблица workspace_secrets","type":"object","description":"Хранилище доступов организации (отдельно от личного E2EE). Читать, создавать, показывать и удалять может только владелец организации; открыто хранятся лишь название и тип, всё остальное — в `cipher`.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `created_by` → [db.users](#модели/dbusers) (id), при удалении: SET NULL\n- `workspace_id` → [db.workspaces](#модели/dbworkspaces) (id), при удалении: CASCADE\n\n**На таблицу ссылаются:** [db.agent_steps](#модели/dbagent-steps) (secret_id), [db.secret_audit](#модели/dbsecret-audit) (secret_id), [db.secret_grants](#модели/dbsecret-grants) (secret_id)\n\n**Триггеры:**\n- `workspace_secret_version`: BEFORE UPDATE ON workspace_secrets FOR EACH ROW EXECUTE FUNCTION workspace_secret_version()\n\n**Используется в коде:** `agent-secrets.ts`, `agent-workflows.ts`","required":["id","workspace_id","title","kind","cipher","version","created_at"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID секрета; входит в AAD шифрования.\n\n`uuid` · NOT NULL"},"workspace_id":{"type":"string","format":"uuid","description":"Организация-владелец секрета.\n\n`uuid` · NOT NULL"},"title":{"type":"string","description":"Название, 1–100 символов (не шифруется).\n\n`text` · NOT NULL"},"kind":{"type":"string","enum":["ssh","password","account"],"description":"`ssh` — SSH-доступ (IP, пользователь, приватный ключ, fingerprint, операции); `password` — пароль; `account` — учётная запись.\n\n`text` · NOT NULL"},"cipher":{"type":"string","format":"binary","description":"Конверт MDX1 (`WORKSPACE_SECRET_KEY`, AAD `workspace:<ws>:secret:<id>`) с JSON значения, хоста, порта, пользователя, fingerprint и операций.\n\n`bytea` · NOT NULL"},"created_by":{"type":["string","null"],"format":"uuid","description":"Кто создал секрет.\n\n`uuid` · NULL"},"version":{"type":"integer","format":"int32","description":"Версия секрета, с 1; снимок в `agent_steps.secret_version` сверяется перед SSH-операцией. Растёт на 1 при изменении самого секрета (`cipher`, `title` или `kind` — триггер `workspace_secret_version`, миграция 030) и при каждой фактической выдаче или отзыве доступа ИИ-сотруднику (`agent-secrets.ts`). Поэтому шаг, одобренный со старой версией, не выполнится.\n\n`integer` · NOT NULL · по умолчанию `1`"},"created_at":{"type":"string","format":"date-time","description":"Момент создания.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"}},"x-db-table":"workspace_secrets"},"db.workspaces":{"title":"Таблица workspaces","type":"object","description":"Организация (команда). Создатель записывается в `owner_id` и одновременно получает членство `owner`; создание требует подтверждённого email (если требование включено). Удаление строки каскадно удаляет доски, участников, проекты, Wiki, секреты и ИИ-сотрудников организации.\n\n**Первичный ключ:** `id`\n\n**Внешние ключи:**\n- `owner_id` → [db.users](#модели/dbusers) (id), при удалении: CASCADE\n\n**На таблицу ссылаются:** [db.agent_workflows](#модели/dbagent-workflows) (workspace_id), [db.ai_employees](#модели/dbai-employees) (workspace_id), [db.boards](#модели/dbboards) (workspace_id), [db.invitations](#модели/dbinvitations) (workspace_id), [db.secret_audit](#модели/dbsecret-audit) (workspace_id), [db.team_backlog](#модели/dbteam-backlog) (workspace_id), [db.team_sprints](#модели/dbteam-sprints) (workspace_id), [db.wiki_pages](#модели/dbwiki-pages) (workspace_id), [db.workspace_events](#модели/dbworkspace-events) (workspace_id), [db.workspace_members](#модели/dbworkspace-members) (workspace_id), [db.workspace_projects](#модели/dbworkspace-projects) (workspace_id), [db.workspace_secrets](#модели/dbworkspace-secrets) (workspace_id)\n\n**Используется в коде:** `access.ts`, `admin.ts`, `ai.ts`, `main.ts`, `profile.ts`, `realtime.ts`, `team.ts`, `workspaces.ts`","required":["id","name","description","owner_id","created_at"],"properties":{"id":{"type":"string","format":"uuid","description":"UUID организации.\n\n`uuid` · NOT NULL"},"name":{"type":"string","description":"Название, 2–80 символов.\n\n`text` · NOT NULL"},"description":{"type":"string","description":"Описание, до 500 символов.\n\n`text` · NOT NULL · по умолчанию `''::text`"},"owner_id":{"type":"string","format":"uuid","description":"Создатель организации; его роль `owner` закреплена в `workspace_members`.\n\n`uuid` · NOT NULL"},"created_at":{"type":"string","format":"date-time","description":"Момент создания.\n\n`timestamp with time zone` · NOT NULL · по умолчанию `now()`"}},"x-db-table":"workspaces"}},"responses":{"BadRequest":{"description":"Тело, query или параметр пути не прошли проверку.","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/Error"}]}}}},"Unauthorized":{"description":"Нет сессии или access-токен истёк — обновите токен через `/auth/refresh`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":401,"message":"Сессия истекла","error":"Unauthorized"}}}},"Forbidden":{"description":"Проверка `Origin` (CSRF) отклонила изменяющий запрос:\n- «Для изменений с входом по cookie нужен заголовок Origin» — запрос несёт cookie сессии, в нём нет `Origin` и нет заголовка `Authorization: Bearer …`;\n- «Запросы с этого адреса (Origin) не разрешены» — `Origin` передан и не входит в `WEB_ORIGIN` (так отклоняется и запрос с Bearer-токеном).\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"originRequired":{"summary":"Cookie без Origin","value":{"statusCode":403,"message":"Для изменений с входом по cookie нужен заголовок Origin","error":"Forbidden"}},"originNotAllowed":{"summary":"Чужой Origin","value":{"statusCode":403,"message":"Запросы с этого адреса (Origin) не разрешены","error":"Forbidden"}}}}}},"NotFound":{"description":"Ресурс не существует или недоступен текущему пользователю (чужие объекты не раскрываются).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Задача не найдена","error":"Not Found"}}}},"Conflict":{"description":"Конфликт состояния, чаще всего устаревший `version` — перечитайте объект и повторите.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":409,"message":"Настройки изменились. Обновите страницу.","error":"Conflict"}}}},"PayloadTooLarge":{"description":"Превышен лимит размера файла или контейнера.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"TooManyRequests":{"description":"Превышен лимит запросов (общий — 120 в минуту на маршрут для сессии, без сессии — для IP; у отдельных маршрутов строже). Заголовок `Retry-After` — секунды до сброса.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":429,"message":"ThrottlerException: Too Many Requests"}}}},"ServiceUnavailable":{"description":"Внешний сервис (почта, S3, брокер, ИИ-провайдер) недоступен или вернул ошибку.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"paths":{"/api/v1/auth/register":{"post":{"operationId":"authRegister","tags":["Аутентификация"],"summary":"Зарегистрироваться и открыть сессию","description":"Создаёт аккаунт человека с `emailVerified = false` и сразу выдаёт пару токенов, как при входе.\n\n- `email` приводится к нижнему регистру и должен быть уникальным, иначе 409.\n- `name` обязателен: 2–80 символов после обрезки пробелов.\n- Пароль 10–128 символов. Сохраняется хеш scrypt `s2$…`.\n- `client: \"web\"` (по умолчанию): токены приходят в двух заголовках `Set-Cookie`, тело — `{ user, expiresIn }`. `client: \"desktop\"`: токены в теле, cookie не ставятся.\n- `acceptTerms: true` обязателен: согласие на обработку персональных данных (152-ФЗ) фиксируется в `users.consent_version` и `users.consent_at`.\n- Профиль создаётся скрытым (`publicProfile: false`): публичность требует отдельного согласия и включается в профиле. Цвет аватара выбирается случайно из 12 цветов палитры.\n- Неизвестные поля тела отбрасываются.\n\n**Доступ:** публичный.\n\n**Побочные эффекты:** в одной транзакции создаются строка в [users](#модели/dbusers) с версией согласия и учебная личная доска «Первые шаги в Metodox» — 3 колонки («Нужно сделать», «В работе», «Готово») и 5 карточек-подсказок ([boards](#модели/dbboards), [columns](#модели/dbcolumns), [tasks](#модели/dbtasks)). Затем открывается сессия в [sessions](#модели/dbsessions). При `MAIL_ENABLED=true` создаются токен подтверждения на 24 часа в [auth_tokens](#модели/dbauth-tokens) и письмо в [mail_outbox](#модели/dbmail-outbox).\n\nПисьмо и сессия создаются после транзакции. Если на этих шагах случится ошибка 500, аккаунт уже существует: повторная регистрация вернёт 409, нужно войти.\n\n**Лимит:** 15 запросов за 60 с на сессию (без сессии — с одного IP).","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthRegisterRequest"},"examples":{"web":{"summary":"Веб-клиент (cookie)","value":{"email":"anna@example.com","password":"correct-horse-42","name":"Анна Смирнова","acceptTerms":true}},"native":{"summary":"Нативный клиент (токены в теле)","value":{"email":"anna@example.com","password":"correct-horse-42","name":"Анна Смирнова","acceptTerms":true,"client":"desktop"}}}}}},"responses":{"201":{"description":"Аккаунт создан, сессия открыта. Состав тела зависит от `client`.\n","headers":{"Set-Cookie":{"description":"Только для `client: \"web\"`: два заголовка — access-cookie (`Max-Age=900`) и refresh-cookie (`Max-Age=2592000`). Атрибуты: `HttpOnly`, `SameSite=Lax`, `Path` и `Secure` по окружению.\n","schema":{"type":"string"},"example":"__Host-md_access=EXAMPLE_access_token_base64url_43_chars_aaa; Max-Age=900; Path=/; Expires=Fri, 02 Oct 2026 08:15:00 GMT; HttpOnly; Secure; SameSite=Lax"}},"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/AuthWebLoginResponse"},{"$ref":"#/components/schemas/AuthNativeLoginResponse"}]},"examples":{"web":{"summary":"client = web","value":{"user":{"id":"9480fab5-024c-464c-8fa7-8be874db60ca","email":"anna@example.com","name":"Анна Смирнова"},"expiresIn":900}},"native":{"summary":"client = desktop","value":{"user":{"id":"9480fab5-024c-464c-8fa7-8be874db60ca","email":"anna@example.com","name":"Анна Смирнова"},"accessToken":"EXAMPLE_access_token_base64url_43_chars_aaa","refreshToken":"EXAMPLE_refresh_token_base64url_43_chars_bb","expiresIn":900,"tokenType":"Bearer"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"description":"Email уже зарегистрирован.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Этот email уже зарегистрирован","error":"Conflict","statusCode":409}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}},"security":[],"x-ratelimit":{"limit":15,"window":"60s"}}},"/api/v1/auth/login":{"post":{"operationId":"authLogin","tags":["Аутентификация"],"summary":"Войти по email и паролю","description":"Проверяет email и пароль и открывает новую сессию. Уже открытые сессии пользователя не закрываются.\n\n- Email сравнивается после приведения к нижнему регистру.\n- Для несуществующего email пароль проверяется против фиктивного хеша: время ответа одинаковое. Неверный пароль, неизвестный email и аккаунт ИИ-сотрудника (`account_kind = 'ai'`) дают один ответ — 401 «Неверный email или пароль».\n- **Обновление хеша.** Если пароль хранится в старом формате `соль:хеш`, после успешной проверки он перехешируется в `s2$соль$хеш`. Это происходит в той же транзакции, что и создание сессии.\n- Если хеш пароля сменился между проверкой и созданием сессии (параллельная смена или сброс пароля), вход отклоняется: 401 «Войдите заново».\n- Поле `name` принимается и проверяется, но не используется.\n\n**Доступ:** публичный.\n\n**Побочные эффекты:** новая строка в [sessions](#модели/dbsessions). При старом формате хеша обновляется `users.password_hash`.\n\n**Лимит:** 15 запросов за 60 с на сессию (без сессии — с одного IP).","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthLoginRequest"},"examples":{"web":{"summary":"Веб-клиент (cookie)","value":{"email":"anna@example.com","password":"correct-horse-42"}},"native":{"summary":"Нативный клиент (токены в теле)","value":{"email":"anna@example.com","password":"correct-horse-42","client":"desktop"}}}}}},"responses":{"201":{"description":"Сессия открыта. Состав тела зависит от `client`.","headers":{"Set-Cookie":{"description":"Только для `client: \"web\"`: access- и refresh-cookie, как при регистрации.","schema":{"type":"string"},"example":"__Host-md_refresh=EXAMPLE_refresh_token_base64url_43_chars_bb; Max-Age=2592000; Path=/; Expires=Sun, 01 Nov 2026 08:00:00 GMT; HttpOnly; Secure; SameSite=Lax"}},"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/AuthWebLoginResponse"},{"$ref":"#/components/schemas/AuthNativeLoginResponse"}]},"examples":{"web":{"summary":"client = web","value":{"user":{"id":"9480fab5-024c-464c-8fa7-8be874db60ca","email":"anna@example.com","name":"Анна Смирнова"},"expiresIn":900}},"native":{"summary":"client = desktop","value":{"user":{"id":"9480fab5-024c-464c-8fa7-8be874db60ca","email":"anna@example.com","name":"Анна Смирнова"},"accessToken":"EXAMPLE_access_token_base64url_43_chars_aaa","refreshToken":"EXAMPLE_refresh_token_base64url_43_chars_bb","expiresIn":900,"tokenType":"Bearer"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"- «Неверный email или пароль»: нет аккаунта, пароль неверен или это аккаунт ИИ-сотрудника.\n- «Войдите заново»: пароль сменился во время входа.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"invalid":{"summary":"Неверные данные","value":{"message":"Неверный email или пароль","error":"Unauthorized","statusCode":401}},"changed":{"summary":"Пароль сменился во время входа","value":{"message":"Войдите заново","error":"Unauthorized","statusCode":401}}}}}},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}},"security":[],"x-ratelimit":{"limit":15,"window":"60s"}}},"/api/v1/auth/refresh":{"post":{"operationId":"authRefresh","tags":["Аутентификация"],"summary":"Обменять refresh-токен на новую пару","description":"Ротация токенов: старая сессия удаляется, создаётся новая.\n\n- `client: \"web\"` (по умолчанию): refresh-токен берётся из cookie, поле `refreshToken` игнорируется. Запрос несёт cookie, поэтому нужен заголовок `Origin` из `WEB_ORIGIN`.\n- `client: \"desktop\"`: refresh-токен берётся только из поля `refreshToken`, cookie не читаются. В заголовках refresh-токен не принимается.\n- Удаление и создание сессии идут в одной транзакции. Новая сессия получает новые сроки: access 15 минут, refresh 30 дней. Старые access- и refresh-токены перестают работать сразу, `id` сессии меняется.\n- SHA-256 использованного refresh-токена хранится 30 дней в [rotated_refresh_tokens](#модели/dbrotated-refresh-tokens).\n- **Повторный обмен** уже использованного токена в течение 30 секунд после ротации — обычный 401 `Unauthorized`: это параллельное обновление (две вкладки, realtime-клиент). Позже — признак кражи: сервер закрывает **все** сессии пользователя, удаляет его сохранённые хеши ротаций, для `web` очищает cookie и отвечает 401 «Сессия завершена из соображений безопасности. Войдите снова.».\n- Из параллельных запросов с одним токеном успешен только один, остальные получают 401. Выполняйте обновление в одном потоке и сохраняйте новую пару сразу: повтор старого токена через 30 секунд разлогинит пользователя на всех устройствах.\n- В ответе нет `user`; при необходимости вызовите `GET /api/v1/auth/me`.\n- Тело можно не передавать: тогда действует `client: \"web\"`.\n\n**Доступ:** публичный. Аутентификацией служит сам refresh-токен.\n\n**Побочные эффекты:** удаление и вставка строки в [sessions](#модели/dbsessions), вставка в [rotated_refresh_tokens](#модели/dbrotated-refresh-tokens). При обнаруженном повторе — удаление всех сессий и хешей ротаций пользователя и запись в журнал сервера. Realtime-подключение на старом токене разрывается при ближайшей проверке сессии (раз в 30 секунд) с событием `session_expired`.\n\n**Лимит:** 15 запросов за 60 с на сессию (без сессии — с одного IP).","requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthRefreshRequest"},"examples":{"native":{"summary":"Нативный клиент","value":{"client":"desktop","refreshToken":"EXAMPLE_refresh_token_base64url_43_chars_bb"}},"web":{"summary":"Веб-клиент (токен в cookie)","value":{}}}}}},"responses":{"201":{"description":"Новая пара выдана. Для `web` токены приходят в `Set-Cookie`, тело — `{ expiresIn }`.","headers":{"Set-Cookie":{"description":"Только для `client: \"web\"`: новые access- и refresh-cookie.","schema":{"type":"string"},"example":"__Host-md_access=EXAMPLE_access_token_rotated_43_chars_ccccc; Max-Age=900; Path=/; Expires=Fri, 02 Oct 2026 08:30:00 GMT; HttpOnly; Secure; SameSite=Lax"}},"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/AuthWebTokens"},{"$ref":"#/components/schemas/AuthNativeTokens"}]},"examples":{"web":{"summary":"client = web","value":{"expiresIn":900}},"native":{"summary":"client = desktop","value":{"accessToken":"EXAMPLE_access_token_rotated_43_chars_ccccc","refreshToken":"EXAMPLE_refresh_token_rotated_43_chars_dddd","expiresIn":900,"tokenType":"Bearer"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"- `{ \"message\": \"Unauthorized\", \"statusCode\": 401 }`: refresh-токена нет, он длиннее 200 символов, не найден (отозван, истёк) или уже обменян меньше 30 секунд назад.\n- «Сессия истекла»: токен успели обменять параллельно, между проверкой и ротацией.\n- «Сессия завершена из соображений безопасности. Войдите снова.»: токен уже обменяли больше 30 секунд назад (и меньше 30 дней). Все сессии пользователя закрыты; для `web` в ответе есть `Set-Cookie`, очищающие обе cookie.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"missing":{"summary":"Токен не найден, истёк или только что обменян","value":{"message":"Unauthorized","statusCode":401}},"race":{"summary":"Параллельная ротация","value":{"message":"Сессия истекла","error":"Unauthorized","statusCode":401}},"reuse":{"summary":"Повтор обменянного токена после 30 секунд","value":{"message":"Сессия завершена из соображений безопасности. Войдите снова.","error":"Unauthorized","statusCode":401}}}}}},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}},"security":[],"x-ratelimit":{"limit":15,"window":"60s"}}},"/api/v1/auth/me":{"get":{"operationId":"authMe","tags":["Аутентификация"],"summary":"Текущий пользователь","description":"Возвращает пользователя текущей сессии. Токен берётся из `Authorization: Bearer …`, если заголовок есть, иначе из access-cookie.\n\nСессия действительна, пока не истекли и access-, и refresh-срок. Ответы 401: «Войдите в аккаунт», если токена нет; «Сессия истекла», если токен не найден или истёк.\n\nКлиенты вызывают маршрут на каждом экране, поэтому лимит контроллера `auth` (15 в минуту) к нему не применяется: действует общий — 120 запросов в минуту на сессию. Опрашивать его по таймеру всё равно не нужно: запрашивайте при старте, после обновления токенов и для проверки сессии после `session_expired` в realtime.\n\n**Доступ:** нужна сессия.\n","responses":{"200":{"description":"Пользователь сессии.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthCurrentUser"},"example":{"id":"9480fab5-024c-464c-8fa7-8be874db60ca","name":"Анна Смирнова","email":"anna@example.com","emailVerified":true,"consentRequired":false}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/auth/logout":{"post":{"operationId":"authLogout","tags":["Аутентификация"],"summary":"Выйти и очистить cookie","description":"Закрывает сессию запроса и очищает cookie.\n\n- Удаляются сессии, у которых совпадает access-токен (из `Authorization: Bearer` или cookie) или refresh-токен из cookie. Срок не проверяется: просроченный access-токен тоже закрывает свою сессию, если она ещё существует.\n- Refresh-токен в теле не принимается. Нативный клиент передаёт access-токен в заголовке.\n- Обе cookie очищаются в любом режиме: `Set-Cookie` с истёкшей датой и теми же `Path`, `Secure`, `HttpOnly`, `SameSite`.\n- Ответ всегда `{ \"ok\": true }`, даже без токенов. **401 этот метод не возвращает.**\n- Веб-запрос с cookie должен нести `Origin`.\n\nОстальные сессии пользователя остаются активными. Закрыть их можно через `DELETE /api/v1/profile/sessions/{id}` или сменой пароля.\n\n**Побочные эффекты:** удаление строки из [sessions](#модели/dbsessions).\n\n**Лимит:** 15 запросов за 60 с на сессию (без сессии — с одного IP).","responses":{"201":{"description":"Сессия закрыта (или её не было), cookie очищены.","headers":{"Set-Cookie":{"description":"Два заголовка, очищающие access- и refresh-cookie.","schema":{"type":"string"},"example":"__Host-md_access=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}},"security":[],"x-ratelimit":{"limit":15,"window":"60s"}}},"/api/v1/account/status":{"get":{"operationId":"accountStatus","tags":["Почта и восстановление"],"summary":"Статус подтверждения email и почты","description":"Показывает, подтверждён ли email, и включена ли на сервере отправка писем. При `mailEnabled: false` клиенту стоит скрыть повторную отправку письма: `request-verification` всё равно ответит 400.\n\n**Доступ:** нужна сессия (cookie или Bearer). Без сессии или после её истечения — 401 «Войдите в аккаунт».\n\nЭкраны настроек читают статус при каждом открытии, поэтому лимит контроллера `account` (5 в минуту) к маршруту не применяется: действует общий — 120 запросов в минуту на сессию.\n","responses":{"200":{"description":"Состояние аккаунта.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountStatus"},"example":{"emailVerified":false,"mailEnabled":true}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/account/request-verification":{"post":{"operationId":"accountRequestVerification","tags":["Почта и восстановление"],"summary":"Отправить письмо подтверждения ещё раз","description":"Ставит в очередь новое письмо со ссылкой `{APP_URL}/account/verify#token=…`. Ссылка действует 24 часа.\n\n- Предыдущая ссылка подтверждения перестаёт действовать, неотправленные письма подтверждения отменяются.\n- Если email уже подтверждён, ничего не происходит, но ответ всё равно `{ \"ok\": true }`.\n- При `MAIL_ENABLED` ≠ `true` — 400.\n\nТело не нужно.\n\n**Доступ:** нужна сессия.\n\n**Побочные эффекты:** [auth_tokens](#модели/dbauth-tokens), [mail_outbox](#модели/dbmail-outbox). Письмо отправит обработчик очереди в течение примерно 15 секунд.\n\n**Лимит:** 5 запросов за 60 с на сессию (без сессии — с одного IP).","responses":{"201":{"description":"Письмо поставлено в очередь (или email уже подтверждён).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"description":"Отправка писем на сервере выключена.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Почта будет подключена при развёртывании","error":"Bad Request","statusCode":400}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}},"x-ratelimit":{"limit":5,"window":"60s"}}},"/api/v1/account/forgot-password":{"post":{"operationId":"accountForgotPassword","tags":["Почта и восстановление"],"summary":"Запросить письмо для сброса пароля","description":"Запрашивает письмо со ссылкой сброса пароля.\n\nОтвет всегда одинаковый (201 и один текст): есть аккаунт или нет, включена почта или нет. Так API не раскрывает, какие адреса зарегистрированы.\n\n**Асинхронно.** Сервер проверяет тело и сразу отвечает; поиск аккаунта и постановка письма в очередь выполняются уже после ответа. Поэтому время ответа одинаково для любого адреса, а сбой на этом шаге клиенту не виден (он только пишется в журнал сервера).\n\nПисьмо уходит, только если почта включена и с этим email есть аккаунт человека (`account_kind = 'human'`). Тогда старый токен сброса удаляется, неотправленные письма сброса отменяются, создаётся новый токен на 30 минут. Ссылка в письме: `{APP_URL}/account/reset#token=…`.\n\n**Доступ:** публичный.\n\n**Побочные эффекты:** после ответа, при совпадении — [auth_tokens](#модели/dbauth-tokens) и [mail_outbox](#модели/dbmail-outbox).\n\n**Лимит:** 5 запросов за 60 с на сессию (без сессии — с одного IP).","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountForgotPasswordRequest"},"example":{"email":"anna@example.com"}}}},"responses":{"201":{"description":"Запрос принят. Текст не зависит от существования аккаунта.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountForgotPasswordResponse"},"example":{"message":"Если аккаунт существует и почта подключена, письмо с инструкциями будет отправлено."}}}},"400":{"$ref":"#/components/responses/BadRequest"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}},"security":[],"x-ratelimit":{"limit":5,"window":"60s"}}},"/api/v1/account/verify":{"post":{"operationId":"accountVerify","tags":["Почта и восстановление"],"summary":"Подтвердить email по токену из письма","description":"Подтверждает email по токену из ссылки. Сессия не нужна и не создаётся.\n\nТокен передаётся в теле. В письме он стоит во фрагменте URL (`#token=…`), поэтому браузер не отправляет его серверу при открытии страницы. Веб-страница `/account/verify` читает фрагмент, убирает его из адресной строки и вызывает этот метод.\n\n- Токен одноразовый: после успеха он удаляется.\n- Срок — 24 часа с момента выдачи. Действует только последняя выданная ссылка.\n\n**Доступ:** публичный.\n\n**Побочные эффекты:** `users.email_verified = true`, токен удаляется из [auth_tokens](#модели/dbauth-tokens).\n\n**Лимит:** 5 запросов за 60 с на сессию (без сессии — с одного IP).","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountVerifyRequest"},"example":{"token":"EXAMPLE_verify_token_from_email_link_000000"}}}},"responses":{"201":{"description":"Email подтверждён.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"description":"- Тело не прошло проверку: формат `ValidationError`.\n- «Ссылка недействительна или истекла»: токен не найден, уже использован, заменён более новым или истёк.\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/Error"}]},"examples":{"invalidLink":{"summary":"Токен недействителен","value":{"message":"Ссылка недействительна или истекла","error":"Bad Request","statusCode":400}},"validation":{"summary":"Токен короче 32 символов","value":{"message":"Проверьте поля формы","errors":{"formErrors":[],"fieldErrors":{"token":["Too small: expected string to have >=32 characters"]}}}}}}}},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}},"security":[],"x-ratelimit":{"limit":5,"window":"60s"}}},"/api/v1/account/reset":{"post":{"operationId":"accountReset","tags":["Почта и восстановление"],"summary":"Задать новый пароль по токену из письма","description":"Задаёт новый пароль по токену из ссылки и закрывает **все** сессии пользователя на всех устройствах. Новая сессия не создаётся: после ответа нужно войти заново.\n\n- Токен одноразовый, действует 30 минут. Работает только последний выданный.\n- Пароль 10–128 символов, сохраняется хеш scrypt `s2$…`.\n- Если два запроса пришли с одним токеном одновременно, второй получит 400 «Ссылка уже использована».\n- **Подтверждает email.** Ссылка пришла на адрес аккаунта, поэтому `emailVerified` становится `true`. Все остальные ссылки пользователя (и сброса, и подтверждения) перестают действовать, неотправленные письма этих типов отменяются.\n\n**Доступ:** публичный.\n\n**Побочные эффекты:** в одной транзакции — `users.password_hash` и `users.email_verified = true`, удаление всех строк пользователя в [auth_tokens](#модели/dbauth-tokens) и [sessions](#модели/dbsessions), отмена писем `verify` и `reset` со статусом `pending` в [mail_outbox](#модели/dbmail-outbox) (содержимое стирается). Realtime-подключения пользователя разрываются при ближайшей проверке сессии (раз в 30 секунд).\n\n**Лимит:** 5 запросов за 60 с на сессию (без сессии — с одного IP).","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountResetRequest"},"example":{"token":"EXAMPLE_reset_token_from_email_link_0000000","password":"new-correct-horse-42"}}}},"responses":{"201":{"description":"Пароль изменён, все сессии закрыты.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"description":"- Тело не прошло проверку: формат `ValidationError`.\n- «Ссылка недействительна или истекла»: токен не найден, заменён более новым или истёк.\n- «Ссылка уже использована»: токен израсходован параллельным запросом.\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/Error"}]},"examples":{"invalidLink":{"summary":"Токен недействителен","value":{"message":"Ссылка недействительна или истекла","error":"Bad Request","statusCode":400}},"used":{"summary":"Токен уже использован","value":{"message":"Ссылка уже использована","error":"Bad Request","statusCode":400}},"validation":{"summary":"Пароль слишком короткий","value":{"message":"Проверьте поля формы","errors":{"formErrors":[],"fieldErrors":{"password":["Too small: expected string to have >=10 characters"]}}}}}}}},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}},"security":[],"x-ratelimit":{"limit":5,"window":"60s"}}},"/api/v1/health":{"get":{"operationId":"healthCheck","tags":["Служебное"],"summary":"Проверка работоспособности","description":"Выполняет `SELECT 1` в PostgreSQL и отвечает `{ \"status\": \"ok\" }`.\n\nЕсли запрос к базе завершился ошибкой или не уложился в **3 секунды** (например, соединение зависло), ответ — 503 «База данных недоступна», а не 500. По нему прокси и мониторинг видят, что сервис недоступен.\n\n**Доступ:** публичный. Действует общий лимит — 120 запросов в минуту с IP.\n","responses":{"200":{"description":"API и база данных доступны.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthStatus"},"example":{"status":"ok"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"},"503":{"description":"База данных недоступна, запрос к ней завершился ошибкой или длился дольше 3 секунд.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":503,"message":"База данных недоступна","error":"Service Unavailable"}}}}},"security":[]}},"/api/v1/profile":{"get":{"operationId":"getOwnProfile","tags":["Профиль"],"summary":"Получить свой профиль","description":"Возвращает профиль текущего пользователя с игровыми показателями.\n\n- `balance`, `xp` и `activeCosmetic` относятся к мотивационной системе и здесь только читаются.\n- Признака подтверждения email здесь нет: он есть в `GET /api/v1/auth/me` и `GET /api/v1/account/status`.\n\n**Доступ:** нужна сессия; доступен только свой профиль.\n","responses":{"200":{"description":"Профиль.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OwnProfile"},"example":{"id":"9480fab5-024c-464c-8fa7-8be874db60ca","name":"Анна Смирнова","email":"anna@example.com","bio":"Веду продуктовые доски и спринты команды.","jobTitle":"Product manager","timezone":"Europe/Moscow","avatarColor":"#cfb39a","publicProfile":true,"balance":120,"xp":860,"activeCosmetic":null,"investmentsAvailable":false,"consentRequired":false}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"patch":{"operationId":"updateOwnProfile","tags":["Профиль"],"summary":"Изменить свой профиль","description":"Полностью заменяет редактируемые поля профиля: все пять полей обязательны. Частичного обновления нет, поэтому сначала прочитайте профиль, затем отправьте все поля. Возвращает профиль в том же виде, что и `GET /api/v1/profile`.\n\n- `name`: 2–80 символов после обрезки пробелов.\n- `bio` — до 1000 символов, `jobTitle` — до 100. Пробелы в этих полях не обрезаются.\n- `timezone`: до 80 символов, только `UTC` или имя IANA вида `Область/Место` (`Europe/Moscow`, `Etc/GMT-3`). Смещения вида `+03:00` и однословные имена (`CET`, `Japan`) отклоняются. Пояс должны знать и `Intl.DateTimeFormat`, и PostgreSQL. По нему сервер считает локальную дату пользователя: сроки, дневную цель, планер, серии и награды.\n- `avatarColor`: цвет `#RRGGBB`.\n- Email и пароль здесь не меняются. Для пароля есть `POST /api/v1/profile/password`; метода смены email нет.\n\n**Доступ:** нужна сессия.\n\n**Побочные эффекты:** обновление строки [users](#модели/dbusers).\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OwnProfileUpdate"},"example":{"name":"Анна Смирнова","bio":"Веду продуктовые доски и спринты команды.","jobTitle":"Product manager","timezone":"Europe/Moscow","avatarColor":"#5b8def"}}}},"responses":{"200":{"description":"Обновлённый профиль.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OwnProfile"},"example":{"id":"9480fab5-024c-464c-8fa7-8be874db60ca","name":"Анна Смирнова","email":"anna@example.com","bio":"Веду продуктовые доски и спринты команды.","jobTitle":"Product manager","timezone":"Europe/Moscow","avatarColor":"#5b8def","publicProfile":true,"balance":120,"xp":860,"activeCosmetic":null,"investmentsAvailable":false,"consentRequired":false}}}},"400":{"description":"- Поля не прошли проверку (`ValidationError`). Для `timezone`: «Выберите часовой пояс из списка, например Europe/Moscow» — не тот формат; «Неизвестный часовой пояс» — такого пояса нет в `Intl`.\n- «Неизвестный часовой пояс» в формате `Error`: пояс прошёл проверку формата и `Intl`, но его не знает PostgreSQL.\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/Error"}]},"examples":{"validation":{"summary":"Смещение вместо имени пояса","value":{"message":"Проверьте поля формы","errors":{"formErrors":[],"fieldErrors":{"timezone":["Выберите часовой пояс из списка, например Europe/Moscow"],"avatarColor":["Invalid string: must match pattern /^#[0-9a-fA-F]{6}$/"]}}}},"database":{"summary":"Пояс неизвестен PostgreSQL","value":{"message":"Неизвестный часовой пояс","error":"Bad Request","statusCode":400}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/profile/visibility":{"patch":{"operationId":"updateProfileVisibility","tags":["Профиль"],"summary":"Открыть или скрыть профиль","description":"Включает или выключает публичность профиля (`publicProfile`). Новый аккаунт создаётся со скрытым профилем: показывать его всем пользователям Metodox можно только с согласия владельца.\n\nСкрытый профиль:\n- не показывается в поиске людей `GET /api/v1/people`;\n- по `GET /api/v1/people/{id}` для других пользователей отвечает 404 «Профиль скрыт или не найден». Владелец видит свой профиль всегда;\n- не может получить новую заявку в друзья: `POST /api/v1/social/friends` отвечает 404 «Открытый профиль не найден».\n\nСуществующие дружбы и переписки не меняются.\n\n**Доступ:** нужна сессия.\n\n**Побочные эффекты:** `users.public_profile`.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileVisibilityUpdate"},"example":{"publicProfile":false}}}},"responses":{"200":{"description":"Видимость изменена.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/profile/sessions":{"get":{"operationId":"listProfileSessions","tags":["Профиль"],"summary":"Список активных сессий","description":"Сессии пользователя, у которых не истёк refresh-срок. Новые сверху. Лимита на количество нет.\n\n- Сессия — это одна пара токенов. При каждом `POST /api/v1/auth/refresh` она пересоздаётся: меняются `id` и `createdAt`, `expiresAt` сдвигается на 30 дней вперёд. Поэтому `createdAt` — время последнего входа или обновления, а не первого входа на устройстве.\n- `current: true` — сессия, чьим access-токеном выполнен запрос.\n- Сведений об устройстве, IP или User-Agent сервер не хранит.\n- В список попадают и сессии с истёкшим access-токеном, если refresh ещё действует.\n\n**Доступ:** нужна сессия; видны только свои сессии.\n","responses":{"200":{"description":"Активные сессии.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProfileSession"}},"example":[{"id":"f1850d6f-a5b5-431b-a178-6ae79292c55a","createdAt":"2026-10-02T08:00:12.345Z","expiresAt":"2026-11-01T08:00:12.345Z","current":true},{"id":"56301d7f-1525-442f-b5e6-b0f852495f84","createdAt":"2026-09-28T19:41:03.120Z","expiresAt":"2026-10-28T19:41:03.120Z","current":false}]}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/profile/sessions/{id}":{"delete":{"operationId":"revokeProfileSession","tags":["Профиль"],"summary":"Закрыть сессию","description":"Закрывает одну сессию пользователя по `id` из `GET /api/v1/profile/sessions`.\n\n- Текущую сессию так закрыть нельзя: запрос молча игнорируется. Для неё есть `POST /api/v1/auth/logout`.\n- Ответ всегда `{ \"ok\": true }`: и если сессия уже закрыта, и если `id` принадлежит другому пользователю, и если сессия успела смениться при ротации токенов. Чтобы проверить результат, перечитайте список.\n- Токены закрытой сессии перестают работать сразу. Её realtime-подключение разрывается при ближайшей проверке (раз в 30 секунд).\n\n**Доступ:** нужна сессия; закрываются только свои сессии.\n\n**Побочные эффекты:** удаление строки из [sessions](#модели/dbsessions).\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID сессии"}],"responses":{"200":{"description":"Запрос выполнен (сессия закрыта или её не было).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"description":"`id` — не UUID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"},"example":{"message":"Проверьте поля формы","errors":{"formErrors":["Invalid UUID"],"fieldErrors":{}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/profile/password":{"post":{"operationId":"changeOwnPassword","tags":["Профиль"],"summary":"Сменить пароль","description":"Меняет пароль после проверки текущего и закрывает все **остальные** сессии пользователя. Текущая сессия (по access-токену запроса) остаётся.\n\n- Неверный `currentPassword` — 400 «Текущий пароль указан неверно».\n- `newPassword`: 10–128 символов, сохраняется хеш scrypt `s2$…`. Совпадение со старым паролем не проверяется.\n- Проверка и замена идут в одной транзакции с блокировкой строки пользователя.\n- Свой лимит — 5 запросов в минуту: с украденной сессией именно здесь подбирали бы пароль.\n- Ранее выданные ссылки сброса пароля перестают действовать, неотправленные письма сброса отменяются: старая ссылка не перезапишет новый пароль.\n\n**Доступ:** нужна сессия.\n\n**Побочные эффекты:** `users.password_hash`, удаление остальных строк пользователя в [sessions](#модели/dbsessions), удаление токенов `reset` из [auth_tokens](#модели/dbauth-tokens), отмена писем `reset` со статусом `pending` в [mail_outbox](#модели/dbmail-outbox).\n\n**Лимит:** 5 запросов за 60 с на сессию (без сессии — с одного IP).","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfilePasswordChange"},"example":{"currentPassword":"correct-horse-42","newPassword":"even-better-horse-43"}}}},"responses":{"201":{"description":"Пароль изменён, остальные сессии закрыты.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"description":"- Тело не прошло проверку: формат `ValidationError`.\n- «Текущий пароль указан неверно»: `currentPassword` не совпал с паролем аккаунта.\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/Error"}]},"examples":{"wrongPassword":{"summary":"Неверный текущий пароль","value":{"message":"Текущий пароль указан неверно","error":"Bad Request","statusCode":400}},"validation":{"summary":"Новый пароль слишком короткий","value":{"message":"Проверьте поля формы","errors":{"formErrors":[],"fieldErrors":{"newPassword":["Too small: expected string to have >=10 characters"]}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}},"x-ratelimit":{"limit":5,"window":"60s"}}},"/api/v1/profile/consent":{"post":{"operationId":"acceptConsent","tags":["Профиль"],"summary":"Подтвердить согласие на обработку данных","description":"Записывает, что пользователь принял текущую версию [соглашения](https://metodox.ru/terms), [политики](https://metodox.ru/privacy) и [согласия](https://metodox.ru/consent). Нужен аккаунтам, созданным до появления согласия, и после обновления документов: признак `consentRequired` приходит в `GET /api/v1/profile` и `GET /api/v1/auth/me`.\n\n**Доступ:** нужна сессия.\n\n**Побочные эффекты:** `users.consent_version` и `users.consent_at` в [users](#модели/dbusers).\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accept"],"additionalProperties":false,"properties":{"accept":{"type":"boolean","const":true}}},"example":{"accept":true}}}},"responses":{"201":{"description":"Согласие записано.","content":{"application/json":{"schema":{"type":"object","required":["ok","consentVersion"],"properties":{"ok":{"type":"boolean","const":true},"consentVersion":{"type":"string","description":"Версия документов (дата публикации)."}}},"example":{"ok":true,"consentVersion":"2026-10-03"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/profile/delete":{"post":{"operationId":"deleteOwnAccount","tags":["Профиль"],"summary":"Удалить аккаунт и личные данные","description":"Безвозвратно удаляет аккаунт по запросу пользователя (152-ФЗ, требования магазинов приложений).\n\n- Нужен текущий пароль. Неверный пароль — 403 «Пароль неверен».\n- Если пользователь — владелец организации, где есть другие люди, удаление блокируется (409) со списком организаций. Сначала передайте владение (`POST /api/v1/workspaces/{id}/transfer`) или удалите организацию.\n- Организации без других участников удаляются вместе с аккаунтом, включая их ИИ-сотрудников.\n- Доски, которые пользователь создал в чужих организациях, переходят владельцу организации. Задачи, комментарии и история в них сохраняются, ссылки на автора обнуляются.\n- Каскадно удаляются личные доски, заметки, план, переписки, файлы чатов, хранилище, уведомления, награды, сессии и ожидающие приглашения на email пользователя. Объекты в S3 ставятся в очередь очистки триггерами.\n- Cookie сессии очищаются.\n\n**Доступ:** нужна сессия.\n\n**Побочные эффекты:** удаление строки в [users](#модели/dbusers) и каскад по связанным таблицам, [storage_cleanup](#модели/dbstorage-cleanup).\n\n**Лимит:** 5 запросов за 60 с на сессию (без сессии — с одного IP).","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["password"],"additionalProperties":false,"properties":{"password":{"type":"string","minLength":1,"maxLength":128,"description":"Текущий пароль."}}},"example":{"password":"correct-horse-42"}}}},"responses":{"201":{"description":"Аккаунт удалён, cookie очищены.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Пароль неверен. Тот же статус с другим текстом возвращает проверка `Origin` (см. «Общие правила»).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Пароль неверен","error":"Forbidden","statusCode":403}}}},"409":{"description":"Пользователь владеет организациями с другими участниками.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Сначала передайте владение или удалите организации: «Маркетинг»","error":"Conflict","statusCode":409}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}},"x-ratelimit":{"limit":5,"window":"60s"}}},"/api/v1/workspaces":{"get":{"operationId":"listWorkspaces","tags":["Организации"],"summary":"Список моих организаций","description":"Организации, в которых состоит текущий пользователь, с его ролью. Отсортированы по дате создания организации.\n\n**Доступ:** любой вошедший пользователь, возвращаются только его организации.\n","responses":{"200":{"description":"Организации пользователя","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceSummary"}},"example":[{"id":"3f6c1a2e-8b4d-4c1f-9a7e-2d5b8c9e0f13","name":"Студия «Северный ветер»","description":"Маркетинг и дизайн","role":"owner"},{"id":"5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d","name":"Клуб разработчиков","description":"","role":"guest"}]}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"post":{"operationId":"createWorkspace","tags":["Организации"],"summary":"Создать организацию","description":"Создаёт организацию. Текущий пользователь становится её владельцем (`owner`).\n\n**Доступ:** вошедший пользователь с подтверждённым email (если проверка включена, см. описание раздела).\n\n**Побочные эффекты:** записи в [workspaces](#модели/dbworkspaces) и [workspace_members](#модели/dbworkspace-members), событие `team.created` в журнале организации, realtime-событие `changed`.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInput"},"example":{"name":"Студия «Северный ветер»","description":"Маркетинг и дизайн"}}}},"responses":{"201":{"description":"Организация создана","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceCreated"},"example":{"id":"3f6c1a2e-8b4d-4c1f-9a7e-2d5b8c9e0f13","name":"Студия «Северный ветер»","description":"Маркетинг и дизайн","role":"owner"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Email не подтверждён, а проверка включена.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Сначала подтвердите email в настройках безопасности","error":"Forbidden","statusCode":403}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/workspaces/invitations":{"get":{"operationId":"listIncomingInvitations","tags":["Организации"],"summary":"Приглашения для меня","description":"Неистёкшие приглашения на email текущего пользователя.\n\n**Доступ:** любой вошедший пользователь с подтверждённым email, если проверка включена (см. описание раздела). Иначе 403: аккаунт, который не доказал владение адресом, не видит, куда приглашён владелец адреса.\n","responses":{"200":{"description":"Активные приглашения","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/IncomingInvitation"}},"example":[{"id":"5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a","role":"member","name":"Студия «Северный ветер»","expiresAt":"2026-10-09T09:15:00.000Z"}]}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Email не подтверждён, а проверка включена.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Сначала подтвердите email в настройках безопасности","error":"Forbidden","statusCode":403}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/workspaces/invitations/{inviteId}/accept":{"post":{"operationId":"acceptInvitation","tags":["Организации"],"summary":"Принять приглашение","description":"Принимает приглашение: удаляет его и добавляет пользователя в организацию с ролью из приглашения. Если пользователь уже участник, его роль не меняется.\n\n**Доступ:** пользователь, чей email совпадает с email приглашения. Нужен подтверждённый email, если проверка включена.\n\n**Побочные эффекты:** запись в [workspace_members](#модели/dbworkspace-members), событие `member.joined` в журнале (пишется и тогда, когда пользователь уже был участником), realtime-событие `changed`.\n","parameters":[{"name":"inviteId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID приглашения"}],"responses":{"201":{"description":"Приглашение принято","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvitationAccepted"},"example":{"workspaceId":"3f6c1a2e-8b4d-4c1f-9a7e-2d5b8c9e0f13"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Email не подтверждён, а проверка включена.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Сначала подтвердите email в настройках безопасности","error":"Forbidden","statusCode":403}}}},"404":{"description":"Приглашения нет, оно истекло или адресовано другому email.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Приглашение истекло или недоступно","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/workspaces/invitations/{inviteId}":{"delete":{"operationId":"declineInvitation","tags":["Организации"],"summary":"Отклонить приглашение","description":"Удаляет приглашение, адресованное email текущего пользователя, в том числе истёкшее. Идемпотентно: если приглашения нет, ответ тоже `{ ok: true }`.\n\n**Доступ:** пользователь, чей email совпадает с email приглашения. Нужен подтверждённый email, если проверка включена: иначе 403, и приглашение не удаляется.\n\n**Побочные эффекты:** если приглашение удалено — событие `invitation.declined` в журнале организации и realtime-событие `changed`.\n","parameters":[{"name":"inviteId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID приглашения"}],"responses":{"200":{"description":"Готово","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Email не подтверждён, а проверка включена.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Сначала подтвердите email в настройках безопасности","error":"Forbidden","statusCode":403}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/workspaces/{id}":{"get":{"operationId":"getWorkspace","tags":["Организации"],"summary":"Организация с участниками","description":"Возвращает организацию, роль текущего пользователя, всех участников и (для владельца и админов) активные приглашения.\n\nУчастники отсортированы по старшинству роли: `owner`, `admin`, `member`, `guest`, внутри роли — по имени.\n\n**Доступ:** любой участник организации, включая гостей. Владелец, админы и участники видят email всех участников. Гость видит только свой email, у остальных приходит `email: null`. Не участнику — 404.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"}],"responses":{"200":{"description":"Организация","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Workspace"},"examples":{"owner":{"summary":"Смотрит владелец","value":{"id":"3f6c1a2e-8b4d-4c1f-9a7e-2d5b8c9e0f13","name":"Студия «Северный ветер»","description":"Маркетинг и дизайн","role":"owner","members":[{"id":"7a1d9c3e-5b2f-4e8a-b6c4-1f0e2d3c4b5a","name":"Сергей Ковалёв","kind":"human","email":"sergey@example.com","jobTitle":"Руководитель студии","avatarColor":"#cfb39a","role":"owner"},{"id":"c2e4f6a8-1b3d-4f5e-8a7c-9d0b1e2f3a4b","name":"Анна Смирнова","kind":"human","email":"anna@example.com","jobTitle":"Дизайнер","avatarColor":"#9ab3cf","role":"member"},{"id":"4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b","name":"Олег Петров","kind":"human","email":"oleg@example.com","jobTitle":"","avatarColor":"#b39ddb","role":"guest"}],"invitations":[{"id":"5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a","email":"maria@example.com","role":"member","expiresAt":"2026-10-09T09:15:00.000Z"}]}},"guest":{"summary":"Смотрит гость","value":{"id":"3f6c1a2e-8b4d-4c1f-9a7e-2d5b8c9e0f13","name":"Студия «Северный ветер»","description":"Маркетинг и дизайн","role":"guest","members":[{"id":"7a1d9c3e-5b2f-4e8a-b6c4-1f0e2d3c4b5a","name":"Сергей Ковалёв","kind":"human","email":null,"jobTitle":"Руководитель студии","avatarColor":"#cfb39a","role":"owner"},{"id":"c2e4f6a8-1b3d-4f5e-8a7c-9d0b1e2f3a4b","name":"Анна Смирнова","kind":"human","email":null,"jobTitle":"Дизайнер","avatarColor":"#9ab3cf","role":"member"},{"id":"4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b","name":"Олег Петров","kind":"human","email":"oleg@example.com","jobTitle":"","avatarColor":"#b39ddb","role":"guest"}],"invitations":[]}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Организации нет или пользователь в ней не состоит.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Организация не найдена","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"patch":{"operationId":"updateWorkspace","tags":["Организации"],"summary":"Изменить название и описание","description":"Меняет название и/или описание. Частичное обновление: пропущенное поле сохраняет текущее значение, `description: \"\"` очищает описание. Пустое тело ничего не меняет, но событие в журнал всё равно пишется.\n\n**Доступ:** `owner` или `admin` организации. Права проверяются до разбора тела запроса.\n\n**Побочные эффекты:** событие `team.updated` в журнале, realtime-событие `changed`.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceUpdateInput"},"example":{"description":"Маркетинг, дизайн и контент"}}}},"responses":{"200":{"description":"Сохранено","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Роль ниже `admin`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Нужны права администратора организации","error":"Forbidden","statusCode":403}}}},"404":{"description":"Организации нет или пользователь в ней не состоит.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Организация не найдена","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"delete":{"operationId":"deleteWorkspace","tags":["Организации"],"summary":"Удалить организацию","description":"Безвозвратно удаляет организацию. Чтобы случайный вызов ничего не удалил, в теле нужно передать точное название организации.\n\nВ одной транзакции строка организации блокируется (`FOR UPDATE`), права и название перепроверяются под блокировкой. Затем удаляются аккаунты ИИ-сотрудников этой организации (как при удалении аккаунта владельца) и сама организация. Каскадом удаляются участники, приглашения, доски с колонками, задачами, комментариями и вложениями, проекты и этапы, Wiki, бэклог, спринты, ИИ-цепочки, сейф организации и журнал. Файлы в S3 ставятся в очередь на удаление триггерами.\n\nЛичные доски участников и их аккаунты не затрагиваются.\n\n**Доступ:** только владелец организации. Не участнику — 404, остальным ролям — 403.\n\n**Побочные эффекты:** удаление строк [workspace_members](#модели/dbworkspace-members) рассылает realtime-событие `changed` каждому бывшему участнику, удаление досок — их владельцам. В журнал ничего не пишется.\n\n**Лимит:** 5 запросов за 60 с на сессию (без сессии — с одного IP).","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceDeleteInput"},"example":{"confirmName":"Студия «Северный ветер»"}}}},"responses":{"200":{"description":"Организация удалена","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"description":"- ошибка валидации: нет `confirmName`, он длиннее 200 символов или в теле есть лишние поля (схема `.strict()`);\n- `confirmName` не совпадает с названием — «Название не совпадает. Введите название организации так же, как в настройках».\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/Error"}]},"example":{"message":"Название не совпадает. Введите название организации так же, как в настройках","error":"Bad Request","statusCode":400}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Пользователь не владелец — «Удалить организацию может только владелец».","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Удалить организацию может только владелец","error":"Forbidden","statusCode":403}}}},"404":{"description":"Организации нет или пользователь в ней не состоит.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Организация не найдена","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}},"x-ratelimit":{"limit":5,"window":"60s"}}},"/api/v1/workspaces/{id}/leave":{"post":{"operationId":"leaveWorkspace","tags":["Организации"],"summary":"Покинуть организацию","description":"Текущий пользователь выходит из организации. Очистка та же, что при удалении участника администратором (`DELETE /workspaces/{id}/members/{userId}`): удаляются явные роли на досках организации и в задачах этих досок, пользователь снимается с исполнителей задач организации (у этих задач увеличивается `version`), созданные им доски организации переходят её владельцу.\n\nВернуться можно только по новому приглашению. Тело запроса не читается.\n\n**Доступ:** любой участник, кроме владельца: `admin`, `member`, `guest`. Владелец получает 403 и должен сначала передать владение (`POST /workspaces/{id}/transfer`) или удалить организацию. Роль перепроверяется под блокировкой строки участника, поэтому выход не пересекается с параллельной передачей владения.\n\n**Побочные эффекты:** событие `member.left` (`scope: admin`, `title` — имя участника, `details: { role }`) в журнале, realtime-событие `changed` организации и самому пользователю.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"}],"responses":{"201":{"description":"Пользователь вышел из организации","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Пользователь — владелец организации.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Владелец не может покинуть организацию. Передайте владение или удалите организацию","error":"Forbidden","statusCode":403}}}},"404":{"description":"Организации нет или пользователь в ней не состоит.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Организация не найдена","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/workspaces/{id}/transfer":{"post":{"operationId":"transferWorkspaceOwnership","tags":["Организации"],"summary":"Передать владение организацией","description":"Делает другого участника владельцем организации. В одной транзакции `workspaces.owner_id` указывает на нового владельца, его роль становится `owner`, а роль прежнего владельца — `admin`. Строка организации и обе строки участников блокируются (`FOR UPDATE`), а права перепроверяются уже под блокировкой. Поэтому параллельные передачи, смены ролей и удаления участников выполняются по очереди.\n\nТребования к новому владельцу:\n- участник этой организации, иначе 404;\n- человек (`kind: human`), не ИИ-сотрудник, иначе 400;\n- роль `admin` или `member`, не `guest`, иначе 400. Гостю сначала нужно назначить другую роль;\n- подтверждённый email, если проверка включена (в production — по умолчанию), иначе 403.\n\nЯвные роли на досках и в задачах не меняются. Прежний владелец как `admin` сохраняет доступ ко всем доскам организации, но теряет права, которые есть только у владельца: сейф организации, SSH-этапы ИИ-цепочек, назначение администраторов и повторную передачу.\n\n**Доступ:** только текущий `owner` с подтверждённым email (если проверка включена). Не участнику — 404, остальным ролям — 403.\n\n**Побочные эффекты:** обновляются [workspaces](#модели/dbworkspaces) и [workspace_members](#модели/dbworkspace-members). В журнал пишется событие `team.ownership_transferred` (`scope: team`, `title` — имя нового владельца, `details: { from, to, previousRole }`). Изменение участников рассылает realtime-событие `changed` организации и обоим затронутым пользователям.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceTransferInput"},"example":{"userId":"c2e4f6a8-1b3d-4f5e-8a7c-9d0b1e2f3a4b"}}}},"responses":{"201":{"description":"Владение передано","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceTransferred"},"example":{"ok":true,"ownerId":"c2e4f6a8-1b3d-4f5e-8a7c-9d0b1e2f3a4b","role":"admin"}}}},"400":{"description":"- ошибка валидации: `userId` не UUID или в теле есть лишние поля (схема `.strict()`);\n- `userId` — сам пользователь: «Выберите другого участника организации»;\n- участник — ИИ-сотрудник: «Владельцем может стать только человек, а не ИИ-сотрудник»;\n- участник — гость: «Гостя нельзя сделать владельцем. Сначала назначьте ему роль участника или администратора».\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/Error"}]},"example":{"message":"Гостя нельзя сделать владельцем. Сначала назначьте ему роль участника или администратора","error":"Bad Request","statusCode":400}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"- пользователь не владелец — «Передать организацию может только владелец»;\n- email владельца не подтверждён, а проверка включена — «Сначала подтвердите email в настройках безопасности»;\n- email нового владельца не подтверждён, а проверка включена — «Новый владелец должен сначала подтвердить email».\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Передать организацию может только владелец","error":"Forbidden","statusCode":403}}}},"404":{"description":"- организации нет или пользователь в ней не состоит — «Организация не найдена»;\n- `userId` не участник организации — «Участник организации не найден».\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Участник организации не найден","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/workspaces/{id}/invitations":{"post":{"operationId":"createInvitation","tags":["Организации"],"summary":"Пригласить в организацию","description":"Создаёт приглашение на email или обновляет существующее: меняет роль и продлевает срок до 7 дней от текущего момента. Повторный вызов с тем же email — это и есть «Отправить повторно»: при `MAIL_ENABLED=true` письмо ставится в очередь ещё раз.\n\nПорядок проверок: роль в организации → тело → роль `admin` только от владельца → человек с этим email уже участник → (в транзакции, под блокировкой строки приглашения) нет ли активного приглашения с ролью `admin`, если вызывает не владелец.\n\n**Доступ:** `owner` или `admin`. Пригласить с ролью `admin` может только `owner`. Активное (неистёкшее) приглашение с ролью `admin` может изменить или продлить только `owner`: админ не может ни понизить его, ни отправить повторно.\n\n**Побочные эффекты:** запись в [invitations](#модели/dbinvitations), событие `invitation.sent` (с email и ролью) в журнале организации, realtime-событие `changed`. При `MAIL_ENABLED=true` письмо ставится в очередь [mail_outbox](#модели/dbmail-outbox).\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationInput"},"example":{"email":"oleg@example.com","role":"member"}}}},"responses":{"201":{"description":"Приглашение создано или обновлено","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"- роль ниже `admin` — «Нужны права администратора организации»;\n- `admin` приглашает с ролью `admin` — «Только владелец назначает администраторов»;\n- пользователь с этим email уже участник — «Этот человек уже в организации»;\n- `admin` повторно приглашает email, у которого есть активное приглашение с ролью `admin`, — «Этому человеку владелец уже отправил приглашение администратора. Изменить его может только владелец».\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Этот человек уже в организации","error":"Forbidden","statusCode":403}}}},"404":{"description":"Организации нет или пользователь в ней не состоит.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Организация не найдена","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/workspaces/{id}/invitations/{inviteId}":{"delete":{"operationId":"cancelInvitation","tags":["Организации"],"summary":"Отменить приглашение","description":"Удаляет приглашение организации, в том числе истёкшее. Идемпотентно: если приглашения нет, ответ тоже `{ ok: true }`.\n\n**Доступ:** `owner` или `admin`.\n\n**Побочные эффекты:** если приглашение удалено — событие `invitation.cancelled` (с email) в журнале и realtime-событие `changed`.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"},{"name":"inviteId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID приглашения"}],"responses":{"200":{"description":"Готово","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Роль ниже `admin`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Нужны права администратора организации","error":"Forbidden","statusCode":403}}}},"404":{"description":"Организации нет или пользователь в ней не состоит.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Организация не найдена","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/workspaces/{id}/members/{userId}":{"patch":{"operationId":"updateWorkspaceMemberRole","tags":["Организации"],"summary":"Изменить роль участника","description":"Меняет роль участника на `admin`, `member` или `guest`. Строка участника блокируется на время транзакции.\n\n**Доступ:** `owner` или `admin`. Роль владельца и свою собственную роль менять нельзя. Если участник сейчас `admin` или новая роль — `admin`, нужна роль `owner`.\n\n**Побочные эффекты:** событие `member.role_changed` (`{ from, to }`) в журнале, realtime-событие `changed`. Явные роли на досках и в задачах не меняются.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"},{"name":"userId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID участника"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleInput"},"example":{"role":"guest"}}}},"responses":{"200":{"description":"Роль изменена","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"- роль ниже `admin` — «Нужны права администратора организации»;\n- участник — владелец, или это сам пользователь — «Роль владельца и свою роль менять нельзя»;\n- действие затрагивает роль `admin`, а пользователь не владелец — «Нужны права владельца».\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Нужны права владельца","error":"Forbidden","statusCode":403}}}},"404":{"description":"Организации нет, пользователь в ней не состоит, или `userId` — не участник (тогда `message` — «Not Found»).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"delete":{"operationId":"removeWorkspaceMember","tags":["Организации"],"summary":"Удалить участника","description":"Удаляет участника из организации.\n\n**Доступ:** `owner` или `admin`. Нельзя удалить владельца и самого себя. Удалить `admin` может только владелец.\n\n**Побочные эффекты:** в одной транзакции удаляются явные роли пользователя на всех досках организации ([board_members](#модели/dbboard-members)) и в задачах этих досок ([task_members](#модели/dbtask-members)). Пользователь снимается с исполнителей задач организации, у этих задач увеличивается `version`. Доски организации, которые он создал, переходят владельцу организации (`boards.owner_id`). В журнал пишется `member.removed`, отправляется realtime-событие `changed`. Сам участник выходит методом `POST /workspaces/{id}/leave`.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"},{"name":"userId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID участника"}],"responses":{"200":{"description":"Участник удалён","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"- роль ниже `admin` — «Нужны права администратора организации»;\n- участник — владелец, сам пользователь или `admin` при удалении не владельцем — «Нельзя удалить этого участника».\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Нельзя удалить этого участника","error":"Forbidden","statusCode":403}}}},"404":{"description":"Организации нет, пользователь в ней не состоит, или `userId` — не участник (тогда `message` — «Not Found»).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/boards":{"get":{"operationId":"listBoards","tags":["Доски"],"summary":"Список доступных досок","description":"Доски, к которым у пользователя есть доступ: свои личные и доски организаций по правилам ролей. Отсортированы по дате создания. Роль на доске в списке не возвращается — её даёт `GET /boards/{id}`.\n\nС параметром `workspaceId` возвращаются только доски этой организации (без личных). Пустое значение параметра равносильно его отсутствию.\n\n**Доступ:** любой вошедший пользователь. Если передан `workspaceId`, пользователь должен быть участником этой организации.\n","parameters":[{"name":"workspaceId","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"Показать только доски этой организации."}],"responses":{"200":{"description":"Доски","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/BoardSummary"}},"example":[{"id":"8f1c2d4e-6a7b-4c8d-9e0f-1a2b3c4d5e6f","title":"Маркетинг","description":"Осенняя кампания","workspaceId":"3f6c1a2e-8b4d-4c1f-9a7e-2d5b8c9e0f13","visibility":"workspace","projectId":"1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e","phaseId":"2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f","projectTitle":"Запуск нового сайта","phaseTitle":"Подготовка","color":null,"projectColor":"#7c9cff","phaseColor":"#f472b6","accent":"#f472b6"},{"id":"0f9e8d7c-6b5a-4c4d-8e3f-2a1b0c9d8e7f","title":"Личные дела","description":"","workspaceId":null,"visibility":"private","projectId":null,"phaseId":null,"projectTitle":null,"phaseTitle":null,"color":"#4ade80","projectColor":null,"phaseColor":null,"accent":"#4ade80"}]}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Передан `workspaceId` организации, в которой пользователь не состоит.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Организация не найдена","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"post":{"operationId":"createBoard","tags":["Доски"],"summary":"Создать доску","description":"Создаёт личную доску (без `workspaceId`) или доску организации. У доски организации сразу 4 колонки: «В планах», «В работе», «На проверке», «Готово». У личной — 3: «Нужно сделать», «В работе», «Готово».\n\nОтвет содержит значения, записанные в базу: у личной доски `visibility` всегда `private`, даже если в запросе было `workspace`; непереданные `projectId` и `phaseId` приходят как `null`.\n\nПорядок проверок: тело → членство в организации и роль → привязка к проекту и этапу.\n\n**Доступ:** личную доску может создать любой вошедший пользователь. Доску организации — участник с ролью `owner`, `admin` или `member`; гостю — 403.\n\n**Побочные эффекты:** записи в [boards](#модели/dbboards) и [columns](#модели/dbcolumns). Для доски организации создатель получает явную роль `admin` в [board_members](#модели/dbboard-members). В журнал пишется `board.created`, отправляется realtime-событие `changed`.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BoardInput"},"example":{"title":"Маркетинг","description":"Осенняя кампания","workspaceId":"3f6c1a2e-8b4d-4c1f-9a7e-2d5b8c9e0f13","projectId":"1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e","phaseId":"2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f","visibility":"workspace"}}}},"responses":{"201":{"description":"Доска создана","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BoardCreated"},"examples":{"team":{"summary":"Доска организации","value":{"id":"8f1c2d4e-6a7b-4c8d-9e0f-1a2b3c4d5e6f","title":"Маркетинг","description":"Осенняя кампания","workspaceId":"3f6c1a2e-8b4d-4c1f-9a7e-2d5b8c9e0f13","projectId":"1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e","phaseId":"2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f","visibility":"workspace","color":null}},"personal":{"summary":"Личная доска: видимость всегда private","value":{"id":"0f9e8d7c-6b5a-4c4d-8e3f-2a1b0c9d8e7f","title":"Личные дела","description":"","workspaceId":null,"projectId":null,"phaseId":null,"visibility":"private","color":"#4ade80"}}}}}},"400":{"description":"Ошибка валидации (`ValidationError`) или неверная привязка к проекту (`Error`):\n«Этап должен принадлежать проекту», «Проекты доступны в организации», «Проект недоступен или архивирован», «Этап другой принадлежности».\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/Error"}]},"example":{"message":"Проект недоступен или архивирован","error":"Bad Request","statusCode":400}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Пользователь — гость организации.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Гости не могут создавать доски","error":"Forbidden","statusCode":403}}}},"404":{"description":"Пользователь не состоит в организации `workspaceId`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Организация не найдена","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/boards/{id}":{"get":{"operationId":"getBoard","tags":["Доски"],"summary":"Доска с колонками и задачами","description":"Возвращает доску, роль текущего пользователя, колонки, видимые ему задачи и связи между этими задачами. У каждой задачи есть номер на доске (`number`), число комментариев (без удалённых) и вложений, а также права текущего пользователя (`canManage`, `canEdit`).\n\n**Доступ:** любая роль на доске (`viewer` и выше). Задачи с видимостью `restricted` возвращаются только тем, кто может их видеть (раздел «Задачи»).\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"}],"responses":{"200":{"description":"Доска","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Board"},"example":{"id":"8f1c2d4e-6a7b-4c8d-9e0f-1a2b3c4d5e6f","title":"Маркетинг","description":"Осенняя кампания","workspaceId":"3f6c1a2e-8b4d-4c1f-9a7e-2d5b8c9e0f13","projectId":"1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e","phaseId":"2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f","projectTitle":"Запуск нового сайта","phaseTitle":"Подготовка","color":null,"phaseColor":"#f472b6","projectColor":"#7c9cff","accent":"#f472b6","visibility":"workspace","role":"admin","columns":[{"id":"0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d","title":"В планах","position":0},{"id":"1a2b3c4d-5e6f-4a7b-8c8d-9e0f1a2b3c4e","title":"В работе","position":1},{"id":"2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d","title":"На проверке","position":2},{"id":"3a4b5c6d-7e8f-4a9b-8c1d-2e3f4a5b6c7d","title":"Готово","position":3}],"tasks":[{"id":"9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d","number":12,"title":"Подготовить бриф","description":"Собрать требования от отдела продаж","columnId":"1a2b3c4d-5e6f-4a7b-8c8d-9e0f1a2b3c4e","priority":"high","label":"контент","dueDate":"2026-10-10","position":0,"version":3,"visibility":"board","assigneeId":"c2e4f6a8-1b3d-4f5e-8a7c-9d0b1e2f3a4b","assigneeKind":"human","assigneeName":"Анна Смирнова","assigneeColor":"#9ab3cf","creatorId":"7a1d9c3e-5b2f-4e8a-b6c4-1f0e2d3c4b5a","commentCount":4,"attachmentCount":1,"canManage":true,"canEdit":true},{"id":"6e7f8a9b-0c1d-4e2f-8a3b-4c5d6e7f8a9b","number":13,"title":"Собрать референсы","description":"","columnId":"0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d","priority":"medium","label":"","dueDate":null,"position":0,"version":1,"visibility":"board","assigneeId":null,"assigneeKind":null,"assigneeName":null,"assigneeColor":null,"creatorId":"7a1d9c3e-5b2f-4e8a-b6c4-1f0e2d3c4b5a","commentCount":0,"attachmentCount":0,"canManage":true,"canEdit":true}],"links":[{"id":"a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d","sourceId":"9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d","targetId":"6e7f8a9b-0c1d-4e2f-8a3b-4c5d6e7f8a9b","kind":"subtask"}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Доски нет или у пользователя нет к ней доступа.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Доска не найдена","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"patch":{"operationId":"updateBoard","tags":["Доски"],"summary":"Изменить доску","description":"Частично обновляет название, описание, видимость и привязку к проекту и этапу. Непереданные поля не меняются. Если сменить `projectId` и не передать `phaseId`, этап сбрасывается.\n\nПривязка к проекту проверяется, только если проект или этап меняются. Поэтому доску архивного проекта можно переименовать и сохранить, не отвязывая её.\n\n**Доступ:** `admin` доски. Права проверяются до разбора тела запроса.\n\n**Побочные эффекты:**\n- событие `board.updated` с перечнем переданных полей (`details.fields`), realtime-событие `changed`;\n- закрытие доски (`visibility` было `workspace`, стало `private`): в той же транзакции у всех, кто больше не может открыть доску (участники с ролью `member` без явной роли на доске), удаляются роли в задачах доски, и они снимаются с исполнителей. У этих задач растёт `version`, в историю пишется `updated` с `assigneeId`, а число снятых назначений попадает в `details.unassigned` события `board.updated`.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BoardUpdateInput"},"example":{"title":"Маркетинг — осень","visibility":"private","projectId":"1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e"}}}},"responses":{"200":{"description":"Сохранено","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"description":"Ошибка валидации (`ValidationError`) или неверная привязка к проекту (`Error`):\n«Этап должен принадлежать проекту», «Проекты доступны в организации» (для личной доски), «Проект недоступен или архивирован», «Этап другой принадлежности».\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/Error"}]},"example":{"message":"Этап другой принадлежности","error":"Bad Request","statusCode":400}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Роль на доске ниже `admin`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Недостаточно прав на доске","error":"Forbidden","statusCode":403}}}},"404":{"description":"Доски нет или у пользователя нет к ней доступа.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Доска не найдена","error":"Not Found","statusCode":404}}}},"409":{"description":"`projectId` меняется, а задачи доски входят в незавершённые спринты, привязанные к другому проекту. Это касается и отвязки доски от проекта (`projectId: null`).\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Сначала уберите задачи доски из спринтов другого проекта","error":"Conflict","statusCode":409}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"delete":{"operationId":"deleteBoard","tags":["Доски"],"summary":"Удалить доску","description":"Удаляет доску вместе с колонками, задачами, связями, комментариями, вложениями и ролями (каскадно в базе). Вложения в S3 ставятся в очередь на удаление.\n\n**Доступ:** `admin` доски: владелец личной доски, владелец или админ организации, пользователь с явной ролью `admin`.\n\n**Побочные эффекты:** событие `board.deleted` в журнале организации (сохраняется после удаления), realtime-событие `changed`.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"}],"responses":{"200":{"description":"Доска удалена","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Роль на доске ниже `admin`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Недостаточно прав на доске","error":"Forbidden","statusCode":403}}}},"404":{"description":"Доски нет или у пользователя нет к ней доступа.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Доска не найдена","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/boards/{id}/columns":{"post":{"operationId":"createColumn","tags":["Доски"],"summary":"Добавить колонку","description":"Добавляет колонку перед последней. Последняя колонка (завершения) сдвигается вправо и остаётся последней.\n\n**Доступ:** `editor` или `admin` доски.\n\n**Побочные эффекты:** строка доски блокируется на время транзакции. Событие `column.created` в журнале, realtime-событие `changed`.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ColumnInput"},"example":{"title":"Согласование"}}}},"responses":{"201":{"description":"Колонка создана","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ColumnCreated"},"example":{"id":"7c8d9e0f-1a2b-4c3d-8e4f-5a6b7c8d9e0f","title":"Согласование"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Роль на доске — `viewer`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Недостаточно прав на доске","error":"Forbidden","statusCode":403}}}},"404":{"description":"Доски нет или у пользователя нет к ней доступа.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Доска не найдена","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/boards/{id}/columns/order":{"put":{"operationId":"reorderColumns","tags":["Доски"],"summary":"Переставить колонки","description":"Задаёт новый порядок колонок. В `columnIds` передаются все колонки доски, каждая ровно один раз, слева направо. Позиции становятся 0…n−1 в порядке списка.\n\nПоследняя колонка списка становится колонкой завершения. Отметки завершения задач (`completed_at`, `completed_by`) и награды не пересчитываются. Задачи новой последней колонки считаются выполненными там, где это определяется по колонке: правила графа, `done` в связях, напоминания о сроках. Задачи прежней последней колонки — открытыми.\n\nПорядок проверок: роль на доске → тело → (в транзакции под блокировкой строки доски) список совпадает с колонками доски → если сменилась последняя колонка, у её задач нет подзадач и блокирующих задач вне её, в том числе скрытых от пользователя. Если порядок совпадает с текущим, ничего не записывается и событие в журнал не пишется.\n\n**Доступ:** `editor` или `admin` доски.\n\n**Побочные эффекты:** обновляется `columns.position`. Событие `column.reordered` в журнале (`title` — название доски, `details: { columnIds }`, а если сменилась последняя колонка — ещё `finalColumnId`), realtime-событие `changed`.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ColumnOrderInput"},"example":{"columnIds":["0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d","2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d","1a2b3c4d-5e6f-4a7b-8c8d-9e0f1a2b3c4e","3a4b5c6d-7e8f-4a9b-8c1d-2e3f4a5b6c7d"]}}}},"responses":{"200":{"description":"Колонки в новом порядке","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ColumnOrderResult"},"example":{"ok":true,"columns":[{"id":"0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d","title":"В планах","position":0},{"id":"2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d","title":"На проверке","position":1},{"id":"1a2b3c4d-5e6f-4a7b-8c8d-9e0f1a2b3c4e","title":"В работе","position":2},{"id":"3a4b5c6d-7e8f-4a9b-8c1d-2e3f4a5b6c7d","title":"Готово","position":3}]}}}},"400":{"description":"Ошибка валидации (`ValidationError`): нет `columnIds`, список пуст или длиннее 100, элемент не UUID, ID повторяются («Колонки в списке повторяются» в `fieldErrors.columnIds`). Также неверный UUID доски в пути.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"},"example":{"message":"Проверьте поля формы","errors":{"formErrors":[],"fieldErrors":{"columnIds":["Колонки в списке повторяются"]}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Роль на доске — `viewer`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Недостаточно прав на доске","error":"Forbidden","statusCode":403}}}},"404":{"description":"Доски нет или у пользователя нет к ней доступа.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Доска не найдена","error":"Not Found","statusCode":404}}}},"409":{"description":"- список не совпадает с колонками доски: не хватает колонки, есть лишняя или чужая (например, колонку только что добавили или удалили) — «Колонки доски изменились. Обновите доску и повторите»;\n- в новой последней колонке есть задача, у которой подзадача или блокирующая задача стоит в другой колонке — «В новой последней колонке есть задачи с незавершёнными подзадачами или блокирующими задачами. Выберите другой порядок или сначала перенесите эти задачи». Порядок не меняется.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Колонки доски изменились. Обновите доску и повторите","error":"Conflict","statusCode":409}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/boards/{id}/columns/{columnId}":{"patch":{"operationId":"renameColumn","tags":["Доски"],"summary":"Переименовать колонку","description":"Меняет название колонки. Позиция и роль колонки (например, колонки завершения) не меняются.\n\n**Доступ:** `editor` или `admin` доски.\n\n**Побочные эффекты:** событие `column.renamed` в журнале, realtime-событие `changed`.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"},{"name":"columnId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID колонки"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ColumnInput"},"example":{"title":"Сделано"}}}},"responses":{"200":{"description":"Колонка после переименования","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Column"},"example":{"id":"3a4b5c6d-7e8f-4a9b-8c1d-2e3f4a5b6c7d","title":"Сделано","position":3}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Роль на доске — `viewer`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Недостаточно прав на доске","error":"Forbidden","statusCode":403}}}},"404":{"description":"Доска недоступна («Доска не найдена») или колонки нет на этой доске («Колонка не найдена»).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Колонка не найдена","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"delete":{"operationId":"deleteColumn","tags":["Доски"],"summary":"Удалить колонку","description":"Удаляет колонку доски. Задачи из неё переносятся в колонку `moveTo` этой же доски. Пустую колонку можно удалить без тела запроса.\n\nПорядок проверок: роль на доске → ID колонки и тело → (в транзакции под блокировкой строки доски) колонка есть на доске → она не единственная → она не последняя (колонка завершения) → `moveTo` не совпадает с удаляемой колонкой и есть на доске → если в колонке есть задачи, `moveTo` передан.\n\nЕсли `moveTo` — последняя колонка, перенесённые задачи завершаются, как при `PATCH`. Все их предпосылки по графу, в том числе скрытые от пользователя, должны быть выполнены, иначе 409 и ничего не меняется. Проставляется `completed_at` (если его ещё нет) и `completed_by`, получатель награды получает её один раз за задачу (раздел «Задачи», «Завершение»). Награды в ответе не возвращаются. При переносе в другую колонку `completed_at` и `completed_by` у перенесённых задач очищаются.\n\nПосле удаления позиции оставшихся колонок пересчитываются подряд с 0, так что колонкой завершения остаётся последняя колонка списка.\n\n**Доступ:** `editor` или `admin` доски.\n\n**Побочные эффекты:**\n- у перенесённых задач меняется `column_id`, `version` увеличивается на 1, обновляется `updated_at`;\n- у каждой перенесённой задачи — событие истории `updated` (`{ fields: [\"columnId\"], changes: { columnId: { from, to } } }`), копируется в журнал организации как `task.updated`;\n- при переносе в последнюю колонку — записи в [completion_records](#модели/dbcompletion-records) и [coin_ledger](#модели/dbcoin-ledger), проверка достижений;\n- событие `column.deleted` в журнале (`title` — название колонки, `details: { moveTo, moved }`), realtime-событие `changed`;\n- уведомления не создаются.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"},{"name":"columnId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID удаляемой колонки"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ColumnDeleteInput"},"example":{"moveTo":"1a2b3c4d-5e6f-4a7b-8c8d-9e0f1a2b3c4e"}}}},"responses":{"200":{"description":"Колонка удалена","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ColumnDeleted"},"example":{"ok":true,"moved":2,"columns":[{"id":"0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d","title":"В планах","position":0},{"id":"1a2b3c4d-5e6f-4a7b-8c8d-9e0f1a2b3c4e","title":"В работе","position":1},{"id":"3a4b5c6d-7e8f-4a9b-8c1d-2e3f4a5b6c7d","title":"Готово","position":2}]}}}},"400":{"description":"- ошибка валидации (`ValidationError`): `columnId`, `moveTo` или ID доски — не UUID;\n- `moveTo` совпадает с удаляемой колонкой — «Выберите другую колонку для переноса задач».\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/Error"}]},"example":{"message":"Выберите другую колонку для переноса задач","error":"Bad Request","statusCode":400}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Роль на доске — `viewer`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Недостаточно прав на доске","error":"Forbidden","statusCode":403}}}},"404":{"description":"- доска недоступна — «Доска не найдена»;\n- колонки нет на этой доске — «Колонка не найдена»;\n- колонки `moveTo` нет на этой доске — «Колонка для переноса не найдена на этой доске».\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Колонка для переноса не найдена на этой доске","error":"Not Found","statusCode":404}}}},"409":{"description":"- у доски одна колонка — «Единственную колонку доски удалить нельзя»;\n- это последняя колонка — «Последнюю колонку удалить нельзя: задачи в ней считаются выполненными. Сначала поставьте в конец другую колонку». Сначала переставьте колонки (`PUT /boards/{id}/columns/order`);\n- в колонке есть задачи, а `moveTo` не передан или `null` — «В колонке есть задачи. Выберите колонку, куда их перенести»;\n- `moveTo` — последняя колонка, а после переноса у какой-то задачи в ней подзадача или блокирующая задача стоит вне последней колонки — «Сначала завершите подзадачи и блокирующие задачи. Если они скрыты, обратитесь к администратору доски.»\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"В колонке есть задачи. Выберите колонку, куда их перенести","error":"Conflict","statusCode":409}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/boards/{id}/members":{"post":{"operationId":"grantBoardRole","tags":["Доски"],"summary":"Выдать роль на доске","description":"Выдаёт участнику организации явную роль на доске или меняет существующую. Можно выдать любую роль, в том числе `admin` и в том числе гостю. Явная роль владельца и админов организации записывается, но на их действующую роль (`admin`) не влияет.\n\n**Доступ:** `admin` доски, только на доске организации.\n\n**Побочные эффекты:** запись в [board_members](#модели/dbboard-members), событие `board.access_granted` (`{ userId, role }`), realtime-событие `changed`.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BoardMemberInput"},"example":{"userId":"c2e4f6a8-1b3d-4f5e-8a7c-9d0b1e2f3a4b","role":"editor"}}}},"responses":{"201":{"description":"Роль выдана","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"- роль на доске ниже `admin` — «Недостаточно прав на доске»;\n- доска личная — «Участники доступны в корпоративной доске»;\n- `userId` — сам пользователь — «Свою роль здесь менять нельзя».\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Участники доступны в корпоративной доске","error":"Forbidden","statusCode":403}}}},"404":{"description":"Доска недоступна («Доска не найдена») или `userId` не состоит в организации («Сначала пригласите человека в организацию»).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Сначала пригласите человека в организацию","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/boards/{id}/members/{userId}":{"delete":{"operationId":"revokeBoardRole","tags":["Доски"],"summary":"Отозвать роль на доске","description":"Удаляет явную роль пользователя на доске. Идемпотентно: если роли не было, ничего не меняется и событие не пишется. Доступ может остаться, если он есть через роль `owner`/`admin` в организации или как `member` при видимости `workspace`. Это показывает поле `accessRemains` ответа.\n\n**Доступ:** `admin` доски. Отозвать свою роль нельзя.\n\n**Побочные эффекты:** строка доски блокируется на время транзакции. Если явная роль была:\n- все, кто после этого не может открыть доску, теряют роли в задачах доски и снимаются с исполнителей её задач (у задач растёт `version`, в историю пишется `updated` с `assigneeId`). Кто сохраняет доступ через организацию, сохраняет и роли в задачах, и назначения;\n- событие `board.access_revoked` (`{ userId, role, accessRemains, unassigned }`, где `role` — отозванная роль, `unassigned` — число снятых назначений), realtime-событие `changed`.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"},{"name":"userId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID пользователя"}],"responses":{"200":{"description":"Роль отозвана (или её не было)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BoardRoleRevoked"},"examples":{"lost":{"summary":"Доступ к доске потерян","value":{"ok":true,"accessRemains":false}},"remains":{"summary":"Доступ остался через организацию","value":{"ok":true,"accessRemains":true}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Роль на доске ниже `admin` («Недостаточно прав на доске») или `userId` — сам пользователь («Нельзя удалить собственный доступ»).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Нельзя удалить собственный доступ","error":"Forbidden","statusCode":403}}}},"404":{"description":"Доски нет или у пользователя нет к ней доступа.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Доска не найдена","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/boards/{id}/people":{"get":{"operationId":"listBoardPeople","tags":["Доски"],"summary":"Люди с доступом к доске","description":"Список людей, у которых есть доступ к доске, с их действующей ролью. Используется, например, для выбора исполнителя и упоминаний.\n\n- Личная доска — только сам пользователь с ролью `admin`.\n- Доска организации — владелец и админы организации (роль `admin`), пользователи с явной ролью, а при видимости `workspace` ещё и все участники с ролью `member` (`viewer`, если явной роли нет). Отсортировано по имени.\n\n**Доступ:** любая роль на доске. Если текущий пользователь — гость организации, он видит только свой email, у остальных приходит `email: null` (как в `GET /workspaces/{id}`).\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"}],"responses":{"200":{"description":"Люди доски","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/BoardPerson"}},"example":[{"id":"c2e4f6a8-1b3d-4f5e-8a7c-9d0b1e2f3a4b","name":"Анна Смирнова","kind":"human","email":"anna@example.com","avatarColor":"#9ab3cf","role":"editor","explicitRole":"editor"},{"id":"e9f8d7c6-b5a4-4c3d-9e2f-1a0b9c8d7e6f","name":"Ассистент Метод","kind":"ai","email":"ai-e9f8d7c6-b5a4-4c3d-9e2f-1a0b9c8d7e6f@metodox.invalid","avatarColor":"#b3cf9a","role":"viewer","explicitRole":null},{"id":"7a1d9c3e-5b2f-4e8a-b6c4-1f0e2d3c4b5a","name":"Сергей Ковалёв","kind":"human","email":"sergey@example.com","avatarColor":"#cfb39a","role":"admin","explicitRole":"admin"}]}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Доски нет или у пользователя нет к ней доступа.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Доска не найдена","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/boards/{id}/tasks":{"post":{"operationId":"createBoardTask","tags":["Задачи"],"summary":"Создать задачу","description":"Создаёт задачу в указанной колонке доски. Автором становится текущий пользователь. Номер задачи на доске (`number`) назначает сервер.\n\nПорядок проверок: роль на доске → тело → доступ исполнителя к доске → (в транзакции под блокировкой строки доски) колонка есть на доске.\n\n**Доступ:** `editor` или `admin` доски. Исполнитель (`assigneeId`) должен иметь доступ к доске.\n\n**Побочные эффекты:**\n- запись в [tasks](#модели/dbtasks), событие истории `created` (копируется в журнал организации как `task.created`, отправляется realtime-событие `changed`);\n- если назначен исполнитель, отличный от автора, — уведомление `assigned`;\n- если срок не позже завтрашнего дня (по часовому поясу получателя), а задача не в последней колонке, — сразу уведомление `due_soon` или `overdue`;\n- если задача создана сразу в последней колонке, она засчитывается выполненной: `completed_at` и награда, как при завершении (раздел «Задачи»). Итог — в поле `reward` ответа.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskCreateInput"},"example":{"title":"Подготовить бриф","description":"Собрать требования от отдела продаж","columnId":"0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d","priority":"high","label":"контент","dueDate":"2026-10-10","assigneeId":"c2e4f6a8-1b3d-4f5e-8a7c-9d0b1e2f3a4b","visibility":"board"}}}},"responses":{"201":{"description":"Задача создана","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskCreated"},"examples":{"created":{"summary":"Обычная задача","value":{"id":"9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d","number":12,"title":"Подготовить бриф","description":"Собрать требования от отдела продаж","columnId":"0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d","priority":"high","label":"контент","dueDate":"2026-10-10","assigneeId":"c2e4f6a8-1b3d-4f5e-8a7c-9d0b1e2f3a4b","visibility":"board","version":1}},"completed":{"summary":"Создана сразу в последней колонке","value":{"id":"4d5e6f7a-8b9c-4d0e-9f1a-2b3c4d5e6f7a","number":14,"title":"Согласовать бюджет","description":"","columnId":"3a4b5c6d-7e8f-4a9b-8c1d-2e3f4a5b6c7d","priority":"medium","label":"","dueDate":null,"assigneeId":null,"visibility":"board","version":1,"reward":{"coins":10,"xp":10,"unlocked":[],"forActor":true}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Роль на доске — `viewer` («Недостаточно прав на доске») или у исполнителя нет доступа к доске («Участнику сначала нужен доступ к доске»).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Участнику сначала нужен доступ к доске","error":"Forbidden","statusCode":403}}}},"404":{"description":"Доска недоступна («Доска не найдена») или колонки нет на этой доске («Колонка не найдена»).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Колонка не найдена","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/boards/{id}/tasks/{taskId}":{"get":{"operationId":"getBoardTaskDetails","tags":["Задачи"],"summary":"Карточка задачи — обсуждение и история","description":"Возвращает права текущего пользователя на задачу, текущую `version` и значения полей (`task`), последние 100 комментариев, историю, вложения и явных участников. По `task` открытая карточка может подхватить правки коллег, не теряя своих. Номер, автор, данные исполнителя и счётчики приходят только в `GET /boards/{id}`.\n\nУдалённые комментарии остаются в списке с `deleted: true` и пустым `body`, чтобы ответы на них сохранили место. Для каждого комментария и вложения сервер сообщает, может ли текущий пользователь его изменить или удалить (`canEdit`, `canDelete`).\n\n**Доступ:** любой, кто видит задачу. Скрытая задача (`restricted`) даёт 404.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"},{"name":"taskId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID задачи"}],"responses":{"200":{"description":"Карточка задачи","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskDetail"},"example":{"canEdit":true,"canManage":false,"canComment":true,"version":3,"task":{"title":"Подготовить бриф","description":"Собрать требования от отдела продаж","columnId":"1a2b3c4d-5e6f-4a7b-8c8d-9e0f1a2b3c4e","priority":"high","label":"контент","dueDate":"2026-10-10","assigneeId":"c2e4f6a8-1b3d-4f5e-8a7c-9d0b1e2f3a4b","visibility":"board","version":3},"comments":[{"id":"b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e","body":"[@Анна Смирнова](/people/c2e4f6a8-1b3d-4f5e-8a7c-9d0b1e2f3a4b), посмотри черновик брифа","forwarded":null,"createdAt":"2026-10-02T09:15:00.000Z","authorId":"7a1d9c3e-5b2f-4e8a-b6c4-1f0e2d3c4b5a","authorKind":"human","authorName":"Сергей Ковалёв","avatarColor":"#cfb39a","editedAt":"2026-10-02T09:20:00.000Z","deleted":false,"reply":null,"reactions":[{"emoji":"👍","count":1,"mine":false,"names":["Анна Смирнова"]}],"canEdit":false,"canDelete":false},{"id":"e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a8b","body":"","forwarded":null,"createdAt":"2026-10-02T09:40:00.000Z","authorId":"c2e4f6a8-1b3d-4f5e-8a7c-9d0b1e2f3a4b","authorKind":"human","authorName":"Анна Смирнова","avatarColor":"#9ab3cf","editedAt":null,"deleted":true,"reply":{"id":"b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e","body":"[@Анна Смирнова](/people/c2e4f6a8-1b3d-4f5e-8a7c-9d0b1e2f3a4b), посмотри черновик брифа","authorName":"Сергей Ковалёв","deleted":false},"reactions":[],"canEdit":false,"canDelete":false},{"id":"f7a8b9c0-d1e2-4f3a-9b4c-5d6e7f8a9b0c","body":"Черновик выглядит хорошо, добавлю сроки","forwarded":null,"createdAt":"2026-10-02T10:05:00.000Z","authorId":"c2e4f6a8-1b3d-4f5e-8a7c-9d0b1e2f3a4b","authorKind":"human","authorName":"Анна Смирнова","avatarColor":"#9ab3cf","editedAt":null,"deleted":false,"reply":{"id":"e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a8b","body":"","authorName":"Анна Смирнова","deleted":true},"reactions":[],"canEdit":true,"canDelete":true}],"commentCount":3,"history":[{"id":"f6a7b8c9-d0e1-4f2a-9b3c-4d5e6f7a8b9c","action":"updated","details":{"fields":["columnId"],"changes":{"columnId":{"from":"0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d","to":"1a2b3c4d-5e6f-4a7b-8c8d-9e0f1a2b3c4e"}}},"createdAt":"2026-10-02T10:00:00.000Z","actorName":"Анна Смирнова"},{"id":"1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a","action":"comment_deleted","details":{"commentId":"e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a8b","attachments":0},"createdAt":"2026-10-02T09:45:00.000Z","actorName":"Анна Смирнова"},{"id":"0c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f","action":"created","details":{},"createdAt":"2026-10-01T08:30:00.000Z","actorName":"Сергей Ковалёв"}],"attachments":[{"id":"d4e5f6a7-b8c9-4d0e-8f1a-2b3c4d5e6f7a","name":"brief-v1.pdf","mime":"application/pdf","size":482113,"commentId":null,"authorId":"7a1d9c3e-5b2f-4e8a-b6c4-1f0e2d3c4b5a","createdAt":"2026-10-02T09:10:00.000Z","authorName":"Сергей Ковалёв","canDelete":true}],"members":[{"id":"c2e4f6a8-1b3d-4f5e-8a7c-9d0b1e2f3a4b","name":"Анна Смирнова","email":"anna@example.com","role":"editor"}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Доска недоступна («Доска не найдена»), задачи нет на этой доске или она скрыта («Задача не найдена»).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Задача не найдена","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"patch":{"operationId":"updateBoardTask","tags":["Задачи"],"summary":"Изменить задачу","description":"Частично обновляет поля задачи и переносит её между колонками. Нужна актуальная `version` (оптимистичная блокировка).\n\nПорядок проверок: права на задачу (`editor`) → тело запроса → право менять видимость и исполнителя → доступ нового исполнителя к доске. Затем в транзакции блокируются строки доски и задачи, сверяется `version`, проверяется колонка и, если колонка меняется, правила завершения по графу задач.\n\n**Доступ:** роль `editor` в задаче (раздел «Задачи»). Смена `visibility` или `assigneeId` — только с `canManage`.\n\n**Побочные эффекты:**\n- `version` увеличивается на 1, обновляется `updated_at` — даже если ни одно значение не изменилось;\n- событие истории `updated` с `fields` и `changes` (`from`/`to`) только по полям, значение которых действительно изменилось; если не изменилось ничего, события нет. Событие копируется в журнал организации, отправляется realtime-событие `changed`;\n- при смене исполнителя — уведомление `assigned` новому исполнителю (если это не автор запроса);\n- если меняется срок, исполнитель или колонка и задача теперь подходит под напоминание (срок не позже завтрашнего дня, задача не в последней колонке), сразу создаётся `due_soon` или `overdue`;\n- при смене колонки — учёт завершения: перенос в последнюю колонку проставляет `completed_at` и может начислить награду (`reward` в ответе), перенос в другую колонку очищает `completed_at` и `completed_by`.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"},{"name":"taskId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID задачи"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskUpdateInput"},"example":{"columnId":"3a4b5c6d-7e8f-4a9b-8c1d-2e3f4a5b6c7d","version":3}}}},"responses":{"200":{"description":"Задача обновлена","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskUpdateResult"},"examples":{"completed":{"summary":"Перенос в последнюю колонку","value":{"id":"9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d","version":4,"reward":{"coins":10,"xp":10,"unlocked":["Первый шаг"],"forActor":false}}},"edited":{"summary":"Обычное изменение","value":{"id":"9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d","version":4}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"- нет роли `editor` в задаче — «Недостаточно прав на задаче»;\n- смена видимости или исполнителя без `canManage` — «Только автор или администратор управляет видимостью и исполнителем»;\n- у исполнителя нет доступа к доске — «Участнику сначала нужен доступ к доске».\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Только автор или администратор управляет видимостью и исполнителем","error":"Forbidden","statusCode":403}}}},"404":{"description":"Доска недоступна («Доска не найдена»), задачи нет или она скрыта («Задача не найдена»), колонки нет на этой доске («Колонка не найдена»). Если задачу удалили одновременно с запросом, `message` — «Not Found».","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Колонка не найдена","error":"Not Found","statusCode":404}}}},"409":{"description":"- `version` устарела — «Задачу изменили другие участники. Обновите карточку и сохраните ещё раз.»;\n- перенос в последнюю колонку при невыполненных предпосылках — «Сначала завершите подзадачи и блокирующие задачи. Если они скрыты, обратитесь к администратору доски.»;\n- перенос из последней колонки, когда выполнена зависимая задача — «Сначала верните в работу завершённую родительскую или зависимую задачу.»\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Задачу изменили другие участники. Обновите карточку и сохраните ещё раз.","error":"Conflict","statusCode":409}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"delete":{"operationId":"deleteBoardTask","tags":["Задачи"],"summary":"Удалить задачу","description":"Удаляет задачу. Каскадно удаляются её связи, комментарии, вложения, роли и история. Вложения в S3 ставятся в очередь на удаление. Зависимые задачи после удаления связей больше не ждут эту задачу.\n\n**Доступ:** `canManage` — `admin` доски или автор задачи с ролью `editor` на доске.\n\n**Побочные эффекты:** событие `task.deleted` в журнале организации (сохраняется после удаления задачи), realtime-событие `changed`.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"},{"name":"taskId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID задачи"}],"responses":{"200":{"description":"Задача удалена","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Нет права управлять задачей.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Недостаточно прав на задаче","error":"Forbidden","statusCode":403}}}},"404":{"description":"Доска недоступна («Доска не найдена»), задачи нет или она скрыта («Задача не найдена»).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Задача не найдена","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/boards/{id}/tasks/{taskId}/attachments":{"post":{"operationId":"uploadTaskAttachment","tags":["Задачи"],"summary":"Загрузить вложение","description":"Загружает один файл в задачу (`multipart/form-data`, поле `file`, других полей быть не должно). Позже файл можно прикрепить к комментарию через `attachmentIds`.\n\nПрава и свободный слот обработки проверяются до приёма файла (`TaskUploadGuard`), затем права проверяются ещё раз. Порядок дальнейших проверок: файл передан → он не пустой → тип определяется по содержимому (список форматов в описании раздела) → лимит задачи. Суммарный размер вложений проверяется ещё раз в транзакции под блокировкой строки задачи.\n\nИмя файла читается как UTF-8, поэтому кириллица сохраняется. Затем оно приводится к NFC, управляющие символы, `/`, `\\` и символы управления направлением текста заменяются на `_`, пробелы по краям обрезаются, остаётся не больше 200 символов. Пустое имя заменяется на `attachment`.\n\n**Доступ:** право комментировать задачу (`editor` или `commenter`).\n\n**Побочные эффекты:** запись в [attachments](#модели/dbattachments). Содержимое сохраняется в базе или в S3 (зашифрованным) в зависимости от `STORAGE_DRIVER`. Событие истории `attached` (`{ name }`), realtime-событие `changed`.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"},{"name":"taskId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID задачи"}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["file"],"additionalProperties":false,"properties":{"file":{"type":"string","format":"binary","description":"Файл до 100 МБ."}}}}}},"responses":{"201":{"description":"Файл загружен","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskAttachmentUploaded"},"example":{"id":"d4e5f6a7-b8c9-4d0e-8f1a-2b3c4d5e6f7a","name":"brief-v1.pdf","mime":"application/pdf","size":482113}}}},"400":{"description":"- файла нет — «Выберите файл»;\n- файл пустой (0 байт) — «Файл пустой. Выберите файл с содержимым»;\n- тип не поддерживается — «Поддерживаются изображения, видео, аудио, PDF, Office, ZIP и текстовые файлы (TXT, MD, CSV, JSON)»;\n- превышен лимит задачи — «Лимит вложений задачи — 1 ГБ»;\n- ошибки multipart: лишние поля («Too many fields»), больше одного файла, поле с другим именем и т.п.;\n- неверный UUID в пути (`ValidationError`).\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/Error"},{"$ref":"#/components/schemas/ValidationError"}]},"example":{"message":"Лимит вложений задачи — 1 ГБ","error":"Bad Request","statusCode":400}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Нет права комментировать задачу.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Недостаточно прав на задаче","error":"Forbidden","statusCode":403}}}},"404":{"description":"Доска недоступна («Доска не найдена»), задачи нет или она скрыта («Задача не найдена»).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Задача не найдена","error":"Not Found","statusCode":404}}}},"413":{"description":"Файл больше 100 МБ (сообщение multer «File too large»).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"File too large","error":"Payload Too Large","statusCode":413}}}},"429":{"$ref":"#/components/responses/TooManyRequests"},"503":{"description":"Сервер уже обрабатывает максимум передач файлов (2 крупнее 10 МБ или 12 поменьше). Размер оценивается по `Content-Length` запроса.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Сервер обрабатывает другие файлы. Повторите через несколько секунд.","error":"Service Unavailable","statusCode":503}}}}}}},"/api/v1/boards/{id}/tasks/{taskId}/attachments/{attachmentId}":{"get":{"operationId":"downloadTaskAttachment","tags":["Задачи"],"summary":"Скачать вложение","description":"Отдаёт содержимое вложения с исходным MIME-типом.\n\n- Изображения, видео и аудио отдаются с `Content-Disposition: inline`, PDF — `inline` только с `preview=1`, остальное — `attachment`. `download=1` всегда даёт `attachment`.\n- Поддерживается один диапазон в заголовке `Range` (`bytes=начало-конец`, `bytes=начало-`, `bytes=-длина`) — ответ 206.\n- Всегда выставляются `Cache-Control: private, no-store`, `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; sandbox`.\n\n**Доступ:** любой, кто видит задачу.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"},{"name":"taskId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID задачи"},{"name":"attachmentId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID вложения"},{"name":"preview","in":"query","required":false,"schema":{"type":"string","enum":["1"]},"description":"`1` — открыть PDF во встроенном просмотре (`inline`)."},{"name":"download","in":"query","required":false,"schema":{"type":"string","enum":["1"]},"description":"`1` — всегда отдавать как файл для сохранения (`attachment`)."},{"name":"Range","in":"header","required":false,"schema":{"type":"string","example":"bytes=0-1048575"},"description":"Один диапазон байтов."}],"responses":{"200":{"description":"Содержимое файла целиком. `Content-Type` — MIME-тип вложения.","headers":{"Content-Disposition":{"description":"`inline` или `attachment` с именем файла в `filename*=UTF-8''…`.","schema":{"type":"string"}},"Accept-Ranges":{"schema":{"type":"string","const":"bytes"}}},"content":{"*/*":{"schema":{"type":"string","format":"binary"}}}},"206":{"description":"Запрошенный диапазон.","headers":{"Content-Range":{"description":"`bytes начало-конец/размер`.","schema":{"type":"string"}}},"content":{"*/*":{"schema":{"type":"string","format":"binary"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Доска недоступна («Доска не найдена»), задача скрыта или её нет («Задача не найдена»), вложения нет в этой задаче («Not Found»).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Not Found","statusCode":404}}}},"416":{"description":"Диапазон `Range` некорректен или выходит за размер файла. Тело пустое, заголовок `Content-Range` — `bytes */размер`."},"429":{"$ref":"#/components/responses/TooManyRequests"},"503":{"description":"Сервер уже обрабатывает максимум передач файлов.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Сервер обрабатывает другие файлы. Повторите через несколько секунд.","error":"Service Unavailable","statusCode":503}}}}}},"delete":{"operationId":"deleteTaskAttachment","tags":["Задачи"],"summary":"Удалить вложение","description":"Удаляет вложение задачи. Если оно было прикреплено к комментарию, комментарий остаётся.\n\n**Доступ:** право комментировать задачу, и при этом роль `editor` в задаче или авторство вложения. Если вложения нет или удалять его нельзя, ответ одинаковый — 404.\n\n**Побочные эффекты:** S3-объект ставится в очередь на удаление. Событие истории `attachment_removed` (`{ name }`), realtime-событие `changed`.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"},{"name":"taskId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID задачи"},{"name":"attachmentId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID вложения"}],"responses":{"200":{"description":"Вложение удалено","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Нет права комментировать задачу.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Недостаточно прав на задаче","error":"Forbidden","statusCode":403}}}},"404":{"description":"Доска или задача недоступна, вложения нет, или удалять его нельзя (чужое вложение без роли `editor`). В последних двух случаях `message` — «Not Found».","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/boards/{id}/tasks/{taskId}/comments":{"post":{"operationId":"createTaskComment","tags":["Задачи"],"summary":"Написать комментарий","description":"Добавляет комментарий к задаче. Можно ответить на комментарий (`replyTo`), упомянуть участников и прикрепить ранее загруженные вложения.\n\nПорядок проверок: право комментировать → тело запроса → есть текст или вложения → упоминания (не больше 20, каждый упомянутый должен видеть задачу) → `replyTo` → вложения.\n\n**Доступ:** право комментировать задачу (`editor` или `commenter`).\n\n**Побочные эффекты:**\n- записи в [comments](#модели/dbcomments) и `comment_mentions`, вложения получают `commentId`;\n- событие истории `commented`, копируется в журнал организации, отправляется realtime-событие `changed`;\n- уведомления `mention`, `reply` или `comment` (правила в описании раздела), для `mention` и `reply` при `MAIL_ENABLED=true` — копия письмом.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"},{"name":"taskId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID задачи"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskCommentInput"},"example":{"body":"[@Анна Смирнова](/people/c2e4f6a8-1b3d-4f5e-8a7c-9d0b1e2f3a4b), посмотри черновик брифа","attachmentIds":["d4e5f6a7-b8c9-4d0e-8f1a-2b3c4d5e6f7a"],"replyTo":null}}}},"responses":{"201":{"description":"Комментарий создан","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskCommentCreated"},"example":{"id":"b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e"}}}},"400":{"description":"- ошибка валидации (`ValidationError`);\n- пустой текст без вложений — «Напишите текст комментария или прикрепите файл»;\n- больше 20 упоминаний — «Не больше 20 упоминаний в комментарии»;\n- упомянутый не видит задачу — «Упомянутый участник больше не имеет доступа к задаче»;\n- `replyTo` не из этой задачи — «Сообщение для ответа недоступно»;\n- вложения чужие, из другой задачи, уже прикреплены или повторяются — «Вложения недоступны или уже использованы».\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/Error"}]},"example":{"message":"Вложения недоступны или уже использованы","error":"Bad Request","statusCode":400}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Нет права комментировать задачу.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Недостаточно прав на задаче","error":"Forbidden","statusCode":403}}}},"404":{"description":"Доска недоступна («Доска не найдена»), задачи нет или она скрыта («Задача не найдена»).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Задача не найдена","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/boards/{id}/tasks/{taskId}/comments/{commentId}":{"patch":{"operationId":"editTaskComment","tags":["Задачи"],"summary":"Изменить комментарий","description":"Заменяет текст своего комментария. Изменить можно в течение 24 часов после отправки; пересланные сообщения не меняются. Вложения, ответ и реакции остаются как были.\n\nПорядок проверок: право комментировать → ID комментария → тело → упоминания (не больше 20, каждый упомянутый должен видеть задачу) → (в транзакции под блокировкой строки комментария) комментарий есть в этой задаче и не удалён → автор — текущий пользователь → комментарий не пересланный → отправлен меньше 24 часов назад → текст не пустой, если у комментария нет вложений.\n\nЕсли текст после обрезки пробелов совпадает с прежним, ничего не записывается: ответ содержит прежние `body` и `editedAt` (`null`, если комментарий раньше не меняли).\n\n**Доступ:** только автор комментария, и у него должно быть право комментировать задачу (`editor` или `commenter`). Чужие комментарии не может изменить никто, включая `admin` доски.\n\n**Побочные эффекты:**\n- в [comments](#модели/dbcomments) обновляются `body` и `edited_at`; упоминания комментария (`comment_mentions`) заменяются упоминаниями из нового текста;\n- событие истории `comment_edited` (`{ commentId }`), копируется в журнал организации, отправляется realtime-событие `changed`;\n- уведомление `mention` получают только впервые упомянутые в новом тексте, кроме автора, если у них включены уведомления о комментариях. Ключ дедупликации — `{commentId}:mention`, поэтому из-за правок одному человеку по одному комментарию приходит не больше одного такого уведомления. При `MAIL_ENABLED=true` — копия письмом. Остальные участники о правке не уведомляются.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"},{"name":"taskId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID задачи"},{"name":"commentId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID комментария"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskCommentEditInput"},"example":{"body":"[@Анна Смирнова](/people/c2e4f6a8-1b3d-4f5e-8a7c-9d0b1e2f3a4b), посмотри черновик брифа до пятницы"}}}},"responses":{"200":{"description":"Комментарий сохранён (или текст не изменился)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskCommentEdited"},"example":{"id":"b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e","body":"[@Анна Смирнова](/people/c2e4f6a8-1b3d-4f5e-8a7c-9d0b1e2f3a4b), посмотри черновик брифа до пятницы","editedAt":"2026-10-02T09:20:00.000Z"}}}},"400":{"description":"- ошибка валидации (`ValidationError`): нет `body`, он не строка или длиннее 20 000 символов, неверный UUID в пути;\n- больше 20 упоминаний — «Не больше 20 упоминаний в комментарии»;\n- упомянутый не видит задачу — «Упомянутый участник больше не имеет доступа к задаче»;\n- комментарий пересланный — «Пересланное сообщение нельзя изменить»;\n- пустой текст у комментария без вложений — «Комментарий без вложений не может быть пустым. Чтобы убрать его, удалите комментарий».\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/Error"}]},"example":{"message":"Комментарий без вложений не может быть пустым. Чтобы убрать его, удалите комментарий","error":"Bad Request","statusCode":400}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"- нет права комментировать задачу — «Недостаточно прав на задаче»;\n- комментарий чужой — «Изменить можно только свой комментарий»;\n- с отправки прошло 24 часа или больше — «Комментарий можно изменить в течение 24 часов после отправки».\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Комментарий можно изменить в течение 24 часов после отправки","error":"Forbidden","statusCode":403}}}},"404":{"description":"Доска недоступна («Доска не найдена»), задачи нет или она скрыта («Задача не найдена»), комментария нет в этой задаче или он удалён («Комментарий не найден»).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Комментарий не найден","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"delete":{"operationId":"deleteTaskComment","tags":["Задачи"],"summary":"Удалить комментарий","description":"Удаляет комментарий без возможности восстановления. Строка остаётся как «надгробие»: текст очищается, метка пересылки снимается, проставляется `deleted_at`. Ответы на комментарий сохраняют своё место в ветке, а в карточке задачи он приходит с `deleted: true` и пустым `body`.\n\nПорядок проверок: доступ к задаче → ID комментария → (в транзакции под блокировкой строки комментария) комментарий есть в этой задаче и не удалён → право удалять.\n\n**Доступ:** `admin` доски — любой комментарий. Автор — свой, если у него есть право комментировать задачу (`editor` или `commenter`). Остальным — 403.\n\n**Побочные эффекты:**\n- вложения комментария удаляются из [attachments](#модели/dbattachments) (объекты S3 ставятся в очередь на удаление триггером). Отдельные события `attachment_removed` не пишутся;\n- удаляются реакции на комментарий и его упоминания;\n- событие истории `comment_deleted` (`{ commentId, attachments }` — число удалённых вложений; если удалил не автор, ещё `name` — имя автора или «Участник», если аккаунт удалён). Событие копируется в журнал организации, отправляется realtime-событие `changed`;\n- уведомления об этом комментарии остаются в базе, но больше не показываются в списке уведомлений.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"},{"name":"taskId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID задачи"},{"name":"commentId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID комментария"}],"responses":{"200":{"description":"Комментарий удалён","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Пользователь не автор комментария с правом комментировать и не `admin` доски.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Удалить комментарий может его автор или администратор доски","error":"Forbidden","statusCode":403}}}},"404":{"description":"Доска недоступна («Доска не найдена»), задачи нет или она скрыта («Задача не найдена»), комментария нет в этой задаче или он уже удалён («Комментарий не найден»).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Комментарий не найден","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/boards/{id}/tasks/{taskId}/members":{"post":{"operationId":"grantTaskRole","tags":["Задачи"],"summary":"Выдать роль в задаче","description":"Выдаёт пользователю явную роль в задаче (`editor`, `commenter`, `viewer`) или меняет существующую. Явная роль открывает задачу с видимостью `restricted` и важнее роли на доске (раздел «Задачи»).\n\n**Доступ:** `canManage` — `admin` доски или автор задачи с ролью `editor` на доске. У получателя должен быть доступ к доске.\n\n**Побочные эффекты:** запись в [task_members](#модели/dbtask-members), событие истории `access_granted` (`{ name, role }`), realtime-событие `changed`.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"},{"name":"taskId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID задачи"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskMemberInput"},"example":{"userId":"c2e4f6a8-1b3d-4f5e-8a7c-9d0b1e2f3a4b","role":"commenter"}}}},"responses":{"201":{"description":"Роль выдана","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Нет права управлять задачей («Недостаточно прав на задаче») или у получателя нет доступа к доске («Участнику сначала нужен доступ к доске»).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Участнику сначала нужен доступ к доске","error":"Forbidden","statusCode":403}}}},"404":{"description":"Доска недоступна («Доска не найдена»), задачи нет или она скрыта («Задача не найдена»).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Задача не найдена","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/boards/{id}/tasks/{taskId}/members/{userId}":{"delete":{"operationId":"revokeTaskRole","tags":["Задачи"],"summary":"Отозвать роль в задаче","description":"Удаляет явную роль пользователя в задаче. Идемпотентно. Исполнитель задачи сохраняет роль `editor`, потому что она вытекает из назначения.\n\n**Доступ:** `canManage`.\n\n**Побочные эффекты:** если роль была, событие истории `access_revoked` (`{ name }`; «Участник», если аккаунта нет), копируется в журнал организации, отправляется realtime-событие `changed`. Если роли не было, ничего не пишется.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"},{"name":"taskId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID задачи"},{"name":"userId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID пользователя"}],"responses":{"200":{"description":"Роль отозвана","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Нет права управлять задачей.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Недостаточно прав на задаче","error":"Forbidden","statusCode":403}}}},"404":{"description":"Доска недоступна («Доска не найдена»), задачи нет или она скрыта («Задача не найдена»).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Задача не найдена","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/boards/{id}/tasks/{taskId}/relations":{"get":{"operationId":"listTaskRelations","tags":["Граф задач"],"summary":"Связи задачи","description":"Возвращает связи текущей задачи с видимыми пользователю задачами, флаг скрытых связей и признак того, что все предпосылки выполнены. Для каждой связи доступ ко второй задаче проверяется отдельно. Недоступные связи пропускаются без раскрытия ID.\n\n**Доступ:** любой, кто видит задачу.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"},{"name":"taskId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID задачи"}],"responses":{"200":{"description":"Связи","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskRelationList"},"example":{"links":[{"id":"a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d","taskId":"6e7f8a9b-0c1d-4e2f-8a3b-4c5d6e7f8a9b","title":"Собрать референсы","columnId":"0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d","status":"В планах","done":false,"type":"child"},{"id":"4b5c6d7e-8f9a-4b0c-9d1e-2f3a4b5c6d7e","taskId":"4d5e6f7a-8b9c-4d0e-9f1a-2b3c4d5e6f7a","title":"Согласовать бюджет","columnId":"3a4b5c6d-7e8f-4a9b-8c1d-2e3f4a5b6c7d","status":"Готово","done":true,"type":"blockedBy"}],"hasHiddenLinks":false,"canComplete":false}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Доска недоступна («Доска не найдена»), задачи нет или она скрыта («Задача не найдена»).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Задача не найдена","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"post":{"operationId":"createTaskRelation","tags":["Граф задач"],"summary":"Связать задачи","description":"Создаёт связь между текущей задачей и задачей `taskId` из тела запроса. `type` — кем станет другая задача для текущей: `child`, `parent`, `blockedBy` или `blocking`. Как это хранится — в описании раздела.\n\nВ транзакции под блокировкой строки доски проверяются инварианты графа: нет связи с самим собой, не больше одного родителя, нет дубликата, нет цикла, предпосылка не остаётся невыполненной при выполненной зависимой задаче.\n\n**Доступ:** роль `editor` в обеих задачах.\n\n**Побочные эффекты:** запись в [task_links](#модели/dbtask-links). Событие истории `relation_added` (`{ kind }`) в обеих задачах, realtime-событие `changed`.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"},{"name":"taskId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID текущей задачи"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskRelationInput"},"example":{"taskId":"4d5e6f7a-8b9c-4d0e-9f1a-2b3c4d5e6f7a","type":"blockedBy"}}}},"responses":{"201":{"description":"Связь создана. В ответе — форма хранения: для `blockedBy` source — другая задача, target — текущая.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskRelationCreated"},"example":{"id":"4b5c6d7e-8f9a-4b0c-9d1e-2f3a4b5c6d7e","sourceId":"4d5e6f7a-8b9c-4d0e-9f1a-2b3c4d5e6f7a","targetId":"9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d","kind":"blocks"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Нет роли `editor` в одной из задач.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Недостаточно прав на задаче","error":"Forbidden","statusCode":403}}}},"404":{"description":"Доска недоступна («Доска не найдена»), одной из задач нет на этой доске или она скрыта («Задача не найдена»).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Задача не найдена","error":"Not Found","statusCode":404}}}},"409":{"description":"- «Задача не может ссылаться сама на себя»;\n- у будущей подзадачи уже есть родитель — «У задачи уже есть родитель. Сначала удалите прежнюю связь.»;\n- «Эта связь уже существует»;\n- «Связь создаёт цикл между подзадачами и зависимостями»;\n- предпосылка не выполнена, а зависимая задача уже в последней колонке — «Сначала верните зависимую или родительскую задачу в работу».\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Связь создаёт цикл между подзадачами и зависимостями","error":"Conflict","statusCode":409}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/boards/{id}/tasks/{taskId}/relations/{linkId}":{"delete":{"operationId":"deleteTaskRelation","tags":["Граф задач"],"summary":"Удалить связь","description":"Удаляет связь, в которой участвует текущая задача.\n\n**Доступ:** роль `editor` в обеих задачах связи. Если вторая задача скрыта, ответ 404.\n\n**Побочные эффекты:** строка доски блокируется на время удаления. Событие истории `relation_removed` (`{ kind }`) в обеих задачах, realtime-событие `changed`.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"},{"name":"taskId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID текущей задачи"},{"name":"linkId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID связи (`TaskRelation.id`)"}],"responses":{"200":{"description":"Связь удалена","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Нет роли `editor` в одной из задач.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Недостаточно прав на задаче","error":"Forbidden","statusCode":403}}}},"404":{"description":"Доска или одна из задач недоступна, или связи нет у этой задачи («Связь не найдена»).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Связь не найдена","error":"Not Found","statusCode":404}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/boards/{id}/tasks/{taskId}/subtasks":{"post":{"operationId":"createSubtask","tags":["Граф задач"],"summary":"Создать подзадачу","description":"Создаёт новую задачу в первой колонке доски и сразу делает её подзадачей текущей. Подзадача наследует видимость родителя, её автор — текущий пользователь. Остальные поля получают значения по умолчанию.\n\nЕсли родитель уже в последней колонке, связь нарушила бы правило завершения: ответ 409, и подзадача не создаётся.\n\n**Доступ:** роль `editor` в родительской задаче и роль `editor` или `admin` на доске.\n\n**Побочные эффекты:** строка доски блокируется на время транзакции. Записи в [tasks](#модели/dbtasks) и [task_links](#модели/dbtask-links). События истории `created` у подзадачи и `relation_added` (`{ kind: subtask }`) у обеих задач, realtime-событие `changed`. Уведомления и награды не создаются.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"},{"name":"taskId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID родительской задачи"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubtaskInput"},"example":{"title":"Собрать референсы"}}}},"responses":{"201":{"description":"Подзадача создана","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubtaskCreated"},"example":{"id":"6e7f8a9b-0c1d-4e2f-8a3b-4c5d6e7f8a9b"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Нет роли `editor` в родительской задаче («Недостаточно прав на задаче») или роль на доске — `viewer` («Недостаточно прав на доске»).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Недостаточно прав на доске","error":"Forbidden","statusCode":403}}}},"404":{"description":"Доска недоступна («Доска не найдена»), задачи нет или она скрыта («Задача не найдена»).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Задача не найдена","error":"Not Found","statusCode":404}}}},"409":{"description":"Родительская задача уже в последней колонке.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"message":"Сначала верните зависимую или родительскую задачу в работу","error":"Conflict","statusCode":409}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/boards/{board}/tasks/{task}/participants":{"get":{"operationId":"listTaskMentionCandidates","tags":["Обсуждения задач"],"summary":"Кандидаты для @упоминания в задаче","description":"Возвращает людей, которых можно упомянуть в комментарии к задаче.\n\nАлгоритм (discussion.ts), одним SQL-запросом:\n- кандидаты — владелец личной доски (доска вне организации) или все участники организации доски\n  ([workspace_members](#модели/dbworkspace-members)), чьё имя содержит `q` без учёта регистра;\n- остаются только те, у кого есть право **чтения** этой задачи (та же логика, что в `AccessService.board` и\n  `AccessService.task`: роль в организации, [board_members](#модели/dbboard-members), видимость доски,\n  для `restricted` — автор, исполнитель, [task_members](#модели/dbtask-members) или администраторы);\n- фильтр по доступу применяется **до** `LIMIT 100`, поэтому ответ — до 100 подходящих кандидатов по алфавиту\n  (`name`, затем `id`), и каждого из них сервер примет в упоминании.\n\nВ список может попасть сам вызывающий и ИИ-сотрудники (`kind: ai`), если у них есть доступ.\n\n**Доступ:** право чтения задачи. Иначе `404`.\n\n**Побочные эффекты:** нет.\n","parameters":[{"name":"board","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"},{"name":"task","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID задачи"},{"name":"q","in":"query","required":false,"schema":{"type":"string","default":""},"description":"Подстрока имени; учитываются первые 80 символов. Пустая строка — все кандидаты."}],"responses":{"200":{"description":"Кандидаты по алфавиту (не больше 100)","content":{"application/json":{"schema":{"type":"array","maxItems":100,"items":{"$ref":"#/components/schemas/DiscussionParticipant"}},"example":[{"id":"3b0f6a52-8c1e-4d7a-9f2b-61c4e0a8d915","name":"Анна Смирнова","avatarColor":"#cfb39a","kind":"human"},{"id":"9d2e7c41-5a6b-4f3c-8e1d-2b7a90c4f6e3","name":"Аналитик ИИ","avatarColor":"#8fb3d9","kind":"ai"}]}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Доска или задача не найдена либо недоступна («Доска не найдена», «Задача не найдена»)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Задача не найдена","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/boards/{board}/tasks/{task}/comments":{"get":{"operationId":"listTaskCommentRoots","tags":["Обсуждения задач"],"summary":"Корневые комментарии задачи","description":"Страница **корневых** комментариев (без `replyTo`) — по 30 штук. Ответы не включаются: у каждого корня есть\n`replyCount`, а сама ветка загружается через `GET …/comments/{comment}/thread`.\n\nБез `before` возвращаются 30 самых новых корней. Чтобы загрузить более старые, передайте в `before`\nID самого старого загруженного корня (`items[0].id`). `before` должен быть корневым комментарием этой задачи,\nиначе `400` (см. ответы). Удалённые комментарии остаются в списке «надгробиями» (`deleted: true`).\n\nУ каждого комментария есть `canEdit` и `canDelete` — права текущего пользователя (см. описание тега).\n\n**Доступ:** право чтения задачи. Иначе `404`. Доступ проверяется до разбора `before`.\n\n**Побочные эффекты:** нет.\n","parameters":[{"name":"board","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"},{"name":"task","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID задачи"},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID корневого комментария этой задачи; вернутся корни строго старше него"}],"responses":{"200":{"description":"Страница корневых комментариев","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DiscussionRootsPage"},"example":{"items":[{"id":"5c8e1f2a-7b3d-4e6f-9a1b-2c3d4e5f6a7b","body":"Макет главной готов, [@Анна Смирнова](/people/3b0f6a52-8c1e-4d7a-9f2b-61c4e0a8d915), посмотри, пожалуйста.","rootId":null,"authorId":"7e4a1c9b-2d5f-4a8e-b3c6-0f1e2d3c4b5a","forwarded":null,"createdAt":"2026-10-01T09:12:44.512Z","authorName":"Игорь Петров","authorKind":"human","avatarColor":"#a3c4a8","editedAt":"2026-10-01T09:15:02.104Z","deleted":false,"canEdit":true,"canDelete":true,"reply":null,"replyCount":3,"reactions":[{"emoji":"👍","count":2,"mine":true,"names":["Анна Смирнова","Игорь Петров"]}]},{"id":"6d9f2a3b-8c4e-4f7a-9b2c-3d4e5f6a7b8c","body":"","rootId":null,"authorId":"3b0f6a52-8c1e-4d7a-9f2b-61c4e0a8d915","forwarded":null,"createdAt":"2026-10-01T10:02:17.640Z","authorName":"Анна Смирнова","authorKind":"human","avatarColor":"#cfb39a","editedAt":null,"deleted":true,"canEdit":false,"canDelete":false,"reply":null,"replyCount":1,"reactions":[]}],"hasOlder":true}}}},"400":{"description":"Некорректный UUID в `before` (`ValidationError`) или курсор не подходит (`Error`):\n- «Комментарий, от которого загружаются более ранние, не найден» — `before` не комментарий этой задачи;\n- «Параметр before должен указывать на корневой комментарий, а не на ответ в ветке» — `before` указывает на ответ.\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/Error"}]},"examples":{"unknownCursor":{"summary":"before не из этой задачи","value":{"statusCode":400,"message":"Комментарий, от которого загружаются более ранние, не найден","error":"Bad Request"}},"replyCursor":{"summary":"before указывает на ответ","value":{"statusCode":400,"message":"Параметр before должен указывать на корневой комментарий, а не на ответ в ветке","error":"Bad Request"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Доска или задача не найдена либо недоступна","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Доска не найдена","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/boards/{board}/tasks/{task}/comments/{comment}/thread":{"get":{"operationId":"getTaskCommentThread","tags":["Обсуждения задач"],"summary":"Ветка комментария","description":"Возвращает корень ветки (`root`) и страницу ответов (`items`, до 50, от старых к новым).\n`{comment}` может быть как корнем, так и любым ответом ветки — корень определяется автоматически.\n\nРежимы выборки:\n\n| Запрос | Что вернётся |\n|---|---|\n| `{comment}` — корень, без курсора | 50 самых новых ответов |\n| `{comment}` — ответ, без курсора | до 50 ответов, заканчивая **этим ответом включительно** (для глубоких ссылок из уведомлений) |\n| `?before={replyId}` | до 50 ответов строго старше `replyId` |\n| `?after={replyId}` | до 50 ответов строго новее `replyId` |\n\n`before` и `after` вместе — `400`. Курсор должен быть ответом **этой** ветки (не корнем и не комментарием\nдругой ветки или задачи), иначе `400`. `hasOlder` / `hasNewer` показывают, есть ли ответы за пределами страницы\n(при пустой странице оба `false`). Удалённые ответы и удалённый корень остаются «надгробиями» (`deleted: true`).\n\n**Доступ:** право чтения задачи. Иначе `404`. Доступ проверяется до разбора `{comment}` и курсоров.\n\n**Побочные эффекты:** нет.\n","parameters":[{"name":"board","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"},{"name":"task","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID задачи"},{"name":"comment","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID корня или любого ответа ветки"},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID ответа; вернутся более старые ответы. Нельзя вместе с `after`."},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID ответа; вернутся более новые ответы. Нельзя вместе с `before`."}],"responses":{"200":{"description":"Корень и страница ответов","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DiscussionThreadPage"},"example":{"root":{"id":"5c8e1f2a-7b3d-4e6f-9a1b-2c3d4e5f6a7b","body":"Макет главной готов, посмотрите, пожалуйста.","rootId":null,"authorId":"7e4a1c9b-2d5f-4a8e-b3c6-0f1e2d3c4b5a","forwarded":null,"createdAt":"2026-10-01T09:12:44.512Z","authorName":"Игорь Петров","authorKind":"human","avatarColor":"#a3c4a8","editedAt":null,"deleted":false,"canEdit":true,"canDelete":true,"reply":null,"replyCount":2,"reactions":[]},"items":[{"id":"0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d","body":"","rootId":"5c8e1f2a-7b3d-4e6f-9a1b-2c3d4e5f6a7b","authorId":"3b0f6a52-8c1e-4d7a-9f2b-61c4e0a8d915","forwarded":null,"createdAt":"2026-10-01T09:20:03.118Z","authorName":"Анна Смирнова","authorKind":"human","avatarColor":"#cfb39a","editedAt":null,"deleted":true,"canEdit":false,"canDelete":false,"reply":{"id":"5c8e1f2a-7b3d-4e6f-9a1b-2c3d4e5f6a7b","body":"Макет главной готов, посмотрите, пожалуйста.","authorName":"Игорь Петров","deleted":false},"replyCount":0,"reactions":[]},{"id":"1b2c3d4e-5f6a-4b7c-9d8e-0f1a2b3c4d5e","body":"Согласен, поправлю к вечеру.","rootId":"5c8e1f2a-7b3d-4e6f-9a1b-2c3d4e5f6a7b","authorId":"7e4a1c9b-2d5f-4a8e-b3c6-0f1e2d3c4b5a","forwarded":null,"createdAt":"2026-10-01T09:25:41.907Z","authorName":"Игорь Петров","authorKind":"human","avatarColor":"#a3c4a8","editedAt":"2026-10-01T09:27:10.315Z","deleted":false,"canEdit":true,"canDelete":true,"reply":{"id":"0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d","body":"","authorName":"Анна Смирнова","deleted":true},"replyCount":0,"reactions":[{"emoji":"🔥","count":1,"mine":false,"names":["Анна Смирнова"]}]}],"hasOlder":false,"hasNewer":false}}}},"400":{"description":"Некорректный UUID в пути или курсоре (`ValidationError`) либо курсор не подходит (`Error`):\n- «Укажите только один параметр: before или after» — переданы оба курсора;\n- «Параметры before и after должны указывать на ответ из этой ветки» — курсор не является ответом ветки\n  комментария `{comment}` в этой задаче.\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/Error"}]},"examples":{"bothCursors":{"summary":"before и after одновременно","value":{"statusCode":400,"message":"Укажите только один параметр: before или after","error":"Bad Request"}},"foreignCursor":{"summary":"Курсор не из этой ветки","value":{"statusCode":400,"message":"Параметры before и after должны указывать на ответ из этой ветки","error":"Bad Request"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Доска или задача недоступна («Доска не найдена», «Задача не найдена») либо комментарий\nне относится к этой задаче (сообщение по умолчанию `Not Found`).\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/messages/reaction":{"put":{"operationId":"setMessageReaction","tags":["Действия с сообщениями"],"summary":"Поставить или снять реакцию","description":"Ставит (`active: true`) или снимает (`active: false`) реакцию текущего пользователя на комментарий\nзадачи (`source.kind = task`, `source.id` — ID комментария) или личное сообщение\n(`source.kind = chat`, `source.id` — ID сообщения).\n\nОперация идемпотентна: повторная постановка той же реакции и снятие отсутствующей не дают ошибки.\nРеакции разными эмодзи независимы: новый эмодзи **добавляется** к уже поставленным текущим пользователем,\nа не заменяет их; чтобы сменить реакцию, снимите старую отдельным запросом.\nОдновременные переключения сериализуются блокировкой строки сообщения (`SELECT … FOR UPDATE`).\n\nПеред проверкой `emoji` без `U+FE0F` дополняется им, если так получается эмодзи (`❤` → `❤️`); правила — в описании тега.\n\n**Доступ:** для `chat` — участник принятой дружбы; для `task` — право комментировать задачу\n(роль `editor` или `commenter`). Читатель задачи (`viewer`) получает `403`. Удалённый комментарий — `404`.\n\n**Побочные эффекты:** запись/удаление в [message_reactions](#модели/dbmessage-reactions); триггер\n`reaction_changed` рассылает realtime: для личного сообщения — обоим участникам (область `friends`),\nдля комментария — подписчикам доски и её владельцу (области `boards`, `tasks`, `team`).\nУведомления не создаются.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageReactionInput"},"example":{"source":{"kind":"task","id":"5c8e1f2a-7b3d-4e6f-9a1b-2c3d4e5f6a7b"},"emoji":"👍","active":true}}}},"responses":{"200":{"description":"Реакция установлена или снята","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"description":"Ошибка валидации тела (`ValidationError`). При `active: true` и строке, которая не является ровно одним\nэмодзи, — ошибка поля `emoji` «Выберите одно эмодзи».\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"},"example":{"message":"Проверьте поля формы","errors":{"formErrors":[],"fieldErrors":{"emoji":["Выберите одно эмодзи"]}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Нет права комментировать задачу («Недостаточно прав на задаче»)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Недостаточно прав на задаче","error":"Forbidden"}}}},"404":{"description":"Сообщение не найдено («Сообщение недоступно»), комментарий удалён («Сообщение удалено»), переписка\nнедоступна или дружба не принята («Переписка недоступна»), доска или задача недоступна\n(«Доска не найдена», «Задача не найдена»).\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Сообщение недоступно","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/messages/forward":{"post":{"operationId":"forwardMessage","tags":["Действия с сообщениями"],"summary":"Переслать сообщение в задачу или чат","description":"Копирует комментарий или личное сообщение (`source`) в задачу или личную переписку (`target`)\nкак новое сообщение от имени текущего пользователя. Поддерживаются все четыре направления:\nзадача → задача, задача → чат, чат → задача, чат → чат.\n\n- Текст копируется как есть; ссылки на вложения источника (`/api/v1/boards/…/attachments/{id}` и\n  `/api/v1/social/friends/…/files/{id}`) заменяются на URL копий в цели.\n- Вложения (до 10) копируются независимо: новый ID, новая строка и новый зашифрованный объект.\n- В новом сообщении заполняется `forwarded` (`author`, `createdAt` оригинала); если источник уже\n  был пересылкой, сохраняется исходная метка.\n- Новый комментарий в задаче всегда корневой (без `replyTo`).\n- При `target.kind = task` @упоминания в тексте проверяются для задачи-цели (`validateMentions`): не больше 20,\n  у каждого упомянутого — право чтения этой задачи. Упоминания сохраняются в\n  [comment_mentions](#модели/dbcomment-mentions) и дают уведомление `mention`. При `target.kind = chat`\n  упоминания не проверяются и не сохраняются.\n- Удалённый комментарий переслать нельзя (`404 «Сообщение удалено»`).\n\nПроверки до копирования файлов: число вложений, длина текста, упоминания, объём цели. Объём проверяется\nещё раз в транзакции под блокировкой цели. Для цели-переписки считаются только **отправленные** файлы\n(неотправленные черновики в квоту не входят), для задачи — все её вложения.\n\n**Доступ:** чтение источника (для `task` — право чтения задачи, для `chat` — участник принятой\nдружбы); запись в цель (для `task` — право комментировать, для `chat` — участник принятой дружбы).\nПрава перепроверяются после копирования файлов.\n\n**Побочные эффекты:** вставка в [comments](#модели/dbcomments) или [direct_messages](#модели/dbdirect-messages),\nкопии в [attachments](#модели/dbattachments) / [direct_files](#модели/dbdirect-files). Для цели `task`:\nстроки [comment_mentions](#модели/dbcomment-mentions), событие `commented` в [task_events](#модели/dbtask-events)\nи уведомления `mention`/`comment` участникам задачи (см. тег «Уведомления»; `reply` не возникает, потому что\nкомментарий корневой). Для цели `chat` уведомлений нет. Realtime — через триггеры таблиц.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageForwardInput"},"examples":{"taskToChat":{"summary":"Комментарий задачи → личная переписка","value":{"source":{"kind":"task","id":"5c8e1f2a-7b3d-4e6f-9a1b-2c3d4e5f6a7b"},"target":{"kind":"chat","id":"6f7e8d9c-0b1a-4c2d-8e3f-4a5b6c7d8e9f"}}},"chatToTask":{"summary":"Личное сообщение → задача","value":{"source":{"kind":"chat","id":"2d3e4f5a-6b7c-4d8e-9f0a-1b2c3d4e5f6a"},"target":{"kind":"task","id":"c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f"}}}}}}},"responses":{"201":{"description":"Сообщение переслано","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageForwardResult"},"example":{"id":"8a9b0c1d-2e3f-4a5b-9c6d-7e8f9a0b1c2d"}}}},"400":{"description":"Ошибка валидации тела (`ValidationError`) или ограничение пересылки (`Error`):\n- «Не больше 10 вложений»;\n- «Сообщение слишком длинное для этого диалога» — больше 4 000 символов для чата или 20 000 для задачи;\n- «Не больше 20 упоминаний в комментарии» — только для цели `task`;\n- «Упомянутый участник больше не имеет доступа к задаче» — упомянутый не может читать задачу-цель;\n- «Недостаточно места для вложений: лимит 1 ГБ».\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/Error"}]},"examples":{"tooLong":{"summary":"Текст не помещается в чат","value":{"statusCode":400,"message":"Сообщение слишком длинное для этого диалога","error":"Bad Request"}},"mentionNoAccess":{"summary":"Упомянутый не видит задачу-цель","value":{"statusCode":400,"message":"Упомянутый участник больше не имеет доступа к задаче","error":"Bad Request"}},"noSpace":{"summary":"Превышен лимит 1 ГБ цели","value":{"statusCode":400,"message":"Недостаточно места для вложений: лимит 1 ГБ","error":"Bad Request"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Нет права комментировать задачу-цель («Недостаточно прав на задаче»)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Недостаточно прав на задаче","error":"Forbidden"}}}},"404":{"description":"Источник не найден («Сообщение недоступно») или удалён («Сообщение удалено»), переписка недоступна\n(«Переписка недоступна»), задача или доска недоступна, либо цель исчезла к моменту записи (`Not Found`).\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Переписка недоступна","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"},"503":{"description":"Пересылка с вложениями занимает слот передачи файлов по суммарному размеру копий: больше 10 МБ —\n«большой» (их два на сервер), иначе — один из 12 малых. Нет свободного слота —\n«Сервер обрабатывает другие файлы. Повторите через несколько секунд.»\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":503,"message":"Сервер обрабатывает другие файлы. Повторите через несколько секунд.","error":"Service Unavailable"}}}}}}},"/api/v1/social/friends":{"get":{"operationId":"listFriends","tags":["Друзья и чаты"],"summary":"Друзья и заявки","description":"Все записи дружбы текущего пользователя — принятые и ожидающие, входящие и исходящие\n(`outgoing`). Сортировка: по времени последнего сообщения, а без сообщений — по времени создания\nдружбы, новые сначала. Пагинации нет.\n\nНастроение собеседника (`mood`, `moodNote`, `moodDay`) заполняется, только если дружба принята, собеседник\nотметил `shareWithFriends` и запись сделана за **его сегодняшний** день (по его часовому поясу); иначе `null`.\n\n**Доступ:** любой авторизованный пользователь, только свои записи.\n\n**Побочные эффекты:** нет.\n","responses":{"200":{"description":"Список дружб","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SocialFriendship"}},"example":[{"id":"6f7e8d9c-0b1a-4c2d-8e3f-4a5b6c7d8e9f","status":"accepted","lastMessage":"Скинул тебе макет, глянь вечером","lastAt":"2026-10-02T07:41:09.330Z","outgoing":false,"userId":"3b0f6a52-8c1e-4d7a-9f2b-61c4e0a8d915","name":"Анна Смирнова","avatarColor":"#cfb39a","jobTitle":"Дизайнер","mood":4,"moodNote":"Хороший день, всё успеваю","moodDay":"2026-10-02"},{"id":"4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8","status":"pending","lastMessage":null,"lastAt":null,"outgoing":true,"userId":"9c8b7a6f-5e4d-4c3b-8a2f-1e0d9c8b7a6f","name":"Олег Ким","avatarColor":"#d9b38f","jobTitle":"","mood":null,"moodNote":null,"moodDay":null}]}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"post":{"operationId":"sendFriendRequest","tags":["Друзья и чаты"],"summary":"Отправить заявку в друзья","description":"Отправляет заявку в друзья и возвращает итоговую запись дружбы пары (`id`, `status`, `outgoing`).\nВ транзакции под advisory-блокировкой пары (параллельные заявки в обе стороны не создают дублей):\n\n| Что есть для пары | Что происходит | Ответ |\n|---|---|---|\n| ничего | создаётся заявка `pending`; получатель должен быть человеком (`account_kind = human`) с открытым профилем (`public_profile = true`) | `status: pending`, `outgoing: true` |\n| входящая заявка `pending` (собеседник уже пригласил вас) | встречная заявка **принимает** существующую | `status: accepted`, `outgoing: false` |\n| исходящая заявка `pending` | ничего не меняется | `status: pending`, `outgoing: true` |\n| дружба `accepted` | ничего не меняется | `status: accepted`, `outgoing` — кто отправлял исходную заявку |\n\nОткрытость профиля проверяется только при создании новой записи.\n\n**Доступ:** любой авторизованный пользователь.\n\n**Побочные эффекты:** вставка или обновление [friendships](#модели/dbfriendships); realtime `changed`\nс областью `friends` обоим пользователям. Уведомление в ленту не создаётся.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SocialFriendInviteInput"},"example":{"userId":"3b0f6a52-8c1e-4d7a-9f2b-61c4e0a8d915"}}}},"responses":{"201":{"description":"Заявка создана, встречная заявка принята или запись уже существовала","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SocialFriendRequestResult"},"examples":{"created":{"summary":"Новая заявка","value":{"ok":true,"id":"4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8","status":"pending","outgoing":true}},"counterAccepted":{"summary":"Встречная заявка — дружба принята","value":{"ok":true,"id":"6f7e8d9c-0b1a-4c2d-8e3f-4a5b6c7d8e9f","status":"accepted","outgoing":false}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Заявка самому себе («Это ваш профиль»)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Это ваш профиль","error":"Forbidden"}}}},"404":{"description":"Записи для пары нет, а пользователь не найден, профиль закрыт или это ИИ-аккаунт («Открытый профиль не найден»)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Открытый профиль не найден","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/social/friends/{id}/accept":{"post":{"operationId":"acceptFriendRequest","tags":["Друзья и чаты"],"summary":"Принять заявку в друзья","description":"Переводит дружбу в статус `accepted`. Принять может только получатель заявки (`recipient_id`).\nПовторное принятие уже принятой дружбы тоже отвечает `{ ok: true }`.\n\n**Доступ:** получатель заявки; для отправителя и посторонних — `404`.\n\n**Побочные эффекты:** обновление [friendships](#модели/dbfriendships); realtime `changed` с областью\n`friends` обоим пользователям. После принятия становятся доступны переписка и настроение друга.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID дружбы"}],"responses":{"201":{"description":"Заявка принята","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"Заявка не найдена или текущий пользователь не её получатель (`Not Found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/social/friends/{id}":{"delete":{"operationId":"removeFriendship","tags":["Друзья и чаты"],"summary":"Отклонить заявку или удалить друга","description":"Удаляет запись дружбы в любом статусе: отклонить входящую заявку, отозвать исходящую или удалить друга.\nВызвать может любая из сторон. Если записи нет или пользователь не её участник — `404 «Контакт не найден»`\n(в том числе при повторном удалении).\n\n**Удаление необратимо:** каскадом удаляются все личные сообщения, файлы и реакции этой переписки.\n\n**Доступ:** участник дружбы.\n\n**Побочные эффекты:** удаление из [friendships](#модели/dbfriendships) с каскадом в\n[direct_messages](#модели/dbdirect-messages), [direct_files](#модели/dbdirect-files) и\n[message_reactions](#модели/dbmessage-reactions); объекты S3 ставятся в очередь очистки\n([storage_cleanup](#модели/dbstorage-cleanup)); realtime `changed` с областью `friends` обоим.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID дружбы"}],"responses":{"200":{"description":"Запись дружбы удалена","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"Записи нет или текущий пользователь не её участник («Контакт не найден»)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Контакт не найден","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/social/friends/{id}/messages":{"get":{"operationId":"listDirectMessages","tags":["Друзья и чаты"],"summary":"История личной переписки","description":"До 100 сообщений переписки, от старых к новым. Без `before` — 100 последних.\n\n**Постраничная загрузка.** У каждого сообщения есть `cursor` (`<время UTC с микросекундами>~<id>`).\nЧтобы получить более старые сообщения, передайте в `before` курсор самого старого загруженного\n(`items[0].cursor` или `nextCursor`): вернутся сообщения строго раньше по `(created_at, id)`, поэтому сообщения\nс одинаковым временем не теряются и не повторяются.\n\nДля старых клиентов `before` по-прежнему принимает простую дату-время ISO 8601 (с `Z` или смещением): вернутся\nсообщения с `created_at` строго раньше неё. Строка с `~` разбирается как курсор, иначе — как дата.\n\n**Формат ответа.**\n- Без `format` — массив сообщений, как раньше. Флага «есть ещё» нет: если пришло меньше 100, начало переписки достигнуто.\n- `?format=page` — объект [SocialDirectMessagePage](#модели/socialdirectmessagepage): `items`, `hasOlder`,\n  `nextCursor` (курсор для следующего `before` или `null`).\n\nВ `files` — вложения, уже привязанные к сообщению; неотправленные файлы здесь не видны.\n\n**Доступ:** участник дружбы со статусом `accepted`, иначе `404`. Доступ проверяется до разбора `format` и `before`.\n\n**Побочные эффекты:** нет. Отметок о прочтении в личных сообщениях нет.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID дружбы"},{"name":"before","in":"query","required":false,"schema":{"anyOf":[{"type":"string","pattern":"^[^~]+~[0-9a-fA-F-]{36}$","description":"Курсор `cursor` сообщения"},{"type":"string","format":"date-time","description":"Дата-время ISO 8601 (совместимость)"}]},"description":"Курсор `cursor` самого старого загруженного сообщения (например `2026-10-02T07:41:09.330412Z~2d3e4f5a-6b7c-4d8e-9f0a-1b2c3d4e5f6a`)\nили, для совместимости, дата-время ISO 8601 с `Z` или смещением\n","example":"2026-10-02T07:41:09.330412Z~2d3e4f5a-6b7c-4d8e-9f0a-1b2c3d4e5f6a"},{"name":"format","in":"query","required":false,"schema":{"type":"string","enum":["page"]},"description":"`page` — вернуть объект `{ items, hasOlder, nextCursor }` вместо массива. Другое значение — `400`"}],"responses":{"200":{"description":"Сообщения переписки — массив (по умолчанию) или страница (`?format=page`)","content":{"application/json":{"schema":{"oneOf":[{"type":"array","maxItems":100,"items":{"$ref":"#/components/schemas/SocialDirectMessage"}},{"$ref":"#/components/schemas/SocialDirectMessagePage"}]},"examples":{"plain":{"summary":"Без format — массив","value":[{"id":"2d3e4f5a-6b7c-4d8e-9f0a-1b2c3d4e5f6a","body":"Скинул тебе макет, глянь вечером","forwarded":null,"reactions":[{"emoji":"❤️","count":1,"mine":true,"names":["Игорь Петров"]}],"senderId":"3b0f6a52-8c1e-4d7a-9f2b-61c4e0a8d915","mine":false,"createdAt":"2026-10-02T07:41:09.330Z","cursor":"2026-10-02T07:41:09.330412Z~2d3e4f5a-6b7c-4d8e-9f0a-1b2c3d4e5f6a","reply":null,"files":[{"id":"e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b","name":"Макет главной.png","mime":"image/png","size":482113,"url":"/api/v1/social/friends/6f7e8d9c-0b1a-4c2d-8e3f-4a5b6c7d8e9f/files/e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b"}]},{"id":"7f8a9b0c-1d2e-4f3a-8b4c-5d6e7f8a9b0c","body":"Посмотрел, отличный вариант!","forwarded":null,"reactions":[],"senderId":"7e4a1c9b-2d5f-4a8e-b3c6-0f1e2d3c4b5a","mine":true,"createdAt":"2026-10-02T08:03:55.021Z","cursor":"2026-10-02T08:03:55.021087Z~7f8a9b0c-1d2e-4f3a-8b4c-5d6e7f8a9b0c","reply":{"id":"2d3e4f5a-6b7c-4d8e-9f0a-1b2c3d4e5f6a","body":"Скинул тебе макет, глянь вечером","mine":false},"files":[]}]},"page":{"summary":"?format=page — страница с курсором","value":{"items":[{"id":"7f8a9b0c-1d2e-4f3a-8b4c-5d6e7f8a9b0c","body":"Посмотрел, отличный вариант!","forwarded":null,"reactions":[],"senderId":"7e4a1c9b-2d5f-4a8e-b3c6-0f1e2d3c4b5a","mine":true,"createdAt":"2026-10-02T08:03:55.021Z","cursor":"2026-10-02T08:03:55.021087Z~7f8a9b0c-1d2e-4f3a-8b4c-5d6e7f8a9b0c","reply":null,"files":[]}],"hasOlder":true,"nextCursor":"2026-10-02T08:03:55.021087Z~7f8a9b0c-1d2e-4f3a-8b4c-5d6e7f8a9b0c"}}}}}},"400":{"description":"Некорректный ID дружбы, `format` не `page`, `before` — не курсор и не дата-время ISO 8601\n(в курсоре: время не ISO 8601, ID не UUID или больше одного `~`). Тело — `ValidationError`.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Переписка не найдена, не принята или пользователь не её участник («Личная переписка недоступна»)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Личная переписка недоступна","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"post":{"operationId":"sendDirectMessage","tags":["Друзья и чаты"],"summary":"Отправить личное сообщение","description":"Создаёт сообщение в переписке. Нужен непустой текст или хотя бы одно вложение.\n\n- `attachmentIds` — ID **своих неотправленных** файлов этой переписки (загруженных через\n  `POST /social/friends/{id}/files` меньше 24 часов назад). Если хотя бы один файл чужой, из другой переписки,\n  уже отправлен или просрочен, транзакция откатывается целиком с `403` — просроченный файл нужно загрузить заново.\n- После привязки файлов проверяется лимит переписки: отправленные файлы не должны превышать 1 ГБ, иначе `400`\n  и откат (неотправленные черновики в этот лимит не входят, поэтому он проверяется здесь).\n- `replyTo` — сообщение этой же переписки.\n\nТело проверяется до проверки доступа к переписке.\n\n**Доступ:** участник дружбы со статусом `accepted`.\n\n**Побочные эффекты:** вставка в [direct_messages](#модели/dbdirect-messages), привязка файлов\n[direct_files](#модели/dbdirect-files) к сообщению; realtime `changed` с областью `friends` обоим участникам.\nУведомление в ленту не создаётся.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID дружбы"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SocialDirectMessageInput"},"example":{"body":"Посмотрел, отличный вариант!","attachmentIds":[],"replyTo":"2d3e4f5a-6b7c-4d8e-9f0a-1b2c3d4e5f6a"}}}},"responses":{"201":{"description":"Сообщение отправлено","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SocialMessageCreated"},"example":{"id":"7f8a9b0c-1d2e-4f3a-8b4c-5d6e7f8a9b0c"}}}},"400":{"description":"Ошибка валидации тела или ID (`ValidationError`) либо (`Error`):\n- «Напишите сообщение или прикрепите файл» — пустой `body` (после `trim`) и пустой `attachmentIds`;\n- «Лимит файлов переписки — 1 ГБ» — с этими вложениями отправленные файлы переписки превысят 1 ГБ.\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/Error"}]},"examples":{"empty":{"summary":"Нет ни текста, ни вложений","value":{"statusCode":400,"message":"Напишите сообщение или прикрепите файл","error":"Bad Request"}},"quota":{"summary":"Превышен лимит переписки","value":{"statusCode":400,"message":"Лимит файлов переписки — 1 ГБ","error":"Bad Request"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Вложения чужие, из другой переписки, уже отправлены или загружены больше 24 часов назад\n(«Вложения недоступны или уже отправлены. Файлы, которые не отправили за 24 часа, нужно загрузить заново»)\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Вложения недоступны или уже отправлены. Файлы, которые не отправили за 24 часа, нужно загрузить заново","error":"Forbidden"}}}},"404":{"description":"Переписка недоступна («Личная переписка недоступна» или `Not Found`, если дружбу удалили во время запроса)\nлибо сообщение для ответа не из этой переписки («Сообщение для ответа недоступно»).\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Сообщение для ответа недоступно","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/social/friends/{id}/files":{"post":{"operationId":"uploadDirectFile","tags":["Друзья и чаты"],"summary":"Загрузить вложение в переписку","description":"Загружает один файл в переписку как **неотправленный** (`message_id = NULL`). Чтобы собеседник его увидел,\nотправьте сообщение с этим ID в `attachmentIds`.\n\n- Формат: `multipart/form-data`, ровно один файл в поле `file`, других полей нет (`fields: 0`).\n- Размер — от 1 байта до 100 МБ (`FILE_LIMIT`); пустой файл — `400`. Тип определяется по содержимому\n  (см. описание тега); заголовок `Content-Type` части игнорируется.\n- Имя файла читается как UTF-8 (кириллица сохраняется), нормализуется в NFC; управляющие символы, `/`, `\\`\n  и символы управления направлением текста заменяются на `_`; длина — до 200 кодовых точек; пустое имя\n  превращается в `file`.\n- Файл — **черновик**: живёт 24 часа, пока его не отправят сообщением.\n- Квоты: отправленные файлы переписки плюс этот файл — не больше 1 ГБ; неотправленные файлы автора во всех\n  переписках (за 24 часа) плюс этот файл — тоже не больше 1 ГБ.\n\n**Порядок обработки:**\n1. Guard до чтения тела проверяет сессию и дружбу и занимает слот передачи файлов\n   (по `Content-Length`: больше 10 МБ — «большой» слот, их на сервер два; меньше — один из 12).\n2. Multer принимает multipart; затем проверяются наличие файла, пустота, имя и тип.\n3. Удаляются просроченные черновики (до 200), проверяются квоты.\n4. При S3 зашифрованный объект загружается в хранилище вне транзакции; затем под блокировкой дружбы квоты\n   проверяются повторно и создаётся строка файла.\n\n**Доступ:** участник дружбы со статусом `accepted`.\n\n**Побочные эффекты:** вставка в [direct_files](#модели/dbdirect-files); удаление просроченных черновиков;\nпри S3 — зашифрованный объект в хранилище и запись в [storage_objects](#модели/dbstorage-objects).\nRealtime не рассылается.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID дружбы"}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/SocialFileUpload"}}}},"responses":{"201":{"description":"Файл загружен (ещё не отправлен)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SocialDirectFile"},"example":{"id":"e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b","name":"Макет главной.png","mime":"image/png","size":482113,"url":"/api/v1/social/friends/6f7e8d9c-0b1a-4c2d-8e3f-4a5b6c7d8e9f/files/e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b"}}}},"400":{"description":"Некорректный ID (`ValidationError`) или ошибка файла (`Error`):\n- «Выберите файл» — нет поля `file`;\n- «Файл пустой. Выберите файл с содержимым» — файл размером 0 байт;\n- «Поддерживаются изображения, видео, аудио, PDF, Office, ZIP и текстовые файлы (TXT, MD, CSV, JSON)» — тип не распознан;\n- «Лимит файлов переписки — 1 ГБ» — отправленные файлы переписки вместе с этим превысят 1 ГБ;\n- «Неотправленные вложения уже занимают 1 ГБ. Отправьте их или уберите из черновиков» — черновики автора\n  во всех переписках вместе с этим превысят 1 ГБ;\n- ошибки multipart от Multer (`Too many files`, `Too many fields`, `Unexpected file field - <поле>` и т.п.).\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/Error"}]},"examples":{"empty":{"summary":"Пустой файл","value":{"statusCode":400,"message":"Файл пустой. Выберите файл с содержимым","error":"Bad Request"}},"unsupported":{"summary":"Неподдерживаемый тип","value":{"statusCode":400,"message":"Поддерживаются изображения, видео, аудио, PDF, Office, ZIP и текстовые файлы (TXT, MD, CSV, JSON)","error":"Bad Request"}},"quota":{"summary":"Превышен лимит переписки","value":{"statusCode":400,"message":"Лимит файлов переписки — 1 ГБ","error":"Bad Request"}},"drafts":{"summary":"Слишком много черновиков","value":{"statusCode":400,"message":"Неотправленные вложения уже занимают 1 ГБ. Отправьте их или уберите из черновиков","error":"Bad Request"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"Переписка недоступна («Переписка недоступна» или `Not Found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Переписка недоступна","error":"Not Found"}}}},"413":{"description":"Файл больше 100 МБ (Multer `LIMIT_FILE_SIZE`, сообщение `File too large`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":413,"message":"File too large","error":"Payload Too Large"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"},"503":{"$ref":"#/components/responses/ServiceUnavailable"}}}},"/api/v1/social/friends/{id}/files/{file}":{"get":{"operationId":"downloadDirectFile","tags":["Друзья и чаты"],"summary":"Скачать вложение переписки","description":"Отдаёт содержимое файла. Отправленные файлы доступны обоим участникам, неотправленные — только автору\nи только 24 часа после загрузки (просроченный черновик — `404`).\n\nЗаголовки ответа (`sendMedia`, media.ts):\n- `Content-Type` — тип, определённый при загрузке;\n- `Content-Disposition: inline` для `image/*`, `video/*`, `audio/*` и для PDF при `?preview=1`;\n  иначе (и всегда при `?download=1`) — `attachment`; имя в `filename*=UTF-8''…`;\n- `Cache-Control: private, no-store`, `X-Content-Type-Options: nosniff`,\n  `Content-Security-Policy: default-src 'none'; sandbox`, `Accept-Ranges: bytes`.\n\nПоддерживается один диапазон `Range: bytes=start-end`, `bytes=start-` или `bytes=-suffix` → `206`.\nНеверный или неудовлетворимый диапазон → `416` с `Content-Range: bytes */<размер>`.\n\n**Доступ:** участник дружбы со статусом `accepted`.\n\n**Побочные эффекты:** занимает слот передачи файлов на время ответа (по размеру файла).\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID дружбы"},{"name":"file","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID файла"},{"name":"preview","in":"query","required":false,"schema":{"type":"string","enum":["1"]},"description":"`1` — показывать PDF внутри страницы (`inline`)"},{"name":"download","in":"query","required":false,"schema":{"type":"string","enum":["1"]},"description":"`1` — всегда отдавать как вложение (`attachment`)"},{"name":"Range","in":"header","required":false,"schema":{"type":"string","example":"bytes=0-1048575"},"description":"Один диапазон байтов"}],"responses":{"200":{"description":"Файл целиком","headers":{"Content-Disposition":{"schema":{"type":"string","example":"inline; filename*=UTF-8''main-v3.png"}},"Accept-Ranges":{"schema":{"type":"string","example":"bytes"}}},"content":{"*/*":{"schema":{"type":"string","format":"binary"}}}},"206":{"description":"Часть файла по заголовку `Range`","headers":{"Content-Range":{"schema":{"type":"string","example":"bytes 0-1048575/48211300"}}},"content":{"*/*":{"schema":{"type":"string","format":"binary"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Переписка недоступна («Переписка недоступна»), файла нет в этой переписке, это чужой неотправленный файл или свой черновик старше 24 часов (`Not Found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Not Found"}}}},"416":{"description":"Диапазон не удовлетворим; тело пустое, заголовок `Content-Range` равен `bytes */<размер>`"},"429":{"$ref":"#/components/responses/TooManyRequests"},"503":{"$ref":"#/components/responses/ServiceUnavailable"}}},"delete":{"operationId":"deleteDirectFile","tags":["Друзья и чаты"],"summary":"Удалить неотправленное вложение","description":"Удаляет **свой неотправленный** файл (ещё не привязанный к сообщению). Отправленные файлы через API\nудалить нельзя — они исчезают только вместе с перепиской. Неотправленные файлы старше 24 часов сервер удаляет\nсам (фоновая очистка раз в час и перед каждой загрузкой); пока очистка не прошла, их можно удалить и этим запросом.\n\n**Доступ:** автор файла, участник дружбы со статусом `accepted`.\n\n**Побочные эффекты:** удаление из [direct_files](#модели/dbdirect-files), объект S3 ставится в очередь\nочистки ([storage_cleanup](#модели/dbstorage-cleanup)).\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID дружбы"},{"name":"file","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID файла"}],"responses":{"200":{"description":"Файл удалён","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"Переписка недоступна («Переписка недоступна») либо файл чужой, уже отправлен или не существует\n(«Можно убрать только своё неотправленное вложение»).\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Можно убрать только своё неотправленное вложение","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/social/day":{"get":{"operationId":"getMyDay","tags":["Друзья и чаты"],"summary":"Настроение и радость от задач","description":"Личная сводка «Мой день»: сегодняшняя дата по часовому поясу пользователя, история настроения и\nсуммы баллов радости за последние 28 дней, а также до 30 последних задач, завершённых пользователем\n(`tasks.completed_by`), с поставленными баллами. Задачи, к которым доступ потерян, отфильтровываются,\nпоэтому их может быть меньше 30.\n\n**Доступ:** только собственные данные.\n\n**Побочные эффекты:** нет.\n","responses":{"200":{"description":"Сводка дня","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SocialDay"},"example":{"day":"2026-10-02","entries":[{"day":"2026-10-01","score":3,"note":"","shareWithFriends":false},{"day":"2026-10-02","score":4,"note":"Хороший день, всё успеваю","shareWithFriends":true}],"rewards":[{"day":"2026-10-02","points":7}],"tasks":[{"id":"c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f","boardId":"b0c1d2e3-f4a5-4b6c-9d7e-8f9a0b1c2d3e","title":"Подготовить отчёт за сентябрь","points":5},{"id":"d2e3f4a5-b6c7-4d8e-9f0a-1b2c3d4e5f6a","boardId":"b0c1d2e3-f4a5-4b6c-9d7e-8f9a0b1c2d3e","title":"Созвон с подрядчиком","points":null}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"put":{"operationId":"setMyDayMood","tags":["Друзья и чаты"],"summary":"Отметить настроение дня","description":"Создаёт или полностью перезаписывает запись настроения за **сегодня** (по часовому поясу пользователя).\nПоля, не переданные в теле, принимают значения по умолчанию (`note: \"\"`, `shareWithFriends: false`),\nа не сохраняют прежние.\n\n**Приватность:** друзья (только принятые) увидят оценку и заметку в `GET /social/friends`, лишь если\n`shareWithFriends = true`, и только пока у владельца длится этот день. История видна только владельцу.\n\n**Доступ:** только собственная запись.\n\n**Побочные эффекты:** upsert в [mood_entries](#модели/dbmood-entries); realtime `changed` с областью\n`friends` владельцу и всем его принятым друзьям (независимо от `shareWithFriends`, данные в событии не передаются).\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SocialMoodInput"},"example":{"score":4,"note":"Хороший день, всё успеваю","shareWithFriends":true}}}},"responses":{"200":{"description":"Запись сохранена","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/social/task-joy":{"post":{"operationId":"rateTaskJoy","tags":["Друзья и чаты"],"summary":"Оценить радость от выполненной задачи","description":"Сохраняет баллы радости (1–5) за задачу, которую завершил текущий пользователь. Баллы относятся к\nсегодняшнему дню пользователя. Оценка ставится один раз: повторный запрос по той же задаче ничего не\nменяет и отвечает `{ ok: true }`.\n\n**Доступ:** право чтения задачи и `tasks.completed_by` = текущий пользователь при непустом `completed_at`.\n\n**Побочные эффекты:** вставка в [task_mood_rewards](#модели/dbtask-mood-rewards). Realtime не рассылается.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SocialTaskJoyInput"},"example":{"boardId":"b0c1d2e3-f4a5-4b6c-9d7e-8f9a0b1c2d3e","taskId":"d2e3f4a5-b6c7-4d8e-9f0a-1b2c3d4e5f6a","points":4}}}},"responses":{"201":{"description":"Баллы сохранены (или уже были поставлены раньше)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Задача не завершена или завершена не вами («Отметить можно задачу, которую завершили вы»)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Отметить можно задачу, которую завершили вы","error":"Forbidden"}}}},"404":{"description":"Доска или задача недоступна («Доска не найдена», «Задача не найдена»)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Задача не найдена","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/notifications":{"get":{"operationId":"listNotifications","tags":["Уведомления"],"summary":"Лента уведомлений","description":"100 последних **видимых** уведомлений пользователя, новые сначала. Устаревшие и недоступные записи\n(правила — в описании тега), в том числе уведомления об удалённых комментариях, отфильтровываются в SQL\nдо `LIMIT`, поэтому меньше 100 записей приходит, только если видимых меньше. Пагинации нет.\n`unreadCount` — число непрочитанных среди **всех** видимых уведомлений, а не только среди возвращённых.\n\nПоля `title`, `dueDate`, `boardTitle` — **текущие** значения задачи и доски, а не снимок на момент события.\n\n**Доступ:** только свои уведомления; уведомления по задачам без доступа скрываются.\n\n**Побочные эффекты:** нет. Уведомления о сроках этот запрос не создаёт: их генерирует фоновая задача\nраз в минуту, а также изменения задач со сроком (см. описание тега).\n","responses":{"200":{"description":"Лента уведомлений","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationFeed"},"example":{"items":[{"id":"f0e1d2c3-b4a5-4968-8776-5a4b3c2d1e0f","kind":"mention","commentId":"5c8e1f2a-7b3d-4e6f-9a1b-2c3d4e5f6a7b","readAt":null,"createdAt":"2026-10-01T09:12:44.530Z","taskId":"c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f","boardId":"b0c1d2e3-f4a5-4b6c-9d7e-8f9a0b1c2d3e","title":"Редизайн главной страницы","dueDate":"2026-10-03","actorName":"Игорь Петров","boardTitle":"Маркетинг"},{"id":"a9b8c7d6-e5f4-4a3b-9c2d-1e0f9a8b7c6d","kind":"due_soon","commentId":null,"readAt":"2026-10-02T06:00:12.004Z","createdAt":"2026-10-02T00:00:41.871Z","taskId":"c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f","boardId":"b0c1d2e3-f4a5-4b6c-9d7e-8f9a0b1c2d3e","title":"Редизайн главной страницы","dueDate":"2026-10-03","actorName":null,"boardTitle":"Маркетинг"}],"unreadCount":1}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/notifications/preferences":{"get":{"operationId":"getNotificationPreferences","tags":["Уведомления"],"summary":"Настройки уведомлений","description":"Шесть флагов: три управляют **созданием** уведомлений в ленте (см. таблицу видов в описании тега),\nтри — **письмами** на email.\n\n**Письма на email.** Когда создаётся *новое* уведомление `assigned`, `mention`, `reply`, `due_soon` или `overdue`,\nв той же транзакции в зашифрованную очередь [mail_outbox](#модели/dbmail-outbox) ставится письмо, если одновременно:\n\n- на сервере `MAIL_ENABLED=true` (иначе письма не ставятся вовсе);\n- получатель — человек (`account_kind = 'human'`) с подтверждённым email (`email_verified`);\n- включён соответствующий флаг: `emailAssignments`, `emailMentions` (упоминания и ответы) или `emailDeadlines`;\n- получатель сейчас может открыть задачу: права на доску и на ограниченную задачу проверяются при постановке\n  письма. Лента проверяет доступ только при чтении, поэтому, например, автор старого комментария в ограниченной\n  задаче без доступа получит уведомление `reply` (скрытое в ленте), но не письмо.\n\nБез уведомления нет и письма: выключенные `assignments`, `comments` или `deadlines` отключают и письма этого вида.\nОбычные комментарии (`comment`) писем не дают. Ошибка постановки письма не отменяет действие с задачей.\n\n**Ограничения на получателя:**\n\n- одно письмо на ключ дедупликации уведомления (`mail_outbox.dedup_key`);\n- по одной задаче — не больше одного письма за 10 минут. Письмо ждёт отправки 1 минуту; события задачи, пришедшие,\n  пока оно не ушло, добавляются в него (тема «Новые события в задаче «…»», в списке до 10 строк). Если письмо уже\n  отправляется или отправлено меньше 10 минут назад, новое событие остаётся только в ленте;\n- не больше 30 писем о задачах за любые 24 часа;\n- напоминания о сроках, созданные до 09:00 по часовому поясу получателя, отправляются в 09:00;\n- письмо по задаче, удалённой до отправки, отменяется.\n\n**Содержимое** (русский текст и простая HTML-версия). Темы: «Вам назначена задача: {название}»,\n«Вас упомянули в задаче «{название}»», «Ответ на ваш комментарий в задаче «{название}»»,\n«Срок задачи «{название}» — сегодня/завтра/просрочен». В письме: доска, кто совершил действие, фрагмент\nописания задачи или комментария (простой текст без разметки Markdown, до 300 символов), ссылка\n`{APP_URL}/?board={boardId}&task={taskId}` (для упоминаний и ответов — с `&comment={commentId}`) и строка\n«Настроить письма: {APP_URL}/settings/notifications».\n\n**Доступ:** только свои настройки.\n\n**Побочные эффекты:** нет.\n","responses":{"200":{"description":"Текущие настройки","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationPreferences"},"example":{"assignments":true,"comments":true,"deadlines":false,"emailAssignments":true,"emailMentions":true,"emailDeadlines":false}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"patch":{"operationId":"updateNotificationPreferences","tags":["Уведомления"],"summary":"Изменить настройки уведомлений","description":"Частичное обновление: переданные флаги меняются, остальные сохраняются. Прежнее тело из трёх полей\n(`assignments`, `comments`, `deadlines`) по-прежнему принимается и не трогает флаги писем.\nВозвращает все шесть сохранённых значений.\n\n**Доступ:** только свои настройки.\n\n**Побочные эффекты:** обновление `notify_assignments`, `notify_comments`, `notify_deadlines`,\n`email_assignments`, `email_mentions`, `email_deadlines` в [users](#модели/dbusers). Уже созданные уведомления\nне удаляются и не скрываются, уже поставленные в очередь письма не отменяются.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationPreferencesUpdate"},"example":{"emailDeadlines":false}}}},"responses":{"200":{"description":"Сохранённые настройки","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationPreferences"},"example":{"assignments":true,"comments":true,"deadlines":true,"emailAssignments":true,"emailMentions":true,"emailDeadlines":false}}}},"400":{"description":"Пустое тело или ни одного известного флага (`formErrors: [\"Укажите хотя бы одну настройку\"]`) либо значение\nне `true`/`false` (ошибка поля). Сообщение — «Проверьте поля формы».\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"},"example":{"message":"Проверьте поля формы","errors":{"formErrors":["Укажите хотя бы одну настройку"],"fieldErrors":{}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/notifications/read-all":{"post":{"operationId":"markAllNotificationsRead","tags":["Уведомления"],"summary":"Отметить все уведомления прочитанными","description":"Ставит `read_at = now()` всем непрочитанным уведомлениям пользователя, включая те, что скрыты фильтрами\nленты или не попали в последние 100. Тело запроса не нужно.\n\n**Доступ:** только свои уведомления.\n\n**Побочные эффекты:** обновление [notifications](#модели/dbnotifications); если хотя бы одна запись\nизменилась — realtime `changed` с областью `notifications` на все подключения пользователя.\n","responses":{"201":{"description":"Готово","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/notifications/{id}/read":{"patch":{"operationId":"markNotificationRead","tags":["Уведомления"],"summary":"Отметить уведомление прочитанным","description":"Отмечает одно уведомление прочитанным. Повторный вызов не меняет исходное время прочтения\n(`read_at = COALESCE(read_at, now())`). Тело запроса не нужно.\n\n**Доступ:** только своё уведомление; чужое или несуществующее — `404`.\n\n**Побочные эффекты:** обновление [notifications](#модели/dbnotifications); realtime `changed` с областью\n`notifications` на все подключения пользователя.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID уведомления"}],"responses":{"200":{"description":"Уведомление отмечено","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"Уведомление не найдено или принадлежит другому пользователю (`Not Found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/team/{space}":{"get":{"operationId":"getTeamOverview","tags":["TEAM"],"summary":"Сводка TEAM организации","description":"Возвращает сводку организации:\n\n- видимые доски со счётчиками;\n- общие счётчики задач и бэклога;\n- нагрузку участников;\n- последние 50 спринтов с их видимыми задачами.\n\nПросрочка считается по дате в часовом поясе текущего пользователя (`users.timezone`).\n\n**Доступ:** `owner`, `admin`, `member`. Гость получает 403, не участник — 404. В спринтах поля `committed*` и `completed*` заполнены только для `owner` и `admin`, остальным приходит `null`.\n\n**Побочные эффекты:** нет.\n","parameters":[{"name":"space","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"}],"responses":{"200":{"description":"Сводка","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeamOverview"},"example":{"id":"3f6c1a2e-8b7d-4c19-9a55-2d0e7b4f9c11","name":"Metodox","description":"Продуктовая команда","role":"member","boards":[{"id":"8f1c2d4e-5a6b-4c7d-9e8f-0a1b2c3d4e5f","title":"Сайт: запуск","description":"Лендинг и документация","visibility":"workspace","role":"editor","projectId":"c2a9e4b7-1d3f-4a6c-8e5b-9f0d1c2b3a4e","phaseId":"d4b8f2a6-3c5e-4f7a-9b1d-2e3f4a5b6c7d","color":null,"accent":"#f472b6","total":24,"done":15,"overdue":2}],"stats":{"total":57,"open":31,"done":26,"unassigned":4,"overdue":3,"doneThisWeek":9,"backlog":12},"workload":[{"id":"a1d4e7f0-2b3c-4d5e-8f90-1a2b3c4d5e6f","name":"Анна Смирнова","kind":"human","avatarColor":"#4f46e5","role":"owner","open":7,"done":3,"overdue":1},{"id":"b7e2c9d1-4a5f-4e6b-9c8d-7f1e2a3b4c5d","name":"Игорь Петров","kind":"human","avatarColor":"#0ea5e9","role":"member","open":5,"done":4,"overdue":0}],"sprints":[{"id":"0a1b2c3d-6f8b-4cad-8e4a-5b6c7d8e9f0a","projectId":"c2a9e4b7-1d3f-4a6c-8e5b-9f0d1c2b3a4e","title":"Спринт 14","goal":"Запустить лендинг","startDate":"2026-09-28","endDate":"2026-10-09","status":"active","version":6,"startedAt":"2026-09-28T07:02:13.511Z","finishedAt":null,"committedTasks":null,"committedPoints":null,"completedTasks":null,"completedPoints":null,"tasks":[{"sprintId":"0a1b2c3d-6f8b-4cad-8e4a-5b6c7d8e9f0a","id":"e5c9a3b7-4d6f-4a8b-8c2e-3f4a5b6c7d8e","number":12,"title":"Сверстать блок тарифов","boardId":"8f1c2d4e-5a6b-4c7d-9e8f-0a1b2c3d4e5f","boardTitle":"Сайт: запуск","columnTitle":"В работе","priority":"high","dueDate":"2026-10-05","assigneeId":"b7e2c9d1-4a5f-4e6b-9c8d-7f1e2a3b4c5d","assigneeName":"Игорь Петров","assigneeColor":"#0ea5e9","points":3,"done":false,"completedDay":null,"canEdit":true}]}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Гость: «TEAM доступен сотрудникам организации. Гостю доступны назначенные доски.»","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"TEAM доступен сотрудникам организации. Гостю доступны назначенные доски.","error":"Forbidden"}}}},"404":{"description":"«Организация не найдена»: организации нет или пользователь не её участник","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Организация не найдена","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/team/{space}/tasks":{"get":{"operationId":"listTeamTasks","tags":["TEAM"],"summary":"Поиск по видимым задачам организации","description":"Ищет задачи на видимых досках организации. Сначала идут незавершённые, затем остальные по убыванию `updated_at`.\n\nФильтры совпадают со счётчиками сводки, поэтому плитка открывает ровно те задачи, которые считает:\n\n- «В работе и планах» (`stats.open`) — `status=open`;\n- «Завершено за 7 дней» (`stats.doneThisWeek`) — `status=done&completedDays=7`;\n- «Просрочено» (`stats.overdue`) — `due=overdue`;\n- «Без исполнителя» (`stats.unassigned`) — `assignee=none&status=open`.\n\n«Сегодня» для `due` и поле `overdue` считаются по часовому поясу пользователя (`users.timezone`). Все фильтры необязательны и объединяются через И.\n\n**Лимит:** не больше 500 задач. Если подходящих больше, в ответе `truncated: true`.\n\n**Доступ:** `owner`, `admin`, `member`. Гость получает 403, не участник — 404. Параметр `boardId` только сужает выборку: задачи недоступной доски в ответ не попадут, ошибки при этом нет.\n\n**Побочные эффекты:** нет.\n","parameters":[{"name":"space","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"},{"name":"q","in":"query","required":false,"schema":{"type":"string","default":""},"description":"Подстрока названия, без учёта регистра (`ILIKE`). Берутся первые 100 символов. Поиск буквальный: `%`, `_` и `\\` экранируются (`likePattern`) и ищутся как обычные символы, а не как шаблоны"},{"name":"boardId","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"Только задачи этой доски"},{"name":"assignee","in":"query","required":false,"schema":{"type":"string","examples":["me","none","b7e2c9d1-4a5f-4e6b-9c8d-7f1e2a3b4c5d"]},"description":"UUID исполнителя, `me` — текущий пользователь, `none` — без исполнителя"},{"name":"due","in":"query","required":false,"schema":{"type":"string","enum":["overdue","week","none"]},"description":"`overdue` — незавершённые со сроком раньше сегодня; `week` — незавершённые со сроком не позже чем через 7 дней (включая просроченные, как фильтр доски «На этой неделе»); `none` — без срока"},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["open","done"]},"description":"`open` — не в последней колонке, `done` — в последней колонке (`completed_at` заполнен)"},{"name":"completedDays","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":365},"description":"Только задачи, завершённые за последние N дней (`completed_at >= now() - N дней`)"},{"name":"projectId","in":"query","required":false,"schema":{"type":"string","examples":["none","c2a9e4b7-1d3f-4a6c-8e5b-9f0d1c2b3a4e"]},"description":"UUID проекта или `none` — задачи досок без проекта"}],"responses":{"200":{"description":"Задачи","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeamTaskList"},"example":{"items":[{"id":"e5c9a3b7-4d6f-4a8b-8c2e-3f4a5b6c7d8e","number":12,"title":"Сверстать блок тарифов","boardId":"8f1c2d4e-5a6b-4c7d-9e8f-0a1b2c3d4e5f","boardTitle":"Сайт: запуск","projectId":"c2a9e4b7-1d3f-4a6c-8e5b-9f0d1c2b3a4e","columnTitle":"В работе","priority":"high","dueDate":"2026-10-05","assigneeId":"b7e2c9d1-4a5f-4e6b-9c8d-7f1e2a3b4c5d","assigneeName":"Игорь Петров","assigneeColor":"#0ea5e9","done":false,"completedAt":null,"overdue":false,"canEdit":true,"version":4}],"truncated":false}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Гость организации","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"TEAM доступен сотрудникам организации. Гостю доступны назначенные доски.","error":"Forbidden"}}}},"404":{"description":"«Организация не найдена»","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Организация не найдена","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/team/{space}/backlog":{"get":{"operationId":"listTeamBacklog","tags":["TEAM"],"summary":"Список бэклога","description":"Возвращает видимые записи бэклога. Порядок: по приоритету (high → medium → low), внутри приоритета новые первыми.\n\n**Какие записи попадают:**\n\n- без `archived=true` — записи вне архива, включая перенесённые (`transferred`);\n- с `archived=true` — только архивные.\n\n**Лимит:** не больше 500 записей. Если записей больше, в ответе `truncated: true`.\n\n**Доступ:** `owner`, `admin`, `member`. Гость получает 403, не участник — 404.\n\n- Записи `private` видят только автор и `owner`/`admin`.\n- Для перенесённой записи сервер проверяет доступ к задаче. Если доступа нет или задача удалена, `taskId` и `boardId` приходят как `null`.\n\n**Побочные эффекты:** нет.\n","parameters":[{"name":"space","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"},{"name":"archived","in":"query","required":false,"schema":{"type":"string","example":"true"},"description":"`true` — архив. Любое другое значение или его отсутствие — записи вне архива"}],"responses":{"200":{"description":"Записи бэклога","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BacklogList"},"example":{"items":[{"id":"f6d0b4c8-5e7a-4b9c-9d3f-4a5b6c7d8e9f","projectId":"c2a9e4b7-1d3f-4a6c-8e5b-9f0d1c2b3a4e","title":"Тёмная тема лендинга","description":"Проверить контраст и логотип","priority":"high","status":"ready","visibility":"team","points":5,"version":2,"creatorId":"b7e2c9d1-4a5f-4e6b-9c8d-7f1e2a3b4c5d","creatorName":"Игорь Петров","taskId":null,"boardId":null,"createdAt":"2026-09-21T10:15:42.120Z","archivedAt":null,"canEdit":true}],"truncated":false}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Гость организации","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"TEAM доступен сотрудникам организации. Гостю доступны назначенные доски.","error":"Forbidden"}}}},"404":{"description":"«Организация не найдена»","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Организация не найдена","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"post":{"operationId":"createBacklogItem","tags":["TEAM"],"summary":"Добавить запись в бэклог","description":"Создаёт запись бэклога. Автор — текущий пользователь, `version = 1`.\n\n**Доступ:** `owner`, `admin`, `member`. Гость получает 403, не участник — 404.\n\n**Побочные эффекты:** событие журнала `backlog.created` (`scope: backlog`) и сигнал `changed` по WebSocket.\n","parameters":[{"name":"space","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BacklogInput"},"example":{"title":"Тёмная тема лендинга","description":"Проверить контраст и логотип","priority":"high","status":"idea","visibility":"team","projectId":"c2a9e4b7-1d3f-4a6c-8e5b-9f0d1c2b3a4e","points":5}}}},"responses":{"201":{"description":"Запись создана","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeamCreated"},"example":{"id":"f6d0b4c8-5e7a-4b9c-9d3f-4a5b6c7d8e9f","version":1}}}},"400":{"description":"- Ошибка валидации полей (`ValidationError`).\n- «Проект недоступен или архивирован»: `projectId` из другой организации или проект в архиве.\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/Error"}]},"example":{"statusCode":400,"message":"Проект недоступен или архивирован","error":"Bad Request"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Гость организации","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"TEAM доступен сотрудникам организации. Гостю доступны назначенные доски.","error":"Forbidden"}}}},"404":{"description":"«Организация не найдена»","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Организация не найдена","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/team/{space}/backlog/{id}":{"patch":{"operationId":"updateBacklogItem","tags":["TEAM"],"summary":"Изменить, архивировать или вернуть запись бэклога","description":"Частичное обновление: меняются только переданные поля, остальные сохраняют текущие значения. `version` обязателен и увеличивается при каждом успешном запросе. Явный `null` очищает `projectId` или `points`. Запись в архиве тоже можно изменить и вернуть из архива (`archived: false`). Без поля `archived` состояние архива не меняется.\n\nСтрока записи блокируется (`FOR UPDATE`) до слияния, поэтому параллельные изменения не теряются: второй запрос с той же `version` получит 409. `projectId` проверяется после проверки прав и версии и только если он меняется. Запись, уже привязанная к проекту, который потом ушёл в архив, можно изменять, не передавая `projectId`.\n\n**Доступ:** автор записи или `owner`/`admin`. Другой сотрудник получает 403 для записи `team` и 404 для чужой записи `private` (она для него не существует). Гость получает 403.\n\n**Порядок проверок:** организация и роль → ID записи и тело → блокировка строки → существование и видимость (404) → права (403) → `version` (409) → перенесённая запись (409) → проект, если он меняется (400).\n\n**Побочные эффекты:** событие журнала `backlog.archived`, если запись переходит в архив (`archived: true` у записи вне архива), иначе `backlog.updated`. Сигнал `changed` по WebSocket. Повторный `archived: true` сохраняет исходное `archivedAt`.\n","parameters":[{"name":"space","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"},{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID записи бэклога"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BacklogUpdateInput"},"example":{"status":"parked","points":5,"version":2}}}},"responses":{"200":{"description":"Новая версия записи","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeamVersion"},"example":{"version":3}}}},"400":{"description":"- Ошибка валидации (`ValidationError`).\n- «Проект недоступен или архивирован».\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/Error"}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"- Гость организации.\n- «Изменять запись может автор или администратор организации»: запись `team`, а пользователь не её автор и не `owner`/`admin`.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Изменять запись может автор или администратор организации","error":"Forbidden"}}}},"404":{"description":"- «Организация не найдена».\n- «Запись бэклога не найдена»: записи нет в этой организации или это чужая запись `private`, а пользователь не `owner`/`admin`.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Запись бэклога не найдена","error":"Not Found"}}}},"409":{"description":"- «Запись изменилась. Обновите бэклог.»: `version` не совпадает.\n- «Задача уже передана на доску. Изменяйте её в карточке.»: запись уже перенесена (`transferred`).\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":409,"message":"Запись изменилась. Обновите бэклог.","error":"Conflict"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/team/{space}/backlog/{id}/convert":{"post":{"operationId":"convertBacklogItem","tags":["TEAM"],"summary":"Перенести запись бэклога на доску задачей","description":"Создаёт по записи бэклога ровно одну задачу.\n\n**Как создаётся задача:**\n\n- колонка — первая по `position`;\n- копируются `title`, `description` и `priority`; `points` в задачу не переносятся;\n- создатель задачи — текущий пользователь;\n- видимость: из `private` получается `restricted`, из `team` — `board`.\n\n**Что происходит с записью:** статус `transferred`, в `task_id` пишется ID задачи, `version` увеличивается. После переноса запись изменить нельзя.\n\n**Защита от повторов:** доска и запись блокируются (`FOR UPDATE`) в одной транзакции, поэтому из параллельных запросов успешен только один.\n\n**Доступ:**\n\n- автор записи или `owner`/`admin`; чужая запись `private` для остальных не существует (404);\n- права редактирования на целевой доске (не `viewer`);\n- доска должна принадлежать этой организации;\n- если у записи есть проект, доска должна быть из того же проекта.\n\nДоступ к доске проверяется до блокировки записи бэклога, поэтому ошибки доски (404, 403, 400) приходят раньше ошибок записи.\n\n**Побочные эффекты:**\n\n- событие задачи `created` с `details: {source: \"backlog\"}` в [task_events](#модели/dbtask-events), которое копируется в журнал как `task.created`;\n- событие `backlog.transferred` в журнале;\n- сигнал `changed` по WebSocket.\n","parameters":[{"name":"space","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"},{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID записи бэклога"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BacklogConvertInput"},"example":{"boardId":"8f1c2d4e-5a6b-4c7d-9e8f-0a1b2c3d4e5f","version":3}}}},"responses":{"201":{"description":"Задача создана","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BacklogConvertResult"},"example":{"taskId":"3d4e5f6a-9cbe-4fd0-9b7d-8e9f0a1b2c3d","boardId":"8f1c2d4e-5a6b-4c7d-9e8f-0a1b2c3d4e5f"}}}},"400":{"description":"- Ошибка валидации (`ValidationError`).\n- «Выберите доску этой организации».\n- «Выберите доску проекта этой идеи»: проект записи не совпадает с проектом доски.\n- «Нужны рабочая и завершающая колонки»: на доске меньше двух колонок.\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/Error"}]},"example":{"statusCode":400,"message":"Выберите доску проекта этой идеи","error":"Bad Request"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"- Гость организации.\n- «Недостаточно прав на доске»: роль `viewer`.\n- «Перенести запись на доску может автор или администратор организации»: запись `team`, а пользователь не её автор и не `owner`/`admin`.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Недостаточно прав на доске","error":"Forbidden"}}}},"404":{"description":"«Организация не найдена», «Доска не найдена» или «Запись бэклога не найдена» (записи нет в организации либо это чужая запись `private`, а пользователь не `owner`/`admin`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Доска не найдена","error":"Not Found"}}}},"409":{"description":"«Запись уже перенесена или изменена»: `version` не совпадает, запись уже `transferred` или в архиве","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":409,"message":"Запись уже перенесена или изменена","error":"Conflict"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/team/{space}/sprints":{"post":{"operationId":"createTeamSprint","tags":["TEAM"],"summary":"Создать спринт","description":"Создаёт спринт со статусом `planned` и `version = 1`. Если указан `projectId`, в спринт можно добавлять только задачи досок этого проекта. Без проекта спринт относится ко всей команде.\n\n**Доступ:** только `owner` и `admin` организации.\n\n**Побочные эффекты:** событие журнала `sprint.created` (`scope: sprint`) и сигнал `changed` по WebSocket.\n","parameters":[{"name":"space","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SprintCreateInput"},"example":{"title":"Спринт 15","goal":"Подготовить релиз iOS","projectId":null,"startDate":"2026-10-12","endDate":"2026-10-23"}}}},"responses":{"201":{"description":"Спринт создан","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeamCreated"},"example":{"id":"1b2c3d4e-7a9c-4dbe-9f5b-6c7d8e9f0a1b","version":1}}}},"400":{"description":"- Ошибка валидации, в том числе «Дата окончания раньше начала» (`ValidationError`).\n- «Проект недоступен или архивирован».\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/Error"}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"«Нужны права администратора организации» (`member` и `guest`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Нужны права администратора организации","error":"Forbidden"}}}},"404":{"description":"«Организация не найдена»","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Организация не найдена","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/team/{space}/sprints/{id}/tasks":{"post":{"operationId":"setTeamSprintTask","tags":["TEAM"],"summary":"Добавить, переоценить или убрать задачу спринта","description":"Работает в одном из трёх режимов:\n\n- `remove: false` — добавляет задачу в спринт с оценкой `points`. Если задача уже в спринте, перезаписывает оценку.\n- `remove: true` — убирает задачу из спринта. Если задачи в спринте нет, ошибки не будет.\n\nВ любом режиме `version` спринта увеличивается.\n\nЗадачи можно добавлять и в активный спринт. Зафиксированные при запуске `committedTasks` и `committedPoints` при этом не меняются.\n\n**Проверки:**\n\n- задача на доске этой организации;\n- если у спринта есть проект, доска задачи из того же проекта;\n- спринт не завершён;\n- задача не стоит в другом незавершённом спринте этой организации.\n\n**Доступ:** только `owner` и `admin` организации, плюс права редактирования задачи.\n\n**Побочные эффекты:**\n\n- событие задачи `sprint_added` или `sprint_removed` с `details: {name, points}`, которое копируется в журнал как `task.sprint_added` или `task.sprint_removed`;\n- сигнал `changed` по WebSocket.\n","parameters":[{"name":"space","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"},{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID спринта"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SprintTaskInput"},"example":{"taskId":"e5c9a3b7-4d6f-4a8b-8c2e-3f4a5b6c7d8e","boardId":"8f1c2d4e-5a6b-4c7d-9e8f-0a1b2c3d4e5f","points":3,"remove":false}}}},"responses":{"201":{"description":"Готово","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"description":"- Ошибка валидации (`ValidationError`).\n- «Задача другой организации».\n- «Задача другого проекта».\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/Error"}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"«Нужны права администратора организации» или «Недостаточно прав на задаче»","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Нужны права администратора организации","error":"Forbidden"}}}},"404":{"description":"«Организация не найдена», «Доска не найдена», «Задача не найдена» или спринта нет в этой организации","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Задача не найдена","error":"Not Found"}}}},"409":{"description":"- «Спринт завершён».\n- «Задача уже запланирована в другом спринте».\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":409,"message":"Задача уже запланирована в другом спринте","error":"Conflict"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/team/{space}/sprints/{id}":{"patch":{"operationId":"changeTeamSprintState","tags":["TEAM"],"summary":"Запустить или завершить спринт","description":"**`start`.** Спринт должен быть в статусе `planned`, в организации не должно быть другого активного спринта, в спринте должна быть хотя бы одна задача. Статус становится `active`, ставится `startedAt`, фиксируются `committedTasks` (число задач) и `committedPoints` (сумма `points`). Даты спринта при запуске не проверяются.\n\n**`complete`.** Спринт должен быть `active`. По шагам:\n\n1. Задачи спринта блокируются.\n2. Для каждой задачи фиксируется признак выполнения на момент закрытия (`completed_at_close`).\n3. Считаются `completedTasks` и `completedPoints`.\n4. Статус становится `completed`, ставится `finishedAt`.\n5. Если передан `carryTo`, незавершённые задачи с их `points` копируются в указанный запланированный спринт, и его `version` увеличивается.\n\nСтатусы и сроки задач не меняются.\n\nОбе операции выполняются под блокировкой строки организации и увеличивают `version` спринта.\n\n**Доступ:** только `owner` и `admin` организации.\n\n**Побочные эффекты:** событие журнала `sprint.started` или `sprint.completed` и сигнал `changed` по WebSocket.\n","parameters":[{"name":"space","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"},{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID спринта"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SprintChangeInput"},"examples":{"start":{"summary":"Запуск","value":{"action":"start","version":4}},"complete":{"summary":"Завершение с переносом","value":{"action":"complete","version":6,"carryTo":"1b2c3d4e-7a9c-4dbe-9f5b-6c7d8e9f0a1b"}}}}}},"responses":{"200":{"description":"Готово","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"description":"- Ошибка валидации (`ValidationError`).\n- «Сначала добавьте задачи»: запуск пустого спринта.\n- «Незавершённые задачи нельзя перенести в тот же спринт. Выберите другой запланированный спринт»: `carryTo` совпадает с ID завершаемого спринта (сравнение без учёта регистра).\n- «Выберите запланированный спринт этой команды»: `carryTo` не найден, из другой организации или не `planned`.\n- «Незавершённые задачи относятся к другому проекту»: у целевого спринта есть проект, а среди незавершённых задач есть задачи досок другого проекта или без проекта.\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/Error"}]},"example":{"statusCode":400,"message":"Сначала добавьте задачи","error":"Bad Request"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"«Нужны права администратора организации»","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Нужны права администратора организации","error":"Forbidden"}}}},"404":{"description":"«Организация не найдена» или спринта нет в этой организации","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Not Found"}}}},"409":{"description":"- «Спринт изменился. Обновите страницу.»: `version` не совпадает.\n- «Можно запустить только запланированный спринт».\n- «Сначала завершите активный спринт».\n- «Завершить можно только активный спринт».\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":409,"message":"Сначала завершите активный спринт","error":"Conflict"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/team/{space}/projects":{"get":{"operationId":"listTeamProjects","tags":["Проекты"],"summary":"Проекты и этапы организации","description":"Возвращает все проекты организации, включая архивные, в порядке создания. У каждого проекта — этапы по `position`. Лимита на число проектов нет.\n\n**Доступ:** любой участник организации. Гость получает `[]` (код 200), не участник — 404.\n\n**Побочные эффекты:** нет.\n","parameters":[{"name":"space","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"}],"responses":{"200":{"description":"Проекты","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TeamProject"}},"example":[{"id":"c2a9e4b7-1d3f-4a6c-8e5b-9f0d1c2b3a4e","title":"WEB SITE","description":"Сайт и документация продукта","status":"active","version":5,"color":"#7c9cff","archivedAt":null,"phases":[{"id":"d4b8f2a6-3c5e-4f7a-9b1d-2e3f4a5b6c7d","title":"Запуск","status":"active","position":0,"color":"#f472b6"},{"id":"6a7b8c9d-becf-4b2c-9d9f-0a1b2c3d4e5f","title":"Доработка","status":"planned","position":1,"color":null}]}]}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"«Организация не найдена»","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Организация не найдена","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"post":{"operationId":"createTeamProject","tags":["Проекты"],"summary":"Создать проект","description":"Создаёт проект со статусом `active` и `version = 1`. Этапы из `phases` создаются в той же транзакции, по порядку.\n\n**Доступ:** только `owner` и `admin` организации.\n\n**Побочные эффекты:** событие журнала `project.created` (`scope: project`) и сигнал `changed` по WebSocket.\n","parameters":[{"name":"space","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectCreateInput"},"example":{"title":"iOS App","description":"Мобильное приложение для iPhone","color":"#a78bfa","phases":["Дизайн","Разработка","Публикация"]}}}},"responses":{"201":{"description":"Проект создан","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectCreated"},"example":{"id":"7b8c9dae-cfd0-4c3d-8eaf-1b2c3d4e5f60","phases":["8c9daebf-d0e1-4d4e-9fa0-2c3d4e5f6071","9daebfc0-e1f2-4e5f-8a1b-3d4e5f607182","aebfc0d1-f203-4f60-9b2c-4e5f60718293"]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"«Нужны права администратора организации»","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Нужны права администратора организации","error":"Forbidden"}}}},"404":{"description":"«Организация не найдена»","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Организация не найдена","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/team/{space}/projects/{id}":{"patch":{"operationId":"updateTeamProject","tags":["Проекты"],"summary":"Изменить или архивировать проект","description":"Частичное обновление: меняет только переданные поля и увеличивает `version`. Пропущенные `title`, `description` и `status` сохраняют текущие значения.\n\n- `archived: true` ставит `archivedAt = now()`. Если проект уже в архиве, исходное `archivedAt` сохраняется.\n- `archived: false` снимает архив.\n- Без поля `archived` состояние архива не меняется.\n\nДоски проекта не меняются.\n\n**Доступ:** только `owner` и `admin` организации.\n\n**Побочные эффекты:** событие журнала `project.updated` или `project.archived` и сигнал `changed` по WebSocket.\n","parameters":[{"name":"space","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"},{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID проекта"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectUpdateInput"},"example":{"status":"paused","version":5}}}},"responses":{"200":{"description":"Готово","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"«Нужны права администратора организации»","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Нужны права администратора организации","error":"Forbidden"}}}},"404":{"description":"«Организация не найдена»","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Организация не найдена","error":"Not Found"}}}},"409":{"description":"«Проект изменился или недоступен»: `version` не совпадает или проекта нет в этой организации. Отдельного 404 для проекта нет","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":409,"message":"Проект изменился или недоступен","error":"Conflict"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/team/{space}/projects/{id}/phases":{"post":{"operationId":"createProjectPhase","tags":["Проекты"],"summary":"Добавить этап проекта","description":"Добавляет этап в конец проекта: `position = max + 1`, статус `planned`. Увеличивает `version` проекта. В архивный проект этап не добавить.\n\n**Доступ:** только `owner` и `admin` организации.\n\n**Побочные эффекты:** событие журнала `phase.created` с заголовком «Проект / Этап» и сигнал `changed` по WebSocket.\n","parameters":[{"name":"space","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"},{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID проекта"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhaseCreateInput"},"example":{"title":"Реклама"}}}},"responses":{"201":{"description":"Этап создан","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeamIdResult"},"example":{"id":"6a7b8c9d-becf-4b2c-9d9f-0a1b2c3d4e5f"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"«Нужны права администратора организации»","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Нужны права администратора организации","error":"Forbidden"}}}},"404":{"description":"«Организация не найдена» или проекта нет в организации, либо он в архиве (`Not Found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/team/{space}/projects/{id}/phases/{phase}":{"patch":{"operationId":"updateProjectPhase","tags":["Проекты"],"summary":"Изменить этап проекта","description":"Меняет название, статус или цвет этапа (только переданные поля). Блокировка идёт по версии **проекта**: передайте `version` проекта, она увеличится. В архивном проекте этап не изменить.\n\n**Доступ:** только `owner` и `admin` организации.\n\n**Побочные эффекты:** событие журнала `phase.updated` с заголовком «Проект / Этап» и сигнал `changed` по WebSocket.\n","parameters":[{"name":"space","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"},{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID проекта"},{"name":"phase","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID этапа"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhaseUpdateInput"},"example":{"title":"Запуск","status":"completed","version":6}}}},"responses":{"200":{"description":"Готово","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"«Нужны права администратора организации»","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Нужны права администратора организации","error":"Forbidden"}}}},"404":{"description":"«Организация не найдена» или этапа нет в этом проекте (`Not Found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Not Found"}}}},"409":{"description":"«Проект изменился»: `version` проекта не совпадает, проекта нет или он в архиве","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":409,"message":"Проект изменился","error":"Conflict"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/team/{space}/activity":{"get":{"operationId":"listTeamActivity","tags":["Журнал активности"],"summary":"Журнал событий организации","description":"Возвращает события организации, новые первыми, по 50 на страницу. Какие события видны, показывает таблица в описании раздела.\n\n**Доступ:** `owner`, `admin`, `member`. Гость получает 403, не участник — 404. Если передан `boardId`, нужен доступ к этой доске на чтение.\n\n**Побочные эффекты:** нет.\n","parameters":[{"name":"space","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"},{"name":"before","in":"query","required":false,"schema":{"type":"string","pattern":"^[^~]+~[0-9a-fA-F-]{36}$"},"description":"Курсор `nextCursor` предыдущей страницы: `<время ISO 8601 с Z или смещением>~<UUID>`. Другой формат — 400","example":"2026-09-30T14:05:11.482113Z~2c3d4e5f-8bad-4ecf-8a6c-7d8e9f0a1b2c"},{"name":"boardId","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"Только события этой доски"},{"name":"scope","in":"query","required":false,"schema":{"type":"string","enum":["team","admin","board","task","backlog","sprint","project","wiki"]},"description":"Только события этой области (правила видимости сохраняются)"}],"responses":{"200":{"description":"Страница событий","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActivityPage"},"example":{"items":[{"id":"2c3d4e5f-8bad-4ecf-8a6c-7d8e9f0a1b2c","scope":"backlog","action":"backlog.transferred","title":"Тёмная тема лендинга","details":{},"createdAt":"2026-09-30T14:05:11.482Z","boardId":null,"taskId":null,"boardTitle":null,"actorName":"Анна Смирнова","avatarColor":"#4f46e5","taskExists":false},{"id":"3d4e5f6a-9cbe-4fd0-9b7d-8e9f0a1b2c3d","scope":"task","action":"task.created","title":"Тёмная тема лендинга","details":{"source":"backlog"},"createdAt":"2026-09-30T14:05:11.482Z","boardId":"8f1c2d4e-5a6b-4c7d-9e8f-0a1b2c3d4e5f","taskId":"3d4e5f6a-9cbe-4fd0-9b7d-8e9f0a1b2c3d","boardTitle":"Сайт: запуск","actorName":"Анна Смирнова","avatarColor":"#4f46e5","taskExists":true}],"nextCursor":"2026-09-30T14:05:11.482113Z~3d4e5f6a-9cbe-4fd0-9b7d-8e9f0a1b2c3d"}}}},"400":{"description":"Некорректный UUID в `space` или `boardId`, `scope` не из списка или неверный курсор `before` (`ValidationError`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"«Гостю доступна история назначенных досок»","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Гостю доступна история назначенных досок","error":"Forbidden"}}}},"404":{"description":"«Организация не найдена» или «Доска не найдена» (`boardId`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Доска не найдена","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/boards/{id}/activity":{"get":{"operationId":"listBoardActivity","tags":["Журнал активности"],"summary":"История доски","description":"Возвращает события `board` и `task` одной доски, новые первыми, по 50 на страницу. Работает и для личных досок вне организации.\n\n**Доступ:** любой пользователь с доступом к доске на чтение, в том числе гость организации.\n\n- События `board` видны всем, кто видит доску.\n- События `task` роль `admin` доски видит все, включая события удалённых задач.\n- Остальные видят события `task` только у существующих задач, где `visibility: board`, или где пользователь — автор, исполнитель либо участник задачи.\n\n**Побочные эффекты:** нет.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"},{"name":"before","in":"query","required":false,"schema":{"type":"string","pattern":"^[^~]+~[0-9a-fA-F-]{36}$"},"description":"Курсор `nextCursor` предыдущей страницы (формат — в описании раздела). Другой формат — 400","example":"2026-09-29T09:12:40.031254Z~4e5f6a7b-9cbe-4f0a-9b7d-8e9f0a1b2c3d"}],"responses":{"200":{"description":"Страница событий","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActivityPage"},"example":{"items":[{"id":"4e5f6a7b-9cbe-4f0a-9b7d-8e9f0a1b2c3d","scope":"board","action":"column.created","title":"На проверке","details":{},"createdAt":"2026-09-29T09:12:40.031Z","boardId":"8f1c2d4e-5a6b-4c7d-9e8f-0a1b2c3d4e5f","taskId":null,"boardTitle":"Сайт: запуск","actorName":"Анна Смирнова","avatarColor":"#4f46e5","taskExists":false}],"nextCursor":null}}}},"400":{"description":"Некорректный UUID доски или неверный курсор `before` (`ValidationError`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"«Доска не найдена»","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Доска не найдена","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/wiki/spaces/{space}":{"get":{"operationId":"listWikiPages","tags":["Wiki"],"summary":"Статьи Wiki организации или публичной Wiki","description":"Возвращает статьи одной Wiki, недавно изменённые первыми (`updated_at DESC`, затем `id`), не больше 500.\nЕсли статей больше, в ответе `truncated: true`.\n\nУ каждой статьи — создатель (`creatorId`, `creatorName`) и последний редактор (`updatedById`, `updatedByName`).\nПоле `authorName` оставлено для старых клиентов и означает последнего редактора.\n\n**Доступ и состав списка:**\n\n- `space = public`: любой вошедший пользователь видит опубликованные статьи; редактор платформы видит все статусы.\n- UUID организации: `owner` и `admin` видят все статьи; `member` — опубликованные и свои статьи в любом статусе. Гость получает 403 «Wiki организации доступна сотрудникам», не участник — 404.\n\n**Побочные эффекты:** нет.\n","parameters":[{"name":"space","in":"path","required":true,"schema":{"anyOf":[{"$ref":"#/components/schemas/Uuid"},{"type":"string","const":"public"}]},"description":"UUID организации или `public` — общая Wiki продукта"}],"responses":{"200":{"description":"Статьи и права текущего пользователя","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WikiSpace"},"example":{"pages":[{"id":"4e5f6a7b-9cbe-4f0a-9b7d-8e9f0a1b2c3d","slug":"onboarding","title":"Как мы работаем","summary":"Правила команды для новых сотрудников","status":"published","version":7,"createdAt":"2026-09-10T08:00:00.000Z","updatedAt":"2026-09-25T16:40:02.774Z","authorName":"Анна Смирнова","creatorId":"b7e2c9d1-4a5f-4e6b-9c8d-7f1e2a3b4c5d","creatorName":"Игорь Петров","updatedById":"a1d4e7f0-2b3c-4d5e-8f90-1a2b3c4d5e6f","updatedByName":"Анна Смирнова","canEdit":false}],"truncated":false,"canCreate":true,"canPublish":false}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"«Wiki организации доступна сотрудникам» (гость)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Wiki организации доступна сотрудникам","error":"Forbidden"}}}},"404":{"description":"«Организация не найдена»","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Организация не найдена","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"post":{"operationId":"createWikiPage","tags":["Wiki"],"summary":"Создать статью","description":"Создаёт черновик (`draft`, `version = 1`) с пустым документом `{type: \"doc\", content: [{type: \"paragraph\"}]}`. Текст статьи сохраняется отдельным `PATCH`.\n\n**Доступ:**\n\n- `space = public` — только редактор платформы (подтверждённый email из `WIKI_ADMIN_EMAILS`);\n- организация — любой сотрудник (`owner`, `admin`, `member`), гость получает 403.\n\n**Побочные эффекты:**\n\n- версия 1 в [wiki_versions](#модели/dbwiki-versions);\n- событие журнала `wiki.created` (`scope: wiki`; у публичной Wiki без организации);\n- сигнал `changed` по WebSocket.\n","parameters":[{"name":"space","in":"path","required":true,"schema":{"anyOf":[{"$ref":"#/components/schemas/Uuid"},{"type":"string","const":"public"}]},"description":"UUID организации или `public`"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WikiPageCreateInput"},"example":{"title":"Как мы работаем","slug":"onboarding","summary":"Правила команды для новых сотрудников"}}}},"responses":{"201":{"description":"Статья создана","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeamIdResult"},"example":{"id":"4e5f6a7b-9cbe-4f0a-9b7d-8e9f0a1b2c3d"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Гость организации или, для `public`, пользователь не редактор платформы (`Forbidden`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Forbidden"}}}},"404":{"description":"«Организация не найдена»","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Организация не найдена","error":"Not Found"}}}},"409":{"description":"«Такой адрес статьи уже существует»","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":409,"message":"Такой адрес статьи уже существует","error":"Conflict"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/wiki/pages/{id}":{"get":{"operationId":"getWikiPage","tags":["Wiki"],"summary":"Статья Wiki с документом","description":"Возвращает статью с документом, создателем и последним редактором в camelCase (`creatorId`, `creatorName`, `updatedById`, `updatedByName`, `createdAt`, `updatedAt`, `workspaceId`) и вычисленные `canEdit` и `canPublish`. Колонки строки в snake_case (`workspace_id`, `creator_id`, `updated_by`, `created_at`, `updated_at`) остаются в ответе для старых клиентов.\n\n**Доступ:**\n\n- **Публичная Wiki.** Опубликованную статью может прочитать любой вошедший пользователь. Неопубликованную — только редактор платформы, остальным 404.\n- **Wiki организации.** Нужно быть сотрудником; гость получает 403, посторонний — 404. Неопубликованную (`draft`, `archived`) статью видят `owner`, `admin` и автор, остальным 404 — скрытый черновик для них не существует.\n\n`canEdit` равен `true`, если пользователь — `owner`/`admin` организации (для публичной Wiki — редактор платформы) или автор ещё не опубликованной статьи организации.\n\n**Побочные эффекты:** нет.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID статьи"}],"responses":{"200":{"description":"Статья","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WikiPageDetail"},"example":{"id":"4e5f6a7b-9cbe-4f0a-9b7d-8e9f0a1b2c3d","slug":"onboarding","title":"Как мы работаем","summary":"Правила команды для новых сотрудников","body":{"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Первый день"}]},{"type":"paragraph","content":[{"type":"text","text":"Доступы выдаёт "},{"type":"text","text":"администратор","marks":[{"type":"bold"}]},{"type":"text","text":"."}]},{"type":"image","attrs":{"src":"/api/v1/wiki/files/5f6a7b8c-adcf-4a1b-8c8e-9f0a1b2c3d4e","alt":"Схема доступа","title":null}}]},"status":"published","version":7,"workspaceId":"3f6c1a2e-8b7d-4c19-9a55-2d0e7b4f9c11","creatorId":"b7e2c9d1-4a5f-4e6b-9c8d-7f1e2a3b4c5d","creatorName":"Игорь Петров","updatedById":"a1d4e7f0-2b3c-4d5e-8f90-1a2b3c4d5e6f","updatedByName":"Анна Смирнова","createdAt":"2026-09-10T08:00:00.000Z","updatedAt":"2026-09-25T16:40:02.774Z","canEdit":false,"canPublish":false,"workspace_id":"3f6c1a2e-8b7d-4c19-9a55-2d0e7b4f9c11","creator_id":"b7e2c9d1-4a5f-4e6b-9c8d-7f1e2a3b4c5d","updated_by":"a1d4e7f0-2b3c-4d5e-8f90-1a2b3c4d5e6f","created_at":"2026-09-10T08:00:00.000Z","updated_at":"2026-09-25T16:40:02.774Z"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"«Wiki организации доступна сотрудникам» (гость)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Wiki организации доступна сотрудникам","error":"Forbidden"}}}},"404":{"description":"Статьи нет, она не опубликована и пользователь не может её редактировать, или пользователь не участник организации","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"patch":{"operationId":"saveWikiPage","tags":["Wiki"],"summary":"Сохранить статью (текст, адрес, статус)","description":"Полностью сохраняет статью: заголовок, slug, описание, документ и статус. Увеличивает `version` и пишет новую версию в историю.\n\n**Порядок проверок:**\n\n1. Права редактирования (скрытый от пользователя черновик — 404, остальным без прав — 403).\n2. Поля тела запроса.\n3. Нормализация `body`, см. таблицу узлов в описании раздела «Wiki».\n4. Запрет публикации и правки опубликованного для не-администратора.\n5. Медиа-ссылки документа ведут на файлы этой статьи.\n6. В транзакции статус статьи перечитывается под блокировкой (`FOR UPDATE`): если её успели опубликовать, не-администратор получает 403; если удалили — 404.\n7. Совпадение `version`.\n\n**Доступ:**\n\n- **Публичная Wiki** — только редактор платформы.\n- **Wiki организации.** `owner` и `admin` могут всё. Автор может править свою статью, пока она `draft` или `archived`, и переводить её между этими статусами. Опубликовать статью или изменить опубликованную автор не может: 403.\n\n**Побочные эффекты:**\n\n- новая строка [wiki_versions](#модели/dbwiki-versions);\n- событие журнала по **реальному переходу** статуса (статус до сохранения читается под блокировкой): `wiki.published` — статья стала опубликованной, `wiki.archived` — ушла в архив; во всех остальных случаях, в том числе при повторном сохранении опубликованной статьи и при возврате в `draft`, — `wiki.updated`;\n- сигнал `changed` по WebSocket.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID статьи"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WikiPageSaveInput"},"example":{"title":"Как мы работаем","slug":"onboarding","summary":"Правила команды для новых сотрудников","status":"published","version":7,"body":{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Регламент","marks":[{"type":"link","attrs":{"href":"https://metodox.ru/wiki"}}]}]},{"type":"taskList","content":[{"type":"taskItem","attrs":{"checked":true},"content":[{"type":"paragraph","content":[{"type":"text","text":"Получить доступы"}]}]}]}]}}}}},"responses":{"200":{"description":"Новая версия статьи","content":{"application/json":{"schema":{"type":"object","required":["version"],"properties":{"version":{"type":"integer"}}},"example":{"version":8}}}},"400":{"description":"- Ошибка валидации полей (`ValidationError`), включая медиа-ID не в формате UUID.\n- «Статья слишком большая», «Некорректный документ», «Неподдерживаемый блок», «Нужен документ», «Загрузите изображение в эту статью», «Некорректное форматирование», «Неподдерживаемое форматирование», «Недопустимая ссылка», `Bad Request` без пояснения: ошибки нормализатора.\n- «Медиа должно принадлежать этой статье».\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/Error"}]},"example":{"statusCode":400,"message":"Недопустимая ссылка","error":"Bad Request"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"- «Нет прав редактирования статьи»: опубликованная статья, а пользователь не `owner`/`admin` (для публичной Wiki — не редактор платформы).\n- «Публикацией и изменением опубликованной статьи управляет администратор»: автор публикует статью или статью опубликовали, пока он её правил.\n- «Wiki организации доступна сотрудникам» (гость).\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Нет прав редактирования статьи","error":"Forbidden"}}}},"404":{"description":"Статьи нет (или её удалили во время сохранения), это чужой черновик или архивная статья, которую пользователь не может редактировать, либо пользователь не участник организации","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Not Found"}}}},"409":{"description":"- «Статью уже изменили. Сохраните свой текст отдельно и обновите страницу.»: `version` не совпадает.\n- «Такой адрес статьи уже существует»: slug занят в этой Wiki.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":409,"message":"Статью уже изменили. Сохраните свой текст отдельно и обновите страницу.","error":"Conflict"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/wiki/pages/{id}/versions":{"get":{"operationId":"listWikiPageVersions","tags":["Wiki"],"summary":"История версий статьи","description":"Возвращает до 50 последних версий статьи, новые первыми, с полным документом каждой. Версия 1 — момент создания статьи.\n\n**Доступ:** только тот, кто может редактировать статью, см. `canEdit`. Для опубликованной статьи остальные получают 403 «Нет прав редактирования статьи»; это относится и к автору после публикации статьи организации. Для черновика или архивной статьи, которую пользователь не может редактировать, — 404.\n\n**Побочные эффекты:** нет.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID статьи"}],"responses":{"200":{"description":"Версии","content":{"application/json":{"schema":{"type":"array","maxItems":50,"items":{"$ref":"#/components/schemas/WikiVersion"}},"example":[{"id":"6a7b8c9d-becf-4b2c-9d9f-0a1b2c3d4e5f","version":2,"title":"Как мы работаем","summary":"Правила команды для новых сотрудников","body":{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Черновик регламента"}]}]},"status":"draft","createdAt":"2026-09-10T08:12:31.090Z","authorName":"Игорь Петров"},{"id":"7b8c9dae-cfd0-4c3d-8eaf-1b2c3d4e5f60","version":1,"title":"Как мы работаем","summary":"","body":{"type":"doc","content":[{"type":"paragraph"}]},"status":"draft","createdAt":"2026-09-10T08:00:00.000Z","authorName":"Игорь Петров"}]}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"«Нет прав редактирования статьи» или гость организации","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Нет прав редактирования статьи","error":"Forbidden"}}}},"404":{"description":"Статьи нет, это черновик или архивная статья, которую пользователь не может редактировать, или пользователь не участник организации","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/wiki/pages/{id}/files":{"post":{"operationId":"uploadWikiFile","tags":["Wiki"],"summary":"Загрузить медиа в статью","description":"Загружает файл и привязывает его к статье. Чтобы показать файл в статье, вставьте полученный `url` в `image.src` или `link.href` и сохраните статью через `PATCH`.\n\n**Порядок обработки:**\n\n1. Guard проверяет право редактирования статьи (скрытый черновик — 404, опубликованная статья без прав — 403) и наличие свободного слота передачи (503).\n2. Multer принимает multipart; имя части декодируется как UTF-8.\n3. Проверяются наличие и непустота файла, имя нормализуется (см. «Медиа» в описании раздела), тип определяется по содержимому.\n4. Проверяется квота статьи 1 ГиБ. При S3 зашифрованный объект загружается в хранилище до транзакции, чтобы строка статьи не была заблокирована во время передачи.\n5. В транзакции статья блокируется (`FOR UPDATE`), квота проверяется повторно, создаётся строка файла.\n\n**Лимиты:**\n\n- от 1 байта до 100 МиБ на файл;\n- один файл в поле `file`, других полей формы быть не должно;\n- на домене приложения Caddy ограничивает тело запроса 110 МБ.\n\n**Доступ:** редактор статьи, см. `canEdit`.\n\n**Побочные эффекты:** строка [wiki_files](#модели/dbwiki-files). При `STORAGE_DRIVER=s3` — ещё зашифрованный объект в S3 и запись `storage_objects` в состоянии `attached`. Событие журнала не пишется.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID статьи"}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["file"],"additionalProperties":false,"properties":{"file":{"type":"string","format":"binary","description":"Изображение, видео (MP4, WebM), аудио (MP3), PDF, документ Office, ZIP или текстовый файл (TXT, MD, CSV, JSON), от 1 байта до 100 МиБ. Имя части (`filename`) — в UTF-8"}}}}}},"responses":{"201":{"description":"Файл сохранён","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WikiFileUploaded"},"example":{"id":"5f6a7b8c-adcf-4a1b-8c8e-9f0a1b2c3d4e","name":"Схема доступа.png","mime":"image/png","url":"/api/v1/wiki/files/5f6a7b8c-adcf-4a1b-8c8e-9f0a1b2c3d4e"}}}},"400":{"description":"- «Выберите файл»: нет поля `file`.\n- «Файл пустой. Выберите файл с содержимым»: файл размером 0 байт.\n- «Поддерживаются изображения, видео, аудио, PDF, Office, ZIP и текстовые файлы (TXT, MD, CSV, JSON)»: тип не распознан (`ATTACHMENT_FORMATS`, общий текст для всех загрузок).\n- «Лимит статьи — 1 ГБ»: суммарный размер файлов статьи превысит 1 ГиБ.\n- Ошибки multer: `Unexpected field` (имя поля не `file`), `Too many files`, `Too many fields`.\n- Невалидный UUID в пути (`ValidationError`).\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/Error"},{"$ref":"#/components/schemas/ValidationError"}]},"examples":{"empty":{"summary":"Пустой файл","value":{"statusCode":400,"message":"Файл пустой. Выберите файл с содержимым","error":"Bad Request"}},"unsupported":{"summary":"Тип не распознан","value":{"statusCode":400,"message":"Поддерживаются изображения, видео, аудио, PDF, Office, ZIP и текстовые файлы (TXT, MD, CSV, JSON)","error":"Bad Request"}},"quota":{"summary":"Превышен лимит статьи","value":{"statusCode":400,"message":"Лимит статьи — 1 ГБ","error":"Bad Request"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"«Нет прав редактирования статьи» (опубликованная статья, пользователь не может её редактировать) или гость организации","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Нет прав редактирования статьи","error":"Forbidden"}}}},"404":{"description":"Статьи нет (или её удалили во время загрузки), это черновик или архивная статья, которую пользователь не может редактировать, или пользователь не участник организации","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Not Found"}}}},"413":{"$ref":"#/components/responses/PayloadTooLarge"},"429":{"$ref":"#/components/responses/TooManyRequests"},"503":{"$ref":"#/components/responses/ServiceUnavailable"}}}},"/api/v1/wiki/files/{id}":{"get":{"operationId":"downloadWikiFile","tags":["Wiki"],"summary":"Получить медиа статьи","description":"Отдаёт содержимое файла статьи.\n\n**Заголовки ответа:**\n\n- `Content-Type` — тип, сохранённый при загрузке.\n- `Content-Disposition: inline` для `image/*`, `video/*`, `audio/*`, а также для PDF при `?preview=1`. В остальных случаях и при `?download=1` — `attachment`. Имя передаётся как `filename*=UTF-8''…`.\n- `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; sandbox`, `Cache-Control: private, no-store`, `Accept-Ranges: bytes`.\n\n**Диапазоны.** Поддерживается один диапазон `Range: bytes=start-end` или `bytes=-N`. Зашифрованный объект при этом всё равно читается и расшифровывается целиком.\n\n**Доступ:**\n\n- нужен вход и доступ к статье на чтение, как в `GET /api/v1/wiki/pages/{id}`;\n- редактор статьи получает любой её файл;\n- остальные — только файлы, на которые ссылается текущий `body` статьи, иначе 404.\n\n**Побочные эффекты:** нет.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID файла"},{"name":"download","in":"query","required":false,"schema":{"type":"string","example":"1"},"description":"`1` — всегда `attachment`"},{"name":"preview","in":"query","required":false,"schema":{"type":"string","example":"1"},"description":"`1` — PDF отдаётся `inline`"},{"name":"Range","in":"header","required":false,"schema":{"type":"string","example":"bytes=0-1048575"},"description":"Один байтовый диапазон"}],"responses":{"200":{"description":"Файл целиком","headers":{"Content-Disposition":{"schema":{"type":"string"},"description":"`inline` или `attachment`, с `filename*`"},"Accept-Ranges":{"schema":{"type":"string","const":"bytes"}}},"content":{"*/*":{"schema":{"type":"string","format":"binary"}}}},"206":{"description":"Часть файла","headers":{"Content-Range":{"schema":{"type":"string","example":"bytes 0-1048575/5242880"}}},"content":{"*/*":{"schema":{"type":"string","format":"binary"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Гость организации","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Wiki организации доступна сотрудникам","error":"Forbidden"}}}},"404":{"description":"Файла нет, статья недоступна, или читатель запросил файл, на который нет ссылки в текущем документе","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Not Found"}}}},"416":{"description":"Диапазон некорректен или вне файла. Тело пустое, заголовок `Content-Range: bytes */<размер>`","headers":{"Content-Range":{"schema":{"type":"string","example":"bytes */5242880"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"},"503":{"$ref":"#/components/responses/ServiceUnavailable"}}}},"/api/v1/wiki/public":{"get":{"operationId":"listPublicWikiPages","tags":["Публичная Wiki"],"summary":"Опубликованные статьи публичной Wiki","description":"Возвращает опубликованные статьи публичной Wiki по названию (затем по `id`), не больше 500. Документы в список не входят. Вход не нужен. Маршрут доступен и на https://metodox.ru.\n\nОтвет остаётся массивом для совместимости, а признак усечения передаётся заголовком ответа `X-Truncated`: `true`, если опубликованных статей больше 500 и список обрезан, иначе `false`. Заголовок не перечислен в `Access-Control-Expose-Headers` (CORS настроен без `exposedHeaders`), поэтому браузерный код с другого origin его не прочитает; на том же домене и вне браузера он доступен.\n\n**Побочные эффекты:** нет.\n","responses":{"200":{"description":"Статьи","headers":{"X-Truncated":{"description":"`true` — статей больше 500 и в ответе только первые 500; `false` — список полный","required":true,"schema":{"type":"string","enum":["true","false"]},"example":"false"}},"content":{"application/json":{"schema":{"type":"array","maxItems":500,"items":{"$ref":"#/components/schemas/PublicWikiPageListItem"}},"example":[{"slug":"struktura-rabochego-prostranstva","title":"Как устроен Metodox: организации, проекты и доски","summary":"Иерархия организации, проекты и этапы, независимые доски, бэклог, спринты и два вида Wiki.","updatedAt":"2026-09-15T12:00:00.000Z"}]}}},"429":{"$ref":"#/components/responses/TooManyRequests"}},"security":[]}},"/api/v1/wiki/public/pages/{slug}":{"get":{"operationId":"getPublicWikiPage","tags":["Публичная Wiki"],"summary":"Опубликованная статья по адресу","description":"Возвращает опубликованную статью публичной Wiki по `slug`. Вход не нужен. Маршрут доступен и на https://metodox.ru.\n\nДокумент приходит в двух вариантах:\n- `publicBody` — медиа-ссылки уже указывают на анонимный `/api/v1/wiki/public/files/{id}`; используйте его для показа без входа;\n- `body` — документ как хранится, со ссылками `/api/v1/wiki/files/{id}` (требуют входа); оставлен для старых клиентов.\n\n**Побочные эффекты:** нет.\n","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","maxLength":120,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$"},"description":"Адрес статьи. Проверяется тем же правилом, что при сохранении (`wikiSlug`): строчные латинские буквы и цифры, группы через один дефис, до 120 символов. Иначе — 400","example":"struktura-rabochego-prostranstva"}],"responses":{"200":{"description":"Статья","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicWikiPage"},"example":{"slug":"struktura-rabochego-prostranstva","title":"Как устроен Metodox: организации, проекты и доски","summary":"Иерархия организации, проекты и этапы, независимые доски, бэклог, спринты и два вида Wiki.","body":{"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Проект — общее направление"}]},{"type":"paragraph","content":[{"type":"text","text":"Проект группирует несколько досок вокруг продукта или инициативы."}]},{"type":"image","attrs":{"src":"/api/v1/wiki/files/5f6a7b8c-adcf-4a1b-8c8e-9f0a1b2c3d4e","alt":"Иерархия организации","title":null}}]},"publicBody":{"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Проект — общее направление"}]},{"type":"paragraph","content":[{"type":"text","text":"Проект группирует несколько досок вокруг продукта или инициативы."}]},{"type":"image","attrs":{"src":"/api/v1/wiki/public/files/5f6a7b8c-adcf-4a1b-8c8e-9f0a1b2c3d4e","alt":"Иерархия организации","title":null}}]},"updatedAt":"2026-09-15T12:00:00.000Z"}}}},"400":{"description":"`slug` не соответствует правилу адреса статьи (`ValidationError`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}}},"404":{"description":"Статьи с таким адресом нет, она не опубликована или принадлежит организации","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}},"security":[]}},"/api/v1/wiki/public/files/{id}":{"get":{"operationId":"downloadPublicWikiFile","tags":["Публичная Wiki"],"summary":"Медиа опубликованной статьи","description":"Отдаёт файл без входа. Маршрут доступен и на https://metodox.ru.\n\n**Условия выдачи:**\n\n- файл принадлежит статье публичной Wiki (`workspace_id IS NULL`);\n- статья опубликована;\n- на файл ссылается её текущий `body`.\n\nИменно на этот маршрут указывают медиа-ссылки в `publicBody` ответа `GET /api/v1/wiki/public/pages/{slug}`.\n\nМедиа внутренних Wiki организаций здесь не отдаётся, даже если статья опубликована.\n\nЗаголовки ответа, `?download=1`, `?preview=1` и `Range` работают так же, как в `GET /api/v1/wiki/files/{id}`.\n\n**Побочные эффекты:** нет.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID файла"},{"name":"download","in":"query","required":false,"schema":{"type":"string","example":"1"},"description":"`1` — всегда `attachment`"},{"name":"preview","in":"query","required":false,"schema":{"type":"string","example":"1"},"description":"`1` — PDF отдаётся `inline`"},{"name":"Range","in":"header","required":false,"schema":{"type":"string","example":"bytes=0-1048575"},"description":"Один байтовый диапазон"}],"responses":{"200":{"description":"Файл целиком","headers":{"Content-Disposition":{"schema":{"type":"string"},"description":"`inline` или `attachment`, с `filename*`"},"Accept-Ranges":{"schema":{"type":"string","const":"bytes"}}},"content":{"*/*":{"schema":{"type":"string","format":"binary"}}}},"206":{"description":"Часть файла","headers":{"Content-Range":{"schema":{"type":"string","example":"bytes 0-1048575/5242880"}}},"content":{"*/*":{"schema":{"type":"string","format":"binary"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"description":"Файла нет, он не из опубликованной публичной статьи или на него нет ссылки в её текущем документе","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Not Found"}}}},"416":{"description":"Диапазон некорректен. Тело пустое, заголовок `Content-Range: bytes */<размер>`","headers":{"Content-Range":{"schema":{"type":"string","example":"bytes */5242880"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"},"503":{"$ref":"#/components/responses/ServiceUnavailable"}},"security":[]}},"/api/v1/notes":{"get":{"operationId":"listNotes","tags":["Заметки"],"summary":"Список заметок или корзины","description":"Возвращает страницу до 300 заметок текущего пользователя: закреплённые первыми, затем по\n`updatedAt` от новых к старым (при равенстве — по `id`). С `trash=true` — заметки из корзины.\nТело заметки (`body`) приходит целиком.\n\n**Пагинация.** У каждой заметки есть `cursor`. Следующая страница —\n`?before=<cursor последней заметки>` (с тем же `trash`). Вместо курсора можно передать `id`\nпоследней заметки: тогда сервер сам найдёт её позицию. Страница короче 300 — последняя.\n\n**Доступ:** только свои заметки.\n\n**Побочные эффекты:** нет.\n","parameters":[{"name":"trash","in":"query","required":false,"schema":{"type":"string"},"example":"true","description":"Ровно `true` — показать корзину. Любое другое значение или его отсутствие — активные заметки"},{"name":"before","in":"query","required":false,"schema":{"type":"string","maxLength":200},"example":"WzEsIjIwMjYtMTAtMDJUMDg6MTQ6MDMuNTEyMzQ1WiIsIjNmNmMxYTJlLTliNGQtNGU3YS04YzIxLTVkMGU3ZjlhMWIzNCJd","description":"`cursor` последней заметки предыдущей страницы или её `id`. Без параметра — первая страница"}],"responses":{"200":{"description":"Страница заметок (не больше 300)","content":{"application/json":{"schema":{"type":"array","maxItems":300,"items":{"$ref":"#/components/schemas/NoteListItem"}},"example":[{"id":"3f6c1a2e-9b4d-4e7a-8c21-5d0e7f9a1b34","title":"План запуска","body":"## Неделя 1\n- Согласовать бюджет\n- Подготовить лендинг","folder":"Работа","pinned":true,"version":7,"updatedAt":"2026-10-02T08:14:03.512Z","cursor":"WzEsIjIwMjYtMTAtMDJUMDg6MTQ6MDMuNTEyMzQ1WiIsIjNmNmMxYTJlLTliNGQtNGU3YS04YzIxLTVkMGU3ZjlhMWIzNCJd"},{"id":"a8d2e4f1-6c3b-4f9e-b0a7-2e1d9c8b7a65","title":"Книги","body":"Дизайн привычных вещей","folder":"","pinned":false,"version":2,"updatedAt":"2026-09-28T19:40:11.002Z","cursor":"WzAsIjIwMjYtMDktMjhUMTk6NDA6MTEuMDAyMDAwWiIsImE4ZDJlNGYxLTZjM2ItNGY5ZS1iMGE3LTJlMWQ5YzhiN2E2NSJd"}]}}},"400":{"description":"`before` — не UUID и не курсор этого списка: «Неверный курсор списка заметок» в `formErrors`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"},"example":{"message":"Проверьте поля формы","errors":{"formErrors":["Неверный курсор списка заметок"],"fieldErrors":{}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"`before` — UUID, но такой заметки у пользователя нет","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Заметка для продолжения списка не найдена","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"post":{"operationId":"createNote","tags":["Заметки"],"summary":"Создать заметку","description":"Создаёт заметку с `version = 1`.\n\n**Доступ:** любой вошедший пользователь; заметка принадлежит ему.\n\n**Побочные эффекты:** запись в [notes](#модели/dbnotes).\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NoteInput"},"example":{"title":"Идеи для статьи","body":"- Сравнить подходы\n- Собрать примеры","folder":"Блог","pinned":false}}}},"responses":{"201":{"description":"Заметка создана. Значения по умолчанию уже подставлены","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NoteCreated"},"example":{"id":"c7e9b1d3-2f4a-4b6c-8d0e-1a2b3c4d5e6f","title":"Идеи для статьи","body":"- Сравнить подходы\n- Собрать примеры","folder":"Блог","pinned":false,"version":1}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/notes/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID заметки"}],"patch":{"operationId":"updateNote","tags":["Заметки"],"summary":"Изменить заметку","description":"Меняет только переданные поля из `title`, `body`, `folder` и `pinned`, остальные сохраняют\nтекущие значения. Если версия совпала, увеличивает `version` и обновляет `updatedAt`, даже\nкогда кроме `version` ничего не передано. Ответ — заметка целиком, в том виде, как она сохранена.\n\n**Доступ:** только владелец; заметка не должна быть в корзине.\n\n**Побочные эффекты:** изменение строки в [notes](#модели/dbnotes).\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NoteUpdateInput"},"example":{"pinned":true,"version":7}}}},"responses":{"200":{"description":"Обновлённая заметка с новой версией","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Note"},"example":{"id":"3f6c1a2e-9b4d-4e7a-8c21-5d0e7f9a1b34","title":"План запуска","body":"## Неделя 1\n- Согласовать бюджет\n- Подготовить лендинг\n- Запустить рекламу","folder":"Работа","pinned":true,"version":8,"updatedAt":"2026-10-02T08:20:45.118Z"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"Заметки нет, она чужая или лежит в корзине","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Not Found"}}}},"409":{"description":"`version` устарела: заметку уже изменили в другом окне или на другом устройстве","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":409,"message":"Заметка изменена в другом окне. Скопируйте текст и откройте актуальную версию.","error":"Conflict"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"delete":{"operationId":"trashNote","tags":["Заметки"],"summary":"Переместить заметку в корзину","description":"Мягкое удаление: ставит `deleted_at`, увеличивает `version` и записывает в `updatedAt` момент\nпереноса, поэтому в корзине недавно удалённые заметки идут первыми. Удалить навсегда можно\nпотом из корзины: `DELETE /api/v1/notes/{id}/permanent` или `DELETE /api/v1/notes/trash`.\n\n**Доступ:** только владелец.\n\n**Побочные эффекты:** изменение строки в [notes](#модели/dbnotes).\n","responses":{"200":{"description":"Заметка в корзине","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"Заметки нет, она чужая или уже в корзине","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/notes/{id}/restore":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID заметки"}],"post":{"operationId":"restoreNote","tags":["Заметки"],"summary":"Восстановить заметку из корзины","description":"Убирает `deleted_at`, увеличивает `version` и обновляет `updatedAt`. Новая версия в ответе\nне приходит: перечитайте список.\n\n**Доступ:** только владелец.\n\n**Побочные эффекты:** изменение строки в [notes](#модели/dbnotes).\n","responses":{"201":{"description":"Заметка восстановлена","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"Заметки нет, она чужая или не лежит в корзине","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/notes/{id}/permanent":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID заметки в корзине"}],"delete":{"operationId":"deleteNotePermanently","tags":["Заметки"],"summary":"Удалить заметку из корзины навсегда","description":"Окончательно удаляет одну заметку, которая уже лежит в корзине. Восстановить её после этого\nнельзя. Активную заметку так удалить нельзя: сначала переместите её в корзину\n(`DELETE /api/v1/notes/{id}`), иначе 409. Версия не проверяется.\n\n**Доступ:** только владелец; чужая заметка неотличима от несуществующей (404).\n\n**Побочные эффекты:** удаление строки из [notes](#модели/dbnotes).\n","responses":{"200":{"description":"Заметка удалена навсегда","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"Заметки нет или она чужая","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Заметка не найдена","error":"Not Found"}}}},"409":{"description":"Заметка не в корзине","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":409,"message":"Навсегда удалить можно только заметку из корзины. Сначала переместите её в корзину","error":"Conflict"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/notes/trash":{"delete":{"operationId":"emptyNotesTrash","tags":["Заметки"],"summary":"Очистить корзину заметок","description":"Окончательно удаляет все заметки пользователя, лежащие в корзине (`deleted_at` задан).\nАктивные заметки не затрагиваются. Пустая корзина — не ошибка: ответ `{ ok: true, deleted: 0 }`.\nТело не нужно.\n\nМаршрут объявлен раньше `DELETE /api/v1/notes/{id}`, поэтому `trash` не принимается за ID заметки.\n\n**Доступ:** только свои заметки.\n\n**Побочные эффекты:** удаление строк из [notes](#модели/dbnotes).\n","responses":{"200":{"description":"Корзина очищена","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotesTrashEmptied"},"example":{"ok":true,"deleted":4}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/planner":{"get":{"operationId":"getPlannerWeek","tags":["Планер"],"summary":"Недельный план задач","description":"Возвращает задачи пользователя с личным планом, «сегодня» по его часовому поясу, дневную цель\nи число завершений по дням окна `week … week+6`. Какие задачи попадают в ответ, описано\nв теге «Планер».\n\n**Доступ:** любой вошедший пользователь. Доступ к задачам проверяется в запросе по тем же\nправилам, что и открытие карточки, до `LIMIT 300`; недоступные задачи в ответ не попадают.\n\n**Побочные эффекты:** нет.\n","parameters":[{"name":"week","in":"query","required":false,"schema":{"type":"string","format":"date"},"description":"Первый день окна (`YYYY-MM-DD`). Переданная дата по понедельнику не выравнивается. По умолчанию — понедельник текущей недели в часовом поясе пользователя"}],"responses":{"200":{"description":"План на неделю","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlannerWeek"},"example":{"tasks":[{"id":"e1b2c3d4-5f6a-4b7c-8d9e-0f1a2b3c4d5e","boardId":"8f1c2d4e-6a7b-4c8d-9e0f-1a2b3c4d5e6f","title":"Подготовить отчёт для инвесторов","version":4,"priority":"high","columnId":"0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d","dueDate":"2026-10-03","day":"2026-10-01","minutes":90,"boardTitle":"Финансы","done":false,"doneColumnId":"9f8e7d6c-5b4a-4392-8170-6f5e4d3c2b1a","canEdit":true},{"id":"b4c5d6e7-8f9a-4b0c-9d1e-2f3a4b5c6d7e","boardId":"8f1c2d4e-6a7b-4c8d-9e0f-1a2b3c4d5e6f","title":"Сверить счета","version":2,"priority":"medium","columnId":"0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d","dueDate":null,"day":null,"minutes":null,"boardTitle":"Финансы","done":false,"doneColumnId":"9f8e7d6c-5b4a-4392-8170-6f5e4d3c2b1a","canEdit":false}],"today":"2026-10-02","dailyGoal":3,"week":"2026-09-28","completions":[{"day":"2026-09-29","count":2},{"day":"2026-10-01","count":3}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/planner/today":{"get":{"operationId":"getPlannerToday","tags":["Планер"],"summary":"Задачи на сегодня","description":"Экран «Сегодня»: четыре списка задач на текущий календарный день пользователя (по\n`users.timezone`) — просроченные, со сроком на сегодня, запланированные на сегодня и\nвыполненные сегодня. Правила отбора и порядок описаны в теге «Планер», раздел «День».\n\n«Мои» задачи — где пользователь исполнитель, и задачи без исполнителя, которые он создал.\nВыполненной здесь считается задача в последней колонке своей доски. Чтобы отметить задачу\nвыполненной или вернуть в работу, перенесите её в `lastColumnId` или `firstColumnId` через\n`PATCH /api/v1/boards/{boardId}/tasks/{id}` с текущей `version` (нужен `canEdit`).\n\n**Доступ:** любой вошедший пользователь. Доступ к задачам проверяется в запросе по правилам\nоткрытия карточки; недоступные задачи не попадают ни в один список.\n\n**Побочные эффекты:** нет.\n","responses":{"200":{"description":"Списки задач на сегодня","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlannerToday"},"example":{"date":"2026-10-03","overdue":[{"id":"e1b2c3d4-5f6a-4b7c-8d9e-0f1a2b3c4d5e","boardId":"8f1c2d4e-6a7b-4c8d-9e0f-1a2b3c4d5e6f","boardTitle":"Финансы","boardAccent":"#facc15","number":12,"title":"Подготовить отчёт для инвесторов","dueDate":"2026-10-01","priority":"high","columnId":"0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d","firstColumnId":"0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d","lastColumnId":"9f8e7d6c-5b4a-4392-8170-6f5e4d3c2b1a","version":4,"canEdit":true,"plannedDay":null}],"today":[],"planned":[{"id":"b4c5d6e7-8f9a-4b0c-9d1e-2f3a4b5c6d7e","boardId":"8f1c2d4e-6a7b-4c8d-9e0f-1a2b3c4d5e6f","boardTitle":"Финансы","boardAccent":"#facc15","number":15,"title":"Сверить счета","dueDate":null,"priority":"medium","columnId":"0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d","firstColumnId":"0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d","lastColumnId":"9f8e7d6c-5b4a-4392-8170-6f5e4d3c2b1a","version":2,"canEdit":false,"plannedDay":"2026-10-03"}],"doneToday":[{"id":"c7d8e9f0-1a2b-4c3d-8e4f-5a6b7c8d9e0f","boardId":"8f1c2d4e-6a7b-4c8d-9e0f-1a2b3c4d5e6f","boardTitle":"Финансы","boardAccent":"#facc15","number":9,"title":"Оплатить аренду","dueDate":"2026-10-03","priority":"medium","columnId":"9f8e7d6c-5b4a-4392-8170-6f5e4d3c2b1a","firstColumnId":"0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d","lastColumnId":"9f8e7d6c-5b4a-4392-8170-6f5e4d3c2b1a","version":6,"canEdit":true,"plannedDay":null}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/planner/goal":{"patch":{"operationId":"setPlannerDailyGoal","tags":["Планер"],"summary":"Задать дневную цель","description":"Сохраняет, сколько задач в день пользователь хочет завершать (1–20).\n\n**Доступ:** любой вошедший пользователь, только для себя.\n\n**Побочные эффекты:** `users.daily_goal`.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlannerGoal"},"example":{"dailyGoal":5}}}},"responses":{"200":{"description":"Сохранённая цель (повторяет тело запроса)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlannerGoal"},"example":{"dailyGoal":5}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/planner/tasks/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID задачи"}],"patch":{"operationId":"planTask","tags":["Планер"],"summary":"Поставить задачу на день или снять с плана","description":"`day` — дата: создаёт или заменяет личный план (день и минуты). `day: null` удаляет план.\nЗадача и её `version` не меняются.\n\n**Доступ:** нужно право **чтения** задачи на доске `boardId` (как при открытии карточки).\nБыть исполнителем или иметь право редактирования не требуется.\n\n**Побочные эффекты:** запись или удаление в [task_plans](#модели/dbtask-plans). Уведомлений\nи realtime нет.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlannerPlanInput"},"examples":{"plan":{"summary":"Поставить на день","value":{"boardId":"8f1c2d4e-6a7b-4c8d-9e0f-1a2b3c4d5e6f","day":"2026-10-05","minutes":60}},"unplan":{"summary":"Снять с плана","value":{"boardId":"8f1c2d4e-6a7b-4c8d-9e0f-1a2b3c4d5e6f","day":null}}}}}},"responses":{"200":{"description":"План сохранён или удалён","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"Доски нет или к ней нет доступа («Доска не найдена»); задачи нет на этой доске\nили она скрыта (`restricted`) от пользователя («Задача не найдена»)\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Задача не найдена","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/motivation":{"get":{"operationId":"getMotivation","tags":["Мотивация и люди"],"summary":"Кошелёк, достижения, магазин и активность","description":"Всё для экрана прогресса одним ответом:\n- баланс, XP и уровень;\n- статистика задач;\n- активность за 14 дней и тепловая карта за 28 дней;\n- каталог магазина с отметками «куплено» и «надето»;\n- последние 30 операций журнала;\n- серии и все достижения с прогрессом.\n\n**Доступ:** любой вошедший пользователь, только свои данные.\n\n**Побочные эффекты:** нет. Достижения здесь не разблокируются: это происходит при завершении\nзадачи.\n","responses":{"200":{"description":"Сводка мотивации","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MotivationOverview"},"example":{"balance":85,"xp":230,"activeCosmetic":"ocean-frame","level":3,"stats":{"completed":23,"month":9,"active":6,"overdue":1},"activity":[{"day":"2026-09-19","count":0},{"day":"2026-09-20","count":2},{"day":"2026-10-02","count":1}],"catalog":[{"id":"navigator-badge","title":"Навигатор","description":"Подпись для тех, кто держит курс","price":130,"slot":"badge","owned":false,"equipped":false},{"id":"aurora-cover","title":"Северное сияние","description":"Градиентная обложка публичного профиля","price":120,"slot":"cover","owned":false,"equipped":false},{"id":"lime-frame","title":"Лаймовая рамка","description":"Акцентная рамка вокруг аватара","price":50,"slot":"frame","owned":true,"equipped":false},{"id":"ocean-frame","title":"Океан","description":"Голубая рамка аватара","price":70,"slot":"frame","owned":true,"equipped":true}],"ledger":[{"amount":10,"reason":"task_completed","createdAt":"2026-10-02T07:55:12.004Z"},{"amount":25,"reason":"achievement","createdAt":"2026-09-30T16:02:41.331Z"},{"amount":-70,"reason":"purchase","createdAt":"2026-09-29T10:12:08.778Z"}],"game":{"metrics":{"completed":23,"onTime":11,"streak":4},"streak":{"current":2,"longest":4},"today":"2026-10-02","todayCount":1,"dailyGoal":3},"achievements":[{"id":"first-step","title":"Первый шаг","description":"Завершить первую задачу","metric":"completed","target":1,"coins":10,"xp":20,"unlockedAt":"2026-09-10T09:00:00.000Z","claimedAt":"2026-09-10T09:01:30.000Z","progress":1,"eligible":true},{"id":"rhythm","title":"Свой ритм","description":"Завершать задачи 3 дня подряд","metric":"streak","target":3,"coins":40,"xp":60,"unlockedAt":"2026-09-22T12:10:00.000Z","claimedAt":null,"progress":3,"eligible":true},{"id":"builder","title":"Создатель результата","description":"Завершить 25 задач","metric":"completed","target":25,"coins":80,"xp":100,"unlockedAt":null,"claimedAt":null,"progress":23,"eligible":false}],"heatmap":[{"day":"2026-09-05","count":0},{"day":"2026-10-01","count":3},{"day":"2026-10-02","count":1}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/motivation/purchase":{"post":{"operationId":"purchaseCosmetic","tags":["Мотивация и люди"],"summary":"Купить предмет оформления","description":"Списывает цену предмета с баланса и добавляет предмет пользователю. Всё происходит в одной\nтранзакции с блокировкой строки пользователя (`FOR UPDATE`). Предмет не надевается\nавтоматически: для этого есть `PATCH /api/v1/motivation/appearance`.\n\n**Доступ:** любой вошедший пользователь.\n\n**Побочные эффекты:** [user_cosmetics](#модели/dbuser-cosmetics), `users.coin_balance`\nи строка в [coin_ledger](#модели/dbcoin-ledger) (`reason = purchase`, отрицательная сумма).\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MotivationPurchaseInput"},"example":{"itemId":"sunset-cover"}}}},"responses":{"201":{"description":"Предмет куплен","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"Такого предмета нет в каталоге","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Not Found"}}}},"409":{"description":"Предмет уже куплен («Уже приобретено») или не хватает монет («Недостаточно монет»)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":409,"message":"Недостаточно монет","error":"Conflict"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/motivation/appearance":{"patch":{"operationId":"setAppearance","tags":["Мотивация и люди"],"summary":"Надеть или снять оформление","description":"- `{ itemId: \"<id>\" }` надевает купленный предмет в его слот из каталога и заменяет прежний предмет этого слота.\n- `{ itemId: null, slot }` снимает предмет со слота.\n- `{ itemId: null }` снимает всё.\n\n`activeCosmetic` (устаревшее поле одного предмета) следует за надетым: при надевании — этот\nпредмет; при снятии слота остаётся прежним, если тот предмет всё ещё надет, иначе переходит\nна другой надетый (порядок слотов `frame`, `cover`, `badge`); `null` — только когда не надето\nничего.\n\n**Доступ:** любой вошедший пользователь, только купленные предметы.\n\n**Побочные эффекты:** [equipped_cosmetics](#модели/dbequipped-cosmetics) и\n`users.active_cosmetic`. Оформление видно в публичном профиле (`GET /api/v1/people/{id}`).\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MotivationAppearanceInput"},"examples":{"equip":{"summary":"Надеть рамку","value":{"itemId":"ocean-frame"}},"unequipSlot":{"summary":"Снять обложку","value":{"itemId":null,"slot":"cover"}},"unequipAll":{"summary":"Снять всё","value":{"itemId":null}}}}}},"responses":{"200":{"description":"Оформление обновлено","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"Предмет не куплен пользователем (или такого предмета нет)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Сначала приобретите оформление","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/motivation/achievements/{id}/claim":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","minLength":1,"maxLength":50},"description":"ID достижения из каталога, например `first-step`","example":"momentum"}],"post":{"operationId":"claimAchievement","tags":["Мотивация и люди"],"summary":"Получить награду за достижение","description":"Проверяет условие достижения по актуальным метрикам. Если достижение ещё не было\nразблокировано, разблокирует его, отмечает награду полученной и начисляет монеты и XP\nдостижения. Всё происходит в одной транзакции с блокировкой строки пользователя.\nНаграду за каждое достижение можно получить один раз.\n\n**Доступ:** любой вошедший пользователь, только для себя.\n\n**Побочные эффекты:** [user_achievements](#модели/dbuser-achievements) (`claimed_at`),\nстрока в [coin_ledger](#модели/dbcoin-ledger) (`reason = achievement`), `users.coin_balance`\nи `users.xp`.\n","responses":{"201":{"description":"Награда начислена","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MotivationClaimResult"},"example":{"reward":{"coins":25,"xp":40,"unlocked":["Набирая ход"],"forActor":true}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"Такого достижения нет в каталоге","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Not Found"}}}},"409":{"description":"Условие ещё не выполнено («Условия достижения ещё не выполнены») или награда уже получена («Награда уже получена»)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":409,"message":"Награда уже получена","error":"Conflict"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/motivation/team/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"}],"get":{"operationId":"getTeamMotivation","tags":["Мотивация и люди"],"summary":"Сводка прогресса команды","description":"Сводка по всем задачам на досках организации и по каждому участнику, включая\nИИ-сотрудников (`kind: ai`). Задача засчитывается участнику, если он её исполнитель, а для\nзадачи без исполнителя — если он её завершил (или создал, пока она не завершена).\n\n**Доступ:** только владелец или администратор организации (`owner`/`admin`). Участник без\nэтих прав получает 403, не участник — 404.\n\n**Побочные эффекты:** нет.\n","responses":{"200":{"description":"Сводка организации","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MotivationTeam"},"example":{"summary":{"total":48,"completed":31,"overdue":3},"members":[{"id":"5b0f3c7e-2a41-4d8e-9c1a-7e6f2b9d4a10","name":"Анна Смирнова","kind":"human","completed":12,"active":4},{"id":"d9e8f7a6-b5c4-4d3e-8f2a-1b0c9d8e7f6a","name":"Ассистент аналитики","kind":"ai","completed":5,"active":1},{"id":"1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d","name":"Иван Петров","kind":"human","completed":3,"active":7}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Пользователь состоит в организации, но не владелец и не администратор (403 при запрещённом Origin добавляет сборка)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Нужны права администратора организации","error":"Forbidden"}}}},"404":{"description":"Организации нет или пользователь в ней не состоит","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Организация не найдена","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/people":{"get":{"operationId":"listPeople","tags":["Мотивация и люди"],"summary":"Каталог публичных профилей","description":"До 100 публичных профилей людей (ИИ-сотрудники скрыты), отсортированных по XP (по убыванию),\nзатем по имени. Поиск по подстроке имени без учёта регистра; строка ищется буквально.\n\n**Доступ:** любой вошедший пользователь.\n\n**Побочные эффекты:** нет.\n","parameters":[{"name":"q","in":"query","required":false,"schema":{"type":"string","default":""},"description":"Подстрока имени без учёта регистра. Берутся первые 80 символов; `%`, `_` и `\\` экранируются и ищутся как обычные символы","example":"анна"}],"responses":{"200":{"description":"Публичные профили (не больше 100)","content":{"application/json":{"schema":{"type":"array","maxItems":100,"items":{"$ref":"#/components/schemas/PeopleCard"}},"example":[{"id":"5b0f3c7e-2a41-4d8e-9c1a-7e6f2b9d4a10","name":"Анна Смирнова","jobTitle":"Продакт-менеджер","avatarColor":"#cfb39a","xp":640,"activeCosmetic":"sunset-cover","cosmetics":["ocean-frame","sunset-cover"]},{"id":"7c6b5a49-3827-4615-a0f9-e8d7c6b5a493","name":"Анна Ким","jobTitle":"","avatarColor":"#7aa2c8","xp":40,"activeCosmetic":null,"cosmetics":[]}]}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/people/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID пользователя"}],"get":{"operationId":"getPersonProfile","tags":["Мотивация и люди"],"summary":"Публичный профиль пользователя","description":"Имя, описание, должность, цвет аватара, XP, уровень, надетое оформление и число\nзавершённых задач. Email, баланс и часовой пояс не возвращаются.\n\n**Доступ:** профиль должен быть публичным (`public_profile = true`); свой профиль доступен\nвсегда. Профилей ИИ-сотрудников нет: для них ответ 404, как для скрытого профиля.\n\n**Побочные эффекты:** нет.\n","responses":{"200":{"description":"Профиль","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PeopleProfile"},"example":{"id":"5b0f3c7e-2a41-4d8e-9c1a-7e6f2b9d4a10","name":"Анна Смирнова","bio":"Запускаю продукты и пишу о планировании.","jobTitle":"Продакт-менеджер","avatarColor":"#cfb39a","xp":640,"activeCosmetic":"sunset-cover","publicProfile":true,"cosmetics":["ocean-frame","sunset-cover"],"level":7,"completed":58}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Пользователя нет, его профиль скрыт или это ИИ-сотрудник","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Профиль скрыт или не найден","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/vault/config":{"get":{"operationId":"getVaultConfig","tags":["Личный сейф"],"summary":"Соль и проверочный конверт сейфа","description":"Поле `initialized` есть в ответе всегда. Если сейф создан — `{ initialized: true, salt, verifier }`:\nсоль и проверочный конверт нужны для вывода ключа и проверки мастер-пароля на клиенте.\nЕсли нет — `{ initialized: false }`.\n\n**Доступ:** только свой сейф.\n\n**Побочные эффекты:** нет.\n","responses":{"200":{"description":"Конфигурация сейфа или признак, что его ещё нет","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/PersonalVaultConfig"},{"$ref":"#/components/schemas/PersonalVaultUninitialized"}]},"examples":{"initialized":{"summary":"Сейф создан","value":{"initialized":true,"salt":"kH83tpxNPSfspvfAojeOaQ==","verifier":{"iv":"FAJqrfzGsl+C6qGo","ciphertext":"z4qbaDw4kpvCsXAROGFSIZN44ABR9pL8VrE0D5K6jUoRIQ=="}}},"empty":{"summary":"Сейфа ещё нет","value":{"initialized":false}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"post":{"operationId":"createVault","tags":["Личный сейф"],"summary":"Создать сейф","description":"Сохраняет соль PBKDF2 и проверочный конверт: зашифрованную строку `\"metodox-vault-v1\"`\nс AAD `metodox-vault:<userId>`. Мастер-пароль и ключ на сервер не передаются. Создать сейф\nможно один раз; сменить соль и мастер-пароль позже можно через `POST /api/v1/vault/rekey`.\n\n**Доступ:** любой вошедший пользователь с подтверждённым email. Подтверждение обязательно,\nесли `EMAIL_VERIFICATION_REQUIRED=true`, а также в `NODE_ENV=production`, если явно не задано\n`EMAIL_VERIFICATION_REQUIRED=false`. Тело проверяется раньше, чем email.\n\n**Побочные эффекты:** запись в [vault_configs](#модели/dbvault-configs).\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonalVaultConfigInput"},"example":{"salt":"kH83tpxNPSfspvfAojeOaQ==","verifier":{"iv":"FAJqrfzGsl+C6qGo","ciphertext":"z4qbaDw4kpvCsXAROGFSIZN44ABR9pL8VrE0D5K6jUoRIQ=="}}}}},"responses":{"201":{"description":"Сейф создан","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Email не подтверждён, а подтверждение обязательно (403 при запрещённом Origin добавляет сборка)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Сначала подтвердите email в настройках безопасности","error":"Forbidden"}}}},"409":{"description":"Сейф уже создан","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":409,"message":"Хранилище уже создано","error":"Conflict"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/vault/rekey":{"post":{"operationId":"rekeyVault","tags":["Личный сейф"],"summary":"Сменить мастер-пароль сейфа","description":"Заменяет соль, проверочный конверт и конверты **всех** записей одной транзакцией. Ключ из\nнового мастер-пароля выводит и перешифровывает записи клиент (порядок — в теге «Личный сейф»,\nраздел 4а); сервер по-прежнему видит только шифротекст.\n\n- `records` должен содержать ровно текущие записи пользователя с их текущими `version`. Если\n  запись добавили, изменили или удалили в другом окне, ничего не меняется и приходит 409:\n  иначе такая запись осталась бы зашифрованной старым паролем. Перечитайте сейф и повторите.\n- `kind` записей не передаётся и не меняется, поэтому AAD `metodox-record:<id>:<kind>` остаётся прежним.\n- `id` приводятся к нижнему регистру; повторы в списке — 400 «Записи в списке повторяются».\n- Пустой сейф: `records: []`, меняются только соль и проверочный конверт.\n- Тело — до 8 MiB (у остальных маршрутов 512 KiB) и не больше 10 000 записей. Больше 8 MiB — 413.\n\n**Доступ:** только свой сейф; он должен быть создан.\n\n**Побочные эффекты:** в одной транзакции с блокировкой строк — `salt` и `verifier` в\n[vault_configs](#модели/dbvault-configs), `envelope`, `version + 1` и `updated_at` у каждой\nзаписи в [vault_records](#модели/dbvault-records).\n\n**Лимит:** 10 запросов за 60 с на сессию (без сессии — с одного IP).","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonalVaultRekeyInput"},"example":{"salt":"Xq3v9LmP2sT8wY1zA4bC6w==","verifier":{"iv":"R2d9Kp0sVx7mQe1T","ciphertext":"b6Zr0Kq9mXv2TnPa8sLd4WcE7yHu1JgF3oQi5RzN6tA="},"records":[{"id":"c1e2a3b4-5d6f-4a7b-8c9d-0e1f2a3b4c5d","envelope":{"iv":"Lw8eT3nB5qZr1XmK","ciphertext":"9fGh2Jk4Lm6Np8Qr0St2Uv4Wx6Yz8Ab0Cd2Ef4Gh6Ij8Kl0Mn2Op4Qr6St8Uv0Wx2Yz4=="},"version":3}]}}}},"responses":{"201":{"description":"Мастер-пароль сменён; новые версии записей","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonalVaultRekeyResult"},"example":{"ok":true,"records":[{"id":"c1e2a3b4-5d6f-4a7b-8c9d-0e1f2a3b4c5d","version":4}]}}}},"400":{"description":"Тело не прошло проверку (`ValidationError`): неверный base64 или длина соли и конвертов,\nбольше 10 000 записей, повторяющиеся `id` («Записи в списке повторяются» в `fieldErrors.records`).\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"},"example":{"message":"Проверьте поля формы","errors":{"formErrors":[],"fieldErrors":{"records":["Записи в списке повторяются"]}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"description":"- «Сначала создайте хранилище» — сейфа ещё нет.\n- «Записи сейфа изменились в другом окне. Обновите сейф и повторите смену мастер-пароля» — набор `id` или их `version` не совпал с текущими записями.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":409,"message":"Записи сейфа изменились в другом окне. Обновите сейф и повторите смену мастер-пароля","error":"Conflict"}}}},"413":{"description":"Тело больше 8 MiB","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":413,"message":"request entity too large"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}},"x-ratelimit":{"limit":10,"window":"60s"}}},"/api/v1/vault":{"get":{"operationId":"listVaultRecords","tags":["Личный сейф"],"summary":"Зашифрованные записи сейфа","description":"Все записи пользователя: сначала недавно изменённые. Лимита и пагинации нет. Содержимое\nзашифровано; расшифровка — на клиенте с AAD `metodox-record:<id>:<kind>`.\n\n**Доступ:** только свои записи.\n\n**Побочные эффекты:** нет.\n","responses":{"200":{"description":"Записи (возможно, пустой массив)","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PersonalVaultRecord"}},"example":[{"id":"c1e2a3b4-5d6f-4a7b-8c9d-0e1f2a3b4c5d","kind":"password","envelope":{"iv":"u48TLU0LXFMEKxrA","ciphertext":"c7/ct1wzA/7eLDyX6FVt4RCwj1DRgNpWFNXiB+sLIdL8TwKdiCT9U3itjZ7yDA0cnbfUms3r+XBwUeZl/C3RkLre4S7qaaVKIpscgt6g+Qpp0id/nMA91vmSTiLPGBdTRXy1bYmQJ8GQETSeVX1M0SBRXMQ4M+HNdrDe5NVkAHpxgtup9kk="},"version":1,"updatedAt":"2026-10-01T18:22:07.441Z"}]}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"post":{"operationId":"createVaultRecord","tags":["Личный сейф"],"summary":"Добавить зашифрованную запись","description":"Сохраняет конверт новой записи. `id` генерирует **клиент** (UUID в нижнем регистре): он\nвходит в AAD ещё до отправки. Сервер приводит `id` к нижнему регистру, сохраняет и\nвозвращает его в таком виде.\n\n**Доступ:** любой вошедший пользователь с уже созданным сейфом.\n\n**Побочные эффекты:** запись в [vault_records](#модели/dbvault-records).\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonalVaultRecordInput"},"example":{"id":"c1e2a3b4-5d6f-4a7b-8c9d-0e1f2a3b4c5d","kind":"password","envelope":{"iv":"u48TLU0LXFMEKxrA","ciphertext":"c7/ct1wzA/7eLDyX6FVt4RCwj1DRgNpWFNXiB+sLIdL8TwKdiCT9U3itjZ7yDA0cnbfUms3r+XBwUeZl/C3RkLre4S7qaaVKIpscgt6g+Qpp0id/nMA91vmSTiLPGBdTRXy1bYmQJ8GQETSeVX1M0SBRXMQ4M+HNdrDe5NVkAHpxgtup9kk="}}}}},"responses":{"201":{"description":"Запись создана","content":{"application/json":{"schema":{"type":"object","required":["id","version"],"properties":{"id":{"$ref":"#/components/schemas/Uuid"},"version":{"type":"integer","const":1}}},"example":{"id":"c1e2a3b4-5d6f-4a7b-8c9d-0e1f2a3b4c5d","version":1}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"description":"Сейф ещё не создан («Сначала создайте хранилище») или запись с таким `id` уже\nсуществует («Запись уже существует»). Уникальность `id` проверяется по всей таблице,\nа не только среди записей пользователя\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":409,"message":"Сначала создайте хранилище","error":"Conflict"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/vault/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID записи сейфа"}],"patch":{"operationId":"updateVaultRecord","tags":["Личный сейф"],"summary":"Заменить конверт записи","description":"Заменяет конверт и увеличивает `version`, если передана текущая версия. `kind` не меняется.\nНовый конверт шифруется с тем же AAD `metodox-record:<id>:<kind>` и новым `iv`.\n\n**Доступ:** только владелец записи.\n\n**Побочные эффекты:** изменение строки в [vault_records](#модели/dbvault-records).\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonalVaultRecordUpdate"},"example":{"envelope":{"iv":"q0KFqeghlBXfhSfN","ciphertext":"0Q0I82AG6MWxPR8v18Whj3qnosaAGJ3hqtOPSif0I190FiqdXnlyKeDDvkg0K9FMKegC4ro6apSPkHRuJ9tFNUAI7yVMjNO3lXTocQJfRAhxGAC4GtZMiMsWbrHxIoSGrit8Mb1uvAwQz5qRVquwBJAFcvfcksNU3Gl+rPnkGvM6uQ=="},"version":1}}}},"responses":{"200":{"description":"Новая версия записи. Возвращается только `version`","content":{"application/json":{"schema":{"type":"object","required":["version"],"properties":{"version":{"type":"integer","minimum":2}}},"example":{"version":2}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"Записи нет или она чужая","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Not Found"}}}},"409":{"description":"`version` устарела: запись уже изменили на другом устройстве","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":409,"message":"Запись изменена. Перезагрузите хранилище.","error":"Conflict"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"delete":{"operationId":"deleteVaultRecord","tags":["Личный сейф"],"summary":"Удалить запись сейфа","description":"Удаляет запись окончательно, корзины нет. Версия не проверяется.\n\n**Доступ:** только владелец записи.\n\n**Побочные эффекты:** удаление строки из [vault_records](#модели/dbvault-records).\n","responses":{"200":{"description":"Запись удалена","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"Записи нет или она чужая","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/ai/workspaces/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"}],"get":{"operationId":"listAiEmployees","tags":["ИИ-сотрудники"],"summary":"Список ИИ-сотрудников организации","description":"Возвращает ИИ-сотрудников организации в порядке создания: их настройки, роль и расход за текущий день UTC. Ключ провайдера не возвращается — только признак `hasKey`.\n\nВ список попадают только сотрудники, которые остаются участниками организации. Если ИИ исключить из участников, его настройки сохранятся, но в списке он больше не появится.\n\n**Доступ:** владелец и администратор организации. Участник и гость получают 403, пользователь вне организации — 404.\n\n**Побочные эффекты:** нет.\n","responses":{"200":{"description":"ИИ-сотрудники","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AiEmployee"}},"example":[{"id":"b7e2c9d4-1a3f-4e8b-9c6d-5f0a2b4c7e19","name":"Разработчик","specialty":"developer","provider":"anthropic","model":"claude-sonnet-4-5","instructions":"Пиши на русском. Предлагай изменения в виде diff.","enabled":true,"dailyRequests":20,"dailyTokens":100000,"maxOutputTokens":1024,"hasKey":true,"role":"member","usedRequests":3,"usedTokens":9412},{"id":"c1d4e7f0-2b5a-4c8d-8e1f-6a9b3c5d7e22","name":"Тестировщик","specialty":"tester","provider":"ollama","model":"llama3.1:8b","instructions":"","enabled":true,"dailyRequests":50,"dailyTokens":200000,"maxOutputTokens":2048,"hasKey":false,"role":"guest","usedRequests":0,"usedTokens":0}]}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Пользователь — участник или гость организации","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Нужны права администратора организации","error":"Forbidden"}}}},"404":{"description":"Организация не найдена или пользователь в ней не состоит","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Организация не найдена","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"post":{"operationId":"createAiEmployee","tags":["ИИ-сотрудники"],"summary":"Создать ИИ-сотрудника","description":"Создаёт ИИ-сотрудника и добавляет его в организацию с указанной ролью.\n\nВ одной транзакции создаются пользователь с `account_kind='ai'` (адрес `ai-<id>@metodox.invalid`, случайный пароль, `public_profile=false`), запись в [workspace_members](#модели/dbworkspace-members) и настройки в [ai_employees](#модели/dbai-employees). Ключ провайдера, если он передан, шифруется `AI_SECRET_KEY`. Имена сотрудников не обязаны быть уникальными.\n\n**Доступ:** владелец и администратор организации. Роль `admin` может назначить только владелец.\n\n**Побочные эффекты:** новая запись участника порождает realtime-событие `changed` для организации.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AiEmployeeInput"},"example":{"name":"Разработчик","provider":"anthropic","model":"claude-sonnet-4-5","instructions":"Пиши на русском. Предлагай изменения в виде diff.","apiKey":"sk-ant-…","role":"member","specialty":"developer","enabled":true,"dailyRequests":20,"dailyTokens":100000,"maxOutputTokens":1024}}}},"responses":{"201":{"description":"Сотрудник создан","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AiEmployeeCreated"},"example":{"id":"b7e2c9d4-1a3f-4e8b-9c6d-5f0a2b4c7e19"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"- «Нужны права администратора организации» — пользователь не владелец и не администратор;\n- «Только владелец назначает ИИ-администраторов» — `role: admin` от администратора.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"notAdmin":{"summary":"Нет прав администратора","value":{"statusCode":403,"message":"Нужны права администратора организации","error":"Forbidden"}},"adminRole":{"summary":"Роль admin назначает только владелец","value":{"statusCode":403,"message":"Только владелец назначает ИИ-администраторов","error":"Forbidden"}}}}}},"404":{"description":"Организация не найдена или пользователь в ней не состоит","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Организация не найдена","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/ai/{id}":{"patch":{"operationId":"updateAiEmployee","tags":["ИИ-сотрудники"],"summary":"Изменить ИИ-сотрудника","description":"Частичное обновление настроек ИИ-сотрудника: имени, специализации, провайдера, модели, инструкций, `enabled`, лимитов и роли в организации. Меняются только переданные поля, остальные сохраняют текущие значения. Строка сотрудника блокируется на время слияния, поэтому параллельные изменения разных полей не затирают друг друга.\n\nКлюч провайдера:\n- передан непустой `apiKey` — он шифруется и заменяет прежний;\n- `apiKey` не передан или пуст, провайдер прежний — сохранённый ключ остаётся;\n- `apiKey` не передан или пуст, провайдер сменился — сохранённый ключ стирается.\n\nОтдельно удалить ключ, не меняя провайдера, нельзя.\n\nПорядок проверок: формат `id` (400) → сотрудник ищется только в организациях, где состоит пользователь (404) → права администратора (403) → тело запроса (400) → правила для роли `admin` и SSH-доступа (403). Если ИИ исключён из участников организации, переданный `role` ничего не меняет: записи участника для него нет.\n\n**Доступ:** владелец и администратор организации, в которой состоит сотрудник. Для остальных сотрудник «не существует»: и чужой, и несуществующий `id` дают одинаковый 404. Изменить ИИ-администратора или назначить роль `admin` может только владелец. Если сотруднику выдан доступ к серверу в сейфе организации, менять его провайдера, модель, ключ, инструкции и специализацию тоже может только владелец: иначе администратор мог бы перенаправить вывод SSH-команд на свой ключ провайдера. Имя, `enabled`, лимиты и роль `member`/`guest` администратор менять может.\n\n**Побочные эффекты:** если передан `role`, обновляется запись в [workspace_members](#модели/dbworkspace-members), это порождает realtime-событие `changed` для организации. Цепочки, которые уже стоят в очереди, на следующих этапах используют новые настройки; при `enabled: false` их этапы завершатся ошибкой «Сотрудник выключен или подключение не настроено». Исключение — SSH-этапы: если провайдер, модель, ключ, инструкции или специализация изменились после запуска цепочки, такой этап не выполняется (см. тег «ИИ-цепочки»).\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID ИИ-сотрудника (`users.id`)"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AiEmployeeUpdateInput"},"example":{"instructions":"Пиши на русском. Предлагай изменения в виде diff.","dailyTokens":150000}}}},"responses":{"200":{"description":"Настройки сохранены","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"- «Нужны права администратора организации» — пользователь не владелец и не администратор;\n- «Только владелец может менять настройки ИИ-администратора» — сотрудник сейчас `admin` или ему назначают `admin`, а пользователь не владелец;\n- «Только владелец может менять подключение и инструкции ИИ-сотрудника с доступом к серверам» — у сотрудника есть доступ к секрету сейфа организации, а не владелец меняет провайдера, модель, ключ, инструкции или специализацию.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"notAdmin":{"summary":"Нет прав администратора","value":{"statusCode":403,"message":"Нужны права администратора организации","error":"Forbidden"}},"aiAdmin":{"summary":"ИИ-администратора меняет только владелец","value":{"statusCode":403,"message":"Только владелец может менять настройки ИИ-администратора","error":"Forbidden"}},"sshEmployee":{"summary":"Сотрудника с доступом к серверу перенастраивает только владелец","value":{"statusCode":403,"message":"Только владелец может менять подключение и инструкции ИИ-сотрудника с доступом к серверам","error":"Forbidden"}}}}}},"404":{"description":"«ИИ-сотрудник не найден» — сотрудника с таким `id` нет или пользователь не состоит в его организации. Ответ в обоих случаях одинаковый, поэтому чужие `id` не раскрываются.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"ИИ-сотрудник не найден","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/ai/{id}/run":{"post":{"operationId":"runAiEmployee","tags":["ИИ-сотрудники"],"summary":"Запустить ИИ-сотрудника по задаче","description":"Синхронно отправляет модели название и описание задачи и публикует ответ комментарием от имени ИИ-сотрудника. HTTP-ответ приходит после ответа модели (таймаут провайдера — 60 с).\n\nПорядок проверок:\n1. Пользователь может редактировать задачу.\n2. ИИ — исполнитель задачи (`assigneeId`).\n3. У ИИ есть право редактировать задачу. Если у ИИ нет доступа к доске или задаче, вернётся 400 «У ИИ-сотрудника нет доступа к этой доске или задаче…», без права редактирования — 400 «У ИИ-сотрудника нет прав на редактирование этой задачи…». Исполнитель задачи с доступом к доске право редактирования получает автоматически, поэтому на практике встречается первый текст.\n4. Сотрудник включён (`id`, не принадлежащий ИИ-сотруднику, тоже даёт «ИИ-сотрудник выключен»), и у него есть ключ (для `ollama` не нужен).\n5. В транзакции с блокировкой задачи: в задаче нет активной цепочки; запуски `running` старше 5 минут (во всех задачах) переводятся в `failed`; хватает дневного лимита на резерв; в задаче нет другого запуска `running`.\n\nЗатем засчитываются 1 запрос и резерв токенов, создаётся запись [ai_runs](#модели/dbai-runs) со статусом `running` и вызывается провайдер.\n\n**Доступ:** пользователь с правом редактирования задачи.\n\n**Побочные эффекты:**\n- при успехе — комментарий от ИИ в [comments](#модели/dbcomments) (текст модели после фильтра секретов, до 20 000 символов), событие задачи `commented`, уведомления участникам. Резерв токенов заменяется фактическим расходом, запуск получает `completed`;\n- при ошибке после резервирования — запуск получает `failed`, засчитанный запрос и резерв токенов не возвращаются.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID ИИ-сотрудника (`users.id`)"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AiRunInput"},"example":{"boardId":"5a8d2f1c-7e4b-4a9c-b3d6-1e0f2a4c6b33","taskId":"9e1b4d7a-3c6f-4b2e-a5d8-7f0c1e3b5a44"}}}},"responses":{"201":{"description":"Ответ модели опубликован комментарием","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AiRunResult"},"example":{"id":"e4a7b0c3-5d8f-4e1a-b6c9-2d5f8a1c4e55","status":"completed","commentId":"f2c5e8b1-6a9d-4f3c-8b7e-0a3d6f9c2b66"}}}},"400":{"description":"- `id` или тело не прошли проверку (`ValidationError`);\n- «Сначала назначьте этого ИИ исполнителем задачи»;\n- «У ИИ-сотрудника нет доступа к этой доске или задаче. Выдайте ему роль на доске или откройте доску для организации» — у ИИ нет доступа к доске или задача для него скрыта;\n- «У ИИ-сотрудника нет прав на редактирование этой задачи. Назначьте ему роль редактора на доске»;\n- «ИИ-сотрудник выключен» — сотрудник выключен или `id` не принадлежит ИИ-сотруднику;\n- «В настройках ИИ не указан ключ провайдера» — нет ключа у `openai`/`anthropic`.\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/Error"}]},"examples":{"validation":{"summary":"Неверный UUID","value":{"message":"Проверьте поля формы","errors":{"formErrors":["Invalid UUID"],"fieldErrors":{}}}},"notAssignee":{"summary":"ИИ не исполнитель задачи","value":{"statusCode":400,"message":"Сначала назначьте этого ИИ исполнителем задачи","error":"Bad Request"}},"agentNoAccess":{"summary":"У ИИ нет доступа к доске","value":{"statusCode":400,"message":"У ИИ-сотрудника нет доступа к этой доске или задаче. Выдайте ему роль на доске или откройте доску для организации","error":"Bad Request"}},"disabled":{"summary":"Сотрудник выключен","value":{"statusCode":400,"message":"ИИ-сотрудник выключен","error":"Bad Request"}},"noKey":{"summary":"Нет ключа провайдера","value":{"statusCode":400,"message":"В настройках ИИ не указан ключ провайдера","error":"Bad Request"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"У пользователя нет права редактировать задачу (нехватка прав у самого ИИ — это 400)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Недостаточно прав на задаче","error":"Forbidden"}}}},"404":{"description":"Доска или задача не найдены либо недоступны пользователю (недоступность для ИИ — это 400)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"board":{"summary":"Нет доступа к доске","value":{"statusCode":404,"message":"Доска не найдена","error":"Not Found"}},"task":{"summary":"Задача не найдена","value":{"statusCode":404,"message":"Задача не найдена","error":"Not Found"}}}}}},"409":{"description":"- «В задаче уже выполняется цепочка ИИ» — у задачи есть цепочка `queued`/`running`;\n- «Дневной лимит ИИ исчерпан или недостаточен для резервирования ответа»;\n- «Задача уже выполняется ИИ» — в задаче уже есть запуск `running`.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"workflow":{"summary":"Идёт цепочка","value":{"statusCode":409,"message":"В задаче уже выполняется цепочка ИИ","error":"Conflict"}},"limit":{"summary":"Лимит исчерпан","value":{"statusCode":409,"message":"Дневной лимит ИИ исчерпан или недостаточен для резервирования ответа","error":"Conflict"}},"running":{"summary":"Запуск уже идёт","value":{"statusCode":409,"message":"Задача уже выполняется ИИ","error":"Conflict"}}}}}},"429":{"$ref":"#/components/responses/TooManyRequests"},"502":{"description":"Вызов не завершён; запрос и резерв токенов остаются засчитанными.\n- «Провайдер ответил HTTP N. Проверьте модель и настройки подключения.» — провайдер вернул код не 2xx (например, неизвестная модель или неверный ключ);\n- «Модель не вернула текст. Проверьте модель и лимит ответа.»;\n- «Запуск ИИ не завершён. Проверьте подключение и доступ к задаче.» — любая другая ошибка: таймаут 60 с, сеть, ответ не в JSON, ошибка расшифровки ключа, потеря права комментировать до публикации.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"providerStatus":{"summary":"Ошибка провайдера","value":{"statusCode":502,"message":"Провайдер ответил HTTP 401. Проверьте модель и настройки подключения.","error":"Bad Gateway"}},"emptyText":{"summary":"Пустой ответ","value":{"statusCode":502,"message":"Модель не вернула текст. Проверьте модель и лимит ответа.","error":"Bad Gateway"}},"generic":{"summary":"Прочие ошибки","value":{"statusCode":502,"message":"Запуск ИИ не завершён. Проверьте подключение и доступ к задаче.","error":"Bad Gateway"}}}}}}}}},"/api/v1/boards/{board}/tasks/{task}/agents":{"parameters":[{"name":"board","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"},{"name":"task","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID задачи"}],"get":{"operationId":"getTaskAgentWorkflows","tags":["ИИ-цепочки"],"summary":"Цепочки задачи и доступные сотрудники","description":"Возвращает состояние ИИ-цепочек задачи:\n- `flows` — 20 последних цепочек, новые первыми, с этапами по порядку (отчёты и ошибки этапов). Если комментарий с отчётом этапа удалён, `report` у этапа — `null`;\n- `employees` — ИИ-сотрудники организации доски, которые сейчас состоят в ней, включая выключенных. Исключённые из участников не предлагаются;\n- `resources` — SSH-секреты организации без секретных полей: имена операций, выданные доступы и признак `ready` (IP есть в `AGENT_SSH_ALLOWED_HOSTS`). Заполняется только для владельца организации. Секрет, который не удаётся расшифровать (например, сменился `WORKSPACE_SECRET_KEY`), приходит с `unreadable: true`, пустым `operations` и `ready: false`, а не ломает весь ответ;\n- `owner` — является ли пользователь владельцем.\n\nДля личной доски `employees` и `resources` пусты, а `owner` равен `false`.\n\n**Доступ:** любой, кто может просматривать задачу, включая наблюдателей. Ответ одинаков для всех, кроме полей `resources` и `owner`.\n\n**Побочные эффекты:** нет.\n","responses":{"200":{"description":"Состояние цепочек","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentWorkflowState"},"example":{"flows":[{"id":"0d3f6a9c-4e7b-4d1a-9c2f-5b8e1a4d7c77","status":"running","createdAt":"2026-10-02T09:14:05.120Z","steps":[{"id":"1a4d7b0e-8c3f-4a6d-b9e2-3c6f9a2d5b88","name":"Разработчик","employeeId":"b7e2c9d4-1a3f-4e8b-9c6d-5f0a2b4c7e19","status":"completed","instruction":"Предложи исправление валидации формы","operation":null,"report":"Результат: валидация email срабатывает до trim. Проверки: не выполнялись. Ограничения: код не менялся. Следующий шаг: передать тестировщику.","error":null},{"id":"2b5e8c1f-9d4a-4b7e-8c3f-4d7a0b3e6c99","name":"Тестировщик","employeeId":"c1d4e7f0-2b5a-4c8d-8e1f-6a9b3c5d7e22","status":"running","instruction":"Прогони тесты на стенде","operation":"test","report":null,"error":null}]}],"employees":[{"id":"b7e2c9d4-1a3f-4e8b-9c6d-5f0a2b4c7e19","name":"Разработчик","specialty":"developer","enabled":true,"provider":"anthropic","model":"claude-sonnet-4-5"},{"id":"c1d4e7f0-2b5a-4c8d-8e1f-6a9b3c5d7e22","name":"Тестировщик","specialty":"tester","enabled":true,"provider":"ollama","model":"llama3.1:8b"}],"resources":[{"id":"6c9f2b5e-1d4a-4e7b-a8c3-9f2b5e8d1a10","title":"Стенд staging","operations":["test","status"],"grants":["c1d4e7f0-2b5a-4c8d-8e1f-6a9b3c5d7e22"],"ready":true},{"id":"3e6a9d2c-7b0f-4c3e-9a5d-8b1e4f7a0c31","title":"Старый прод","operations":[],"grants":[],"ready":false,"unreadable":true}],"owner":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Доска или задача не найдены либо недоступны пользователю","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"board":{"summary":"Доска не найдена","value":{"statusCode":404,"message":"Доска не найдена","error":"Not Found"}},"task":{"summary":"Задача не найдена","value":{"statusCode":404,"message":"Задача не найдена","error":"Not Found"}}}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"post":{"operationId":"startTaskAgentWorkflow","tags":["ИИ-цепочки"],"summary":"Запустить ИИ-цепочку","description":"Ставит в очередь цепочку из 1–6 этапов. Этапы выполняет фоновый worker по одному. Ответ приходит сразу со статусом `queued`.\n\nПроверки до записи (по порядку):\n1. Пользователь управляет задачей. Эта проверка выполняется раньше проверки тела запроса.\n2. Доска принадлежит организации.\n3. Для каждого этапа: сотрудник — включённый ИИ-сотрудник этой организации, у него есть ключ (кроме `ollama`), у ИИ есть доступ к доске и задаче и право комментировать (иначе 400 с текстом про сотрудника).\n4. Для этапа с `secretId` или `operation`: пользователь — владелец организации и передал `approveOperations: true`; секрет — SSH-секрет организации с доступом для этого сотрудника; операция есть в секрете, а IP — в `AGENT_SSH_ALLOWED_HOSTS`. Запоминаются версия секрета и отпечаток настроек сотрудника (`employee_fingerprint`): если они изменятся до выполнения этапа, worker его не запустит. Версия секрета меняется и при любом изменении его доступов. Если секрет не удаётся расшифровать (`unreadable`), ответ — 500.\n5. В транзакции с блокировкой задачи: в задаче нет цепочки `queued`/`running` и прямого запуска ИИ `running`.\n\nЛимиты на этом шаге не резервируются — их проверяет worker перед каждым этапом.\n\n**Доступ:** администратор доски или автор задачи с ролью редактора; SSH-этапы — только владелец организации.\n\n**Побочные эффекты:** записи в [agent_workflows](#модели/dbagent-workflows) и [agent_steps](#модели/dbagent-steps), событие задачи `agent_workflow_started`, realtime-событие `changed` (область `agents`).\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentWorkflowInput"},"examples":{"textOnly":{"summary":"Разработчик → тестировщик без SSH","value":{"steps":[{"employeeId":"b7e2c9d4-1a3f-4e8b-9c6d-5f0a2b4c7e19","instruction":"Предложи исправление валидации формы"},{"employeeId":"c1d4e7f0-2b5a-4c8d-8e1f-6a9b3c5d7e22","instruction":"Проверь предложение и опиши тест-кейсы"}]}},"withSsh":{"summary":"Этап с SSH-операцией (только владелец)","value":{"steps":[{"employeeId":"b7e2c9d4-1a3f-4e8b-9c6d-5f0a2b4c7e19","instruction":"Предложи исправление валидации формы"},{"employeeId":"c1d4e7f0-2b5a-4c8d-8e1f-6a9b3c5d7e22","instruction":"Прогони тесты на стенде и оцени результат","secretId":"6c9f2b5e-1d4a-4e7b-a8c3-9f2b5e8d1a10","operation":"test"}],"approveOperations":true}}}}}},"responses":{"201":{"description":"Цепочка поставлена в очередь","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentWorkflowStarted"},"example":{"id":"0d3f6a9c-4e7b-4d1a-9c2f-5b8e1a4d7c77","status":"queued"}}}},"400":{"description":"- тело не прошло проверку, например 0 или больше 6 этапов (`ValidationError`);\n- «Нужна доска организации»;\n- «Сотрудник недоступен» — сотрудник выключен или не из этой организации;\n- «У сотрудника не настроен ключ провайдера»;\n- «У ИИ-сотрудника нет доступа к этой доске или задаче. Выдайте ему роль на доске или откройте доску для организации» — доска или задача для ИИ недоступны (в том числе если ИИ исключён из участников организации);\n- «ИИ-сотрудник не может комментировать эту задачу. Назначьте ему роль на доске или в задаче» — у ИИ только просмотр;\n- «Операция или IP не разрешены» — операции нет в секрете (в том числе если `operation` не указана) или IP нет в `AGENT_SSH_ALLOWED_HOSTS`.\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/Error"}]},"examples":{"validation":{"summary":"Нет этапов","value":{"message":"Проверьте поля формы","errors":{"formErrors":[],"fieldErrors":{"steps":["Too small: expected array to have >=1 items"]}}}},"personalBoard":{"summary":"Личная доска","value":{"statusCode":400,"message":"Нужна доска организации","error":"Bad Request"}},"employee":{"summary":"Сотрудник недоступен","value":{"statusCode":400,"message":"Сотрудник недоступен","error":"Bad Request"}},"noKey":{"summary":"Нет ключа","value":{"statusCode":400,"message":"У сотрудника не настроен ключ провайдера","error":"Bad Request"}},"agentNoAccess":{"summary":"У ИИ нет доступа к доске","value":{"statusCode":400,"message":"У ИИ-сотрудника нет доступа к этой доске или задаче. Выдайте ему роль на доске или откройте доску для организации","error":"Bad Request"}},"agentCannotComment":{"summary":"ИИ не может комментировать","value":{"statusCode":400,"message":"ИИ-сотрудник не может комментировать эту задачу. Назначьте ему роль на доске или в задаче","error":"Bad Request"}},"operation":{"summary":"Операция или IP не разрешены","value":{"statusCode":400,"message":"Операция или IP не разрешены","error":"Bad Request"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"- «Недостаточно прав на задаче» — пользователь не управляет задачей;\n- «SSH-операции запускает владелец после подтверждения» — этап с SSH от не-владельца или без `approveOperations: true`;\n- «Сотруднику не выдан доступ к серверу» — секрета нет, он не SSH или сотруднику не выдан.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"task":{"summary":"Нет прав на задаче","value":{"statusCode":403,"message":"Недостаточно прав на задаче","error":"Forbidden"}},"approve":{"summary":"Нет подтверждения владельца","value":{"statusCode":403,"message":"SSH-операции запускает владелец после подтверждения","error":"Forbidden"}},"grant":{"summary":"Нет доступа к серверу","value":{"statusCode":403,"message":"Сотруднику не выдан доступ к серверу","error":"Forbidden"}}}}}},"404":{"description":"Доска или задача не найдены либо недоступны пользователю (недоступность для ИИ-сотрудника этапа — это 400)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"board":{"summary":"Доска не найдена","value":{"statusCode":404,"message":"Доска не найдена","error":"Not Found"}},"task":{"summary":"Задача не найдена","value":{"statusCode":404,"message":"Задача не найдена","error":"Not Found"}}}}}},"409":{"description":"В задаче уже есть цепочка `queued`/`running` или прямой запуск ИИ `running`. После сбоя процесса запуск прерванного этапа снимает worker на ближайшем такте (см. «Прерывание процесса»)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":409,"message":"Цепочка уже выполняется","error":"Conflict"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/boards/{board}/tasks/{task}/agents/{id}/cancel":{"post":{"operationId":"cancelTaskAgentWorkflow","tags":["ИИ-цепочки"],"summary":"Отменить ИИ-цепочку","description":"Переводит цепочку в `cancelled`, если она в статусе `queued` или `running`, а её этапы в статусе `queued` — тоже в `cancelled`.\n\nSSH-команда этапа, который уже зарезервировал лимит, но ещё не отправил команду, не запускается: перед SSH worker перепроверяет статус цепочки под той же блокировкой строки, а этап получает `cancelled` с текстом «Цепочка отменена до запуска SSH-операции». Уже отправленные запрос к модели и SSH-команда не прерываются и доходят до конца. Потом этап получает `cancelled`, отчёт отбрасывается и не публикуется.\n\nЕсли цепочка уже завершена, не найдена или относится к другой задаче, ничего не меняется, а ответ всё равно `{ ok: true }`.\n\n**Доступ:** администратор доски или автор задачи с ролью редактора.\n\n**Побочные эффекты:** обновление этапов порождает realtime-событие `changed` (область `agents`). Если обновлять нечего, события нет.\n","parameters":[{"name":"board","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID доски"},{"name":"task","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID задачи"},{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID цепочки"}],"responses":{"201":{"description":"Запрос обработан","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Пользователь не управляет задачей","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Недостаточно прав на задаче","error":"Forbidden"}}}},"404":{"description":"Доска или задача не найдены либо недоступны","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Задача не найдена","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/company-vault/{space}":{"parameters":[{"name":"space","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"}],"get":{"operationId":"listCompanySecrets","tags":["Сейф организации"],"summary":"Список секретов организации","description":"Возвращает все секреты организации, новые первыми. Поле `value` не возвращается никогда. Остальные поля расшифровываются и возвращаются: адрес, порт, пользователь, fingerprint, операции вместе с командами и выданные доступы. Пагинации нет.\n\nСекрет, который не удаётся расшифровать (сменился `WORKSPACE_SECRET_KEY` или запись повреждена), не ломает список: он приходит с `unreadable: true`, пустыми `host`, `username`, `fingerprint`, `operations: []` и `port: null`. Такой секрет можно только удалить и создать заново.\n\n**Доступ:** только владелец организации.\n\n**Побочные эффекты:** нет; чтение списка в журнал не пишется.\n","responses":{"200":{"description":"Секреты без значений","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CompanySecret"}},"example":[{"id":"6c9f2b5e-1d4a-4e7b-a8c3-9f2b5e8d1a10","title":"Стенд staging","kind":"ssh","version":2,"host":"203.0.113.10","port":22,"username":"deploy","fingerprint":"SHA256:k3Vq9tYp2LmX8rWc4NzB7hJd1sFg6aQe0uIo5yTr3Kw","operations":[{"name":"test","command":"cd /srv/app && npm test"},{"name":"status","command":"systemctl status app --no-pager"}],"grants":["c1d4e7f0-2b5a-4c8d-8e1f-6a9b3c5d7e22"]},{"id":"7d0a3c6f-2e5b-4f8c-9d4a-0a3c6f9e2b21","title":"Почта поддержки","kind":"account","version":1,"host":"","port":22,"username":"","fingerprint":"","operations":[],"grants":[]},{"id":"3e6a9d2c-7b0f-4c3e-9a5d-8b1e4f7a0c31","title":"Старый прод","kind":"ssh","version":3,"host":"","port":null,"username":"","fingerprint":"","operations":[],"grants":[],"unreadable":true}]}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"- «Доступами инфраструктуры управляет владелец организации» — пользователь администратор;\n- «Нужны права администратора организации» — пользователь участник или гость.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"admin":{"summary":"Администратор","value":{"statusCode":403,"message":"Доступами инфраструктуры управляет владелец организации","error":"Forbidden"}},"member":{"summary":"Участник или гость","value":{"statusCode":403,"message":"Нужны права администратора организации","error":"Forbidden"}}}}}},"404":{"description":"Организация не найдена или пользователь в ней не состоит","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Организация не найдена","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"post":{"operationId":"createCompanySecret","tags":["Сейф организации"],"summary":"Сохранить секрет организации","description":"Проверяет секрет, шифрует весь объект ключом `WORKSPACE_SECRET_KEY` (AAD `workspace:<space>:secret:<id>`) и сохраняет. В открытом виде остаются только `title` и `kind`.\n\nДля `ssh` адрес должен быть IP-адресом, но его присутствие в `AGENT_SSH_ALLOWED_HOSTS` здесь не проверяется — только при запуске цепочки.\n\nПроверка тела выполняется после проверки прав.\n\n**Доступ:** только владелец организации.\n\n**Побочные эффекты:** событие `created` в [secret_audit](#модели/dbsecret-audit).\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CompanySecretInput"},"examples":{"ssh":{"summary":"SSH-доступ к серверу","value":{"title":"Стенд staging","kind":"ssh","host":"203.0.113.10","port":22,"username":"deploy","value":"-----BEGIN OPENSSH PRIVATE KEY-----\n…\n-----END OPENSSH PRIVATE KEY-----","fingerprint":"SHA256:k3Vq9tYp2LmX8rWc4NzB7hJd1sFg6aQe0uIo5yTr3Kw","operations":[{"name":"test","command":"cd /srv/app && npm test"},{"name":"status","command":"systemctl status app --no-pager"}]}},"account":{"summary":"Учётная запись","value":{"title":"Почта поддержки","kind":"account","value":"support@example.com / …"}}}}}},"responses":{"201":{"description":"Секрет сохранён","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CompanySecretCreated"},"example":{"id":"6c9f2b5e-1d4a-4e7b-a8c3-9f2b5e8d1a10"}}}},"400":{"description":"Тело или `space` не прошли проверку, включая дополнительные правила для `ssh` (см. схему `CompanySecretInput`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"},"examples":{"ssh":{"summary":"Неполный SSH-секрет","value":{"message":"Проверьте поля формы","errors":{"formErrors":["Укажите IP, SSH-пользователя, приватный ключ и SHA256 fingerprint сервера"],"fieldErrors":{}}}},"empty":{"summary":"Пустое значение","value":{"message":"Проверьте поля формы","errors":{"formErrors":["Введите секрет"],"fieldErrors":{}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Пользователь не владелец организации","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"admin":{"summary":"Администратор","value":{"statusCode":403,"message":"Доступами инфраструктуры управляет владелец организации","error":"Forbidden"}},"member":{"summary":"Участник или гость","value":{"statusCode":403,"message":"Нужны права администратора организации","error":"Forbidden"}}}}}},"404":{"description":"Организация не найдена или пользователь в ней не состоит","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Организация не найдена","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/company-vault/{space}/{id}":{"delete":{"operationId":"deleteCompanySecret","tags":["Сейф организации"],"summary":"Удалить секрет организации","description":"Удаляет секрет вместе с выданными доступами. В этапах цепочек ссылка на секрет обнуляется, поэтому ещё не выполненные SSH-этапы с ним завершатся ошибкой «Доступ к секрету отозван или секрет изменён». Прежние события журнала остаются, но теряют название секрета.\n\nУдалить можно и секрет с `unreadable: true`: расшифровка для удаления не нужна. Если секрета с таким `id` в организации нет, ответ — 404 «Секрет не найден», а событие в журнал не пишется.\n\n**Доступ:** только владелец организации.\n\n**Побочные эффекты:** в той же транзакции, что и удаление, — событие `deleted:<id>` в [secret_audit](#модели/dbsecret-audit) без ссылки на секрет (поэтому `title` у него `null`).\n","parameters":[{"name":"space","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"},{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID секрета"}],"responses":{"200":{"description":"Секрет удалён","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Пользователь не владелец организации","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Доступами инфраструктуры управляет владелец организации","error":"Forbidden"}}}},"404":{"description":"- «Секрет не найден» — секрета с таким `id` нет в этой организации;\n- «Организация не найдена» — пользователь не состоит в организации.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"secret":{"summary":"Секрет не найден","value":{"statusCode":404,"message":"Секрет не найден","error":"Not Found"}},"workspace":{"summary":"Нет доступа к организации","value":{"statusCode":404,"message":"Организация не найдена","error":"Not Found"}}}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/company-vault/{space}/{id}/grants":{"post":{"operationId":"setCompanySecretGrant","tags":["Сейф организации"],"summary":"Выдать или отозвать доступ ИИ-сотрудника","description":"`allow: true` выдаёт ИИ-сотруднику доступ к секрету, `allow: false` отзывает его. Повторная выдача и отзыв отсутствующего доступа не считаются ошибкой. Доступ можно выдать только ИИ-сотруднику этой же организации, человеку — нельзя. Строка секрета блокируется на время изменения.\n\nЕсли доступ действительно изменился, `version` секрета увеличивается на 1. Поэтому ещё не начавшиеся SSH-этапы с этим секретом (у любого сотрудника) не выполнятся и завершатся ошибкой «Доступ к секрету отозван или секрет изменён». Уже отправленная SSH-команда не останавливается.\n\n**Доступ:** только владелец организации.\n\n**Побочные эффекты:** запись или удаление в [secret_grants](#модели/dbsecret-grants); при фактическом изменении — `version + 1` в [workspace_secrets](#модели/dbworkspace-secrets); событие `grant:<employeeId>` или `revoke:<employeeId>` в журнале (пишется при каждом вызове, даже без изменений).\n","parameters":[{"name":"space","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"},{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID секрета"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CompanySecretGrantInput"},"example":{"employeeId":"c1d4e7f0-2b5a-4c8d-8e1f-6a9b3c5d7e22","allow":true}}}},"responses":{"201":{"description":"Доступ обновлён","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Пользователь не владелец организации","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Доступами инфраструктуры управляет владелец организации","error":"Forbidden"}}}},"404":{"description":"- `Not Found` — секрета нет в этой организации или `employeeId` не принадлежит ИИ-сотруднику этой организации;\n- «Организация не найдена» — пользователь не состоит в организации.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"target":{"summary":"Секрет или сотрудник не найдены","value":{"statusCode":404,"message":"Not Found"}},"workspace":{"summary":"Нет доступа к организации","value":{"statusCode":404,"message":"Организация не найдена","error":"Not Found"}}}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/company-vault/{space}/{id}/reveal":{"post":{"operationId":"revealCompanySecret","tags":["Сейф организации"],"summary":"Раскрыть значение секрета","description":"Расшифровывает и возвращает `value` в открытом виде; у `ssh` это приватный ключ. API не ограничивает время показа — через 30 секунд значение скрывает веб-интерфейс. Ответ передаётся с `Cache-Control: private, no-store`.\n\nСекрет с `unreadable: true` раскрыть нельзя: ответ 500.\n\n**Доступ:** только владелец организации.\n\n**Побочные эффекты:** событие `revealed` в [secret_audit](#модели/dbsecret-audit) при каждом вызове. Оно пишется до расшифровки, поэтому остаётся в журнале и тогда, когда расшифровать секрет не удалось.\n","parameters":[{"name":"space","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"},{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID секрета"}],"responses":{"201":{"description":"Значение секрета","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CompanySecretValue"},"example":{"value":"support@example.com / пример-пароля"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Пользователь не владелец организации","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Доступами инфраструктуры управляет владелец организации","error":"Forbidden"}}}},"404":{"description":"- `Not Found` — секрета нет в этой организации;\n- «Организация не найдена» — пользователь не состоит в организации.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"secret":{"summary":"Секрет не найден","value":{"statusCode":404,"message":"Not Found"}},"workspace":{"summary":"Нет доступа к организации","value":{"statusCode":404,"message":"Организация не найдена","error":"Not Found"}}}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/v1/company-vault/{space}/audit/events":{"get":{"operationId":"listCompanySecretAudit","tags":["Сейф организации"],"summary":"Журнал действий с секретами","description":"Возвращает 100 последних событий журнала организации, новые первыми; пагинации нет. В `actor` — имя пользователя (у событий `execute:` — имя ИИ-сотрудника), в `title` — текущее название секрета.\n\n**Доступ:** только владелец организации.\n\n**Побочные эффекты:** нет.\n","parameters":[{"name":"space","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Uuid"},"description":"ID организации"}],"responses":{"200":{"description":"События журнала","content":{"application/json":{"schema":{"type":"array","maxItems":100,"items":{"$ref":"#/components/schemas/CompanySecretAuditEvent"}},"example":[{"id":"8e1b4c7f-3a6d-4e9b-b2c5-1f4a7d0e3b32","action":"execute:test","createdAt":"2026-10-02T09:15:11.402Z","actor":"Тестировщик","title":"Стенд staging"},{"id":"9f2c5d8a-4b7e-4f0c-a3d6-2a5b8e1f4c43","action":"grant:c1d4e7f0-2b5a-4c8d-8e1f-6a9b3c5d7e22","createdAt":"2026-10-02T09:02:40.018Z","actor":"Анна Смирнова","title":"Стенд staging"},{"id":"a03d6e9b-5c8f-4a1d-b4e7-3b6c9f2a5d54","action":"deleted:4b7e0a3d-6f9c-4b2e-8d5a-1c4f7b0e3a65","createdAt":"2026-10-01T17:40:03.551Z","actor":"Анна Смирнова","title":null}]}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Пользователь не владелец организации","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":403,"message":"Доступами инфраструктуры управляет владелец организации","error":"Forbidden"}}}},"404":{"description":"Организация не найдена или пользователь в ней не состоит","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"statusCode":404,"message":"Организация не найдена","error":"Not Found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}}}}