Browse Source

Initial commit: digital reception monorepo (M1-M11 + demo extensions)

main
commit
b9f88194d9
  1. 53
      .env.example
  2. 30
      .gitignore
  3. 78
      README.md
  4. 227
      TZ (1).md
  5. 29
      apps/api/.env.example
  6. 12
      apps/api/jest.e2e.config.js
  7. 5
      apps/api/nest-cli.json
  8. 57
      apps/api/package.json
  9. 67
      apps/api/src/app.module.ts
  10. 38
      apps/api/src/audit/audit.controller.ts
  11. 7
      apps/api/src/audit/audit.module.ts
  12. 92
      apps/api/src/auth/auth.controller.ts
  13. 27
      apps/api/src/auth/auth.module.ts
  14. 106
      apps/api/src/auth/auth.service.ts
  15. 16
      apps/api/src/auth/decorators/current-user.decorator.ts
  16. 11
      apps/api/src/auth/decorators/logs-biometry.decorator.ts
  17. 4
      apps/api/src/auth/decorators/public.decorator.ts
  18. 5
      apps/api/src/auth/decorators/roles.decorator.ts
  19. 20
      apps/api/src/auth/guards/jwt-auth.guard.ts
  20. 26
      apps/api/src/auth/guards/roles.guard.ts
  21. 9
      apps/api/src/auth/index.ts
  22. 54
      apps/api/src/auth/interceptors/biometry-access-log.interceptor.ts
  23. 1
      apps/api/src/auth/role.enum.ts
  24. 38
      apps/api/src/auth/strategies/jwt.strategy.ts
  25. 23
      apps/api/src/cameras/cameras.controller.ts
  26. 7
      apps/api/src/cameras/cameras.module.ts
  27. 43
      apps/api/src/config/env.schema.ts
  28. 72
      apps/api/src/consents/consent-revocation.processor.ts
  29. 21
      apps/api/src/consents/consents.controller.ts
  30. 13
      apps/api/src/consents/consents.module.ts
  31. 60
      apps/api/src/consents/consents.service.ts
  32. 18
      apps/api/src/dashboard/dashboard.controller.ts
  33. 9
      apps/api/src/dashboard/dashboard.module.ts
  34. 198
      apps/api/src/dashboard/dashboard.service.ts
  35. 43
      apps/api/src/enrollment/enrollment.controller.ts
  36. 9
      apps/api/src/enrollment/enrollment.module.ts
  37. 118
      apps/api/src/enrollment/enrollment.service.ts
  38. 9
      apps/api/src/evidence/evidence.module.ts
  39. 65
      apps/api/src/evidence/evidence.service.ts
  40. 64
      apps/api/src/face/face.client.ts
  41. 9
      apps/api/src/face/face.module.ts
  42. 11
      apps/api/src/health.controller.ts
  43. 83
      apps/api/src/ingest/ingest.controller.ts
  44. 10
      apps/api/src/ingest/ingest.module.ts
  45. 236
      apps/api/src/ingest/ingest.service.ts
  46. 24
      apps/api/src/main.ts
  47. 78
      apps/api/src/polimed/polimed.client.ts
  48. 29
      apps/api/src/polimed/polimed.controller.ts
  49. 11
      apps/api/src/polimed/polimed.module.ts
  50. 9
      apps/api/src/prisma/prisma.module.ts
  51. 16
      apps/api/src/prisma/prisma.service.ts
  52. 36
      apps/api/src/recognition/recognition.controller.ts
  53. 10
      apps/api/src/recognition/recognition.module.ts
  54. 77
      apps/api/src/recognition/recognition.service.ts
  55. 28
      apps/api/src/tracks/tracks.controller.ts
  56. 10
      apps/api/src/tracks/tracks.module.ts
  57. 177
      apps/api/src/tracks/tracks.service.ts
  58. 25
      apps/api/src/visits/visits.controller.ts
  59. 9
      apps/api/src/visits/visits.module.ts
  60. 189
      apps/api/src/visits/visits.service.ts
  61. 123
      apps/api/test/auth.e2e-spec.ts
  62. 229
      apps/api/test/enrollment-consent.e2e-spec.ts
  63. 9
      apps/api/test/setup-env.ts
  64. 8
      apps/api/tsconfig.build.json
  65. 1
      apps/api/tsconfig.build.tsbuildinfo
  66. 12
      apps/api/tsconfig.json
  67. 6
      apps/face-service/.env.example
  68. 29
      apps/face-service/Dockerfile
  69. 51
      apps/face-service/README.md
  70. 200
      apps/face-service/database.py
  71. 93
      apps/face-service/face_engine.py
  72. 288
      apps/face-service/main.py
  73. 22
      apps/face-service/requirements.txt
  74. 0
      apps/face-service/tests/__init__.py
  75. 77
      apps/face-service/tests/conftest.py
  76. 124
      apps/face-service/tests/test_reid.py
  77. 40
      apps/fixtures-runner/README.md
  78. 22
      apps/fixtures-runner/package.json
  79. 12
      apps/fixtures-runner/scenarios/left-without-service.json
  80. 13
      apps/fixtures-runner/scenarios/new-patient.json
  81. 11
      apps/fixtures-runner/scenarios/returning-patient.json
  82. 85
      apps/fixtures-runner/src/clients.ts
  83. 40
      apps/fixtures-runner/src/embedding.ts
  84. 76
      apps/fixtures-runner/src/main.ts
  85. 123
      apps/fixtures-runner/src/runner.ts
  86. 24
      apps/fixtures-runner/src/types.ts
  87. 10
      apps/fixtures-runner/tsconfig.json
  88. 27
      apps/polimed-mock/README.md
  89. 15
      apps/polimed-mock/nest-cli.json
  90. 30
      apps/polimed-mock/package.json
  91. 22
      apps/polimed-mock/seeds/appointments.json
  92. 22
      apps/polimed-mock/seeds/patients.json
  93. 9
      apps/polimed-mock/src/app.module.ts
  94. 15
      apps/polimed-mock/src/main.ts
  95. 52
      apps/polimed-mock/src/polimed.controller.ts
  96. 84
      apps/polimed-mock/src/polimed.store.ts
  97. 25
      apps/polimed-mock/src/polimed.types.ts
  98. 4
      apps/polimed-mock/tsconfig.build.json
  99. 1
      apps/polimed-mock/tsconfig.build.tsbuildinfo
  100. 13
      apps/polimed-mock/tsconfig.json
  101. Some files were not shown because too many files have changed in this diff Show More

53
.env.example

@ -0,0 +1,53 @@
# ---- Postgres ----
POSTGRES_HOST=localhost
POSTGRES_PORT=5434
POSTGRES_DB=reception
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
DATABASE_URL=postgresql://postgres:postgres@localhost:5434/reception
# ---- Redis (BullMQ) ----
REDIS_HOST=localhost
REDIS_PORT=6380
REDIS_URL=redis://localhost:6380
# ---- MinIO ----
MINIO_ENDPOINT=http://localhost:9000
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin
MINIO_BUCKET=reception-evidence
# ---- Services ports ----
API_PORT=4000
POLIMED_MOCK_PORT=4100
FACE_SERVICE_PORT=8001
WEB_ADMIN_PORT=3000
# ---- Service URLs (apps see each other) ----
FACE_SERVICE_URL=http://localhost:8001
POLIMED_BASE_URL=http://localhost:4100
WEB_ADMIN_ORIGIN=http://localhost:3000
API_BASE_URL=http://localhost:4000
# ---- Auth ----
JWT_ACCESS_SECRET=dev-access-secret-change-me
JWT_REFRESH_SECRET=dev-refresh-secret-change-me
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=30d
COOKIE_SECURE=false
# ---- Dev users (seeded by db:seed) ----
SEED_PASSWORD_MANAGER=manager123
SEED_PASSWORD_SENIOR=senior123
SEED_PASSWORD_SECURITY=security123
SEED_PASSWORD_SYSADMIN=admin123
# ---- Face-service thresholds (ТЗ §4.3) ----
REID_THRESHOLD=0.35
RECOGNITION_THRESHOLD=0.5
# ---- Consent revocation delay (prod=24h, e2e=5s) ----
CONSENT_REVOKE_DELAY_MS=86400000
# ---- Evidence TTL ----
EVIDENCE_PRESIGN_TTL_SECONDS=900

30
.gitignore vendored

@ -0,0 +1,30 @@
node_modules
dist
build
.next
.turbo
.cache
*.log
.DS_Store
# env
.env
.env.local
.env.*.local
# python
__pycache__/
*.py[cod]
*$py.class
.venv/
.venv-*/
venv/
.pytest_cache/
.mypy_cache/
# editors
.vscode/
.idea/
# prisma local
prisma/.env

78
README.md

@ -0,0 +1,78 @@
# Digital Reception — Фаза 1
Видеоаналитика рецепции клиники: распознавание лиц, ручной enrollment, согласия, история визитов. Компонент Платформы цифровых сервисов клиники (ПЦС).
ТЗ: [`TZ (1).md`](./TZ%20(1).md).
План разработки: `~/.claude/plans/plan-mode-reflective-wand.md`.
## Стек
- **Монорепо:** pnpm workspaces + Turborepo.
- **БД:** PostgreSQL 16 + pgvector, Prisma 5.
- **Backend:** NestJS 10 (`apps/api`, `apps/polimed-mock`), Python + FastAPI (`apps/face-service`, `apps/video-ingest`).
- **Frontend:** Next.js 15 (App Router) + shadcn/ui + Recharts (`apps/web-admin`).
- **Очереди:** BullMQ (Redis).
- **Хранилище кадров:** MinIO (S3-совместимое).
- **Распознавание лиц:** InsightFace `buffalo_l`, 512-d cosine.
## Структура
```
apps/
api/ # NestJS — auth, RBAC, треки, согласия, визиты, аудит
face-service/ # Python FastAPI — InsightFace + pgvector + re-id
polimed-mock/ # NestJS — мок МИС Полимед (вне скоупа Ф1)
video-ingest/ # Python — минимальный mp4-консьюмер (скаффолд)
fixtures-runner/ # Node — e2e сценарии треков и событий
web-admin/ # Next.js 15 + shadcn/ui — админка
packages/
db/ # Prisma schema, миграции, сид
ui/ # shadcn/ui компоненты (shared)
tsconfig/ # base / nest / next tsconfig пресеты
eslint-config/ # общий ESLint
docker/
docker-compose.yml # postgres + redis + minio
init.sql # CREATE EXTENSION vector
```
## Быстрый старт
```bash
# 1. Инфраструктура
cp .env.example .env
pnpm docker:up # postgres+redis+minio
# 2. Зависимости
pnpm install
# 3. БД
pnpm db:migrate # создаёт схему
pnpm db:seed # 4 юзера, 3 камеры, 3 зоны
# 4. Сервисы (каждый в своём терминале)
pnpm --filter=@reception/api dev
pnpm --filter=@reception/polimed-mock dev
pnpm --filter=@reception/web-admin dev
# face-service:
cd apps/face-service && uvicorn main:app --reload --port 8001
```
## Контроль доступа
Сидер создаёт 4 dev-пользователя (пароли в `.env.example`):
| Email | Роль | Что видит |
|-------------------|---------------|---------------------------------|
| `manager@local` | MANAGER | Дашборд, история визитов |
| `senior@local` | SENIOR_ADMIN | Enrollment, согласия |
| `security@local` | SECURITY | Лента инцидентов (Ф2) |
| `admin@local` | SYSADMIN | Аудит, пользователи, камеры |
## Скоуп Фазы 1
- ✅ `face-service` + pgvector + cross-camera re-id.
- ✅ Web-admin: ручной enrollment, согласия, история визитов, аудит.
- ✅ Фиксация бумажных согласий, отзыв → удаление эмбеддингов за 24 ч.
- 🚫 **Полимед API** — замокан `apps/polimed-mock`, реальной интеграции нет.
- 🚫 **RTSP / GPU / ByteTrack** — работа Фазы 0, в Ф1 только минимальный mp4-скаффолд.
- 🚫 **Поведенческие алерты (Max-бот)** — Фаза 2.

227
TZ (1).md

@ -0,0 +1,227 @@
# ТЗ. Цифровая рецепция (видеоаналитика фронт-офиса)
**Статус:** v0.1 (драфт для обсуждения)
**Контекст:** компонент Платформы цифровых сервисов клиники (ПЦС), блок «Цифровые сервисы для администрации и управления».
**Руководитель направления ПЦС:** Пётр Потураев.
---
## 1. Цели и KPI
**Бизнес-цели:**
- Контроль качества обслуживания на рецепции (объективные, а не «по ощущениям» цифры).
- Снижение времени ожидания пациента и времени простоя администратора.
- Раннее обнаружение инцидентов (неадекватное поведение).
- Персональная история визитов пациента на рецепции для разбора жалоб и кейс-стади.
**Целевые метрики (фиксируются как KPI):**
- Среднее время ожидания в очереди (мин).
- Среднее время обслуживания у стойки (мин).
- Длина очереди (среднее, пиковое, по часам).
- Время простоя администратора (мин/смена, %).
- Количество пациентов, ушедших без обслуживания (шт/смена).
- Количество и тип инцидентов (агрессия, скопление и т.п.).
**Нормативов сейчас нет.** В админке пороги делаем редактируемыми (default — пустые). Базовые значения подбираем после 1–2 недель baseline-замеров на проде в анонимном режиме (Фаза 0).
---
## 2. Сценарии наблюдения
**Зоны (логические, привязываются к камерам в админке):**
- Зона A — вход в клинику.
- Зона B — коридор / зона ожидания.
- Зона C — стойка рецепции (рабочее место администратора).
**События жизненного цикла визита:**
| Событие | Триггер |
|---|---|
| `arrived` | трек впервые появился в зоне A |
| `waiting` | трек удерживается в зоне B без позиции у стойки |
| `service_started` | трек оказался у стойки C на расстоянии < X от администратора, > N сек |
| `service_ended` | трек покинул зону C |
| `left_without_service` | трек ушёл из зоны A/B без `service_started`, >= Tmin времени с `arrived` |
Все пороги (X, N, Tmin) — настраиваемые.
---
## 3. Источники видео
- IP-камеры (RTSP), уже стоят в клинике. Сырое видео **не храним**.
- В рамках Фазы 0: **инвентаризация камер** (отдельная активность с заказчиком на месте):
- ID/имя камеры, RTSP URL, разрешение, FPS, угол обзора, освещение, привязка к зоне (A/B/C).
- Минимум для запуска: 1 камера на вход, 1 на коридор, 1 над стойкой. Желательно с перекрытием — нужно для cross-camera re-id.
- Подключение по локальной сети клиники. Внешний доступ — только через VPN.
---
## 4. Идентификация пациентов
### 4.1 Подход
В МИС Полимед фотографий пациентов нет, поэтому базу эмбеддингов строим сами. Используется только при наличии бумажного согласия пациента (см. 4.4).
### 4.2 Ручной enrollment (через web-admin)
Сценарий старшего администратора:
1. Открывает страницу «Новые треки за смену».
2. Видит карточки треков. Один трек = серия кадров одного и того же человека, объединённая cross-camera re-id по лицевому эмбеддингу (вход → коридор → стойка).
3. Рядом — журнал записей на приём из Полимед за это же временное окно (REST: поиск пациентов / журнал записей).
4. Сопоставляет трек ↔ запись на приём → ставит галочку «бумажное согласие получено» → подтверждает.
5. Эмбеддинг сохраняется в нашей БД с привязкой к `patient_id` из Полимед.
Без галочки согласия эмбеддинг в долгую базу не уходит; трек живёт обезличенно до конца смены и удаляется.
### 4.3 Автоматическое распознавание (повторные визиты)
- При появлении лица — поиск ближайшего эмбеддинга в pgvector (cos distance, top-5).
- Threshold 0.35 (как в time-tracker, тюнингуем после первой партии данных).
- При match → подтягиваем из Полимед текущий приём → метрики становятся персональными.
### 4.4 Согласие
- Только бумажный носитель (договор/анкета).
- В админке оператор ставит галочку «согласие получено».
- Отзыв согласия → удаление эмбеддинга и персональной истории визитов в течение 24 ч. Обезличенные агрегаты остаются.
---
## 5. Приватность и 152-ФЗ
- Биометрические ПДн (лицевые эмбеддинги) обрабатываются только при наличии письменного согласия.
- Сырое видео **не записывается** на диск.
- Хранятся:
- Лицевые эмбеддинги (512-d) — pgvector.
- Метаданные треков (timestamps, camera_id, события).
- Кадры-доказательства для инцидентов — MinIO, TTL 30 дней (настраиваемо).
- ЕБС не используется.
- Журнал доступа к биометрии (кто, когда, к чьим данным) — для аудита.
- Контур развёртывания — внутри ЛВС клиники. Внешний доступ только через VPN.
---
## 6. Архитектура
### 6.1 Сервисы
| Сервис | Стек | Назначение |
|---|---|---|
| `video-ingest` | Python, OpenCV/ffmpeg, YOLOv8 (или RT-DETR), ByteTrack/BoT-SORT | На каждую RTSP-камеру отдельный воркер. Декод (по возможности NVDEC), детекция людей, трекинг внутри камеры, выдача кропов лиц и событий треков. |
| `face-service` | Python/FastAPI, InsightFace `buffalo_l`, pgvector | Эмбеддинги лиц, поиск ближайших, эндпоинт cross-camera re-id (склейка треков с разных камер по эмбеддингу в окне T минут). Переиспользуется из `work-pcs-adm-time-tracker`. |
| `behavior-service` | Python, action recognition (SlowFast / X3D / MoViNet) | Распознавание агрессии. **Фаза 2.** |
| `analytics-worker` | Node.js, BullMQ | Считает метрики (очередь, время ожидания/обслуживания), формирует инциденты. |
| `api` | Nest.js, Prisma | Коннектор к Полимед, enrollment-API, дашборд-API, RBAC. |
| `web-admin` | Next.js 15 (App Router), shadcn/ui, Recharts | Дашборд управляющего, ручной enrollment, фиксация согласий, пороги, расписание алертов, лента инцидентов. |
| `max-bot` | Node.js | Рассылка алертов в мессенджер Max. Фаза 2 (после `behavior-service`). |
### 6.2 Инфраструктура
- PostgreSQL 16 + pgvector.
- Redis (BullMQ).
- MinIO (кадры-доказательства).
- GPU-сервер (NVIDIA, CUDA, nvidia-container-toolkit) — для `video-ingest`, `face-service`, `behavior-service`.
- Docker Compose (по образцу time-tracker, отдельный `docker-compose.prod.yml`).
- Монорепо: pnpm + Turborepo.
### 6.3 Видеопайплайн (детально)
1. `video-ingest` открывает RTSP по камере (1 воркер = 1 камера).
2. Декодирование на GPU (NVDEC) при поддержке камеры; fallback на CPU.
3. Детектор людей — каждый N-й кадр (целевая частота ~5 fps хватит для рецепции).
4. Трекер (ByteTrack или BoT-SORT) — треки внутри одной камеры.
5. На каждый трек периодически вырезается лучший по качеству кроп лица (фронтальность, размер, sharpness) → в `face-service`.
6. `face-service` считает эмбеддинг. Если в окне последних T минут есть открытый трек с близким эмбеддингом — треки склеиваются (cross-camera re-id). Окно T — настраиваемо.
7. События трека (`arrived` / `waiting` / `service_started` / `service_ended` / `left_without_service`) уходят в `api``analytics-worker`.
---
## 7. Интеграции
### 7.1 МИС Полимед (REST)
- **Read:** поиск пациента по ФИО, журнал записей на дату, статус визита.
- **Write:** события визита на рецепции (`arrived`, `service_started`, `service_ended`) — назад в Полимед (объём write-операций уточняется со стороны Полимед).
### 7.2 Мессенджер Max (бот)
- Минимально (Фаза 2): инциденты «неадекватное поведение» (агрессия) → сообщение с кадром-доказательством.
- Получатели и расписание — настраиваются в админке.
- Расширения (по запросу): сводки за смену, триггерные алерты по очереди и т.п.
### 7.3 ПЦС-портал
- SSO и общие роли (Управляющий, Старший администратор, Безопасность, Админ системы) — на уровне ПЦС. На время Фазы 0 — локальная авторизация (как в time-tracker, JWT + refresh).
---
## 8. Роли и UI
| Роль | Что видит / делает |
|---|---|
| Управляющий клиники | Live-дашборд (текущая очередь, метрики смены), отчёты день/неделя/месяц, лента инцидентов. |
| Старший администратор | Очередь задач на enrollment, ручная привязка треков к пациентам Полимед, фиксация согласий, отзыв согласия. |
| Безопасность | Лента инцидентов «неадекватное поведение» + кадры. |
| Админ системы | Камеры (RTSP, привязка к зонам), пороги метрик, расписание алертов, пользователи и роли. |
---
## 9. Фазы
### Фаза 0 — MVP, анонимная аналитика (2–4 нед)
- Инвентаризация камер.
- `video-ingest` + детекция + трекинг (без идентификации).
- Метрики: длина очереди, время ожидания, время обслуживания, время простоя администратора, ушедшие без обслуживания.
- Дашборд управляющего (live + отчёт за день/смену).
- Baseline-замеры для подбора порогов.
**Критерий завершения:** управляющий видит реальные цифры по своей рецепции и может настроить пороги.
### Фаза 1 — идентификация + Полимед (+4–6 нед)
- `face-service` + pgvector.
- Cross-camera re-id (склейка треков по лицевому эмбеддингу).
- Web-admin: страница ручного enrollment с журналом записей из Полимед.
- Фиксация бумажных согласий, отзыв согласия (удаление за 24 ч).
- Персональная история визитов пациента.
- Write-события визитов в Полимед (если согласовано со стороны МИС).
**Критерий завершения:** при повторном визите пациента (после первого ручного enrollment) система автоматически узнаёт его и подтягивает текущий приём из Полимед.
### Фаза 2 — поведенческие сигналы (+6–8 нед)
- `behavior-service`: action recognition на агрессию.
- `max-bot`: алерты в Max с кадром-доказательством.
- Логика «уход без обслуживания» (поверх треков, без отдельной модели).
- Скопление в зоне (counter, без отдельной модели).
**Критерий завершения:** инцидент агрессии у стойки рецепции в течение ≤ N секунд попадает в Max нужному получателю.
### Фаза 3+ — по запросу
- Дополнительные поведенческие сигналы.
- Сводки и триггерные алерты в Max.
- Интеграции с другими компонентами ПЦС.
---
## 10. Открытые вопросы
- Точная спецификация REST-эндпоинтов Полимед (методы, форматы, аутентификация).
- Допустимость write-операций назад в Полимед и их объём.
- Параметры Max-бота (как регистрируется бот, какой API/SDK).
- Хостинг GPU-сервера (внутри ЛВС клиники, требования по сети и питанию).
- Шаблон согласия на обработку биометрических ПДн (юр. отдел).
- SSO от ПЦС: на каком этапе подключаем.
---
## Приложение A. Тех. стек = `work-pcs-adm-time-tracker` + дельта
| Компонент | Из time-tracker (`/Users/alekseyrazorvinm4/Project-with-LLM/work-pcs-adm-time-tracker`) | Дельта для «Цифровой рецепции» |
|---|---|---|
| Языки | Node.js (Nest.js, Next.js), Python (FastAPI) | + Python для `video-ingest` и `behavior-service` |
| Распознавание лиц | InsightFace `buffalo_l` (`/apps/face-service`) | — (переиспользуем) |
| Векторный поиск | PostgreSQL + pgvector (`/packages/db/prisma/schema.prisma`) | — (переиспользуем) |
| Видео-вход | MediaDevices браузера, кадр раз в 2 сек | → серверный RTSP-консьюмер (OpenCV/ffmpeg), GPU |
| Трекинг | нет | + ByteTrack / BoT-SORT |
| Cross-camera re-id | нет | + по лицевому эмбеддингу в окне T мин |
| Action recognition | нет | + SlowFast / X3D / MoViNet (Фаза 2) |
| Хранилище | Postgres + pgvector, Redis | + MinIO (кадры-доказательства) |
| Деплой | docker-compose (`/docker/docker-compose.yml`) | + GPU-runtime (nvidia-container-toolkit), `docker-compose.prod.yml` |
| Авторизация | JWT + refresh в httpOnly cookie (`/apps/api`) | — (Ф0), → SSO от ПЦС (Ф1+) |
| Монорепо | pnpm + Turborepo | — (переиспользуем) |
**Ссылки для разработки:**
- `/Users/alekseyrazorvinm4/Project-with-LLM/work-pcs-adm-time-tracker/ARCHITECTURE.md` — техническая схема time-tracker.
- `/Users/alekseyrazorvinm4/Project-with-LLM/work-pcs-adm-time-tracker/apps/face-service/main.py``/recognize` эндпоинт.
- `/Users/alekseyrazorvinm4/Project-with-LLM/work-pcs-adm-time-tracker/apps/face-service/requirements.txt` — версии Python-зависимостей.
- `/Users/alekseyrazorvinm4/Project-with-LLM/work-pcs-adm-time-tracker/packages/db/prisma/schema.prisma` — модель данных.

29
apps/api/.env.example

@ -0,0 +1,29 @@
# apps/api/.env.example
# Обычно apps/api берёт env из корневого .env через NestJS ConfigModule,
# но дублирующий файл здесь — для удобства самостоятельного запуска.
NODE_ENV=development
API_PORT=4000
DATABASE_URL=postgresql://postgres:postgres@localhost:5434/reception
REDIS_HOST=localhost
REDIS_PORT=6380
FACE_SERVICE_URL=http://localhost:8001
POLIMED_BASE_URL=http://localhost:4100
WEB_ADMIN_ORIGIN=http://localhost:3000
JWT_ACCESS_SECRET=dev-access-secret-change-me-please
JWT_REFRESH_SECRET=dev-refresh-secret-change-me-please
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=30d
COOKIE_SECURE=false
MINIO_ENDPOINT=http://localhost:9000
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin
MINIO_BUCKET=reception-evidence
EVIDENCE_PRESIGN_TTL_SECONDS=900
CONSENT_REVOKE_DELAY_MS=86400000

12
apps/api/jest.e2e.config.js

@ -0,0 +1,12 @@
/** @type {import('jest').Config} */
module.exports = {
testEnvironment: 'node',
rootDir: '.',
testRegex: '\\.e2e-spec\\.ts$',
moduleFileExtensions: ['ts', 'js', 'json'],
transform: {
'^.+\\.ts$': ['ts-jest', { tsconfig: 'tsconfig.json', isolatedModules: true }],
},
testTimeout: 30000,
setupFiles: ['<rootDir>/test/setup-env.ts'],
};

5
apps/api/nest-cli.json

@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}

57
apps/api/package.json

@ -0,0 +1,57 @@
{
"name": "@reception/api",
"version": "0.0.1",
"private": true,
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "node dist/main.js",
"lint": "eslint src --ext .ts",
"typecheck": "tsc --noEmit",
"test": "jest",
"test:e2e": "jest --config jest.e2e.config.js"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.687.0",
"@aws-sdk/s3-request-presigner": "^3.687.0",
"@nestjs/bullmq": "^10.2.3",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.15",
"@reception/db": "workspace:*",
"bcrypt": "^5.1.1",
"bullmq": "^5.34.10",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cookie-parser": "^1.4.7",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/testing": "^10.4.15",
"@reception/eslint-config": "workspace:*",
"@reception/tsconfig": "workspace:*",
"@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.8",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.9.0",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/supertest": "^6.0.2",
"dotenv": "^16.4.7",
"jest": "^29.7.0",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
}
}

67
apps/api/src/app.module.ts

@ -0,0 +1,67 @@
import { BullModule } from '@nestjs/bullmq';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { join } from 'node:path';
import { validateEnv } from './config/env.schema';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RolesGuard } from './auth/guards/roles.guard';
import { BiometryAccessLogInterceptor } from './auth/interceptors/biometry-access-log.interceptor';
import { HealthController } from './health.controller';
import { PolimedModule } from './polimed/polimed.module';
import { FaceModule } from './face/face.module';
import { EvidenceModule } from './evidence/evidence.module';
import { TracksModule } from './tracks/tracks.module';
import { EnrollmentModule } from './enrollment/enrollment.module';
import { ConsentsModule } from './consents/consents.module';
import { VisitsModule } from './visits/visits.module';
import { RecognitionModule } from './recognition/recognition.module';
import { AuditModule } from './audit/audit.module';
import { IngestModule } from './ingest/ingest.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { CamerasModule } from './cameras/cameras.module';
const REPO_ROOT_ENV = join(__dirname, '..', '..', '..', '.env');
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
cache: true,
envFilePath: ['.env', REPO_ROOT_ENV],
validate: validateEnv,
}),
BullModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
connection: {
host: config.getOrThrow<string>('REDIS_HOST'),
port: config.getOrThrow<number>('REDIS_PORT'),
},
}),
}),
PrismaModule,
AuthModule,
PolimedModule,
FaceModule,
EvidenceModule,
TracksModule,
EnrollmentModule,
ConsentsModule,
VisitsModule,
RecognitionModule,
AuditModule,
IngestModule,
DashboardModule,
CamerasModule,
],
controllers: [HealthController],
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
{ provide: APP_INTERCEPTOR, useClass: BiometryAccessLogInterceptor },
],
})
export class AppModule {}

38
apps/api/src/audit/audit.controller.ts

@ -0,0 +1,38 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { Role } from '@reception/db';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { PrismaService } from '../prisma/prisma.service';
@UseGuards(RolesGuard)
@Controller('audit')
export class AuditController {
constructor(private readonly prisma: PrismaService) {}
@Roles(Role.SYSADMIN)
@Get('biometry')
async biometry(
@Query('actorUserId') actorUserId?: string,
@Query('subjectPatientId') subjectPatientId?: string,
@Query('from') from?: string,
@Query('to') to?: string,
@Query('limit') limitRaw?: string,
) {
const take = Math.min(Number(limitRaw) || 100, 500);
const where: Record<string, unknown> = {};
if (actorUserId) where.actorUserId = actorUserId;
if (subjectPatientId) where.subjectPatientId = subjectPatientId;
if (from || to) {
where.occurredAt = {} as Record<string, Date>;
if (from) (where.occurredAt as Record<string, Date>).gte = new Date(from);
if (to) (where.occurredAt as Record<string, Date>).lte = new Date(to);
}
return this.prisma.biometryAccessLog.findMany({
where,
orderBy: { occurredAt: 'desc' },
include: { actor: { select: { email: true, fullName: true, role: true } } },
take,
});
}
}

7
apps/api/src/audit/audit.module.ts

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { AuditController } from './audit.controller';
@Module({
controllers: [AuditController],
})
export class AuditModule {}

92
apps/api/src/auth/auth.controller.ts

@ -0,0 +1,92 @@
import {
Body,
Controller,
Get,
HttpCode,
Post,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IsEmail, IsString, MinLength } from 'class-validator';
import type { CookieOptions, Request, Response } from 'express';
import { AuthService, type AuthTokens } from './auth.service';
import { CurrentUser, type AuthUser } from './decorators/current-user.decorator';
import { Public } from './decorators/public.decorator';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
class LoginDto {
// Разрешаем email без TLD (для dev-юзеров senior@local и т.п.)
@IsEmail({ require_tld: false })
email!: string;
@IsString()
@MinLength(6)
password!: string;
}
const ACCESS_COOKIE = 'access_token';
const REFRESH_COOKIE = 'refresh_token';
@Controller('auth')
export class AuthController {
constructor(
private readonly auth: AuthService,
private readonly config: ConfigService,
) {}
@Public()
@Post('login')
@HttpCode(200)
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
const tokens = await this.auth.login(dto.email, dto.password);
this.setAuthCookies(res, tokens);
return { ok: true, userId: tokens.userId };
}
@Public()
@Post('refresh')
@HttpCode(200)
async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
const refresh = req.cookies?.[REFRESH_COOKIE];
const tokens = await this.auth.refresh(refresh);
this.setAuthCookies(res, tokens);
return { ok: true };
}
@Post('logout')
@HttpCode(200)
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
const refresh = req.cookies?.[REFRESH_COOKIE];
await this.auth.logout(refresh);
res.clearCookie(ACCESS_COOKIE);
res.clearCookie(REFRESH_COOKIE);
return { ok: true };
}
@UseGuards(JwtAuthGuard)
@Get('me')
me(@CurrentUser() user: AuthUser) {
return user;
}
private setAuthCookies(res: Response, tokens: AuthTokens) {
const secure = this.config.get<boolean>('COOKIE_SECURE') ?? false;
const baseCookie: CookieOptions = {
httpOnly: true,
sameSite: 'lax',
secure,
path: '/',
};
res.cookie(ACCESS_COOKIE, tokens.accessToken, {
...baseCookie,
maxAge: 15 * 60 * 1000,
});
res.cookie(REFRESH_COOKIE, tokens.refreshToken, {
...baseCookie,
maxAge: 30 * 24 * 60 * 60 * 1000,
path: '/auth',
});
}
}

27
apps/api/src/auth/auth.module.ts

@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
signOptions: {
expiresIn: config.get<string>('JWT_ACCESS_TTL') ?? '15m',
},
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}

106
apps/api/src/auth/auth.service.ts

@ -0,0 +1,106 @@
import {
ConflictException,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import bcrypt from 'bcrypt';
import { createHash, randomBytes } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import type { JwtPayload } from './strategies/jwt.strategy';
export interface AuthTokens {
accessToken: string;
refreshToken: string;
}
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
) {}
async validateUser(email: string, password: string) {
const user = await this.prisma.user.findUnique({ where: { email } });
if (!user || !user.isActive) return null;
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) return null;
return user;
}
async login(email: string, password: string): Promise<AuthTokens & { userId: string }> {
const user = await this.validateUser(email, password);
if (!user) throw new UnauthorizedException('Invalid credentials');
const tokens = await this.issueTokens(user.id, user.email, user.role);
return { ...tokens, userId: user.id };
}
async issueTokens(userId: string, email: string, role: string): Promise<AuthTokens> {
const payload: JwtPayload = { sub: userId, email, role };
const accessToken = await this.jwt.signAsync(payload, {
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
expiresIn: this.config.get<string>('JWT_ACCESS_TTL') ?? '15m',
});
const refreshRaw = randomBytes(48).toString('hex');
const refreshHash = this.hashToken(refreshRaw);
const expiresAt = this.computeRefreshExpiry();
await this.prisma.refreshToken.create({
data: { userId, tokenHash: refreshHash, expiresAt },
});
return { accessToken, refreshToken: refreshRaw };
}
async refresh(refreshTokenRaw: string): Promise<AuthTokens> {
const tokenHash = this.hashToken(refreshTokenRaw);
const stored = await this.prisma.refreshToken.findUnique({ where: { tokenHash } });
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
throw new UnauthorizedException('Invalid refresh token');
}
const user = await this.prisma.user.findUnique({ where: { id: stored.userId } });
if (!user || !user.isActive) throw new UnauthorizedException('User inactive');
await this.prisma.refreshToken.update({
where: { id: stored.id },
data: { revokedAt: new Date() },
});
return this.issueTokens(user.id, user.email, user.role);
}
async logout(refreshTokenRaw: string | undefined) {
if (!refreshTokenRaw) return;
const tokenHash = this.hashToken(refreshTokenRaw);
await this.prisma.refreshToken
.updateMany({ where: { tokenHash, revokedAt: null }, data: { revokedAt: new Date() } })
.catch((e) => {
this.logger.warn(`Logout failed silently: ${e}`);
});
}
private hashToken(raw: string): string {
return createHash('sha256').update(raw).digest('hex');
}
private computeRefreshExpiry(): Date {
const ttl = this.config.get<string>('JWT_REFRESH_TTL') ?? '30d';
const match = ttl.match(/^(\d+)([smhd])$/);
const now = Date.now();
if (!match) {
throw new ConflictException(`Invalid JWT_REFRESH_TTL: ${ttl}`);
}
const value = Number(match[1]);
const unit = match[2];
const multiplier = unit === 's' ? 1000 : unit === 'm' ? 60_000 : unit === 'h' ? 3_600_000 : 86_400_000;
return new Date(now + value * multiplier);
}
}

16
apps/api/src/auth/decorators/current-user.decorator.ts

@ -0,0 +1,16 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { Request } from 'express';
import type { Role } from '@reception/db';
export interface AuthUser {
id: string;
email: string;
role: Role;
}
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AuthUser => {
const req = ctx.switchToHttp().getRequest<Request & { user: AuthUser }>();
return req.user;
},
);

11
apps/api/src/auth/decorators/logs-biometry.decorator.ts

@ -0,0 +1,11 @@
import { SetMetadata } from '@nestjs/common';
export const LOGS_BIOMETRY_KEY = 'logs_biometry';
/**
* Помечает контроллер/маршрут, чьи вызовы должны логироваться в biometry_access_log.
* Используется BiometryAccessLogInterceptor.
*
* @param action произвольная метка действия (например, 'enroll', 'recognize', 'view_visits').
*/
export const LogsBiometry = (action: string) => SetMetadata(LOGS_BIOMETRY_KEY, action);

4
apps/api/src/auth/decorators/public.decorator.ts

@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'is_public';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

5
apps/api/src/auth/decorators/roles.decorator.ts

@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { Role } from '@reception/db';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

20
apps/api/src/auth/guards/jwt-auth.guard.ts

@ -0,0 +1,20 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}

26
apps/api/src/auth/guards/roles.guard.ts

@ -0,0 +1,26 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '@reception/db';
import { ROLES_KEY } from '../decorators/roles.decorator';
import type { AuthUser } from '../decorators/current-user.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<Role[] | undefined>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!required || required.length === 0) return true;
const req = context.switchToHttp().getRequest();
const user: AuthUser | undefined = req.user;
if (!user) throw new ForbiddenException('No user context');
if (!required.includes(user.role)) {
throw new ForbiddenException(`Required role: ${required.join(' or ')}`);
}
return true;
}
}

9
apps/api/src/auth/index.ts

@ -0,0 +1,9 @@
export { AuthModule } from './auth.module';
export { AuthService } from './auth.service';
export { JwtAuthGuard } from './guards/jwt-auth.guard';
export { RolesGuard } from './guards/roles.guard';
export { BiometryAccessLogInterceptor } from './interceptors/biometry-access-log.interceptor';
export { Roles } from './decorators/roles.decorator';
export { Public } from './decorators/public.decorator';
export { LogsBiometry } from './decorators/logs-biometry.decorator';
export { CurrentUser, type AuthUser } from './decorators/current-user.decorator';

54
apps/api/src/auth/interceptors/biometry-access-log.interceptor.ts

@ -0,0 +1,54 @@
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable, tap } from 'rxjs';
import { PrismaService } from '../../prisma/prisma.service';
import { LOGS_BIOMETRY_KEY } from '../decorators/logs-biometry.decorator';
import type { AuthUser } from '../decorators/current-user.decorator';
@Injectable()
export class BiometryAccessLogInterceptor implements NestInterceptor {
private readonly logger = new Logger(BiometryAccessLogInterceptor.name);
constructor(
private readonly reflector: Reflector,
private readonly prisma: PrismaService,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const action = this.reflector.getAllAndOverride<string | undefined>(LOGS_BIOMETRY_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!action) return next.handle();
const req = context.switchToHttp().getRequest();
const user: AuthUser | undefined = req.user;
return next.handle().pipe(
tap((responseBody) => {
// Извлекаем subject (UUID нашего Patient) из:
// 1. params.patientId, 2. body.patientId, 3. responseBody.patientId (для enrollment).
const candidates = [
req.params?.patientId,
req.body?.patientId,
(responseBody as { patientId?: string } | null)?.patientId,
];
const subjectPatientId = candidates.find((c) => typeof c === 'string' && UUID_RE.test(c)) ?? null;
this.prisma.biometryAccessLog
.create({
data: {
action,
requestPath: req.originalUrl ?? req.url,
actorUserId: user?.id ?? null,
subjectPatientId,
},
})
.catch((err) => this.logger.error(`Failed to write biometry access log: ${err}`));
}),
);
}
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

1
apps/api/src/auth/role.enum.ts

@ -0,0 +1 @@
export { Role } from '@reception/db';

38
apps/api/src/auth/strategies/jwt.strategy.ts

@ -0,0 +1,38 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import type { Request } from 'express';
import { PrismaService } from '../../prisma/prisma.service';
import type { AuthUser } from '../decorators/current-user.decorator';
export interface JwtPayload {
sub: string;
email: string;
role: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
config: ConfigService,
private readonly prisma: PrismaService,
) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(req: Request) => req?.cookies?.access_token ?? null,
ExtractJwt.fromAuthHeaderAsBearerToken(),
]),
ignoreExpiration: false,
secretOrKey: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
});
}
async validate(payload: JwtPayload): Promise<AuthUser> {
const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
if (!user || !user.isActive) {
throw new UnauthorizedException('User not found or inactive');
}
return { id: user.id, email: user.email, role: user.role };
}
}

23
apps/api/src/cameras/cameras.controller.ts

@ -0,0 +1,23 @@
import { Controller, Get } from '@nestjs/common';
import { Public } from '../auth/decorators/public.decorator';
import { PrismaService } from '../prisma/prisma.service';
@Controller('cameras')
export class CamerasController {
constructor(private readonly prisma: PrismaService) {}
@Public()
@Get()
async list() {
const cameras = await this.prisma.camera.findMany({
include: { zone: true },
orderBy: [{ zone: { code: 'asc' } }, { name: 'asc' }],
});
return cameras.map((c) => ({
id: c.id,
name: c.name,
zoneCode: c.zone.code,
zoneName: c.zone.name,
}));
}
}

7
apps/api/src/cameras/cameras.module.ts

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { CamerasController } from './cameras.controller';
@Module({
controllers: [CamerasController],
})
export class CamerasModule {}

43
apps/api/src/config/env.schema.ts

@ -0,0 +1,43 @@
import { z } from 'zod';
export const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
API_PORT: z.coerce.number().int().positive().default(4000),
DATABASE_URL: z.string().url(),
REDIS_HOST: z.string().default('localhost'),
REDIS_PORT: z.coerce.number().int().positive().default(6380),
FACE_SERVICE_URL: z.string().url().default('http://localhost:8001'),
POLIMED_BASE_URL: z.string().url().default('http://localhost:4100'),
WEB_ADMIN_ORIGIN: z.string().url().default('http://localhost:3000'),
JWT_ACCESS_SECRET: z.string().min(16),
JWT_REFRESH_SECRET: z.string().min(16),
JWT_ACCESS_TTL: z.string().default('15m'),
JWT_REFRESH_TTL: z.string().default('30d'),
COOKIE_SECURE: z.enum(['true', 'false']).default('false').transform((v) => v === 'true'),
MINIO_ENDPOINT: z.string().url().default('http://localhost:9000'),
MINIO_ROOT_USER: z.string().default('minioadmin'),
MINIO_ROOT_PASSWORD: z.string().default('minioadmin'),
MINIO_BUCKET: z.string().default('reception-evidence'),
EVIDENCE_PRESIGN_TTL_SECONDS: z.coerce.number().int().positive().default(900),
CONSENT_REVOKE_DELAY_MS: z.coerce.number().int().nonnegative().default(86_400_000),
});
export type AppEnv = z.infer<typeof envSchema>;
export function validateEnv(raw: Record<string, unknown>): AppEnv {
const parsed = envSchema.safeParse(raw);
if (!parsed.success) {
const issues = parsed.error.issues.map((i) => ` - ${i.path.join('.')}: ${i.message}`).join('\n');
throw new Error(`Invalid environment configuration:\n${issues}`);
}
return parsed.data;
}

72
apps/api/src/consents/consent-revocation.processor.ts

@ -0,0 +1,72 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { ConsentAction, ConsentRevocationStatus, TrackStatus } from '@reception/db';
import { PrismaService } from '../prisma/prisma.service';
import { FaceClient } from '../face/face.client';
export const CONSENT_REVOCATION_QUEUE = 'consent-revocation';
export interface ConsentRevocationJobData {
jobId: string;
patientId: string;
actorUserId: string;
}
@Processor(CONSENT_REVOCATION_QUEUE)
export class ConsentRevocationProcessor extends WorkerHost {
private readonly logger = new Logger(ConsentRevocationProcessor.name);
constructor(
private readonly prisma: PrismaService,
private readonly face: FaceClient,
) {
super();
}
async process(job: Job<ConsentRevocationJobData>): Promise<void> {
const { jobId, patientId, actorUserId } = job.data;
this.logger.log(`Executing consent revocation for patient ${patientId} (job=${jobId})`);
const deletedEmbeddings = await this.face.deletePatientEmbeddings(patientId);
await this.prisma.$transaction(async (tx) => {
await tx.patient.update({
where: { id: patientId },
data: { fullName: null, pendingDeletionAt: null },
});
await tx.track.updateMany({
where: { patientId },
data: { status: TrackStatus.ANONYMIZED },
});
await tx.patientConsent.create({
data: {
patientId,
action: ConsentAction.REVOKED,
paperRef: 'revocation-completed',
actorUserId,
},
});
await tx.consentRevocationJob.update({
where: { id: jobId },
data: { status: ConsentRevocationStatus.DONE, completedAt: new Date() },
});
await tx.biometryAccessLog.create({
data: {
action: 'consent_revocation_completed',
actorUserId,
subjectPatientId: patientId,
requestPath: 'queue:consent-revocation',
},
});
});
this.logger.log(
`Consent revocation done: patient=${patientId}, deleted_embeddings=${deletedEmbeddings}`,
);
}
}

21
apps/api/src/consents/consents.controller.ts

@ -0,0 +1,21 @@
import { Controller, HttpCode, Param, Post, UseGuards } from '@nestjs/common';
import { Role } from '@reception/db';
import { Roles } from '../auth/decorators/roles.decorator';
import { LogsBiometry } from '../auth/decorators/logs-biometry.decorator';
import { CurrentUser, type AuthUser } from '../auth/decorators/current-user.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { ConsentsService } from './consents.service';
@UseGuards(RolesGuard)
@Controller('consents')
export class ConsentsController {
constructor(private readonly consents: ConsentsService) {}
@Roles(Role.SENIOR_ADMIN)
@LogsBiometry('consent_revoke')
@Post(':patientId/revoke')
@HttpCode(202)
revoke(@Param('patientId') patientId: string, @CurrentUser() user: AuthUser) {
return this.consents.revoke(patientId, user.id);
}
}

13
apps/api/src/consents/consents.module.ts

@ -0,0 +1,13 @@
import { BullModule } from '@nestjs/bullmq';
import { Module } from '@nestjs/common';
import { ConsentsController } from './consents.controller';
import { ConsentsService } from './consents.service';
import { CONSENT_REVOCATION_QUEUE, ConsentRevocationProcessor } from './consent-revocation.processor';
@Module({
imports: [BullModule.registerQueue({ name: CONSENT_REVOCATION_QUEUE })],
controllers: [ConsentsController],
providers: [ConsentsService, ConsentRevocationProcessor],
exports: [ConsentsService],
})
export class ConsentsModule {}

60
apps/api/src/consents/consents.service.ts

@ -0,0 +1,60 @@
import { InjectQueue } from '@nestjs/bullmq';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Queue } from 'bullmq';
import { PrismaService } from '../prisma/prisma.service';
import {
CONSENT_REVOCATION_QUEUE,
type ConsentRevocationJobData,
} from './consent-revocation.processor';
@Injectable()
export class ConsentsService {
private readonly logger = new Logger(ConsentsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
@InjectQueue(CONSENT_REVOCATION_QUEUE) private readonly queue: Queue<ConsentRevocationJobData>,
) {}
private getDelayMs(): number {
// Читаем динамически — env может быть переопределён в тестах перед вызовом.
return this.config.get<number>('CONSENT_REVOKE_DELAY_MS') ?? 86_400_000;
}
async revoke(patientId: string, actorUserId: string) {
const patient = await this.prisma.patient.findUnique({ where: { id: patientId } });
if (!patient) throw new NotFoundException(`Patient ${patientId} not found`);
const delayMs = this.getDelayMs();
const scheduledFor = new Date(Date.now() + delayMs);
const job = await this.prisma.$transaction(async (tx) => {
await tx.patient.update({
where: { id: patientId },
data: { consentRevokedAt: new Date(), pendingDeletionAt: scheduledFor },
});
return tx.consentRevocationJob.create({
data: {
patientId,
revokedAt: new Date(),
scheduledFor,
},
});
});
await this.queue.add(
'revoke',
{ jobId: job.id, patientId, actorUserId },
{ delay: delayMs, removeOnComplete: true, removeOnFail: false },
);
this.logger.log(
`Scheduled consent revocation: patient=${patientId}${scheduledFor.toISOString()} (delay=${delayMs}ms)`,
);
return { jobId: job.id, scheduledFor };
}
}

18
apps/api/src/dashboard/dashboard.controller.ts

@ -0,0 +1,18 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { Role } from '@reception/db';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { DashboardService } from './dashboard.service';
@UseGuards(RolesGuard)
@Controller('dashboard')
export class DashboardController {
constructor(private readonly dashboard: DashboardService) {}
@Roles(Role.MANAGER, Role.SYSADMIN, Role.SENIOR_ADMIN)
@Get('overview')
overview(@Query('date') date?: string) {
const dateIso = date ?? new Date().toISOString().slice(0, 10);
return this.dashboard.overview({ dateIso });
}
}

9
apps/api/src/dashboard/dashboard.module.ts

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
@Module({
controllers: [DashboardController],
providers: [DashboardService],
})
export class DashboardModule {}

198
apps/api/src/dashboard/dashboard.service.ts

@ -0,0 +1,198 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
interface OverviewOpts {
dateIso: string; // YYYY-MM-DD
}
export interface KpiCard {
label: string;
value: number;
unit?: string;
hint?: string;
/** true — данные синтетические / неполные (нужен Ф0 для realistic). */
synthetic?: boolean;
}
@Injectable()
export class DashboardService {
constructor(private readonly prisma: PrismaService) {}
async overview(opts: OverviewOpts) {
const start = new Date(`${opts.dateIso}T00:00:00.000Z`);
const end = new Date(`${opts.dateIso}T23:59:59.999Z`);
const [
visitsToday,
newEnrollmentsToday,
unmatchedTracks,
leftWithoutService,
visitsWithService,
tracksToday,
visitsByHour,
eventsByZone,
zoneTimeStats,
] = await Promise.all([
this.prisma.visit.count({ where: { arrivedAt: { gte: start, lte: end } } }),
this.prisma.patientConsent.count({
where: { action: 'GRANTED', occurredAt: { gte: start, lte: end } },
}),
this.prisma.track.count({
where: { status: 'UNMATCHED', firstSeenAt: { gte: start, lte: end } },
}),
this.prisma.visit.count({
where: { arrivedAt: { gte: start, lte: end }, leftWithoutService: true },
}),
this.prisma.visit.findMany({
where: {
arrivedAt: { gte: start, lte: end },
serviceStartedAt: { not: null },
serviceEndedAt: { not: null },
},
select: { arrivedAt: true, serviceStartedAt: true, serviceEndedAt: true },
}),
this.prisma.track.count({
where: { firstSeenAt: { gte: start, lte: end } },
}),
this.prisma.$queryRaw<Array<{ hour: number; visits: bigint }>>`
SELECT
EXTRACT(HOUR FROM arrived_at)::int AS hour,
COUNT(*)::bigint AS visits
FROM visits
WHERE arrived_at >= ${start} AND arrived_at <= ${end}
GROUP BY 1
ORDER BY 1
`,
this.prisma.$queryRaw<Array<{ code: string; events: bigint; tracks: bigint }>>`
SELECT
z.code::text AS code,
COUNT(te.*)::bigint AS events,
COUNT(DISTINCT te.track_id)::bigint AS tracks
FROM zones z
LEFT JOIN track_events te ON te.zone_id = z.id
AND te.occurred_at >= ${start} AND te.occurred_at <= ${end}
GROUP BY z.code
ORDER BY z.code
`,
// Среднее время в зоне для каждой зоны: для каждого трека
// считаем разницу между первым и последним событием в зоне.
this.prisma.$queryRaw<Array<{ code: string; avg_seconds: number }>>`
WITH zone_segments AS (
SELECT
z.code::text AS code,
te.track_id,
EXTRACT(EPOCH FROM (MAX(te.occurred_at) - MIN(te.occurred_at)))::float AS seconds
FROM track_events te
JOIN zones z ON z.id = te.zone_id
WHERE te.occurred_at >= ${start} AND te.occurred_at <= ${end}
GROUP BY z.code, te.track_id
HAVING COUNT(*) >= 2
)
SELECT code, AVG(seconds)::float AS avg_seconds
FROM zone_segments
GROUP BY code
ORDER BY code
`,
]);
const avgWaitingSec = avg(
visitsWithService.map(
(v) => (v.serviceStartedAt!.getTime() - v.arrivedAt.getTime()) / 1000,
),
);
const avgServiceSec = avg(
visitsWithService.map(
(v) => (v.serviceEndedAt!.getTime() - v.serviceStartedAt!.getTime()) / 1000,
),
);
// Подтянем «in flight» — пациентов сейчас в клинике (есть `arrived`, нет `service_ended` и не вышел через `left_without_service`).
const liveQueue = await this.prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(DISTINCT t.id)::bigint AS count
FROM tracks t
WHERE t.first_seen_at >= ${start} AND t.last_seen_at >= NOW() - INTERVAL '30 minutes'
AND NOT EXISTS (
SELECT 1 FROM track_events te
WHERE te.track_id = t.id
AND te.type IN ('service_ended', 'left_without_service')
)
`;
const cards: KpiCard[] = [
{ label: 'Визитов сегодня', value: visitsToday, unit: 'шт', hint: 'из таблицы visits' },
{
label: 'Новых enrollment',
value: newEnrollmentsToday,
unit: 'шт',
hint: 'согласие GRANTED сегодня',
},
{
label: 'Активных треков',
value: Number(liveQueue[0]?.count ?? 0),
unit: 'шт',
hint: 'не закрыты service_ended/left_without_service',
},
{
label: 'Unmatched-треков',
value: unmatchedTracks,
unit: 'шт',
hint: 'ждут ручного enrollment',
},
{
label: 'Среднее ожидание',
value: Math.round(avgWaitingSec ?? 0),
unit: 'сек',
hint: 'arrived → service_started',
synthetic: visitsWithService.length === 0,
},
{
label: 'Среднее обслуживание',
value: Math.round(avgServiceSec ?? 0),
unit: 'сек',
hint: 'service_started → service_ended',
synthetic: visitsWithService.length === 0,
},
{
label: 'Ушли без обслуживания',
value: leftWithoutService,
unit: 'шт',
hint: 'left_without_service за день',
},
{
label: 'Треков создано',
value: tracksToday,
unit: 'шт',
hint: 'все треки за день, включая повторные узнавания',
},
];
return {
date: opts.dateIso,
cards,
visitsByHour: visitsByHour.map((v) => ({ hour: v.hour, visits: Number(v.visits) })),
zoneActivity: eventsByZone.map((z) => ({
code: z.code,
events: Number(z.events),
tracks: Number(z.tracks),
})),
avgTimeInZoneSec: zoneTimeStats.map((s) => ({
code: s.code,
seconds: Math.round(s.avg_seconds),
})),
hasRealData: visitsWithService.length > 0,
};
}
}
function avg(arr: number[]): number | null {
if (arr.length === 0) return null;
return arr.reduce((a, b) => a + b, 0) / arr.length;
}

43
apps/api/src/enrollment/enrollment.controller.ts

@ -0,0 +1,43 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { IsOptional, IsString, IsUUID, MinLength } from 'class-validator';
import { Role } from '@reception/db';
import { Roles } from '../auth/decorators/roles.decorator';
import { LogsBiometry } from '../auth/decorators/logs-biometry.decorator';
import { CurrentUser, type AuthUser } from '../auth/decorators/current-user.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { EnrollmentService } from './enrollment.service';
class EnrollmentDto {
@IsUUID()
trackId!: string;
@IsString()
polimedPatientId!: string;
@IsOptional()
@IsString()
polimedAppointmentId?: string;
@IsString()
@MinLength(1)
paperConsentRef!: string;
}
@UseGuards(RolesGuard)
@Controller('enrollment')
export class EnrollmentController {
constructor(private readonly enrollment: EnrollmentService) {}
@Roles(Role.SENIOR_ADMIN)
@LogsBiometry('enroll')
@Post()
enroll(@Body() dto: EnrollmentDto, @CurrentUser() user: AuthUser) {
return this.enrollment.enroll({
trackId: dto.trackId,
polimedPatientId: dto.polimedPatientId,
polimedAppointmentId: dto.polimedAppointmentId,
paperConsentRef: dto.paperConsentRef,
actorUserId: user.id,
});
}
}

9
apps/api/src/enrollment/enrollment.module.ts

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { EnrollmentController } from './enrollment.controller';
import { EnrollmentService } from './enrollment.service';
@Module({
controllers: [EnrollmentController],
providers: [EnrollmentService],
})
export class EnrollmentModule {}

118
apps/api/src/enrollment/enrollment.service.ts

@ -0,0 +1,118 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConsentAction, TrackStatus } from '@reception/db';
import { PrismaService } from '../prisma/prisma.service';
import { FaceClient } from '../face/face.client';
import { PolimedClient } from '../polimed/polimed.client';
interface EnrollmentOpts {
trackId: string;
polimedPatientId: string;
polimedAppointmentId?: string;
paperConsentRef: string;
actorUserId: string;
}
@Injectable()
export class EnrollmentService {
private readonly logger = new Logger(EnrollmentService.name);
constructor(
private readonly prisma: PrismaService,
private readonly face: FaceClient,
private readonly polimed: PolimedClient,
) {}
async enroll(opts: EnrollmentOpts) {
const track = await this.prisma.track.findUnique({
where: { id: opts.trackId },
include: { _count: { select: { faceEmbeddings: true } } },
});
if (!track) throw new NotFoundException(`Track ${opts.trackId} not found`);
if (track.patientId) {
throw new BadRequestException(`Track ${opts.trackId} уже привязан к пациенту`);
}
if (track._count.faceEmbeddings === 0) {
throw new BadRequestException(`У трека ${opts.trackId} нет эмбеддингов`);
}
// 1. Получаем данные пациента из Полимед.
const polimedAppointment = opts.polimedAppointmentId
? await this.polimed.getAppointment(opts.polimedAppointmentId).catch(() => null)
: null;
const polimedPatients = await this.polimed.searchPatients(opts.polimedPatientId, 50);
const polimedPatient = polimedPatients.find((p) => p.id === opts.polimedPatientId)
?? polimedAppointment;
const fullName = polimedAppointment?.patientFullName
?? polimedPatients.find((p) => p.id === opts.polimedPatientId)?.fullName
?? null;
if (!fullName) {
this.logger.warn(`Не нашли полное ФИО в Полимед для ${opts.polimedPatientId}`);
}
// 2. Транзакция: создаём/обновляем Patient + Consent + Track + Visit.
const result = await this.prisma.$transaction(async (tx) => {
const patient = await tx.patient.upsert({
where: { polimedPatientId: opts.polimedPatientId },
update: {
fullName: fullName ?? undefined,
consentReceivedAt: new Date(),
consentRevokedAt: null,
pendingDeletionAt: null,
},
create: {
polimedPatientId: opts.polimedPatientId,
fullName: fullName ?? 'Без ФИО',
consentReceivedAt: new Date(),
},
});
await tx.patientConsent.create({
data: {
patientId: patient.id,
action: ConsentAction.GRANTED,
paperRef: opts.paperConsentRef,
actorUserId: opts.actorUserId,
},
});
await tx.track.update({
where: { id: opts.trackId },
data: { patientId: patient.id, status: TrackStatus.MATCHED },
});
const visit = await tx.visit.create({
data: {
patientId: patient.id,
polimedAppointmentId: opts.polimedAppointmentId ?? null,
arrivedAt: track.firstSeenAt,
},
});
return { patient, visit };
});
// 3. Привязываем эмбеддинги трека к пациенту в face-service.
const attached = await this.face.enrollTrack(opts.trackId, result.patient.id);
if (opts.polimedAppointmentId) {
this.polimed
.pushVisitEvent(opts.polimedAppointmentId, {
type: 'arrived',
occurredAt: track.firstSeenAt.toISOString(),
})
.catch((err) => this.logger.warn(`Polimed pushVisitEvent failed: ${err}`));
}
this.logger.log(
`Enrollment: track=${opts.trackId} → patient=${result.patient.id}, embeddings=${attached}`,
);
return {
patientId: result.patient.id,
visitId: result.visit.id,
embeddingsAttached: attached,
};
}
}

9
apps/api/src/evidence/evidence.module.ts

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { EvidenceService } from './evidence.service';
@Global()
@Module({
providers: [EvidenceService],
exports: [EvidenceService],
})
export class EvidenceModule {}

65
apps/api/src/evidence/evidence.service.ts

@ -0,0 +1,65 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
CreateBucketCommand,
GetObjectCommand,
HeadBucketCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { randomUUID } from 'node:crypto';
@Injectable()
export class EvidenceService implements OnModuleInit {
private readonly logger = new Logger(EvidenceService.name);
private readonly s3: S3Client;
private readonly bucket: string;
private readonly presignTtl: number;
constructor(config: ConfigService) {
this.s3 = new S3Client({
endpoint: config.getOrThrow<string>('MINIO_ENDPOINT'),
region: 'us-east-1',
credentials: {
accessKeyId: config.getOrThrow<string>('MINIO_ROOT_USER'),
secretAccessKey: config.getOrThrow<string>('MINIO_ROOT_PASSWORD'),
},
forcePathStyle: true,
});
this.bucket = config.getOrThrow<string>('MINIO_BUCKET');
this.presignTtl = config.get<number>('EVIDENCE_PRESIGN_TTL_SECONDS') ?? 900;
}
async onModuleInit() {
try {
await this.s3.send(new HeadBucketCommand({ Bucket: this.bucket }));
} catch {
try {
await this.s3.send(new CreateBucketCommand({ Bucket: this.bucket }));
this.logger.log(`Created MinIO bucket "${this.bucket}"`);
} catch (err) {
this.logger.warn(`MinIO bucket "${this.bucket}" unavailable: ${err}`);
}
}
}
/** Сохраняет JPEG-кадр в MinIO. Возвращает object key. */
async putEvidence(jpegBuffer: Buffer, opts: { trackId: string; cameraId: string }): Promise<string> {
const key = `tracks/${opts.trackId}/${opts.cameraId}-${Date.now()}-${randomUUID()}.jpg`;
await this.s3.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: jpegBuffer,
ContentType: 'image/jpeg',
}),
);
return key;
}
async getPresignedUrl(key: string, ttlSeconds?: number): Promise<string> {
const cmd = new GetObjectCommand({ Bucket: this.bucket, Key: key });
return getSignedUrl(this.s3, cmd, { expiresIn: ttlSeconds ?? this.presignTtl });
}
}

64
apps/api/src/face/face.client.ts

@ -0,0 +1,64 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export interface RecognizeResult {
patient_id: string;
confidence: number;
distance: number;
}
@Injectable()
export class FaceClient {
private readonly logger = new Logger(FaceClient.name);
private readonly baseUrl: string;
constructor(config: ConfigService) {
this.baseUrl = config.getOrThrow<string>('FACE_SERVICE_URL');
}
async recognize(frameBase64: string): Promise<RecognizeResult | null> {
const res = await fetch(`${this.baseUrl}/recognize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ frame: frameBase64 }),
});
if (!res.ok) {
this.logger.warn(`face-service /recognize failed: ${res.status}`);
return null;
}
const json = (await res.json()) as RecognizeResult | null;
return json;
}
async enrollTrack(trackId: string, patientId: string): Promise<number> {
const res = await fetch(`${this.baseUrl}/enroll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track_id: trackId, patient_id: patientId }),
});
if (!res.ok) {
throw new Error(`face-service /enroll failed: ${res.status}`);
}
const json = (await res.json()) as { embeddings_attached: number };
return json.embeddings_attached;
}
async deletePatientEmbeddings(patientId: string): Promise<number> {
const res = await fetch(`${this.baseUrl}/patient/${patientId}/embeddings`, {
method: 'DELETE',
});
if (!res.ok) {
this.logger.warn(`face-service DELETE /patient/.../embeddings failed: ${res.status}`);
return 0;
}
const json = (await res.json()) as { deleted: number };
return json.deleted;
}
async countPatientEmbeddings(patientId: string): Promise<number> {
const res = await fetch(`${this.baseUrl}/patient/${patientId}/count`);
if (!res.ok) return 0;
const json = (await res.json()) as { count: number };
return json.count;
}
}

9
apps/api/src/face/face.module.ts

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { FaceClient } from './face.client';
@Global()
@Module({
providers: [FaceClient],
exports: [FaceClient],
})
export class FaceModule {}

11
apps/api/src/health.controller.ts

@ -0,0 +1,11 @@
import { Controller, Get } from '@nestjs/common';
import { Public } from './auth/decorators/public.decorator';
@Controller('health')
export class HealthController {
@Public()
@Get()
check() {
return { status: 'ok', service: 'reception-api' };
}
}

83
apps/api/src/ingest/ingest.controller.ts

@ -0,0 +1,83 @@
import { Body, Controller, Post } from '@nestjs/common';
import { IsEnum, IsISO8601, IsOptional, IsString, IsUUID } from 'class-validator';
import { TrackEventType, ZoneCode } from '@reception/db';
import { Public } from '../auth/decorators/public.decorator';
import { IngestService } from './ingest.service';
class CreateTrackDto {
@IsString()
cameraName!: string;
@IsISO8601()
firstSeenAt!: string;
}
class AddEventDto {
@IsUUID()
trackId!: string;
@IsEnum(['arrived', 'waiting', 'service_started', 'service_ended', 'left_without_service'])
type!: TrackEventType;
@IsString()
cameraName!: string;
@IsOptional()
@IsEnum(['A', 'B', 'C'])
zoneCode?: ZoneCode;
@IsISO8601()
occurredAt!: string;
@IsOptional()
@IsString()
evidenceKey?: string;
}
class CaptureFrameDto {
@IsUUID()
trackId!: string;
@IsString()
cameraName!: string;
@IsString()
frame!: string;
}
@Controller('ingest')
export class IngestController {
constructor(private readonly ingest: IngestService) {}
@Public()
@Post('tracks')
createTrack(@Body() dto: CreateTrackDto) {
return this.ingest.createTrack({
cameraName: dto.cameraName,
firstSeenAt: new Date(dto.firstSeenAt),
});
}
@Public()
@Post('track-events')
addEvent(@Body() dto: AddEventDto) {
return this.ingest.addEvent({
trackId: dto.trackId,
type: dto.type,
cameraName: dto.cameraName,
zoneCode: dto.zoneCode,
occurredAt: new Date(dto.occurredAt),
evidenceKey: dto.evidenceKey,
});
}
@Public()
@Post('capture-frame')
captureFrame(@Body() dto: CaptureFrameDto) {
return this.ingest.captureFrame({
trackId: dto.trackId,
cameraName: dto.cameraName,
frameBase64: dto.frame,
});
}
}

10
apps/api/src/ingest/ingest.module.ts

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { IngestController } from './ingest.controller';
import { IngestService } from './ingest.service';
@Module({
controllers: [IngestController],
providers: [IngestService],
exports: [IngestService],
})
export class IngestModule {}

236
apps/api/src/ingest/ingest.service.ts

@ -0,0 +1,236 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TrackEventType, ZoneCode } from '@reception/db';
import { PrismaService } from '../prisma/prisma.service';
import { EvidenceService } from '../evidence/evidence.service';
interface CreateTrackOpts {
cameraName: string;
firstSeenAt: Date;
}
interface AddEventOpts {
trackId: string;
type: TrackEventType;
cameraName: string;
zoneCode?: ZoneCode;
occurredAt: Date;
evidenceKey?: string;
}
interface CaptureFrameOpts {
trackId: string;
cameraName: string;
frameBase64: string;
}
/**
* Internal ingest API для video-ingest и fixtures-runner. Помечен Public
* (нет JWT в Ф1) в проде будет service-to-service токен.
*/
@Injectable()
export class IngestService {
private readonly logger = new Logger(IngestService.name);
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
private readonly evidence: EvidenceService,
) {}
async createTrack(opts: CreateTrackOpts) {
const camera = await this.prisma.camera.findUnique({ where: { name: opts.cameraName } });
if (!camera) throw new NotFoundException(`Camera "${opts.cameraName}" not found`);
const track = await this.prisma.track.create({
data: { firstSeenAt: opts.firstSeenAt, lastSeenAt: opts.firstSeenAt },
});
this.logger.log(`Track created: ${track.id} (camera=${opts.cameraName})`);
return { trackId: track.id, cameraId: camera.id, zoneId: camera.zoneId };
}
async addEvent(opts: AddEventOpts) {
const camera = await this.prisma.camera.findUnique({
where: { name: opts.cameraName },
include: { zone: true },
});
if (!camera) throw new NotFoundException(`Camera "${opts.cameraName}" not found`);
if (opts.zoneCode && camera.zone.code !== opts.zoneCode) {
throw new BadRequestException(
`Camera "${opts.cameraName}" привязана к зоне ${camera.zone.code}, не к ${opts.zoneCode}`,
);
}
const track = await this.prisma.track.findUnique({ where: { id: opts.trackId } });
if (!track) throw new NotFoundException(`Track ${opts.trackId} not found`);
const event = await this.prisma.trackEvent.create({
data: {
trackId: opts.trackId,
type: opts.type,
cameraId: camera.id,
zoneId: camera.zone.id,
occurredAt: opts.occurredAt,
evidenceKey: opts.evidenceKey ?? null,
},
});
// Обновляем lastSeenAt трека.
if (opts.occurredAt > track.lastSeenAt) {
await this.prisma.track.update({
where: { id: opts.trackId },
data: { lastSeenAt: opts.occurredAt },
});
}
return { eventId: event.id };
}
/**
* Принимает base64 JPEG из браузера, шлёт в face-service /track-embeddings,
* получает id сохранённого эмбеддинга (либо null, если лицо не найдено).
* Параллельно вызывает face-service /recognize чтобы узнать, есть ли уже
* привязанный пациент (для UI «узнан/не узнан»).
*/
async captureFrame(opts: CaptureFrameOpts) {
const camera = await this.prisma.camera.findUniqueOrThrow({
where: { name: opts.cameraName },
include: { zone: true },
});
const track = await this.prisma.track.findUnique({
where: { id: opts.trackId },
include: { _count: { select: { events: true } } },
});
if (!track) throw new NotFoundException(`Track ${opts.trackId} not found`);
const faceServiceUrl = this.config.getOrThrow<string>('FACE_SERVICE_URL');
const now = new Date();
type EmbedRes = {
id: string;
quality: number;
bbox: { box: number[]; imgW: number; imgH: number } | null;
} | null;
type RecogRes = { patient_id: string; confidence: number; distance: number } | null;
const [embedRes, recogRes] = (await Promise.all([
fetch(`${faceServiceUrl}/track-embeddings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
frame: opts.frameBase64,
track_id: opts.trackId,
camera_id: camera.id,
captured_at: now.toISOString(),
}),
}).then((r) => (r.ok ? (r.json() as Promise<EmbedRes>) : null)),
fetch(`${faceServiceUrl}/recognize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ frame: opts.frameBase64 }),
}).then((r) => (r.ok ? (r.json() as Promise<RecogRes>) : null)),
]).catch((err) => {
this.logger.warn(`captureFrame face-service error: ${err}`);
return [null, null] as const;
})) as [EmbedRes, RecogRes];
// Сохраняем JPEG в MinIO (только когда лицо обнаружено — нет смысла копить пустые кадры).
let evidenceKey: string | null = null;
if (embedRes) {
try {
const jpegBuffer = decodeBase64Jpeg(opts.frameBase64);
evidenceKey = await this.evidence.putEvidence(jpegBuffer, {
trackId: opts.trackId,
cameraId: camera.id,
});
} catch (err) {
this.logger.warn(`MinIO put failed: ${err}`);
}
}
// Каждый удачный кадр → событие. Первый — `arrived`, остальные — `waiting`.
// Так на /enrollment/[id] видна вся серия кадров.
if (embedRes && evidenceKey) {
await this.prisma.trackEvent.create({
data: {
trackId: opts.trackId,
type: track._count.events === 0 ? 'arrived' : 'waiting',
cameraId: camera.id,
zoneId: camera.zone.id,
occurredAt: now,
evidenceKey,
faceBbox: (embedRes.bbox as unknown as object) ?? undefined,
},
});
}
// Если узнан — подтягиваем пациента и АВТО-ПРИВЯЗЫВАЕМ к нему текущий трек.
// Без этого каждая сессия на /capture создаёт unmatched-трек, и маршрут пациента
// остаётся обрезанным (видим только первый enrollment-трек).
let recognizedPatient: { id: string; fullName: string | null } | null = null;
if (recogRes) {
const p = await this.prisma.patient.findUnique({
where: { id: recogRes.patient_id },
select: { id: true, fullName: true, consentRevokedAt: true, pendingDeletionAt: true },
});
if (p && !p.consentRevokedAt && !p.pendingDeletionAt) {
recognizedPatient = { id: p.id, fullName: p.fullName };
if (!track.patientId) {
// Привязываем трек к пациенту и переводим в MATCHED.
await this.prisma.track.update({
where: { id: opts.trackId },
data: { patientId: p.id, status: 'MATCHED', lastSeenAt: now },
});
// Привязываем эмбеддинги трека к пациенту в face-service.
fetch(`${faceServiceUrl}/enroll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track_id: opts.trackId, patient_id: p.id }),
})
.then((r) => {
if (!r.ok) this.logger.warn(`face-service /enroll auto failed: ${r.status}`);
else this.logger.log(`Auto-enrolled track ${opts.trackId} → patient ${p.id}`);
})
.catch((err) => this.logger.warn(`face-service /enroll auto error: ${err}`));
} else {
await this.prisma.track.update({
where: { id: opts.trackId },
data: { lastSeenAt: now },
});
}
} else {
await this.prisma.track.update({
where: { id: opts.trackId },
data: { lastSeenAt: now },
});
}
} else {
await this.prisma.track.update({
where: { id: opts.trackId },
data: { lastSeenAt: now },
});
}
return {
embedding: embedRes,
faceDetected: embedRes !== null,
evidenceKey,
recognized: recognizedPatient
? {
patientId: recognizedPatient.id,
fullName: recognizedPatient.fullName,
confidence: recogRes?.confidence ?? null,
}
: null,
};
}
}
function decodeBase64Jpeg(input: string): Buffer {
// Убираем data:image/jpeg;base64, если есть.
const stripped = input.includes(',') ? input.split(',', 2)[1]! : input;
return Buffer.from(stripped, 'base64');
}

24
apps/api/src/main.ts

@ -0,0 +1,24 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { Logger, ValidationPipe } from '@nestjs/common';
import cookieParser from 'cookie-parser';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bufferLogs: false });
const config = app.get(ConfigService);
app.use(cookieParser());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.enableCors({
origin: config.get<string>('WEB_ADMIN_ORIGIN'),
credentials: true,
});
const port = config.get<number>('API_PORT') ?? 4000;
await app.listen(port);
Logger.log(`reception-api listening on http://localhost:${port}`, 'Bootstrap');
}
bootstrap();

78
apps/api/src/polimed/polimed.client.ts

@ -0,0 +1,78 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export interface PolimedPatient {
id: string;
fullName: string;
birthDate: string;
phone: string;
cardNumber: string;
}
export interface PolimedAppointment {
id: string;
patientId: string;
patientFullName: string;
doctorFullName: string;
specialty: string;
scheduledFor: string;
status: 'scheduled' | 'completed' | 'cancelled' | 'no_show';
}
export type PolimedVisitEventType =
| 'arrived'
| 'service_started'
| 'service_ended'
| 'left_without_service';
@Injectable()
export class PolimedClient {
private readonly logger = new Logger(PolimedClient.name);
private readonly baseUrl: string;
constructor(config: ConfigService) {
this.baseUrl = config.getOrThrow<string>('POLIMED_BASE_URL');
}
async searchPatients(query: string, limit = 20): Promise<PolimedPatient[]> {
const url = new URL('/patients/search', this.baseUrl);
url.searchParams.set('q', query);
url.searchParams.set('limit', String(limit));
return this.fetchJson(url);
}
async getAppointments(date?: string): Promise<PolimedAppointment[]> {
const url = new URL('/appointments', this.baseUrl);
if (date) url.searchParams.set('date', date);
return this.fetchJson(url);
}
async getAppointment(id: string): Promise<PolimedAppointment> {
const url = new URL(`/appointments/${id}`, this.baseUrl);
return this.fetchJson(url);
}
async pushVisitEvent(
appointmentId: string,
event: { type: PolimedVisitEventType; occurredAt: string; source?: string },
): Promise<void> {
const url = new URL(`/visits/${appointmentId}/events`, this.baseUrl);
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...event, source: event.source ?? 'reception-video' }),
});
if (!res.ok) {
this.logger.warn(`pushVisitEvent failed: ${res.status} ${url}`);
throw new Error(`Polimed event failed: ${res.status}`);
}
}
private async fetchJson<T>(url: URL): Promise<T> {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Polimed ${url.pathname} failed: ${res.status}`);
}
return (await res.json()) as T;
}
}

29
apps/api/src/polimed/polimed.controller.ts

@ -0,0 +1,29 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { Role } from '@reception/db';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { PolimedClient } from './polimed.client';
@UseGuards(RolesGuard)
@Controller('polimed')
export class PolimedController {
constructor(private readonly polimed: PolimedClient) {}
@Roles(Role.SENIOR_ADMIN, Role.MANAGER, Role.SYSADMIN)
@Get('patients/search')
search(@Query('q') q = '', @Query('limit') limit = '20') {
return this.polimed.searchPatients(q, Number(limit) || 20);
}
@Roles(Role.SENIOR_ADMIN, Role.MANAGER, Role.SYSADMIN)
@Get('appointments')
appointments(@Query('date') date?: string) {
return this.polimed.getAppointments(date);
}
@Roles(Role.SENIOR_ADMIN, Role.MANAGER, Role.SYSADMIN)
@Get('appointments/:id')
appointment(@Param('id') id: string) {
return this.polimed.getAppointment(id);
}
}

11
apps/api/src/polimed/polimed.module.ts

@ -0,0 +1,11 @@
import { Global, Module } from '@nestjs/common';
import { PolimedClient } from './polimed.client';
import { PolimedController } from './polimed.controller';
@Global()
@Module({
controllers: [PolimedController],
providers: [PolimedClient],
exports: [PolimedClient],
})
export class PolimedModule {}

9
apps/api/src/prisma/prisma.module.ts

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

16
apps/api/src/prisma/prisma.service.ts

@ -0,0 +1,16 @@
import { Injectable, OnModuleDestroy, OnModuleInit, Logger } from '@nestjs/common';
import { PrismaClient } from '@reception/db';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name);
async onModuleInit() {
await this.$connect();
this.logger.log('Connected to PostgreSQL');
}
async onModuleDestroy() {
await this.$disconnect();
}
}

36
apps/api/src/recognition/recognition.controller.ts

@ -0,0 +1,36 @@
import { Body, Controller, Post } from '@nestjs/common';
import { IsISO8601, IsString } from 'class-validator';
import { Type } from 'class-transformer';
import { Public } from '../auth/decorators/public.decorator';
import { LogsBiometry } from '../auth/decorators/logs-biometry.decorator';
import { RecognitionService } from './recognition.service';
class ProbeDto {
@IsString()
frame!: string;
@IsString()
cameraId!: string;
@IsISO8601()
@Type(() => String)
occurredAt!: string;
}
@Controller('recognition')
export class RecognitionController {
constructor(private readonly recognition: RecognitionService) {}
// Public (нет JWT), но логируется как биометрический доступ.
// В будущем заменим на внутренний service-to-service token.
@Public()
@LogsBiometry('recognition_probe')
@Post('probe')
probe(@Body() dto: ProbeDto) {
return this.recognition.probe({
frameBase64: dto.frame,
cameraId: dto.cameraId,
occurredAt: new Date(dto.occurredAt),
});
}
}

10
apps/api/src/recognition/recognition.module.ts

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { RecognitionController } from './recognition.controller';
import { RecognitionService } from './recognition.service';
@Module({
controllers: [RecognitionController],
providers: [RecognitionService],
exports: [RecognitionService],
})
export class RecognitionModule {}

77
apps/api/src/recognition/recognition.service.ts

@ -0,0 +1,77 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { FaceClient } from '../face/face.client';
import { PolimedClient } from '../polimed/polimed.client';
interface ProbeOpts {
frameBase64: string;
cameraId: string;
occurredAt: Date;
}
@Injectable()
export class RecognitionService {
private readonly logger = new Logger(RecognitionService.name);
constructor(
private readonly prisma: PrismaService,
private readonly face: FaceClient,
private readonly polimed: PolimedClient,
) {}
/**
* Внутренний эндпоинт: вызывается fixtures-runner / video-ingest при появлении
* лица. Идёт в face-service /recognize, при match создаёт Visit и
* (опционально) подтягивает текущий appointment из Полимед.
*
* Возвращает информацию о созданном Visit или null если пациент не узнан.
*/
async probe(opts: ProbeOpts) {
const match = await this.face.recognize(opts.frameBase64);
if (!match) return null;
const patient = await this.prisma.patient.findUnique({ where: { id: match.patient_id } });
if (!patient || patient.consentRevokedAt) {
// Согласие отозвано — не создаём Visit, эмбеддинги скоро удалят.
this.logger.warn(`Match patient_id=${match.patient_id} но пациент не найден / без согласия`);
return null;
}
// Подтягиваем appointments на сегодня и ищем подходящий по polimedPatientId.
let polimedAppointmentId: string | null = null;
if (patient.polimedPatientId) {
try {
const today = new Date().toISOString().slice(0, 10);
const appointments = await this.polimed.getAppointments(today);
const ap = appointments.find((a) => a.patientId === patient.polimedPatientId);
if (ap) polimedAppointmentId = ap.id;
} catch (err) {
this.logger.warn(`Polimed lookup failed: ${err}`);
}
}
const visit = await this.prisma.visit.create({
data: {
patientId: patient.id,
polimedAppointmentId,
arrivedAt: opts.occurredAt,
},
});
if (polimedAppointmentId) {
this.polimed
.pushVisitEvent(polimedAppointmentId, {
type: 'arrived',
occurredAt: opts.occurredAt.toISOString(),
})
.catch((err) => this.logger.warn(`Polimed pushVisitEvent failed: ${err}`));
}
return {
visitId: visit.id,
patientId: patient.id,
polimedAppointmentId,
confidence: match.confidence,
};
}
}

28
apps/api/src/tracks/tracks.controller.ts

@ -0,0 +1,28 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { Role, TrackStatus } from '@reception/db';
import { Roles } from '../auth/decorators/roles.decorator';
import { LogsBiometry } from '../auth/decorators/logs-biometry.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { TracksService } from './tracks.service';
@UseGuards(RolesGuard)
@Controller('tracks')
export class TracksController {
constructor(private readonly tracks: TracksService) {}
@Roles(Role.SENIOR_ADMIN, Role.MANAGER, Role.SYSADMIN)
@Get()
list(
@Query('status') status?: TrackStatus,
@Query('shiftDate') shiftDate?: string,
) {
return this.tracks.list({ status, shiftDate });
}
@Roles(Role.SENIOR_ADMIN, Role.MANAGER, Role.SYSADMIN)
@LogsBiometry('view_track')
@Get(':id')
getOne(@Param('id') id: string) {
return this.tracks.getOne(id);
}
}

10
apps/api/src/tracks/tracks.module.ts

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TracksController } from './tracks.controller';
import { TracksService } from './tracks.service';
@Module({
controllers: [TracksController],
providers: [TracksService],
exports: [TracksService],
})
export class TracksModule {}

177
apps/api/src/tracks/tracks.service.ts

@ -0,0 +1,177 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { TrackStatus } from '@reception/db';
import { PrismaService } from '../prisma/prisma.service';
import { EvidenceService } from '../evidence/evidence.service';
interface ListTracksOpts {
status?: TrackStatus;
shiftDate?: string; // YYYY-MM-DD; если задан — firstSeenAt в этом дне
}
@Injectable()
export class TracksService {
constructor(
private readonly prisma: PrismaService,
private readonly evidence: EvidenceService,
) {}
async list(opts: ListTracksOpts) {
const where: Parameters<PrismaService['track']['findMany']>[0] extends infer T
? T extends { where?: infer W }
? W
: never
: never = {};
if (opts.status) (where as { status?: TrackStatus }).status = opts.status;
if (opts.shiftDate) {
const from = new Date(opts.shiftDate);
from.setHours(0, 0, 0, 0);
const to = new Date(opts.shiftDate);
to.setHours(23, 59, 59, 999);
(where as { firstSeenAt?: object }).firstSeenAt = { gte: from, lte: to };
}
const tracks = await this.prisma.track.findMany({
where,
orderBy: { firstSeenAt: 'desc' },
include: {
events: { orderBy: { occurredAt: 'asc' } },
_count: { select: { faceEmbeddings: true } },
},
take: 100,
});
return Promise.all(
tracks.map(async (t) => ({
id: t.id,
status: t.status,
firstSeenAt: t.firstSeenAt,
lastSeenAt: t.lastSeenAt,
patientId: t.patientId,
embeddingsCount: t._count.faceEmbeddings,
zonesPath: Array.from(new Set(t.events.map((e) => e.zoneId))),
events: t.events.map((e) => ({
type: e.type,
cameraId: e.cameraId,
zoneId: e.zoneId,
occurredAt: e.occurredAt,
evidenceKey: e.evidenceKey,
})),
thumbnailUrl: await this.firstEvidenceUrl(t.events),
})),
);
}
async getOne(id: string) {
const track = await this.prisma.track.findUnique({
where: { id },
include: {
events: { orderBy: { occurredAt: 'asc' }, include: { zone: true, camera: true } },
patient: true,
},
});
if (!track) throw new NotFoundException(`Track ${id} not found`);
const eventsWithUrls = await Promise.all(
track.events.map(async (e) => ({
type: e.type,
cameraId: e.cameraId,
cameraName: e.camera.name,
zoneCode: e.zone.code,
occurredAt: e.occurredAt,
evidenceKey: e.evidenceKey,
evidenceUrl: e.evidenceKey ? await this.evidence.getPresignedUrl(e.evidenceKey) : null,
faceBbox: e.faceBbox as { box: number[]; imgW: number; imgH: number } | null,
})),
);
const consistency = await this.computeEmbeddingConsistency(id);
return {
id: track.id,
status: track.status,
firstSeenAt: track.firstSeenAt,
lastSeenAt: track.lastSeenAt,
patient: track.patient,
events: eventsWithUrls,
consistency,
};
}
/**
* Считает попарные cos-дистанции между всеми эмбеддингами трека (через pgvector `<=>`).
* Возвращает min/max/avg + статус «один ли это человек».
*
* Пороги (эмпирические для InsightFace buffalo_l):
* max 0.40 definitely_same: один человек, ракурсы похожи.
* 0.40 < max 0.55 likely_same: один человек, но сильная вариация (поворот головы, очки).
* max > 0.55 suspicious: возможно, разные лица.
*
* REID_THRESHOLD (0.35) для consistency не подходит он строже, рассчитан на склейку
* РАЗНЫХ треков между камерами, а не на внутреннюю когерентность одного трека.
*/
private async computeEmbeddingConsistency(trackId: string): Promise<{
count: number;
pairs: number;
minDistance: number | null;
maxDistance: number | null;
avgDistance: number | null;
status: 'definitely_same' | 'likely_same' | 'suspicious';
isCoherent: boolean;
}> {
const [{ count }] = await this.prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(*)::bigint AS count
FROM face_embeddings
WHERE track_id = ${trackId}::uuid
`;
const n = Number(count);
if (n < 2) {
return {
count: n,
pairs: 0,
minDistance: null,
maxDistance: null,
avgDistance: null,
status: 'definitely_same',
isCoherent: true,
};
}
const [stats] = await this.prisma.$queryRaw<
Array<{ min_d: number; max_d: number; avg_d: number; pairs: bigint }>
>`
SELECT
MIN(d)::float AS min_d,
MAX(d)::float AS max_d,
AVG(d)::float AS avg_d,
COUNT(*)::bigint AS pairs
FROM (
SELECT (a.embedding <=> b.embedding) AS d
FROM face_embeddings a
JOIN face_embeddings b ON a.id < b.id
WHERE a.track_id = ${trackId}::uuid AND b.track_id = ${trackId}::uuid
) p
`;
const maxD = stats?.max_d ?? 1;
const status: 'definitely_same' | 'likely_same' | 'suspicious' =
maxD <= 0.4 ? 'definitely_same' : maxD <= 0.55 ? 'likely_same' : 'suspicious';
return {
count: n,
pairs: Number(stats?.pairs ?? 0),
minDistance: stats?.min_d ?? null,
maxDistance: maxD,
avgDistance: stats?.avg_d ?? null,
status,
isCoherent: status !== 'suspicious',
};
}
private async firstEvidenceUrl(events: Array<{ evidenceKey: string | null }>): Promise<string | null> {
const first = events.find((e) => e.evidenceKey);
return first?.evidenceKey ? this.evidence.getPresignedUrl(first.evidenceKey) : null;
}
}

25
apps/api/src/visits/visits.controller.ts

@ -0,0 +1,25 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { Role } from '@reception/db';
import { Roles } from '../auth/decorators/roles.decorator';
import { LogsBiometry } from '../auth/decorators/logs-biometry.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { VisitsService } from './visits.service';
@UseGuards(RolesGuard)
@Controller()
export class VisitsController {
constructor(private readonly visits: VisitsService) {}
@Roles(Role.SENIOR_ADMIN, Role.MANAGER, Role.SYSADMIN)
@Get('patients')
listPatients() {
return this.visits.listPatients();
}
@Roles(Role.SENIOR_ADMIN, Role.MANAGER, Role.SYSADMIN)
@LogsBiometry('view_patient_visits')
@Get('patients/:patientId/visits')
listVisits(@Param('patientId') patientId: string) {
return this.visits.listForPatient(patientId);
}
}

9
apps/api/src/visits/visits.module.ts

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { VisitsController } from './visits.controller';
import { VisitsService } from './visits.service';
@Module({
controllers: [VisitsController],
providers: [VisitsService],
})
export class VisitsModule {}

189
apps/api/src/visits/visits.service.ts

@ -0,0 +1,189 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { EvidenceService } from '../evidence/evidence.service';
export interface JourneySegment {
zoneCode: string;
cameraName: string;
startedAt: Date;
endedAt: Date;
eventTypes: string[];
durationSec: number;
}
@Injectable()
export class VisitsService {
constructor(
private readonly prisma: PrismaService,
private readonly evidence: EvidenceService,
) {}
async listForPatient(patientId: string) {
const patient = await this.prisma.patient.findUnique({
where: { id: patientId },
include: { visits: { orderBy: { arrivedAt: 'desc' } } },
});
if (!patient) throw new NotFoundException(`Patient ${patientId} not found`);
const [avatar, journey] = await Promise.all([
this.getPatientAvatar(patientId),
this.getPatientJourney(patientId),
]);
return {
patient: {
id: patient.id,
fullName: patient.fullName,
polimedPatientId: patient.polimedPatientId,
consentReceivedAt: patient.consentReceivedAt,
consentRevokedAt: patient.consentRevokedAt,
pendingDeletionAt: patient.pendingDeletionAt,
avatarUrl: avatar?.url ?? null,
avatarBbox: avatar?.bbox ?? null,
},
journey,
visits: patient.visits.map((v) => ({
id: v.id,
arrivedAt: v.arrivedAt,
serviceStartedAt: v.serviceStartedAt,
serviceEndedAt: v.serviceEndedAt,
leftWithoutService: v.leftWithoutService,
polimedAppointmentId: v.polimedAppointmentId,
waitingSec:
v.serviceStartedAt && v.arrivedAt
? Math.round((v.serviceStartedAt.getTime() - v.arrivedAt.getTime()) / 1000)
: null,
serviceSec:
v.serviceEndedAt && v.serviceStartedAt
? Math.round((v.serviceEndedAt.getTime() - v.serviceStartedAt.getTime()) / 1000)
: null,
})),
};
}
async listPatients() {
const patients = await this.prisma.patient.findMany({
orderBy: { updatedAt: 'desc' },
include: { _count: { select: { visits: true } } },
});
const rows = await Promise.all(
patients.map(async (p) => {
const avatar = await this.getPatientAvatar(p.id);
return {
id: p.id,
fullName: p.fullName,
polimedPatientId: p.polimedPatientId,
consentReceivedAt: p.consentReceivedAt,
consentRevokedAt: p.consentRevokedAt,
pendingDeletionAt: p.pendingDeletionAt,
visitsCount: p._count.visits,
avatarUrl: avatar?.url ?? null,
avatarBbox: avatar?.bbox ?? null,
};
}),
);
return rows;
}
/**
* Маршрут пациента: события всех его треков, сгруппированные по непрерывным
* пребываниям в одной зоне. Считаем длительность каждого сегмента и суммы по зонам.
* Эвристика "потерян": последний сегмент в зоне D, прошло >15 мин, нет последующих событий.
*/
private async getPatientJourney(patientId: string) {
const events = await this.prisma.$queryRaw<
Array<{
track_id: string;
type: string;
occurred_at: Date;
zone_code: string;
camera_name: string;
}>
>`
SELECT
te.track_id::text AS track_id,
te.type::text AS type,
te.occurred_at,
z.code::text AS zone_code,
c.name AS camera_name
FROM track_events te
JOIN tracks t ON t.id = te.track_id
JOIN zones z ON z.id = te.zone_id
JOIN cameras c ON c.id = te.camera_id
WHERE t.patient_id = ${patientId}::uuid
ORDER BY te.occurred_at ASC
`;
const segments: JourneySegment[] = [];
for (const e of events) {
const last = segments[segments.length - 1];
if (last && last.zoneCode === e.zone_code && last.cameraName === e.camera_name) {
last.endedAt = e.occurred_at;
last.eventTypes.push(e.type);
last.durationSec = Math.round((last.endedAt.getTime() - last.startedAt.getTime()) / 1000);
} else {
segments.push({
zoneCode: e.zone_code,
cameraName: e.camera_name,
startedAt: e.occurred_at,
endedAt: e.occurred_at,
eventTypes: [e.type],
durationSec: 0,
});
}
}
const byZone: Record<string, number> = {};
for (const s of segments) {
byZone[s.zoneCode] = (byZone[s.zoneCode] ?? 0) + s.durationSec;
}
const lastSegment = segments[segments.length - 1];
let lostInTransit = false;
if (lastSegment && lastSegment.zoneCode === 'D') {
const ageMs = Date.now() - lastSegment.endedAt.getTime();
if (ageMs > 15 * 60 * 1000) lostInTransit = true;
}
return {
segments,
timeInZoneSec: byZone,
lostInTransit,
totalEvents: events.length,
firstSeenAt: events[0]?.occurred_at ?? null,
lastSeenAt: events[events.length - 1]?.occurred_at ?? null,
};
}
/**
* Аватар пациента лучший кадр из одного из его треков.
* Предпочитаем кадры с face_bbox (детектированное лицо).
*/
private async getPatientAvatar(
patientId: string,
): Promise<{ url: string; bbox: { box: number[]; imgW: number; imgH: number } | null } | null> {
const rows = await this.prisma.$queryRaw<
Array<{ evidence_key: string; face_bbox: unknown }>
>`
SELECT te.evidence_key, te.face_bbox
FROM track_events te
JOIN tracks t ON t.id = te.track_id
WHERE t.patient_id = ${patientId}::uuid
AND te.evidence_key IS NOT NULL
ORDER BY (te.face_bbox IS NULL) ASC, te.occurred_at ASC
LIMIT 1
`;
const row = rows[0];
if (!row) return null;
const url = await this.evidence.getPresignedUrl(row.evidence_key);
return {
url,
bbox:
(row.face_bbox as { box: number[]; imgW: number; imgH: number } | null) ?? null,
};
}
}

123
apps/api/test/auth.e2e-spec.ts

@ -0,0 +1,123 @@
import { Test, type TestingModule } from '@nestjs/testing';
import type { INestApplication } from '@nestjs/common';
import { ValidationPipe } from '@nestjs/common';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import { AppModule } from '../src/app.module';
import { PrismaService } from '../src/prisma/prisma.service';
describe('Auth (e2e)', () => {
let app: INestApplication;
let prisma: PrismaService;
const SENIOR_EMAIL = 'senior@local';
const SENIOR_PASSWORD = process.env.SEED_PASSWORD_SENIOR ?? 'senior123';
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
app.use(cookieParser());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
prisma = app.get(PrismaService);
});
afterAll(async () => {
await app.close();
});
it('GET /health is public', async () => {
const res = await request(app.getHttpServer()).get('/health');
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
});
it('GET /auth/me requires auth', async () => {
const res = await request(app.getHttpServer()).get('/auth/me');
expect(res.status).toBe(401);
});
it('POST /auth/login returns access cookie and userId', async () => {
const res = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: SENIOR_EMAIL, password: SENIOR_PASSWORD });
expect(res.status).toBe(200);
expect(res.body.userId).toBeDefined();
const cookies = (res.headers['set-cookie'] as unknown as string[]) ?? [];
expect(cookies.some((c) => c.startsWith('access_token='))).toBe(true);
expect(cookies.some((c) => c.startsWith('refresh_token='))).toBe(true);
});
it('GET /auth/me with cookie returns user + role', async () => {
const loginRes = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: SENIOR_EMAIL, password: SENIOR_PASSWORD });
const cookies = (loginRes.headers['set-cookie'] as unknown as string[]) ?? [];
const meRes = await request(app.getHttpServer())
.get('/auth/me')
.set('Cookie', cookies);
expect(meRes.status).toBe(200);
expect(meRes.body.email).toBe(SENIOR_EMAIL);
expect(meRes.body.role).toBe('SENIOR_ADMIN');
});
it('POST /auth/login with bad password → 401', async () => {
const res = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: SENIOR_EMAIL, password: 'wrong-password' });
expect(res.status).toBe(401);
});
it('POST /auth/logout clears tokens', async () => {
const loginRes = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: SENIOR_EMAIL, password: SENIOR_PASSWORD });
const cookies = (loginRes.headers['set-cookie'] as unknown as string[]) ?? [];
const logoutRes = await request(app.getHttpServer())
.post('/auth/logout')
.set('Cookie', cookies);
expect(logoutRes.status).toBe(200);
// Используем тот же refresh — должен быть отозван.
const refreshCookie = cookies.find((c) => c.startsWith('refresh_token='));
expect(refreshCookie).toBeDefined();
const refreshRes = await request(app.getHttpServer())
.post('/auth/refresh')
.set('Cookie', refreshCookie!);
expect(refreshRes.status).toBe(401);
});
describe('refresh token rotation', () => {
it('issues new tokens with valid refresh', async () => {
const loginRes = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: SENIOR_EMAIL, password: SENIOR_PASSWORD });
const cookies = (loginRes.headers['set-cookie'] as unknown as string[]) ?? [];
const refreshCookie = cookies.find((c) => c.startsWith('refresh_token='))!;
const refreshRes = await request(app.getHttpServer())
.post('/auth/refresh')
.set('Cookie', refreshCookie);
expect(refreshRes.status).toBe(200);
const newCookies = (refreshRes.headers['set-cookie'] as unknown as string[]) ?? [];
expect(newCookies.some((c) => c.startsWith('access_token='))).toBe(true);
// Старый refresh теперь невалиден — должен быть отозван.
const reuseRes = await request(app.getHttpServer())
.post('/auth/refresh')
.set('Cookie', refreshCookie);
expect(reuseRes.status).toBe(401);
});
});
});

229
apps/api/test/enrollment-consent.e2e-spec.ts

@ -0,0 +1,229 @@
/**
* M6 acceptance test:
* создаём фикстуру (track + embedding)
* POST /enrollment
* POST /consents/.../revoke с CONSENT_REVOKE_DELAY_MS=300
* через ~600мс эмбеддинги удалены, трек ANONYMIZED, ФИО null,
* в biometry_access_log полный след.
*
* FaceClient и PolimedClient замоканы. Redis/Postgres реальные.
*/
// CONSENT_REVOKE_DELAY_MS=300 устанавливается в test/setup-env.ts ДО imports.
import { Test, type TestingModule } from '@nestjs/testing';
import type { INestApplication } from '@nestjs/common';
import { ValidationPipe } from '@nestjs/common';
import { TrackStatus } from '@reception/db';
import cookieParser from 'cookie-parser';
import request from 'supertest';
import { randomUUID } from 'node:crypto';
import { AppModule } from '../src/app.module';
import { PrismaService } from '../src/prisma/prisma.service';
import { FaceClient } from '../src/face/face.client';
import { PolimedClient } from '../src/polimed/polimed.client';
const SENIOR_EMAIL = 'senior@local';
const SENIOR_PASSWORD = process.env.SEED_PASSWORD_SENIOR ?? 'senior123';
class FaceClientMock {
enrolledTracks: string[] = [];
deletedPatients: string[] = [];
countCalls = 0;
async recognize() {
return null;
}
async enrollTrack(trackId: string, _patientId: string): Promise<number> {
this.enrolledTracks.push(trackId);
return 1;
}
async deletePatientEmbeddings(patientId: string): Promise<number> {
this.deletedPatients.push(patientId);
return 1;
}
async countPatientEmbeddings(_patientId: string): Promise<number> {
this.countCalls += 1;
return 0;
}
}
class PolimedClientMock {
async searchPatients(q: string) {
return [
{ id: q, fullName: 'Иванов Иван Иванович', birthDate: '1980-01-01', phone: '+7900', cardNumber: 'K-MOCK' },
];
}
async getAppointments() {
return [];
}
async getAppointment(id: string) {
return {
id,
patientId: 'pol-p-test',
patientFullName: 'Иванов Иван Иванович',
doctorFullName: 'Доктор Тест',
specialty: 'Терапевт',
scheduledFor: new Date().toISOString(),
status: 'scheduled' as const,
};
}
async pushVisitEvent() {
return;
}
}
describe('Enrollment + Consent revoke (e2e, M6)', () => {
let app: INestApplication;
let prisma: PrismaService;
let face: FaceClientMock;
let cameraId: string;
let zoneId: string;
let trackId: string;
let embeddingId: string;
const polimedPatientId = `pol-p-test-${Date.now()}`;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(FaceClient)
.useClass(FaceClientMock)
.overrideProvider(PolimedClient)
.useClass(PolimedClientMock)
.compile();
app = module.createNestApplication();
app.use(cookieParser());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
prisma = app.get(PrismaService);
face = app.get(FaceClient) as unknown as FaceClientMock;
// Готовим зону, камеру, трек, фейковый эмбеддинг.
const zone = await prisma.zone.findUniqueOrThrow({ where: { code: 'A' } });
zoneId = zone.id;
const camera = await prisma.camera.create({
data: { name: `test-cam-${Date.now()}`, zoneId },
});
cameraId = camera.id;
trackId = randomUUID();
embeddingId = randomUUID();
await prisma.track.create({
data: {
id: trackId,
firstSeenAt: new Date(Date.now() - 60_000),
lastSeenAt: new Date(),
},
});
// Прямой INSERT эмбеддинга через $executeRawUnsafe (синтетический 512-d).
const embeddingLiteral = '[' + Array.from({ length: 512 }, () => Math.random()).join(',') + ']';
await prisma.$executeRawUnsafe(
`INSERT INTO face_embeddings (id, embedding, track_id, camera_id, quality, captured_at, created_at)
VALUES ($1::uuid, $2::vector, $3::uuid, $4::uuid, $5, NOW(), NOW())`,
embeddingId,
embeddingLiteral,
trackId,
cameraId,
0.9,
);
});
afterAll(async () => {
await prisma.$executeRawUnsafe(`DELETE FROM face_embeddings WHERE track_id = $1::uuid`, trackId);
await prisma.visit.deleteMany({ where: { patient: { polimedPatientId } } });
await prisma.patientConsent.deleteMany({ where: { patient: { polimedPatientId } } });
await prisma.consentRevocationJob.deleteMany({ where: { patient: { polimedPatientId } } });
await prisma.biometryAccessLog.deleteMany({ where: { subjectPatientId: { not: null } } });
await prisma.track.deleteMany({ where: { id: trackId } });
await prisma.patient.deleteMany({ where: { polimedPatientId } });
await prisma.camera.deleteMany({ where: { id: cameraId } });
await app.close();
});
async function loginSenior(): Promise<string[]> {
const res = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: SENIOR_EMAIL, password: SENIOR_PASSWORD });
expect(res.status).toBe(200);
return (res.headers['set-cookie'] as unknown as string[]) ?? [];
}
it('full enrollment + revoke flow', async () => {
const cookies = await loginSenior();
// 1. Enrollment.
const enrollRes = await request(app.getHttpServer())
.post('/enrollment')
.set('Cookie', cookies)
.send({
trackId,
polimedPatientId,
polimedAppointmentId: 'pol-a-test',
paperConsentRef: 'paper-ref-001',
});
expect(enrollRes.status).toBe(201);
expect(enrollRes.body.patientId).toBeDefined();
expect(enrollRes.body.visitId).toBeDefined();
const patientId: string = enrollRes.body.patientId;
// FaceClient.enrollTrack был вызван.
expect(face.enrolledTracks).toContain(trackId);
// Проверяем DB-состояние после enrollment.
const patientAfterEnroll = await prisma.patient.findUniqueOrThrow({ where: { id: patientId } });
expect(patientAfterEnroll.polimedPatientId).toBe(polimedPatientId);
expect(patientAfterEnroll.consentReceivedAt).not.toBeNull();
const trackAfterEnroll = await prisma.track.findUniqueOrThrow({ where: { id: trackId } });
expect(trackAfterEnroll.patientId).toBe(patientId);
expect(trackAfterEnroll.status).toBe(TrackStatus.MATCHED);
const grantedConsent = await prisma.patientConsent.findFirst({
where: { patientId, action: 'GRANTED' },
});
expect(grantedConsent).not.toBeNull();
expect(grantedConsent?.paperRef).toBe('paper-ref-001');
// 2. Revoke consent.
const revokeRes = await request(app.getHttpServer())
.post(`/consents/${patientId}/revoke`)
.set('Cookie', cookies);
expect(revokeRes.status).toBe(202);
expect(revokeRes.body.jobId).toBeDefined();
// Сразу после revoke — consentRevokedAt и pendingDeletionAt установлены.
const patientAfterRevoke = await prisma.patient.findUniqueOrThrow({ where: { id: patientId } });
expect(patientAfterRevoke.consentRevokedAt).not.toBeNull();
expect(patientAfterRevoke.pendingDeletionAt).not.toBeNull();
// 3. Ждём срабатывания BullMQ (delay=300мс + обработка).
await new Promise((r) => setTimeout(r, 1500));
// 4. Проверяем результат отложенной задачи.
expect(face.deletedPatients).toContain(patientId);
const patientFinal = await prisma.patient.findUniqueOrThrow({ where: { id: patientId } });
expect(patientFinal.fullName).toBeNull();
expect(patientFinal.pendingDeletionAt).toBeNull();
const trackFinal = await prisma.track.findUniqueOrThrow({ where: { id: trackId } });
expect(trackFinal.status).toBe(TrackStatus.ANONYMIZED);
const job = await prisma.consentRevocationJob.findFirstOrThrow({ where: { patientId } });
expect(job.status).toBe('DONE');
expect(job.completedAt).not.toBeNull();
// 5. Полный аудит-след в biometry_access_log.
const logs = await prisma.biometryAccessLog.findMany({
where: { subjectPatientId: patientId },
orderBy: { occurredAt: 'asc' },
});
const actions = logs.map((l) => l.action);
expect(actions).toContain('enroll');
expect(actions).toContain('consent_revoke');
expect(actions).toContain('consent_revocation_completed');
}, 30_000);
});

9
apps/api/test/setup-env.ts

@ -0,0 +1,9 @@
import { config as dotenv } from 'dotenv';
import { join } from 'node:path';
// Загружаем корневой .env для e2e-тестов.
dotenv({ path: join(__dirname, '..', '..', '..', '.env') });
// В e2e ускоряем delay очереди до 300мс (иначе тест M6 не дождётся 24 ч).
// override=true потому что .env может уже содержать прод-значение.
process.env.CONSENT_REVOKE_DELAY_MS = '300';

8
apps/api/tsconfig.build.json

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts", "**/*.e2e-spec.ts"]
}

1
apps/api/tsconfig.build.tsbuildinfo

File diff suppressed because one or more lines are too long

12
apps/api/tsconfig.json

@ -0,0 +1,12 @@
{
"extends": "@reception/tsconfig/nest.json",
"compilerOptions": {
"outDir": "./dist",
"baseUrl": "./src",
"paths": {
"@/*": ["*"]
}
},
"include": ["src/**/*.ts", "test/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

6
apps/face-service/.env.example

@ -0,0 +1,6 @@
DATABASE_URL=postgresql://postgres:postgres@localhost:5434/reception
MODEL_NAME=buffalo_l
DET_SCORE_THRESHOLD=0.7
RECOGNITION_THRESHOLD=0.5
REID_THRESHOLD=0.35
REID_WINDOW_MINUTES=5

29
apps/face-service/Dockerfile

@ -0,0 +1,29 @@
FROM python:3.11-slim
ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libgl1 \
libglib2.0-0 \
libsm6 \
libxext6 \
libxrender1 \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt ./
RUN pip install -r requirements.txt
COPY . .
EXPOSE 8001
HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \
CMD curl -fsS http://localhost:8001/health || exit 1
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]

51
apps/face-service/README.md

@ -0,0 +1,51 @@
# face-service
Python + FastAPI + InsightFace `buffalo_l`. Считает 512-d L2-нормализованные эмбеддинги лиц, хранит в pgvector, делает cross-camera re-id и узнавание пациентов.
## Эндпоинты
| Метод | Путь | Назначение |
|---|---|---|
| `GET` | `/health` | Статус + флаг загрузки модели |
| `POST` | `/embed` | Только эмбеддинг (без БД). Body: `{frame: base64}` |
| `POST` | `/track-embeddings` | Сохранить эмбеддинг с привязкой к треку/камере |
| `POST` | `/reid/search` | Cross-camera re-id (top-K в окне T мин) |
| `POST` | `/recognize` | Узнать пациента (`patient_id`) по кадру |
| `POST` | `/enroll` | Привязать эмбеддинги трека к пациенту |
| `DELETE` | `/patient/{id}/embeddings` | Удалить эмбеддинги пациента (отзыв согласия) |
| `GET` | `/patient/{id}/count` | Кол-во эмбеддингов у пациента |
## Запуск (dev)
```bash
cd apps/face-service
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # либо использовать корневой .env
uvicorn main:app --reload --port 8001
```
## Запуск (docker)
```bash
docker build -t reception/face-service .
docker run --rm -p 8001:8001 \
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5434/reception \
reception/face-service
```
## Пороги (ТЗ §4.3)
- `RECOGNITION_THRESHOLD=0.5` — узнавание уже зарегистрированного пациента.
- `REID_THRESHOLD=0.35` — склейка треков между камерами (строже, иначе ложные склейки).
Тюним после baseline-замеров на проде.
## Источник
Скопировано и расширено из `work-pcs-adm-time-tracker/apps/face-service/`. Изменения:
- Новая схема `face_embeddings` (track_id, camera_id, patient_id, quality, captured_at) — управляется через Prisma в `packages/db`.
- Добавлены функции `save_embedding_with_meta`, `attach_track_to_patient`, `find_topk_in_window`, `find_nearest_patient`, `delete_patient_embeddings`.
- Эндпоинты `/embed`, `/track-embeddings`, `/reid/search`, `/enroll`, `/patient/.../embeddings` — новые.
- Эндпоинт `/recognize` теперь работает только с эмбеддингами с проставленным `patient_id` (т.е. с согласием).

200
apps/face-service/database.py

@ -0,0 +1,200 @@
"""Прямой доступ к pgvector через psycopg2.
Расширение time-tracker/apps/face-service/database.py для домена «Цифровой
рецепции»: новая схема (patient_id, track_id, camera_id, quality, captured_at),
cross-camera re-id (top-K в окне T минут с фильтром по камерам),
поиск пациента (только эмбеддинги с patient_id IS NOT NULL).
"""
import os
import uuid
from datetime import datetime, timedelta
from typing import Any
import psycopg2
import psycopg2.extras
from pgvector.psycopg2 import register_vector
from dotenv import load_dotenv
load_dotenv()
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql://postgres:postgres@localhost:5434/reception",
)
def get_connection():
conn = psycopg2.connect(DATABASE_URL)
register_vector(conn)
return conn
# ---------- WRITE ----------
def save_embedding_with_meta(
embedding,
track_id: str,
camera_id: str,
quality: float,
captured_at: datetime | None = None,
patient_id: str | None = None,
) -> str:
"""Сохраняет эмбеддинг с привязкой к треку/камере/пациенту.
Возвращает id записи. captured_at по умолчанию now().
"""
record_id = str(uuid.uuid4())
captured_at = captured_at or datetime.utcnow()
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO face_embeddings
(id, embedding, track_id, camera_id, quality, patient_id, captured_at, created_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, NOW())
""",
(record_id, embedding, track_id, camera_id, quality, patient_id, captured_at),
)
conn.commit()
finally:
conn.close()
return record_id
def attach_track_to_patient(track_id: str, patient_id: str) -> int:
"""Привязывает все эмбеддинги трека к пациенту. Возвращает кол-во затронутых строк."""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"UPDATE face_embeddings SET patient_id = %s WHERE track_id = %s",
(patient_id, track_id),
)
affected = cur.rowcount
conn.commit()
finally:
conn.close()
return affected
# ---------- READ ----------
def find_topk_in_window(
embedding,
camera_id: str,
window_minutes: int = 5,
k: int = 5,
exclude_same_camera: bool = True,
) -> list[dict[str, Any]]:
"""Top-K ближайших эмбеддингов с других камер в окне последних window_minutes минут.
Используется для cross-camera re-id: на новой камере появился человек,
ищем тот же эмбеддинг с другой камеры в недавнем прошлом склейка треков.
"""
since = datetime.utcnow() - timedelta(minutes=window_minutes)
conn = get_connection()
try:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
sql = """
SELECT fe.track_id, fe.camera_id, fe.captured_at,
(fe.embedding <=> %s::vector) AS distance
FROM face_embeddings fe
WHERE fe.captured_at >= %s
AND fe.track_id IS NOT NULL
"""
params: list[Any] = [embedding, since]
if exclude_same_camera:
sql += " AND fe.camera_id <> %s"
params.append(camera_id)
sql += " ORDER BY distance ASC LIMIT %s"
params.append(k)
cur.execute(sql, params)
rows = cur.fetchall()
finally:
conn.close()
return [
{
"track_id": str(r["track_id"]),
"camera_id": str(r["camera_id"]),
"captured_at": r["captured_at"].isoformat(),
"distance": float(r["distance"]),
}
for r in rows
]
def find_nearest_patient(embedding, threshold: float) -> dict[str, Any] | None:
"""Узнать пациента по лицу: ищет ближайший эмбеддинг среди записей,
у которых patient_id IS NOT NULL (т.е. дано согласие).
Возвращает {patient_id, confidence, distance} или None.
"""
conn = get_connection()
try:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"""
SELECT fe.patient_id, (fe.embedding <=> %s::vector) AS distance
FROM face_embeddings fe
WHERE fe.patient_id IS NOT NULL
ORDER BY distance ASC
LIMIT 1
""",
(embedding,),
)
row = cur.fetchone()
finally:
conn.close()
if row is None:
return None
distance = float(row["distance"])
if distance > threshold:
return None
confidence = round(max(0.0, 1.0 - (distance / threshold)), 3)
return {
"patient_id": str(row["patient_id"]),
"confidence": confidence,
"distance": distance,
}
# ---------- DELETE ----------
def delete_patient_embeddings(patient_id: str) -> int:
"""Удаляет все эмбеддинги пациента. Возвращает кол-во удалённых.
Вызывается при отзыве согласия (через 24 ч).
"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM face_embeddings WHERE patient_id = %s",
(patient_id,),
)
deleted = cur.rowcount
conn.commit()
finally:
conn.close()
return deleted
def count_patient_embeddings(patient_id: str) -> int:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT COUNT(*) FROM face_embeddings WHERE patient_id = %s",
(patient_id,),
)
return cur.fetchone()[0]
finally:
conn.close()

93
apps/face-service/face_engine.py

@ -0,0 +1,93 @@
"""InsightFace wrapper: load model, decode images, extract 512-d embeddings.
Скопировано и расширено из work-pcs-adm-time-tracker. Импорты InsightFace и PIL
сделаны ленивыми face-service может запускаться без них (SKIP_MODEL_LOAD=true)
для интеграционных тестов raw-embedding эндпоинтов.
"""
import os
import base64
import io
import logging
import numpy as np
logger = logging.getLogger(__name__)
MODEL_NAME = os.getenv("MODEL_NAME", "buffalo_l")
DET_SCORE_THRESHOLD = float(os.getenv("DET_SCORE_THRESHOLD", "0.7"))
_app = None # FaceAnalysis | None — ленивый импорт.
def load_model():
global _app
if _app is not None:
return _app
from insightface.app import FaceAnalysis # ленивый импорт
logger.info(f"Загружаю модель InsightFace '{MODEL_NAME}'...")
app = FaceAnalysis(name=MODEL_NAME, providers=["CPUExecutionProvider"])
app.prepare(ctx_id=0, det_thresh=DET_SCORE_THRESHOLD, det_size=(640, 640))
_app = app
logger.info("Модель загружена.")
return _app
def decode_image(base64_str: str) -> np.ndarray:
"""Декодирует base64-строку в numpy-массив (BGR, формат OpenCV)."""
from PIL import Image # ленивый импорт
if "," in base64_str:
base64_str = base64_str.split(",", 1)[1]
image_bytes = base64.b64decode(base64_str)
image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
img_array = np.array(image)
return img_array[:, :, ::-1].copy()
def detect_best_face(base64_str: str):
"""Возвращает (embedding, quality, bbox_norm) лучшего лица или (None, None, None).
bbox_norm [x1, y1, x2, y2] в нормализованных 0..1 координатах относительно
размера изображения. UI рисует overlay поверх displayed image.
"""
app = load_model()
try:
img = decode_image(base64_str)
except Exception as e:
logger.warning(f"Ошибка декодирования изображения: {e}")
return None, None, None
faces = app.get(img)
if not faces:
return None, None, None
best_face = max(faces, key=lambda f: f.det_score)
if best_face.det_score < DET_SCORE_THRESHOLD:
return None, None, None
embedding = best_face.normed_embedding.astype(np.float32)
quality = float(best_face.det_score)
h, w = img.shape[:2]
box = best_face.bbox.tolist() # [x1, y1, x2, y2] в пикселях
bbox = {
"box": [
max(0, int(box[0])),
max(0, int(box[1])),
min(w, int(box[2])),
min(h, int(box[3])),
],
"imgW": w,
"imgH": h,
}
return embedding, quality, bbox
def get_embedding(base64_str: str) -> np.ndarray | None:
"""Обратная совместимость: только эмбеддинг лучшего лица."""
embedding, _, _ = detect_best_face(base64_str)
return embedding
def is_model_loaded() -> bool:
return _app is not None

288
apps/face-service/main.py

@ -0,0 +1,288 @@
"""face-service v2 для проекта «Цифровая рецепция».
Расширение time-tracker face-service: добавлены эндпоинты cross-camera re-id,
сохранения эмбеддингов с метаданными трека/камеры, узнавания пациента
(только среди эмбеддингов с согласием), удаления при отзыве согласия.
Эндпоинты:
GET /health статус + флаг loaded
POST /embed только эмбеддинг кадра (без БД)
POST /track-embeddings сохранить эмбеддинг с привязкой к треку
POST /reid/search cross-camera re-id (top-K в окне T мин)
POST /recognize узнать пациента (patient_id) по кадру
POST /enroll привязать эмбеддинги трека к пациенту
DELETE /patient/{patient_id}/embeddings удалить все эмбеддинги пациента
GET /patient/{patient_id}/count кол-во эмбеддингов у пациента
"""
import os
import logging
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Optional
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from database import (
save_embedding_with_meta,
attach_track_to_patient,
find_topk_in_window,
find_nearest_patient,
delete_patient_embeddings,
count_patient_embeddings,
)
from face_engine import detect_best_face, load_model, is_model_loaded
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Порог узнавания пациента по лицу (ТЗ §4.3 — после первой партии данных тюним).
RECOGNITION_THRESHOLD = float(os.getenv("RECOGNITION_THRESHOLD", "0.5"))
# Порог склейки треков cross-camera (строже — иначе ложные склейки).
REID_THRESHOLD = float(os.getenv("REID_THRESHOLD", "0.35"))
# Окно для cross-camera re-id (минуты).
DEFAULT_REID_WINDOW_MIN = int(os.getenv("REID_WINDOW_MINUTES", "5"))
@asynccontextmanager
async def lifespan(app: FastAPI):
if os.getenv("SKIP_MODEL_LOAD", "false").lower() == "true":
logger.warning("SKIP_MODEL_LOAD=true — модель не загружается, frame-эндпоинты вернут 503")
yield
return
try:
load_model()
except Exception as e: # noqa: BLE001
logger.error(f"Не удалось загрузить InsightFace: {e}. Frame-эндпоинты не будут работать.")
yield
app = FastAPI(title="reception/face-service", version="0.2.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# ---------- Схемы ----------
class EmbedRequest(BaseModel):
frame: str = Field(..., description="base64-encoded JPEG/PNG")
class FaceBbox(BaseModel):
box: list[int] # [x1, y1, x2, y2] в пикселях
imgW: int
imgH: int
class EmbedResponse(BaseModel):
embedding: list[float]
quality: float
bbox: FaceBbox | None = None
class TrackEmbeddingRequest(BaseModel):
frame: str
track_id: str
camera_id: str
captured_at: datetime | None = None
patient_id: str | None = None
class TrackEmbeddingResponse(BaseModel):
id: str
quality: float
bbox: FaceBbox | None = None
class ReidSearchRequest(BaseModel):
frame: str | None = None
embedding: list[float] | None = None
camera_id: str
window_minutes: int | None = None
k: int = 5
exclude_same_camera: bool = True
class ReidMatch(BaseModel):
track_id: str
camera_id: str
captured_at: str
distance: float
class ReidSearchResponse(BaseModel):
matches: list[ReidMatch]
threshold: float
class RecognizeRequest(BaseModel):
frame: str
class RecognizeResponse(BaseModel):
patient_id: str
confidence: float
distance: float
class EnrollRequest(BaseModel):
track_id: str
patient_id: str
class EnrollResponse(BaseModel):
ok: bool
embeddings_attached: int
class TrackEmbeddingRawRequest(BaseModel):
"""Сохранить готовый эмбеддинг без детекции лица — для fixtures-runner."""
embedding: list[float]
track_id: str
camera_id: str
captured_at: datetime | None = None
patient_id: str | None = None
quality: float = 0.9
class RecognizeEmbeddingRequest(BaseModel):
"""Узнать пациента по готовому эмбеддингу — для fixtures-runner."""
embedding: list[float]
# ---------- Эндпоинты ----------
@app.get("/health")
def health():
return {
"status": "ok",
"model_loaded": is_model_loaded(),
"recognition_threshold": RECOGNITION_THRESHOLD,
"reid_threshold": REID_THRESHOLD,
}
@app.post("/embed", response_model=Optional[EmbedResponse])
def embed(req: EmbedRequest):
"""Возвращает 512-d эмбеддинг лучшего лица на кадре, без записи в БД."""
embedding, quality, bbox = detect_best_face(req.frame)
if embedding is None:
return None
return EmbedResponse(embedding=embedding.tolist(), quality=quality, bbox=bbox)
@app.post("/track-embeddings", response_model=Optional[TrackEmbeddingResponse])
def store_track_embedding(req: TrackEmbeddingRequest):
"""Сохраняет эмбеддинг с привязкой к треку (используется video-ingest/fixtures)."""
embedding, quality, bbox = detect_best_face(req.frame)
if embedding is None:
return None
record_id = save_embedding_with_meta(
embedding=embedding,
track_id=req.track_id,
camera_id=req.camera_id,
quality=quality,
captured_at=req.captured_at,
patient_id=req.patient_id,
)
return TrackEmbeddingResponse(id=record_id, quality=quality, bbox=bbox)
@app.post("/reid/search", response_model=ReidSearchResponse)
def reid_search(req: ReidSearchRequest):
"""Cross-camera re-id: ищет top-K ближайших эмбеддингов с других камер в окне T мин."""
if req.embedding is None and req.frame is None:
raise HTTPException(400, "Нужен либо frame, либо embedding")
if req.embedding is not None:
embedding = req.embedding
else:
embedding, _quality, _bbox = detect_best_face(req.frame)
if embedding is None:
return ReidSearchResponse(matches=[], threshold=REID_THRESHOLD)
embedding = embedding.tolist()
window = req.window_minutes or DEFAULT_REID_WINDOW_MIN
matches = find_topk_in_window(
embedding=embedding,
camera_id=req.camera_id,
window_minutes=window,
k=req.k,
exclude_same_camera=req.exclude_same_camera,
)
return ReidSearchResponse(
matches=[ReidMatch(**m) for m in matches if m["distance"] <= REID_THRESHOLD],
threshold=REID_THRESHOLD,
)
@app.post("/recognize", response_model=Optional[RecognizeResponse])
def recognize(req: RecognizeRequest):
"""Узнавание пациента: ищет ближайший эмбеддинг среди записей с patient_id."""
embedding, _quality, _bbox = detect_best_face(req.frame)
if embedding is None:
return None
result = find_nearest_patient(embedding, threshold=RECOGNITION_THRESHOLD)
if result is None:
return None
return RecognizeResponse(**result)
@app.post("/track-embeddings/raw", response_model=TrackEmbeddingResponse)
def store_raw_track_embedding(req: TrackEmbeddingRawRequest):
"""Сохранить эмбеддинг без детекции лица. Использует fixtures-runner с
синтетическими векторами; в продовом потоке не используется."""
record_id = save_embedding_with_meta(
embedding=req.embedding,
track_id=req.track_id,
camera_id=req.camera_id,
quality=req.quality,
captured_at=req.captured_at,
patient_id=req.patient_id,
)
return TrackEmbeddingResponse(id=record_id, quality=req.quality)
@app.post("/recognize/embedding", response_model=Optional[RecognizeResponse])
def recognize_by_embedding(req: RecognizeEmbeddingRequest):
"""Узнать пациента по готовому эмбеддингу (для fixtures-runner)."""
result = find_nearest_patient(req.embedding, threshold=RECOGNITION_THRESHOLD)
if result is None:
return None
return RecognizeResponse(**result)
@app.post("/enroll", response_model=EnrollResponse)
def enroll(req: EnrollRequest):
"""Привязывает все эмбеддинги трека к пациенту (после согласия)."""
affected = attach_track_to_patient(req.track_id, req.patient_id)
if affected == 0:
raise HTTPException(404, f"Не найдено эмбеддингов для трека {req.track_id}")
logger.info(f"Enroll: трек {req.track_id} → пациент {req.patient_id} ({affected} эмбеддингов)")
return EnrollResponse(ok=True, embeddings_attached=affected)
@app.delete("/patient/{patient_id}/embeddings")
def delete_embeddings(patient_id: str):
deleted = delete_patient_embeddings(patient_id)
logger.info(f"Отозвано согласие пациента {patient_id}: удалено {deleted} эмбеддингов")
return {"ok": True, "deleted": deleted}
@app.get("/patient/{patient_id}/count")
def patient_count(patient_id: str):
count = count_patient_embeddings(patient_id)
return {"patient_id": patient_id, "count": count}

22
apps/face-service/requirements.txt

@ -0,0 +1,22 @@
fastapi==0.115.5
uvicorn[standard]==0.32.1
insightface==0.7.3
onnxruntime==1.24.1
opencv-python-headless==4.11.0.86
numpy==2.2.3
psycopg2-binary==2.9.11
pgvector==0.3.6
python-dotenv==1.0.1
Pillow==12.1.0
onnx
tqdm
prettytable
requests
scipy
scikit-learn
scikit-image
matplotlib
albumentations
easydict
cython
pytest==8.3.4

0
apps/face-service/tests/__init__.py

77
apps/face-service/tests/conftest.py

@ -0,0 +1,77 @@
"""Pytest-фикстуры для face-service.
Подключение к локальному pgvector через DATABASE_URL из корневого .env.
Тесты re-id логики работают на чистых эмбеддингах модель InsightFace не нужна.
"""
import os
import sys
import uuid
from pathlib import Path
import pytest
from dotenv import load_dotenv
# Подгружаем корневой .env и .env сервиса.
load_dotenv(Path(__file__).parent.parent / ".env")
load_dotenv(Path(__file__).parent.parent.parent.parent / ".env")
sys.path.insert(0, str(Path(__file__).parent.parent))
# Импортируем после load_dotenv, чтобы DATABASE_URL подцепился.
from database import get_connection # noqa: E402
@pytest.fixture
def db_conn():
conn = get_connection()
yield conn
conn.close()
@pytest.fixture
def seed_camera_and_track(db_conn):
"""Создаёт уникальные camera_id + track_id для теста и убирает после."""
camera_ids = []
track_ids = []
def _make(zone_code: str = "A"):
cam_id = str(uuid.uuid4())
zone_id = str(uuid.uuid4())
track_id = str(uuid.uuid4())
with db_conn.cursor() as cur:
cur.execute(
"INSERT INTO zones (id, code, name) VALUES (%s, %s::\"ZoneCode\", %s)"
" ON CONFLICT (code) DO NOTHING RETURNING id",
(zone_id, zone_code, f"test-zone-{zone_code}"),
)
row = cur.fetchone()
if row is None:
cur.execute('SELECT id FROM zones WHERE code = %s::"ZoneCode"', (zone_code,))
zone_id = str(cur.fetchone()[0])
cur.execute(
"INSERT INTO cameras (id, name, zone_id) VALUES (%s, %s, %s)",
(cam_id, f"test-cam-{cam_id[:6]}", zone_id),
)
cur.execute(
"INSERT INTO tracks (id, status, first_seen_at, last_seen_at, updated_at)"
" VALUES (%s, 'UNMATCHED', NOW(), NOW(), NOW())",
(track_id,),
)
db_conn.commit()
camera_ids.append(cam_id)
track_ids.append(track_id)
return cam_id, track_id
yield _make
# Cleanup
with db_conn.cursor() as cur:
if track_ids:
cur.execute(
"DELETE FROM face_embeddings WHERE track_id = ANY(%s::uuid[])",
(track_ids,),
)
cur.execute("DELETE FROM tracks WHERE id = ANY(%s::uuid[])", (track_ids,))
if camera_ids:
cur.execute("DELETE FROM cameras WHERE id = ANY(%s::uuid[])", (camera_ids,))
db_conn.commit()

124
apps/face-service/tests/test_reid.py

@ -0,0 +1,124 @@
"""Тесты cross-camera re-id логики.
Используем синтетические 512-мерные эмбеддинги (без InsightFace).
Проверяем, что find_topk_in_window:
1. Возвращает соседей в правильном порядке по cos-дистанции.
2. Фильтрует по camera_id (исключает ту же камеру).
3. Фильтрует по временному окну.
"""
from datetime import datetime, timedelta
import numpy as np
import pytest
from database import (
save_embedding_with_meta,
find_topk_in_window,
find_nearest_patient,
attach_track_to_patient,
delete_patient_embeddings,
)
def normed(vec: np.ndarray) -> np.ndarray:
return (vec / np.linalg.norm(vec)).astype(np.float32)
def make_embedding(seed: int) -> np.ndarray:
rng = np.random.default_rng(seed)
return normed(rng.standard_normal(512))
def test_topk_in_window_basic(seed_camera_and_track):
"""Из 3 эмбеддингов на 3 разных камерах находим 2 ближайших к query (исключая саму камеру query)."""
cam_a, track_a = seed_camera_and_track("A")
cam_b, track_b = seed_camera_and_track("B")
cam_c, track_c = seed_camera_and_track("C")
base = make_embedding(seed=42)
# Соседи: tweak base слегка для cam_b, сильнее для cam_c.
near = normed(base + 0.05 * make_embedding(seed=43))
far = normed(base + 0.5 * make_embedding(seed=44))
save_embedding_with_meta(base, track_a, cam_a, quality=0.9, captured_at=datetime.utcnow())
save_embedding_with_meta(near, track_b, cam_b, quality=0.9, captured_at=datetime.utcnow())
save_embedding_with_meta(far, track_c, cam_c, quality=0.9, captured_at=datetime.utcnow())
# Запрос с cam_a — должен вернуть cam_b раньше cam_c, cam_a исключаем.
results = find_topk_in_window(
embedding=base.tolist(),
camera_id=cam_a,
window_minutes=5,
k=5,
exclude_same_camera=True,
)
cam_ids_in_results = [r["camera_id"] for r in results]
assert cam_a not in cam_ids_in_results
assert cam_b in cam_ids_in_results
assert cam_c in cam_ids_in_results
# Порядок: ближе → дальше
assert results[0]["camera_id"] == cam_b
assert results[0]["distance"] < results[-1]["distance"]
def test_topk_filters_by_window(seed_camera_and_track):
"""Старый эмбеддинг (вне окна) не должен попадать в результат."""
cam_a, track_a = seed_camera_and_track("A")
cam_b, track_b = seed_camera_and_track("B")
base = make_embedding(seed=7)
save_embedding_with_meta(
base, track_b, cam_b, quality=0.9,
captured_at=datetime.utcnow() - timedelta(hours=1), # вне окна 5 мин
)
results = find_topk_in_window(
embedding=base.tolist(),
camera_id=cam_a,
window_minutes=5,
k=5,
)
cam_ids = [r["camera_id"] for r in results]
assert cam_b not in cam_ids
def test_find_nearest_patient_only_consented(db_conn, seed_camera_and_track):
"""find_nearest_patient ищет только среди эмбеддингов с patient_id IS NOT NULL."""
cam_a, track_a = seed_camera_and_track("A")
base = make_embedding(seed=100)
# Сохраняем эмбеддинг без patient_id.
save_embedding_with_meta(base, track_a, cam_a, quality=0.9)
# Ищем — никого не должно найти.
assert find_nearest_patient(base.tolist(), threshold=0.5) is None
# Создаём пациента и привязываем трек.
import uuid
patient_id = str(uuid.uuid4())
with db_conn.cursor() as cur:
cur.execute(
"INSERT INTO patients (id, full_name, updated_at) VALUES (%s, %s, NOW())",
(patient_id, "Тестовый Пациент"),
)
db_conn.commit()
affected = attach_track_to_patient(track_a, patient_id)
assert affected == 1
# Теперь должны найти.
result = find_nearest_patient(base.tolist(), threshold=0.5)
assert result is not None
assert result["patient_id"] == patient_id
assert result["distance"] < 0.01 # тот же эмбеддинг
# Очистка.
deleted = delete_patient_embeddings(patient_id)
assert deleted == 1
with db_conn.cursor() as cur:
cur.execute("DELETE FROM patients WHERE id = %s", (patient_id,))
db_conn.commit()

40
apps/fixtures-runner/README.md

@ -0,0 +1,40 @@
# fixtures-runner
E2E-сценарии для Фазы 1. Генерирует синтетические треки с детерминированными эмбеддингами (через persona seed) и шлёт их через `apps/api /ingest/*` + `face-service /track-embeddings/raw`. Не требует реальных видео.
## Запуск
```bash
# Список сценариев
pnpm fixtures:run --list
# Запуск
pnpm fixtures:run --scenario=new-patient
pnpm fixtures:run --scenario=returning-patient
pnpm fixtures:run --scenario=left-without-service
# Realtime — с задержками между событиями (как «живой» поток)
pnpm fixtures:run --scenario=new-patient --mode=realtime
```
## Сценарии
| Имя | Описание |
|---|---|
| `new-patient` | A → B → C. Создаёт unmatched-трек для ручного enrollment в web-admin. |
| `returning-patient` | Тот же `personaSeed=1001` как у new-patient. После enrollment персона должна быть узнана автоматически → создаётся Visit. **Это критерий завершения Ф1.** |
| `left-without-service` | A → B → уход. Триггерит событие `left_without_service`. |
## Как это работает
- `personaSeed` → детерминированный 512-d L2-нормализованный вектор. Тот же seed = тот же вектор (с малым jitter между эмбеддингами одного трека). После enrollment в `new-patient` вектор привязан к `patient_id`. В `returning-patient` тот же seed → match.
- Эмбеддинги пишутся через face-service `/track-embeddings/raw` (без InsightFace-детекции, эмбеддинг сразу передаётся).
- События — через `apps/api /ingest/track-events`.
- При `triggerRecognition=true` после прогона runner вызывает `/recognize/embedding` и при match создаёт Visit напрямую через Prisma.
## Критерий приёмки M11
После прогона:
1. `pnpm fixtures:run --scenario=new-patient` — в БД появился unmatched-трек.
2. Через web-admin (или curl) — `POST /enrollment` для этого трека.
3. `pnpm fixtures:run --scenario=returning-patient``runner.visitCreated !== null`, в БД есть новый Visit с привязкой к тому же `patientId`.

22
apps/fixtures-runner/package.json

@ -0,0 +1,22 @@
{
"name": "@reception/fixtures-runner",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/main.ts",
"lint": "eslint src --ext .ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@reception/db": "workspace:*",
"dotenv": "^16.4.7"
},
"devDependencies": {
"@reception/eslint-config": "workspace:*",
"@reception/tsconfig": "workspace:*",
"@types/node": "^22.9.0",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
}
}

12
apps/fixtures-runner/scenarios/left-without-service.json

@ -0,0 +1,12 @@
{
"name": "left-without-service",
"description": "Пациент входит, ждёт, уходит без обслуживания (триггер left_without_service).",
"personaSeed": 2002,
"embeddingsPerCamera": 2,
"triggerRecognition": false,
"events": [
{ "type": "arrived", "cameraName": "cam-entrance", "zoneCode": "A", "offsetSec": 0 },
{ "type": "waiting", "cameraName": "cam-corridor", "zoneCode": "B", "offsetSec": 15 },
{ "type": "left_without_service", "cameraName": "cam-corridor", "zoneCode": "B", "offsetSec": 600 }
]
}

13
apps/fixtures-runner/scenarios/new-patient.json

@ -0,0 +1,13 @@
{
"name": "new-patient",
"description": "Новый пациент: входит (A) → ждёт (B) → обслуживается у стойки (C). Создаёт unmatched-трек для ручного enrollment.",
"personaSeed": 1001,
"embeddingsPerCamera": 3,
"triggerRecognition": false,
"events": [
{ "type": "arrived", "cameraName": "cam-entrance", "zoneCode": "A", "offsetSec": 0 },
{ "type": "waiting", "cameraName": "cam-corridor", "zoneCode": "B", "offsetSec": 20 },
{ "type": "service_started", "cameraName": "cam-reception", "zoneCode": "C", "offsetSec": 180 },
{ "type": "service_ended", "cameraName": "cam-reception", "zoneCode": "C", "offsetSec": 480 }
]
}

11
apps/fixtures-runner/scenarios/returning-patient.json

@ -0,0 +1,11 @@
{
"name": "returning-patient",
"description": "Тот же пациент (тот же personaSeed) приходит повторно. После enrollment в new-patient — system должна узнать его, создать Visit автоматически. Критерий завершения Ф1.",
"personaSeed": 1001,
"embeddingsPerCamera": 2,
"triggerRecognition": true,
"events": [
{ "type": "arrived", "cameraName": "cam-entrance", "zoneCode": "A", "offsetSec": 0 },
{ "type": "waiting", "cameraName": "cam-corridor", "zoneCode": "B", "offsetSec": 25 }
]
}

85
apps/fixtures-runner/src/clients.ts

@ -0,0 +1,85 @@
export interface IngestTrackResponse {
trackId: string;
cameraId: string;
zoneId: string;
}
export class ApiClient {
constructor(private readonly baseUrl: string) {}
async createTrack(cameraName: string, firstSeenAt: Date): Promise<IngestTrackResponse> {
return this.post('/ingest/tracks', { cameraName, firstSeenAt: firstSeenAt.toISOString() });
}
async addEvent(opts: {
trackId: string;
type: string;
cameraName: string;
zoneCode: string;
occurredAt: Date;
}) {
return this.post('/ingest/track-events', {
trackId: opts.trackId,
type: opts.type,
cameraName: opts.cameraName,
zoneCode: opts.zoneCode,
occurredAt: opts.occurredAt.toISOString(),
});
}
private async post<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`POST ${path} ${res.status}: ${text}`);
}
return (await res.json()) as T;
}
}
export interface RecognizeResult {
patient_id: string;
confidence: number;
distance: number;
}
export class FaceServiceClient {
constructor(private readonly baseUrl: string) {}
async saveRawEmbedding(opts: {
embedding: number[];
trackId: string;
cameraId: string;
capturedAt: Date;
quality?: number;
}): Promise<{ id: string }> {
return this.post('/track-embeddings/raw', {
embedding: opts.embedding,
track_id: opts.trackId,
camera_id: opts.cameraId,
captured_at: opts.capturedAt.toISOString(),
quality: opts.quality ?? 0.9,
});
}
async recognizeByEmbedding(embedding: number[]): Promise<RecognizeResult | null> {
return this.post('/recognize/embedding', { embedding });
}
private async post<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`POST ${path} ${res.status}: ${text}`);
}
return (await res.json()) as T;
}
}

40
apps/fixtures-runner/src/embedding.ts

@ -0,0 +1,40 @@
/** Детерминированный 512-d L2-нормализованный псевдо-эмбеддинг. */
import { createHash } from 'node:crypto';
function seededRandom(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 0xffffffff;
};
}
export function makeEmbedding(seed: number, jitter = 0): number[] {
// Гауссово приближение через 12-tap uniform.
const rng = seededRandom(seed);
const jitterRng = seededRandom(seed + Math.floor(jitter * 1000) + 7);
const vec = new Array<number>(512);
let norm = 0;
for (let i = 0; i < 512; i++) {
let s = 0;
for (let k = 0; k < 12; k++) s += rng();
const base = s - 6;
let noise = 0;
if (jitter > 0) {
let s2 = 0;
for (let k = 0; k < 12; k++) s2 += jitterRng();
noise = (s2 - 6) * jitter;
}
const v = base + noise;
vec[i] = v;
norm += v * v;
}
norm = Math.sqrt(norm) || 1;
for (let i = 0; i < 512; i++) vec[i] /= norm;
return vec;
}
/** Хеш для логов. */
export function embeddingFingerprint(embedding: number[]): string {
return createHash('sha1').update(embedding.slice(0, 32).join(',')).digest('hex').slice(0, 12);
}

76
apps/fixtures-runner/src/main.ts

@ -0,0 +1,76 @@
import 'dotenv/config';
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { PrismaClient } from '@reception/db';
import { runScenario } from './runner.js';
import type { Scenario } from './types.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCENARIOS_DIR = resolve(__dirname, '..', 'scenarios');
function parseArgs(argv: string[]): { scenario?: string; mode: 'realtime' | 'fast'; list: boolean } {
let scenario: string | undefined;
let mode: 'realtime' | 'fast' = 'fast';
let list = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--list') list = true;
else if (arg?.startsWith('--scenario=')) scenario = arg.slice('--scenario='.length);
else if (arg === '--scenario') scenario = argv[++i];
else if (arg?.startsWith('--mode=')) mode = arg.slice('--mode='.length) as 'realtime' | 'fast';
else if (arg === '--mode') mode = argv[++i] as 'realtime' | 'fast';
}
return { scenario, mode, list };
}
function loadScenario(name: string): Scenario {
const path = join(SCENARIOS_DIR, `${name}.json`);
if (!existsSync(path)) {
throw new Error(`Сценарий "${name}" не найден в ${SCENARIOS_DIR}`);
}
return JSON.parse(readFileSync(path, 'utf-8')) as Scenario;
}
async function main() {
const { scenario, mode, list } = parseArgs(process.argv.slice(2));
if (list || !scenario) {
const files = readdirSync(SCENARIOS_DIR).filter((f) => f.endsWith('.json'));
console.log('Доступные сценарии:');
for (const f of files) {
const s = JSON.parse(readFileSync(join(SCENARIOS_DIR, f), 'utf-8')) as Scenario;
console.log(` ${s.name.padEnd(24)}${s.description}`);
}
if (!scenario) {
console.log('\nИспользование: pnpm fixtures:run --scenario=new-patient [--mode=realtime|fast]');
// --list — нормальный код выхода 0. Если просто отсутствует --scenario, тоже 0.
return;
}
if (list) return; // --list + --scenario — показать список и продолжить выполнение? нет, выйти.
}
const apiBaseUrl = process.env.API_BASE_URL ?? 'http://localhost:4000';
const faceServiceUrl = process.env.FACE_SERVICE_URL ?? 'http://localhost:8001';
console.log(`Загружаю сценарий ${scenario}, mode=${mode}, api=${apiBaseUrl}, face=${faceServiceUrl}`);
const s = loadScenario(scenario);
const prisma = new PrismaClient();
try {
const result = await runScenario({
scenario: s,
apiBaseUrl,
faceServiceUrl,
prisma,
realtime: mode === 'realtime',
});
console.log('\nИтог:', JSON.stringify(result, null, 2));
} finally {
await prisma.$disconnect();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

123
apps/fixtures-runner/src/runner.ts

@ -0,0 +1,123 @@
import { PrismaClient } from '@reception/db';
import { ApiClient, FaceServiceClient } from './clients.js';
import { makeEmbedding, embeddingFingerprint } from './embedding.js';
import type { Scenario } from './types.js';
export interface RunOptions {
scenario: Scenario;
apiBaseUrl: string;
faceServiceUrl: string;
prisma: PrismaClient;
realtime: boolean;
}
export interface RunResult {
trackIds: string[];
embeddingsSaved: number;
eventsSent: number;
visitCreated?: { visitId: string; patientId: string; confidence: number } | null;
}
/**
* Прогоняет сценарий: создаёт треки (по одному на камеру в сценарии),
* сохраняет эмбеддинги, шлёт события. Если scenario.triggerRecognition
* после всех событий пытается узнать persona и создать Visit.
*/
export async function runScenario(opts: RunOptions): Promise<RunResult> {
const { scenario, apiBaseUrl, faceServiceUrl, prisma } = opts;
const api = new ApiClient(apiBaseUrl);
const face = new FaceServiceClient(faceServiceUrl);
const baseTime = new Date();
const personaEmbedding = makeEmbedding(scenario.personaSeed, 0);
console.log(
`[scenario:${scenario.name}] persona seed=${scenario.personaSeed}, fp=${embeddingFingerprint(personaEmbedding)}`,
);
const tracksByCamera = new Map<string, { trackId: string; cameraId: string; zoneCode: string }>();
const result: RunResult = { trackIds: [], embeddingsSaved: 0, eventsSent: 0, visitCreated: null };
for (const event of scenario.events) {
const occurredAt = new Date(baseTime.getTime() + event.offsetSec * 1000);
let track = tracksByCamera.get(event.cameraName);
if (!track) {
const created = await api.createTrack(event.cameraName, occurredAt);
track = { trackId: created.trackId, cameraId: created.cameraId, zoneCode: event.zoneCode };
tracksByCamera.set(event.cameraName, track);
result.trackIds.push(track.trackId);
// Сохраняем N эмбеддингов с малым jitter — чтобы было что искать.
for (let i = 0; i < scenario.embeddingsPerCamera; i++) {
const jittered = makeEmbedding(scenario.personaSeed, 0.0001 * i);
await face.saveRawEmbedding({
embedding: jittered,
trackId: track.trackId,
cameraId: track.cameraId,
capturedAt: new Date(occurredAt.getTime() + i * 100),
});
result.embeddingsSaved += 1;
}
}
if (opts.realtime && event.offsetSec > 0) {
await sleep(Math.min(event.offsetSec * 1000, 5000));
}
await api.addEvent({
trackId: track.trackId,
type: event.type,
cameraName: event.cameraName,
zoneCode: event.zoneCode,
occurredAt,
});
result.eventsSent += 1;
console.log(
`[scenario:${scenario.name}] event ${event.type} on ${event.cameraName} at +${event.offsetSec}s`,
);
}
if (scenario.triggerRecognition) {
const match = await face.recognizeByEmbedding(personaEmbedding);
if (match) {
console.log(
`[scenario:${scenario.name}] recognized patient ${match.patient_id} (confidence=${match.confidence})`,
);
// Если уже есть Visit для этого пациента в окне ~5 мин — переиспользуем (idempotent).
const recentVisit = await prisma.visit.findFirst({
where: {
patientId: match.patient_id,
arrivedAt: { gte: new Date(Date.now() - 5 * 60 * 1000) },
},
});
if (recentVisit) {
result.visitCreated = {
visitId: recentVisit.id,
patientId: match.patient_id,
confidence: match.confidence,
};
} else {
const visit = await prisma.visit.create({
data: { patientId: match.patient_id, arrivedAt: baseTime },
});
result.visitCreated = {
visitId: visit.id,
patientId: match.patient_id,
confidence: match.confidence,
};
}
} else {
console.log(`[scenario:${scenario.name}] persona не узнан (нет enrolled-эмбеддинга)`);
}
}
console.log(
`[scenario:${scenario.name}] DONE — tracks=${result.trackIds.length}, embeddings=${result.embeddingsSaved}, events=${result.eventsSent}, visit=${result.visitCreated ? 'created' : 'none'}`,
);
return result;
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}

24
apps/fixtures-runner/src/types.ts

@ -0,0 +1,24 @@
export type ZoneCode = 'A' | 'B' | 'C';
export type TrackEventType =
| 'arrived'
| 'waiting'
| 'service_started'
| 'service_ended'
| 'left_without_service';
export interface ScenarioEvent {
type: TrackEventType;
cameraName: string;
zoneCode: ZoneCode;
offsetSec: number; // секунды от начала сценария
}
export interface Scenario {
name: string;
description: string;
personaSeed: number; // одинаковый seed → одинаковый эмбеддинг (узнавание персонажа)
embeddingsPerCamera: number;
events: ScenarioEvent[];
/** Если true, после прогона ходим в /face-service/recognize/embedding и при match создаём Visit. */
triggerRecognition: boolean;
}

10
apps/fixtures-runner/tsconfig.json

@ -0,0 +1,10 @@
{
"extends": "@reception/tsconfig/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"noEmit": false,
"noUncheckedIndexedAccess": false
},
"include": ["src/**/*.ts"]
}

27
apps/polimed-mock/README.md

@ -0,0 +1,27 @@
# polimed-mock
In-memory мок МИС Полимед для разработки Цифровой рецепции. Заменяется на реальный SDK Полимед в более поздних фазах — нужно поменять только `POLIMED_BASE_URL` в `apps/api`.
Порт: **4100** (см. `.env`).
## Эндпоинты
| Метод | Путь | Описание |
|---|---|---|
| `GET` | `/health` | Health-чек |
| `GET` | `/patients/search?q=&limit=20` | Поиск пациентов по ФИО / № карты / телефону |
| `GET` | `/appointments?date=YYYY-MM-DD` | Журнал записей. Без `date` — все. |
| `GET` | `/appointments/:id` | Детали записи |
| `GET` | `/appointments/:id/events` | События визита (отправленные через POST) |
| `POST` | `/visits/:appointmentId/events` | Write-back события визита (`arrived` / `service_started` / `service_ended` / `left_without_service`) |
## Данные
Пациенты и шаблоны записей лежат в `seeds/*.json`. На старте записи пересчитываются на сегодняшнюю дату (`hourOffset` + `minuteOffset` относительно 08:00) — журнал «на сегодня» всегда живой.
## Запуск
```bash
pnpm --filter=@reception/polimed-mock dev
# curl http://localhost:4100/appointments?date=$(date +%F)
```

15
apps/polimed-mock/nest-cli.json

@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"assets": [
{
"include": "../seeds/**/*.json",
"outDir": "dist/seeds",
"watchAssets": true
}
],
"watchAssets": true
}
}

30
apps/polimed-mock/package.json

@ -0,0 +1,30 @@
{
"name": "@reception/polimed-mock",
"version": "0.0.1",
"private": true,
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "node dist/main.js",
"lint": "eslint src --ext .ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@nestjs/common": "^10.4.15",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@reception/eslint-config": "workspace:*",
"@reception/tsconfig": "workspace:*",
"@types/express": "^5.0.0",
"@types/node": "^22.9.0",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
}
}

22
apps/polimed-mock/seeds/appointments.json

@ -0,0 +1,22 @@
[
{ "id": "pol-a-001", "patientId": "pol-p-001", "patientFullName": "Иванов Иван Иванович", "doctorFullName": "Лазарева Е.А.", "specialty": "Терапевт", "status": "scheduled", "hourOffset": 0, "minuteOffset": 30 },
{ "id": "pol-a-002", "patientId": "pol-p-002", "patientFullName": "Петрова Анна Сергеевна", "doctorFullName": "Лазарева Е.А.", "specialty": "Терапевт", "status": "scheduled", "hourOffset": 1, "minuteOffset": 0 },
{ "id": "pol-a-003", "patientId": "pol-p-003", "patientFullName": "Смирнов Олег Петрович", "doctorFullName": "Захаров Д.Ю.", "specialty": "Кардиолог", "status": "scheduled", "hourOffset": 1, "minuteOffset": 30 },
{ "id": "pol-a-004", "patientId": "pol-p-004", "patientFullName": "Кузнецова Мария Андреевна", "doctorFullName": "Виноградова О.С.", "specialty": "Эндокринолог", "status": "scheduled", "hourOffset": 2, "minuteOffset": 0 },
{ "id": "pol-a-005", "patientId": "pol-p-005", "patientFullName": "Васильев Дмитрий Николаевич", "doctorFullName": "Захаров Д.Ю.", "specialty": "Кардиолог", "status": "scheduled", "hourOffset": 2, "minuteOffset": 30 },
{ "id": "pol-a-006", "patientId": "pol-p-006", "patientFullName": "Соколова Елена Викторовна", "doctorFullName": "Лазарева Е.А.", "specialty": "Терапевт", "status": "scheduled", "hourOffset": 3, "minuteOffset": 0 },
{ "id": "pol-a-007", "patientId": "pol-p-007", "patientFullName": "Михайлов Сергей Александрович", "doctorFullName": "Виноградова О.С.", "specialty": "Эндокринолог", "status": "scheduled", "hourOffset": 3, "minuteOffset": 30 },
{ "id": "pol-a-008", "patientId": "pol-p-008", "patientFullName": "Новикова Ольга Дмитриевна", "doctorFullName": "Зайцев Р.В.", "specialty": "Хирург", "status": "scheduled", "hourOffset": 4, "minuteOffset": 0 },
{ "id": "pol-a-009", "patientId": "pol-p-009", "patientFullName": "Фёдоров Александр Юрьевич", "doctorFullName": "Зайцев Р.В.", "specialty": "Хирург", "status": "scheduled", "hourOffset": 4, "minuteOffset": 30 },
{ "id": "pol-a-010", "patientId": "pol-p-010", "patientFullName": "Морозова Татьяна Игоревна", "doctorFullName": "Лазарева Е.А.", "specialty": "Терапевт", "status": "scheduled", "hourOffset": 5, "minuteOffset": 0 },
{ "id": "pol-a-011", "patientId": "pol-p-011", "patientFullName": "Волков Артём Сергеевич", "doctorFullName": "Захаров Д.Ю.", "specialty": "Кардиолог", "status": "scheduled", "hourOffset": 5, "minuteOffset": 30 },
{ "id": "pol-a-012", "patientId": "pol-p-012", "patientFullName": "Алексеева Наталья Павловна", "doctorFullName": "Виноградова О.С.", "specialty": "Эндокринолог", "status": "scheduled", "hourOffset": 6, "minuteOffset": 0 },
{ "id": "pol-a-013", "patientId": "pol-p-013", "patientFullName": "Лебедев Григорий Михайлович", "doctorFullName": "Зайцев Р.В.", "specialty": "Хирург", "status": "scheduled", "hourOffset": 6, "minuteOffset": 30 },
{ "id": "pol-a-014", "patientId": "pol-p-014", "patientFullName": "Семёнова Екатерина Олеговна", "doctorFullName": "Лазарева Е.А.", "specialty": "Терапевт", "status": "scheduled", "hourOffset": 7, "minuteOffset": 0 },
{ "id": "pol-a-015", "patientId": "pol-p-015", "patientFullName": "Егоров Виталий Андреевич", "doctorFullName": "Захаров Д.Ю.", "specialty": "Кардиолог", "status": "scheduled", "hourOffset": 7, "minuteOffset": 30 },
{ "id": "pol-a-016", "patientId": "pol-p-016", "patientFullName": "Павлова Дарья Ивановна", "doctorFullName": "Виноградова О.С.", "specialty": "Эндокринолог", "status": "scheduled", "hourOffset": 8, "minuteOffset": 0 },
{ "id": "pol-a-017", "patientId": "pol-p-017", "patientFullName": "Степанов Николай Романович", "doctorFullName": "Зайцев Р.В.", "specialty": "Хирург", "status": "scheduled", "hourOffset": 8, "minuteOffset": 30 },
{ "id": "pol-a-018", "patientId": "pol-p-018", "patientFullName": "Никитина Юлия Викторовна", "doctorFullName": "Лазарева Е.А.", "specialty": "Терапевт", "status": "scheduled", "hourOffset": 9, "minuteOffset": 0 },
{ "id": "pol-a-019", "patientId": "pol-p-019", "patientFullName": "Орлов Максим Денисович", "doctorFullName": "Захаров Д.Ю.", "specialty": "Кардиолог", "status": "scheduled", "hourOffset": 9, "minuteOffset": 30 },
{ "id": "pol-a-020", "patientId": "pol-p-020", "patientFullName": "Андреева Светлана Геннадьевна", "doctorFullName": "Виноградова О.С.", "specialty": "Эндокринолог", "status": "scheduled", "hourOffset": 10, "minuteOffset": 0 }
]

22
apps/polimed-mock/seeds/patients.json

@ -0,0 +1,22 @@
[
{ "id": "pol-p-001", "fullName": "Иванов Иван Иванович", "birthDate": "1980-03-12", "phone": "+79001234501", "cardNumber": "K-001" },
{ "id": "pol-p-002", "fullName": "Петрова Анна Сергеевна", "birthDate": "1992-07-23", "phone": "+79001234502", "cardNumber": "K-002" },
{ "id": "pol-p-003", "fullName": "Смирнов Олег Петрович", "birthDate": "1975-11-04", "phone": "+79001234503", "cardNumber": "K-003" },
{ "id": "pol-p-004", "fullName": "Кузнецова Мария Андреевна", "birthDate": "1988-01-18", "phone": "+79001234504", "cardNumber": "K-004" },
{ "id": "pol-p-005", "fullName": "Васильев Дмитрий Николаевич", "birthDate": "1965-09-30", "phone": "+79001234505", "cardNumber": "K-005" },
{ "id": "pol-p-006", "fullName": "Соколова Елена Викторовна", "birthDate": "1990-04-14", "phone": "+79001234506", "cardNumber": "K-006" },
{ "id": "pol-p-007", "fullName": "Михайлов Сергей Александрович", "birthDate": "1983-12-08", "phone": "+79001234507", "cardNumber": "K-007" },
{ "id": "pol-p-008", "fullName": "Новикова Ольга Дмитриевна", "birthDate": "1978-06-27", "phone": "+79001234508", "cardNumber": "K-008" },
{ "id": "pol-p-009", "fullName": "Фёдоров Александр Юрьевич", "birthDate": "1995-02-11", "phone": "+79001234509", "cardNumber": "K-009" },
{ "id": "pol-p-010", "fullName": "Морозова Татьяна Игоревна", "birthDate": "1972-08-19", "phone": "+79001234510", "cardNumber": "K-010" },
{ "id": "pol-p-011", "fullName": "Волков Артём Сергеевич", "birthDate": "1986-05-03", "phone": "+79001234511", "cardNumber": "K-011" },
{ "id": "pol-p-012", "fullName": "Алексеева Наталья Павловна", "birthDate": "1969-10-25", "phone": "+79001234512", "cardNumber": "K-012" },
{ "id": "pol-p-013", "fullName": "Лебедев Григорий Михайлович", "birthDate": "1991-12-15", "phone": "+79001234513", "cardNumber": "K-013" },
{ "id": "pol-p-014", "fullName": "Семёнова Екатерина Олеговна", "birthDate": "1984-03-07", "phone": "+79001234514", "cardNumber": "K-014" },
{ "id": "pol-p-015", "fullName": "Егоров Виталий Андреевич", "birthDate": "1977-07-29", "phone": "+79001234515", "cardNumber": "K-015" },
{ "id": "pol-p-016", "fullName": "Павлова Дарья Ивановна", "birthDate": "1996-09-12", "phone": "+79001234516", "cardNumber": "K-016" },
{ "id": "pol-p-017", "fullName": "Степанов Николай Романович", "birthDate": "1962-11-21", "phone": "+79001234517", "cardNumber": "K-017" },
{ "id": "pol-p-018", "fullName": "Никитина Юлия Викторовна", "birthDate": "1989-04-06", "phone": "+79001234518", "cardNumber": "K-018" },
{ "id": "pol-p-019", "fullName": "Орлов Максим Денисович", "birthDate": "1993-01-30", "phone": "+79001234519", "cardNumber": "K-019" },
{ "id": "pol-p-020", "fullName": "Андреева Светлана Геннадьевна", "birthDate": "1981-08-09", "phone": "+79001234520", "cardNumber": "K-020" }
]

9
apps/polimed-mock/src/app.module.ts

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PolimedController } from './polimed.controller';
import { PolimedStore } from './polimed.store';
@Module({
controllers: [PolimedController],
providers: [PolimedStore],
})
export class AppModule {}

15
apps/polimed-mock/src/main.ts

@ -0,0 +1,15 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { Logger, ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
app.enableCors({ origin: true });
const port = Number(process.env.POLIMED_MOCK_PORT ?? 4100);
await app.listen(port);
Logger.log(`polimed-mock listening on http://localhost:${port}`, 'Bootstrap');
}
bootstrap();

52
apps/polimed-mock/src/polimed.controller.ts

@ -0,0 +1,52 @@
import { Body, Controller, Get, HttpCode, Param, Post, Query } from '@nestjs/common';
import { IsEnum, IsISO8601, IsOptional, IsString } from 'class-validator';
import { PolimedStore } from './polimed.store';
import type { VisitEventType } from './polimed.types';
class PushVisitEventDto {
@IsEnum(['arrived', 'service_started', 'service_ended', 'left_without_service'])
type!: VisitEventType;
@IsISO8601()
occurredAt!: string;
@IsOptional()
@IsString()
source?: string;
}
@Controller()
export class PolimedController {
constructor(private readonly store: PolimedStore) {}
@Get('health')
health() {
return { status: 'ok', service: 'polimed-mock' };
}
@Get('patients/search')
searchPatients(@Query('q') q = '', @Query('limit') limit = '20') {
return this.store.searchPatients(q, Number(limit) || 20);
}
@Get('appointments')
listAppointments(@Query('date') date?: string) {
return this.store.listAppointments(date);
}
@Get('appointments/:id')
getAppointment(@Param('id') id: string) {
return this.store.getAppointment(id);
}
@Get('appointments/:id/events')
getAppointmentEvents(@Param('id') id: string) {
return this.store.getVisitEvents(id);
}
@Post('visits/:appointmentId/events')
@HttpCode(200)
pushVisitEvent(@Param('appointmentId') appointmentId: string, @Body() body: PushVisitEventDto) {
return this.store.pushVisitEvent(appointmentId, body);
}
}

84
apps/polimed-mock/src/polimed.store.ts

@ -0,0 +1,84 @@
import { Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/common';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { PolimedAppointment, PolimedPatient, VisitEvent } from './polimed.types';
// В dev (nest start) __dirname = .../apps/polimed-mock/src — поднимаемся в корень app.
// В prod (node dist/main.js) __dirname = .../apps/polimed-mock/dist — то же самое.
const SEEDS_DIR = join(__dirname, '..', 'seeds');
@Injectable()
export class PolimedStore implements OnModuleInit {
private readonly logger = new Logger(PolimedStore.name);
private patients: PolimedPatient[] = [];
private appointments: PolimedAppointment[] = [];
private visitEvents = new Map<string, VisitEvent[]>();
onModuleInit() {
this.patients = JSON.parse(readFileSync(join(SEEDS_DIR, 'patients.json'), 'utf-8'));
const appointmentsTemplate: Array<Omit<PolimedAppointment, 'scheduledFor'> & { hourOffset: number; minuteOffset: number }> =
JSON.parse(readFileSync(join(SEEDS_DIR, 'appointments.json'), 'utf-8'));
// Привязываем appointments к сегодняшней дате на старте — журнал «на сегодня» всегда живой.
const today = new Date();
today.setHours(8, 0, 0, 0);
this.appointments = appointmentsTemplate.map((a) => {
const dt = new Date(today);
dt.setHours(today.getHours() + a.hourOffset, today.getMinutes() + a.minuteOffset, 0, 0);
return {
id: a.id,
patientId: a.patientId,
patientFullName: a.patientFullName,
doctorFullName: a.doctorFullName,
specialty: a.specialty,
status: a.status,
scheduledFor: dt.toISOString(),
};
});
this.logger.log(
`Seeded ${this.patients.length} patients, ${this.appointments.length} appointments (today)`,
);
}
searchPatients(query: string, limit = 20): PolimedPatient[] {
const q = query.trim().toLowerCase();
if (!q) return this.patients.slice(0, limit);
return this.patients
.filter(
(p) =>
p.fullName.toLowerCase().includes(q) ||
p.cardNumber.toLowerCase().includes(q) ||
p.phone.includes(q),
)
.slice(0, limit);
}
listAppointments(dateIso?: string): PolimedAppointment[] {
if (!dateIso) return this.appointments;
return this.appointments.filter((a) => a.scheduledFor.startsWith(dateIso));
}
getAppointment(id: string): PolimedAppointment {
const ap = this.appointments.find((a) => a.id === id);
if (!ap) throw new NotFoundException(`Appointment ${id} not found`);
return ap;
}
pushVisitEvent(appointmentId: string, event: VisitEvent) {
this.getAppointment(appointmentId); // throws 404 if missing
const arr = this.visitEvents.get(appointmentId) ?? [];
arr.push(event);
this.visitEvents.set(appointmentId, arr);
this.logger.log(
`WRITE-BACK appointment=${appointmentId} event=${event.type} at=${event.occurredAt}`,
);
return { appointmentId, eventsTotal: arr.length };
}
getVisitEvents(appointmentId: string): VisitEvent[] {
return this.visitEvents.get(appointmentId) ?? [];
}
}

25
apps/polimed-mock/src/polimed.types.ts

@ -0,0 +1,25 @@
export interface PolimedPatient {
id: string;
fullName: string;
birthDate: string; // YYYY-MM-DD
phone: string;
cardNumber: string;
}
export interface PolimedAppointment {
id: string;
patientId: string;
patientFullName: string;
doctorFullName: string;
specialty: string;
scheduledFor: string; // ISO datetime
status: 'scheduled' | 'completed' | 'cancelled' | 'no_show';
}
export type VisitEventType = 'arrived' | 'service_started' | 'service_ended' | 'left_without_service';
export interface VisitEvent {
type: VisitEventType;
occurredAt: string; // ISO datetime
source?: string; // например 'reception-video'
}

4
apps/polimed-mock/tsconfig.build.json

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
}

1
apps/polimed-mock/tsconfig.build.tsbuildinfo

File diff suppressed because one or more lines are too long

13
apps/polimed-mock/tsconfig.json

@ -0,0 +1,13 @@
{
"extends": "@reception/tsconfig/nest.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save