diff --git a/apps/remarks/ENDPOINTS.md b/apps/remarks/ENDPOINTS.md new file mode 100644 index 0000000..a2d65fb --- /dev/null +++ b/apps/remarks/ENDPOINTS.md @@ -0,0 +1,114 @@ +# Эндпоинты, с которыми взаимодействует remarks-frontend + +Документ описывает все HTTP-эндпоинты внешних сервисов, к которым обращается модуль (микрофронтенд `remarks-frontend`). + +## Как устроено взаимодействие + +В отличие от `transmittal-frontend`, в `remarks-frontend` **нет декларативного реестра эндпоинтов** (`endpoints.ts`). Запросы формируются по месту — в API-объектах (`module/api/*.ts`) и в MobX-сторах (`module/store/*.ts`) — прямыми вызовами методов `httpService`: + +- `httpService.getRequest(options)` +- `httpService.postRequest(options)` +- `httpService.putRequest(options)` (объявлен, в коде не используется) +- `httpService.patchRequest(options)` +- `httpService.deleteRequest(options)` + +Каждый вызов задаётся объектом-параметром: + +- `service` — логическое имя сервиса (см. таблицу хостов ниже), определяет базовый хост; +- `url` — путь запроса (часто шаблонная строка с подстановкой id/query); +- `data` — тело запроса (для `POST`/`PATCH`/`PUT`); +- `axiosConfig` — доп. настройки axios (`params` для query-параметров, `responseType: "blob"` для файлов и т.п.); +- `showErrorNotification` — включает показ уведомления об ошибке средствами SDK. + +`httpService` (`module/api/http-client.ts`) — это `Proxy` поверх базового `baseHttpService` (`module/api/http-service.ts`, создаётся через `createHttpService` из `@sarex-team/sdk-js`). Прокси добавляет единую обработку ответа `403`: показывает toast с текстом `response.data.detail` либо сообщением «У вас недостаточно прав для выполнения данного действия.». Базовый хост подставляется SDK по значению `service` и текущему окружению `BUILD_ENV` (значения — из `module/api/hosts.ts`, по умолчанию `prod`). В окружении `local` тип HTTP-сервиса переключается на `zitadel` (`setTypeOfHttpService("zitadel")`). + +Итоговый URL = `<базовый хост сервиса>` + `url` эндпоинта. + +## Базовые хосты по сервисам и окружениям + +Значения из `module/api/hosts.ts`. + +| Сервис (`service`) | Назначение | `stage` | `prod` | +| --- | --- | --- | --- | +| `remarks` | Сервис замечаний | `https://stage-api.sarex.io/remarks` | `https://api.sarex.io/remarks` | +| `sarexApi` | Gateway/API Sarex (`/issues`, `/flows`, `/eav`) | `https://stage-api.sarex.io` | `https://api.sarex.io` | +| `gateway` | Gateway Sarex | `https://stage-api.sarex.io/gateway` | `https://api.sarex.io/gateway` | +| `documentations` | Сервис документации | `https://stage-api.sarex.io/documentations` | `https://api.sarex.io/documentations` | +| `eavV4` | Сервис атрибутов/ассетов (EAV v4) | `https://stage-api.sarex.io/eav/api/v4` | `https://api.sarex.io/eav/api/v4` | +| `sarex` | Локальный сервис данных (`/api/core`, `/api/client`) | `""` (относительные пути) | `""` | +| `zitadel` | IdP (аутентификация) | `https://idp.dev.stage.sarex.io` | `https://login.sarex.io` | + +> Также определены окружения `local` и `preprod`. В `preprod` хосты указывают на `https://api.preprod.sarex.io/*` (для `zitadel` — `https://login.preprod.sarex.io`). В `local` сервис `sarex` проксируется на `/sarex-backend`, а остальные сервисы указывают на `stage`-хосты (`https://stage-api.sarex.io/*`, `zitadel` → `https://idp.dev.stage.sarex.io`). Сервис `zitadel` явно в коде не вызывается — используется SDK для аутентификации. + +## Эндпоинты по сервисам + +### `sarexApi` — Gateway/API Sarex (issues / flows / eav) + +Базовый хост — корневой (`https://-api.sarex.io`); маршрутизация задаётся префиксами пути (`/issues/api`, `/flows/api`, `/eav/api`). + +| Метод | Путь | Назначение | Где вызывается | +| --- | --- | --- | --- | +| POST | `/issues/api/issues/filter/` | Список/фильтрация замечаний (пагинация `limit`/`offset`) | `api/issues/issues.ts` | +| POST | `/issues/api/issues/` | Создать замечание | `store/collectionRemarks.ts` | +| GET | `/issues/api/issues/{uuid}/` | Замечание по uuid | `store/collectionRemarks.ts` | +| PATCH | `/issues/api/issues/{uuid}/` | Обновить замечание | `store/collectionRemarks.ts`, `store/remarks.ts` | +| POST | `/issues/api/issues/export/?{params}` | Экспорт замечаний (ответ `blob`) | `store/remarks.ts` | +| GET | `/issues/api/issues/daterange/` | Диапазон дат замечаний | `store/filters.ts` | +| POST | `/issues/api/issues/filter-options/?{params}` | Опции фильтра замечаний | `store/filters.ts` | +| GET | `/issues/api/issue-changes/?issue_id={uuid}` | История изменений замечания | `store/remark.ts` | +| POST | `/issues/api/comments/` | Создать комментарий | `store/collectionRemarks.ts` | +| DELETE | `/issues/api/comments/{id}/` | Удалить комментарий | `store/collectionRemarks.ts` | +| GET | `/issues/api/attachments/{fileId}/` | Получить вложение | `store/collectionRemarks.ts` | +| POST | `/issues/api/attachments/` | Загрузить вложение | `store/collectionRemarks.ts` | +| DELETE | `/issues/api/attachments/{id}/` | Удалить вложение | `store/collectionRemarks.ts` | +| GET | `/issues/api/companies/{companyId}/status-model/v2/` | Модель статусов компании | `store/remarks.ts` | +| GET | `/flows/api/v1/documents/` | Документы (flows) | `store/filters.ts` | +| GET | `/flows/api/v1/documents/?{query}&offset=0&limit=100000` | Документы (flows, полная выборка) | `store/remarks.ts` | +| POST | `/flows/api/v1/flows/filter/` | Фильтрация flows | `store/filters.ts` | +| GET | `/eav/api/v0/attribute/?company_id={params}` | Атрибуты компании (EAV v0) | `store/remarks.ts` | + +### `remarks` — Сервис замечаний + +| Метод | Путь | Назначение | Где вызывается | +| --- | --- | --- | --- | +| GET | `/api/v1/remarks?target=true{params}` | Список замечаний по target | `store/remarks.ts` | +| DELETE | `/api/v1/remarks/{uuid}` | Удалить замечание | `store/collectionRemarks.ts` | + +### `gateway` — Gateway Sarex + +| Метод | Путь | Назначение | Где вызывается | +| --- | --- | --- | --- | +| GET | `/api/v2/users/?{query}` | Пользователи (с фильтрами прав/таргета) | `store/remarks.ts` | +| GET | `/api/v2/users/` | Пользователи (без фильтров) | `store/remarks.ts` | +| GET | `/api/v1/documents/{docId}/attributes/` | Атрибуты документа | `store/remarks.ts` | +| GET | `/api/v1/resources/` | Список ресурсов | `store/resourcesStore.ts` | + +### `documentations` — Сервис документации + +| Метод | Путь | Назначение | Где вызывается | +| --- | --- | --- | --- | +| GET | `/api/v1/documents/{docId}?extend=bundles` | Документ с бандлами | `store/remark.ts` | + +### `eavV4` — Сервис ассетов (EAV v4) + +Базовый хост уже включает префикс `/eav/api/v4`. + +| Метод | Путь | Назначение | Где вызывается | +| --- | --- | --- | --- | +| POST | `/assets/search/` | Поиск ассетов по набору id | `api/assets.ts` | +| GET | `/assets/` | Список ассетов (фильтры, пагинация, `tag`) | `api/assets.ts` | + +### `sarex` — Локальный сервис данных + +| Метод | Путь | Назначение | Где вызывается | +| --- | --- | --- | --- | +| GET | `/api/client/settings/` | Клиентские настройки | `store/remarks.ts` | +| GET | `/api/core/admin/departments/?company={companyId}` | Отделы компании (пагинация по `next`) | `store/remarks.ts` | +| GET | `/api/core/admin/positions/?company={companyId}` | Должности компании (пагинация по `next`) | `store/remarks.ts` | + +## Обработка ошибок + +Отдельного файла-маппера ошибок (аналога `module/api/errors.ts` в `transmittal-frontend`) в проекте нет. Обработка сосредоточена в двух местах: + +- `module/api/http-client.ts` — прокси перехватывает ответ `403` и показывает toast (`react-toastify`) с текстом `response.data.detail` или сообщением по умолчанию «У вас недостаточно прав для выполнения данного действия.»; +- SDK `@sarex-team/sdk-js` — при `showErrorNotification: true` показывает стандартное уведомление об ошибке; в сторах ряд операций дополнительно оборачивается в `try/catch` с собственными toast-сообщениями (напр. «Замечание успешно создано!» / «Произошла ошибка при создании замечания»). diff --git a/apps/resources/.env.example b/apps/resources/.env.example new file mode 100644 index 0000000..6d927f2 --- /dev/null +++ b/apps/resources/.env.example @@ -0,0 +1,58 @@ +# Django settings module +# wsgi.py / manage.py по умолчанию используют config.settings.production +DJANGO_SETTINGS_MODULE=config.settings.production + +# Django +# Читаются только при DEBUG (в base.py) и в production.py (ALLOWED_HOSTS зашиты в коде) +# SECRET_KEY и DEBUG заданы в самих settings-модулях, через окружение не переопределяются + +# Environment +# Метка окружения (используется в атрибутах трейсинга): stage / preprod / prod +ENVIRONMENT=prod + +# Database (PostGIS) +# Читаются в config/settings/production.py; ENGINE фиксирован: django.contrib.gis.db.backends.postgis +DATABASE_HOST=127.0.0.1 +DATABASE_PORT=5432 +DATABASE_NAME=resources +DATABASE_USER=postgres +DATABASE_PASSWORD=password + +# Database (тесты) +# Читаются только в config/settings/test.py (запуск pytest) +DJANGO_POSTGRES_DB=resources +DJANGO_POSTGRES_USER=postgres +DJANGO_POSTGRES_PASSWORD=12345678dev +DJANGO_POSTGRES_HOST=test_postgres +DJANGO_POSTGRES_PORT=5432 + +# S3 (django-storages / boto3, Yandex Object Storage) +# Маппятся на AWS_* в settings; DEFAULT_FILE_STORAGE=storages.backends.s3boto3.S3Boto3Storage +YC_S3_ACCESS_KEY_ID= +YC_S3_SECRET_ACCESS_KEY= +YC_S3_BUCKET_NAME=resources +YC_S3_ENDPOINT_URL=https://storage.yandexcloud.net + +# Sarex backend (интеграция с ядром Sarex) +# SAREX_HOST -> SAREX_BASE_HOST; используется для получения токена и списка пользователей +SAREX_HOST=https://lk.sarex.io +SAREX_ADMIN_USERNAME= +SAREX_ADMIN_PASSWORD= +# Определяется в production.py, но кодом приложения напрямую не читается +SERVICE_ACCOUNTS_HOST=https://lk.sarex.io/api/core + +# OpenTelemetry (django-otel-tools) +# ВНИМАНИЕ: трейсинг включается по любой непустой строке (os.getenv('USE_OTEL', False)), +# поэтому USE_OTEL=False строкой ТОЖЕ включит его. Для выключения переменную не задавать. +USE_OTEL=True +SERVICE_NAME=resources-backend.sarex-resources +TRACER_ENDPOINT=localhost:4375 +# То же поведение "любая строка = True", что и у USE_OTEL +USE_INSECURE=True +MODULE=resources +TEAM=platform_team +COMPONENT=backend + +# Uwsgi / инфраструктура +# Прокидывается в Helm/k8s, но кодом приложения не читается (порт задан в uwsgi.ini) +API_ADDRESS=8000 diff --git a/apps/resources/CONFIGURATION.md b/apps/resources/CONFIGURATION.md new file mode 100644 index 0000000..2e237ce --- /dev/null +++ b/apps/resources/CONFIGURATION.md @@ -0,0 +1,203 @@ +# Конфигурация проекта sarex-resources + +Документ описывает все переменные окружения и способы конфигурирования сервиса ресурсов (`sarex-resources`, backend модуля «Ресурсы/Проекты»). + +## Способы конфигурирования + +Сервис — приложение на **Django 4.1 + Django REST Framework** (GeoDjango/PostGIS). В отличие от сервисов на `pydantic-settings`, конфигурация задаётся **классическими Django settings-модулями** в `server/config/settings/`, а не единым классом настроек: + +- `base.py` — общие настройки (приложения, middleware, DRF, S3, интеграция с Sarex, OpenTelemetry); +- `production.py` — наследует `base.py` (`from .base import *`), задаёт `DEBUG=False`, `ALLOWED_HOSTS`, БД, CORS, S3, статику; +- `test.py` — наследует `base.py`, отдельная тестовая БД, снятие permission-классов DRF. + +Активный модуль выбирается переменной **`DJANGO_SETTINGS_MODULE`**. По умолчанию (`server/config/wsgi.py`, `server/manage.py`) — `config.settings.production`. + +Особенности разбора: + +- **префикса у переменных нет** — имена плоские (`DATABASE_HOST`, `YC_S3_BUCKET_NAME` и т.п.); +- значения читаются напрямую через `os.getenv(...)`; вложенных секций/делимитеров (как `__` в pydantic-сервисах) нет; +- **типизация и валидация окружения отсутствуют** — всё приходит строками; булевы флаги (`USE_OTEL`, `USE_INSECURE`) проверяются на «truthy», поэтому **любая непустая строка, включая `"False"`, считается истиной** (см. «Замечания»); +- `.env`-файл приложением **не загружается автоматически** — переменные должны быть в окружении процесса (docker-compose `environment`, k8s `env`/`secretKeyRef`, либо `set -a && . ./.env && set +a`). + +> **Важно для прод-развёртывания.** В кластере файл `server/config/settings/production.py` **подменяется** содержимым ConfigMap `django-configmap` (монтируется на `/server/config/settings/production.py`, см. `infra/iac/apps/resources/base/django-configmap.yaml` и `.helm/templates/api.yaml`). Именно версия из ConfigMap определяет фактические `ALLOWED_HOSTS`, `CORS_*`, `SERVICE_ACCOUNTS_HOST`, cookie-имена и часть значений Sarex. Репозиторный `production.py` — это шаблон/локальный вариант. + +Источники переменных по способам запуска: + +| Способ запуска | Откуда берутся переменные | +| --- | --- | +| Локально (docker-compose) | `docker-compose.yml` — сервисы `postgres`/`api`; переменные Postgres задаются в `environment`, приложение читает окружение контейнера | +| Локально (тесты) | `docker-compose.test.yml` + `pytest.ini` (`DJANGO_SETTINGS_MODULE=config.settings.test`), переменные `DJANGO_POSTGRES_*` | +| Kubernetes (Helm-чарт репозитория) | `.helm/values-.yaml`: блоки `envs` (обычные значения) и `secrets` (из k8s-секретов); шаблон `.helm/templates/api.yaml` | +| Kubernetes (infra, GitOps) | `infra/iac/apps/resources/*` — kustomize `base` (Vault-инъекция env в аннотациях Deployment + ConfigMaps) и оверлеи `brusnika-stage`/`brusnika-prod` (`helmrelease.yaml`, universal-chart) | +| CI/CD (GitLab) | `.gitlab-ci.yml`: подключает общие шаблоны `generic/common-ci`, переключает окружение по ветке/тегу через `workflow.rules` | + +Способы запуска процессов: + +| Команда | Точка входа | Назначение | +| --- | --- | --- | +| `uwsgi --ini /opt/server/uwsgi.ini` | `config.wsgi:application` | HTTP API (uWSGI, порт 8000) | +| `python manage.py migrate` | `manage.py` | Применение миграций (в `entrypoint.sh` перед стартом uWSGI) | +| `python manage.py ` | `manage.py` | Служебные Django-команды (`createsuperuser`, `shell`, миграции и т.п.) | +| `pytest` | `config.settings.test` | Юнит-тесты (`compose/test_server/entrypoint.sh`) | + +Порядок запуска в контейнере (`compose/server/entrypoint.sh`): сначала `python manage.py migrate`, затем `opentelemetry-instrument uwsgi --plugin python3 --ini /opt/server/uwsgi.ini` с `DJANGO_SETTINGS_MODULE=config.settings.production`. + +## Переменные приложения + +Все переменные плоские (без префикса). Дефолт `—` означает, что значение при обращении вернёт `None`/пусто (Django/psycopg2 или интеграции могут упасть уже в рантайме, а не на старте). + +### Выбор настроек + +| Переменная | Тип | Значение по умолчанию | Назначение | +| --- | --- | --- | --- | +| `DJANGO_SETTINGS_MODULE` | string | `config.settings.production` | Модуль настроек Django. Значения: `config.settings.production` / `config.settings.test` | + +### Окружение и трейсинг (`base.py`) + +Блок OpenTelemetry активируется целиком по `USE_OTEL` (`django-otel-tools`): добавляется `OtelMiddleware`, настраивается трейсер и OTEL-логгер. + +| Переменная | Тип | Значение по умолчанию | Назначение | +| --- | --- | --- | --- | +| `USE_OTEL` | bool-ish | `False` (выкл.) | Включение трейсинга. Truthy-проверка: любая непустая строка включает | +| `SERVICE_NAME` | string | `resources-backend.sarex-resources` | Имя сервиса в трейсах | +| `TRACER_ENDPOINT` | string | `localhost:4375` | Адрес OTLP-коллектора | +| `USE_INSECURE` | bool-ish | `False` | Небезопасное (без TLS) подключение к коллектору; та же truthy-проверка | +| `ENVIRONMENT` | string | `prod` | Метка окружения в атрибутах трейсинга (`stage`/`preprod`/`prod`) | +| `MODULE` | string | `resources` | Атрибут `module` в трейсинге | +| `TEAM` | string | `platform_team` | Атрибут `team` в трейсинге | +| `COMPONENT` | string | `backend` | Атрибут `component` в трейсинге | + +### База данных (`production.py`, `django.contrib.gis.db.backends.postgis`) + +| Переменная | Тип | Значение по умолчанию | Назначение | +| --- | --- | --- | --- | +| `DATABASE_HOST` | string | — | Хост PostgreSQL/PostGIS | +| `DATABASE_PORT` | int (string) | — | Порт PostgreSQL | +| `DATABASE_NAME` | string | — | Имя базы данных | +| `DATABASE_USER` | string | — | Пользователь БД | +| `DATABASE_PASSWORD` | string | — | Пароль пользователя БД | + +> БД требует расширения PostGIS (образ `postgis/postgis`), т.к. используются гео-поля (`Location.geometry`/`geography`) и `django.contrib.gis`. TLS к БД настраивается на уровне libpq: в Helm-чарте секрет `yc-pg-certificate` монтируется как `/root/.postgresql/root.crt` (в settings отдельного `sslmode` нет). + +### База данных для тестов (`test.py`) + +| Переменная | Тип | Значение по умолчанию | Назначение | +| --- | --- | --- | --- | +| `DJANGO_POSTGRES_DB` | string | `resources` | Имя тестовой БД | +| `DJANGO_POSTGRES_USER` | string | `postgres` | Пользователь тестовой БД | +| `DJANGO_POSTGRES_PASSWORD` | string | `12345678dev` | Пароль тестовой БД | +| `DJANGO_POSTGRES_HOST` | string | `test_postgres` | Хост тестовой БД | +| `DJANGO_POSTGRES_PORT` | int (string) | `5432` | Порт тестовой БД | + +Дополнительно `test.py` выставляет `DJANGO_ALLOW_ASYNC_UNSAFE=true` в коде. + +### S3 / объектное хранилище (`base.py` и `production.py`, `django-storages` + `boto3`) + +Файлы (`ResourcePhoto.image` и т.п.) хранятся в S3-совместимом хранилище; `DEFAULT_FILE_STORAGE=storages.backends.s3boto3.S3Boto3Storage`, `AWS_DEFAULT_ACL="public-read"`. + +| Переменная | Тип | Значение по умолчанию | Назначение (Django-настройка) | +| --- | --- | --- | --- | +| `YC_S3_ACCESS_KEY_ID` | string | — | `AWS_ACCESS_KEY_ID` | +| `YC_S3_SECRET_ACCESS_KEY` | string | — | `AWS_SECRET_ACCESS_KEY` | +| `YC_S3_BUCKET_NAME` | string | — | `AWS_STORAGE_BUCKET_NAME` | +| `YC_S3_ENDPOINT_URL` | string | — | `AWS_S3_ENDPOINT_URL` | + +### Интеграция с ядром Sarex (`base.py`) + +Используется для получения токена (`/api/token/`) и списка пользователей (`/api/core/users/`) в эндпоинтах группировки пользователей/ресурсов (`users-grouped-by-resource`, `users-with-resources`). + +| Переменная | Тип | Значение по умолчанию | Назначение | +| --- | --- | --- | --- | +| `SAREX_HOST` | string | `https://lk.sarex.io` | Базовый хост Sarex (`SAREX_BASE_HOST`) | +| `SAREX_ADMIN_USERNAME` | string | — | Логин сервисной учётки Sarex | +| `SAREX_ADMIN_PASSWORD` | string | — | Пароль сервисной учётки Sarex | +| `SERVICE_ACCOUNTS_HOST` | string | `https://lk.sarex.io/api/core` | Определяется в `production.py`; кодом приложения напрямую не читается (см. «Замечания») | + +## Переменные инфраструктуры, сборки и вспомогательных утилит + +Не читаются кодом приложения, но участвуют в запуске/сборке/деплое. + +| Переменная | Где используется | Назначение | +| --- | --- | --- | +| `API_ADDRESS` | `.helm/values-*.yaml`, `infra/.../backend-deployment.yaml` | Задаётся в окружении (`8000`), кодом не читается — порт фактически берётся из `uwsgi.ini` (`http = 0.0.0.0:8000`) | +| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | `docker-compose.yml`, `docker-compose.test.yml` | Инициализация контейнера Postgres (не путать с `DATABASE_*`, которые читает Django) | +| `PYTHONUNBUFFERED` | `Dockerfile` | Небуферизованный stdout/stderr | +| build-настройки uWSGI | `compose/server/uwsgi.ini` | `processes=8`, `http=0.0.0.0:8000`, `harakiri`, `buffer-size`, `static-map` для `/static` и `/media` | + +Приватный индекс пакетов для сборки (`django-otel-tools`) задан прямо в `requirements/base.txt` через `--extra-index-url` (nexus.infra.sarex.io). + +## Переменные из Helm-чарта репозитория (`.helm/values-.yaml`) + +Обычные значения — блок `envs`, секреты — блок `secrets` (монтируются как env через `secretKeyRef`). Значения различаются по окружениям (`stage`/`preprod`/`production`): адрес БД (`DATABASE_HOST`), `SERVICE_NAME`, `TRACER_ENDPOINT`, `ENVIRONMENT`, число реплик. + +Значения из секретов: + +| Переменная | Секрет (`secret_name`) | Ключ (`secret_key`) | +| --- | --- | --- | +| `DATABASE_USER` | `ya-pg-secret` | `user` | +| `DATABASE_PASSWORD` | `ya-pg-secret` | `password` | +| `YC_S3_ACCESS_KEY_ID` | `yc-s3-secret` | `key_id` | +| `YC_S3_SECRET_ACCESS_KEY` | `yc-s3-secret` | `access_key` | +| `YC_S3_BUCKET_NAME` | `yc-s3-secret` | `storage_bucket_name` | +| `YC_S3_ENDPOINT_URL` | `yc-s3-secret` | `endpoint_url` | +| `SAREX_ADMIN_USERNAME` | `sarex-auth-secret` | `username` | +| `SAREX_ADMIN_PASSWORD` | `sarex-auth-secret` | `password` | + +Помимо env, чарт монтирует CA-сертификат PostgreSQL из секрета `yc-pg-certificate` (ключ `certificate`) как файл `/root/.postgresql/root.crt`, а также ConfigMap `uwsgi-configmap` (файл `uwsgi.ini`). Прочие значения чарта (не переменные приложения): `deployment.*` (имя, образ, порт, реплики, ресурсы), `service_*`, `api_host` (istio `VirtualService`, prefix `/resource-management`). + +## Переменные из infra (GitOps, kustomize `base`) + +В `infra/iac/apps/resources/base/backend-deployment.yaml` секреты БД и S3 подаются через **HashiCorp Vault Agent** (аннотации `vault.hashicorp.com/*`), который рендерит файлы `/vault/secrets/resources-db` и `/vault/secrets/resources-s3`; они подгружаются в окружение в `args` контейнера (`set -a && . /vault/secrets/... && set +a`) перед запуском `entrypoint.sh`. + +| Переменная | Источник (Vault path / значение) | +| --- | --- | +| `DATABASE_HOST` | `postgresql.resources.svc.cluster.local` (шаблон Vault) | +| `DATABASE_PORT` | `5432` | +| `DATABASE_NAME` | `resources_db` | +| `DATABASE_USER` | `secrets/data/postgresql/apps/resources` → `username` | +| `DATABASE_PASSWORD` | `secrets/data/postgresql/apps/resources` → `password` | +| `YC_S3_ENDPOINT_URL` | `secrets/data/minio/apps/resources` → `client.endpoint` | +| `YC_S3_BUCKET_NAME` | `resources` | +| `YC_S3_ACCESS_KEY_ID` | `secrets/data/minio/apps/resources` → `access_key` | +| `YC_S3_SECRET_ACCESS_KEY` | `secrets/data/minio/apps/resources` → `secret_key` | +| `DJANGO_SETTINGS_MODULE` | `config.settings.production` (env Deployment) | +| `API_ADDRESS` | `8000` (env Deployment) | + +Оверлеи `brusnika-stage`/`brusnika-prod` используют `HelmRelease` (universal-chart): env `DJANGO_SETTINGS_MODULE`, `DATABASE_HOST/PORT/NAME`, `API_ADDRESS`, `YC_S3_ENDPOINT_URL`, `YC_S3_BUCKET_NAME`; секреты `DATABASE_USER/PASSWORD` (`postgres-secret`), `YC_S3_ACCESS_KEY_ID/SECRET_ACCESS_KEY` (`yc-s3-secret`, ключи `key-id`/`access-key`). + +## Переменные в CI (`.gitlab-ci.yml`) + +Пайплайн подключает общие шаблоны из `generic/common-ci` (`universal-pipeline-*`, `common-security-scan`, `common-build`) и переключает окружение по ветке/тегу: + +| Условие | STAND | Namespace | RELEASE_NAME | +| --- | --- | --- | --- | +| ветка `master` | `preprod` | `resources-preprod` | `resources` | +| ветка `stage` | `stage` | `resources-stage` | `sarex-resources` | +| тег (`CI_COMMIT_TAG`) | `prod` | `resources-prod` | `sarex-resources` | + +Ключевые переменные пайплайна: `CHART_NAME=sarex-resources`, `CHART_VERSION`, `DOCKERFILE_PATH=./compose/server/Dockerfile`, `IMAGE_PATH=deployment.image`, `HELM_SET_ARGS` (`--set deployment.image=…`), флаги `ENABLE_LINTER`/`ENABLE_BUILD_CHART`/`ENABLE_BUILD_IMAGE`/`ENABLE_STATE_UPDATE`/`ENABLE_DEPLOY`. Job `linter` запускает `flake8` (образ `python:3.8-buster`). + +## Замечания и потенциальные проблемы + +- **Truthy-флаги.** `USE_OTEL` и `USE_INSECURE` читаются как `os.getenv(name, False)` без приведения типа. Любая непустая строка — истина, включая `"False"`, `"0"`, `"no"`. Чтобы **выключить** трейсинг, переменную `USE_OTEL` нужно **не задавать вовсе** (а не ставить в `False`). В Helm-values она задана строкой `"True"`. +- **`.env` не загружается автоматически.** В settings нет `python-dotenv`/`env_file`; переменные должны попадать в окружение процесса (compose `environment`, k8s `env`/Vault, ручной `export`). +- **`production.py` подменяется в кластере.** Репозиторный `production.py` содержит хардкод `ALLOWED_HOSTS`, `CORS_*`, тестовый `SECRET_KEY` и др.; в проде используется версия из ConfigMap `django-configmap`. Различия: `ALLOWED_HOSTS=['*']`, другой `SERVICE_ACCOUNTS_HOST`, хардкод `SAREX_ADMIN_*`/`SAREX_BASE_HOST`, cookie-имена `resource-sessionid`/`resource-csrftoken`. +- **`SECRET_KEY` захардкожен** в `base.py`/`production.py` (в т.ч. пометка `# Delete after Test`) — секрет не берётся из окружения. Для реального прода его следует вынести в секрет. +- **`SERVICE_ACCOUNTS_HOST`** определяется, но напрямую в коде не используется (интеграции ходят по `SAREX_BASE_HOST`); переменная задаётся «на вырост». +- **`DATABASE_*` без дефолтов.** При отсутствии переменных Django получит `None` и подключение к БД упадёт в рантайме (не на импортстарте). Пять переменных БД обязательны для рабочего запуска. +- **Аутентификация DRF отключена.** `DEFAULT_AUTHENTICATION_CLASSES=[]`, permission-классы не заданы (эффективно `AllowAny`) — доступ ограничивается только сетевым слоем/istio. Учитывать при публикации. +- **Имя релиза различается между окружениями** (`resources` на preprod vs `sarex-resources` на stage/prod) — при работе с Helm/namespace это легко перепутать. + +## Минимальный набор для локального запуска + +Postgres (PostGIS) поднимается через `docker-compose up postgres`, приложение — через сервис `api` (`docker-compose.yml`) или локально `uwsgi`/`manage.py runserver`. Минимально необходимо задать: + +- `DJANGO_SETTINGS_MODULE=config.settings.production` (или свой dev-модуль) +- `DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_NAME`, `DATABASE_USER`, `DATABASE_PASSWORD` +- `YC_S3_*` — если задействуется загрузка файлов/статики в S3 (иначе операции с файлами упадут) +- `SAREX_HOST`, `SAREX_ADMIN_USERNAME`, `SAREX_ADMIN_PASSWORD` — если нужны эндпоинты интеграции с пользователями Sarex +- OTEL — по умолчанию не задавать (`USE_OTEL` отсутствует); при включении — `USE_OTEL=1`, `SERVICE_NAME`, `TRACER_ENDPOINT`, при необходимости `USE_INSECURE` + +Для тестов достаточно поднять `docker-compose.test.yml` — переменные `DJANGO_POSTGRES_*` имеют рабочие дефолты, `pytest.ini` уже указывает `config.settings.test`. + +Готовые значения-примеры для всех переменных приведены в `.env.example`. diff --git a/apps/resources/openapi.yaml b/apps/resources/openapi.yaml new file mode 100644 index 0000000..321e9fc --- /dev/null +++ b/apps/resources/openapi.yaml @@ -0,0 +1,841 @@ +openapi: 3.0.3 + +info: + title: Sarex Resources API + version: "1.0.0" + description: | + REST API сервиса **sarex-resources** (`platform/sarex-resources`) — управление + ресурсами/проектами, их типами (иерархия), локациями, а также разрешениями + сервисных аккаунтов на ресурсы и на компании. + + Сервис написан на Python (**Django 4.1 + Django REST Framework**, GeoDjango/PostGIS). + Роутинг задаётся в `server/config/urls.py`: + + - `resource-management/` — Django-admin; + - `api/v1/` — основной API (`apps.resource.urls`); + - `api/v2/` — расширенный API ресурсов (`apps.resource.urls_v2`). + + Списочные и CRUD-эндпоинты строятся `rest_framework.routers.DefaultRouter` + поверh `ModelViewSet`; часть операций — отдельные `APIView`. + + ### Аутентификация + На уровне приложения аутентификация и permission-классы DRF **отключены** + (`DEFAULT_AUTHENTICATION_CLASSES = []`, permission-классы не заданы — + эффективно `AllowAny`). Ограничение доступа обеспечивается сетевым слоем + (istio/ingress, внутрикластерный доступ к `sarex-resources-service`). + + ### Пагинация + Используется `rest_framework.pagination.LimitOffsetPagination` с очень большим + `PAGE_SIZE` (100000). Списочные ответы оборачиваются в + `{ count, next, previous, results }`; постранично управляется параметрами + `limit` и `offset`. + + ### Идентификаторы ресурсов + У ресурса есть внутренний `id` (int, в API почти не используется), публичный + `public_id`/`id` (UUID — основной идентификатор в URL detail-эндпоинтов v1/v2) + и устаревший `target_id` (`_target_id`, int) для обратной совместимости. + Удаление — «мягкое» (`deleted=True`), объекты с `deleted=True` из выборок + исключаются. + + ### Замечания (расхождения кода) + - Detail-роуты `resource` в v1/v2 ищут объект по `public_id` (UUID), а не по + первичному ключу; путь при этом выглядит как `/api/v1/resource/{public_id}/`. + - Ряд аналитических эндпоинтов (`users-grouped-by-resource`, + `users-with-resources`, `resources-grouped-by-sa`) возвращают + «сырые» структуры (`dict`/списки), не обёрнутые в пагинацию. + - `bulk_delete/resource_permission/` и `permissions-bulk/` выполняют + **жёсткое** удаление разрешений (`.delete()`), в отличие от «мягкого» + удаления сущностей. + - `RetrieveResourceByTargetIdAPIView` при отсутствии ресурса возвращает + `404` без тела. + + contact: + name: sarex-resources + url: https://gitlab/platform/sarex-resources + +servers: + - url: https://api.sarex.io/resource-management + description: Production admin (istio VirtualService, prefix /resource-management) + - url: http://sarex-resources-service.resources-prod + description: Внутрикластерный адрес (ClusterIP), production namespace + - url: http://sarex-resources-service.resources-stage + description: Внутрикластерный адрес (ClusterIP), stage namespace + - url: http://localhost:8888 + description: Локальный запуск (docker-compose, 8888 → 8000) + +tags: + - name: resources + description: Ресурсы (проекты) + - name: resources_v2 + description: Расширенные ресурсы (api/v2) + - name: resource_types + description: Типы ресурсов (иерархия) + - name: locations + description: Локации (гео-данные) + - name: resource_permissions + description: Разрешения сервисных аккаунтов на ресурсы + - name: company_permissions + description: Разрешения сервисных аккаунтов на компании + - name: analytics + description: Служебные/аналитические эндпоинты (группировки, интеграция с Sarex) + +paths: + + /api/v1/resource/: + get: + tags: [resources] + summary: Список ресурсов + parameters: + - { name: tenant_id, in: query, schema: { type: string }, description: "ID компании; поддерживает список через запятую" } + - { name: type, in: query, schema: { type: integer } } + - { name: parent, in: query, schema: { type: integer }, description: "ID родителя; фильтрация по поддереву (ltree)" } + - { name: show_in_overview, in: query, schema: { type: boolean } } + - { name: id, in: query, schema: { type: string }, description: "public_id (UUID); список через запятую" } + - { name: target_id, in: query, schema: { type: string }, description: "устаревший target_id; список через запятую" } + - { name: service_accounts, in: query, schema: { type: string }, description: "UUID сервисных аккаунтов через запятую (доступные ресурсы)" } + - { name: limit, in: query, schema: { type: integer } } + - { name: offset, in: query, schema: { type: integer } } + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/PaginatedResourceList" + post: + tags: [resources] + summary: Создать ресурс + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ResourceWrite" } + responses: + "201": + description: Created + content: + application/json: + schema: { $ref: "#/components/schemas/ResourceWrite" } + + /api/v1/resource/{public_id}/: + parameters: + - { name: public_id, in: path, required: true, schema: { type: string, format: uuid } } + get: + tags: [resources] + summary: Получить ресурс по public_id + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/ResourceRetrieve" } + "404": { description: Не найден } + delete: + tags: [resources] + summary: Мягкое удаление ресурса (deleted=true) + responses: + "204": { description: No Content } + + /api/v1/resource_type/: + get: + tags: [resource_types] + summary: Список типов ресурсов + parameters: + - { name: parent, in: query, schema: { type: integer }, description: "ID родителя; фильтрация по поддереву" } + - { name: limit, in: query, schema: { type: integer } } + - { name: offset, in: query, schema: { type: integer } } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/PaginatedResourceTypeList" } + post: + tags: [resource_types] + summary: Создать тип ресурса + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ResourceTypeWrite" } + responses: + "201": + description: Created + content: + application/json: + schema: { $ref: "#/components/schemas/ResourceTypeWrite" } + + /api/v1/resource_type/filters/: + get: + tags: [resource_types] + summary: "Значения фильтра типов ресурсов (потомки родителя)" + parameters: + - { name: parent, in: query, schema: { type: integer }, description: "ID родителя; вернёт его потомков" } + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + parent: + type: array + items: + type: object + properties: + id: { type: integer } + name: { type: string } + + /api/v1/resource_type/{id}/: + parameters: + - { name: id, in: path, required: true, schema: { type: integer } } + get: + tags: [resource_types] + summary: Получить тип ресурса + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/ResourceTypeRetrieve" } + delete: + tags: [resource_types] + summary: Мягкое удаление типа ресурса + responses: + "204": { description: No Content } + + /api/v1/location/: + get: + tags: [locations] + summary: Список локаций + parameters: + - { name: limit, in: query, schema: { type: integer } } + - { name: offset, in: query, schema: { type: integer } } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/PaginatedLocationList" } + post: + tags: [locations] + summary: Создать локацию + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/LocationWrite" } + responses: + "201": + description: Created + content: + application/json: + schema: { $ref: "#/components/schemas/LocationWrite" } + + /api/v1/location/{id}/: + parameters: + - { name: id, in: path, required: true, schema: { type: integer } } + get: + tags: [locations] + summary: Получить локацию + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/LocationRetrieve" } + delete: + tags: [locations] + summary: Мягкое удаление локации + responses: + "204": { description: No Content } + + /api/v1/resource_permission/: + get: + tags: [resource_permissions] + summary: Список разрешений на ресурсы + parameters: + - { name: public_resource_id, in: query, schema: { type: string }, description: "UUID через запятую" } + - { name: resource_id, in: query, schema: { type: string }, description: "внутренние id через запятую" } + - { name: service_account, in: query, schema: { type: string }, description: "UUID через запятую" } + - { name: tenant_id, in: query, schema: { type: string }, description: "ID компаний через запятую (по resource.tenant_id)" } + - { name: company_id, in: query, schema: { type: string }, description: "синоним tenant_id" } + - { name: limit, in: query, schema: { type: integer } } + - { name: offset, in: query, schema: { type: integer } } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/PaginatedResourcePermissionList" } + post: + tags: [resource_permissions] + summary: "Создать разрешение(я) на ресурс" + description: "Принимает один объект или массив (bulk). Поле resource определяется по public_resource_id." + requestBody: + required: true + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/ResourcePermissionWrite" + - type: array + items: { $ref: "#/components/schemas/ResourcePermissionWrite" } + responses: + "201": + description: Created + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/ResourcePermissionWrite" + - type: array + items: { $ref: "#/components/schemas/ResourcePermissionWrite" } + + /api/v1/resource_permission/{id}/: + parameters: + - { name: id, in: path, required: true, schema: { type: integer } } + get: + tags: [resource_permissions] + summary: Получить разрешение на ресурс + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/ResourcePermissionRetrieve" } + delete: + tags: [resource_permissions] + summary: Мягкое удаление разрешения + responses: + "204": { description: No Content } + + /api/v1/company_resource_permission/: + get: + tags: [company_permissions] + summary: Список разрешений на компании + parameters: + - { name: service_account, in: query, schema: { type: string }, description: "UUID через запятую" } + - { name: tenant_id, in: query, schema: { type: string }, description: "ID компаний через запятую" } + - { name: limit, in: query, schema: { type: integer } } + - { name: offset, in: query, schema: { type: integer } } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/PaginatedCompanyPermissionList" } + post: + tags: [company_permissions] + summary: "Создать разрешение(я) на компанию" + description: "Принимает один объект или массив (bulk_create)." + requestBody: + required: true + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/CompanyPermission" + - type: array + items: { $ref: "#/components/schemas/CompanyPermission" } + responses: + "201": + description: Created + + /api/v1/company_resource_permission/{id}/: + parameters: + - { name: id, in: path, required: true, schema: { type: integer } } + get: + tags: [company_permissions] + summary: Получить разрешение на компанию + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/CompanyPermission" } + delete: + tags: [company_permissions] + summary: Мягкое удаление разрешения на компанию + responses: + "204": { description: No Content } + + /api/v1/targets/{target_id}/resource/: + get: + tags: [resources] + summary: Получить ресурс по устаревшему target_id + parameters: + - { name: target_id, in: path, required: true, schema: { type: integer } } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/ResourceRetrieve" } + "404": { description: Не найден } + + /api/v1/bulk_delete/resource_permission/: + post: + tags: [resource_permissions] + summary: Массовое (жёсткое) удаление разрешений на ресурсы + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + id: + type: array + items: { type: integer } + required: [id] + responses: + "200": { description: OK } + "400": { description: "id не передан или содержит не-int" } + + /api/v1/service-accounts/: + get: + tags: [analytics] + summary: Сервисные аккаунты, имеющие доступ к ресурсу + description: "Нужно указать ровно один из resource_id (UUID) или target_id (int)." + parameters: + - { name: resource_id, in: query, schema: { type: string, format: uuid } } + - { name: target_id, in: query, schema: { type: integer } } + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + count: { type: integer } + results: + type: array + items: { type: string, format: uuid } + "400": { description: "не указан либо указаны оба параметра / ошибка парсинга" } + "404": { description: Ресурс не найден } + + /api/v1/users-grouped-by-resource/: + get: + tags: [analytics] + summary: "Пользователи, сгруппированные по ресурсам" + description: "Обращается к ядру Sarex за списком пользователей. Возвращает map resource_id → [user_id]." + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + additionalProperties: + type: array + items: { type: integer } + + /api/v1/resources-grouped-by-sa/: + get: + tags: [analytics] + summary: "Ресурсы, сгруппированные по сервисным аккаунтам" + description: "Учитывает прямые (read) и компанейские разрешения. Возвращает map service_account → [public_resource_id]." + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + additionalProperties: + type: array + items: { type: string, format: uuid } + + /api/v1/users-with-resources/: + post: + tags: [analytics] + summary: "Доступные ресурсы для списка пользователей в рамках компании" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + id: + type: array + items: { type: integer } + tenant_id: { type: integer } + required: [id, tenant_id] + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + type: object + properties: + id: { type: integer } + unrestricted_access: { type: boolean } + resources: + type: array + items: + type: object + properties: + id: { type: string, format: uuid } + name: { type: string } + "400": { description: "не передан id или tenant_id / некорректный tenant_id" } + + /api/v1/permissions-bulk/: + patch: + tags: [resource_permissions] + summary: "Синхронизация (bulk) разрешений на ресурсы и компании" + description: | + Приводит разрешения к переданному состоянию: создаёт недостающие, + удаляет лишние (жёстко). `permissions` — map service_account → [resource_id], + `unrestricted_permissions` — map service_account → bool (доступ ко всей компании). + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + tenant_id: { type: integer } + permissions: + type: object + additionalProperties: + type: array + items: { type: string, format: uuid } + unrestricted_permissions: + type: object + additionalProperties: { type: boolean } + required: [tenant_id, permissions] + responses: + "200": { description: OK } + + /api/v2/resource/: + get: + tags: [resources_v2] + summary: Список расширенных ресурсов + parameters: + - { name: tenant_id, in: query, schema: { type: string }, description: "список через запятую" } + - { name: type, in: query, schema: { type: integer } } + - { name: parent, in: query, schema: { type: integer } } + - { name: show_in_overview, in: query, schema: { type: boolean } } + - { name: id, in: query, schema: { type: string }, description: "public_id (UUID) через запятую" } + - { name: target_id, in: query, schema: { type: integer } } + - { name: service_accounts, in: query, schema: { type: string } } + - { name: limit, in: query, schema: { type: integer } } + - { name: offset, in: query, schema: { type: integer } } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/PaginatedResourceExpandedList" } + post: + tags: [resources_v2] + summary: Создать расширенный ресурс + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ResourceExpandedWrite" } + responses: + "201": + description: Created + content: + application/json: + schema: { $ref: "#/components/schemas/ResourceExpandedWrite" } + + /api/v2/resource/{public_id}/: + parameters: + - { name: public_id, in: path, required: true, schema: { type: string, format: uuid } } + - { name: tenant_id, in: query, schema: { type: string }, description: "фильтр по компаниям (через запятую) при retrieve" } + get: + tags: [resources_v2] + summary: Получить расширенный ресурс по public_id + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/ResourceExpandedRetrieve" } + "404": { description: "there is no resource with provided id" } + patch: + tags: [resources_v2] + summary: Частичное обновление расширенного ресурса + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ResourceExpandedPartialUpdate" } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/ResourceExpandedPartialUpdate" } + delete: + tags: [resources_v2] + summary: Мягкое удаление расширенного ресурса + responses: + "204": { description: No Content } + +components: + + schemas: + + PermissionType: + type: integer + description: "0=read, 1=write, 2=delete, 3=admin" + enum: [0, 1, 2, 3] + + ResourceRetrieve: + type: object + properties: + id: { type: string, format: uuid, description: "public_id" } + name: { type: string } + type: { type: integer, nullable: true } + tenant_id: { type: integer } + location: { type: integer, nullable: true } + parent_id: { type: integer, nullable: true } + created_by: { type: integer } + target_id: { type: integer, nullable: true } + code: { type: string } + + ResourceWrite: + type: object + properties: + id: { type: string, format: uuid, nullable: true, readOnly: true } + name: { type: string } + type: { type: integer, nullable: true } + tenant_id: { type: integer } + location: { type: integer, nullable: true } + parent_id: { type: integer, nullable: true } + created_by: { type: integer } + target_id: { type: integer, nullable: true } + code: { type: string } + created_at: { type: string, format: date-time, readOnly: true } + updated_at: { type: string, format: date-time, readOnly: true } + required: [name, tenant_id, target_id] + + PaginatedResourceList: + type: object + properties: + count: { type: integer } + next: { type: string, nullable: true } + previous: { type: string, nullable: true } + results: + type: array + items: { $ref: "#/components/schemas/ResourceRetrieve" } + + ResourceTypeRetrieve: + type: object + properties: + id: { type: integer } + name: { type: string } + parent_id: { type: integer, nullable: true } + created_by: { type: integer } + + ResourceTypeWrite: + type: object + properties: + id: { type: integer, readOnly: true } + name: { type: string } + parent_id: { type: integer, nullable: true } + created_by: { type: integer } + created_at: { type: string, format: date-time, readOnly: true } + updated_at: { type: string, format: date-time, readOnly: true } + required: [name] + + PaginatedResourceTypeList: + type: object + properties: + count: { type: integer } + next: { type: string, nullable: true } + previous: { type: string, nullable: true } + results: + type: array + items: { $ref: "#/components/schemas/ResourceTypeRetrieve" } + + LocationRetrieve: + type: object + properties: + id: { type: integer } + name: { type: string } + coordinate_system: { type: integer } + geometry: { type: object, description: "GeoJSON-геометрия" } + geography: { type: object, description: "GeoJSON-геометрия (geography)" } + latitude: { type: string, format: decimal } + longitude: { type: string, format: decimal } + created_by: { type: integer } + + LocationWrite: + allOf: + - $ref: "#/components/schemas/LocationRetrieve" + - type: object + properties: + created_at: { type: string, format: date-time, readOnly: true } + updated_at: { type: string, format: date-time, readOnly: true } + required: [name, coordinate_system, geometry, geography, latitude, longitude] + + PaginatedLocationList: + type: object + properties: + count: { type: integer } + next: { type: string, nullable: true } + previous: { type: string, nullable: true } + results: + type: array + items: { $ref: "#/components/schemas/LocationRetrieve" } + + ResourcePermissionRetrieve: + type: object + properties: + id: { type: integer } + service_account: { type: string, format: uuid } + public_resource_id: { type: string, format: uuid } + type: { $ref: "#/components/schemas/PermissionType" } + created_by: { type: integer } + + ResourcePermissionWrite: + type: object + properties: + id: { type: integer, readOnly: true } + service_account: { type: string, format: uuid } + public_resource_id: { type: string, format: uuid } + type: { $ref: "#/components/schemas/PermissionType" } + created_by: { type: integer } + resource: { type: integer, description: "заполняется сервером по public_resource_id" } + created_at: { type: string, format: date-time, readOnly: true } + updated_at: { type: string, format: date-time, readOnly: true } + required: [service_account, public_resource_id] + + PaginatedResourcePermissionList: + type: object + properties: + count: { type: integer } + next: { type: string, nullable: true } + previous: { type: string, nullable: true } + results: + type: array + items: { $ref: "#/components/schemas/ResourcePermissionRetrieve" } + + CompanyPermission: + type: object + properties: + id: { type: integer, readOnly: true } + service_account: { type: string, format: uuid } + tenant_id: { type: integer } + created_by: { type: integer } + created_at: { type: string, format: date-time, readOnly: true } + updated_at: { type: string, format: date-time, readOnly: true } + required: [service_account, tenant_id] + + PaginatedCompanyPermissionList: + type: object + properties: + count: { type: integer } + next: { type: string, nullable: true } + previous: { type: string, nullable: true } + results: + type: array + items: { $ref: "#/components/schemas/CompanyPermission" } + + ResourcePhoto: + type: object + properties: + id: { type: integer } + author_id: { type: integer } + created_at: { type: string, format: date-time } + image: { type: string, format: uri } + + ResourceExpandedRead: + type: object + properties: + id: { type: string, format: uuid } + name: { type: string } + type: { type: integer, nullable: true } + tenant_id: { type: integer } + location: { type: integer, nullable: true } + parent_id: { type: integer, nullable: true } + created_by: { type: integer } + target_id: { type: integer, nullable: true } + code: { type: string } + description: { type: string } + location_verbose: { type: string } + latitude: { type: number, format: float } + longitude: { type: number, format: float } + photos: + type: array + items: { $ref: "#/components/schemas/ResourcePhoto" } + attributes: { type: object } + planning_widget_id: { type: integer, nullable: true } + work_schedule_project_id: { type: integer, nullable: true } + show_in_overview: { type: boolean } + widgets: { type: object } + meta: { type: object } + + ResourceExpandedRetrieve: + allOf: + - $ref: "#/components/schemas/ResourceExpandedRead" + + PaginatedResourceExpandedList: + type: object + properties: + count: { type: integer } + next: { type: string, nullable: true } + previous: { type: string, nullable: true } + results: + type: array + items: { $ref: "#/components/schemas/ResourceExpandedRead" } + + ResourceExpandedWrite: + type: object + properties: + id: { type: string, format: uuid, nullable: true, readOnly: true } + name: { type: string } + type: { type: integer, nullable: true } + tenant_id: { type: integer } + parent_id: { type: integer, nullable: true } + created_by: { type: integer } + target_id: { type: integer, nullable: true } + description: { type: string } + location_verbose: { type: string } + latitude: { type: number, format: float } + longitude: { type: number, format: float } + attributes: { type: object } + planning_widget_id: { type: integer, nullable: true } + work_schedule_project_id: { type: integer, nullable: true } + code: { type: string } + show_in_overview: { type: boolean } + widgets: { type: object } + meta: { type: object } + required: [name, tenant_id, target_id] + + ResourceExpandedPartialUpdate: + type: object + properties: + id: { type: string, format: uuid, readOnly: true } + name: { type: string } + type: { type: integer, nullable: true } + tenant_id: { type: integer, readOnly: true } + location: { type: integer, nullable: true } + parent_id: { type: integer, nullable: true } + created_by: { type: integer, readOnly: true } + target_id: { type: integer, readOnly: true } + code: { type: string } + description: { type: string } + location_verbose: { type: string } + latitude: { type: number, format: float } + longitude: { type: number, format: float } + attributes: { type: object } + planning_widget_id: { type: integer, nullable: true } + show_in_overview: { type: boolean } + widgets: { type: object } + meta: { type: object } diff --git a/apps/reviews/ENDPOINTS.md b/apps/reviews/ENDPOINTS.md new file mode 100644 index 0000000..e14cd23 --- /dev/null +++ b/apps/reviews/ENDPOINTS.md @@ -0,0 +1,226 @@ +# Эндпоинты, с которыми взаимодействует reviews-frontend + +Документ описывает все HTTP-эндпоинты внешних сервисов, к которым обращается модуль (микрофронтенд `reviews-frontend` — страница обзора/согласования с проектами). + +## Как устроено взаимодействие + +В отличие от декларативного реестра `endpoints.ts`, запросы в `reviews-frontend` описаны императивно: в виде методов API-объектов и отдельных функций в каталоге `module/api/` (а также в нескольких сторах/страницах). Каждый вызов идёт через единый `httpService` (`module/api/http-service.ts`), созданный `createHttpService(...)` из `@sarex-team/sdk-js` (поверх `axios`). + +Вызов задаётся объектом со следующими полями: + +- `service` — логическое имя сервиса (см. таблицу хостов ниже); +- метод определяется функцией `httpService` (`getRequest`/`postRequest`/`putRequest`/`patchRequest`/`deleteRequest`); +- `url` — путь запроса **относительно базового хоста сервиса** (базовый хост уже включает версионный префикс, напр. `/api/v1`); +- `data` — тело запроса (для POST/PUT/PATCH); +- `axiosConfig` — доп. настройки axios (`params`, `paramsSerializer`, `responseType: "blob"`, `timeout` и т.п.); +- `controller` — `AbortController` для отмены запроса; +- `showErrorNotification` — показывать ли уведомление об ошибке (обрабатывается на стороне SDK). + +Базовый хост подставляется SDK по имени `service` в зависимости от `BUILD_ENV` (см. `module/api/hosts.ts`). Итоговый URL = `<базовый хост сервиса>` + `url`. + +Основные точки, где выполняются запросы: + +| Файл | Экспорт | Назначение | +| --- | --- | --- | +| `module/api/index.ts` | `ReviewAPI`, `DocumentAPI`, `IssuesAPI` + отдельные функции (`getUsersByResourceId`, `getUsersByCompanyId`, `getDepartments*`, `getPositions*`, `getMrpaList`, `fetchParentDocumentByResourceId`, `fetchDocumentsBundleVersions`, `getDocumentAncestors`, `getDiskDocumentsPath`, `fetchExportReviews*`) | Ядро API: reviews, задачи, документы, справочники, экспорт | +| `module/api/agents.ts` | `AgentsAPI` | AI-агент подбора путей копирования (router-agent) | +| `module/api/checklists.ts` | `ChecklistsAPI` | Чек-листы и их результаты | +| `module/api/documentations.ts` | `DocumentationsAPI` | Дети папок с активными процессами | +| `module/api/marks.ts` | `MarksAPI` | Оркестрация штампов/подписей | +| `module/api/tranmittals.ts` | `TransmittalsAPI` | Создание трансмитталов и работа с шаблонами | +| `module/pages/Review/CheckList/AiCheck/api.ts` | `AiCheckAPI` | AI-проверка документов по чек-листу | +| `module/store/stores/resources.ts` | `ResourcesStore.fetchResources` | Список ресурсов компании | +| `module/store/stores/users.ts` | `Users.fetchSettings` | Клиентские настройки пользователя | + +## Базовые хосты по сервисам и окружениям + +Значения из `module/api/hosts.ts`. Базовый хост уже включает версионный префикс сервиса, поэтому в таблицах эндпоинтов ниже указан только `url` (без него). + +| Сервис (`service`) | Назначение | `stage` | `prod` | +| --- | --- | --- | --- | +| `flows` | Сервис рабочих процессов (reviews, задачи, документы review) | `https://stage-api.sarex.io/flows/api/v1` | `https://api.sarex.io/flows/api/v1` | +| `documentations` | Сервис документации (бандлы, файлы, штампы, подписи) | `https://stage-api.sarex.io/documentations/api/v1` | `https://api.sarex.io/documentations/api/v1` | +| `gateway_api_v1` | Gateway API v1 (ресурсы, документы, версии бандлов) | `https://stage-api.sarex.io/gateway/api/v1` | `https://api.sarex.io/gateway/api/v1` | +| `gateway_api_v2` | Gateway API v2 (пользователи по ресурсу) | `https://stage-api.sarex.io/gateway/api/v2` | `https://api.sarex.io/gateway/api/v2` | +| `sarex` | Локальный сервис данных (`/api/core`, `/api/client`) | `""` (относительные пути) | `""` | +| `eav_api_v0` | Сервис атрибутов (EAV) | `https://stage-api.sarex.io/eav/api/v0` | `https://api.sarex.io/eav/api/v0` | +| `checklists` | Сервис чек-листов | `https://stage-api.sarex.io/checklists/api/v1` | `https://api.sarex.io/checklists/api/v1` | +| `transmittals` | Сервис передачи документации (трансмитталы, шаблоны) | `https://stage-api.sarex.io/transmittals/api/v1` | `https://api.sarex.io/transmittals/api/v1` | +| `issues` | Сервис замечаний | `https://stage-api.sarex.io/issues/api` | `https://api.sarex.io/issues/api` | +| `orchestrator` | Оркестратор процессов (штампы/подписи) | `https://stage-api.sarex.io/orchestrator` | `https://api.sarex.io/orchestrator/api` | +| `files` | Сервис файлов (скачивание бандлов) | `https://stage-api.sarex.io/files/api/v1` | `https://api.sarex.io/files/api/v1` | +| `lambdas` | Сервис экспорта (lambda-функции) | `https://stage-api.sarex.io/lambdas` | `https://api.sarex.io/lambdas` | +| `sarexAgents` | Сервис AI-агентов (проверка, подбор путей, загрузка файлов) | `https://sarex-agents.dev.stage.sarex.io/api/v1` | `https://agents.sarex.tech/api/v1` | +| `zitadel` | IdP (аутентификация) | `https://idp.dev.stage.sarex.io` | `https://login.sarex.io` | + +> Также определены окружения `local`, `preprod` и `contour`. В `contour` все хосты — относительные пути (изолированный контур), а `zitadel` пуст. В `local` сервис `sarex` указывает на `https://stage.sarex.io`, а `httpService` переключается в режим `zitadel` (`setTypeOfHttpService("zitadel")` в `http-service.ts`). Подключаемый удалённый модуль `documentations` (Module Federation) описан отдельно в `module/api/module-hosts.ts`. + +## Эндпоинты по сервисам + +### `flows` — Сервис рабочих процессов (reviews) + +| Ключ (метод API) | HTTP-метод | Путь (`url`) | Назначение | +| --- | --- | --- | --- | +| `ReviewAPI.getReviews` | POST | `/reviews/filter/` | Список reviews с фильтрами (пагинация `limit`/`offset` в query) | +| `ReviewAPI.getReview` | GET | `/reviews/{id}/` | Review по id | +| `ReviewAPI.getReviewsByDocumentIds` | GET | `/documents/?document_ids={ids}&full=true&review_status=completed,canceled&limit=100000` | Документы review по id документов | +| `ReviewAPI.getReviewsByBundleCopiedIds` | GET | `/documents/?bundle_copied_ids={ids}&full=true&review_status=completed,canceled&limit=100000` | Документы review по id скопированных бандлов | +| `ReviewAPI.fetchCurrentTasks` | GET | `/tasks/` | Текущие задачи (фильтры `reviewer_id`, `is_active`, `resource_id`, пагинация) | +| `ReviewAPI.changePriorityTask` | PATCH | `/tasks/{id}/change-priority/` | Изменить приоритет задачи | +| `ReviewAPI.changeDurationTask` | PATCH | `/tasks/{id}/change-duration/` | Изменить длительность задачи | +| `ReviewAPI.getTasksEndDates` | GET | `/tasks/reviewers-max-end-dates/?{query}` | Макс. даты завершения по проверяющим | +| `ReviewAPI.getCountByResourceId` | POST | `/reviews/count_by_resource_id/` | Количество reviews по ресурсам | +| `ReviewAPI.getCountByReviewers` | POST | `/reviews/count_by_reviewer_id/` | Количество reviews по проверяющим | +| `ReviewAPI.getNextStepReviewers` | GET | `/steps/{stepId}/get_reviewers/?review_id={reviewId}` | Проверяющие следующего шага | +| `ReviewAPI.getReviewDocuments` | GET | `/reviews/{id}/documents/` | Документы review | +| `ReviewAPI.updateReviewDocument` | PUT | `/documents/{id}/` | Обновить документ review | +| `ReviewAPI.bulkUpdateReviewDocument` | PUT | `/reviews/{reviewId}/documents/` | Массовое обновление документов review | +| `ReviewAPI.setStatus` | PATCH | `/documents/set-status/?document_ids={ids}` | Проставить статус документам | +| `ReviewAPI.createReview` | POST | `/reviews/` | Создать review | +| `ReviewAPI.changeReviewers` | PATCH | `/reviews/{reviewId}/change_reviewers/` | Сменить проверяющих | +| `ReviewAPI.changeMinReviewers` | PATCH | `/reviews/{reviewId}/change-min-reviewers/` | Изменить мин. число проверяющих | +| `ReviewAPI.getTimeTrackerInfo` | GET | `/reviews/{reviewId}/time-tracking/` | Данные тайм-трекинга review | +| `ReviewAPI.startReview` | PATCH | `/reviews/{reviewId}/start/` | Запустить review | +| `ReviewAPI.patchReview` | PATCH | `/reviews/{id}/` | Обновить атрибуты review | +| `ReviewAPI.deleteReview` | DELETE | `/reviews/{id}/` | Удалить review | +| `ReviewAPI.activateReview` / `ReviewAPI.passReview` | PATCH | `/reviews/{id}/approve/` | Утвердить/пройти review (с комментарием и статусом) | +| `ReviewAPI.setReviewStep` | PATCH | `/reviews/{review_id}/set-step/{step_id}/` | Установить шаг review | +| `ReviewAPI.reviewUpdateBundles` | PATCH | `/reviews/{id}/update-bundles/` | Обновить бандлы review | +| `ReviewAPI.userAction` | POST | `/user-actions/` | Записать действие пользователя | +| `ReviewAPI.writeTransmittalCreated` | POST | `/reviews/{review_id}/transmittal-created/` | Отметить создание трансмиттала для review | +| `ReviewAPI.getProcesses` | GET | `/flows/light/?{query}` | Список процессов (облегчённый) | +| `ReviewAPI.getProcessById` | GET | `/flows/{id}/` | Процесс по id | +| `DocumentAPI.changeCopyPaths` | PATCH | `/documents/change-copy-paths/` | Изменить пути копирования документов | +| `ChecklistsAPI.createReviewsChecklistResult` | PATCH | `/reviews/{reviewId}/checklist-results/` | Результаты чек-листа для review | + +### `documentations` — Сервис документации + +| Ключ (метод API) | HTTP-метод | Путь (`url`) | Назначение | +| --- | --- | --- | --- | +| `DocumentAPI.getDocumentsBatch` | POST | `/documents/batch` | Пакетное получение документов | +| `DocumentAPI.mark` | PUT | `/bundles/{bundleId}/marks` | Добавить штампы/QR/подписи | +| `DocumentAPI.sign` | POST | `/bundles/{bundleId}/sign` | Подписать бандл | +| `DocumentAPI.downloadFile` | GET | `/bundles/{bundleId}/{key}/download` | Скачать файл (ответ `blob`) | +| `DocumentAPI.getDisks` | GET | `/disks` | Список дисков | +| `DocumentationsAPI.getFolderChildrenWithActiveProcesses` | POST | `/documents/flows` | Дети папок с активными процессами | +| `AiCheckAPI.getBundlePresignedUrl` | GET | `/bundles/{bundleDocumentId}/presigned_url?key=pdf` | Presigned-URL PDF бандла | + +### `gateway_api_v1` — Gateway API v1 + +| Ключ (метод API) | HTTP-метод | Путь (`url`) | Назначение | +| --- | --- | --- | --- | +| `fetchResources` (`ResourcesStore`) | GET | `/resources/?company_id={companyId}` | Список ресурсов компании | +| `fetchParentDocumentByResourceId` | GET | `/resources-rpc/parent-document-by-resource-id/{resourceId}/` | Родительский документ по resource id | +| `fetchDocumentsBundleVersions` | POST | `/documents/bundle_versions` | Версии бандлов документов | +| `getDocumentAncestors` | POST | `/documents/ancestors` | Предки документов | +| `getDiskDocumentsPath` | GET | `/disks/{diskId}/documents?child_id={childId}` | Путь документа на диске | + +### `gateway_api_v2` — Gateway API v2 + +| Ключ (метод API) | HTTP-метод | Путь (`url`) | Назначение | +| --- | --- | --- | --- | +| `getUsersByResourceId` | GET | `/users/?{query}` | Пользователи по ресурсу (с правами) | + +### `sarex` — Локальный сервис данных + +| Ключ (метод API) | HTTP-метод | Путь (`url`) | Назначение | +| --- | --- | --- | --- | +| `Users.fetchSettings` | GET | `/api/client/settings/` | Клиентские настройки пользователя | +| `getUsersByCompanyId` | GET | `/api/core/users/?company={companyId}&{query}` | Пользователи компании | +| `getDepartments` | GET | `/api/core/admin/departments/` | Отделы | +| `getDepartmentsV2` | GET | `/api/core/admin/departments/?company={companyId}&{query}` | Отделы компании (пагинация) | +| `getPositions` | GET | `/api/core/admin/positions/` | Должности | +| `getPositionsV2` | GET | `/api/core/admin/positions/?company={companyId}&{query}` | Должности компании (пагинация) | +| `getMrpaList` | POST | `/api/core/mrpa/list/` | Список MRPA | + +### `eav_api_v0` — Сервис атрибутов (EAV) + +| Ключ (метод API) | HTTP-метод | Путь (`url`) | Назначение | +| --- | --- | --- | --- | +| `ReviewAPI.getAttributes` | GET | `/schema/?model_name=flow&company_id={companyId}` | Схема атрибутов модели `flow` | + +### `checklists` — Сервис чек-листов + +| Ключ (метод API) | HTTP-метод | Путь (`url`) | Назначение | +| --- | --- | --- | --- | +| `ChecklistsAPI.getChecklist` | GET | `/checklists/{id}/` | Чек-лист по id | +| `ChecklistsAPI.getChecklistResults` | GET | `/results/` | Результаты чек-листов (фильтры в query) | +| `ChecklistsAPI.createChecklistResult` | POST | `/results/` | Создать результат чек-листа | +| `ChecklistsAPI.updateChecklistResult` | PATCH | `/results/{id}/` | Обновить результат чек-листа | + +### `transmittals` — Сервис передачи документации + +| Ключ (метод API) | HTTP-метод | Путь (`url`) | Назначение | +| --- | --- | --- | --- | +| `TransmittalsAPI.createTransmittal` | POST | `/transmittals/create` | Создать трансмиттал | +| `TransmittalsAPI.getTransmittals` | POST | `/transmittals` | Список трансмитталов ресурса (пагинация по `bookmark`) | +| `TransmittalsAPI.getTemplate` | GET | `/transmittal_templates/{templateId}?resource={resourceId}` | Шаблон трансмиттала по id | +| `TransmittalsAPI.getSelectTemplates` | GET | `/transmittal_templates/select?resource={resourceId}` | Список шаблонов для выбора | + +### `issues` — Сервис замечаний + +| Ключ (метод API) | HTTP-метод | Путь (`url`) | Назначение | +| --- | --- | --- | --- | +| `IssuesAPI.getIssues` | POST | `/issues/filter/` | Список замечаний с фильтрами (пагинация в query) | +| `IssuesAPI.getIssuesTypesStatusModelsByCompanyId` | GET | `/status-models/?company_id={companyId}` | Модели статусов замечаний компании | + +### `orchestrator` — Оркестратор процессов + +| Ключ (метод API) | HTTP-метод | Путь (`url`) | Назначение | +| --- | --- | --- | --- | +| `MarksAPI.createMarkFlow` | POST | `/process` | Запустить процесс маркировки | +| `MarksAPI.getMarkFlow` | GET | `/process/{id}` | Процесс маркировки по id | +| `MarksAPI.startSign` | POST | `/sign` | Запустить подписание | + +### `files` — Сервис файлов + +| Ключ (метод API) | HTTP-метод | Путь (`url`) | Назначение | +| --- | --- | --- | --- | +| `DocumentAPI.downloadDocuments` | POST | `/documents/` | Скачать документы по `bundle_ids` (ответ `blob`) | + +### `lambdas` — Сервис экспорта + +| Ключ (метод API) | HTTP-метод | Путь (`url`) | Назначение | +| --- | --- | --- | --- | +| `fetchExportReviewsByResourceIDs` | GET | `/export-reviews/{params}` | Экспорт reviews в XLSX (ответ `blob`) | +| `fetchExportReview` | GET | `/export-reviews/{reviewId}/report/` | Экспорт отчёта по review в PDF (ответ `blob`) | + +### `sarexAgents` — Сервис AI-агентов + +| Ключ (метод API) | HTTP-метод | Путь (`url`) | Назначение | +| --- | --- | --- | --- | +| `AgentsAPI.suggestCopyPaths` | POST | `/runs/wait` | Запуск `router-agent` для подбора путей копирования | +| `AiCheckAPI.runValidator` | POST | `/runs/wait` | Запуск AI-проверки документов по чек-листу | +| `AiCheckAPI.getDocumentStorageStatus` | GET | `/files/documents/{documentId}/storage-status?tenant_id={tenantId}` | Статус загрузки документа в хранилище | +| `AiCheckAPI.uploadFileByUrl` | POST | `/files/upload/url` | Загрузить файл по URL в RAG-каталог | +| `AiCheckAPI.getEntitled` | GET | `/internal/tenant-agent-entitlements/{tenantId}/agents-status?user_id={userId}` | Доступность AI-агента для тенанта | + +> Запросы `/runs/wait` выполняются с увеличенным таймаутом `RUN_WAIT_TIMEOUT_MS = 600000` мс (10 минут) и с `showErrorNotification: false`. + +## Удалённый модуль (Module Federation) + +Помимо HTTP-API, `reviews-frontend` подключает удалённый микрофронтенд `documentations` через Module Federation (`module/api/module-hosts.ts`, функция `getModuleHost`): + +| Модуль | `stage` | `prod` | +| --- | --- | --- | +| `documentations` | `https://stage-modules.sarex.io/documentations/static/module/remoteEntry.js` | `https://modules.sarex.io/documentations/static/module/remoteEntry.js` | + +В `contour` путь относительный (`/documentations/static/module/remoteEntry.js`), в `preprod` — `https://modules.preprod.sarex.io/...`. + +## Аутентификация + +Токен и режим аутентификации обеспечиваются `@sarex-team/sdk-js`. В окружении `local` `httpService` переводится в режim `zitadel` (`setTypeOfHttpService("zitadel")`), а хост IdP берётся из `hosts.zitadel` (`https://idp.dev.stage.sarex.io` для stage, `https://login.sarex.io` для prod). В остальных окружениях используется режим по умолчанию SDK. + +## Обработка ошибок + +Отдельного модуля маппинга ошибок (аналогичного `errors.ts`) в `reviews-frontend` нет. Обработка ошибок выполняется в двух местах: + +- **SDK `@sarex-team/sdk-js`** — при `showErrorNotification: true` (значение по умолчанию для большинства запросов) показывает пользователю уведомление об ошибке. Для «тихих» запросов (AI-агенты, presigned-URL, часть фоновых вызовов) явно задаётся `showErrorNotification: false`. +- **Локальные `try/catch`** — в сторах (`resources.ts`, `users.ts`) и функциях экспорта (`fetchExportReviews*`) ошибки перехватываются и логируются через `console.error`, без проброса наверх. + +## Замечания + +- Пути (`url`) указываются **относительно** базового хоста сервиса, который уже содержит версионный префикс (`/api/v1`, `/api/v0` и т.п.). Это отличается от реестра `endpoints.ts` в некоторых других микрофронтендах, где префикс включается в путь эндпоинта. +- Файл `module/api/tranmittals.ts` назван с опечаткой (`tranmittals` вместо `transmittals`); экспорт при этом называется `TransmittalsAPI`. +- Сервис `sarex` в окружениях `stage`/`prod`/`preprod`/`contour` имеет пустой базовый хост (`""`) — запросы идут по относительным путям (через тот же origin/реверс-прокси); в `local` он указывает на `https://stage.sarex.io`. +- Хост сервиса `orchestrator` в `prod` содержит суффикс `/api` (`.../orchestrator/api`), тогда как в `stage`/`preprod` — без него (`.../orchestrator`); пути методов (`/process`, `/sign`) одинаковы.