PlayPulse — full-stack веб-приложение для поиска, каталогизации и открытия игр. Главная фишка — механика свайпов в стиле Tinder: пользователь листает карточки игр, ставит лайки/дизлайки, оценивает их, ведёт списки прохождения и покупок, а также читает агрегированную ленту игровых новостей (RSS).
Проект — это монорепозиторий из двух приложений:
apps/backend— REST API на NestJS 11 (порт3001).apps/frontend— веб-клиент на Next.js 16 / React 18 (порт3000).
Данные хранятся в Supabase (PostgreSQL), ответы внешнего игрового API кэшируются в Redis, а данные об играх берутся из RAWG API через отдельный proxy на Vercel.
Документация написана по фактическому коду репозитория. Места, где поведение неочевидно или где в коде есть расхождения, помечены значком
⚠️ .
- Архитектура
- Технологический стек
- Структура репозитория
- Backend (NestJS)
- Frontend (Next.js)
- Аутентификация и JWT
- Система свайпов
- Лента новостей (RSS)
- База данных (Supabase)
- Кэширование (Redis)
- Переменные окружения
- Запуск проекта
- Make-команды
- Известные нюансы и техдолг
┌──────────┐ /api/* ┌────────────────────────┐ /games,/auth,… ┌──────────────┐ /api/rawg ┌───────────┐
│ Browser │ ──────────▶ │ Next.js (порт 3000) │ ─────────────────▶ │ NestJS API │ ───────────▶ │ RAWG Proxy│
│ (React) │ │ • Route Handlers │ │ (порт 3001) │ │ (Vercel) │
│ │ ◀────────── │ • next.config rewrite │ ◀───────────────── │ │ ◀─────────── └─────┬─────┘
└──────────┘ └────────────────────────┘ └──────┬───────┘ │
│ ┌─────▼─────┐
┌──────▼───────┐ │ RAWG API │
│ Supabase + │ └───────────┘
│ Redis │
└──────────────┘
Клиент (браузер) никогда не обращается ни к NestJS-бэкенду, ни к RAWG напрямую.
Всё идёт через /api/* на самом Next.js-сервере, который выступает proxy-слоем:
- Frontend proxy — Next.js перенаправляет
/api/*на NestJS. Делается это двумя способами одновременно (см. Два механизма проксирования): достаёт httpOnly-cookie с JWT, добавляет заголовокAuthorizationи форвардит запрос. - Backend proxy — NestJS обращается к RAWG не напрямую, а через внешний proxy
https://playpulse-rawg-proxy.vercel.app/api/rawg, где спрятан ключ RAWG.
Зачем так:
- API-ключ RAWG не попадает в браузер.
- JWT хранится в httpOnly-cookie и не доступен JS на клиенте.
- Нет проблем с CORS.
- Появляется единая точка для кэширования и обработки ошибок.
| Технология | Версия (package.json) | Назначение |
|---|---|---|
| NestJS | ^11 (@nestjs/*) |
Основной фреймворк |
| TypeScript | ^5.7 |
Язык |
@supabase/supabase-js |
^2.89 |
Клиент PostgreSQL/Supabase |
@nestjs/passport + passport-jwt |
^11 / ^4 |
JWT-аутентификация |
@nestjs/jwt |
^11 |
Подпись/проверка токенов |
bcrypt |
^6 |
Хеширование паролей |
@nestjs/cache-manager + cache-manager-redis-yet |
^3 / ^5 |
Кэширование в Redis |
@nestjs/axios + rxjs |
^4 / ^7 |
HTTP-запросы к RAWG proxy |
class-validator + class-transformer |
^0.14 / ^0.5 |
Валидация DTO |
@nestjs/swagger |
^11 |
Декораторы для документации |
| Jest | ^30 |
Тесты |
| Технология | Версия (package.json) | Назначение |
|---|---|---|
| Next.js | ^16 (App Router) |
React-фреймворк, SSR, API routes |
| React / React DOM | 18.3.1 |
UI |
| TypeScript | ^5 |
Язык |
| Tailwind CSS | ^4 (@tailwindcss/postcss) |
Утилитарные стили |
| CSS Modules | — | Стили компонентов (*.module.css) |
zustand |
^5 |
Хранилище состояния (объявлено в зависимостях) |
recharts |
^3 |
Графики (в зависимостях; статистика рисуется и кастомным SVG) |
lucide-react, react-icons |
— | Иконки |
use-debounce |
^10 |
Debounce для поиска |
react-paginate |
^8 |
Пагинация каталога |
cheerio, fast-xml-parser, @xmldom/xmldom |
— | Парсинг RSS на сервере |
sharp |
^0.34 |
Обрезка изображений (/api/crop-image) |
@supabase/supabase-js |
^2 |
Клиент Supabase (для изображений профиля) |
| Сервис | Назначение |
|---|---|
| Supabase | PostgreSQL для пользователей и их действий |
| Redis | Кэш ответов RAWG (через cache-manager) |
| Vercel | Хостинг RAWG proxy и image proxy |
| Docker Compose | Локальный запуск redis + backend + frontend |
play-pulse/
├── apps/
│ ├── backend/ # NestJS API (порт 3001)
│ │ ├── src/
│ │ │ ├── main.ts # Bootstrap: CORS, ValidationPipe, фильтр ошибок
│ │ │ ├── app.module.ts # Корневой модуль + CacheModule(Redis) + middleware
│ │ │ ├── app.controller.ts # GET / → "Hello World"
│ │ │ ├── auth/ # Регистрация, вход, JWT-стратегия, guards
│ │ │ ├── users/ # Профиль пользователя, "мои игры"
│ │ │ ├── games/ # Каталог, детали, действия, похожие/популярные
│ │ │ │ └── rawg-proxy.ts # ⭐ функция запроса к RAWG proxy
│ │ │ ├── swipes/ # Выдача случайных игр для свайпов + batch-действия
│ │ │ ├── preferences/ # Сервис записи действий в БД (used by games/swipes/users)
│ │ │ ├── supabase/ # Глобальный модуль клиента Supabase
│ │ │ └── common/filters/ # HttpExceptionFilter + RequestLoggerMiddleware
│ │ ├── test/ # e2e-тест (app.e2e-spec.ts)
│ │ └── Dockerfile
│ │
│ └── frontend/ # Next.js (порт 3000)
│ ├── next.config.ts # rewrites /api/* → backend, настройки next/image
│ ├── middleware.ts # Защита /profile (редирект на /auth без токена)
│ ├── src/
│ │ ├── app/
│ │ │ ├── api/ # ⭐ PROXY LAYER (route handlers)
│ │ │ ├── page.tsx # Главная
│ │ │ ├── games/ # Каталог + детали игры
│ │ │ ├── swipes/ # Свайпы
│ │ │ ├── profile/ # Профиль (защищён middleware)
│ │ │ ├── news/ # Лента новостей
│ │ │ ├── auth/ # Вход/регистрация
│ │ │ ├── layout.tsx # Корневой layout
│ │ │ └── ClientLayout.tsx # Провайдеры контекстов + навигация
│ │ ├── components/ # UI-компоненты
│ │ ├── contexts/ # AuthContext, GameActionsContexts
│ │ ├── hooks/useMediaActions.ts
│ │ ├── services/ # gameService, scrollService
│ │ └── lib/ # supabase.ts, platforms.ts
│ └── Dockerfile
│
├── docker-compose.yml # redis + backend + frontend
├── Makefile # команды запуска/сборки
└── README.md
Файл src/main.ts:
- CORS — разрешён origin из
FRONTEND_URL(по умолчаниюhttp://localhost:3000), сcredentials: trueи заголовкамиContent-Type, Authorization. - ValidationPipe глобально с
whitelist: true,transform: true,forbidNonWhitelisted: true— лишние поля в теле запроса приводят к ошибке 400. - HttpExceptionFilter глобально — единый формат ответа об ошибке.
- PORT валидируется (1–65535), иначе процесс падает с понятным сообщением.
В app.module.ts подключены:
ConfigModule.forRoot({ isGlobal: true })— чтение.env.CacheModule.registerAsync({ isGlobal: true })с Redis-стором (cache-manager-redis-yet, URL изREDIS_URL).- Модули:
Supabase,Auth,Users,Games,Swipes. RequestLoggerMiddlewareлогирует все входящие запросы (forRoutes('*')).
| Модуль | Каталог | Ответственность |
|---|---|---|
| AuthModule | auth/ |
Регистрация, вход, проверка токена, JWT-стратегия, guards |
| UsersModule | users/ |
Профиль (/users/me), публичный профиль, список «мои игры» |
| GamesModule | games/ |
Каталог, детали, лайк/дизлайк/wishlist/оценка/статусы, похожие, популярные |
| SwipesModule | swipes/ |
Случайные игры для свайпов + batch-обработка действий |
| PreferencesModule | preferences/ |
PreferencesService — запись действий в БД (переиспользуется другими модулями) |
| SupabaseModule | supabase/ |
Глобальный провайдер SupabaseClient |
| CommonModule (файлы) | common/filters/ |
Глобальный фильтр исключений + middleware-логгер |
⚠️ PreferencesServiceиPreferencesControllerфизически лежат вpreferences/, но регистрируются внутриSwipesModule(см.swipes.module.ts), а отдельногоPreferencesModuleвapp.module.tsнет. Контроллер/preferences/game-actionsподнимается черезSwipesModule.
Базовый адрес: http://localhost:3001. Защищённые маршруты требуют заголовок
Authorization: Bearer <jwt> (Passport-стратегия jwt).
| Метод | Endpoint | Auth | Описание |
|---|---|---|---|
| POST | /auth/register |
❌ | Регистрация. Тело: { login, password }. Возвращает { user, token } |
| POST | /auth/login |
❌ | Вход. Тело: { login, password }. Возвращает { user, token } |
| GET | /auth/validate |
✅ | Проверка валидности токена (guard jwt) |
| POST | /auth/logout |
❌ | Возвращает { message: 'Logged out successfully' }; токенов не инвалидирует |
POST /auth/logoutна бэкенде ничего не делает (JWT stateless). Реальный выход происходит на стороне Next.js — удалением httpOnly-cookietoken/user.
Ответ register/login:
{
"user": { "id": 1, "login": "gamemaster", "created_at": "..." },
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}Валидация (DTO):
login— строка, 3–30 символов, только[a-zA-Z0-9_](только при регистрации).password— строка, 6–50 символов.
| Метод | Endpoint | Auth | Кэш | Описание |
|---|---|---|---|---|
| GET | /games |
❌ | 300 c | Список игр с фильтрами |
| GET | /games/popular |
❌ | — | Популярные игры (по -added) |
| GET | /games/similar/:gameId |
❌ | — | Похожие игры (по жанрам) |
| GET | /games/:id |
❌ | 3600 c | Детали игры + скриншоты |
| POST/DELETE | /games/:id/like |
✅ | — | Поставить / убрать лайк |
| POST/DELETE | /games/:id/dislike |
✅ | — | Поставить / убрать дизлайк |
| POST/DELETE | /games/:id/wishlist |
✅ | — | Добавить / убрать из wishlist |
| POST/DELETE | /games/:id/rate |
✅ | — | Оценить (1–10) / удалить оценку |
| GET | /games/:id/my-rating |
✅ | — | Моя оценка игры |
| POST | /games/:id/status |
✅ | — | Статус прохождения |
| POST | /games/:id/purchase |
✅ | — | Статус покупки |
Кэширование GET /games и GET /games/:id реализовано через CacheInterceptor
@CacheTTL(...)(хранилище — Redis).
Query-параметры GET /games:
| Параметр | По умолчанию | Описание |
|---|---|---|
page |
1 |
Номер страницы |
pageSize |
20 |
Размер страницы |
search |
— | Поиск по названию |
ordering |
-rating |
Сортировка (-rating, -added, -released, …) |
genres |
— | ID/slug жанров через запятую |
platforms |
— | ID платформ |
tags |
— | ID тегов |
dates |
— | Диапазон дат, напр. 2020-01-01,2020-12-31 |
developers |
— | ID разработчиков |
publishers |
— | ID издателей |
Тело для действий (like/dislike/wishlist) — GameMetaDto:
{
"gameName": "Cyberpunk 2077",
"gameImage": "https://media.rawg.io/.../game.jpg",
"genres": [{ "id": 4, "name": "Action" }],
"tags": [{ "id": 31, "name": "Singleplayer" }]
}Для оценки — RateGameDto: те же поля + rating: number (1–10).
Для статусов — UpdateGameStatusDto (status: not_played|playing|completed|dropped)
и UpdatePurchaseDto (purchase: owned|not_owned|want_to_buy).
| Метод | Endpoint | Auth | Описание |
|---|---|---|---|
| GET | /swipes |
✅ | Случайные игры для свайпов. Query: limit (10), exclude (CSV id) |
| POST | /swipes/swipe-action/batch |
✅ | Пачка действий { actions: [{ gameId, action, gameName, ... }] } |
Ответ GET /swipes:
{ "success": true, "games": [ /* … */ ], "hasMore": true }| Метод | Endpoint | Auth | Описание |
|---|---|---|---|
| GET | /users/me |
✅ | { user, profile } текущего пользователя |
| PUT | /users/me |
✅ | Обновить логин/пароль |
| PUT | /users/me/profile |
✅ | Обновить name, avatar_url, bio |
| GET | /users/me/games |
✅ | Все действия пользователя (полные записи) |
| GET | /users/:id |
❌ | Публичный профиль |
| Метод | Endpoint | Auth | Кэш | Описание |
|---|---|---|---|---|
| GET | /preferences/game-actions |
✅ | 60 c | Компактный список действий пользователя |
game-actions возвращает урезанный набор полей
(game_id, action_type, rating, completion_status, purchase_status, лимит 500),
который фронтенд раскладывает в карту состояний игр.
Файл games/rawg-proxy.ts — единственная точка обращения к RAWG.
const RAWG_PROXY_URL = 'https://playpulse-rawg-proxy.vercel.app/api/rawg';
export async function fetchFromRawgProxy(httpService, endpoint, params?) {
// чистит пустые параметры, собирает query, делает GET с таймаутом 15с,
// логирует URL и подробности ошибки, пробрасывает исключение наверх
}Особенности:
- Пустые/
null/''параметры отбрасываются перед формированием query. - Таймаут запроса — 15 секунд.
- Сервисы (
GamesService,SwipesService) ловят ошибку и возвращают «мягкий» результат (пустой список / fallback), чтобы не ронять API.
GamesService не отдаёт сырой ответ RAWG, а прогоняет его через эвристики:
meetsQualityThresholds— отбрасывает игры без названия/картинки, сrating < 3, а также малопопулярные (added < 100приrating < 4.3).calculateHybridScore— для сортировки по рейтингу учитывает популярность:rating * (1 + log10(added + 1) / 4).getSimilarGames— берёт целевую игру из таблицыgames(Supabase), считает «похожесть» по числу общих жанров (+ бонусы за рейтинг/metacritic) и сортирует. Если игры нет в БД — fallback на RAWG (getSimilarGamesFromRawg), затем наgetPopularGames.getPopularGames— топ по-addedс фильтромrating >= 3.5и наличием картинки.
Вся история действий хранится в таблице user_game_actions, запись идёт через
PreferencesService с использованием upsert по уникальному ключу
(user_id, game_id, action_type):
Тип (action_type) |
Метод сервиса |
|---|---|
like |
processGameAction(..., 'like') (снимает конфликтующий dislike) |
dislike |
processGameAction(..., 'dislike') (снимает конфликтующий like) |
wishlist |
processGameAction(..., 'wishlist') |
rate |
processGameRating(...) (поле rating) |
status_change |
updateGameCompletionStatus(...) (поле completion_status) |
purchase_change |
updatePurchaseStatus(...) (поле purchase_status) |
Удаление действия — removeGameAction(userId, gameId, actionType).
Средняя оценка — getUserAverageRating (усреднение всех записей rate).
Это самая важная деталь для понимания фронтенда. Запрос с клиента на /api/...
обрабатывается одним из двух способов:
-
Явные Route Handlers (
src/app/api/**/route.ts). Они достают JWT из httpOnly-cookietoken, подставляют заголовокAuthorization: Bearer ...и форвардят запрос на бэкенд. Так работают, например,/api/auth/*,/api/games/:id/like,/api/users/me,/api/preferences/game-actions. -
Глобальный rewrite из
next.config.ts:async rewrites() { return [{ source: '/api/:path*', destination: 'http://localhost:3001/:path*' }]; }
Rewrite применяется после файловых маршрутов (
afterFiles), то есть служит «запасным» прямым proxy на бэкенд для всех/api/*, под которые нет своего route handler.
Из-за этого часть фич ходит мимо явных handler'ов, прямо через rewrite:
| Запрос клиента | Обработчик | Куда уходит на бэкенд |
|---|---|---|
GET /api/games?... |
route handler games/route.ts |
GET /games |
GET /api/swipes?limit=&exclude= |
rewrite (своего handler нет) | GET /swipes |
POST /api/swipes/swipe-action/batch |
rewrite | POST /swipes/swipe-action/batch |
GET /api/games/similar/:id |
rewrite (своего handler нет) | GET /games/similar/:id |
В этих случаях токен передаётся не из cookie, а явным заголовком Authorization,
который клиентский код добавляет сам (он берёт token из AuthContext).
⚠️ В каталогеsrc/app/api/recommendations/*есть отдельные route handlers (swipes,popular,similar,swipe-action), но реальные страницы их не используют (свайпы ходят на/api/swipes, похожие — на/api/games/similar). Часть из них к тому же ссылается на несуществующие бэкенд-маршруты (/swipes/popular,/swipes/similar,/swipes/swipe-action). Это legacy-код.
| Маршрут | Файл | Описание |
|---|---|---|
/ |
app/page.tsx |
Главная: hero + подборки (Популярное/Action/RPG/Indie) + блок новостей. Грузит данные параллельно через /api/games?... и /api/news/rss |
/games |
app/games/page.tsx → GamesPageContent.tsx |
Каталог: поиск, фильтры (жанры/платформы/теги/даты), сортировка, пагинация |
/games/[id] |
app/games/[id]/page.tsx |
Детали игры: описание, скриншоты/трейлеры, кнопки действий, блок похожих игр (/api/games/similar/:id), цены (/api/crop-image для логотипов магазинов) |
/swipes |
app/swipes/page.tsx |
⭐ Tinder-свайпы. Требует авторизации (иначе AuthPopup) |
/profile |
app/profile/page.tsx |
Профиль: шапка, статистика (StatsCharts), сетка игр пользователя, модалка настроек. Защищён middleware.ts |
/news |
app/news/page.tsx |
Лента новостей с бесконечным скроллом и панелью источников |
/auth |
app/auth/page.tsx |
Переключение между формами входа/регистрации. Учитывает ?tab= и ?redirect= |
* (404) |
app/not-found.tsx |
Простая страница «404» |
Корневая разметка: layout.tsx подключает шрифты/иконки и рендерит ClientLayout,
который оборачивает приложение в AuthProvider → GameActionsProvider и выводит
боковую навигацию NavigationBlock.
Все маршруты лежат в src/app/api. Сводка:
| Route | Метод | Логика |
|---|---|---|
/api/auth/login |
POST | Форвардит на /auth/login, при успехе кладёт token (httpOnly) и user в cookie на 7 дней |
/api/auth/register |
POST | Аналогично login |
/api/auth/logout |
POST | Удаляет cookie token и user (бэкенд не вызывается) |
/api/auth/check |
GET | Проверяет cookie + дёргает /auth/validate; есть fallback на cookie user |
/api/auth/validate |
GET | Проверяет токен на бэкенде, отдаёт { valid } |
/api/auth/sync |
POST | Записывает переданные token/user в cookie |
| Route | Методы | Куда |
|---|---|---|
/api/games |
GET | /games (форвард query, Cache-Control: no-store) |
/api/games/[id] |
GET | /games/:id |
/api/games/[id]/like |
POST, DELETE | /games/:id/like (читает cookie-токен) |
/api/games/[id]/dislike |
POST, DELETE | /games/:id/dislike |
/api/games/[id]/wishlist |
POST, DELETE | /games/:id/wishlist |
/api/games/[id]/rate |
POST, DELETE | /games/:id/rate |
/api/games/[id]/status |
POST | /games/:id/status |
/api/games/[id]/purchase |
POST | /games/:id/purchase |
/api/games/genres |
GET | Хардкод-список жанров (RAWG id) |
/api/games/platforms |
GET | Список из lib/platforms.ts |
/api/users/me |
GET, PUT | /users/me |
/api/users/me/games |
GET | /users/me/games |
/api/users/me/profile |
PUT | /users/me/profile |
/api/preferences/game-actions |
GET | /preferences/game-actions |
| Route | Метод | Логика |
|---|---|---|
/api/news/rss |
GET, POST | Парсит RSS-ленты на сервере (см. ниже) |
/api/deals |
GET | Прокси к plati.io (?q= обязателен), кэш 1 час — цены/предложения |
/api/crop-image |
GET | Через sharp авто-обрезает прозрачные края логотипов магазинов с graph.digiseller.com |
/api/test |
— | Тестовый/служебный endpoint |
| Компонент | Файл | Назначение |
|---|---|---|
NavigationBlock |
NavigationBlock/ |
Боковое меню, умеет сворачиваться (передаёт onCollapseChange) |
GameCard |
GameCard/ |
Карточка игры в сетке; подтягивает среднюю цену через getDealsWithAverage |
GamesGrid |
GamesGrid/ |
Сетка карточек |
SwipeCard |
SwipeCard/ |
Полноэкранная карта свайпа. Drag через Pointer Events, определение направления left/right/up по порогам смещения, попап с полной инфой |
GameActions |
GameActions/ |
Кнопки like/dislike/wishlist/rate/status/purchase. Оптимистичные апдейты через useGameActions, откат при ошибке запроса |
StarRating |
StarRating/StarRaing.tsx |
Интерактивная оценка звёздами, шлёт POST/DELETE /api/games/:id/rate |
ScreenshotGallery |
ScreenshotGallery/ |
Галерея скриншотов игры |
StatsCharts |
StatsCharts/ |
Статистика профиля; рисует собственные BarChart/PieChart на SVG/CSS |
SearchInput |
SearchInput/ |
Поле поиска (debounce) |
NewsCard |
NewsCard/ |
Карточка новости (варианты medium/tall/wide/large) |
RssSourcesPanel |
RssSourcesPanel/ |
Управление источниками RSS (вкл/выкл, добавление, удаление) |
Skeleton |
Skeleton/ |
SkeletonBlock / SkeletonText для состояний загрузки |
AuthGuard |
AuthGuard/ |
Защита контента: редирект на /auth или показ AuthPopup |
AuthPopup |
AuthPopup/ |
Модалка «требуется авторизация» |
LoginForm / RegisterForm |
LoginForm/, RegisterForm/ |
Формы входа/регистрации (используют useAuth) |
profile/ProfileHeader |
profile/ |
Шапка профиля с аватаром, открытие настроек |
profile/ProfileGamesGrid |
profile/ |
Сетка игр пользователя с фильтрами по статусам |
profile/ProfileGameCard |
profile/ |
Карточка игры в профиле |
profile/ProfileSettingsModal |
profile/ |
Модалка редактирования профиля |
Компонента
SwipeControlsв проекте нет — управление свайпами реализовано жестами внутриSwipeCardи кнопками на самой странице/swipes.
Глобальное состояние аутентификации. Предоставляет:
user, profile, token, isAuthenticated, isLoading,
login(login, password), register(login, password), logout(),
updateUser(), updateProfile(), refreshProfile().
Логика инициализации:
- При старте дергает
/api/auth/check(серверная проверка через cookie). - Если сервер не подтвердил — fallback на
localStorage(token/user/profile). - Данные дублируются в
localStorageдля мгновенного восстановления сессии.
Карта состояний игр (Record<gameId, GameAction>), где GameAction:
type GameAction = {
liked: boolean;
disliked: boolean;
in_wishlist: boolean;
rating: number | null;
completion_status: "not_played" | "playing" | "completed" | "dropped";
purchase_status: "owned" | "not_owned" | "want_to_buy";
};Особенности:
- Загрузка действий из
/api/preferences/game-actionsи раскладка поaction_type. - Кэш в
sessionStorage(ключgame_actions_${token}) на 5 минут. - Дедупликация одновременных запросов через
pendingRequests: Map. - Перезагрузка при смене маршрута с debounce 100 мс.
- Таймаут запроса 8 секунд (
AbortController). setGameAction(gameId, partial)— оптимистичное локальное обновление (его и используетGameActionsдо подтверждения сервером).
services/gameService.ts— основной слой работы с играми на клиенте:gameService()(каталог с фильтрами),getGameById,getGenres,getPlatforms,getDealsWithAverage(цены через/api/deals),searchGames,getPopularGames,getNewGames, а такжеproxifyImage()— оборачивает URL картинки в внешний image-proxyhttps://playpulse-rawg-proxy.vercel.app/api/image?url=.... Базовый префикс запросов —API_BASE = "/api".services/scrollService.ts— управление прокруткой.hooks/useMediaActions.ts— работа с медиа (скриншоты/трейлеры) на странице игры.lib/platforms.ts— справочник родительских платформ (PARENT_PLATFORMS_FOR_UI).lib/supabase.ts— клиент Supabase для фронтенда (NEXT_PUBLIC_SUPABASE_URL/NEXT_PUBLIC_SUPABASE_ANON_KEY).
Регистрация / вход:
Browser → POST /api/auth/login → (Next.js) → POST /auth/login → (NestJS)
NestJS: bcrypt.compare(password, hash) → JWT.sign({ sub, login })
Next.js: кладёт token (httpOnly cookie) + user (cookie), отдаёт { user, token } клиенту
Client: дублирует token/user в localStorage (AuthContext)
Защищённый запрос:
Browser → /api/games/:id/like
Next.js route handler: читает cookie token → добавляет Authorization: Bearer → /games/:id/like
NestJS: JwtStrategy валидирует токен → пропускает запрос
Выход:
Browser → POST /api/auth/logout → Next.js удаляет cookie; Client чистит localStorage
- Секрет —
JWT_SECRET(обязателен;AuthModuleбросает ошибку, если не задан). - Срок жизни токена —
7d. - Payload —
{ sub: userId, login }.
JwtStrategy— извлекает Bearer-токен, проверяет подпись, валидирует пользователя в БД. Имеет in-memory кэш пользователей на 5 минут, чтобы не ходить в Supabase на каждый запрос.JwtAuthGuard(guards/jwt-auth.guardl.ts) — кидает 401 с понятным сообщением.OptionalJwtAuthGuard(guards/optional-jwt.ts) — пропускает и анонимов (возвращаетnullвместо ошибки).
Контроллеры используют
@UseGuards(AuthGuard('jwt'))напрямую (а не кастомныйJwtAuthGuard).
Константы:
const BATCH_SIZE = 10; // сколько игр грузим за раз
const PREFETCH_THRESHOLD = 5; // за сколько карт до конца подгружаем следующую пачку
const BATCH_FLUSH_INTERVAL = 5000; // как часто отправляем накопленные действия (мс)
const MAX_BATCH_SIZE = 10; // максимум действий в пачкеЖесты (SwipeCard, Pointer Events):
- → вправо — Like
- ← влево — Dislike
- ↑ вверх — Пропустить (skip)
Логика:
- Загрузка пачки:
GET /api/swipes?limit=10&exclude=<id,через,запятую>(заголовокAuthorization). Уже просмотренные id передаются вexclude. - Лайки/дизлайки копятся в
pendingActionsи отправляются пачкой:- по таймеру каждые 5 секунд / при наборе
MAX_BATCH_SIZE; - при уходе со страницы — через
navigator.sendBeacon('/api/swipes/swipe-action/batch'); - также есть обработчик
visibilitychange.
- по таймеру каждые 5 секунд / при наборе
- Prefetch: когда до конца стопки остаётся ≤ 5 карт, грузится следующая пачка.
- Берёт из
user_game_actionsвсе игры, с которыми пользователь уже взаимодействовал, и объединяет сexclude. - Делает 3 параллельных запроса к случайным страницам (1–10) RAWG, сортировка
-rating. - Фильтрует:
rating >= 4.0, естьbackground_image, не в списке просмотренных. - Если игр не хватило — ещё 2 случайные страницы (11–15).
- Если совсем пусто — fallback: любые популярные с картинкой (
-added). - Перемешивает и возвращает
limitигр.
При ошибке возвращается пустой массив — UI не ломается.
Реализована полностью на стороне Next.js (/api/news/rss + api/news/rss/utils.ts):
GET /api/news/rss— принимает?sources=id1,id2&limit=50, по умолчанию берёт включённые источники изDEFAULT_SOURCES, тянет ленты параллельно, фильтрует, сортирует по дате, перемешивает и режет поlimit.POST /api/news/rss— принимает{ sources: [...] }(используется страницей новостей, где источники хранятся вlocalStorage).- Парсинг — через
@xmldom/xmldom. Поддерживаются RSS и Atom: title, link, description/summary,content:encoded, дата, картинка (media:thumbnail,media:content,enclosure,<img>в контенте), категория, guid. - Обход блокировок — сначала прямой запрос с «браузерным» User-Agent, при неудаче
цепочка CORS-прокси (
allorigins,corsproxy.io,cors.bridged.cc). Таймаут 15 с.
Источники по умолчанию (DEFAULT_SOURCES): StopGame, Cubiq, GoHa.Ru, iGuides, DTF.
На странице /news: бесконечный скролл (IntersectionObserver), панель источников
(RssSourcesPanel) с сохранением выбора в localStorage, скелетоны при загрузке.
Проект использует Supabase как PostgreSQL. Подключение — через серверный ключ
(SUPABASE_SERVICE_KEY, иначе SUPABASE_KEY). При старте SupabaseService
проверяет соединение запросом к user_game_actions.
⚠️ В репозитории нет SQL-миграций. Схема ниже восстановлена по коду (полям, которые читаются/пишутся сервисами) и приведена как ориентир, а не как точный DDL. Реальные типы/ограничения настроены в самом Supabase.
Таблицы, к которым обращается код:
Используемые поля: id, login, password_hash, created_at.
Используемые поля: user_id, name, avatar_url, bio, preferred_language,
total_likes, total_dislikes, total_games_added
(в коде также объявлены favorite_genres, favorite_tags).
Главная таблица взаимодействий. Поля, которые пишет/читает PreferencesService:
user_id, game_id, game_name, game_image,
action_type, -- like | dislike | wishlist | rate | status_change | purchase_change
rating, -- для action_type = 'rate'
genres (jsonb), tags (jsonb),
completion_status, -- not_played | playing | completed | dropped
purchase_status, -- owned | not_owned | want_to_buy
created_at
Upsert идёт по уникальному ключу (user_id, game_id, action_type) — значит, для
корректной работы в Supabase должен быть соответствующий unique constraint.
Кэш-таблица данных RAWG, читается в getSimilarGames / getCachedGameById /
searchCachedGames. Поля: rawg_id, name, slug, released, background_image,
rating, metacritic, genres, tags, screenshots, added.
- Подключение настраивается в
app.module.tsчерезCacheModule+cache-manager-redis-yet, URL изREDIS_URL(по умолчаниюredis://localhost:6379). - Кэшируемые эндпоинты бэкенда:
| Endpoint | TTL |
|---|---|
GET /games |
300 c |
GET /games/:id |
3600 c |
GET /preferences/game-actions |
60 c |
- На фронтенде дополнительно кэшируются действия пользователя в
sessionStorage(5 минут) и список жанров/платформ — черезcache: "force-cache"вgameService.
| Переменная | Обяз. | Назначение |
|---|---|---|
SUPABASE_URL |
✅ | URL проекта Supabase |
SUPABASE_KEY |
✅ | Anon/public ключ |
SUPABASE_SERVICE_KEY |
➖ | Service-role ключ (если задан — используется вместо SUPABASE_KEY) |
JWT_SECRET |
✅ | Секрет для подписи JWT (без него приложение не стартует) |
REDIS_URL |
➖ | URL Redis (по умолчанию redis://localhost:6379) |
PORT |
➖ | Порт API (по умолчанию 3001) |
FRONTEND_URL |
➖ | Origin для CORS (по умолчанию http://localhost:3000) |
| Переменная | Назначение |
|---|---|
NEXT_PUBLIC_API_URL |
URL бэкенда для route handlers (по умолчанию http://localhost:3001) |
NEXT_PUBLIC_BACKEND_URL |
То же, используется в /api/users/* |
BACKEND_URL |
Серверный URL бэкенда для /api/games |
NEXT_PUBLIC_SUPABASE_URL |
URL Supabase (для next/image и клиента) |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
Anon-ключ Supabase для фронтенда |
⚠️ Файлов.env.exampleв репозитории нет (хотяmake env-setupи docker-compose на них ссылаются). Создайте.env/.env.localвручную по таблицам выше. Хост изNEXT_PUBLIC_API_URL(по умолчаниюlocalhost:3001) должен совпадать с адресом из rewrite вnext.config.ts.
- Node.js 18+
- Redis (локально или в облаке)
- Аккаунт Supabase с таблицами из раздела выше
- Доступ к RAWG proxy (используется публичный на Vercel)
# Установка всех зависимостей (корень + backend + frontend)
make install
# Создать backend .env (нужен существующий .env.example — см. примечание выше)
# и заполнить переменные backend/frontend
# Запуск Redis + frontend + backend разом
make runmake run проверит и при необходимости поднимет Redis, затем запустит frontend и
backend через concurrently.
Вручную (в двух терминалах):
# Терминал 1 — backend
cd apps/backend && npm run start:dev
# Терминал 2 — frontend
cd apps/frontend && npm run devmake run-docker # docker-compose up -d (redis + backend + frontend)
make logs # логи всех сервисов
make stop-docker # остановить- Frontend: http://localhost:3000
- Backend: http://localhost:3001
- Redis: localhost:6379
| Команда | Действие |
|---|---|
make install |
Установка зависимостей (root + backend + frontend) |
make run |
Проверка/запуск Redis + frontend + backend (concurrently) |
make tunnel |
localtunnel на порт 3000 (поддомен play-pulse) |
make run-docker / make stop-docker / make restart-docker |
Управление docker-compose |
make logs / make logs-service service=<backend|frontend|redis> |
Логи |
make redis-start / make redis-stop |
Локальный Redis |
make build |
Сборка backend + frontend |
make clean |
Удаление dist, .next, node_modules |
make test / make test-e2e |
Тесты backend (frontend-тесты не настроены) |
make lint / make format |
Линт/формат backend |
make env-setup |
Копирование apps/backend/.env.example → .env |
make health-check |
Пинг backend/frontend/redis |
Скрипты npm:
- backend:
start:dev,start:prod,build,test,test:e2e,lint,format. - frontend:
dev,build,start,lint.
Зафиксировано по состоянию кода, чтобы документация не вводила в заблуждение:
- Swagger не поднят. В
main.tsестьconsole.logпро доступность Swagger на/api, и в контроллерах расставлены декораторы@Api*, ноSwaggerModule.setup()нигде не вызывается — реального UI документации по/apiнет. POST /auth/logoutна бэкенде есть, но это «пустышка» (возвращает сообщение, токен не инвалидирует). Фронтенд для выхода его не вызывает — он просто чистит cookie через свой/api/auth/logout./api/recommendations/*— legacy route handlers, не используемые страницами; часть ссылается на несуществующие бэкенд-маршруты (/swipes/popular,/swipes/similar,/swipes/swipe-action). Похожие/популярные на бэкенде живут под/games/similar/:idи/games/popular./api/swipesи/api/games/similarне имеют своих route handlers и работают через rewrite изnext.config.tsнапрямую на бэкенд.- Нет
.env.example(см. раздел про переменные окружения). - Нет SQL-миграций — схема БД восстановлена по коду и должна быть создана в
Supabase вручную (включая unique-ключ
user_game_actions(user_id, game_id, action_type)). /api/games/genresотдаёт захардкоженный список жанров, а не данные RAWG.- Зависимости
recharts/zustandприсутствуют вpackage.json; графики профиля при этом нарисованы кастомно на SVG/CSS внутриStatsCharts.