From 78007b65e9207b2df28c6fc1bff31e47f813e44c Mon Sep 17 00:00:00 2001 From: Artem Gorlenko Date: Thu, 22 Oct 2026 10:53:02 +0500 Subject: [PATCH] init-commit --- .env.example | 59 + .env.production.example | 41 + .gitignore | 8 + README.md | 81 + apps/api/package.json | 40 + apps/api/src/app.module.ts | 87 + apps/api/src/common/access.service.ts | 40 + apps/api/src/common/audit.ts | 50 + apps/api/src/common/auth.ts | 123 + apps/api/src/common/geo.ts | 67 + apps/api/src/common/input.ts | 117 + apps/api/src/common/time.ts | 68 + apps/api/src/config.ts | 67 + apps/api/src/database/data-source.ts | 22 + .../database/migrations/1761000000000-Init.ts | 262 + .../migrations/1761100000000-Passport.ts | 61 + apps/api/src/entities/index.ts | 293 + apps/api/src/health.controller.ts | 16 + apps/api/src/main.ts | 25 + apps/api/src/modules/auth/auth.controller.ts | 108 + apps/api/src/modules/auth/auth.service.ts | 159 + .../src/modules/geocode/geocode.controller.ts | 14 + .../src/modules/geocode/geocode.service.ts | 95 + .../api/src/modules/media/media.controller.ts | 57 + apps/api/src/modules/media/media.service.ts | 141 + apps/api/src/modules/media/storage.service.ts | 99 + apps/api/src/modules/organizations/bin.ts | 12 + .../modules/organizations/company-registry.ts | 141 + .../organizations/organizations.service.ts | 126 + .../organizations/platform.controller.ts | 36 + .../src/modules/passport/aspans-id.client.ts | 96 + .../src/modules/passport/passport.client.ts | 236 + .../src/modules/reports/reports.controller.ts | 32 + .../src/modules/reports/reports.service.ts | 100 + .../src/modules/rounds/generator.service.ts | 117 + .../src/modules/rounds/rounds.controller.ts | 53 + apps/api/src/modules/rounds/rounds.service.ts | 372 + .../src/modules/routes/routes.controller.ts | 39 + apps/api/src/modules/routes/routes.service.ts | 202 + .../modules/schedules/schedules.controller.ts | 44 + .../modules/schedules/schedules.service.ts | 145 + .../api/src/modules/sites/sites.controller.ts | 38 + apps/api/src/modules/sites/sites.service.ts | 93 + apps/api/src/modules/users-sync/applier.ts | 103 + apps/api/src/modules/users-sync/event.ts | 104 + apps/api/src/modules/users-sync/kafka.ts | 42 + apps/api/src/modules/users-sync/profile.ts | 21 + apps/api/src/modules/users-sync/queue.ts | 86 + apps/api/src/modules/users-sync/snapshot.ts | 23 + .../api/src/modules/users-sync/sync.module.ts | 14 + apps/api/src/modules/users/people.service.ts | 125 + .../api/src/modules/users/users.controller.ts | 75 + apps/api/src/modules/users/users.service.ts | 251 + .../modules/webhooks/webhooks.controller.ts | 29 + .../src/modules/webhooks/webhooks.service.ts | 82 + apps/api/src/sync.ts | 144 + apps/api/tsconfig.build.json | 4 + apps/api/tsconfig.json | 18 + apps/web/index.html | 15 + apps/web/package.json | 37 + apps/web/public/icon-192.png | Bin 0 -> 23837 bytes apps/web/public/icon-512.png | Bin 0 -> 125375 bytes apps/web/public/icon.svg | 1 + apps/web/src/App.tsx | 83 + apps/web/src/api/client.ts | 89 + apps/web/src/api/types.ts | 184 + apps/web/src/auth/AuthContext.tsx | 154 + apps/web/src/auth/aspansId.ts | 199 + apps/web/src/guard/GuardApp.tsx | 269 + apps/web/src/guard/RoundScreen.tsx | 412 + apps/web/src/guard/data.ts | 110 + apps/web/src/guard/geo.ts | 27 + apps/web/src/guard/queue.ts | 183 + apps/web/src/guard/useQueue.ts | 4 + apps/web/src/lib/MediaGallery.tsx | 60 + apps/web/src/lib/PersonFields.tsx | 206 + apps/web/src/lib/SitePicker.tsx | 22 + apps/web/src/lib/StatusChip.tsx | 8 + apps/web/src/lib/format.ts | 52 + apps/web/src/lib/queries.ts | 25 + apps/web/src/main.tsx | 9 + apps/web/src/map/MapSearch.tsx | 132 + apps/web/src/map/PerimeterEditor.tsx | 84 + apps/web/src/map/RouteEditor.tsx | 173 + apps/web/src/map/base.tsx | 109 + apps/web/src/pages/AuthLayout.tsx | 29 + apps/web/src/pages/EmployeesPage.tsx | 450 + apps/web/src/pages/IntegrationsPage.tsx | 281 + apps/web/src/pages/LoginPage.tsx | 63 + apps/web/src/pages/PlatformPage.tsx | 122 + apps/web/src/pages/RegisterPage.tsx | 206 + apps/web/src/pages/ReportsPage.tsx | 209 + apps/web/src/pages/RoundDetailPage.tsx | 191 + apps/web/src/pages/RoutesPage.tsx | 440 + apps/web/src/pages/SchedulePage.tsx | 469 + apps/web/src/pages/SiteEditPage.tsx | 149 + apps/web/src/pages/SitesPage.tsx | 90 + apps/web/src/pages/SsoCallbackPage.tsx | 49 + apps/web/src/shell/Shell.tsx | 214 + apps/web/src/ui/Brand.tsx | 41 + apps/web/src/ui/feedback.tsx | 86 + apps/web/src/ui/layout.tsx | 111 + apps/web/src/ui/theme.ts | 105 + apps/web/tsconfig.json | 19 + apps/web/vite.config.ts | 58 + docker-compose.override.yml | 26 + docker-compose.prod.yml | 25 + docker-compose.yml | 128 + docker/Caddyfile | 6 + docker/api.Dockerfile | 23 + docker/nginx.conf | 47 + docker/web.Dockerfile | 17 + package-lock.json | 10532 ++++++++++++++++ package.json | 17 + packages/shared/package.json | 13 + packages/shared/src/index.ts | 111 + packages/shared/tsconfig.json | 13 + scripts/build-tiles.sh | 11 + scripts/server-check.sh | 84 + tiles/.gitkeep | 0 120 files changed, 22285 insertions(+) create mode 100644 .env.example create mode 100644 .env.production.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 apps/api/package.json create mode 100644 apps/api/src/app.module.ts create mode 100644 apps/api/src/common/access.service.ts create mode 100644 apps/api/src/common/audit.ts create mode 100644 apps/api/src/common/auth.ts create mode 100644 apps/api/src/common/geo.ts create mode 100644 apps/api/src/common/input.ts create mode 100644 apps/api/src/common/time.ts create mode 100644 apps/api/src/config.ts create mode 100644 apps/api/src/database/data-source.ts create mode 100644 apps/api/src/database/migrations/1761000000000-Init.ts create mode 100644 apps/api/src/database/migrations/1761100000000-Passport.ts create mode 100644 apps/api/src/entities/index.ts create mode 100644 apps/api/src/health.controller.ts create mode 100644 apps/api/src/main.ts create mode 100644 apps/api/src/modules/auth/auth.controller.ts create mode 100644 apps/api/src/modules/auth/auth.service.ts create mode 100644 apps/api/src/modules/geocode/geocode.controller.ts create mode 100644 apps/api/src/modules/geocode/geocode.service.ts create mode 100644 apps/api/src/modules/media/media.controller.ts create mode 100644 apps/api/src/modules/media/media.service.ts create mode 100644 apps/api/src/modules/media/storage.service.ts create mode 100644 apps/api/src/modules/organizations/bin.ts create mode 100644 apps/api/src/modules/organizations/company-registry.ts create mode 100644 apps/api/src/modules/organizations/organizations.service.ts create mode 100644 apps/api/src/modules/organizations/platform.controller.ts create mode 100644 apps/api/src/modules/passport/aspans-id.client.ts create mode 100644 apps/api/src/modules/passport/passport.client.ts create mode 100644 apps/api/src/modules/reports/reports.controller.ts create mode 100644 apps/api/src/modules/reports/reports.service.ts create mode 100644 apps/api/src/modules/rounds/generator.service.ts create mode 100644 apps/api/src/modules/rounds/rounds.controller.ts create mode 100644 apps/api/src/modules/rounds/rounds.service.ts create mode 100644 apps/api/src/modules/routes/routes.controller.ts create mode 100644 apps/api/src/modules/routes/routes.service.ts create mode 100644 apps/api/src/modules/schedules/schedules.controller.ts create mode 100644 apps/api/src/modules/schedules/schedules.service.ts create mode 100644 apps/api/src/modules/sites/sites.controller.ts create mode 100644 apps/api/src/modules/sites/sites.service.ts create mode 100644 apps/api/src/modules/users-sync/applier.ts create mode 100644 apps/api/src/modules/users-sync/event.ts create mode 100644 apps/api/src/modules/users-sync/kafka.ts create mode 100644 apps/api/src/modules/users-sync/profile.ts create mode 100644 apps/api/src/modules/users-sync/queue.ts create mode 100644 apps/api/src/modules/users-sync/snapshot.ts create mode 100644 apps/api/src/modules/users-sync/sync.module.ts create mode 100644 apps/api/src/modules/users/people.service.ts create mode 100644 apps/api/src/modules/users/users.controller.ts create mode 100644 apps/api/src/modules/users/users.service.ts create mode 100644 apps/api/src/modules/webhooks/webhooks.controller.ts create mode 100644 apps/api/src/modules/webhooks/webhooks.service.ts create mode 100644 apps/api/src/sync.ts create mode 100644 apps/api/tsconfig.build.json create mode 100644 apps/api/tsconfig.json create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/public/icon-192.png create mode 100644 apps/web/public/icon-512.png create mode 100644 apps/web/public/icon.svg create mode 100644 apps/web/src/App.tsx create mode 100644 apps/web/src/api/client.ts create mode 100644 apps/web/src/api/types.ts create mode 100644 apps/web/src/auth/AuthContext.tsx create mode 100644 apps/web/src/auth/aspansId.ts create mode 100644 apps/web/src/guard/GuardApp.tsx create mode 100644 apps/web/src/guard/RoundScreen.tsx create mode 100644 apps/web/src/guard/data.ts create mode 100644 apps/web/src/guard/geo.ts create mode 100644 apps/web/src/guard/queue.ts create mode 100644 apps/web/src/guard/useQueue.ts create mode 100644 apps/web/src/lib/MediaGallery.tsx create mode 100644 apps/web/src/lib/PersonFields.tsx create mode 100644 apps/web/src/lib/SitePicker.tsx create mode 100644 apps/web/src/lib/StatusChip.tsx create mode 100644 apps/web/src/lib/format.ts create mode 100644 apps/web/src/lib/queries.ts create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/map/MapSearch.tsx create mode 100644 apps/web/src/map/PerimeterEditor.tsx create mode 100644 apps/web/src/map/RouteEditor.tsx create mode 100644 apps/web/src/map/base.tsx create mode 100644 apps/web/src/pages/AuthLayout.tsx create mode 100644 apps/web/src/pages/EmployeesPage.tsx create mode 100644 apps/web/src/pages/IntegrationsPage.tsx create mode 100644 apps/web/src/pages/LoginPage.tsx create mode 100644 apps/web/src/pages/PlatformPage.tsx create mode 100644 apps/web/src/pages/RegisterPage.tsx create mode 100644 apps/web/src/pages/ReportsPage.tsx create mode 100644 apps/web/src/pages/RoundDetailPage.tsx create mode 100644 apps/web/src/pages/RoutesPage.tsx create mode 100644 apps/web/src/pages/SchedulePage.tsx create mode 100644 apps/web/src/pages/SiteEditPage.tsx create mode 100644 apps/web/src/pages/SitesPage.tsx create mode 100644 apps/web/src/pages/SsoCallbackPage.tsx create mode 100644 apps/web/src/shell/Shell.tsx create mode 100644 apps/web/src/ui/Brand.tsx create mode 100644 apps/web/src/ui/feedback.tsx create mode 100644 apps/web/src/ui/layout.tsx create mode 100644 apps/web/src/ui/theme.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts create mode 100644 docker-compose.override.yml create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 docker/Caddyfile create mode 100644 docker/api.Dockerfile create mode 100644 docker/nginx.conf create mode 100644 docker/web.Dockerfile create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/tsconfig.json create mode 100755 scripts/build-tiles.sh create mode 100755 scripts/server-check.sh create mode 100644 tiles/.gitkeep diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3505ffb --- /dev/null +++ b/.env.example @@ -0,0 +1,59 @@ +# Скопируйте в .env и поменяйте секреты. +COMPOSE_PROJECT_NAME=patrol + +POSTGRES_DB=patrol +POSTGRES_USER=patrol +POSTGRES_PASSWORD=patrol +DB_PORT=5470 + +S3_ACCESS_KEY=patrol +S3_SECRET_KEY=patrol-s3-secret +S3_BUCKET=patrol-media + +JWT_SECRET=change-me-to-a-long-random-string + +# ── Паспорт Aspans: люди и пароли. Значения — как в .env symfony. +# Контейнер паспорта в docker-сети aspans (ASPANS_NETWORK). +PASSPORT_URL=http://aspans_passport_nginx +PLATFORM_CODE=patrol +# PLATFORM_SALT паспорта (в symfony — PASSPORT_PLATFORM_TOKEN) +PASSPORT_PLATFORM_TOKEN= +# Логины паспорта, которые входят владельцами платформы (через запятую) +PLATFORM_ADMIN_USERNAMES=admin + +# ── SSO Aspans ID. client_id = PLATFORM_CODE. +ASPANS_ID_BASE_URL=https://id.aspans.com +# Секрет клиента patrol в Aspans ID (выдаёт тот, кто ведёт Aspans ID). Пусто — используется PLATFORM_CODE. +ASPANS_ID_CLIENT_SECRET= +# Адрес фронта для redirect_uri (если не удаётся взять из Origin браузера) +WEBSITE_URL=http://localhost:8090 + +# ── Синхронизация людей с паспортом через Kafka (docker compose --profile kafka up -d users_sync). +# Учётка: в проекте kafka — make service NAME=platform-patrol и права на общий префикс aspans. +# Пустой KAFKA_PASSWORD — шина выключена, правки копятся в users_sync_pending. +KAFKA_BROKERS=kafka-1:9092,kafka-2:9092,kafka-3:9092 +KAFKA_USERNAME=platform-patrol +KAFKA_PASSWORD= +USERS_SYNC_TOPIC=aspans.users.changed +USERS_SYNC_SOURCE=platform-patrol + +# Имена общих docker-сетей Aspans +ASPANS_NETWORK=aspans +KAFKA_NETWORK=kafka-net + +# Порт, на котором открывается приложение (web + /api + /tiles) +WEB_PORT=8090 + +# Откуда фронт берёт подложку карты. Своё значение — свой тайл-сервер. +# Пока файла карты нет, можно временно поставить https://tile.openstreetmap.org/{z}/{x}/{y}.png +VITE_TILE_URL=/tiles/styles/basic-preview/256/{z}/{x}/{y}.png + +# Реквизиты по БИН подтягиваются из pk.uchet.kz без токена (название, адрес, руководитель). +# Токен из личного кабинета pk.uchet.kz (раздел API-токены) добавляет телефон и email. Необязательно. +UCHET_KZ_TOKEN= + +# Поиск адреса на карте — Nominatim (OpenStreetMap), не чаще 1 запроса в секунду. +# Email попадает в User-Agent, как просят правила Nominatim. Свой сервер — NOMINATIM_URL. +NOMINATIM_EMAIL= +# true, когда сервис открыт по HTTPS +COOKIE_SECURE=false diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..0628b2a --- /dev/null +++ b/.env.production.example @@ -0,0 +1,41 @@ +# .env для сервера: cp .env.production.example .env и заполнить всё, где <...>. +# Случайные секреты: openssl rand -hex 32 +COMPOSE_PROJECT_NAME=patrol +COMPOSE_FILE=docker-compose.yml:docker-compose.prod.yml + +# Адрес сервиса. Без своего домена — sslip.io: IP 203.0.113.10 → 203-0-113-10.sslip.io +SITE_ADDRESS=.sslip.io +WEBSITE_URL=https://.sslip.io +COOKIE_SECURE=true + +POSTGRES_DB=patrol +POSTGRES_USER=patrol +POSTGRES_PASSWORD= +# Только 127.0.0.1 — для psql и резервных копий с самого сервера +DB_BIND=127.0.0.1 +DB_PORT=5470 + +S3_ACCESS_KEY=patrol +S3_SECRET_KEY= +S3_BUCKET=patrol-media + +JWT_SECRET= + +# Боевой паспорт Aspans +PASSPORT_URL=https://passport.aspans.com +PLATFORM_CODE=patrol +# PLATFORM_SALT боевого паспорта +PASSPORT_PLATFORM_TOKEN=<...> +PLATFORM_ADMIN_USERNAMES=admin + +# Aspans ID: в клиенте patrol должен быть разрешён redirect_uri ${WEBSITE_URL}/auth/sso/callback +ASPANS_ID_BASE_URL=https://id.aspans.com +ASPANS_ID_CLIENT_SECRET=<...> + +# Kafka на сервере пока не подключаем (users_sync не запускается) +KAFKA_PASSWORD= + +VITE_TILE_URL=/tiles/styles/basic-preview/256/{z}/{x}/{y}.png + +UCHET_KZ_TOKEN= +NOMINATIM_EMAIL= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e36adbc --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +.env +*.log +tiles/*.mbtiles +tiles/planetiler/ +.DS_Store +*.tsbuildinfo diff --git a/README.md b/README.md new file mode 100644 index 0000000..ddee54e --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# Aspans · Обходы + +Сервис обходов охраняемых объектов: периметры и маршруты на карте, график дежурств, мобильный экран сотрудника с офлайн-очередью, отчёты с фото, видео и геометками, вебхуки для интеграций. + +Стек: React 18 + TypeScript + MUI (Vite, PWA), NestJS 11 + TypeORM, PostgreSQL 17 + PostGIS, SeaweedFS (S3 API) для медиа, tileserver-gl для своей карты Казахстана. Всё поднимается через docker compose. + +## Запуск + +```bash +cp .env.example .env +./scripts/build-tiles.sh +docker compose up -d --build +``` + +Откройте http://localhost:8090 и войдите владельцем платформы — учётной записью паспорта Aspans из `PLATFORM_ADMIN_USERNAMES` (по умолчанию `admin`, с его паролем из паспорта). Компанию можно зарегистрировать там же («Организации») или самостоятельно на странице входа → «Зарегистрировать». + +Перед реальным использованием смените в `.env` `JWT_SECRET`, `POSTGRES_PASSWORD`, `S3_SECRET_KEY`. + +## Паспорт Aspans, Aspans ID и Kafka + +Люди и пароли живут в паспорте Aspans, «Обходы» — платформа `patrol` в нём. Организации, роли и доступ к объектам — свои, внутри «Обходов». + +1. В паспорте через интерфейс «Платформы и группы» создать платформу `patrol` (без домена) и её группу `patrol_user`; выложить метод `v1/users/findUsers`. +2. В `.env`: `PASSPORT_URL`, `PASSPORT_PLATFORM_TOKEN` (= PLATFORM_SALT паспорта), `ASPANS_ID_BASE_URL`. Локально паспорт — контейнер в docker-сети `aspans` (см. `docker-compose.override.yml`), на сервере — `https://passport.aspans.com`. +3. Синхронизация через Kafka (как у symfony): в проекте kafka + `make service NAME=platform-patrol` и `./bin/service.sh grant-shared platform-patrol aspans.`, + пароль — в `KAFKA_PASSWORD`, затем `docker compose --profile kafka up -d users_sync`. + +Как это работает: +- **Вход** — логином и паролем паспорта или через Aspans ID (SSO). Если человек уже вошёл в другой сервис Aspans, «Обходы» входят тихо (`prompt=none`). Пускают только тех, кто заведён в «Обходах» и состоит в организации с ролью; владельцы платформы — по `PLATFORM_ADMIN_USERNAMES`. +- **Новый сотрудник** заводится в паспорте (`createOrGetUserSimple`, группа `patrol_user`). **Существующий** — ищется в паспорте (`findUsers`, контакты замаскированы) и привязывается к платформе. Один человек — одна организация в «Обходах». +- **Профиль** (ФИО, email, телефон) правится в обе стороны через `aspans.users.changed`: правки из «Обходов» уходят событием `UserProfileUpsertedEvent` (очередь `users_sync_pending`), правки из паспорта применяются по версии (LWW). Пароль меняется сразу в паспорте. +- **Удаление**: убрали с платформы в паспорте (`UserRemovedFromPlatformEvent`) — доступ закрывается, сессии гаснут, человек пропадает из сотрудников; запись остаётся для отчётов. «Убрать из организации» в «Обходах» отвязывает человека от платформы в паспорте. + +`build-tiles.sh` один раз скачивает OSM-данные Казахстана и собирает `tiles/kazakhstan.mbtiles` (planetiler, нужно ~4 ГБ RAM, несколько минут). Без этого файла сервис работает, но карта будет пустой. Повторный запуск обновляет карту, после него — `docker compose restart tiles`. + +## Сервер + +Без своего домена сервис открывается по `https://.sslip.io`: sslip.io указывает на IP из имени, Caddy сам получает сертификат Let's Encrypt. HTTPS обязателен для телефонов охранников: без него браузер не даёт геолокацию и не включает офлайн-режим PWA. + +1. Проверить сервер (только читает): `ssh user@IP 'bash -s' < scripts/server-check.sh`. Нужно: Docker с `docker compose` v2, свободные 80 и 443 (и открытые в файрволе), от 2 ГБ RAM (сборка фронта), от 10 ГБ свободного места плюс место под фото и видео. +2. Склонировать репозиторий, `cp .env.production.example .env`, заполнить. +3. Карту на сервере **не собирать**: скопировать готовый файл — `scp tiles/kazakhstan.mbtiles user@IP:<папка проекта>/tiles/` (~0,5 ГБ). Сборка planetiler требует ~4 ГБ RAM и качает исходные данные. +4. `docker compose up -d --build` (`COMPOSE_FILE` из `.env` сам подключит `docker-compose.prod.yml`). +5. В Aspans ID у клиента `patrol` разрешить redirect_uri `https://.sslip.io/auth/sso/callback`. + +Наружу открыты только 80 и 443 (Caddy). База слушает 127.0.0.1, api, tiles и s3 доступны только внутри docker. + +## Порты + +| Что | Адрес | +|---|---| +| Веб и API | http://localhost:8090 (`/api/…`, карта — `/tiles/…`) | +| PostgreSQL | localhost:5470 | + +## Разработка без Docker для фронта + +```bash +npm install +docker compose up -d db s3 tiles api web +npm run dev:web +``` + +Vite на http://localhost:5173 проксирует `/api` и `/tiles` на :8090. + +## Устройство + +``` +apps/api NestJS: auth, organizations, users/roles, sites, routes, schedules, rounds, media, reports, webhooks +apps/web React: панель управления и экран сотрудника /guard (PWA, офлайн-очередь в IndexedDB) +packages/shared права, статусы, флаги отметок, события вебхуков — общие для фронта и API +docker/ Dockerfile'ы и nginx +scripts/ сборка тайлов карты +``` + +- Обходы создаются из графиков на 7 дней вперёд (cron раз в час) для дней, где назначены дежурные. Начать обход может любой дежурный этого дня. +- Незакрытые к концу окна обходы становятся «Пропущен» или «Не полностью». Отметки, снятые офлайн до конца окна и пришедшие позже, пересчитывают статус. +- GPS только записывается: отметку «я тут» сотрудник ставит на карте, координаты телефона сохраняются рядом. Расхождения видны в отчёте флагами и ничего не блокируют. +- Медиа грузятся кусками по 8 МБ через API в SeaweedFS и докачиваются после обрыва связи. +- Вебхуки: `POST` с JSON, подпись `X-Patrol-Signature: sha256=`; события `round.completed`, `round.partial`, `round.missed`, `visit.issue`. +- Реквизиты по БИН — из pk.uchet.kz: публичный поиск без токена даёт название, адрес и руководителя. С токеном из личного кабинета pk.uchet.kz (`UCHET_KZ_TOKEN` в `.env`) добавляются телефон и email. Выключить обращения к uchet.kz: `UCHET_KZ_DISABLED=true`. diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..ff74f10 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,40 @@ +{ + "name": "@patrol/api", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "start": "node dist/main.js", + "start:dev": "tsc-watch -p tsconfig.build.json --onSuccess \"node dist/main.js\"", + "migration:run": "typeorm migration:run -d dist/database/data-source.js", + "migration:revert": "typeorm migration:revert -d dist/database/data-source.js" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.700.0", + "@confluentinc/kafka-javascript": "^1.11.0", + "@nestjs/common": "^11.1.0", + "@nestjs/config": "^4.0.4", + "@nestjs/core": "^11.1.0", + "@nestjs/jwt": "^11.0.2", + "@nestjs/platform-express": "^11.1.0", + "@nestjs/schedule": "^5.0.1", + "@nestjs/typeorm": "^11.0.3", + "@patrol/shared": "*", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "cookie-parser": "^1.4.7", + "express": "^5.1.0", + "pg": "^8.13.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "typeorm": "0.3.31" + }, + "devDependencies": { + "@types/cookie-parser": "^1.4.8", + "@types/express": "^5.0.0", + "@types/node": "^22.10.0", + "tsc-watch": "^6.2.1", + "typescript": "^5.9.3" + } +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts new file mode 100644 index 0000000..18f5684 --- /dev/null +++ b/apps/api/src/app.module.ts @@ -0,0 +1,87 @@ +import { Module } from '@nestjs/common'; +import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; +import { JwtModule } from '@nestjs/jwt'; +import { ScheduleModule } from '@nestjs/schedule'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { config } from './config'; +import { dataSourceOptions } from './database/data-source'; +import { ENTITIES } from './entities'; +import { AccessService } from './common/access.service'; +import { AuditInterceptor } from './common/audit'; +import { AuthGuard } from './common/auth'; +import { HealthController } from './health.controller'; +import { AuthController } from './modules/auth/auth.controller'; +import { AuthService } from './modules/auth/auth.service'; +import { CompanyRegistry } from './modules/organizations/company-registry'; +import { OrganizationsService } from './modules/organizations/organizations.service'; +import { PlatformController } from './modules/organizations/platform.controller'; +import { UsersController } from './modules/users/users.controller'; +import { UsersService } from './modules/users/users.service'; +import { PeopleService } from './modules/users/people.service'; +import { PassportClient } from './modules/passport/passport.client'; +import { AspansIdClient } from './modules/passport/aspans-id.client'; +import { SitesController } from './modules/sites/sites.controller'; +import { SitesService } from './modules/sites/sites.service'; +import { RoutesController } from './modules/routes/routes.controller'; +import { RoutesService } from './modules/routes/routes.service'; +import { SchedulesController } from './modules/schedules/schedules.controller'; +import { SchedulesService } from './modules/schedules/schedules.service'; +import { GeneratorService } from './modules/rounds/generator.service'; +import { RoundsController } from './modules/rounds/rounds.controller'; +import { RoundsService } from './modules/rounds/rounds.service'; +import { MediaController } from './modules/media/media.controller'; +import { MediaService } from './modules/media/media.service'; +import { StorageService } from './modules/media/storage.service'; +import { ReportsController } from './modules/reports/reports.controller'; +import { ReportsService } from './modules/reports/reports.service'; +import { WebhooksController } from './modules/webhooks/webhooks.controller'; +import { WebhooksService } from './modules/webhooks/webhooks.service'; +import { GeocodeController } from './modules/geocode/geocode.controller'; +import { GeocodeService } from './modules/geocode/geocode.service'; + +// Один модуль на всё: сервис небольшой, а так проще следить за зависимостями. +@Module({ + imports: [ + TypeOrmModule.forRoot(dataSourceOptions), + TypeOrmModule.forFeature(ENTITIES), + JwtModule.register({ global: true, secret: config.jwtSecret }), + ScheduleModule.forRoot(), + ], + controllers: [ + HealthController, + AuthController, + PlatformController, + UsersController, + SitesController, + RoutesController, + SchedulesController, + RoundsController, + MediaController, + ReportsController, + WebhooksController, + GeocodeController, + ], + providers: [ + { provide: APP_GUARD, useClass: AuthGuard }, + { provide: APP_INTERCEPTOR, useClass: AuditInterceptor }, + AccessService, + AuthService, + CompanyRegistry, + OrganizationsService, + UsersService, + PeopleService, + PassportClient, + AspansIdClient, + SitesService, + RoutesService, + SchedulesService, + GeneratorService, + RoundsService, + StorageService, + MediaService, + ReportsService, + WebhooksService, + GeocodeService, + ], +}) +export class AppModule {} diff --git a/apps/api/src/common/access.service.ts b/apps/api/src/common/access.service.ts new file mode 100644 index 0000000..73d92ab --- /dev/null +++ b/apps/api/src/common/access.service.ts @@ -0,0 +1,40 @@ +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, IsNull, Repository } from 'typeorm'; +import { Site, UserSite } from '../entities'; +import { AuthUser, requireOrg } from './auth'; + +// Роль отвечает на «что можно», привязка user_sites — на «на каких объектах». +@Injectable() +export class AccessService { + constructor( + @InjectRepository(Site) private readonly sites: Repository, + @InjectRepository(UserSite) private readonly userSites: Repository, + ) {} + + async siteIds(user: AuthUser): Promise { + const orgId = requireOrg(user); + if (user.allSites) { + const rows = await this.sites.find({ select: { id: true }, where: { organizationId: orgId, archivedAt: IsNull() } }); + return rows.map((r) => r.id); + } + const links = await this.userSites.find({ where: { userId: user.id } }); + if (!links.length) return []; + const rows = await this.sites.find({ + select: { id: true }, + where: { id: In(links.map((l) => l.siteId)), organizationId: orgId, archivedAt: IsNull() }, + }); + return rows.map((r) => r.id); + } + + async site(user: AuthUser, siteId: string): Promise { + const orgId = requireOrg(user); + const site = await this.sites.findOne({ where: { id: siteId, organizationId: orgId } }); + if (!site) throw new NotFoundException('Объект не найден'); + if (!user.allSites) { + const link = await this.userSites.findOne({ where: { userId: user.id, siteId } }); + if (!link) throw new ForbiddenException('Нет доступа к этому объекту'); + } + return site; + } +} diff --git a/apps/api/src/common/audit.ts b/apps/api/src/common/audit.ts new file mode 100644 index 0000000..cfb4a72 --- /dev/null +++ b/apps/api/src/common/audit.ts @@ -0,0 +1,50 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Observable, tap } from 'rxjs'; +import { Repository } from 'typeorm'; +import { AuditLog } from '../entities'; +import type { AuthUser } from './auth'; + +const SECRET_KEYS = ['password', 'secret', 'token', 'refresh']; + +function scrub(value: unknown, depth = 0): unknown { + if (depth > 4 || value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.length > 50 ? `[${value.length} элементов]` : value.map((v) => scrub(v, depth + 1)); + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = SECRET_KEYS.some((s) => k.toLowerCase().includes(s)) ? '***' : scrub(v, depth + 1); + } + return out; +} + +// Пишем в журнал все изменяющие запросы авторизованных пользователей, кроме загрузки кусков файлов. +@Injectable() +export class AuditInterceptor implements NestInterceptor { + constructor(@InjectRepository(AuditLog) private readonly audit: Repository) {} + + intercept(ctx: ExecutionContext, next: CallHandler): Observable { + const req = ctx.switchToHttp().getRequest(); + const method: string = req.method; + if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) || /\/media\/[^/]+\/parts\//.test(req.path)) { + return next.handle(); + } + return next.handle().pipe( + tap((result: unknown) => { + const user: AuthUser | undefined = req.user; + if (!user) return; + const [, , entity, entityId] = String(req.route?.path || req.path).split('/'); + const resultId = result && typeof result === 'object' && 'id' in result ? String((result as { id: unknown }).id) : null; + this.audit + .insert({ + organizationId: user.organizationId, + userId: user.id, + action: `${method} ${req.route?.path || req.path}`, + entity: entity || null, + entityId: req.params?.id || resultId || (entityId && !entityId.startsWith(':') ? entityId : null), + changes: scrub(req.body) as object, + }) + .catch(() => undefined); + }), + ); + } +} diff --git a/apps/api/src/common/auth.ts b/apps/api/src/common/auth.ts new file mode 100644 index 0000000..7fb806f --- /dev/null +++ b/apps/api/src/common/auth.ts @@ -0,0 +1,123 @@ +import { + CanActivate, + createParamDecorator, + ExecutionContext, + ForbiddenException, + Injectable, + SetMetadata, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { JwtService } from '@nestjs/jwt'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import type { Permission } from '@patrol/shared'; +import { Organization, User } from '../entities'; + +export type AuthUser = { + id: string; + login: string | null; + fullName: string; + organizationId: string | null; + isPlatformAdmin: boolean; + roleId: string | null; + roleName: string | null; + allSites: boolean; + permissions: Set; +}; + +export type AccessTokenPayload = { sub: string; typ: 'access' }; + +const PUBLIC_KEY = 'isPublic'; +const PERMISSIONS_KEY = 'permissions'; +const PLATFORM_KEY = 'platformOnly'; + +export const Public = () => SetMetadata(PUBLIC_KEY, true); +export const RequirePermissions = (...permissions: Permission[]) => SetMetadata(PERMISSIONS_KEY, permissions); +export const PlatformOnly = () => SetMetadata(PLATFORM_KEY, true); + +export const CurrentUser = createParamDecorator((_: unknown, ctx: ExecutionContext): AuthUser => { + return ctx.switchToHttp().getRequest().user; +}); + +// Один глобальный гард: проверяет токен, грузит пользователя с ролью и проверяет права метода. +@Injectable() +export class AuthGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly jwt: JwtService, + @InjectRepository(User) private readonly users: Repository, + @InjectRepository(Organization) private readonly orgs: Repository, + ) {} + + async canActivate(ctx: ExecutionContext): Promise { + const targets = [ctx.getHandler(), ctx.getClass()]; + if (this.reflector.getAllAndOverride(PUBLIC_KEY, targets)) return true; + + const req = ctx.switchToHttp().getRequest(); + const header: string | undefined = req.headers.authorization; + const token = header?.startsWith('Bearer ') ? header.slice(7) : undefined; + if (!token) throw new UnauthorizedException('Нужно войти'); + + let payload: AccessTokenPayload; + try { + payload = await this.jwt.verifyAsync(token); + } catch { + throw new UnauthorizedException('Сессия закончилась'); + } + if (payload.typ !== 'access') throw new UnauthorizedException('Неверный токен'); + + const user = await this.users.findOne({ where: { id: payload.sub }, relations: { role: true } }); + if (!user) throw new UnauthorizedException('Пользователь не найден'); + const org = user.organizationId ? await this.orgs.findOne({ where: { id: user.organizationId } }) : null; + const problem = accessProblem(user, org); + if (problem) throw new UnauthorizedException(problem); + + const authUser: AuthUser = { + id: user.id, + login: user.login, + fullName: user.fullName, + organizationId: user.organizationId, + isPlatformAdmin: user.isPlatformAdmin, + roleId: user.roleId, + roleName: user.role?.name ?? null, + allSites: !!user.role?.allSites, + permissions: new Set(user.role?.permissions ?? []), + }; + req.user = authUser; + + if (this.reflector.getAllAndOverride(PLATFORM_KEY, targets) && !authUser.isPlatformAdmin) { + throw new ForbiddenException('Только для владельца платформы'); + } + + const required = this.reflector.getAllAndOverride(PERMISSIONS_KEY, targets) ?? []; + if (required.length) { + if (!authUser.organizationId) throw new ForbiddenException('Нужна организация'); + const missing = required.filter((p) => !authUser.permissions.has(p)); + if (missing.length) throw new ForbiddenException(`Недостаточно прав: ${missing.join(', ')}`); + } + return true; + } +} + +/** + * Можно ли человеку в patrol прямо сейчас. Одно правило для входа по паролю, SSO и каждого запроса: + * быть на платформе (не убран в паспорте), не отключён в паспорте и здесь, и состоять в организации + * с ролью — либо быть владельцем платформы. + */ +export function accessProblem(user: User, org: Organization | null): string | null { + if (user.removedAt) return 'Вы больше не подключены к «Обходам»'; + if (!user.passportEnabled) return 'Учётная запись отключена в Aspans ID'; + if (!user.isActive) return 'Доступ отключён администратором организации'; + if (user.isPlatformAdmin) return null; + if (!user.organizationId || !user.roleId || !org) { + return 'Вы не подключены ни к одной организации в «Обходах». Обратитесь к администратору своей организации'; + } + if (org.status !== 'active') return 'Организация заблокирована'; + return null; +} + +export function requireOrg(user: AuthUser): string { + if (!user.organizationId) throw new ForbiddenException('Действие доступно только сотрудникам организации'); + return user.organizationId; +} diff --git a/apps/api/src/common/geo.ts b/apps/api/src/common/geo.ts new file mode 100644 index 0000000..8b79d3c --- /dev/null +++ b/apps/api/src/common/geo.ts @@ -0,0 +1,67 @@ +import { BadRequestException } from '@nestjs/common'; +import type { GeoJsonLineString, GeoJsonMultiPolygon, GeoJsonPoint, LatLng } from '@patrol/shared'; + +const R = 6371008.8; + +export function distanceMeters(a: LatLng, b: LatLng): number { + const toRad = (d: number) => (d * Math.PI) / 180; + const dLat = toRad(b.lat - a.lat); + const dLng = toRad(b.lng - a.lng); + const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2; + return 2 * R * Math.asin(Math.sqrt(h)); +} + +export function pointToLatLng(p: GeoJsonPoint): LatLng { + return { lng: p.coordinates[0], lat: p.coordinates[1] }; +} + +export function latLngToPoint(p: LatLng): GeoJsonPoint { + return { type: 'Point', coordinates: [p.lng, p.lat] }; +} + +function isPosition(v: unknown): v is [number, number] { + return ( + Array.isArray(v) && + v.length >= 2 && + typeof v[0] === 'number' && + typeof v[1] === 'number' && + Math.abs(v[0]) <= 180 && + Math.abs(v[1]) <= 90 + ); +} + +export function assertLatLng(v: unknown, field: string): LatLng { + const p = v as LatLng; + if (!p || typeof p.lat !== 'number' || typeof p.lng !== 'number' || Math.abs(p.lat) > 90 || Math.abs(p.lng) > 180) { + throw new BadRequestException(`${field}: нужны координаты { lat, lng }`); + } + return { lat: p.lat, lng: p.lng }; +} + +export function assertPoint(v: unknown, field: string): GeoJsonPoint { + const g = v as GeoJsonPoint; + if (!g || g.type !== 'Point' || !isPosition(g.coordinates)) throw new BadRequestException(`${field}: нужен GeoJSON Point`); + return { type: 'Point', coordinates: [g.coordinates[0], g.coordinates[1]] }; +} + +export function assertLineString(v: unknown, field: string): GeoJsonLineString { + const g = v as GeoJsonLineString; + if (!g || g.type !== 'LineString' || !Array.isArray(g.coordinates) || g.coordinates.length < 2 || !g.coordinates.every(isPosition)) { + throw new BadRequestException(`${field}: нужна линия GeoJSON LineString минимум из 2 точек`); + } + return { type: 'LineString', coordinates: g.coordinates.map((c) => [c[0], c[1]]) }; +} + +// Фронт может прислать и Polygon, и MultiPolygon — храним всегда MultiPolygon. +export function assertMultiPolygon(v: unknown, field: string): GeoJsonMultiPolygon { + const g = v as { type?: string; coordinates?: unknown }; + const ringOk = (ring: unknown) => Array.isArray(ring) && ring.length >= 4 && ring.every(isPosition); + const polyOk = (poly: unknown) => Array.isArray(poly) && poly.length >= 1 && poly.every(ringOk); + if (g?.type === 'Polygon' && polyOk(g.coordinates)) { + return { type: 'MultiPolygon', coordinates: [g.coordinates as [number, number][][]] }; + } + if (g?.type === 'MultiPolygon' && Array.isArray(g.coordinates) && g.coordinates.length >= 1 && g.coordinates.every(polyOk)) { + return { type: 'MultiPolygon', coordinates: g.coordinates as [number, number][][][] }; + } + throw new BadRequestException(`${field}: нужен GeoJSON Polygon или MultiPolygon`); +} diff --git a/apps/api/src/common/input.ts b/apps/api/src/common/input.ts new file mode 100644 index 0000000..ca1032f --- /dev/null +++ b/apps/api/src/common/input.ts @@ -0,0 +1,117 @@ +import { BadRequestException } from '@nestjs/common'; + +// Небольшие проверки входных данных: короче DTO-классов и с понятными русскими ошибками. + +type Obj = Record; + +export function obj(v: unknown, field = 'body'): Obj { + if (!v || typeof v !== 'object' || Array.isArray(v)) throw new BadRequestException(`${field}: нужен объект`); + return v as Obj; +} + +export function str(o: Obj, key: string, opts: { required?: boolean; max?: number; min?: number } = {}): string | undefined { + const v = o[key]; + if (v === undefined || v === null || v === '') { + if (opts.required) throw new BadRequestException(`Поле «${key}» обязательно`); + return undefined; + } + if (typeof v !== 'string') throw new BadRequestException(`Поле «${key}» должно быть строкой`); + const s = v.trim(); + if (opts.required && !s) throw new BadRequestException(`Поле «${key}» обязательно`); + if (opts.min && s.length < opts.min) throw new BadRequestException(`Поле «${key}»: минимум ${opts.min} символов`); + if (s.length > (opts.max ?? 2000)) throw new BadRequestException(`Поле «${key}» слишком длинное`); + return s; +} + +export function reqStr(o: Obj, key: string, opts: { max?: number; min?: number } = {}): string { + return str(o, key, { ...opts, required: true }) as string; +} + +export function int(o: Obj, key: string, opts: { required?: boolean; min?: number; max?: number } = {}): number | undefined { + const v = o[key]; + if (v === undefined || v === null || v === '') { + if (opts.required) throw new BadRequestException(`Поле «${key}» обязательно`); + return undefined; + } + const n = typeof v === 'string' ? Number(v) : v; + if (typeof n !== 'number' || !Number.isInteger(n)) throw new BadRequestException(`Поле «${key}» должно быть целым числом`); + if (opts.min !== undefined && n < opts.min) throw new BadRequestException(`Поле «${key}»: не меньше ${opts.min}`); + if (opts.max !== undefined && n > opts.max) throw new BadRequestException(`Поле «${key}»: не больше ${opts.max}`); + return n; +} + +export function bool(o: Obj, key: string): boolean | undefined { + const v = o[key]; + if (v === undefined || v === null) return undefined; + if (typeof v !== 'boolean') throw new BadRequestException(`Поле «${key}» должно быть true/false`); + return v; +} + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function isUuid(v: unknown): v is string { + return typeof v === 'string' && UUID_RE.test(v); +} + +export function uuid(o: Obj, key: string, opts: { required?: boolean } = {}): string | undefined { + const v = o[key]; + if (v === undefined || v === null || v === '') { + if (opts.required) throw new BadRequestException(`Поле «${key}» обязательно`); + return undefined; + } + if (!isUuid(v)) throw new BadRequestException(`Поле «${key}»: нужен uuid`); + return v; +} + +export function uuidArray(o: Obj, key: string): string[] | undefined { + const v = o[key]; + if (v === undefined || v === null) return undefined; + if (!Array.isArray(v) || !v.every(isUuid)) throw new BadRequestException(`Поле «${key}»: нужен массив uuid`); + return Array.from(new Set(v)); +} + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +export function date(o: Obj, key: string, opts: { required?: boolean } = {}): string | undefined { + const v = o[key]; + if (v === undefined || v === null || v === '') { + if (opts.required) throw new BadRequestException(`Поле «${key}» обязательно`); + return undefined; + } + if (typeof v !== 'string' || !DATE_RE.test(v) || Number.isNaN(Date.parse(`${v}T00:00:00Z`))) { + throw new BadRequestException(`Поле «${key}»: дата в формате ГГГГ-ММ-ДД`); + } + return v; +} + +export function isDateString(v: unknown): v is string { + return typeof v === 'string' && DATE_RE.test(v); +} + +export function timestamp(o: Obj, key: string, opts: { required?: boolean } = {}): Date | undefined { + const v = o[key]; + if (v === undefined || v === null || v === '') { + if (opts.required) throw new BadRequestException(`Поле «${key}» обязательно`); + return undefined; + } + const d = new Date(String(v)); + if (Number.isNaN(d.getTime())) throw new BadRequestException(`Поле «${key}»: неверная дата и время`); + return d; +} + +export function oneOf(o: Obj, key: string, values: readonly T[], opts: { required?: boolean } = {}): T | undefined { + const v = o[key]; + if (v === undefined || v === null || v === '') { + if (opts.required) throw new BadRequestException(`Поле «${key}» обязательно`); + return undefined; + } + if (!values.includes(v as T)) throw new BadRequestException(`Поле «${key}»: одно из ${values.join(', ')}`); + return v as T; +} + +export function strArray(o: Obj, key: string): string[] | undefined { + const v = o[key]; + if (v === undefined || v === null) return undefined; + if (!Array.isArray(v) || !v.every((x) => typeof x === 'string')) throw new BadRequestException(`Поле «${key}»: нужен массив строк`); + return v as string[]; +} diff --git a/apps/api/src/common/time.ts b/apps/api/src/common/time.ts new file mode 100644 index 0000000..ad009d0 --- /dev/null +++ b/apps/api/src/common/time.ts @@ -0,0 +1,68 @@ +// Перевод «дата + время в поясе объекта» в UTC без сторонних библиотек. + +const formatters = new Map(); + +function formatter(tz: string): Intl.DateTimeFormat { + let f = formatters.get(tz); + if (!f) { + f = new Intl.DateTimeFormat('en-US', { + timeZone: tz, + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + formatters.set(tz, f); + } + return f; +} + +function parts(ms: number, tz: string): Record { + const out: Record = {}; + for (const p of formatter(tz).formatToParts(new Date(ms))) { + if (p.type !== 'literal') out[p.type] = Number(p.value); + } + return out; +} + +// Смещение пояса от UTC в миллисекундах в момент ms. +function offsetMs(ms: number, tz: string): number { + const p = parts(ms, tz); + const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second); + return asUtc - Math.floor(ms / 1000) * 1000; +} + +export function isValidTimeZone(tz: string): boolean { + try { + formatter(tz); + return true; + } catch { + return false; + } +} + +export function zonedToUtc(date: string, time: string, tz: string): Date { + const [y, m, d] = date.split('-').map(Number); + const [hh, mm] = time.split(':').map(Number); + const guess = Date.UTC(y, m - 1, d, hh, mm); + let result = guess - offsetMs(guess, tz); + const second = offsetMs(result, tz); + result = guess - second; + return new Date(result); +} + +export function localDate(ms: number, tz: string): string { + const p = parts(ms, tz); + return `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`; +} + +export function addDays(date: string, days: number): string { + const d = new Date(`${date}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} + +export const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/; diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts new file mode 100644 index 0000000..2067f2d --- /dev/null +++ b/apps/api/src/config.ts @@ -0,0 +1,67 @@ +// Все настройки — из переменных окружения (docker compose передаёт их из .env). + +function required(name: string, fallback?: string): string { + const value = process.env[name] ?? fallback; + if (value === undefined || value === '') throw new Error(`Не задана переменная окружения ${name}`); + return value; +} + +export const config = { + port: Number(process.env.PORT || 3000), + db: { + host: process.env.DB_HOST || 'localhost', + port: Number(process.env.DB_PORT || 5432), + database: process.env.POSTGRES_DB || 'patrol', + user: process.env.POSTGRES_USER || 'patrol', + password: process.env.POSTGRES_PASSWORD || 'patrol', + }, + jwtSecret: required('JWT_SECRET', process.env.NODE_ENV === 'production' ? undefined : 'dev-secret'), + accessTtlSeconds: 15 * 60, + refreshTtlDays: 30, + mediaTokenTtlSeconds: 60 * 60, + s3: { + endpoint: process.env.S3_ENDPOINT || 'http://localhost:9000', + region: process.env.S3_REGION || 'us-east-1', + accessKey: process.env.S3_ACCESS_KEY || 'patrol', + secretKey: process.env.S3_SECRET_KEY || 'patrol-s3-secret', + bucket: process.env.S3_BUCKET || 'patrol-media', + }, + // Паспорт Aspans — единственный источник людей и паролей. Платформенные вызовы подписываются + // md5(platform_code-timestamp-token), token = PLATFORM_SALT паспорта (как PASSPORT_PLATFORM_TOKEN в symfony). + passport: { + url: (process.env.PASSPORT_URL || '').replace(/\/+$/, ''), + platformCode: process.env.PLATFORM_CODE || 'patrol', + token: process.env.PASSPORT_PLATFORM_TOKEN || '', + // Единственная группа patrol в паспорте: без группы паспорт не пускает на платформу. + groupCode: process.env.PASSPORT_GROUP_CODE || 'patrol_user', + }, + // SSO Aspans ID: OAuth2 + PKCE. client_id и секрет — код платформы (договорённость, как в symfony). + aspansId: { + baseUrl: (process.env.ASPANS_ID_BASE_URL || '').replace(/\/+$/, ''), + clientId: process.env.ASPANS_ID_CLIENT_ID || process.env.PLATFORM_CODE || 'patrol', + // Секрет OAuth-клиента в Aspans ID. Не задан — код платформы (так у symfony: клиент aspans, секрет aspans). + clientSecret: process.env.ASPANS_ID_CLIENT_SECRET || process.env.PLATFORM_CODE || 'patrol', + callbackPath: process.env.ASPANS_ID_CALLBACK_PATH || '/auth/sso/callback', + scope: process.env.ASPANS_ID_SCOPE || 'openid profile email groups platforms', + // Публичный адрес фронта — для redirect_uri, если Origin запроса не подходит. + websiteUrl: (process.env.WEBSITE_URL || '').replace(/\/+$/, ''), + }, + // Владельцы платформы — логины паспорта. Им не нужна организация в patrol. + platformAdminUsernames: (process.env.PLATFORM_ADMIN_USERNAMES || 'admin') + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean), + // Синхронизация пользователей с паспортом через Kafka (топик aspans.users.changed). + // Пустые KAFKA_BROKERS / KAFKA_USERNAME / KAFKA_PASSWORD — шина выключена, правки копятся в users_sync_pending. + kafka: { + brokers: process.env.KAFKA_BROKERS || '', + username: process.env.KAFKA_USERNAME || '', + password: process.env.KAFKA_PASSWORD || '', + topic: process.env.USERS_SYNC_TOPIC || 'aspans.users.changed', + source: process.env.USERS_SYNC_SOURCE || 'platform-patrol', + }, + // Сколько дней вперёд генерировать обходы из графика. + generateDaysAhead: 7, + cookieSecure: process.env.COOKIE_SECURE === 'true', + disableCron: process.env.DISABLE_CRON === 'true', +}; diff --git a/apps/api/src/database/data-source.ts b/apps/api/src/database/data-source.ts new file mode 100644 index 0000000..ce02540 --- /dev/null +++ b/apps/api/src/database/data-source.ts @@ -0,0 +1,22 @@ +import 'reflect-metadata'; +import { DataSource, DataSourceOptions } from 'typeorm'; +import { config } from '../config'; +import { ENTITIES } from '../entities'; +import { Init1761000000000 } from './migrations/1761000000000-Init'; +import { Passport1761100000000 } from './migrations/1761100000000-Passport'; + +export const dataSourceOptions: DataSourceOptions = { + type: 'postgres', + host: config.db.host, + port: config.db.port, + database: config.db.database, + username: config.db.user, + password: config.db.password, + entities: ENTITIES, + migrations: [Init1761000000000, Passport1761100000000], + synchronize: false, + migrationsRun: true, +}; + +// Для CLI typeorm (npm run migration:revert) +export default new DataSource(dataSourceOptions); diff --git a/apps/api/src/database/migrations/1761000000000-Init.ts b/apps/api/src/database/migrations/1761000000000-Init.ts new file mode 100644 index 0000000..562eb2e --- /dev/null +++ b/apps/api/src/database/migrations/1761000000000-Init.ts @@ -0,0 +1,262 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class Init1761000000000 implements MigrationInterface { + name = 'Init1761000000000'; + + public async up(q: QueryRunner): Promise { + await q.query(`CREATE EXTENSION IF NOT EXISTS postgis`); + await q.query(`CREATE EXTENSION IF NOT EXISTS pgcrypto`); + + await q.query(` + CREATE TABLE organizations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + bin char(12) NOT NULL UNIQUE, + name text NOT NULL, + legal_name text, + legal_address text, + director_name text, + phone text, + email text, + registry_data jsonb, + registry_synced_at timestamptz, + status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'blocked')), + created_at timestamptz NOT NULL DEFAULT now() + )`); + + await q.query(` + CREATE TABLE roles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + code text NOT NULL, + name text NOT NULL, + is_system boolean NOT NULL DEFAULT false, + all_sites boolean NOT NULL DEFAULT false, + permissions text[] NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (organization_id, code) + )`); + + await q.query(` + CREATE TABLE users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid REFERENCES organizations(id) ON DELETE CASCADE, + login text NOT NULL UNIQUE, + password_hash text NOT NULL, + full_name text NOT NULL, + phone text, + role_id uuid REFERENCES roles(id) ON DELETE SET NULL, + is_active boolean NOT NULL DEFAULT true, + is_platform_admin boolean NOT NULL DEFAULT false, + last_login_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (is_platform_admin OR organization_id IS NOT NULL) + )`); + await q.query(`CREATE INDEX users_org_idx ON users (organization_id)`); + + await q.query(` + CREATE TABLE user_sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + refresh_hash text NOT NULL UNIQUE, + user_agent text, + expires_at timestamptz NOT NULL, + revoked_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() + )`); + + await q.query(` + CREATE TABLE audit_log ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid REFERENCES organizations(id) ON DELETE CASCADE, + user_id uuid REFERENCES users(id) ON DELETE SET NULL, + action text NOT NULL, + entity text, + entity_id text, + changes jsonb, + created_at timestamptz NOT NULL DEFAULT now() + )`); + await q.query(`CREATE INDEX audit_log_org_created_idx ON audit_log (organization_id, created_at DESC)`); + + await q.query(` + CREATE TABLE sites ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name text NOT NULL, + address text, + description text, + perimeter geometry(MultiPolygon, 4326), + center geometry(Point, 4326), + timezone text NOT NULL DEFAULT 'Asia/Almaty', + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + archived_at timestamptz + )`); + await q.query(`CREATE INDEX sites_org_idx ON sites (organization_id)`); + await q.query(`CREATE INDEX sites_perimeter_gix ON sites USING gist (perimeter)`); + + await q.query(` + CREATE TABLE user_sites ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + site_id uuid NOT NULL REFERENCES sites(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, site_id) + )`); + + await q.query(` + CREATE TABLE routes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + site_id uuid NOT NULL REFERENCES sites(id) ON DELETE CASCADE, + name text NOT NULL, + description text, + path geometry(LineString, 4326), + expected_minutes int, + strict_order boolean NOT NULL DEFAULT false, + version int NOT NULL DEFAULT 1, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + archived_at timestamptz + )`); + await q.query(`CREATE INDEX routes_site_idx ON routes (site_id)`); + + await q.query(` + CREATE TABLE checkpoints ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + route_id uuid NOT NULL REFERENCES routes(id) ON DELETE CASCADE, + position int NOT NULL, + name text NOT NULL, + location geometry(Point, 4326) NOT NULL, + radius_m int NOT NULL DEFAULT 30 CHECK (radius_m > 0), + media_required text NOT NULL DEFAULT 'photo' CHECK (media_required IN ('none', 'photo', 'video', 'photo_or_video')), + instruction text, + archived_at timestamptz + )`); + await q.query(`CREATE INDEX checkpoints_route_idx ON checkpoints (route_id, position)`); + + await q.query(` + CREATE TABLE patrol_schedules ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + site_id uuid NOT NULL REFERENCES sites(id) ON DELETE CASCADE, + route_id uuid NOT NULL REFERENCES routes(id) ON DELETE CASCADE, + name text NOT NULL, + start_times text[] NOT NULL, + window_minutes int NOT NULL DEFAULT 60 CHECK (window_minutes > 0), + late_tolerance_minutes int NOT NULL DEFAULT 15 CHECK (late_tolerance_minutes >= 0), + valid_from date NOT NULL, + valid_to date, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now() + )`); + await q.query(`CREATE INDEX patrol_schedules_site_idx ON patrol_schedules (site_id)`); + + await q.query(` + CREATE TABLE schedule_assignments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + schedule_id uuid NOT NULL REFERENCES patrol_schedules(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + date date NOT NULL, + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (schedule_id, user_id, date) + )`); + await q.query(`CREATE INDEX schedule_assignments_user_date_idx ON schedule_assignments (user_id, date)`); + + await q.query(` + CREATE TABLE rounds ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + site_id uuid NOT NULL REFERENCES sites(id) ON DELETE CASCADE, + route_id uuid NOT NULL REFERENCES routes(id) ON DELETE CASCADE, + schedule_id uuid REFERENCES patrol_schedules(id) ON DELETE SET NULL, + duty_date date, + performed_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + planned_start timestamptz NOT NULL, + planned_end timestamptz NOT NULL, + late_tolerance_minutes int NOT NULL DEFAULT 15, + started_at timestamptz, + finished_at timestamptz, + status text NOT NULL DEFAULT 'planned' + CHECK (status IN ('planned', 'in_progress', 'completed', 'partial', 'missed', 'cancelled')), + route_snapshot jsonb NOT NULL, + checkpoints_total int NOT NULL DEFAULT 0, + checkpoints_done int NOT NULL DEFAULT 0, + comment text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + )`); + await q.query(`CREATE UNIQUE INDEX rounds_schedule_start_uq ON rounds (schedule_id, planned_start) WHERE schedule_id IS NOT NULL`); + await q.query(`CREATE INDEX rounds_site_start_idx ON rounds (site_id, planned_start)`); + await q.query(`CREATE INDEX rounds_status_end_idx ON rounds (status, planned_end)`); + + await q.query(` + CREATE TABLE checkpoint_visits ( + id uuid PRIMARY KEY, + round_id uuid NOT NULL REFERENCES rounds(id) ON DELETE CASCADE, + checkpoint_id uuid REFERENCES checkpoints(id) ON DELETE SET NULL, + user_id uuid NOT NULL REFERENCES users(id), + marked_location geometry(Point, 4326) NOT NULL, + device_location geometry(Point, 4326), + device_accuracy_m real, + distance_to_checkpoint_m real, + gps_distance_m real, + flags text[] NOT NULL DEFAULT '{}', + note text, + is_issue boolean NOT NULL DEFAULT false, + client_created_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() + )`); + await q.query(`CREATE INDEX checkpoint_visits_round_idx ON checkpoint_visits (round_id)`); + + await q.query(` + CREATE TABLE media ( + id uuid PRIMARY KEY, + organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + uploaded_by uuid NOT NULL REFERENCES users(id), + visit_id uuid REFERENCES checkpoint_visits(id) ON DELETE SET NULL, + kind text NOT NULL CHECK (kind IN ('photo', 'video')), + mime text NOT NULL, + size_bytes bigint NOT NULL, + filename text, + storage_key text NOT NULL, + upload_id text, + parts jsonb NOT NULL DEFAULT '[]', + status text NOT NULL DEFAULT 'uploading' CHECK (status IN ('uploading', 'ready')), + captured_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() + )`); + await q.query(`CREATE INDEX media_visit_idx ON media (visit_id)`); + + await q.query(` + CREATE TABLE webhooks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + url text NOT NULL, + events text[] NOT NULL, + secret text NOT NULL, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now() + )`); + } + + public async down(q: QueryRunner): Promise { + for (const table of [ + 'webhooks', + 'media', + 'checkpoint_visits', + 'rounds', + 'schedule_assignments', + 'patrol_schedules', + 'checkpoints', + 'routes', + 'user_sites', + 'sites', + 'audit_log', + 'user_sessions', + 'users', + 'roles', + 'organizations', + ]) { + await q.query(`DROP TABLE IF EXISTS ${table}`); + } + } +} diff --git a/apps/api/src/database/migrations/1761100000000-Passport.ts b/apps/api/src/database/migrations/1761100000000-Passport.ts new file mode 100644 index 0000000..c151889 --- /dev/null +++ b/apps/api/src/database/migrations/1761100000000-Passport.ts @@ -0,0 +1,61 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Люди — из паспорта Aspans. + * + * Вход и пароли переезжают в паспорт (и Aspans ID), локальные пароли больше не нужны. + * Пользователь patrol = человек паспорта (passport_uuid) + организация, роль и объекты здесь. + * + * users_sync_pending / users_sync_state — та же схема, что groups_sync_* в паспорте и symfony: + * очередь «что отправить в aspans.users.changed» и версия последней правки (LWW) по (kind, ref). + */ +export class Passport1761100000000 implements MigrationInterface { + name = 'Passport1761100000000'; + + async up(q: QueryRunner): Promise { + await q.query(`ALTER TABLE users DROP CONSTRAINT IF EXISTS users_check`); + await q.query(`ALTER TABLE users DROP CONSTRAINT IF EXISTS users_login_key`); + await q.query(`ALTER TABLE users ALTER COLUMN login DROP NOT NULL`); + await q.query(`ALTER TABLE users DROP COLUMN IF EXISTS password_hash`); + await q.query(` + ALTER TABLE users + ADD COLUMN passport_uuid uuid UNIQUE, + ADD COLUMN last_name text, + ADD COLUMN first_name text, + ADD COLUMN middle_name text, + ADD COLUMN email text, + ADD COLUMN passport_enabled boolean NOT NULL DEFAULT true, + ADD COLUMN removed_at timestamptz`); + // Локальные учётки без паспорта войти больше не смогут — помечаем их, чтобы не путали со своими. + await q.query(`UPDATE users SET removed_at = now(), is_active = false WHERE passport_uuid IS NULL`); + + await q.query(` + CREATE TABLE users_sync_pending ( + kind text NOT NULL, + ref text NOT NULL, + changed_at timestamptz NOT NULL DEFAULT clock_timestamp(), + attempts int NOT NULL DEFAULT 0, + last_error text, + next_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (kind, ref) + )`); + await q.query(`CREATE INDEX users_sync_pending_next_idx ON users_sync_pending (next_at)`); + await q.query(` + CREATE TABLE users_sync_state ( + kind text NOT NULL, + ref text NOT NULL, + version timestamptz NOT NULL, + PRIMARY KEY (kind, ref) + )`); + } + + async down(q: QueryRunner): Promise { + await q.query(`DROP TABLE users_sync_state`); + await q.query(`DROP TABLE users_sync_pending`); + await q.query(` + ALTER TABLE users + DROP COLUMN passport_uuid, DROP COLUMN last_name, DROP COLUMN first_name, DROP COLUMN middle_name, + DROP COLUMN email, DROP COLUMN passport_enabled, DROP COLUMN removed_at`); + await q.query(`ALTER TABLE users ADD COLUMN password_hash text NOT NULL DEFAULT ''`); + } +} diff --git a/apps/api/src/entities/index.ts b/apps/api/src/entities/index.ts new file mode 100644 index 0000000..f552f49 --- /dev/null +++ b/apps/api/src/entities/index.ts @@ -0,0 +1,293 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryColumn, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from 'typeorm'; +import type { + GeoJsonLineString, + GeoJsonMultiPolygon, + GeoJsonPoint, + MediaRequirement, + RoundStatus, +} from '@patrol/shared'; + +// TypeORM сам переводит geometry в GeoJSON при чтении и обратно при записи. + +@Entity('organizations') +export class Organization { + @PrimaryGeneratedColumn('uuid') id: string; + @Column({ type: 'char', length: 12, unique: true }) bin: string; + @Column('text') name: string; + @Column({ type: 'text', name: 'legal_name', nullable: true }) legalName: string | null; + @Column({ type: 'text', name: 'legal_address', nullable: true }) legalAddress: string | null; + @Column({ type: 'text', name: 'director_name', nullable: true }) directorName: string | null; + @Column({ type: 'text', nullable: true }) phone: string | null; + @Column({ type: 'text', nullable: true }) email: string | null; + @Column({ type: 'jsonb', name: 'registry_data', nullable: true }) registryData: unknown | null; + @Column({ type: 'timestamptz', name: 'registry_synced_at', nullable: true }) registrySyncedAt: Date | null; + @Column({ type: 'text', default: 'active' }) status: 'active' | 'blocked'; + @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; +} + +@Entity('roles') +export class Role { + @PrimaryGeneratedColumn('uuid') id: string; + @Column({ type: 'uuid', name: 'organization_id' }) organizationId: string; + @Column('text') code: string; + @Column('text') name: string; + @Column({ type: 'boolean', name: 'is_system', default: false }) isSystem: boolean; + @Column({ type: 'boolean', name: 'all_sites', default: false }) allSites: boolean; + @Column({ type: 'text', array: true, default: () => "'{}'" }) permissions: string[]; + @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; +} + +@Entity('users') +export class User { + @PrimaryGeneratedColumn('uuid') id: string; + @Column({ type: 'uuid', name: 'organization_id', nullable: true }) organizationId: string | null; + // Человек из паспорта. Пароли и вход — только через паспорт / Aspans ID. + @Column({ type: 'uuid', name: 'passport_uuid', unique: true, nullable: true }) passportUuid: string | null; + // Логин паспорта (username) — для отображения. + @Column({ type: 'text', nullable: true }) login: string | null; + @Column({ type: 'text', name: 'last_name', nullable: true }) lastName: string | null; + @Column({ type: 'text', name: 'first_name', nullable: true }) firstName: string | null; + @Column({ type: 'text', name: 'middle_name', nullable: true }) middleName: string | null; + @Column({ type: 'text', name: 'full_name' }) fullName: string; + @Column({ type: 'text', nullable: true }) email: string | null; + @Column({ type: 'text', nullable: true }) phone: string | null; + @Column({ type: 'uuid', name: 'role_id', nullable: true }) roleId: string | null; + @ManyToOne(() => Role, { nullable: true }) + @JoinColumn({ name: 'role_id' }) + role: Role | null; + // Доступ в patrol (выключает администратор организации). Паспорт этим не управляет. + @Column({ type: 'boolean', name: 'is_active', default: true }) isActive: boolean; + // is_enabled из паспорта: отключили человека в паспорте — не пускаем и сюда. + @Column({ type: 'boolean', name: 'passport_enabled', default: true }) passportEnabled: boolean; + // Убрали с платформы patrol в паспорте (или удалили из организации здесь). Строку не удаляем: + // на человека ссылаются обходы, отметки и фото в отчётах. + @Column({ type: 'timestamptz', name: 'removed_at', nullable: true }) removedAt: Date | null; + @Column({ type: 'boolean', name: 'is_platform_admin', default: false }) isPlatformAdmin: boolean; + @Column({ type: 'timestamptz', name: 'last_login_at', nullable: true }) lastLoginAt: Date | null; + @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; + @UpdateDateColumn({ type: 'timestamptz', name: 'updated_at' }) updatedAt: Date; +} + +@Entity('user_sites') +export class UserSite { + @PrimaryColumn({ type: 'uuid', name: 'user_id' }) userId: string; + @PrimaryColumn({ type: 'uuid', name: 'site_id' }) siteId: string; +} + +@Entity('user_sessions') +export class UserSession { + @PrimaryGeneratedColumn('uuid') id: string; + @Column({ type: 'uuid', name: 'user_id' }) userId: string; + @Column({ type: 'text', name: 'refresh_hash' }) refreshHash: string; + @Column({ type: 'text', name: 'user_agent', nullable: true }) userAgent: string | null; + @Column({ type: 'timestamptz', name: 'expires_at' }) expiresAt: Date; + @Column({ type: 'timestamptz', name: 'revoked_at', nullable: true }) revokedAt: Date | null; + @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; +} + +@Entity('audit_log') +export class AuditLog { + @PrimaryGeneratedColumn('uuid') id: string; + @Column({ type: 'uuid', name: 'organization_id', nullable: true }) organizationId: string | null; + @Column({ type: 'uuid', name: 'user_id', nullable: true }) userId: string | null; + @Column('text') action: string; + @Column({ type: 'text', nullable: true }) entity: string | null; + @Column({ type: 'text', name: 'entity_id', nullable: true }) entityId: string | null; + @Column({ type: 'jsonb', nullable: true }) changes: unknown | null; + @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; +} + +@Entity('sites') +export class Site { + @PrimaryGeneratedColumn('uuid') id: string; + @Column({ type: 'uuid', name: 'organization_id' }) organizationId: string; + @Column('text') name: string; + @Column({ type: 'text', nullable: true }) address: string | null; + @Column({ type: 'text', nullable: true }) description: string | null; + @Column({ type: 'geometry', spatialFeatureType: 'MultiPolygon', srid: 4326, nullable: true }) + perimeter: GeoJsonMultiPolygon | null; + @Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326, nullable: true }) + center: GeoJsonPoint | null; + @Column({ type: 'text', default: 'Asia/Almaty' }) timezone: string; + @Column({ type: 'uuid', name: 'created_by', nullable: true }) createdBy: string | null; + @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; + @UpdateDateColumn({ type: 'timestamptz', name: 'updated_at' }) updatedAt: Date; + @Column({ type: 'timestamptz', name: 'archived_at', nullable: true }) archivedAt: Date | null; +} + +@Entity('routes') +export class Route { + @PrimaryGeneratedColumn('uuid') id: string; + @Column({ type: 'uuid', name: 'site_id' }) siteId: string; + @Column('text') name: string; + @Column({ type: 'text', nullable: true }) description: string | null; + @Column({ type: 'geometry', spatialFeatureType: 'LineString', srid: 4326, nullable: true }) + path: GeoJsonLineString | null; + @Column({ type: 'int', name: 'expected_minutes', nullable: true }) expectedMinutes: number | null; + @Column({ type: 'boolean', name: 'strict_order', default: false }) strictOrder: boolean; + @Column({ type: 'int', default: 1 }) version: number; + @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; + @UpdateDateColumn({ type: 'timestamptz', name: 'updated_at' }) updatedAt: Date; + @Column({ type: 'timestamptz', name: 'archived_at', nullable: true }) archivedAt: Date | null; +} + +@Entity('checkpoints') +export class Checkpoint { + @PrimaryGeneratedColumn('uuid') id: string; + @Column({ type: 'uuid', name: 'route_id' }) routeId: string; + @Column('int') position: number; + @Column('text') name: string; + @Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326 }) location: GeoJsonPoint; + @Column({ type: 'int', name: 'radius_m', default: 30 }) radiusM: number; + @Column({ type: 'text', name: 'media_required', default: 'photo' }) mediaRequired: MediaRequirement; + @Column({ type: 'text', nullable: true }) instruction: string | null; + @Column({ type: 'timestamptz', name: 'archived_at', nullable: true }) archivedAt: Date | null; +} + +@Entity('patrol_schedules') +export class PatrolSchedule { + @PrimaryGeneratedColumn('uuid') id: string; + @Column({ type: 'uuid', name: 'site_id' }) siteId: string; + @Column({ type: 'uuid', name: 'route_id' }) routeId: string; + @Column('text') name: string; + // Время старта 'HH:MM' в часовом поясе объекта + @Column({ type: 'text', name: 'start_times', array: true }) startTimes: string[]; + @Column({ type: 'int', name: 'window_minutes', default: 60 }) windowMinutes: number; + @Column({ type: 'int', name: 'late_tolerance_minutes', default: 15 }) lateToleranceMinutes: number; + @Column({ type: 'date', name: 'valid_from' }) validFrom: string; + @Column({ type: 'date', name: 'valid_to', nullable: true }) validTo: string | null; + @Column({ type: 'boolean', name: 'is_active', default: true }) isActive: boolean; + @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; +} + +@Entity('schedule_assignments') +@Unique(['scheduleId', 'userId', 'date']) +export class ScheduleAssignment { + @PrimaryGeneratedColumn('uuid') id: string; + @Column({ type: 'uuid', name: 'schedule_id' }) scheduleId: string; + @Column({ type: 'uuid', name: 'user_id' }) userId: string; + @Column({ type: 'date' }) date: string; + @Column({ type: 'uuid', name: 'created_by', nullable: true }) createdBy: string | null; + @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; +} + +export type RouteSnapshot = { + version: number; + name: string; + strictOrder: boolean; + path: GeoJsonLineString | null; + checkpoints: { + id: string; + position: number; + name: string; + location: GeoJsonPoint; + radiusM: number; + mediaRequired: MediaRequirement; + instruction: string | null; + }[]; +}; + +@Entity('rounds') +@Index(['siteId', 'plannedStart']) +export class Round { + @PrimaryGeneratedColumn('uuid') id: string; + @Column({ type: 'uuid', name: 'site_id' }) siteId: string; + @Column({ type: 'uuid', name: 'route_id' }) routeId: string; + @Column({ type: 'uuid', name: 'schedule_id', nullable: true }) scheduleId: string | null; + // Дата графика (в поясе объекта), по ней ищем дежурных + @Column({ type: 'date', name: 'duty_date', nullable: true }) dutyDate: string | null; + @Column({ type: 'uuid', name: 'performed_by', nullable: true }) performedBy: string | null; + @Column({ type: 'uuid', name: 'created_by', nullable: true }) createdBy: string | null; + @Column({ type: 'timestamptz', name: 'planned_start' }) plannedStart: Date; + @Column({ type: 'timestamptz', name: 'planned_end' }) plannedEnd: Date; + @Column({ type: 'int', name: 'late_tolerance_minutes', default: 15 }) lateToleranceMinutes: number; + @Column({ type: 'timestamptz', name: 'started_at', nullable: true }) startedAt: Date | null; + @Column({ type: 'timestamptz', name: 'finished_at', nullable: true }) finishedAt: Date | null; + @Column({ type: 'text', default: 'planned' }) status: RoundStatus; + @Column({ type: 'jsonb', name: 'route_snapshot' }) routeSnapshot: RouteSnapshot; + @Column({ type: 'int', name: 'checkpoints_total', default: 0 }) checkpointsTotal: number; + @Column({ type: 'int', name: 'checkpoints_done', default: 0 }) checkpointsDone: number; + @Column({ type: 'text', nullable: true }) comment: string | null; + @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; + @UpdateDateColumn({ type: 'timestamptz', name: 'updated_at' }) updatedAt: Date; +} + +@Entity('checkpoint_visits') +export class CheckpointVisit { + // id генерирует телефон: повтор из офлайн-очереди не создаёт дубль + @PrimaryColumn('uuid') id: string; + @Column({ type: 'uuid', name: 'round_id' }) roundId: string; + @Column({ type: 'uuid', name: 'checkpoint_id', nullable: true }) checkpointId: string | null; + @Column({ type: 'uuid', name: 'user_id' }) userId: string; + @Column({ type: 'geometry', name: 'marked_location', spatialFeatureType: 'Point', srid: 4326 }) + markedLocation: GeoJsonPoint; + @Column({ type: 'geometry', name: 'device_location', spatialFeatureType: 'Point', srid: 4326, nullable: true }) + deviceLocation: GeoJsonPoint | null; + @Column({ type: 'real', name: 'device_accuracy_m', nullable: true }) deviceAccuracyM: number | null; + @Column({ type: 'real', name: 'distance_to_checkpoint_m', nullable: true }) distanceToCheckpointM: number | null; + @Column({ type: 'real', name: 'gps_distance_m', nullable: true }) gpsDistanceM: number | null; + @Column({ type: 'text', array: true, default: () => "'{}'" }) flags: string[]; + @Column({ type: 'text', nullable: true }) note: string | null; + @Column({ type: 'boolean', name: 'is_issue', default: false }) isIssue: boolean; + @Column({ type: 'timestamptz', name: 'client_created_at' }) clientCreatedAt: Date; + @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; +} + +@Entity('media') +export class Media { + // id тоже генерирует телефон + @PrimaryColumn('uuid') id: string; + @Column({ type: 'uuid', name: 'organization_id' }) organizationId: string; + @Column({ type: 'uuid', name: 'uploaded_by' }) uploadedBy: string; + @Column({ type: 'uuid', name: 'visit_id', nullable: true }) visitId: string | null; + @Column('text') kind: 'photo' | 'video'; + @Column('text') mime: string; + @Column({ type: 'bigint', name: 'size_bytes' }) sizeBytes: string; + @Column({ type: 'text', nullable: true }) filename: string | null; + @Column({ type: 'text', name: 'storage_key' }) storageKey: string; + @Column({ type: 'text', name: 'upload_id', nullable: true }) uploadId: string | null; + @Column({ type: 'jsonb', default: () => "'[]'" }) parts: { partNumber: number; etag: string; size: number }[]; + @Column({ type: 'text', default: 'uploading' }) status: 'uploading' | 'ready'; + @Column({ type: 'timestamptz', name: 'captured_at', nullable: true }) capturedAt: Date | null; + @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; +} + +@Entity('webhooks') +export class Webhook { + @PrimaryGeneratedColumn('uuid') id: string; + @Column({ type: 'uuid', name: 'organization_id' }) organizationId: string; + @Column('text') url: string; + @Column({ type: 'text', array: true }) events: string[]; + @Column('text') secret: string; + @Column({ type: 'boolean', name: 'is_active', default: true }) isActive: boolean; + @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; +} + +export const ENTITIES = [ + Organization, + Role, + User, + UserSite, + UserSession, + AuditLog, + Site, + Route, + Checkpoint, + PatrolSchedule, + ScheduleAssignment, + Round, + CheckpointVisit, + Media, + Webhook, +]; diff --git a/apps/api/src/health.controller.ts b/apps/api/src/health.controller.ts new file mode 100644 index 0000000..36f766f --- /dev/null +++ b/apps/api/src/health.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Get } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { Public } from './common/auth'; + +@Controller('health') +export class HealthController { + constructor(@InjectDataSource() private readonly db: DataSource) {} + + @Public() + @Get() + async health() { + await this.db.query('SELECT 1'); + return { ok: true }; + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..11b900c --- /dev/null +++ b/apps/api/src/main.ts @@ -0,0 +1,25 @@ +import 'reflect-metadata'; +import { Logger } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { NestExpressApplication } from '@nestjs/platform-express'; +import cookieParser from 'cookie-parser'; +import express from 'express'; +import { UPLOAD_PART_SIZE } from '@patrol/shared'; +import { AppModule } from './app.module'; +import { config } from './config'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule, { bodyParser: false }); + app.setGlobalPrefix('api'); + app.set('trust proxy', true); + app.use(cookieParser()); + // Куски файлов приходят сырыми байтами, всё остальное — JSON или форма. + app.use('/api/media', express.raw({ type: 'application/octet-stream', limit: UPLOAD_PART_SIZE + 1024 })); + app.use(express.json({ limit: '2mb' })); + app.use(express.urlencoded({ extended: true, limit: '1mb' })); + app.enableShutdownHooks(); + await app.listen(config.port, '0.0.0.0'); + Logger.log(`API слушает порт ${config.port}`, 'Bootstrap'); +} + +bootstrap(); diff --git a/apps/api/src/modules/auth/auth.controller.ts b/apps/api/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..4368bc9 --- /dev/null +++ b/apps/api/src/modules/auth/auth.controller.ts @@ -0,0 +1,108 @@ +import { Body, Controller, Get, HttpCode, Param, Post, Req, Res } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { config } from '../../config'; +import { AuthUser, CurrentUser, Public } from '../../common/auth'; +import { obj, reqStr, str } from '../../common/input'; +import { OrganizationsService, parseRegisterInput } from '../organizations/organizations.service'; +import { AuthService } from './auth.service'; + +const COOKIE = 'patrol_rt'; + +function setRefreshCookie(res: Response, token: string) { + res.cookie(COOKIE, token, { + httpOnly: true, + sameSite: 'lax', + secure: config.cookieSecure, + path: '/api/auth', + maxAge: config.refreshTtlDays * 864e5, + }); +} + +@Controller('auth') +export class AuthController { + constructor( + private readonly auth: AuthService, + private readonly orgs: OrganizationsService, + ) {} + + @Public() + @Post('login') + @HttpCode(200) + async login(@Body() body: unknown, @Req() req: Request, @Res({ passthrough: true }) res: Response) { + const o = obj(body); + const tokens = await this.auth.login(reqStr(o, 'login'), reqStr(o, 'password'), req.headers['user-agent']); + setRefreshCookie(res, tokens.refreshToken); + return { accessToken: tokens.accessToken }; + } + + // Что включено: вход через Aspans ID, адрес Aspans ID для проверки центральной сессии. + @Public() + @Get('config') + publicConfig() { + return this.auth.publicConfig(); + } + + // SSO Aspans ID: начало — адрес авторизации и PKCE (state и verifier фронт держит у себя до возврата). + @Public() + @Post('sso/start') + @HttpCode(200) + ssoStart(@Body() body: unknown, @Req() req: Request) { + const o = obj(body ?? {}); + const prompt = str(o, 'prompt', { max: 20 }); + const origin = str(o, 'origin', { max: 300 }) ?? (req.headers.origin as string | undefined); + return this.auth.ssoStart(origin, prompt && ['none', 'login', 'consent', 'select_account'].includes(prompt) ? prompt : undefined); + } + + @Public() + @Post('sso/callback') + @HttpCode(200) + async ssoCallback(@Body() body: unknown, @Req() req: Request, @Res({ passthrough: true }) res: Response) { + const o = obj(body); + const tokens = await this.auth.ssoCallback( + reqStr(o, 'code', { max: 2000 }), + reqStr(o, 'codeVerifier', { max: 300 }), + reqStr(o, 'redirectUri', { max: 500 }), + req.headers['user-agent'], + ); + setRefreshCookie(res, tokens.refreshToken); + return { accessToken: tokens.accessToken }; + } + + @Public() + @Post('refresh') + @HttpCode(200) + async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) { + const tokens = await this.auth.refresh(req.cookies?.[COOKIE], req.headers['user-agent']); + setRefreshCookie(res, tokens.refreshToken); + return { accessToken: tokens.accessToken }; + } + + @Public() + @Post('logout') + @HttpCode(204) + async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) { + await this.auth.logout(req.cookies?.[COOKIE]); + res.clearCookie(COOKIE, { path: '/api/auth' }); + } + + @Get('me') + me(@CurrentUser() user: AuthUser) { + return this.auth.me(user); + } + + // Компания регистрируется сама: БИН → реквизиты из учет.kz → организация и первый администратор. + @Public() + @Get('company/:bin') + lookup(@Param('bin') bin: string) { + return this.orgs.lookup(bin.replace(/\s/g, '')); + } + + @Public() + @Post('register') + async register(@Body() body: unknown, @Req() req: Request, @Res({ passthrough: true }) res: Response) { + const { adminId, organization } = await this.orgs.register(parseRegisterInput(body, { allowExisting: false }), { trusted: false }); + const tokens = await this.auth.issue(adminId, req.headers['user-agent']); + setRefreshCookie(res, tokens.refreshToken); + return { accessToken: tokens.accessToken, organization: { id: organization.id, name: organization.name } }; + } +} diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..c36db73 --- /dev/null +++ b/apps/api/src/modules/auth/auth.service.ts @@ -0,0 +1,159 @@ +import { ForbiddenException, Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { InjectRepository } from '@nestjs/typeorm'; +import { createHash, randomBytes } from 'crypto'; +import { IsNull, Repository } from 'typeorm'; +import { config } from '../../config'; +import { Organization, User, UserSession } from '../../entities'; +import { accessProblem, type AccessTokenPayload, type AuthUser } from '../../common/auth'; +import { AspansIdClient } from '../passport/aspans-id.client'; +import { PassportClient, PassportPerson } from '../passport/passport.client'; +import { composeFullName } from '../users-sync/profile'; + +function sha256(v: string): string { + return createHash('sha256').update(v).digest('hex'); +} + +/** + * Вход только через паспорт: по логину и паролю (паспорт проверяет пароль и привязку к patrol) + * или через Aspans ID (SSO). Здесь — проверка, что человек заведён в patrol, и свои токены сессии. + */ +@Injectable() +export class AuthService { + private readonly logger = new Logger(AuthService.name); + + constructor( + private readonly jwt: JwtService, + private readonly passport: PassportClient, + private readonly aspansId: AspansIdClient, + @InjectRepository(User) private readonly users: Repository, + @InjectRepository(UserSession) private readonly sessions: Repository, + @InjectRepository(Organization) private readonly orgs: Repository, + ) {} + + publicConfig() { + return { + passport: { enabled: this.passport.enabled }, + aspansId: { enabled: this.aspansId.enabled, baseUrl: this.aspansId.baseUrl || null }, + }; + } + + async login(login: string, password: string, userAgent?: string) { + const person = await this.passport.login(login.trim(), password); + const user = await this.resolve(person.uuid, person.username, person); + return this.issue(user.id, userAgent); + } + + ssoStart(origin: string | undefined, prompt?: string) { + return this.aspansId.start(origin, prompt); + } + + async ssoCallback(code: string, codeVerifier: string, redirectUri: string, userAgent?: string) { + const info = await this.aspansId.exchange(code, codeVerifier, redirectUri); + const user = await this.resolve(info.uuid, info.username, null); + return this.issue(user.id, userAgent); + } + + /** + * Человек паспорта → пользователь patrol. Не заведён здесь — не пускаем (кроме владельцев + * платформы из PLATFORM_ADMIN_USERNAMES: их запись создаётся при первом входе). + */ + private async resolve(uuid: string, username: string | null, person: PassportPerson | null) { + const isOwner = !!username && config.platformAdminUsernames.includes(username.toLowerCase()); + let user = await this.users.findOneBy({ passportUuid: uuid }); + + if (!user && isOwner) { + const p = person ?? (await this.passport.getUser(uuid).catch(() => null)); + user = await this.users.save({ + passportUuid: uuid, + login: username, + firstName: p?.firstName ?? null, + lastName: p?.lastName ?? null, + middleName: p?.middleName ?? null, + email: p?.email ?? null, + phone: p?.phone ?? null, + fullName: composeFullName(p ?? {}, 'Владелец платформы'), + isPlatformAdmin: true, + organizationId: null, + }); + this.logger.log(`Владелец платформы ${username} вошёл впервые — создана запись`); + } + if (!user) { + throw new ForbiddenException('Вы не подключены к «Обходам». Обратитесь к администратору своей организации'); + } + + const patch: Partial = { lastLoginAt: new Date() }; + if (isOwner && !user.isPlatformAdmin) patch.isPlatformAdmin = true; + // Паспорт только что отдал свежий профиль — подтягиваем, даже если шина выключена. + if (person) { + Object.assign(patch, { + login: person.username, + firstName: person.firstName, + lastName: person.lastName, + middleName: person.middleName, + email: person.email, + phone: person.phone, + passportEnabled: person.isEnabled, + }); + patch.fullName = composeFullName({ ...user, ...patch }, user.fullName); + } + const merged = { ...user, ...patch } as User; + const org = merged.organizationId ? await this.orgs.findOneBy({ id: merged.organizationId }) : null; + const problem = accessProblem(merged, org); + if (problem) throw new ForbiddenException(problem); + await this.users.update({ id: user.id }, patch); + return merged; + } + + async issue(userId: string, userAgent?: string) { + const refresh = randomBytes(32).toString('base64url'); + await this.sessions.save({ + userId, + refreshHash: sha256(refresh), + userAgent: userAgent?.slice(0, 300) ?? null, + expiresAt: new Date(Date.now() + config.refreshTtlDays * 864e5), + }); + return { accessToken: await this.accessToken(userId), refreshToken: refresh }; + } + + accessToken(userId: string) { + const payload: AccessTokenPayload = { sub: userId, typ: 'access' }; + return this.jwt.signAsync(payload, { expiresIn: config.accessTtlSeconds }); + } + + // Ротация: старый refresh гасим, выдаём новый. + async refresh(refresh: string | undefined, userAgent?: string) { + if (!refresh) throw new UnauthorizedException('Нужно войти'); + const session = await this.sessions.findOneBy({ refreshHash: sha256(refresh), revokedAt: IsNull() }); + if (!session || session.expiresAt < new Date()) throw new UnauthorizedException('Сессия закончилась'); + const user = await this.users.findOneBy({ id: session.userId }); + if (!user) throw new UnauthorizedException('Пользователь не найден'); + const org = user.organizationId ? await this.orgs.findOneBy({ id: user.organizationId }) : null; + const problem = accessProblem(user, org); + if (problem) throw new UnauthorizedException(problem); + await this.sessions.update({ id: session.id }, { revokedAt: new Date() }); + return this.issue(user.id, userAgent); + } + + async logout(refresh: string | undefined) { + if (refresh) await this.sessions.update({ refreshHash: sha256(refresh) }, { revokedAt: new Date() }); + } + + async me(user: AuthUser) { + const org = user.organizationId ? await this.orgs.findOneBy({ id: user.organizationId }) : null; + const full = await this.users.findOneByOrFail({ id: user.id }); + return { + id: user.id, + passportUuid: full.passportUuid, + login: user.login, + fullName: user.fullName, + email: full.email, + phone: full.phone, + isPlatformAdmin: user.isPlatformAdmin, + organization: org && { id: org.id, name: org.name, bin: org.bin }, + role: user.roleId ? { id: user.roleId, name: user.roleName } : null, + allSites: user.allSites, + permissions: [...user.permissions], + }; + } +} diff --git a/apps/api/src/modules/geocode/geocode.controller.ts b/apps/api/src/modules/geocode/geocode.controller.ts new file mode 100644 index 0000000..0e3a90d --- /dev/null +++ b/apps/api/src/modules/geocode/geocode.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { RequirePermissions } from '../../common/auth'; +import { GeocodeService } from './geocode.service'; + +@Controller('geocode') +export class GeocodeController { + constructor(private readonly geocode: GeocodeService) {} + + @RequirePermissions('sites.view') + @Get() + search(@Query('q') q?: string) { + return this.geocode.search(q ?? ''); + } +} diff --git a/apps/api/src/modules/geocode/geocode.service.ts b/apps/api/src/modules/geocode/geocode.service.ts new file mode 100644 index 0000000..c1bff3a --- /dev/null +++ b/apps/api/src/modules/geocode/geocode.service.ts @@ -0,0 +1,95 @@ +import { BadGatewayException, BadRequestException, Injectable, Logger } from '@nestjs/common'; + +export type GeocodeHit = { + label: string; + lat: number; + lng: number; + /** [south, west, north, east] */ + bbox: [number, number, number, number] | null; +}; + +type NominatimRow = { display_name?: string; lat?: string; lon?: string; boundingbox?: string[] }; + +// Поиск адреса на карте — публичный Nominatim (OpenStreetMap), те же данные, что у подложки. +// Правила Nominatim: не чаще 1 запроса в секунду, свой User-Agent, кэш результатов. +// Поэтому браузер ходит не туда напрямую, а сюда: запросы всех пользователей идут по очереди. +@Injectable() +export class GeocodeService { + private readonly logger = new Logger(GeocodeService.name); + private readonly url = (process.env.NOMINATIM_URL || 'https://nominatim.openstreetmap.org').replace(/\/+$/, ''); + private readonly countries = process.env.NOMINATIM_COUNTRIES ?? 'kz'; + private readonly userAgent = `Aspans-Patrol/1.0${process.env.NOMINATIM_EMAIL ? ` (${process.env.NOMINATIM_EMAIL})` : ''}`; + private readonly cache = new Map(); + private queue: Promise = Promise.resolve(); + private lastAt = 0; + + get enabled() { + return process.env.GEOCODE_DISABLED !== 'true'; + } + + async search(raw: string): Promise { + const q = raw.trim().replace(/\s+/g, ' '); + if (q.length < 3) throw new BadRequestException('Введите хотя бы 3 символа'); + if (q.length > 200) throw new BadRequestException('Слишком длинный запрос'); + if (!this.enabled) return []; + + const key = q.toLowerCase(); + const hit = this.cache.get(key); + if (hit && Date.now() - hit.at < 24 * 3600_000) return hit.hits; + + const hits = await this.throttled(() => this.fetch(q)); + if (this.cache.size > 1000) this.cache.delete(this.cache.keys().next().value!); + this.cache.set(key, { at: Date.now(), hits }); + return hits; + } + + // Запросы строго друг за другом, между ними не меньше секунды. + private throttled(fn: () => Promise): Promise { + const run = this.queue.then(async () => { + const wait = this.lastAt + 1100 - Date.now(); + if (wait > 0) await new Promise((r) => setTimeout(r, wait)); + try { + return await fn(); + } finally { + this.lastAt = Date.now(); + } + }); + this.queue = run.catch(() => undefined); + return run; + } + + private async fetch(q: string): Promise { + const url = new URL(`${this.url}/search`); + url.searchParams.set('q', q); + url.searchParams.set('format', 'jsonv2'); + url.searchParams.set('limit', '6'); + url.searchParams.set('accept-language', 'ru'); + if (this.countries) url.searchParams.set('countrycodes', this.countries); + let res: Response; + try { + res = await fetch(url, { headers: { 'User-Agent': this.userAgent, Accept: 'application/json' }, signal: AbortSignal.timeout(10000) }); + } catch (e) { + this.logger.warn(`Nominatim недоступен: ${(e as Error).message}`); + throw new BadGatewayException('Поиск адреса сейчас недоступен'); + } + const body = (await res.json().catch(() => null)) as NominatimRow[] | null; + if (!res.ok || !Array.isArray(body)) { + this.logger.warn(`Nominatim ответил ${res.status}`); + throw new BadGatewayException('Поиск адреса сейчас недоступен'); + } + return body + .map((r): GeocodeHit | null => { + const lat = Number(r.lat); + const lng = Number(r.lon); + if (!r.display_name || !Number.isFinite(lat) || !Number.isFinite(lng)) return null; + const b = r.boundingbox?.map(Number); + return { + label: r.display_name, + lat, + lng, + bbox: b && b.length === 4 && b.every(Number.isFinite) ? [b[0], b[2], b[1], b[3]] : null, + }; + }) + .filter((h): h is GeocodeHit => !!h); + } +} diff --git a/apps/api/src/modules/media/media.controller.ts b/apps/api/src/modules/media/media.controller.ts new file mode 100644 index 0000000..e1f2a15 --- /dev/null +++ b/apps/api/src/modules/media/media.controller.ts @@ -0,0 +1,57 @@ +import { Body, Controller, Get, Headers, Param, ParseIntPipe, ParseUUIDPipe, Post, Put, Query, Req, Res } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { AuthUser, CurrentUser, Public, RequirePermissions } from '../../common/auth'; +import { MediaService } from './media.service'; + +@Controller('media') +export class MediaController { + constructor(private readonly media: MediaService) {} + + @RequirePermissions('rounds.perform') + @Post() + create(@CurrentUser() user: AuthUser, @Body() body: unknown) { + return this.media.create(user, body); + } + + @RequirePermissions('rounds.perform') + @Get(':id') + status(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string) { + return this.media.status(user, id); + } + + // Тело — сырые байты куска (application/octet-stream), см. main.ts. + @RequirePermissions('rounds.perform') + @Put(':id/parts/:n') + part( + @CurrentUser() user: AuthUser, + @Param('id', ParseUUIDPipe) id: string, + @Param('n', ParseIntPipe) n: number, + @Req() req: Request, + ) { + return this.media.part(user, id, n, req.body as Buffer); + } + + @RequirePermissions('rounds.perform') + @Post(':id/complete') + complete(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string) { + return this.media.complete(user, id); + } + + @Public() + @Get(':id/content') + async content( + @Param('id', ParseUUIDPipe) id: string, + @Query('t') token: string, + @Headers('range') range: string | undefined, + @Res() res: Response, + ) { + const { media, file } = await this.media.open(id, token, range); + res.status(file.partial ? 206 : 200); + res.setHeader('Content-Type', file.contentType || media.mime); + res.setHeader('Accept-Ranges', 'bytes'); + res.setHeader('Cache-Control', 'private, max-age=3600'); + if (file.contentLength !== undefined) res.setHeader('Content-Length', String(file.contentLength)); + if (file.contentRange) res.setHeader('Content-Range', file.contentRange); + file.body.pipe(res); + } +} diff --git a/apps/api/src/modules/media/media.service.ts b/apps/api/src/modules/media/media.service.ts new file mode 100644 index 0000000..8580d34 --- /dev/null +++ b/apps/api/src/modules/media/media.service.ts @@ -0,0 +1,141 @@ +import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { UPLOAD_PART_SIZE } from '@patrol/shared'; +import { config } from '../../config'; +import { Media } from '../../entities'; +import { AuthUser, requireOrg } from '../../common/auth'; +import { obj, oneOf, reqStr, str, timestamp, uuid } from '../../common/input'; +import { StorageService } from './storage.service'; + +const EXT: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'image/heic': 'heic', + 'image/heif': 'heif', + 'video/mp4': 'mp4', + 'video/quicktime': 'mov', + 'video/webm': 'webm', + 'video/3gpp': '3gp', +}; + +type MediaToken = { mid: string; typ: 'media' }; + +@Injectable() +export class MediaService { + constructor( + @InjectRepository(Media) private readonly media: Repository, + private readonly storage: StorageService, + private readonly jwt: JwtService, + ) {} + + private view(m: Media) { + return { + id: m.id, + kind: m.kind, + status: m.status, + sizeBytes: Number(m.sizeBytes), + partSize: UPLOAD_PART_SIZE, + uploadedParts: m.parts.map((p) => p.partNumber), + }; + } + + // Начать загрузку. id приходит с телефона: повторный вызов из офлайн-очереди возвращает ту же загрузку. + async create(user: AuthUser, body: unknown) { + const orgId = requireOrg(user); + const o = obj(body); + const id = uuid(o, 'id', { required: true })!; + const existing = await this.media.findOneBy({ id }); + if (existing) { + if (existing.uploadedBy !== user.id) throw new ConflictException('Файл с таким id уже есть'); + return this.view(existing); + } + const kind = oneOf(o, 'kind', ['photo', 'video'] as const, { required: true })!; + const mime = reqStr(o, 'mime', { max: 100 }).toLowerCase(); + if (!mime.startsWith(kind === 'photo' ? 'image/' : 'video/')) throw new BadRequestException('Тип файла не совпадает с kind'); + const size = Number(o.size); + if (!Number.isFinite(size) || size <= 0) throw new BadRequestException('Поле «size» обязательно'); + const key = `org/${orgId}/${new Date().toISOString().slice(0, 7)}/${id}.${EXT[mime] || mime.split('/')[1] || 'bin'}`; + const uploadId = await this.storage.startUpload(key, mime); + const saved = await this.media.save({ + id, + organizationId: orgId, + uploadedBy: user.id, + kind, + mime, + sizeBytes: String(size), + filename: str(o, 'filename', { max: 300 }) ?? null, + storageKey: key, + uploadId, + parts: [], + status: 'uploading', + capturedAt: timestamp(o, 'capturedAt') ?? null, + }); + return this.view(saved); + } + + private async own(user: AuthUser, id: string) { + const m = await this.media.findOneBy({ id }); + if (!m || m.organizationId !== user.organizationId) throw new NotFoundException('Файл не найден'); + if (m.uploadedBy !== user.id) throw new ForbiddenException('Это не ваш файл'); + return m; + } + + async status(user: AuthUser, id: string) { + return this.view(await this.own(user, id)); + } + + async part(user: AuthUser, id: string, partNumber: number, body: Buffer) { + const m = await this.own(user, id); + if (m.status === 'ready') return this.view(m); + const total = Math.max(1, Math.ceil(Number(m.sizeBytes) / UPLOAD_PART_SIZE)); + if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > total) throw new BadRequestException('Неверный номер куска'); + if (!Buffer.isBuffer(body) || !body.length) throw new BadRequestException('Пустой кусок файла'); + if (body.length > UPLOAD_PART_SIZE) throw new BadRequestException('Кусок больше допустимого'); + const etag = await this.storage.uploadPart(m.storageKey, m.uploadId!, partNumber, body); + // jsonb_set по одному куску, чтобы параллельные запросы не затирали друг друга. + await this.media.query( + `UPDATE media SET parts = ( + SELECT COALESCE(jsonb_agg(p ORDER BY (p->>'partNumber')::int), '[]'::jsonb) + FROM (SELECT p FROM jsonb_array_elements(parts) p WHERE (p->>'partNumber')::int <> $2 + UNION ALL SELECT jsonb_build_object('partNumber', $2::int, 'etag', $3::text, 'size', $4::int)) t(p)) + WHERE id = $1`, + [id, partNumber, etag, body.length], + ); + return this.view(await this.media.findOneByOrFail({ id })); + } + + async complete(user: AuthUser, id: string) { + const m = await this.own(user, id); + if (m.status === 'ready') return this.view(m); + const total = Math.max(1, Math.ceil(Number(m.sizeBytes) / UPLOAD_PART_SIZE)); + const have = new Set(m.parts.map((p) => p.partNumber)); + const missing = Array.from({ length: total }, (_, i) => i + 1).filter((n) => !have.has(n)); + if (missing.length) throw new BadRequestException(`Не загружены куски: ${missing.slice(0, 10).join(', ')}`); + await this.storage.completeUpload(m.storageKey, m.uploadId!, [...m.parts].sort((a, b) => a.partNumber - b.partNumber)); + await this.media.update({ id }, { status: 'ready', uploadId: null }); + return this.view(await this.media.findOneByOrFail({ id })); + } + + // Ссылка для /