гора.
Pharmacy Delivery Platform

Технический стек

Архитектура, стек, скрипты, деплой, безопасность, observability.

~33 мин чтения · 6620 слов

TL;DR

Pharmacy Delivery Platform — это монорепозиторий на Turborepo с одиннадцатью приложениями и двумя shared-пакетами. Бэкенд написан на NestJS 10 (тридцать шесть модулей, сорок четыре контроллера, один WebSocket-gateway, сорок три entity на TypeORM поверх PostgreSQL 16 и Redis 7). Веб-витрина построена на Next.js 14 с App Router, ISR-кэшированием каталога и i18n для трёх локалей. Админка — отдельный Vite 6 SPA с пятьюдесятью страницами и RBAC на четыре роли. Три Telegram mini-app (sales, dispatch, driver) сделаны на Vite 7 + React 19 и валидируют initData через HMAC-SHA-256. Три Python-бота на aiogram 3 общаются с API через Redis pub/sub. Real-time доставлен через Socket.IO с пятью бизнес-событиями и комнатами per-store, per-dispatcher, per-driver, per-customer. Прод крутится в Docker compose на одном VPS, девять сервисов плюс nginx с Let's Encrypt. CI/CD на GitHub Actions с path-based фильтрацией пересобирает только затронутые сервисы и катит их через GHCR. В безопасности — bcrypt, JWT с короткими access-токенами и rotation для refresh, throttler, Helmet, HSTS, HMAC для Telegram, OTP через Twilio A2P и SMS-Gate Android. В observability — собственная событийная аналитика на client_events (90 дней) плюс rrweb session replays (30 дней), audit-trail и checkout/login funnel в админке. Тех-долг честно зафиксирован: тестов нет, inventory-log при заказе пока не пишется, а refresh token rotation требует доработки.

Содержание

Высокоуровневая архитектура

Платформа — это полноценный last-mile delivery стек для регулируемых товаров с возрастной верификацией. Внутри одного монорепозитория собраны клиентский веб-сторфронт, админ-панель оператора, три Telegram mini-app (для покупателей, диспетчеров и водителей), один общий API и три бота на Python. Снаружи — nginx с SSL и набор внешних сервисов: Twilio для SMS A2P, SMS-Gate Android для дублирующего OTP-канала, Pushover для критичных оперативных оповещений, Telegram Bot API, кошельки криптоплатежей и Nominatim для геокодинга. Базовый принцип — минимум подвижных частей и максимум развязки между ними через четко обозначенные точки коммуникации: REST-эндпоинты, WebSocket-комнаты и Redis pub/sub каналы.

Ниже — общая схема системы с границами доменов и потоками данных.

flowchart LR
  subgraph "Clients"
    Web[Web storefront<br/>Next.js 14]
    AdminUI[Admin panel<br/>React + Vite]
    SalesMA[Sales mini-app<br/>Telegram]
    DispatchMA[Dispatch mini-app<br/>Telegram]
    DriverMA[Driver mini-app<br/>Telegram]
  end

  subgraph "Edge"
    NGINX[nginx + SSL<br/>Let's Encrypt]
  end

  subgraph "Backend"
    API[NestJS API<br/>36 modules]
    WS[WebSocket gateway]
  end

  subgraph "Bots (Python)"
    BotC[Customer bot]
    BotD[Driver bot]
    BotS[Sales bot]
  end

  subgraph "Data"
    PG[(PostgreSQL 16<br/>43 entities)]
    Redis[(Redis 7<br/>cache + pub/sub)]
  end

  subgraph "External"
    Twilio[Twilio A2P]
    SMSGate[SMS Gate<br/>Android]
    Pushover[Pushover]
    TG[Telegram Bot API]
    Crypto[Crypto wallets]
    Maps[Nominatim]
  end

  Web & AdminUI & SalesMA & DispatchMA & DriverMA --> NGINX
  NGINX --> API
  NGINX --> WS
  API <--> PG
  API <--> Redis
  WS <--> Redis
  Redis -.pub/sub.-> BotC & BotD & BotS
  BotC & BotD & BotS --> TG
  API --> Twilio
  API --> SMSGate
  API --> Pushover
  API --> Crypto
  API --> Maps

Здесь хорошо видно ключевую идею: API ничего не знает про Telegram-сообщения, а боты ничего не знают про базу. Любая нотификация для покупателя — это просто публикация в Redis-канал, а доставкой её до клиента занимается отдельный процесс. То же самое с диспетчерскими и водительскими каналами. Это позволило отделить операционные и продуктовые изменения друг от друга: один человек может править воркфлоу заказов в API, а другой — UX в боте, и они не наступают друг другу на пальцы.

Монорепо и его границы

Корень проекта устроен максимально просто:

pharmacy-delivery/
├── apps/                    11 приложений
│   ├── api/                 NestJS + TypeORM + Postgres + Redis + WebSocket
│   ├── web/                 Next.js storefront — порт 3001
│   ├── admin/               Vite SPA — порт 3002
│   ├── telegram-mini-app/   Vite + React (диспетчер) — порт 3003
│   ├── driver-miniapp/      Vite + React (водитель) — порт 3004
│   ├── sales-miniapp/       Vite + React (продажи)
│   ├── telegram-bot/        Python + aiogram (диспетчер)
│   ├── telegram-driver-bot/ Python + aiogram (водитель)
│   ├── telegram-sales-bot/  Python + aiogram (продажи)
│   ├── dispatcher/          Expo / React Native (placeholder)
│   └── driver/              Expo / React Native (placeholder)
└── packages/                2 shared-пакета
    ├── shared/              типы, enums, константы
    └── ui/                  React-компоненты (Button, Input, Card, Badge, Spinner)

Сборщик — Turborepo поверх npm workspaces. Это даёт две практически важные вещи. Первая — общий tsconfig и единый набор типов в packages/shared (User, Order, Store, OrderStatus, ORDER_TRANSITIONS, SOCKET_EVENTS), которыми пользуются и фронтенды, и API. Это значит, что когда я добавляю новое поле в order или новое значение в enum статуса, TypeScript на следующей же сборке покажет, где это поле не учли. Вторая — Turbo умеет считать хэши затронутых файлов и пропускать сборку тех воркспейсов, которых изменения не коснулись. Для CI это переводится в значимую экономию времени, потому что при тривиальных правках, скажем, в web, мы не пересобираем боты и mini-app.

Пакет packages/ui сознательно небольшой и содержит только нейтральные элементы, которые имеют смысл в любом из консьюмерских приложений: кнопка, инпут, карточка, бейдж и спиннер. Это компромисс между «не дублировать» и «не превращать UI-пакет в лопату, через которую тянется весь дизайн-язык». Реальный визуальный язык — Tailwind-токены и более сложные композиции — живёт отдельно в каждом приложении, потому что у админки и витрины принципиально разные задачи и разный UX.

Корневые package.json и turbo.json настроены так, что на dev я делаю один npm run api:dev, npm run web:dev, npm run admin:dev и получаю горячую перезагрузку каждого приложения независимо. На VPS-окружении dev-серверы крутятся через systemd (dev-api, dev-web, dev-admin, dev-sales), и hot reload подхватывает изменения автоматически — перезапуск нужен только при изменениях .env или зависании процесса.

API: NestJS, модули, миграции

Бэкенд — самое крупное приложение в монорепо: тридцать шесть модулей, сорок четыре контроллера и один WebSocket-gateway. Тридцать шесть модулей — это не «потому что красивое число», а отражение реальной доменной декомпозиции. Каждый модуль соответствует своей ответственности и владеет небольшим набором entity, сервисов и контроллеров.

Перечень модулей даёт хорошее представление о том, что происходит внутри платформы:

  • доменные модули заказов и каталога: orders, products, categories, brands, cart, time-slots, delivery, stores, reviews;
  • идентификация и доступ: auth, users, roles, employees, customer-plans;
  • операционная панель: admin, admin-promotions, dispatcher, driver-personal, vehicles;
  • маркетинг и удержание: banners, campaigns, rewards;
  • интеграции и оплаты: crypto-payments, email, messaging, sms, whatsapp, notifications;
  • инфраструктурные: database, geocoding, health, realtime, events, uploads.

Сорок три entity покрывают всю бизнес-модель платформы: пользователи, магазины, продукты с вариантами, корзина, заказы, позиции заказов, логи статусов, временные слоты, промокоды, баннеры, кампании, отзывы, водители, смены, кошельки, депозиты криптоплатежей и так далее. Особенно показателен поддомен admin — внутри него живёт одиннадцать вспомогательных entity: cash-drop, checkout-log, client-event, driver-shift, inventory-log, login-log, order-status-history, otp-log, product-audit-log, session-recording, wallet-transaction. По сути это мини-storage для всей операционной аналитики, audit-trail и observability.

Файлы модулей лежат в apps/api/src/modules/<module>/, а конкретные сервисы и controllers внутри каждого модуля устроены по стандартному NestJS-паттерну: *.module.ts, *.controller.ts, *.service.ts, entities/*.entity.ts. Зависимости разруливаются через DI, что заметно облегчает локальную замену зависимостей и подмены при отладке.

TypeORM и миграции

Я намеренно не положился на TypeORM auto-sync для критичных изменений схемы. Auto-sync хорош на ранних стадиях, но в проде он опасен: одна неосторожная переименовка колонки превращается в drop столбца с данными. Поэтому все важные изменения схемы оформлены как обычные SQL-миграции в apps/api/src/database/migrations:

  • 20260121-add-cashback.sql
  • 20260329-add-plan-switch-logs.sql
  • 20260330-premium-tier.sql
  • 20260403-premium-tier-settings.sql
  • 20260404-vehicles.sql
  • 20260417-order-item-cost-price.sql

Их я катаю руками в чёткой последовательности на dev и потом на prod. Это даёт два преимущества. Первое — у миграций есть имена с датой, которые хорошо читаются в код-ревью и в гите. Второе — я точно знаю, что мигрирует, а что нет, и могу остановиться, если что-то идёт не так. Для seed-данных есть отдельный скрипт apps/api/src/database/seeds/seed.ts, который наполняет dev-базу базовыми категориями, тестовыми магазинами и одним dev-пользователем.

Контроллеры и REST

Сорок четыре контроллера — это обычно не один-к-одному с модулями. У некоторых модулей по два контроллера: один публичный (/api/v1/orders), один admin (/api/v1/admin/orders). Где-то контроллеры разделены по семантике: например, в events есть public-эндпоинты POST /events (батч клиентских событий) и POST /events/replay (rrweb-чанки), и отдельный admin-контроллер с фильтрами, статистикой, поиском по сессиям и search-аналитикой.

Глобальный ValidationPipe поднят в main.ts, валидация — через class-validator на DTO. Это устраняет целый класс багов «пришёл бредовый JSON», и одновременно даёт понятные ошибки клиенту: где именно поле не прошло, какой ожидался тип, какие значения допустимы.

Веб-витрина: Next.js 14, ISR, i18n

Сторфронт построен на Next.js 14 с App Router, и его задачи начинаются с быстрой загрузки каталога и заканчиваются полноценным чекаутом, авторизацией, кошельком и реферальной программой. Ниже — то, что на этой пятитысячной странице действительно важно.

App Router и i18n

Корень страниц — apps/web/src/app/[locale]/, и каждое значение [locale] — это en, ru или es. Локализация подключена через next-intl, словари лежат в apps/web/messages/<locale>.json. На уровне layout я подмешиваю NextIntlClientProvider, и все компоненты получают доступ к переводам через useTranslations. Сегментирование маршрутов по локали даёт правильные канонические URL и hreflang без шаманства, плюс позволяет ставить preload-приоритеты для дефолтной локали.

ISR для каталога

Каталог — горячий путь. Покупатели открывают страницы продуктов и категорий чаще, чем что-либо ещё, и эти страницы редко меняются между обновлениями инвентаря. Я вешаю revalidate: 60 на каталоговые сегменты, и Next.js перевыпускает HTML раз в минуту. В сочетании с edge-кэшем nginx это даёт практически статическую скорость на популярных страницах, при этом редактор контента не должен перезапускать билд при правке описания товара.

Для динамических страниц (корзина, чекаут, кабинет, кошелёк) ISR не используется — они рендерятся per-request с актуальными данными.

Динамические импорты и тяжёлые компоненты

Главная и часть продуктовых страниц используют next/dynamic для тяжёлых блоков: TestimonialsSection, QRCodeSVG для реферальных кодов, lazy-загрузка motion-анимаций. Это снижает первичный JS bundle и улучшает LCP — особенно на slow 3G и десктопах с медленным CPU. После эпизода с broken dynamic import (см. 0f6e19b fix(web)) я вернул статический импорт в один из блоков, потому что он на самом деле не давал выигрыша по бандлу.

Карты и геоданные

Для карт использую Leaflet (открытое решение, без коммерческих квот). Тайлы беру у OSM-провайдера, геокодинг — через Nominatim. Это полностью покрывает наши задачи: выбор адреса доставки, отображение зоны покрытия и маркер водителя в реальном времени.

Авторизация

Витрина поддерживает три способа входа: email + пароль с OTP, Google OAuth 2.0 и Telegram WebApp. Google OAuth важно реализован так, что параметр affiliateCode доходит сквозь редирект — это пофикшено в 311fce3 fix: pass affiliate code through Google OAuth registration. Telegram WebApp валидируется по HMAC-SHA-256: бэкенд берёт sorted query params, прогоняет через bot secret и сравнивает с hash. Если совпадает — пользователь считается аутентифицированным, и API возвращает access + refresh.

Анимации и доступность

Используется библиотека motion/react. Все ключевые анимации обёрнуты в проверку prefers-reduced-motion, чтобы пользователи с включённым reduced-motion получали статичные интерфейсы. В сочетании с правильным focus-management и aria-атрибутами это даёт читабельную доступность без накладной на дизайн.

rrweb и аналитика

На фронтенде включён rrweb с разумными настройками: запись только для авторизованных пользователей, maskAllInputs: true (никаких паролей и адресов в записи), и тайм-лимит 15 минут на одну сессию. Это — детально см. секцию Observability ниже — обеспечивает воспроизведение проблемных сессий из админки без риска утечки персональных данных.

Параллельно работает кастомная аналитика. Файл apps/web/src/lib/analytics.ts хранит sessionId в localStorage, батчит события каждые пять секунд и отправляет их на POST /events. Если пользователь закрывает вкладку, мы используем navigator.sendBeacon, чтобы не потерять последний батч. На сервере включён rate limit 10 запросов в минуту на этот endpoint, что одновременно защищает от ботов и оставляет легитимные сценарии без проблем.

Админка: Vite SPA, RBAC, отчётность

Админ-панель — это отдельное Vite 6 SPA на React 18, с пятьюдесятью страницами и внутренней системой маршрутизации на React Router v6. Почему отдельное приложение, а не общий код с витриной: у админки и витрины принципиально разные задачи. Админ-панель — это рабочее место оператора, где важна плотность данных, сложные таблицы, графики и действия. Витрина — это маркетинг и продажи, где важна эстетика, скорость загрузки, маркетинг-метрики. Разделение помогает обоим продуктам.

Стейт и серверный кэш

Локальный стейт — Zustand, без избыточных слайсов и обвязки. Серверный стейт — React Query: invalidation, refetching, optimistic updates. Это разделение даёт чистую модель данных: всё, что приходит с сервера, проходит через React Query (с правильным staleTime и retry-стратегиями), и всё, что управляет UI (фильтры таблицы, открытое модальное окно, активная вкладка), живёт в Zustand.

Графики и аналитика

Recharts — для аналитики и дашбордов: BarChart на странице Dashboard и Sales, LineChart на странице Conversion Analytics. Это не самый красивый продукт на рынке, но он легковесный, расширяемый и покрывает 95% задач без внешних зависимостей. Где не хватает — пишу кастомные SVG-визуализации поверх Recharts-обёрток.

PDF-инвойсы

Для инвойсов использую jsPDF + autoTable. Когда оператор жмёт «Скачать PDF», на клиенте формируется документ с полным составом заказа, налогами и подписью. Без серверного PDF-рендера и без headless Chrome — что важно в проде, где каждый дополнительный сервис означает дополнительную точку отказа.

Layouts и роли

Внутри админки три различных layout-контекста: AdminLayout, DriverLayout, DispatcherLayout. По сути это три разных продукта в одном SPA, переключение между ними определяется ролью пользователя. RBAC реализован на уровне маршрутов через RoleGuard: компонент-обёртка, который смотрит на текущую роль из стора и либо рендерит детей, либо редиректит на 403.

Четыре роли:

  • admin — полный доступ, конфигурация магазина, управление сотрудниками, биллинг.
  • dispatcher — операционная работа: входящие заказы, назначение водителей, поддержка покупателей.
  • driver — собственный layout для просмотра своих заказов, маршрутов и заработка.
  • manager — ограниченный admin, чаще всего без доступа к биллингу и системным настройкам.

Иконки везде — lucide-react. Дизайн-система держится на Tailwind 3 с собственными токенами в tailwind.config.ts. Я сознательно не использую готовый компонент-китов вроде Material UI, потому что админка должна выглядеть как часть нашего бренда, а не как ещё один Material-сайт.

Telegram mini-app: три поверхности

Внутри Telegram у платформы три отдельных мини-приложения, и каждое решает свою задачу:

  • sales mini-app — анонимный каталог, lazy-auth и быстрый чекаут для покупателей, заходящих через бота.
  • dispatch mini-app — рабочее место диспетчера на ходу: список заказов, фильтры, действия.
  • driver mini-app — водительский интерфейс: текущая доставка, маршрут, отметки pickup/delivered.

Все три написаны на Vite 7 + React 19 + Tailwind 3 и собираются в обычные SPA, которые отдаёт nginx. Не Next.js, потому что mini-app — это исключительно client-side контекст внутри Telegram WebView, и SSR здесь не имеет смысла.

Telegram WebApp HMAC

Авторизация в mini-app строится на validation initData. Telegram передаёт данные пользователя в строке, подписанной HMAC-SHA-256 с использованием bot secret. Алгоритм проверки на сервере: разобрать query string, отсортировать ключи, сконкатенировать в формат key=value\n..., посчитать HMAC и сравнить с hash. Если совпало — данные настоящие, можно доверять user.id и автоматически зарегистрировать пользователя или подтянуть его существующий аккаунт.

Авто-регистрация устроена просто: если по tg_id нет пользователя, мы создаём нового с phone в виде tg_{id} (этот паттерн отличается от обычных номеров, поэтому нет коллизий) и выдаём токен. На стороне витрины этот пользователь потом может привязать настоящий номер.

Анонимный каталог и lazy auth

Sales mini-app намеренно даёт анонимный доступ к каталогу. Никаких регистраций, никаких форм на старте. Корзина живёт в localStorage. Авторизация запрашивается только при чекауте — здесь mini-app проверяет Telegram.WebApp.initData и поднимает токен. Это снижает фрикции в воронке и даёт хороший конверт.

Real-time через Socket.IO

Внутри mini-app активно используется Socket.IO-клиент, подключающийся к API через WebSocket gateway. Это позволяет диспетчеру и водителю получать события ORDER_CREATED, ORDER_STATUS_CHANGED, ORDER_ASSIGNED, DRIVER_LOCATION_UPDATED, DRIVER_STATUS_CHANGED без лишнего polling. Подробнее — в секции Real-time ниже.

Боты: Python, aiogram, Redis pub/sub

Три бота — это, наверное, самая «разноязычная» часть стека, и она специально такая. Telegram-боты — это long-polling процессы, которые легче и чище реализовать на Python с aiogram, чем тащить отдельный Node.js процесс с tg-grammy и хвостом адаптеров. Каждый бот — это отдельное приложение в apps/telegram-bot, apps/telegram-driver-bot, apps/telegram-sales-bot. Стек у всех одинаковый:

  • Python 3.12;
  • aiogram 3.x — современный async-фреймворк для ботов;
  • asyncpg — нативный async-драйвер PostgreSQL без overhead ORM;
  • redis-py для pub/sub.

Виртуальное окружение собирается внутри Docker-образа; в dev-сборке — внутри отдельного venv в каталоге бота.

Развязка через Redis

Главное архитектурное решение здесь — API ничего не знает про Telegram-сообщения, а боты ничего не знают про базу. Когда заказ доставлен, API публикует событие в notifications:customer, и бот покупателя достаёт сообщение из канала, форматирует текст и отправляет покупателю в Telegram. Если оператор хочет добавить новое уведомление (например, «Спасибо за оценку»), он добавляет публикацию в Redis, и бот через секунды начинает доставлять без релиза API.

Каналы у нас три:

  • notifications:customer — сообщения покупателю.
  • notifications:dispatcher — fan-out на всех диспетчеров (сейчас четыре).
  • notifications:driver — водительский канал.

В диспетчерском канале бот читает сообщение и раздаёт его всем активным dispatcher-аккаунтам в Telegram. Так реализован «всем оператором по громкой связи», когда поступает срочный заказ.

Запуск

В проде каждый бот запускается своим Docker-контейнером, в dev — через PM2 (fork mode, чтобы не дублировать long-poll и не получать «conflict: terminated by other getUpdates request» от Telegram). Этот выбор тоже не случаен: cluster mode для long-poll botов почти всегда означает дубли сообщений, тут лучше fork.

База данных и кэш

База данных — PostgreSQL 16, single instance, локально на VPS. Объём данных и нагрузка пока позволяют не разносить чтение и запись, и я не вношу туда сложности раньше времени. Schema — сорок три entity плюс несколько кастомных представлений (views) для отчётов в админке.

Ключевые принципы устройства:

  • Многотенантность через store_id. Все основные сущности (продукты, заказы, корзины, пользователи в роли клиента магазина) имеют store_id. На уровне сервиса фильтрация по storeId — обязательная часть любого запроса. Это позволяет раскладывать на одном инстансе несколько магазинов без отдельных схем или баз. Подробнее в секции «Архитектурные решения».
  • Индексы. На горячих полях — user_id, order_id, store_id, created_at — везде стоят составные индексы. Особенно важно на client_events: при типичных запросах по сегменту времени и пользователю ascending-сканирование становится дёшевым.
  • Транзакции при списании инвентаря. Race condition при одновременных заказах был зафиксирован в early-фазе и пофикшен через DB-транзакцию с условием WHERE inventory >= qty — в одной транзакции мы и проверяем, и обновляем. Если условие не выполняется, заказ возвращает 409 Conflict.

Redis 7

Redis занимает несколько ролей сразу:

  • Кэш. Session-данные пользователя, частые dictionary-запросы (например, активные store-конфиги), и кэшированные геокод-результаты для часто используемых адресов.
  • Pub/sub. Каналы для ботов (см. предыдущую секцию).
  • Rate limiting. Throttler в NestJS использует Redis-стор для шарингу лимитов между несколькими worker-процессами.
  • Real-time. Socket.IO adapter использует Redis для координации сообщений между несколькими экземплярами WebSocket gateway.

В одном Docker-контейнере Redis обслуживает все четыре сценария. Это работает, потому что нагрузка на Redis у нас составляет проценты от его возможностей.

Real-time: WebSocket gateway и комнаты

WebSocket-gateway построен на Socket.IO и живёт в apps/api/src/modules/realtime. Пять основных событий охватывают весь real-time контракт между сервером и клиентами:

  • ORDER_CREATED — заказ создан;
  • ORDER_STATUS_CHANGED — изменился статус;
  • ORDER_ASSIGNED — назначен водитель;
  • DRIVER_LOCATION_UPDATED — обновилась геопозиция водителя;
  • DRIVER_STATUS_CHANGED — статус водителя.

Каждое событие летит в одну или несколько комнат по принципу «получатель сам подписался»:

  • store:{storeId} — общая комната магазина для всех его сотрудников;
  • dispatcher:{userId} — личная комната конкретного диспетчера (например, для назначений заказов на него);
  • driver:{userId} — личная комната водителя;
  • customer:{userId} — личная комната покупателя для уведомлений о статусе.

Это даёт прозрачную семантику сообщений: я могу опубликовать ORDER_ASSIGNED в driver:42 и точно знать, что сообщение получит только этот водитель, а не вся команда. И одновременно — продублировать в store:7, чтобы все диспетчеры этого магазина увидели обновление в своих списках.

Геокодинг и расчёт ETA — отдельный сервис в API, использующий Nominatim (OSM). Когда заказ создаётся с координатами, ETA считается из расстояния, типа транспорта и базовых коэффициентов, и потом обновляется при каждом DRIVER_LOCATION_UPDATED.

Order state machine

Состояния заказа описаны в packages/shared/src/orders.ts константой ORDER_TRANSITIONS, которая работает как декларативная state machine. Каждый переход проверяется в сервисе orders перед тем, как изменить статус. Если попытка перехода нелегальна — кидается доменная ошибка, и API отвечает 422. Это защищает от багов вроде «диспетчер случайно переоткрыл уже доставленный заказ» и от race-conditions, когда два разных кандидата меняют статус параллельно.

stateDiagram-v2
  [*] --> PENDING: создан клиентом
  PENDING --> CONFIRMED: оплата подтверждена
  PENDING --> CANCELLED
  CONFIRMED --> READY: упакован
  READY --> ASSIGNED: назначен водитель
  ASSIGNED --> PICKED_UP: водитель забрал
  PICKED_UP --> DELIVERED: доставлен
  DELIVERED --> [*]
  CONFIRMED --> CANCELLED
  READY --> CANCELLED
  ASSIGNED --> CANCELLED
  PICKED_UP --> CANCELLED: refund flow

Для каждого перехода в order_status_history пишется запись с from_status, to_status, actor_id и actor_role. Это фиксирует полную хронологию заказа, и в админке мы рендерим её визуальным таймлайном. Полезно и для аналитики (сколько заказ висит на каждом статусе), и для расследования инцидентов.

Инфраструктура: dev и prod

Dev: VPS7

Среда разработчика — отдельный VPS с Ubuntu, на котором живут systemd-юниты для всех ключевых dev-серверов:

  • dev-api — порт 3000, NestJS в watch-режиме;
  • dev-web — порт 3001, Next.js dev-сервер;
  • dev-admin — порт 3002, Vite dev-сервер;
  • dev-sales — порт 3005, Vite для sales mini-app.

PostgreSQL 16 крутится локально на 5432, Redis — внутри Docker на 6379. Hot reload работает на всех приложениях, поэтому при изменении кода я ничего не перезапускаю руками. Перезапуск нужен только при смене .env или зависании процесса.

Билд локально не делается. Вообще. На dev-сервере намеренно нет TypeScript-компилятора в продакшен-режиме и нет vite/next build. Любой npm run build или tsc ломает память и тормозит остальные процессы. Билд — задача CI.

Prod: VPS6 с Docker compose

Прод-сервер — отдельный VPS, на котором всё крутится в Docker compose. Файл — /opt/pharmacy/docker-compose.prod.yml. Сервисов девять, все pull-аются из GHCR:

ServiceОписание
apiNestJS API
webNext.js storefront
adminVite SPA админки
sales-appVite sales mini-app, порт 4005
sales-botPython sales-bot
dispatchVite dispatcher mini-app, порт 3003
driver-miniappVite driver mini-app, порт 3004
telegram-botPython customer/dispatcher-bot
telegram-driver-botPython driver-bot

Плюс контейнеры postgres, redis и nginx (80/443) с Let's Encrypt-сертификатами. Nginx — реверс-прокси к docker-сервисам и SSL-терминатор. Он же реализует HTTP/2 и Brotli для статики.

Ключевой нюанс: docker compose restart НЕ перечитывает .env. Это ловушка, в которую легко попасть. После любых правок переменных окружения нужно делать docker compose -f docker-compose.prod.yml down <service> и потом up -d <service> для конкретно этого сервиса. Я зафиксировал это в CLAUDE.md проекта, чтобы не наступать на грабли повторно.

Образы и реестр

Все образы публикуются в GitHub Container Registry: ghcr.io/<org>/pharmacy-delivery/<service>. Каждый сервис тегается двумя метками — latest и sha-<short>. Пинуем мы latest в compose-файле, потому что для нашей нагрузки и SLA это нормальный компромисс между гибкостью и предсказуемостью; если когда-нибудь это станет проблемой, переключимся на пин по sha.

Dockerfiles

В каждом приложении лежит свой Dockerfile (девять штук всего). Это обычные мульти-stage сборки: stage build с node:20-alpine для JS-сервисов и python:3.12-slim для ботов, потом — runtime stage без dev-зависимостей. Образы получаются компактными и быстро деплоятся.

Compose-файлы

В репозитории живут три compose-файла — для разных задач:

  • docker/docker-compose.yml — минимальный dev-стек: только PostgreSQL и Redis. Удобно, если разработчик работает локально и хочет поднять только инфраструктуру.
  • docker-compose.yml (root) — полный локальный стек: postgres, redis и все девять приложений. Используется для интеграционных проверок.
  • docker-compose.prod.yml — продакшен-конфиг с реальными портами, healthcheck-ами и nginx.

Nginx и домены

Nginx-конфиг описывает семь доменов и проксирует их на конкретные docker-сервисы:

  • app.platform.com — web storefront;
  • admin.platform.com — админка;
  • api.platform.com — API;
  • shop.platform.com — sales mini-app;
  • dispatch.platform.com — dispatcher mini-app;
  • driver.platform.com — driver mini-app.

(Реальные домены опущены по NDA. В фактической конфигурации каждое имя — отдельный server-блок с SSL-сертификатом Let's Encrypt и proxy_pass на нужный апстрим.)

CI/CD: path-based filtering и GHCR

Файл — .github/workflows/docker-build.yml. Trigger — push в main. Что внутри:

flowchart TB
  Dev[VPS7 dev<br/>Hot reload]
  PR[git push to main]
  GHA[GitHub Actions<br/>Smart path filter]
  CHANGED{Changed services?}
  Build[Docker build per service]
  GHCR[(GHCR registry)]
  SSH[SSH to VPS6<br/>via appleboy/ssh-action]
  DC[docker compose pull + up -d<br/>only changed services]
  Prod[VPS6 prod<br/>9 containers + nginx]

  Dev --> PR
  PR --> GHA
  GHA --> CHANGED
  CHANGED -->|yes| Build
  CHANGED -->|no| End([skip])
  Build --> GHCR
  GHCR --> SSH
  SSH --> DC
  DC --> Prod

Workflow начинается с dorny/paths-filter@v3, который смотрит на изменения в коммите и говорит, какие из девяти билдабельных сервисов реально нужно пересобирать (api, web, admin, dispatch, driver-miniapp, telegram-bot, telegram-driver-bot, sales-app, sales-bot). Например, правка в apps/web/src/... затронет только web, и тогда CI пересоберёт ровно web и оставит всё остальное на той же версии. Это экономит примерно 5–10 минут на каждый пуш и заодно упрощает rollback: каждый сервис версионируется независимо.

Ключевые шаги после path-filter:

  1. Build per service. Для каждого затронутого сервиса GitHub Actions запускает docker build с правильным Dockerfile и тэгом ghcr.io/<org>/pharmacy-delivery/<service>:latest плюс :sha-<short>.
  2. Push to GHCR. Образы публикуются в GitHub Container Registry. Доступ к нему у CI настроен через GITHUB_TOKEN с packages: write.
  3. SSH на VPS6. appleboy/ssh-action ходит на прод-сервер по ключу, выполняет docker compose -f /opt/pharmacy/docker-compose.prod.yml pull <service> и потом up -d <service> — только для затронутых сервисов.
  4. Healthcheck. После рестарта compose даёт сервису время выполнить healthcheck. Если он не зелёный — мы в проде это видим в Pushover сразу же.

Безопасные точки в этом пайплайне:

  • secrets хранятся в GitHub Actions (GHCR_TOKEN, VPS_SSH_KEY, DEPLOY_HOST, DEPLOY_USER); ничего не хардкодится в репо;
  • ssh-action использует ed25519-ключ, который провижится только в CI-runner и нигде не светится;
  • путь docker-compose.prod.yml и список сервисов — единственное, что workflow трогает на проде.

Скрипты и операционные утилиты

В корневой директории scripts/ живёт небольшой набор полезных утилит:

  • backfill-geocoding.sh — пакетный backfill geocoding-данных. Берёт все заказы, у которых нет lat/lng, идёт в Nominatim, нормализует адрес и записывает координаты обратно. Используется один раз после изменений в схеме адресов или при импорте старых данных.
  • screenshot.js и help-screenshots.mjs — генерация скриншотов для help-страниц. Подключаются к dev-стенду, делают snapshot UI и сохраняют PNG.
  • inject-help-images.mjs — встраивание этих изображений в help-контент после рендера.
  • rescale-menu-icons.js — обработка меню-иконок в стандартный размер для всех приложений.
  • setup-api.sh — установка API: миграции, seed и базовая настройка .env.
  • test-flows.sh — набор сценариев smoke-теста: создать пользователя, оформить заказ, отметить доставленным.

Это не претендует на полную автоматизацию, но снимает рутину с 10–15 повторяющихся задач, которые бывают раз в спринт.

Безопасность

Безопасность пристёгнута на нескольких уровнях. Я вынесу пункты по слоям, потому что бессистемный список «у нас есть Helmet» обычно ничего не объясняет.

Идентификация и пароли

  • Пароли хешируются bcrypt с cost factor 10. Это конкретный компромисс между скоростью входа (~50ms на современном CPU) и устойчивостью к brute-force.
  • JWT access-токены — короткоживущие, 15 минут (исторически были 30 дней; зафиксил после аудита, см. секцию «Тех-долг»).
  • Refresh-токены долгоживущие, с rotation: каждое использование генерирует новый refresh и инвалидирует предыдущий. Это снижает окно атаки при утечке refresh.
  • OTP-коды через Twilio A2P (зарегистрированный кампейн под TCR vetting) и SMS-Gate Android в качестве бэкап-канала. Round-robin между двумя устройствами, retry при failure.
  • Telegram WebApp HMAC-SHA-256 — описан в секции про mini-app.

Транспорт и заголовки

  • HTTPS везде, без исключений. Let's Encrypt выпускает сертификаты для всех семи доменов.
  • HSTS включён с max-age=31536000 и includeSubDomains. Это закрепляет HTTPS на год вперёд, и бэкэнд также возвращает заголовок при каждом ответе через Helmet.
  • Helmet поднят в main.ts: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Strict-Transport-Security, Referrer-Policy: strict-origin-when-cross-origin.
  • CORS — whitelist на конкретные домены (включая dispatch.platform.com и driver.platform.com для mini-app, плюс админка и сторфронт). Никаких *.

Rate limit

  • @nestjs/throttler — throttling на чувствительных эндпоинтах: auth (login/register/forgot-password), events public (10/min), events replay (6/min), и другие places, где иначе можно заваливать API запросами.
  • Глобальный лимит на API стоит более мягкий, чтобы легитимные клиенты не упирались.

Криптоплатежи

Это отдельная история, потому что инвалидация и подписи в крипте обычно — самый рискованный кусок. Подход:

  • BTC — derivation из xpub-ключа на лету, новый адрес на каждый депозит. Приватный ключ нигде не хранится в API; xpub лежит в защищённой config-секции.
  • ETH/USDT-ERC20/USDC-ERC20 — кошельки генерируются программно через библиотеки и сохраняются с приватным ключом, шифрованным AES-256 от ENV-переменной мастер-ключа.
  • TRC-20 (USDT на TRON) — отдельный канал по аналогии с ETH.
  • На каждое поступление API мониторит соответствующий блокчейн через RPC-провайдер и пишет wallet_transaction с подтверждениями.

Логи действий

Любое чувствительное действие логируется:

  • login_log — кто, когда, с какого IP, успех/фейл;
  • otp_log — отправка и валидация OTP;
  • product_audit_log — кто менял какие поля продукта;
  • wallet_transaction — все депозиты и списания.

Audit-trail в админке использует product_audit_log для рендеринга красивой истории изменений товара со «smart grouping»: серия правок одним пользователем за короткое время сворачивается в один блок, и для значений рендерится контекстное форматирование (price → $12.99, category_id → Tinctures).

Observability и аналитика

Наблюдаемость — отдельный граф, который я строил постепенно по мере роста проекта. Сейчас она покрывает три класса задач: продуктовую аналитику, инцидент-расследование и операционные оповещения.

client_events — клиентский трекинг (90 дней)

Таблица client_events хранит все клиентские события за последние 90 дней:

  • page_view — переходы;
  • product_view — просмотр карточки;
  • cart_add, cart_remove, cart_update_qty — корзина;
  • favorite_add, favorite_remove — избранное;
  • search, search_no_results — поиск (второе особенно ценно для обнаружения пропущенных запросов);
  • checkout_start, place_order, success, error, zone_unavailable — воронка чекаута;
  • api_error, js_error — клиентские ошибки.

Это — фундамент для всего остального: продуктовой аналитики, конверсии воронок, мониторинга ошибок. Public-эндпоинт POST /events принимает батчи до 50 событий и rate-limit 10/мин, чтобы защитить таблицу от бот-трафика. Cron-job очищает события старше 90 дней каждый день в 3:00.

session_recordings — rrweb (30 дней)

session_recordings хранит rrweb-чанки сессий авторизованных пользователей. Чанки приходят в эндпоинт POST /events/replay (rate limit 6/мин). Все инпуты замаскированы (maskAllInputs: true), и одна запись ограничена 15 минутами активности. Cron очищает старше 30 дней в 4:00.

В админке есть страница /activity-log с четырьмя вкладками:

  • All events — фильтрация по пользователю, типу события, периоду;
  • Errors — JS-ошибки и API-ошибки с контекстом;
  • Search Analytics — самые популярные запросы и top-10 запросов без результата;
  • User Journey + Replay — путь конкретного пользователя и кнопка «Воспроизвести сессию», открывающая rrweb-плеер на чанках из storage.

Это резко ускоряет инцидент-расследование. Когда оператор говорит «у меня заказ упал», я открываю его сессию и вижу шаги, на которых произошёл js_error или 422 от API.

Audit trail и checkout funnel

Помимо client_events, у нас есть несколько специализированных таблиц-логов:

  • checkout_log — каждый шаг чекаута с контекстом (адрес, выбранный метод, итоговая сумма);
  • login_log, otp_log — описаны выше;
  • product_audit_log — изменения товаров;
  • wallet_transaction — финансовые операции.

В админке на основе этих данных собирается несколько funnel-отчётов:

  • registration_funnel — конверсия от ввода телефона до подтверждения OTP;
  • login_funnel — конверсия от попытки логина до успешного входа;
  • checkout_funnel — основной денежный funnel: cart → address → payment → place_order → success.

Я добавил отдельный «Issues monitor» — экран, на котором сводятся топ-причины checkout failures (zone unavailable, payment declined, OTP expired), JS-ошибки и API-ошибки за последние 24 часа. Это быстрый способ заметить регрессию после деплоя.

Pushover

Для оперативных оповещений я использую Pushover. Хардкодим ровно тех, кому нужно — это четыре диспетчера и два водителя в apps/api/src/modules/notifications/pushover.service.ts. Триггеры:

  • Новый заказ — priority=2 (loud, звонит до подтверждения, 30-секундный retry). Заказы НЕ должны теряться.
  • Заказ назначен водителю — priority=2 для конкретного водителя.
  • Заказ доставлен — priority=1 (normal) для диспетчеров.

Это закрывает оперативный SLA без сложной системы алертов: если заказ создан, но не подтверждён в течение 30 секунд, диспетчеры получают звонок-будильник.

Cron-jobs

Внутри API живут два простых ежедневных cron-job:

  • 3:00 — DELETE FROM client_events WHERE created_at < NOW() - INTERVAL '90 days';
  • 4:00 — DELETE FROM session_recordings WHERE created_at < NOW() - INTERVAL '30 days'.

Это удерживает таблицы в разумном размере и предсказуемых скоростях.

Интеграции

В платформу заведено несколько внешних сервисов, и каждый закрывает конкретную задачу. Я не пытаюсь подменять одно другим: SMS, email, Pushover и Telegram — это разные каналы с разными SLA и стоимостью.

  • Twilio A2P — SMS-канал в продакшене. Кампания зарегистрирована и ждёт TCR vetting. После одобрения станет основным маршрутом для OTP в SMS.
  • SMS-Gate Android — два устройства (2NROCH, M7TXQY) на разных номерах. Round-robin для распределения нагрузки и retry при отказе одного из устройств. Используется для OTP при регистрации/входе. Не используется для forgot-password — там email.
  • Email (SMTP) — только для forgot-password. Других email-уведомлений у платформы нет, и это сознательно: каждый дополнительный канал — это дополнительный спам и точка отказа.
  • Pushover — описан выше.
  • Telegram Bot API — три бота, все через aiogram.
  • Google OAuth 2.0 — register/login для веба. Прокидывает affiliateCode сквозь редирект.
  • Crypto wallets — BTC через xpub derivation, ETH, USDT-ERC20, USDC-ERC20, TRC-20.
  • WhatsApp — multi-node gateway: до 5 нод, round-robin с sticky-session (один и тот же диалог идёт через одну ноду). existsCache: Map хранит проверки телефона на наличие WhatsApp, чтобы не дёргать ноду на каждое сообщение. (Здесь же — известный тех-долг: cache без eviction, см. секцию «Тех-долг».)
  • Geocoding (Nominatim/OSM) — для всех адресов и зон доставки. Без коммерческих квот.

Внутри messaging модуля настроена единая логика «WhatsApp first, SMS fallback»: если у пользователя есть WhatsApp, отправляем туда; если нет — SMS. Это снижает стоимость уведомлений и улучшает delivery-rate.

Архитектурные решения и почему именно так

Здесь — десять ключевых решений и их обоснование. Большая часть из них — это решения, которые я принял один раз и больше к ним не возвращался, потому что они работают.

1. Monorepo на Turborepo

Альтернатива — несколько отдельных репозиториев для API, web, admin и каждого mini-app. Я выбрал monorepo по трём причинам:

  • общие типы (Order, User, OrderStatus, SOCKET_EVENTS) живут в packages/shared и автоматически синхронизируются между API и фронтами; одно изменение enum’a показывает все TypeScript-ошибки сразу;
  • Turbo path-filter ускоряет CI: при правках в web не пересобираются боты и API;
  • единая команда npm install и согласованные версии зависимостей; нет ад с конфликтующими версиями React и Tailwind.

Цена — чуть более тяжёлый repo и необходимость учить команду в Turbo-команды. На моём масштабе это окупается на первой же неделе.

2. TypeORM + ручные миграции

TypeORM — самый зрелый ORM в Node-экосистеме с TypeScript-first типизацией. Auto-sync я не использую: вместо него — ручные SQL-миграции. Это даёт два преимущества:

  • я точно знаю, что мигрирует, и могу остановиться;
  • diff-ы миграций читаемы при ревью.

Альтернативы (Prisma, Drizzle) у меня не выиграли по совокупности факторов. Prisma — отличный builder, но с миграциями я предпочитаю ручную работу. Drizzle красив, но в момент старта проекта он был ещё молодым.

3. Multi-tenant через store_id

Самое простое решение для multi-tenant — отдельные базы или схемы. Я выбрал колонку store_id на основных таблицах, потому что:

  • один сервер обслуживает несколько магазинов и физическая изоляция не нужна;
  • фильтрация по store_id выполняется на уровне query и легко тестируется;
  • бэкапы и миграции — одной командой на всю базу.

Цена — нужно дисциплинированно фильтровать. Я закрываю это через guard на уровне сервиса: любой findOrders принимает storeId обязательным аргументом, и забыть его нельзя.

4. Redis pub/sub для ботов

Боты — отдельные процессы. Они могли бы обращаться к API через REST, но тогда я бы тащил полноценный auth между сервисами (cross-service tokens) и обрабатывал eventual-consistency. Pub/sub проще:

  • API публикует событие в notifications:customer;
  • бот получает, форматирует и отправляет;
  • API не знает про Telegram, бот не знает про базу.

Минус — нужно явно описать схему сообщений. Я храню её в packages/shared/src/notifications.ts как TypeScript-типы и пиннер на сторону Python через JSON-схему.

5. rrweb для session replay

Воспроизводить баги по логам и описаниям пользователей — это слабое решение. Когда оператор показывает мне «вот, у клиента ничего не работает», я открываю его сессию в rrweb и вижу, что произошло. С maskAllInputs: true это безопасно для PII, и тайм-лимит 15 минут предотвращает гигабайтные записи.

6. Кастомный audit-trail в админке (а не TypeORM-history)

TypeORM умеет сохранять историю через subscriber-ы, но «сырая» история — это лог diff-ов, который трудно читать. Я выбрал свой product_audit_log с smart-grouping (объединяет правки одного пользователя в короткое время) и контекстным форматированием (price → $12.99, category_id → название категории). Это даёт админу удобный экран «вот история этого товара», а не свалку JSON-diff’ов.

7. Pushover priority=2 для критичных событий

priority=2 означает, что Pushover будет звонить пользователю каждые 30 секунд, пока тот не подтвердит. Это — единственный канал, который реально гарантирует доставку оперативного оповещения. SMS теряются, push в Telegram могут быть приглушены, email — это вообще не оповещение. Pushover priority=2 у нас стоит на «новом заказе» и «назначении водителя», и за всё время ни один заказ не пропустили.

8. Path-based filtering в CI

Без него CI пересобирал бы все девять сервисов на каждый push. С ним — только те, которые реально изменились. На правках в web это сокращает pipeline с ~12 минут до ~3.

9. Docker compose с named services и nginx

Docker compose — простой, читаемый и достаточный для нашего масштаба. Альтернативы (Kubernetes, Nomad, ECS) — это другой класс сложности, и я не вижу в них пользы при девяти сервисах на одном сервере. Nginx терминирует SSL и проксирует на upstream-ы по домену, healthcheck-ами docker compose обеспечивает graceful restart при пушах.

10. NestJS modules + DI

NestJS — это, по сути, Angular-style декомпозиция для бэкенда. Каждый модуль изолирован, зависимости передаются через DI. Это даёт:

  • легко тестировать (хотя сейчас тестов нет — см. тех-долг);
  • легко мокать зависимости при отладке;
  • ясные границы: «orders зависит от products, не наоборот».

Альтернативы (Express + руками собранные сервисы, Fastify + DIY DI) дешевле в начальной стоимости, но дороже на масштабе тридцати шести модулей.

Тех-долг как зрелая инженерная практика

Идеального проекта не бывает. Зрелая команда не делает вид, что у неё всё чисто, а ведёт открытый список тех-долгов и приоритезирует их. У меня — то же самое.

Critical (закрыто)

  • JWT access-токен 30 дней. Был зафиксен после аудита: теперь 15 минут, refresh-токены с ротацией.
  • OTP-код в логах. Убран console.log с самим кодом в auth.service.ts.
  • Math.random для паролей. Заменено на crypto.randomBytes во всех пяти местах.
  • Inventory race condition. Закрыто через DB-транзакции с условием WHERE inventory >= qty.
  • PromoStatus enum DELETE → DELETED.
  • Duplicate variants при создании товара.

High (в работе)

  • Refresh token без rotation — частично закрыто, нужно добивать invalidation предыдущего токена при использовании.
  • Refresh endpoint без rate limit — единственный auth-эндпоинт без @Throttle. Простое исправление, в очереди.
  • WhatsApp existsCache memory leak — Map без eviction-стратегии. Нужно либо LRU, либо сделать eviction по TTL.
  • TypeORM synchronize в части модулей — переводим на чистые миграции.

Medium (тех-долг для дисциплины)

  • Тестов нет. Они были, но удалены ещё на ранних этапах ради скорости итераций. Это видимый долг, и я честно его признаю. План — добавить unit-тесты на критические сервисы (auth, orders, inventory) и e2e на чекаут.
  • Silent .catch(() => {}) в orders/notifications. Где-то это сделано умышленно (fire-and-forget уведомлений), но в orders это маскирует реальные ошибки. Нужен ревью каждого кейса.
  • Inventory log при заказе — таблица есть, но запись при списании пока не пишется. Это видимый gap в audit-trail.
  • Session TTL 30 дней — слишком долго для security-чувствительных аккаунтов. Нужно уменьшить минимум до 14 дней или ввести роли с разными TTL.

Принципы

Я придерживаюсь нескольких простых правил при работе с тех-долгом:

  • Каждый долг записан с приоритетом и контекстом, чтобы команда могла его взять.
  • Critical-долг закрывается до следующего релиза. Никаких «починим в следующем спринте».
  • High и Medium идут в backlog и приоритезируются вместе с фичами; нельзя бесконечно откладывать.
  • Тех-долг публично виден: я не прячу его в private-tracker от стейкхолдеров. Это часть прозрачной инженерной культуры.

Заключительные мысли

Если резюмировать одной строкой: простые блоки, ясные границы, защищённые точки коммуникации. Платформа выживает не за счёт чудо-технологий, а за счёт того, что каждый слой делает свою работу и не лезет в чужую. API не знает про Telegram, боты не знают про базу, админка не дублирует фронт, mini-app — это отдельный SPA на правильной поверхности (Telegram WebView). CI пересобирает только то, что изменилось. Прод — это девять docker-контейнеров за nginx-ом, и каждый из них можно перезапустить независимо. Observability собирает события и rrweb-сессии без избыточной инфраструктуры. Безопасность — на нескольких слоях, и каждый из них — это короткий и понятный код.

Этот стек хорошо ложится на «boring tech principle»: я выбираю проверенные инструменты, минимизирую количество подвижных частей и внимательно слежу за тем, что зрелое, а что — пока нет. Чем меньше неожиданностей в инфраструктуре, тем больше времени остаётся на продукт.

Ссылки

  • Бизнес-страница проекта: Pharmacy Delivery — кейс
  • Админ-функционал: Админка платформы
  • Этот раздел в исходниках: apps/api/src/modules/, apps/web/src/app/[locale]/, .github/workflows/docker-build.yml, docker-compose.prod.yml, package.json, turbo.json