# 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 | --- ## Рефакторинг: мульти-профиль (02.06.2026) Полная переработка доменной модели. Основная причина: один пользователь может иметь несколько публичных профилей. Все социальные операции переведены с `user_id` на `profile_id`. **Ключевые изменения схемы:** - `profile.user_id` — убран `UNIQUE`, теперь один user → много профилей - `profile.active_chat_id` — перенесено из `user` в `profile` - `user.active_chat_id` — удалено - `media` → `profile_media` (FK на `profile`, тип: `photo | video | audio`, добавлен `sort_order`) - `like.source_user/target_user` → `like.source_profile_id/target_profile_id` - `match.user1_id/user2_id` → `match.profile1_id/profile2_id` - `chat.profile1_id/profile2_id` — теперь честные FK на `profile` - `message.user_id` → `message.profile_id` - `date.user1_id/user2_id` → `date.profile1_id/profile2_id` - `report.source_user` → `report.source_profile_id` **Паттерн ownership:** все операции, изменяющие профиль, проверяют `profile.user_id === jwt.sub` через `assertProfileOwnership()` в каждом сервисе. **WebSocket:** `profileId` передаётся в `handshake.auth.profileId` при подключении. --- ## Архитектурные решения ### 1. Глобальный JWT-guard через `APP_GUARD` Все маршруты закрыты по умолчанию. Публичные эндпоинты помечаются декоратором `@Public()`. Это предотвращает случайное открытие нового роута без авторизации. ### 2. Глобальный response wrapper `{ data: ... }` `TransformInterceptor` оборачивает все успешные ответы в `{ data: T }`. Унифицирует контракт клиента: клиент всегда читает `response.data`. ### 3. Refresh-токены в Redis, не в БД Refresh-токен хранится в Redis с TTL 30 дней (`refresh_token:`). При 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` | Реализовано корректно после рефакторинга | — | | 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 | --- ## Команды ```bash # Инфраструктура 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 |