first commit

This commit is contained in:
Oscar
2026-06-02 15:52:22 +03:00
commit dc44cdd639
105 changed files with 14674 additions and 0 deletions

153
CLAUDE.md Normal file
View File

@@ -0,0 +1,153 @@
# 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 |
---
## Команды
```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 |