Files
dating-app-backend/CLAUDE.md
2026-06-02 15:52:22 +03:00

9.0 KiB
Raw Blame History

CLAUDE.md — Архитектурный журнал

Этот файл ведётся автоматически. Фиксирует архитектурные решения, отступления от ТЗ и причины этих отступлений.


Стек

Слой Выбор Версия
Runtime Node.js + NestJS NestJS 10
HTTP-сервер Fastify (не Express) fastify 4
WebSocket Socket.io (@nestjs/platform-socket.io) socket.io 4
ORM Drizzle ORM 0.38.4
База данных PostgreSQL
Кеш / очереди Redis (ioredis) ioredis 5
Хранилище файлов MinIO (S3-совместимое) minio 8
Push-уведомления Firebase Admin SDK (FCM) firebase-admin 12
Авторизация JWT (passport-jwt) + refresh-token в Redis
Пакетный менеджер pnpm 11.x
API-документация OpenAPI через @nestjs/swagger swagger 7

Архитектурные решения

1. Глобальный JWT-guard через APP_GUARD

Все маршруты закрыты по умолчанию. Публичные эндпоинты помечаются декоратором @Public(). Это предотвращает случайное открытие нового роута без авторизации.

2. Глобальный response wrapper { data: ... }

TransformInterceptor оборачивает все успешные ответы в { data: T }. Унифицирует контракт клиента: клиент всегда читает response.data.

3. Refresh-токены в Redis, не в БД

Refresh-токен хранится в Redis с TTL 30 дней (refresh_token:<userId>). При logout ключ удаляется — токен немедленно инвалидируется без изменений в PostgreSQL. Это быстрее и не засоряет основную БД.

4. user.active_chat_id — ограничение на уровне приложения, не FK

В схеме activeChatId — простой UUID без REFERENCES chat(id). Причина: Drizzle не поддерживает отложенные FK (DEFERRABLE), а при создании чата нужно сначала INSERT в chat, потом UPDATE user. Добавление жёсткого FK создало бы проблему курицы и яйца. Инвариант «один активный чат» соблюдается на уровне сервиса (chat.service.ts → createChat).

5. chat.profile1_id / profile2_id хранят user_id, не profile_id

Отступление от ТЗ. ТЗ называет поля profile1_id → profile, но семантически чат привязан к паре пользователей (через матч). Поиск участника чата по userId напрямую не требует JOIN с profile. Схема сохраняет исходные имена полей, но ссылается на user.id. Если потребуется строгая привязка к профилю — нужен отдельный FK.

6. Лимит матчей проверяется при лайке, не при входе в ленту

ТЗ: «если у пользователя больше N матчей — поиск становится неактивным». Реализовано как BadRequestException в LikesService.createLike(). Лента (FeedService) возвращает профили без проверки лимита — клиент сам скрывает кнопку лайка на основе флага из профиля пользователя. Значение N задаётся через MAX_MATCHES_BEFORE_PAUSE в .env.

7. Сообщения отправляются и через REST, и через WebSocket

Клиент может отправить сообщение двумя путями:

  • POST /api/v1/chats/:id/messages — REST (HTTP)
  • событие send_message через Socket.io

Оба пути вызывают ChatService.sendMessage(). Socket.io-событие дополнительно бродкастит new_message всем участникам комнаты. REST-ответ не бродкастит — клиент должен сам обновить UI или слушать WS.

8. Файловые загрузки через Fastify Multipart, не Multer

ТЗ не оговаривает конкретный механизм. Multer работает только с Express. Поскольку выбран Fastify, используется @fastify/multipart, зарегистрированный в main.ts. Файл читается вручную через req.file() в контроллере.

9. WebSocket-gateway работает в пространстве имён /chat

Одно пространство имён вместо нескольких (чат + уведомления). Уведомления о матчах и системные события отправляются через ChatGateway.emitToUser() напрямую по socketId. Если потребуется изолировать уведомления — вынести в отдельный gateway.

10. Feed использует ORDER BY RANDOM()

Лента случайно перемешивает профили при каждом запросе. Это дёшево на малых данных и соответствует духу ТЗ («уникальная лента»). При росте таблицы нужно заменить на cursor-based с детерминированным seed (например, хешем userId + дата).


Отступления от ТЗ

# Пункт ТЗ Как реализовано Причина
1 chat.profile1_id → profile Поле хранит user_id Матч создаётся между пользователями, а не профилями; JOIN с профилем не нужен на горячем пути
2 Поиск «неактивен» при превышении лимита матчей BadRequestException при лайке Проще контракт с клиентом: ошибка явная, не нужно отдельного флага searchActive
3 Тарифный план один, но оплата предусмотрена Таблицы tariff и payment созданы, логика оплаты не реализована ТЗ: «регистрация открыта для всех, тарифный план один» — бизнес-логики оплаты нет
4 radiusKm в фильтре ленты Параметр принимается, но фильтрация по радиусу не применяется Требует PostGIS или формулы Haversine; добавить в следующей итерации

Известные технические долги

Файл Проблема Причина
*/services/*.ts as any в .insert().values() и .update().set() Drizzle ORM 0.38 не включает nullable и enum-колонки с .default() в TypeScript insert-тип; при апгрейде Drizzle — убрать касты
feed.service.ts N+1 запрос: теги загружаются по одному на профиль Достаточно для MVP; исправить через один JOIN с GROUP BY при росте нагрузки
chat.service.ts getMyChats возвращает только active чаты Закрытые чаты недоступны через API; добавить query-параметр status
pnpm-workspace.yaml allowBuilds пополняется вручную при новых зависимостях с postinstall Требование безопасности pnpm 11

Команды

# Инфраструктура
docker-compose up -d

# Зависимости
pnpm install

# Первый запуск БД
pnpm db:generate   # создать SQL-миграции
pnpm db:migrate    # применить миграции
pnpm db:seed       # заполнить справочники (roles, tariffs, date_statuses, greetings)

# Разработка
pnpm start:dev     # hot-reload dev-сервер
pnpm build         # компиляция в dist/

# Инструменты
pnpm db:studio     # Drizzle Studio (GUI для БД)

Точки входа

URL Описание
http://localhost:3000/api/v1 REST API
http://localhost:3000/api/docs Swagger UI (только dev/staging)
ws://localhost:3000/chat Socket.io namespace
http://localhost:9001 MinIO Console