Compare commits

4 Commits

Author SHA1 Message Date
Oscar
1662bb7dc8 feat(src/modules/greetings/greetings.controller.ts): обновляет ответы API для получения и создания фраз приветствия
 feat(src/modules/likes/likes.controller.ts): обновляет ответы API для создания лайка и получения совпадений
 feat(src/auth/auth.controller.ts): обновляет ответы API для регистрации, входа и выхода пользователя
 feat(src/modules/media/media.controller.ts): обновляет ответы API для загрузки и получения медиа
 feat(src/modules/users/users.controller.ts): обновляет ответы API для получения и управления пользователями
 feat(src/modules/dates/dto/dates-response.dto.ts): изменяет название свойства статуса даты на dateStatus
 feat(src/modules/cities/cities.controller.ts): обновляет ответы API для получения и создания городов и районов
 feat(src/modules/chat/chat.controller.ts): обновляет ответы API для создания и получения чатов и сообщений
 feat(src/modules/reports/reports.controller.ts): обновляет ответы API для создания и получения отчетов
 feat(src/modules/feed/feed.controller.ts): обновляет структуру ответа
2026-06-08 16:41:08 +03:00
Oscar
8b852c9f28 feat(cities): добавляет DTO для создания города и района
 feat(greetings): добавляет схему для тела запроса при добавлении фразы приветствия

 feat(auth): добавляет схему для тела запроса при обновлении FCM токена

 feat(tags): добавляет схему для тела запроса при создании тега
2026-06-08 14:29:40 +03:00
Oscar
102b6b4026 feat(src/modules/cities/cities.controller.ts): добавляет ответы API для получения всех городов и районов
 feat(src/modules/chat/chat.controller.ts): добавляет ответы API для создания чата и получения сообщений

 feat(src/modules/greetings/greetings.controller.ts): добавляет ответы API для получения и создания приветствий

 feat(src/modules/likes/likes.controller.ts): добавляет ответы API для создания лайков и получения совпадений

 feat(src/modules/reports/reports.controller.ts): добавляет ответы API для создания и получения отчетов

 feat(src/modules/feed/feed.controller.ts): добавляет ответ API для получения отфильтрованного фида

 feat(src/auth/auth.controller.ts): добавляет ответы API для регистрации, входа и выхода пользователей

 feat(src/modules/media/media.controller.ts): добавляет ответы API для загрузки и получения медиа

 feat(src/modules/users/users.controller.ts): добавляет ответы API для получения текущего пользователя и управления пользователями

 feat(src/modules/tags/tags.controller.ts): добавляет ответы API для получения и создания тегов

 feat(src/modules/profiles/profiles.controller.ts): добавляет ответы API для управления профилями пользователей

 feat(src/modules/dates/dates.controller.ts): добавляет ответы API для создания и получения встреч
2026-06-08 14:22:50 +03:00
Oscar
bc3e48bcad upd 2026-06-02 16:22:53 +03:00
54 changed files with 1605 additions and 1909 deletions

View File

@@ -23,6 +23,31 @@
--- ---
## Рефакторинг: мульти-профиль (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` ### 1. Глобальный JWT-guard через `APP_GUARD`
@@ -103,7 +128,7 @@ seed (например, хешем userId + дата).
| # | Пункт ТЗ | Как реализовано | Причина | | # | Пункт ТЗ | Как реализовано | Причина |
|---|---|---|---| |---|---|---|---|
| 1 | `chat.profile1_id → profile` | Поле хранит `user_id` | Матч создаётся между пользователями, а не профилями; JOIN с профилем не нужен на горячем пути | | 1 | `chat.profile1_id → profile` | Реализовано корректно после рефакторинга | — |
| 2 | Поиск «неактивен» при превышении лимита матчей | `BadRequestException` при лайке | Проще контракт с клиентом: ошибка явная, не нужно отдельного флага `searchActive` | | 2 | Поиск «неактивен» при превышении лимита матчей | `BadRequestException` при лайке | Проще контракт с клиентом: ошибка явная, не нужно отдельного флага `searchActive` |
| 3 | Тарифный план один, но оплата предусмотрена | Таблицы `tariff` и `payment` созданы, логика оплаты не реализована | ТЗ: «регистрация открыта для всех, тарифный план один» — бизнес-логики оплаты нет | | 3 | Тарифный план один, но оплата предусмотрена | Таблицы `tariff` и `payment` созданы, логика оплаты не реализована | ТЗ: «регистрация открыта для всех, тарифный план один» — бизнес-логики оплаты нет |
| 4 | `radiusKm` в фильтре ленты | Параметр принимается, но фильтрация по радиусу не применяется | Требует PostGIS или формулы Haversine; добавить в следующей итерации | | 4 | `radiusKm` в фильтре ленты | Параметр принимается, но фильтрация по радиусу не применяется | Требует PostGIS или формулы Haversine; добавить в следующей итерации |

341
frontend-starter-prompt.md Normal file
View File

@@ -0,0 +1,341 @@
# Стартовый промт — Frontend Dating App
## Контекст проекта
Ты помогаешь разрабатывать фронтенд мобильного/десктопного дейтинг-приложения.
OpenAPI-схема бэкенда лежит в корне проекта (`openapi.json`).
Генерируй типизированный HTTP-клиент из неё автоматически.
---
## Технологический стек
| Слой | Выбор |
|---|---|
| UI-фреймворк | **Vue 3** (Composition API + `<script setup>`) |
| Язык | **TypeScript** везде |
| Сборщик | **Vite** |
| Десктоп / нативный слой | **Tauri 2** |
| Стейт-менеджер | **Pinia** |
| Роутер | **Vue Router 4** |
| HTTP-клиент | **axios** + автогенерированные типы из OpenAPI (`openapi-typescript` + `axios`) |
| WebSocket | **socket.io-client** |
| UI-библиотека | **Naive UI** (или UnoCSS + headless — на твоё усмотрение) |
| Формы | **VeeValidate** + **Zod** |
| Пакетный менеджер | **pnpm** |
| Анимации | **@vueuse/motion** |
| Утилиты | **VueUse** |
| Иконки | **@iconify/vue** |
| Адаптивность | mobile-first; десктоп — расширение, не отдельная версия |
---
## Что такое это приложение
Дейтинг-приложение. Пользователь:
1. Регистрируется по номеру телефона + пароль
2. Создаёт один или несколько публичных **профилей** (у одного аккаунта может быть много профилей)
3. Листает **ленту** других профилей с фильтрацией
4. Ставит **лайки / дизлайки** на профили
5. При взаимном лайке создаётся **матч**
6. Открывает **чат** с одним матчем за раз (один активный чат на профиль)
7. В чате обменивается текстом, фото, голосовыми и видео-сообщениями
8. Договаривается о реальных **встречах** (дата, координаты, статус)
9. Может подать **жалобу** на профиль или сообщение
---
## Архитектура бэкенда (важно для интеграции)
### Базовый URL
```
http://localhost:3000/api/v1
```
### Формат ответа
**Все** успешные ответы обёрнуты:
```json
{ "data": <payload> }
```
Ошибки:
```json
{ "statusCode": 400, "message": "...", "timestamp": "...", "path": "..." }
```
### Авторизация
- Тип: **Bearer JWT**
- Заголовок: `Authorization: Bearer <accessToken>`
- `POST /auth/register` — регистрация `{ phone, password }`
- `POST /auth/login` — вход `{ phone, password }``{ accessToken, refreshToken }`
- `POST /auth/refresh` — обновление `{ refreshToken }``{ accessToken, refreshToken }`
- `POST /auth/logout` — выход (требует Bearer)
- `POST /auth/fcm-token` — обновить push-токен `{ fcmToken }`
- Access-токен: TTL **7 дней** (настраивается на бэке)
- Refresh-токен: TTL **30 дней**, хранится в Redis
### Профили (один аккаунт → много профилей)
- `POST /profiles` — создать профиль
- `GET /profiles/my` — все мои профили
- `GET /profiles/:profileId` — публичный профиль
- `PUT /profiles/:profileId` — обновить (только владелец)
- `DELETE /profiles/:profileId` — удалить (только владелец)
Поля профиля: `name`, `birthDate`, `gender` (`male|female`), `cityId`, `districtId`,
`description`, `nation`, `height`, `weight`, `tagIds[]`
Ответ профиля включает: `city`, `district`, `tags[]`, `media[]` (отсортировано по `sortOrder`)
### Медиа профиля
Маршруты: `POST /profiles/:profileId/media/upload?type=photo|video|audio`
Медиафайлы профиля: **фото, видео, аудио** — хранятся в MinIO.
В ответе профиля приходит массив `media[]` с полями `{ id, path, type, sortOrder }`.
### Лента
```
GET /feed?profileId=<uuid>&page=1&limit=20
&cityId=&districtId=
&ageMin=&ageMax=
&keyword=
&tagIds[]=
```
- Уже лайкнутые профили **не показываются**
- Порядок случайный
- При достижении лимита матчей (`MAX_MATCHES_BEFORE_PAUSE=10`) лайк вернёт `400`
клиент должен показать экран «разбери матчи»
### Лайки и матчи
- `POST /likes``{ sourceProfileId, targetProfileId, type: "like"|"dislike" }`
Ответ: `{ like, match }` — если `match != null`, это взаимный лайк
- `GET /likes/matches?profileId=<uuid>` — все матчи профиля
### Чат
- Один профиль — один активный чат одновременно (`profile.active_chat_id`)
- `POST /chats``{ profileId, matchId }` → открыть чат для матча
- `GET /chats?profileId=<uuid>` — активные чаты
- `GET /chats/:chatId/messages?profileId=<uuid>&page=1&limit=50`
- `POST /chats/:chatId/messages?profileId=<uuid>` — отправить `{ text?, mediaUrl?, mediaType? }`
- `DELETE /chats/:chatId?profileId=<uuid>` — закрыть чат
### Встречи (dates)
- `POST /dates``{ profileId, partnerProfileId, lat, lng, time, statusId? }`
- `GET /dates?profileId=<uuid>`
- `PATCH /dates/:id/status?profileId=<uuid>``{ statusId }`
- `GET /dates/statuses` — справочник статусов (`pending`, `confirmed`, `cancelled`, `rescheduled`)
### Репорты
- `POST /reports``{ sourceProfileId, entityId, entityType: "profile"|"message", description? }`
### Справочники (публичные, без токена)
- `GET /tags`
- `GET /cities`
- `GET /cities/:cityId/districts`
- `GET /greetings` — готовые приветственные фразы
---
## WebSocket (real-time чат)
Библиотека: **socket.io-client**
```typescript
const socket = io('http://localhost:3000/chat', {
auth: {
token: '<accessToken>',
profileId: '<activeProfileId>', // обязательно!
}
})
```
**Клиент отправляет:**
| Событие | Payload | Описание |
|---|---|---|
| `join_chat` | `{ chatId }` | Войти в комнату чата |
| `leave_chat` | `{ chatId }` | Покинуть комнату |
| `send_message` | `{ chatId, text?, mediaUrl?, mediaType? }` | Отправить сообщение |
| `typing` | `{ chatId, isTyping: bool }` | Индикатор печати |
**Сервер отправляет:**
| Событие | Payload | Описание |
|---|---|---|
| `new_message` | `Message` | Новое сообщение в комнате |
| `user_typing` | `{ profileId, isTyping }` | Кто-то печатает |
**Важно:** при смене активного профиля переподключи сокет с новым `profileId`.
---
## Структура проекта
```
src/
api/ # Автогенерированный клиент из openapi.json
client.ts # axios-инстанс с интерцептором токена и рефреша
index.ts # реэкспорт всех операций
stores/
auth.store.ts # user, accessToken, refreshToken, login/logout
profile.store.ts # activeProfile, myProfiles, switch
feed.store.ts # лента, пагинация
chat.store.ts # активный чат, сообщения, WS-соединение
match.store.ts # матчи
composables/
useSocket.ts # хук socket.io с авто-реконнектом
useInfiniteScroll.ts # бесконечная прокрутка
useMediaUpload.ts # загрузка файлов с прогрессом
router/
index.ts
guards.ts # редирект на /login если нет токена, /setup-profile если нет профиля
views/
auth/
LoginView.vue
RegisterView.vue
onboarding/
CreateProfileView.vue # создание первого профиля после регистрации
feed/
FeedView.vue # свайп-лента или карточки
matches/
MatchesView.vue
chat/
ChatsListView.vue
ChatView.vue # реалтайм переписка
dates/
DatesView.vue
profile/
MyProfilesView.vue # список профилей аккаунта
ProfileEditView.vue
ProfilePublicView.vue # как видят другие
settings/
SettingsView.vue
components/
profile/
ProfileCard.vue # карточка в ленте
ProfileAvatar.vue
MediaGallery.vue # фото/видео/аудио галерея профиля
MediaUploader.vue # загрузка с preview
chat/
MessageBubble.vue
TypingIndicator.vue
AudioPlayer.vue # проигрыватель голосовых
feed/
SwipeCard.vue # свайп-карточка (мобильная)
LikeButtons.vue
common/
BottomNav.vue # навигация для мобилки
SideNav.vue # навигация для десктопа
AppLayout.vue # адаптивный layout
tauri/ # Tauri-специфичный код
notifications.ts # нативные уведомления
deepLinks.ts
```
---
## Адаптивность: мобилка и десктоп в одном приложении
Приложение собирается через **Tauri 2**. Один кодовой базой покрывается:
- **Мобилка** (Tauri + iOS/Android через Tauri Mobile) — свайп-жесты, нижняя навигация, полноэкранные карточки
- **Десктоп** (Tauri + Windows/macOS/Linux) — боковое меню, двухколоночный layout, drag-and-drop для медиа
Используй CSS-брейкпоинты **mobile-first**:
- `< 768px` — мобильный layout (BottomNav, полноэкранная лента)
- `≥ 768px` — планшет/десктоп (SideNav, split-view для чата)
Определяй режим через `window.innerWidth` или VueUse `useBreakpoints`.
Tauri-специфичное поведение (нативные уведомления, файловый пикер) оборачивай в composable с graceful degradation до браузерного API.
---
## Ключевые UX-сценарии
### 1. Первый запуск
`Регистрация``Создание профиля` (name, birthDate, gender обязательны) →
`Загрузка фото``Лента`
### 2. Лента и лайк
Карточка профиля → свайп вправо (лайк) / влево (дизлайк) → если матч →
показать `MatchModal` с кнопкой «Написать» → открыть чат
### 3. Лимит матчей
Попытка лайкнуть при `N >= 10 матчей` → API вернёт `400`
показать экран/модал «У тебя X матчей. Разберись с ними, чтобы продолжить поиск»
### 4. Чат
Список чатов → выбор → join WS-комнаты → обмен сообщениями в реалтайме →
закрытие чата = новый чат стал доступен.
Перед открытием чата — можно выбрать приветственную фразу из `GET /greetings`.
### 5. Смена активного профиля
В настройках — список всех профилей аккаунта →
при смене переподключить WS с новым `profileId`.
### 6. Встреча
В активном чате кнопка «Назначить встречу» → форма (карта/координаты + дата) →
`POST /dates` → партнёр видит `pending` → может подтвердить / отменить / перенести.
---
## Хранение состояния авторизации
```typescript
// stores/auth.store.ts (Pinia)
// accessToken + refreshToken хранить в localStorage
// При 401 — автоматически вызывать POST /auth/refresh
// При неудаче refresh — очистить стор, редирект на /login
```
Интерцептор axios:
```typescript
// При 401: один раз попытаться рефрешнуть токен,
// повторить исходный запрос с новым токеном,
// если рефреш тоже 401 — logout
```
---
## Нотификации
- В браузере / PWA: Web Notifications API
- В Tauri: `@tauri-apps/plugin-notification`
- FCM-токен: после логина отправить на `POST /auth/fcm-token`
- Обрабатывать WS-события `new_message` и `match_created` для in-app уведомлений
---
## Генерация API-клиента
В корне проекта лежит `openapi.json`. Сгенерируй типизированный клиент:
```bash
pnpm add -D openapi-typescript
pnpm exec openapi-typescript openapi.json -o src/api/schema.d.ts
```
Используй типы из схемы для всех запросов/ответов.
Создай обёртку над axios, которая автоматически разворачивает `{ data: T }`.
---
## Что НЕ нужно реализовывать сейчас
- Логика оплаты (таблицы `tariff`/`payment` есть в БД, но API оплаты нет)
- Админ-панель (модераторские эндпоинты — ban/activate пользователей — есть в API, но отдельного UI не нужно)
---
## Старт проекта
```bash
pnpm create tauri-app@latest
# выбрать: Vue + TypeScript + Vite
pnpm add pinia vue-router axios socket.io-client vee-validate zod @vueuse/core @vueuse/motion naive-ui @iconify/vue
pnpm add -D openapi-typescript @types/node
# Сгенерировать типы из схемы
pnpm exec openapi-typescript openapi.json -o src/api/schema.d.ts
```
Не спрашивай подтверждения на каждый шаг — работай до тех пор,
пока весь фронтенд не будет готов к первому запуску (`pnpm tauri dev`).

View File

@@ -1,12 +1,14 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiBody, ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { TokensResponseDto } from './dto/tokens-response.dto';
import { Public } from '../common/decorators/public.decorator'; import { Public } from '../common/decorators/public.decorator';
import { CurrentUser } from '../common/decorators/current-user.decorator'; import { CurrentUser } from '../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { MessageResponseDto } from '../common/dto/message-response.dto';
@ApiTags('auth') @ApiTags('auth')
@Controller('auth') @Controller('auth')
@@ -16,6 +18,7 @@ export class AuthController {
@Public() @Public()
@Post('register') @Post('register')
@ApiOperation({ summary: 'Register new user' }) @ApiOperation({ summary: 'Register new user' })
@ApiCreatedResponse({ type: TokensResponseDto })
register(@Body() dto: RegisterDto) { register(@Body() dto: RegisterDto) {
return this.authService.register(dto); return this.authService.register(dto);
} }
@@ -23,6 +26,7 @@ export class AuthController {
@Public() @Public()
@Post('login') @Post('login')
@ApiOperation({ summary: 'Login with phone and password' }) @ApiOperation({ summary: 'Login with phone and password' })
@ApiCreatedResponse({ type: TokensResponseDto })
login(@Body() dto: LoginDto) { login(@Body() dto: LoginDto) {
return this.authService.login(dto); return this.authService.login(dto);
} }
@@ -31,6 +35,7 @@ export class AuthController {
@Post('logout') @Post('logout')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: 'Logout current user' }) @ApiOperation({ summary: 'Logout current user' })
@ApiOkResponse({ type: MessageResponseDto })
logout(@CurrentUser('id') userId: string) { logout(@CurrentUser('id') userId: string) {
return this.authService.logout(userId); return this.authService.logout(userId);
} }
@@ -38,6 +43,7 @@ export class AuthController {
@Public() @Public()
@Post('refresh') @Post('refresh')
@ApiOperation({ summary: 'Refresh access token' }) @ApiOperation({ summary: 'Refresh access token' })
@ApiCreatedResponse({ type: TokensResponseDto })
refresh(@Body() dto: RefreshTokenDto) { refresh(@Body() dto: RefreshTokenDto) {
return this.authService.refreshTokens(dto.refreshToken); return this.authService.refreshTokens(dto.refreshToken);
} }
@@ -46,6 +52,8 @@ export class AuthController {
@Post('fcm-token') @Post('fcm-token')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: 'Update FCM push token' }) @ApiOperation({ summary: 'Update FCM push token' })
@ApiBody({ schema: { type: 'object', properties: { fcmToken: { type: 'string', example: 'firebase-token-abc123' } }, required: ['fcmToken'] } })
@ApiOkResponse({ type: MessageResponseDto })
updateFcmToken( updateFcmToken(
@CurrentUser('id') userId: string, @CurrentUser('id') userId: string,
@Body('fcmToken') fcmToken: string, @Body('fcmToken') fcmToken: string,

View File

@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
export class TokensResponseDto {
@ApiProperty({ example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' })
accessToken: string;
@ApiProperty({ example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' })
refreshToken: string;
}

View File

@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentProfile = createParamDecorator(
(data: string, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const profile = request.profile;
return data ? profile?.[data] : profile;
},
);

View File

@@ -0,0 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
export class MessageResponseDto {
@ApiProperty({ example: 'Operation successful' })
message: string;
}

View File

@@ -0,0 +1,36 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { DrizzleService } from '../../database/drizzle.service';
import { profile } from '../../database/schema';
/**
* Verifies that the profileId in request body/params belongs to the authenticated user.
* Expects profileId in: params.profileId OR body.profileId OR query.profileId
*/
@Injectable()
export class ProfileOwnerGuard implements CanActivate {
constructor(private readonly drizzleService: DrizzleService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const userId = request.user?.id;
const profileId =
request.params?.profileId ||
request.body?.profileId ||
request.query?.profileId;
if (!profileId) return true;
const [found] = await this.drizzleService.db
.select({ id: profile.id, userId: profile.userId })
.from(profile)
.where(eq(profile.id, profileId))
.limit(1);
if (!found) throw new NotFoundException('Profile not found');
if (found.userId !== userId) throw new ForbiddenException('Profile does not belong to you');
request.profile = found;
return true;
}
}

View File

@@ -1,7 +1,9 @@
CREATE TYPE "public"."chat_status" AS ENUM('active', 'closed');--> statement-breakpoint
CREATE TYPE "public"."media_type" AS ENUM('photo', 'voice', 'video');--> statement-breakpoint
CREATE TYPE "public"."user_status" AS ENUM('active', 'banned', 'pending');--> statement-breakpoint CREATE TYPE "public"."user_status" AS ENUM('active', 'banned', 'pending');--> statement-breakpoint
CREATE TYPE "public"."gender" AS ENUM('male', 'female');--> statement-breakpoint
CREATE TYPE "public"."profile_media_type" AS ENUM('photo', 'video', 'audio');--> statement-breakpoint
CREATE TYPE "public"."like_type" AS ENUM('like', 'dislike');--> statement-breakpoint CREATE TYPE "public"."like_type" AS ENUM('like', 'dislike');--> statement-breakpoint
CREATE TYPE "public"."chat_status" AS ENUM('active', 'closed');--> statement-breakpoint
CREATE TYPE "public"."message_media_type" AS ENUM('photo', 'voice', 'video');--> statement-breakpoint
CREATE TYPE "public"."report_entity_type" AS ENUM('profile', 'message');--> statement-breakpoint CREATE TYPE "public"."report_entity_type" AS ENUM('profile', 'message');--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "permission" ( CREATE TABLE IF NOT EXISTS "permission" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
@@ -35,28 +37,6 @@ CREATE TABLE IF NOT EXISTS "city_district" (
"name" varchar(200) NOT NULL "name" varchar(200) NOT NULL
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE IF NOT EXISTS "chat" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"profile1_id" uuid NOT NULL,
"profile2_id" uuid NOT NULL,
"status" "chat_status" DEFAULT 'active' NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "greetings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"text" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "message" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"chat_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"text" text,
"media_url" text,
"media_type" "media_type",
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "payment" ( CREATE TABLE IF NOT EXISTS "payment" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL, "user_id" uuid NOT NULL,
@@ -72,30 +52,31 @@ CREATE TABLE IF NOT EXISTS "user" (
"role_id" uuid, "role_id" uuid,
"tariff_id" uuid, "tariff_id" uuid,
"payment_id" uuid, "payment_id" uuid,
"active_chat_id" uuid,
"fcm_token" text, "fcm_token" text,
CONSTRAINT "user_phone_unique" UNIQUE("phone") CONSTRAINT "user_phone_unique" UNIQUE("phone")
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE IF NOT EXISTS "media" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"path" text NOT NULL,
"type" varchar(10) NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "profile" ( CREATE TABLE IF NOT EXISTS "profile" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL, "user_id" uuid NOT NULL,
"name" varchar(100) NOT NULL, "name" varchar(100) NOT NULL,
"birth_date" date NOT NULL, "birth_date" date NOT NULL,
"gender" "gender" NOT NULL,
"city_id" uuid, "city_id" uuid,
"district_id" uuid, "district_id" uuid,
"description" text, "description" text,
"nation" varchar(100), "nation" varchar(100),
"height" double precision, "height" double precision,
"weight" double precision, "weight" double precision,
CONSTRAINT "profile_user_id_unique" UNIQUE("user_id") "active_chat_id" uuid
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "profile_media" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"profile_id" uuid NOT NULL,
"path" text NOT NULL,
"type" "profile_media_type" NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE IF NOT EXISTS "profile_tag" ( CREATE TABLE IF NOT EXISTS "profile_tag" (
@@ -111,23 +92,45 @@ CREATE TABLE IF NOT EXISTS "tag" (
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE IF NOT EXISTS "like" ( CREATE TABLE IF NOT EXISTS "like" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"source_user" uuid NOT NULL, "source_profile_id" uuid NOT NULL,
"target_user" uuid NOT NULL, "target_profile_id" uuid NOT NULL,
"type" "like_type" NOT NULL, "type" "like_type" NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL "created_at" timestamp with time zone DEFAULT now() NOT NULL
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE IF NOT EXISTS "match" ( CREATE TABLE IF NOT EXISTS "match" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user1_id" uuid NOT NULL, "profile1_id" uuid NOT NULL,
"user2_id" uuid NOT NULL, "profile2_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "chat" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"profile1_id" uuid NOT NULL,
"profile2_id" uuid NOT NULL,
"status" "chat_status" DEFAULT 'active' NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "greetings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"text" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "message" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"chat_id" uuid NOT NULL,
"profile_id" uuid NOT NULL,
"text" text,
"media_url" text,
"media_type" "message_media_type",
"created_at" timestamp with time zone DEFAULT now() NOT NULL "created_at" timestamp with time zone DEFAULT now() NOT NULL
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE IF NOT EXISTS "date" ( CREATE TABLE IF NOT EXISTS "date" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user1_id" uuid NOT NULL, "profile1_id" uuid NOT NULL,
"user2_id" uuid NOT NULL, "profile2_id" uuid NOT NULL,
"lat" numeric(10, 7) NOT NULL, "lat" numeric(10, 7) NOT NULL,
"lng" numeric(10, 7) NOT NULL, "lng" numeric(10, 7) NOT NULL,
"time" timestamp with time zone NOT NULL, "time" timestamp with time zone NOT NULL,
@@ -141,7 +144,7 @@ CREATE TABLE IF NOT EXISTS "date_status" (
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE IF NOT EXISTS "report" ( CREATE TABLE IF NOT EXISTS "report" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"source_user" uuid NOT NULL, "source_profile_id" uuid NOT NULL,
"entity_id" uuid NOT NULL, "entity_id" uuid NOT NULL,
"entity_type" "report_entity_type" NOT NULL, "entity_type" "report_entity_type" NOT NULL,
"description" text "description" text
@@ -159,12 +162,6 @@ EXCEPTION
WHEN duplicate_object THEN null; WHEN duplicate_object THEN null;
END $$; END $$;
--> statement-breakpoint --> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "message" ADD CONSTRAINT "message_chat_id_chat_id_fk" FOREIGN KEY ("chat_id") REFERENCES "public"."chat"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN DO $$ BEGIN
ALTER TABLE "payment" ADD CONSTRAINT "payment_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; ALTER TABLE "payment" ADD CONSTRAINT "payment_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION EXCEPTION
@@ -183,12 +180,6 @@ EXCEPTION
WHEN duplicate_object THEN null; WHEN duplicate_object THEN null;
END $$; END $$;
--> statement-breakpoint --> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "media" ADD CONSTRAINT "media_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN DO $$ BEGIN
ALTER TABLE "profile" ADD CONSTRAINT "profile_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; ALTER TABLE "profile" ADD CONSTRAINT "profile_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION EXCEPTION
@@ -207,6 +198,12 @@ EXCEPTION
WHEN duplicate_object THEN null; WHEN duplicate_object THEN null;
END $$; END $$;
--> statement-breakpoint --> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "profile_media" ADD CONSTRAINT "profile_media_profile_id_profile_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."profile"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN DO $$ BEGIN
ALTER TABLE "profile_tag" ADD CONSTRAINT "profile_tag_profile_id_profile_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."profile"("id") ON DELETE cascade ON UPDATE no action; ALTER TABLE "profile_tag" ADD CONSTRAINT "profile_tag_profile_id_profile_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."profile"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION EXCEPTION
@@ -220,37 +217,61 @@ EXCEPTION
END $$; END $$;
--> statement-breakpoint --> statement-breakpoint
DO $$ BEGIN DO $$ BEGIN
ALTER TABLE "like" ADD CONSTRAINT "like_source_user_user_id_fk" FOREIGN KEY ("source_user") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; ALTER TABLE "like" ADD CONSTRAINT "like_source_profile_id_profile_id_fk" FOREIGN KEY ("source_profile_id") REFERENCES "public"."profile"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION EXCEPTION
WHEN duplicate_object THEN null; WHEN duplicate_object THEN null;
END $$; END $$;
--> statement-breakpoint --> statement-breakpoint
DO $$ BEGIN DO $$ BEGIN
ALTER TABLE "like" ADD CONSTRAINT "like_target_user_user_id_fk" FOREIGN KEY ("target_user") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; ALTER TABLE "like" ADD CONSTRAINT "like_target_profile_id_profile_id_fk" FOREIGN KEY ("target_profile_id") REFERENCES "public"."profile"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION EXCEPTION
WHEN duplicate_object THEN null; WHEN duplicate_object THEN null;
END $$; END $$;
--> statement-breakpoint --> statement-breakpoint
DO $$ BEGIN DO $$ BEGIN
ALTER TABLE "match" ADD CONSTRAINT "match_user1_id_user_id_fk" FOREIGN KEY ("user1_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; ALTER TABLE "match" ADD CONSTRAINT "match_profile1_id_profile_id_fk" FOREIGN KEY ("profile1_id") REFERENCES "public"."profile"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION EXCEPTION
WHEN duplicate_object THEN null; WHEN duplicate_object THEN null;
END $$; END $$;
--> statement-breakpoint --> statement-breakpoint
DO $$ BEGIN DO $$ BEGIN
ALTER TABLE "match" ADD CONSTRAINT "match_user2_id_user_id_fk" FOREIGN KEY ("user2_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; ALTER TABLE "match" ADD CONSTRAINT "match_profile2_id_profile_id_fk" FOREIGN KEY ("profile2_id") REFERENCES "public"."profile"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION EXCEPTION
WHEN duplicate_object THEN null; WHEN duplicate_object THEN null;
END $$; END $$;
--> statement-breakpoint --> statement-breakpoint
DO $$ BEGIN DO $$ BEGIN
ALTER TABLE "date" ADD CONSTRAINT "date_user1_id_user_id_fk" FOREIGN KEY ("user1_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; ALTER TABLE "chat" ADD CONSTRAINT "chat_profile1_id_profile_id_fk" FOREIGN KEY ("profile1_id") REFERENCES "public"."profile"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION EXCEPTION
WHEN duplicate_object THEN null; WHEN duplicate_object THEN null;
END $$; END $$;
--> statement-breakpoint --> statement-breakpoint
DO $$ BEGIN DO $$ BEGIN
ALTER TABLE "date" ADD CONSTRAINT "date_user2_id_user_id_fk" FOREIGN KEY ("user2_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; ALTER TABLE "chat" ADD CONSTRAINT "chat_profile2_id_profile_id_fk" FOREIGN KEY ("profile2_id") REFERENCES "public"."profile"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "message" ADD CONSTRAINT "message_chat_id_chat_id_fk" FOREIGN KEY ("chat_id") REFERENCES "public"."chat"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "message" ADD CONSTRAINT "message_profile_id_profile_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."profile"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "date" ADD CONSTRAINT "date_profile1_id_profile_id_fk" FOREIGN KEY ("profile1_id") REFERENCES "public"."profile"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "date" ADD CONSTRAINT "date_profile2_id_profile_id_fk" FOREIGN KEY ("profile2_id") REFERENCES "public"."profile"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION EXCEPTION
WHEN duplicate_object THEN null; WHEN duplicate_object THEN null;
END $$; END $$;
@@ -262,7 +283,7 @@ EXCEPTION
END $$; END $$;
--> statement-breakpoint --> statement-breakpoint
DO $$ BEGIN DO $$ BEGIN
ALTER TABLE "report" ADD CONSTRAINT "report_source_user_user_id_fk" FOREIGN KEY ("source_user") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; ALTER TABLE "report" ADD CONSTRAINT "report_source_profile_id_profile_id_fk" FOREIGN KEY ("source_profile_id") REFERENCES "public"."profile"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION EXCEPTION
WHEN duplicate_object THEN null; WHEN duplicate_object THEN null;
END $$; END $$;

View File

@@ -1,3 +0,0 @@
CREATE TYPE "public"."gender" AS ENUM('male', 'female');--> statement-breakpoint
ALTER TABLE "profile" ADD COLUMN "gender" "gender" NOT NULL DEFAULT 'male';--> statement-breakpoint
ALTER TABLE "profile" ALTER COLUMN "gender" DROP DEFAULT;

View File

@@ -1,5 +1,5 @@
{ {
"id": "3d66c7d2-fc68-4c66-ad86-4f558d519225", "id": "7caebd65-9149-400c-92fa-1981f0e4ea72",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
@@ -206,144 +206,6 @@
"checkConstraints": {}, "checkConstraints": {},
"isRLSEnabled": false "isRLSEnabled": false
}, },
"public.chat": {
"name": "chat",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"profile1_id": {
"name": "profile1_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"profile2_id": {
"name": "profile2_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "chat_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'active'"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.greetings": {
"name": "greetings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.message": {
"name": "message",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"chat_id": {
"name": "chat_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": false
},
"media_url": {
"name": "media_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"media_type": {
"name": "media_type",
"type": "media_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"message_chat_id_chat_id_fk": {
"name": "message_chat_id_chat_id_fk",
"tableFrom": "message",
"tableTo": "chat",
"columnsFrom": [
"chat_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.payment": { "public.payment": {
"name": "payment", "name": "payment",
"schema": "", "schema": "",
@@ -445,12 +307,6 @@
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
"active_chat_id": {
"name": "active_chat_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"fcm_token": { "fcm_token": {
"name": "fcm_token", "name": "fcm_token",
"type": "text", "type": "text",
@@ -501,58 +357,6 @@
"checkConstraints": {}, "checkConstraints": {},
"isRLSEnabled": false "isRLSEnabled": false
}, },
"public.media": {
"name": "media",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "varchar(10)",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"media_user_id_user_id_fk": {
"name": "media_user_id_user_id_fk",
"tableFrom": "media",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.profile": { "public.profile": {
"name": "profile", "name": "profile",
"schema": "", "schema": "",
@@ -582,6 +386,13 @@
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"gender": {
"name": "gender",
"type": "gender",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"city_id": { "city_id": {
"name": "city_id", "name": "city_id",
"type": "uuid", "type": "uuid",
@@ -617,6 +428,12 @@
"type": "double precision", "type": "double precision",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
},
"active_chat_id": {
"name": "active_chat_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
} }
}, },
"indexes": {}, "indexes": {},
@@ -662,15 +479,67 @@
} }
}, },
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},
"uniqueConstraints": { "uniqueConstraints": {},
"profile_user_id_unique": { "policies": {},
"name": "profile_user_id_unique", "checkConstraints": {},
"nullsNotDistinct": false, "isRLSEnabled": false
"columns": [ },
"user_id" "public.profile_media": {
] "name": "profile_media",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"profile_id": {
"name": "profile_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "profile_media_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
} }
}, },
"indexes": {},
"foreignKeys": {
"profile_media_profile_id_profile_id_fk": {
"name": "profile_media_profile_id_profile_id_fk",
"tableFrom": "profile_media",
"tableTo": "profile",
"columnsFrom": [
"profile_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {}, "policies": {},
"checkConstraints": {}, "checkConstraints": {},
"isRLSEnabled": false "isRLSEnabled": false
@@ -772,14 +641,14 @@
"notNull": true, "notNull": true,
"default": "gen_random_uuid()" "default": "gen_random_uuid()"
}, },
"source_user": { "source_profile_id": {
"name": "source_user", "name": "source_profile_id",
"type": "uuid", "type": "uuid",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"target_user": { "target_profile_id": {
"name": "target_user", "name": "target_profile_id",
"type": "uuid", "type": "uuid",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
@@ -801,12 +670,12 @@
}, },
"indexes": {}, "indexes": {},
"foreignKeys": { "foreignKeys": {
"like_source_user_user_id_fk": { "like_source_profile_id_profile_id_fk": {
"name": "like_source_user_user_id_fk", "name": "like_source_profile_id_profile_id_fk",
"tableFrom": "like", "tableFrom": "like",
"tableTo": "user", "tableTo": "profile",
"columnsFrom": [ "columnsFrom": [
"source_user" "source_profile_id"
], ],
"columnsTo": [ "columnsTo": [
"id" "id"
@@ -814,12 +683,12 @@
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
"like_target_user_user_id_fk": { "like_target_profile_id_profile_id_fk": {
"name": "like_target_user_user_id_fk", "name": "like_target_profile_id_profile_id_fk",
"tableFrom": "like", "tableFrom": "like",
"tableTo": "user", "tableTo": "profile",
"columnsFrom": [ "columnsFrom": [
"target_user" "target_profile_id"
], ],
"columnsTo": [ "columnsTo": [
"id" "id"
@@ -845,14 +714,14 @@
"notNull": true, "notNull": true,
"default": "gen_random_uuid()" "default": "gen_random_uuid()"
}, },
"user1_id": { "profile1_id": {
"name": "user1_id", "name": "profile1_id",
"type": "uuid", "type": "uuid",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"user2_id": { "profile2_id": {
"name": "user2_id", "name": "profile2_id",
"type": "uuid", "type": "uuid",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
@@ -867,12 +736,12 @@
}, },
"indexes": {}, "indexes": {},
"foreignKeys": { "foreignKeys": {
"match_user1_id_user_id_fk": { "match_profile1_id_profile_id_fk": {
"name": "match_user1_id_user_id_fk", "name": "match_profile1_id_profile_id_fk",
"tableFrom": "match", "tableFrom": "match",
"tableTo": "user", "tableTo": "profile",
"columnsFrom": [ "columnsFrom": [
"user1_id" "profile1_id"
], ],
"columnsTo": [ "columnsTo": [
"id" "id"
@@ -880,12 +749,190 @@
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
"match_user2_id_user_id_fk": { "match_profile2_id_profile_id_fk": {
"name": "match_user2_id_user_id_fk", "name": "match_profile2_id_profile_id_fk",
"tableFrom": "match", "tableFrom": "match",
"tableTo": "user", "tableTo": "profile",
"columnsFrom": [ "columnsFrom": [
"user2_id" "profile2_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.chat": {
"name": "chat",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"profile1_id": {
"name": "profile1_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"profile2_id": {
"name": "profile2_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "chat_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'active'"
}
},
"indexes": {},
"foreignKeys": {
"chat_profile1_id_profile_id_fk": {
"name": "chat_profile1_id_profile_id_fk",
"tableFrom": "chat",
"tableTo": "profile",
"columnsFrom": [
"profile1_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"chat_profile2_id_profile_id_fk": {
"name": "chat_profile2_id_profile_id_fk",
"tableFrom": "chat",
"tableTo": "profile",
"columnsFrom": [
"profile2_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.greetings": {
"name": "greetings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.message": {
"name": "message",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"chat_id": {
"name": "chat_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"profile_id": {
"name": "profile_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": false
},
"media_url": {
"name": "media_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"media_type": {
"name": "media_type",
"type": "message_media_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"message_chat_id_chat_id_fk": {
"name": "message_chat_id_chat_id_fk",
"tableFrom": "message",
"tableTo": "chat",
"columnsFrom": [
"chat_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"message_profile_id_profile_id_fk": {
"name": "message_profile_id_profile_id_fk",
"tableFrom": "message",
"tableTo": "profile",
"columnsFrom": [
"profile_id"
], ],
"columnsTo": [ "columnsTo": [
"id" "id"
@@ -911,14 +958,14 @@
"notNull": true, "notNull": true,
"default": "gen_random_uuid()" "default": "gen_random_uuid()"
}, },
"user1_id": { "profile1_id": {
"name": "user1_id", "name": "profile1_id",
"type": "uuid", "type": "uuid",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"user2_id": { "profile2_id": {
"name": "user2_id", "name": "profile2_id",
"type": "uuid", "type": "uuid",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
@@ -950,12 +997,12 @@
}, },
"indexes": {}, "indexes": {},
"foreignKeys": { "foreignKeys": {
"date_user1_id_user_id_fk": { "date_profile1_id_profile_id_fk": {
"name": "date_user1_id_user_id_fk", "name": "date_profile1_id_profile_id_fk",
"tableFrom": "date", "tableFrom": "date",
"tableTo": "user", "tableTo": "profile",
"columnsFrom": [ "columnsFrom": [
"user1_id" "profile1_id"
], ],
"columnsTo": [ "columnsTo": [
"id" "id"
@@ -963,12 +1010,12 @@
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
"date_user2_id_user_id_fk": { "date_profile2_id_profile_id_fk": {
"name": "date_user2_id_user_id_fk", "name": "date_profile2_id_profile_id_fk",
"tableFrom": "date", "tableFrom": "date",
"tableTo": "user", "tableTo": "profile",
"columnsFrom": [ "columnsFrom": [
"user2_id" "profile2_id"
], ],
"columnsTo": [ "columnsTo": [
"id" "id"
@@ -1033,8 +1080,8 @@
"notNull": true, "notNull": true,
"default": "gen_random_uuid()" "default": "gen_random_uuid()"
}, },
"source_user": { "source_profile_id": {
"name": "source_user", "name": "source_profile_id",
"type": "uuid", "type": "uuid",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
@@ -1061,12 +1108,12 @@
}, },
"indexes": {}, "indexes": {},
"foreignKeys": { "foreignKeys": {
"report_source_user_user_id_fk": { "report_source_profile_id_profile_id_fk": {
"name": "report_source_user_user_id_fk", "name": "report_source_profile_id_profile_id_fk",
"tableFrom": "report", "tableFrom": "report",
"tableTo": "user", "tableTo": "profile",
"columnsFrom": [ "columnsFrom": [
"source_user" "source_profile_id"
], ],
"columnsTo": [ "columnsTo": [
"id" "id"
@@ -1083,23 +1130,6 @@
} }
}, },
"enums": { "enums": {
"public.chat_status": {
"name": "chat_status",
"schema": "public",
"values": [
"active",
"closed"
]
},
"public.media_type": {
"name": "media_type",
"schema": "public",
"values": [
"photo",
"voice",
"video"
]
},
"public.user_status": { "public.user_status": {
"name": "user_status", "name": "user_status",
"schema": "public", "schema": "public",
@@ -1109,6 +1139,23 @@
"pending" "pending"
] ]
}, },
"public.gender": {
"name": "gender",
"schema": "public",
"values": [
"male",
"female"
]
},
"public.profile_media_type": {
"name": "profile_media_type",
"schema": "public",
"values": [
"photo",
"video",
"audio"
]
},
"public.like_type": { "public.like_type": {
"name": "like_type", "name": "like_type",
"schema": "public", "schema": "public",
@@ -1117,6 +1164,23 @@
"dislike" "dislike"
] ]
}, },
"public.chat_status": {
"name": "chat_status",
"schema": "public",
"values": [
"active",
"closed"
]
},
"public.message_media_type": {
"name": "message_media_type",
"schema": "public",
"values": [
"photo",
"voice",
"video"
]
},
"public.report_entity_type": { "public.report_entity_type": {
"name": "report_entity_type", "name": "report_entity_type",
"schema": "public", "schema": "public",

File diff suppressed because it is too large Load Diff

View File

@@ -5,15 +5,8 @@
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1780401435523, "when": 1780405352119,
"tag": "0000_romantic_morg", "tag": "0000_quick_silver_samurai",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1780403477744,
"tag": "0001_brown_marrow",
"breakpoints": true "breakpoints": true
} }
] ]

View File

@@ -1,12 +1,17 @@
import { pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; import { pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { profile } from './profile.schema';
export const chatStatusEnum = pgEnum('chat_status', ['active', 'closed']); export const chatStatusEnum = pgEnum('chat_status', ['active', 'closed']);
export const mediaTypeEnum = pgEnum('media_type', ['photo', 'voice', 'video']); export const messageMediaTypeEnum = pgEnum('message_media_type', ['photo', 'voice', 'video']);
export const chat = pgTable('chat', { export const chat = pgTable('chat', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
profile1Id: uuid('profile1_id').notNull(), profile1Id: uuid('profile1_id')
profile2Id: uuid('profile2_id').notNull(), .notNull()
.references(() => profile.id, { onDelete: 'cascade' }),
profile2Id: uuid('profile2_id')
.notNull()
.references(() => profile.id, { onDelete: 'cascade' }),
status: chatStatusEnum('status').notNull().default('active'), status: chatStatusEnum('status').notNull().default('active'),
}); });
@@ -15,10 +20,12 @@ export const message = pgTable('message', {
chatId: uuid('chat_id') chatId: uuid('chat_id')
.notNull() .notNull()
.references(() => chat.id, { onDelete: 'cascade' }), .references(() => chat.id, { onDelete: 'cascade' }),
userId: uuid('user_id').notNull(), profileId: uuid('profile_id')
.notNull()
.references(() => profile.id, { onDelete: 'cascade' }),
text: text('text'), text: text('text'),
mediaUrl: text('media_url'), mediaUrl: text('media_url'),
mediaType: mediaTypeEnum('media_type'), mediaType: messageMediaTypeEnum('media_type'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}); });

View File

@@ -1,5 +1,5 @@
import { decimal, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; import { decimal, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { user } from './user.schema'; import { profile } from './profile.schema';
export const dateStatus = pgTable('date_status', { export const dateStatus = pgTable('date_status', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
@@ -8,12 +8,12 @@ export const dateStatus = pgTable('date_status', {
export const date = pgTable('date', { export const date = pgTable('date', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
user1Id: uuid('user1_id') profile1Id: uuid('profile1_id')
.notNull() .notNull()
.references(() => user.id, { onDelete: 'cascade' }), .references(() => profile.id, { onDelete: 'cascade' }),
user2Id: uuid('user2_id') profile2Id: uuid('profile2_id')
.notNull() .notNull()
.references(() => user.id, { onDelete: 'cascade' }), .references(() => profile.id, { onDelete: 'cascade' }),
lat: decimal('lat', { precision: 10, scale: 7 }).notNull(), lat: decimal('lat', { precision: 10, scale: 7 }).notNull(),
lng: decimal('lng', { precision: 10, scale: 7 }).notNull(), lng: decimal('lng', { precision: 10, scale: 7 }).notNull(),
time: timestamp('time', { withTimezone: true }).notNull(), time: timestamp('time', { withTimezone: true }).notNull(),

View File

@@ -1,9 +1,9 @@
export * from './role.schema'; export * from './role.schema';
export * from './tariff.schema'; export * from './tariff.schema';
export * from './city.schema'; export * from './city.schema';
export * from './chat.schema';
export * from './user.schema'; export * from './user.schema';
export * from './profile.schema'; export * from './profile.schema';
export * from './social.schema'; export * from './social.schema';
export * from './chat.schema';
export * from './date.schema'; export * from './date.schema';
export * from './report.schema'; export * from './report.schema';

View File

@@ -1,14 +1,14 @@
import { date, doublePrecision, pgEnum, pgTable, text, uuid, varchar } from 'drizzle-orm/pg-core'; import { date, doublePrecision, integer, pgEnum, pgTable, text, uuid, varchar } from 'drizzle-orm/pg-core';
import { user } from './user.schema'; import { user } from './user.schema';
import { city, cityDistrict } from './city.schema'; import { city, cityDistrict } from './city.schema';
export const genderEnum = pgEnum('gender', ['male', 'female']); export const genderEnum = pgEnum('gender', ['male', 'female']);
export const profileMediaTypeEnum = pgEnum('profile_media_type', ['photo', 'video', 'audio']);
export const profile = pgTable('profile', { export const profile = pgTable('profile', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id') userId: uuid('user_id')
.notNull() .notNull()
.unique()
.references(() => user.id, { onDelete: 'cascade' }), .references(() => user.id, { onDelete: 'cascade' }),
name: varchar('name', { length: 100 }).notNull(), name: varchar('name', { length: 100 }).notNull(),
birthDate: date('birth_date').notNull(), birthDate: date('birth_date').notNull(),
@@ -19,6 +19,18 @@ export const profile = pgTable('profile', {
nation: varchar('nation', { length: 100 }), nation: varchar('nation', { length: 100 }),
height: doublePrecision('height'), height: doublePrecision('height'),
weight: doublePrecision('weight'), weight: doublePrecision('weight'),
activeChatId: uuid('active_chat_id'),
});
// Media attachments for a profile (photos, videos, audio)
export const profileMedia = pgTable('profile_media', {
id: uuid('id').primaryKey().defaultRandom(),
profileId: uuid('profile_id')
.notNull()
.references(() => profile.id, { onDelete: 'cascade' }),
path: text('path').notNull(),
type: profileMediaTypeEnum('type').notNull(),
sortOrder: integer('sort_order').notNull().default(0),
}); });
export const tag = pgTable('tag', { export const tag = pgTable('tag', {
@@ -34,12 +46,3 @@ export const profileTag = pgTable('profile_tag', {
.notNull() .notNull()
.references(() => tag.id, { onDelete: 'cascade' }), .references(() => tag.id, { onDelete: 'cascade' }),
}); });
export const media = pgTable('media', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
path: text('path').notNull(),
type: varchar('type', { length: 10 }).notNull(),
});

View File

@@ -1,13 +1,13 @@
import { pgEnum, pgTable, text, uuid } from 'drizzle-orm/pg-core'; import { pgEnum, pgTable, text, uuid } from 'drizzle-orm/pg-core';
import { user } from './user.schema'; import { profile } from './profile.schema';
export const reportEntityTypeEnum = pgEnum('report_entity_type', ['profile', 'message']); export const reportEntityTypeEnum = pgEnum('report_entity_type', ['profile', 'message']);
export const report = pgTable('report', { export const report = pgTable('report', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
sourceUser: uuid('source_user') sourceProfileId: uuid('source_profile_id')
.notNull() .notNull()
.references(() => user.id, { onDelete: 'cascade' }), .references(() => profile.id, { onDelete: 'cascade' }),
entityId: uuid('entity_id').notNull(), entityId: uuid('entity_id').notNull(),
entityType: reportEntityTypeEnum('entity_type').notNull(), entityType: reportEntityTypeEnum('entity_type').notNull(),
description: text('description'), description: text('description'),

View File

@@ -1,27 +1,27 @@
import { pgEnum, pgTable, timestamp, uuid } from 'drizzle-orm/pg-core'; import { pgEnum, pgTable, timestamp, uuid } from 'drizzle-orm/pg-core';
import { user } from './user.schema'; import { profile } from './profile.schema';
export const likeTypeEnum = pgEnum('like_type', ['like', 'dislike']); export const likeTypeEnum = pgEnum('like_type', ['like', 'dislike']);
export const like = pgTable('like', { export const like = pgTable('like', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
sourceUser: uuid('source_user') sourceProfileId: uuid('source_profile_id')
.notNull() .notNull()
.references(() => user.id, { onDelete: 'cascade' }), .references(() => profile.id, { onDelete: 'cascade' }),
targetUser: uuid('target_user') targetProfileId: uuid('target_profile_id')
.notNull() .notNull()
.references(() => user.id, { onDelete: 'cascade' }), .references(() => profile.id, { onDelete: 'cascade' }),
type: likeTypeEnum('type').notNull(), type: likeTypeEnum('type').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}); });
export const match = pgTable('match', { export const match = pgTable('match', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
user1Id: uuid('user1_id') profile1Id: uuid('profile1_id')
.notNull() .notNull()
.references(() => user.id, { onDelete: 'cascade' }), .references(() => profile.id, { onDelete: 'cascade' }),
user2Id: uuid('user2_id') profile2Id: uuid('profile2_id')
.notNull() .notNull()
.references(() => user.id, { onDelete: 'cascade' }), .references(() => profile.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}); });

View File

@@ -12,7 +12,6 @@ export const user = pgTable('user', {
roleId: uuid('role_id').references(() => role.id, { onDelete: 'set null' }), roleId: uuid('role_id').references(() => role.id, { onDelete: 'set null' }),
tariffId: uuid('tariff_id').references(() => tariff.id, { onDelete: 'set null' }), tariffId: uuid('tariff_id').references(() => tariff.id, { onDelete: 'set null' }),
paymentId: uuid('payment_id'), paymentId: uuid('payment_id'),
activeChatId: uuid('active_chat_id'),
fcmToken: text('fcm_token'), fcmToken: text('fcm_token'),
}); });

View File

@@ -16,10 +16,7 @@ import { ChatService } from '../modules/chat/chat.service';
import { SendMessageDto } from '../modules/chat/dto/send-message.dto'; import { SendMessageDto } from '../modules/chat/dto/send-message.dto';
@WebSocketGateway({ @WebSocketGateway({
cors: { cors: { origin: '*', credentials: true },
origin: '*',
credentials: true,
},
namespace: 'chat', namespace: 'chat',
}) })
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@@ -27,7 +24,8 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
server: Server; server: Server;
private readonly logger = new Logger(ChatGateway.name); private readonly logger = new Logger(ChatGateway.name);
private connectedUsers = new Map<string, string>(); // profileId → socketId
private connectedProfiles = new Map<string, string>();
constructor( constructor(
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
@@ -41,28 +39,30 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
client.handshake.auth?.token || client.handshake.auth?.token ||
client.handshake.headers?.authorization?.replace('Bearer ', ''); client.handshake.headers?.authorization?.replace('Bearer ', '');
if (!token) { if (!token) { client.disconnect(); return; }
client.disconnect();
return;
}
const payload = this.jwtService.verify(token, { const payload = this.jwtService.verify(token, {
secret: this.configService.get<string>('jwt.secret'), secret: this.configService.get<string>('jwt.secret'),
}); });
// profileId must be sent in handshake auth
const profileId = client.handshake.auth?.profileId;
if (!profileId) { client.disconnect(); return; }
client.data.userId = payload.sub; client.data.userId = payload.sub;
this.connectedUsers.set(payload.sub, client.id); client.data.profileId = profileId;
this.logger.log(`User ${payload.sub} connected via WebSocket`); this.connectedProfiles.set(profileId, client.id);
this.logger.log(`Profile ${profileId} (user ${payload.sub}) connected`);
} catch { } catch {
client.disconnect(); client.disconnect();
} }
} }
handleDisconnect(client: Socket) { handleDisconnect(client: Socket) {
const userId = client.data.userId; const profileId = client.data.profileId;
if (userId) { if (profileId) {
this.connectedUsers.delete(userId); this.connectedProfiles.delete(profileId);
this.logger.log(`User ${userId} disconnected`); this.logger.log(`Profile ${profileId} disconnected`);
} }
} }
@@ -71,8 +71,7 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@ConnectedSocket() client: Socket, @ConnectedSocket() client: Socket,
@MessageBody() data: { chatId: string }, @MessageBody() data: { chatId: string },
) { ) {
const userId = client.data.userId; if (!client.data.profileId) throw new WsException('Unauthorized');
if (!userId) throw new WsException('Unauthorized');
await client.join(`chat:${data.chatId}`); await client.join(`chat:${data.chatId}`);
return { event: 'joined_chat', chatId: data.chatId }; return { event: 'joined_chat', chatId: data.chatId };
} }
@@ -91,12 +90,11 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@ConnectedSocket() client: Socket, @ConnectedSocket() client: Socket,
@MessageBody() data: { chatId: string } & SendMessageDto, @MessageBody() data: { chatId: string } & SendMessageDto,
) { ) {
const userId = client.data.userId; const { userId, profileId } = client.data;
if (!userId) throw new WsException('Unauthorized'); if (!userId || !profileId) throw new WsException('Unauthorized');
const { chatId, ...msgDto } = data; const { chatId, ...msgDto } = data;
const newMessage = await this.chatService.sendMessage(userId, chatId, msgDto); const newMessage = await this.chatService.sendMessage(userId, profileId, chatId, msgDto);
this.server.to(`chat:${chatId}`).emit('new_message', newMessage); this.server.to(`chat:${chatId}`).emit('new_message', newMessage);
return newMessage; return newMessage;
} }
@@ -106,17 +104,12 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@ConnectedSocket() client: Socket, @ConnectedSocket() client: Socket,
@MessageBody() data: { chatId: string; isTyping: boolean }, @MessageBody() data: { chatId: string; isTyping: boolean },
) { ) {
const userId = client.data.userId; const { profileId } = client.data;
client.to(`chat:${data.chatId}`).emit('user_typing', { client.to(`chat:${data.chatId}`).emit('user_typing', { profileId, isTyping: data.isTyping });
userId,
isTyping: data.isTyping,
});
} }
emitToUser(userId: string, event: string, data: any) { emitToProfile(profileId: string, event: string, data: any) {
const socketId = this.connectedUsers.get(userId); const socketId = this.connectedProfiles.get(profileId);
if (socketId) { if (socketId) this.server.to(socketId).emit(event, data);
this.server.to(socketId).emit(event, data);
}
} }
} }

View File

@@ -1,19 +1,12 @@
import { import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
Body, import { ApiBearerAuth, ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
Controller,
Delete,
Get,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { ChatService } from './chat.service'; import { ChatService } from './chat.service';
import { CreateChatDto } from './dto/create-chat.dto'; import { CreateChatDto } from './dto/create-chat.dto';
import { SendMessageDto } from './dto/send-message.dto'; import { SendMessageDto } from './dto/send-message.dto';
import { ChatDto, MessageDto } from './dto/chat-response.dto';
import { MessageResponseDto } from '../../common/dto/message-response.dto';
@ApiTags('chat') @ApiTags('chat')
@ApiBearerAuth() @ApiBearerAuth()
@@ -24,46 +17,56 @@ export class ChatController {
@Post() @Post()
@ApiOperation({ summary: 'Open a chat for a match' }) @ApiOperation({ summary: 'Open a chat for a match' })
createChat( @ApiCreatedResponse({ type: ChatDto })
@CurrentUser('id') userId: string, createChat(@CurrentUser('id') userId: string, @Body() dto: CreateChatDto) {
@Body() dto: CreateChatDto,
) {
return this.chatService.createChat(userId, dto); return this.chatService.createChat(userId, dto);
} }
@Get() @Get()
@ApiOperation({ summary: 'Get my active chats' }) @ApiOperation({ summary: 'Get active chats for a profile' })
getMyChats(@CurrentUser('id') userId: string) { @ApiOkResponse({ type: [ChatDto] })
return this.chatService.getMyChats(userId); getChats(
@CurrentUser('id') userId: string,
@Query('profileId') profileId: string,
) {
return this.chatService.getChatsForProfile(userId, profileId);
} }
@Get(':chatId/messages') @Get(':chatId/messages')
@ApiOperation({ summary: 'Get chat messages' }) @ApiOperation({ summary: 'Get chat messages' })
@ApiQuery({ name: 'page', required: false, schema: { default: 1, type: 'number' } })
@ApiQuery({ name: 'limit', required: false, schema: { default: 50, type: 'number' } })
@ApiOkResponse({ type: [MessageDto] })
getMessages( getMessages(
@CurrentUser('id') userId: string, @CurrentUser('id') userId: string,
@Query('profileId') profileId: string,
@Param('chatId') chatId: string, @Param('chatId') chatId: string,
@Query('page') page = 1, @Query('page') page = 1,
@Query('limit') limit = 50, @Query('limit') limit = 50,
) { ) {
return this.chatService.getChatMessages(userId, chatId, +page, +limit); return this.chatService.getChatMessages(userId, profileId, chatId, +page, +limit);
} }
@Post(':chatId/messages') @Post(':chatId/messages')
@ApiOperation({ summary: 'Send a message' }) @ApiOperation({ summary: 'Send a message' })
@ApiCreatedResponse({ type: MessageDto })
sendMessage( sendMessage(
@CurrentUser('id') userId: string, @CurrentUser('id') userId: string,
@Query('profileId') profileId: string,
@Param('chatId') chatId: string, @Param('chatId') chatId: string,
@Body() dto: SendMessageDto, @Body() dto: SendMessageDto,
) { ) {
return this.chatService.sendMessage(userId, chatId, dto); return this.chatService.sendMessage(userId, profileId, chatId, dto);
} }
@Delete(':chatId') @Delete(':chatId')
@ApiOperation({ summary: 'Close a chat' }) @ApiOperation({ summary: 'Close a chat' })
@ApiOkResponse({ type: MessageResponseDto })
closeChat( closeChat(
@CurrentUser('id') userId: string, @CurrentUser('id') userId: string,
@Query('profileId') profileId: string,
@Param('chatId') chatId: string, @Param('chatId') chatId: string,
) { ) {
return this.chatService.closeChat(userId, chatId); return this.chatService.closeChat(userId, profileId, chatId);
} }
} }

View File

@@ -19,13 +19,15 @@ export class ChatService {
) {} ) {}
async createChat(userId: string, dto: CreateChatDto) { async createChat(userId: string, dto: CreateChatDto) {
await this.assertProfileOwnership(userId, dto.profileId);
const [foundMatch] = await this.drizzleService.db const [foundMatch] = await this.drizzleService.db
.select() .select()
.from(match) .from(match)
.where( .where(
and( and(
eq(match.id, dto.matchId), eq(match.id, dto.matchId),
or(eq(match.user1Id, userId), eq(match.user2Id, userId)), or(eq(match.profile1Id, dto.profileId), eq(match.profile2Id, dto.profileId)),
), ),
) )
.limit(1); .limit(1);
@@ -37,50 +39,46 @@ export class ChatService {
.from(chat) .from(chat)
.where( .where(
or( or(
and( and(eq(chat.profile1Id, foundMatch.profile1Id), eq(chat.profile2Id, foundMatch.profile2Id)),
eq(chat.profile1Id, foundMatch.user1Id), and(eq(chat.profile1Id, foundMatch.profile2Id), eq(chat.profile2Id, foundMatch.profile1Id)),
eq(chat.profile2Id, foundMatch.user2Id),
),
and(
eq(chat.profile1Id, foundMatch.user2Id),
eq(chat.profile2Id, foundMatch.user1Id),
),
), ),
) )
.limit(1); .limit(1);
if (existingChat.length > 0) return existingChat[0]; if (existingChat.length > 0) return existingChat[0];
const currentUser = await this.drizzleService.db const [currentProfile] = await this.drizzleService.db
.select({ activeChatId: user.activeChatId }) .select({ activeChatId: profile.activeChatId })
.from(user) .from(profile)
.where(eq(user.id, userId)) .where(eq(profile.id, dto.profileId))
.limit(1); .limit(1);
if (currentUser[0]?.activeChatId) { if (currentProfile?.activeChatId) {
throw new BadRequestException( throw new BadRequestException(
'You already have an active chat. Close it before opening a new one.', 'Profile already has an active chat. Close it before opening a new one.',
); );
} }
const [newChat] = await this.drizzleService.db const [newChat] = await this.drizzleService.db
.insert(chat) .insert(chat)
.values({ .values({
profile1Id: foundMatch.user1Id, profile1Id: foundMatch.profile1Id,
profile2Id: foundMatch.user2Id, profile2Id: foundMatch.profile2Id,
status: 'active', status: 'active',
} as any) } as any)
.returning(); .returning();
await this.drizzleService.db await this.drizzleService.db
.update(user) .update(profile)
.set({ activeChatId: newChat.id } as any) .set({ activeChatId: newChat.id } as any)
.where(or(eq(user.id, foundMatch.user1Id), eq(user.id, foundMatch.user2Id))); .where(or(eq(profile.id, foundMatch.profile1Id), eq(profile.id, foundMatch.profile2Id)));
return newChat; return newChat;
} }
async closeChat(userId: string, chatId: string) { async closeChat(userId: string, profileId: string, chatId: string) {
await this.assertProfileOwnership(userId, profileId);
const [foundChat] = await this.drizzleService.db const [foundChat] = await this.drizzleService.db
.select() .select()
.from(chat) .from(chat)
@@ -88,7 +86,7 @@ export class ChatService {
.limit(1); .limit(1);
if (!foundChat) throw new NotFoundException('Chat not found'); if (!foundChat) throw new NotFoundException('Chat not found');
if (foundChat.profile1Id !== userId && foundChat.profile2Id !== userId) { if (foundChat.profile1Id !== profileId && foundChat.profile2Id !== profileId) {
throw new ForbiddenException('Not a chat participant'); throw new ForbiddenException('Not a chat participant');
} }
@@ -98,26 +96,29 @@ export class ChatService {
.where(eq(chat.id, chatId)); .where(eq(chat.id, chatId));
await this.drizzleService.db await this.drizzleService.db
.update(user) .update(profile)
.set({ activeChatId: null } as any) .set({ activeChatId: null } as any)
.where(or(eq(user.id, foundChat.profile1Id), eq(user.id, foundChat.profile2Id))); .where(or(eq(profile.id, foundChat.profile1Id), eq(profile.id, foundChat.profile2Id)));
return { message: 'Chat closed' }; return { message: 'Chat closed' };
} }
async getMyChats(userId: string) { async getChatsForProfile(userId: string, profileId: string) {
await this.assertProfileOwnership(userId, profileId);
return this.drizzleService.db return this.drizzleService.db
.select() .select()
.from(chat) .from(chat)
.where( .where(
and( and(
or(eq(chat.profile1Id, userId), eq(chat.profile2Id, userId)), or(eq(chat.profile1Id, profileId), eq(chat.profile2Id, profileId)),
eq(chat.status, 'active'), eq(chat.status, 'active'),
), ),
); );
} }
async getChatMessages(userId: string, chatId: string, page = 1, limit = 50) { async getChatMessages(userId: string, profileId: string, chatId: string, page = 1, limit = 50) {
await this.assertProfileOwnership(userId, profileId);
const [foundChat] = await this.drizzleService.db const [foundChat] = await this.drizzleService.db
.select() .select()
.from(chat) .from(chat)
@@ -125,7 +126,7 @@ export class ChatService {
.limit(1); .limit(1);
if (!foundChat) throw new NotFoundException('Chat not found'); if (!foundChat) throw new NotFoundException('Chat not found');
if (foundChat.profile1Id !== userId && foundChat.profile2Id !== userId) { if (foundChat.profile1Id !== profileId && foundChat.profile2Id !== profileId) {
throw new ForbiddenException('Not a chat participant'); throw new ForbiddenException('Not a chat participant');
} }
@@ -139,7 +140,9 @@ export class ChatService {
.offset(offset); .offset(offset);
} }
async sendMessage(userId: string, chatId: string, dto: SendMessageDto) { async sendMessage(userId: string, profileId: string, chatId: string, dto: SendMessageDto) {
await this.assertProfileOwnership(userId, profileId);
const [foundChat] = await this.drizzleService.db const [foundChat] = await this.drizzleService.db
.select() .select()
.from(chat) .from(chat)
@@ -147,7 +150,7 @@ export class ChatService {
.limit(1); .limit(1);
if (!foundChat) throw new NotFoundException('Active chat not found'); if (!foundChat) throw new NotFoundException('Active chat not found');
if (foundChat.profile1Id !== userId && foundChat.profile2Id !== userId) { if (foundChat.profile1Id !== profileId && foundChat.profile2Id !== profileId) {
throw new ForbiddenException('Not a chat participant'); throw new ForbiddenException('Not a chat participant');
} }
@@ -159,31 +162,50 @@ export class ChatService {
.insert(message) .insert(message)
.values({ .values({
chatId, chatId,
userId, profileId,
text: dto.text || null, text: dto.text || null,
mediaUrl: dto.mediaUrl || null, mediaUrl: dto.mediaUrl || null,
mediaType: dto.mediaType || null, mediaType: dto.mediaType || null,
} as any) } as any)
.returning(); .returning();
const recipientId = const recipientProfileId =
foundChat.profile1Id === userId ? foundChat.profile2Id : foundChat.profile1Id; foundChat.profile1Id === profileId ? foundChat.profile2Id : foundChat.profile1Id;
const [recipient] = await this.drizzleService.db const [recipientProfile] = await this.drizzleService.db
.select({ fcmToken: user.fcmToken }) .select({ userId: profile.userId })
.from(user) .from(profile)
.where(eq(user.id, recipientId)) .where(eq(profile.id, recipientProfileId))
.limit(1); .limit(1);
if (recipient?.fcmToken) { if (recipientProfile) {
const [recipientUser] = await this.drizzleService.db
.select({ fcmToken: user.fcmToken })
.from(user)
.where(eq(user.id, recipientProfile.userId))
.limit(1);
if (recipientUser?.fcmToken) {
await this.notificationsService.sendPushNotification( await this.notificationsService.sendPushNotification(
recipient.fcmToken, recipientUser.fcmToken,
'New message', 'New message',
dto.text?.substring(0, 100) || 'Media message', dto.text?.substring(0, 100) || 'Media message',
{ chatId, messageId: newMessage.id, type: 'message' }, { chatId, messageId: newMessage.id, type: 'message' },
); );
} }
}
return newMessage; return newMessage;
} }
private async assertProfileOwnership(userId: string, profileId: string) {
const [found] = await this.drizzleService.db
.select({ userId: profile.userId })
.from(profile)
.where(eq(profile.id, profileId))
.limit(1);
if (!found) throw new NotFoundException('Profile not found');
if (found.userId !== userId) throw new ForbiddenException('Profile does not belong to you');
}
} }

View File

@@ -0,0 +1,18 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ChatDto {
@ApiProperty() id: string;
@ApiProperty() profile1Id: string;
@ApiProperty() profile2Id: string;
@ApiProperty({ enum: ['active', 'closed'] }) status: string;
}
export class MessageDto {
@ApiProperty() id: string;
@ApiProperty() chatId: string;
@ApiProperty() profileId: string;
@ApiPropertyOptional({ nullable: true }) text: string | null;
@ApiPropertyOptional({ nullable: true }) mediaUrl: string | null;
@ApiPropertyOptional({ enum: ['photo', 'voice', 'video'], nullable: true }) mediaType: string | null;
@ApiProperty() createdAt: string;
}

View File

@@ -2,6 +2,10 @@ import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator'; import { IsUUID } from 'class-validator';
export class CreateChatDto { export class CreateChatDto {
@ApiProperty({ description: 'Your profile ID' })
@IsUUID()
profileId: string;
@ApiProperty({ description: 'Match ID to open chat for' }) @ApiProperty({ description: 'Match ID to open chat for' })
@IsUUID() @IsUUID()
matchId: string; matchId: string;

View File

@@ -1,10 +1,12 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '../../common/decorators/public.decorator'; import { Public } from '../../common/decorators/public.decorator';
import { Roles } from '../../common/decorators/roles.decorator'; import { Roles } from '../../common/decorators/roles.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RolesGuard } from '../../common/guards/roles.guard'; import { RolesGuard } from '../../common/guards/roles.guard';
import { CitiesService } from './cities.service'; import { CitiesService } from './cities.service';
import { CityResponseDto, DistrictResponseDto } from './dto/city-response.dto';
import { CreateCityDto, CreateDistrictDto } from './dto/create-city.dto';
@ApiTags('cities') @ApiTags('cities')
@Controller('cities') @Controller('cities')
@@ -14,6 +16,7 @@ export class CitiesController {
@Public() @Public()
@Get() @Get()
@ApiOperation({ summary: 'Get all cities' }) @ApiOperation({ summary: 'Get all cities' })
@ApiOkResponse({ type: [CityResponseDto] })
findAll() { findAll() {
return this.citiesService.findAll(); return this.citiesService.findAll();
} }
@@ -21,6 +24,7 @@ export class CitiesController {
@Public() @Public()
@Get(':cityId/districts') @Get(':cityId/districts')
@ApiOperation({ summary: 'Get districts for a city' }) @ApiOperation({ summary: 'Get districts for a city' })
@ApiOkResponse({ type: [DistrictResponseDto] })
findDistricts(@Param('cityId') cityId: string) { findDistricts(@Param('cityId') cityId: string) {
return this.citiesService.findDistricts(cityId); return this.citiesService.findDistricts(cityId);
} }
@@ -30,7 +34,8 @@ export class CitiesController {
@Roles('admin') @Roles('admin')
@Post() @Post()
@ApiOperation({ summary: 'Create city (admin only)' }) @ApiOperation({ summary: 'Create city (admin only)' })
createCity(@Body() body: { name: string; lat: number; lng: number }) { @ApiCreatedResponse({ type: CityResponseDto })
createCity(@Body() body: CreateCityDto) {
return this.citiesService.createCity(body.name, body.lat, body.lng); return this.citiesService.createCity(body.name, body.lat, body.lng);
} }
@@ -39,7 +44,8 @@ export class CitiesController {
@Roles('admin') @Roles('admin')
@Post(':cityId/districts') @Post(':cityId/districts')
@ApiOperation({ summary: 'Create district (admin only)' }) @ApiOperation({ summary: 'Create district (admin only)' })
createDistrict(@Param('cityId') cityId: string, @Body() body: { name: string }) { @ApiCreatedResponse({ type: DistrictResponseDto })
createDistrict(@Param('cityId') cityId: string, @Body() body: CreateDistrictDto) {
return this.citiesService.createDistrict(cityId, body.name); return this.citiesService.createDistrict(cityId, body.name);
} }
} }

View File

@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
export class CityResponseDto {
@ApiProperty() id: string;
@ApiProperty() name: string;
@ApiProperty() lat: string;
@ApiProperty() lng: string;
}
export class DistrictResponseDto {
@ApiProperty() id: string;
@ApiProperty() cityId: string;
@ApiProperty() name: string;
}

View File

@@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
export class CreateCityDto {
@ApiProperty({ example: 'Москва' })
name: string;
@ApiProperty({ example: 55.7558 })
lat: number;
@ApiProperty({ example: 37.6173 })
lng: number;
}
export class CreateDistrictDto {
@ApiProperty({ example: 'Центральный' })
name: string;
}

View File

@@ -1,10 +1,11 @@
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { DatesService } from './dates.service'; import { DatesService } from './dates.service';
import { CreateDateDto } from './dto/create-date.dto'; import { CreateDateDto } from './dto/create-date.dto';
import { UpdateDateStatusDto } from './dto/update-date-status.dto'; import { UpdateDateStatusDto } from './dto/update-date-status.dto';
import { DateDto, DateStatusDto, DateWithStatusDto } from './dto/dates-response.dto';
@ApiTags('dates') @ApiTags('dates')
@ApiBearerAuth() @ApiBearerAuth()
@@ -14,32 +15,37 @@ export class DatesController {
constructor(private readonly datesService: DatesService) {} constructor(private readonly datesService: DatesService) {}
@Post() @Post()
@ApiOperation({ summary: 'Propose a date/meetup' }) @ApiOperation({ summary: 'Propose a meetup' })
create( @ApiCreatedResponse({ type: DateDto })
@CurrentUser('id') userId: string, create(@CurrentUser('id') userId: string, @Body() dto: CreateDateDto) {
@Body() dto: CreateDateDto,
) {
return this.datesService.create(userId, dto); return this.datesService.create(userId, dto);
} }
@Get() @Get()
@ApiOperation({ summary: 'Get my dates' }) @ApiOperation({ summary: 'Get dates for a profile' })
getMyDates(@CurrentUser('id') userId: string) { @ApiOkResponse({ type: [DateWithStatusDto] })
return this.datesService.getMyDates(userId); getDates(
@CurrentUser('id') userId: string,
@Query('profileId') profileId: string,
) {
return this.datesService.getForProfile(userId, profileId);
} }
@Patch(':id/status') @Patch(':id/status')
@ApiOperation({ summary: 'Update date status' }) @ApiOperation({ summary: 'Update date status' })
@ApiOkResponse({ type: DateDto })
updateStatus( updateStatus(
@CurrentUser('id') userId: string, @CurrentUser('id') userId: string,
@Query('profileId') profileId: string,
@Param('id') id: string, @Param('id') id: string,
@Body() dto: UpdateDateStatusDto, @Body() dto: UpdateDateStatusDto,
) { ) {
return this.datesService.updateStatus(userId, id, dto); return this.datesService.updateStatus(userId, profileId, id, dto);
} }
@Get('statuses') @Get('statuses')
@ApiOperation({ summary: 'Get available date statuses' }) @ApiOperation({ summary: 'Get available date statuses' })
@ApiOkResponse({ type: [DateStatusDto] })
getStatuses() { getStatuses() {
return this.datesService.getStatuses(); return this.datesService.getStatuses();
} }

View File

@@ -1,7 +1,7 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { and, eq, or } from 'drizzle-orm'; import { and, eq, or } from 'drizzle-orm';
import { DrizzleService } from '../../database/drizzle.service'; import { DrizzleService } from '../../database/drizzle.service';
import { date, dateStatus } from '../../database/schema'; import { date, dateStatus, profile } from '../../database/schema';
import { CreateDateDto } from './dto/create-date.dto'; import { CreateDateDto } from './dto/create-date.dto';
import { UpdateDateStatusDto } from './dto/update-date-status.dto'; import { UpdateDateStatusDto } from './dto/update-date-status.dto';
@@ -10,8 +10,9 @@ export class DatesService {
constructor(private readonly drizzleService: DrizzleService) {} constructor(private readonly drizzleService: DrizzleService) {}
async create(userId: string, dto: CreateDateDto) { async create(userId: string, dto: CreateDateDto) {
let statusId = dto.statusId; await this.assertProfileOwnership(userId, dto.profileId);
let statusId = dto.statusId;
if (!statusId) { if (!statusId) {
const [pending] = await this.drizzleService.db const [pending] = await this.drizzleService.db
.select({ id: dateStatus.id }) .select({ id: dateStatus.id })
@@ -24,8 +25,8 @@ export class DatesService {
const [newDate] = await this.drizzleService.db const [newDate] = await this.drizzleService.db
.insert(date) .insert(date)
.values({ .values({
user1Id: userId, profile1Id: dto.profileId,
user2Id: dto.partnerId, profile2Id: dto.partnerProfileId,
lat: dto.lat.toString(), lat: dto.lat.toString(),
lng: dto.lng.toString(), lng: dto.lng.toString(),
time: new Date(dto.time), time: new Date(dto.time),
@@ -36,16 +37,19 @@ export class DatesService {
return newDate; return newDate;
} }
async getMyDates(userId: string) { async getForProfile(userId: string, profileId: string) {
await this.assertProfileOwnership(userId, profileId);
return this.drizzleService.db return this.drizzleService.db
.select() .select()
.from(date) .from(date)
.leftJoin(dateStatus, eq(dateStatus.id, date.statusId)) .leftJoin(dateStatus, eq(dateStatus.id, date.statusId))
.where(or(eq(date.user1Id, userId), eq(date.user2Id, userId))) .where(or(eq(date.profile1Id, profileId), eq(date.profile2Id, profileId)))
.orderBy(date.time); .orderBy(date.time);
} }
async updateStatus(userId: string, dateId: string, dto: UpdateDateStatusDto) { async updateStatus(userId: string, profileId: string, dateId: string, dto: UpdateDateStatusDto) {
await this.assertProfileOwnership(userId, profileId);
const [found] = await this.drizzleService.db const [found] = await this.drizzleService.db
.select() .select()
.from(date) .from(date)
@@ -53,7 +57,7 @@ export class DatesService {
.limit(1); .limit(1);
if (!found) throw new NotFoundException('Date not found'); if (!found) throw new NotFoundException('Date not found');
if (found.user1Id !== userId && found.user2Id !== userId) { if (found.profile1Id !== profileId && found.profile2Id !== profileId) {
throw new ForbiddenException('Not a participant'); throw new ForbiddenException('Not a participant');
} }
@@ -69,4 +73,15 @@ export class DatesService {
async getStatuses() { async getStatuses() {
return this.drizzleService.db.select().from(dateStatus); return this.drizzleService.db.select().from(dateStatus);
} }
private async assertProfileOwnership(userId: string, profileId: string) {
const [found] = await this.drizzleService.db
.select({ userId: profile.userId })
.from(profile)
.where(eq(profile.id, profileId))
.limit(1);
if (!found) throw new NotFoundException('Profile not found');
if (found.userId !== userId) throw new ForbiddenException('Profile does not belong to you');
}
} }

View File

@@ -2,9 +2,13 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsNumber, IsOptional, IsUUID } from 'class-validator'; import { IsDateString, IsNumber, IsOptional, IsUUID } from 'class-validator';
export class CreateDateDto { export class CreateDateDto {
@ApiProperty() @ApiProperty({ description: 'Your profile ID' })
@IsUUID() @IsUUID()
partnerId: string; profileId: string;
@ApiProperty({ description: 'Partner profile ID' })
@IsUUID()
partnerProfileId: string;
@ApiProperty() @ApiProperty()
@IsNumber() @IsNumber()

View File

@@ -0,0 +1,21 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class DateStatusDto {
@ApiProperty() id: string;
@ApiProperty() text: string;
}
export class DateDto {
@ApiProperty() id: string;
@ApiProperty() profile1Id: string;
@ApiProperty() profile2Id: string;
@ApiProperty() lat: string;
@ApiProperty() lng: string;
@ApiProperty() time: string;
@ApiPropertyOptional({ nullable: true }) statusId: string | null;
}
export class DateWithStatusDto {
@ApiProperty({ type: DateDto }) date: DateDto;
@ApiPropertyOptional({ type: DateStatusDto, nullable: true }) dateStatus: DateStatusDto | null;
}

View File

@@ -1,8 +1,12 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsArray, IsNumber, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator'; import { IsArray, IsNumber, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
export class FeedFilterDto { export class FeedFilterDto {
@ApiProperty({ description: 'Your profile ID' })
@IsUUID()
profileId: string;
@ApiPropertyOptional({ default: 1 }) @ApiPropertyOptional({ default: 1 })
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@@ -18,24 +22,16 @@ export class FeedFilterDto {
@Max(50) @Max(50)
limit?: number = 20; limit?: number = 20;
@ApiPropertyOptional({ description: 'City UUID filter' }) @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
cityId?: string; cityId?: string;
@ApiPropertyOptional({ description: 'District UUID filter' }) @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
districtId?: string; districtId?: string;
@ApiPropertyOptional({ description: 'Search radius in km' })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
@Max(500)
radiusKm?: number;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@@ -50,12 +46,12 @@ export class FeedFilterDto {
@Max(100) @Max(100)
ageMax?: number; ageMax?: number;
@ApiPropertyOptional({ description: 'Search keyword in description/name' }) @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsString() @IsString()
keyword?: string; keyword?: string;
@ApiPropertyOptional({ type: [String], description: 'Tag IDs to filter' }) @ApiPropertyOptional({ type: [String] })
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@IsUUID(undefined, { each: true }) @IsUUID(undefined, { each: true })

View File

@@ -1,23 +1,32 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common'; import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiExtraModels, ApiOkResponse, ApiOperation, ApiTags, getSchemaPath } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { FeedFilterDto } from './dto/feed-filter.dto'; import { FeedFilterDto } from './dto/feed-filter.dto';
import { FeedService } from './feed.service'; import { FeedService } from './feed.service';
import { ProfileResponseDto } from '../profiles/dto/profile-response.dto';
@ApiTags('feed') @ApiTags('feed')
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiExtraModels(ProfileResponseDto)
@Controller('feed') @Controller('feed')
export class FeedController { export class FeedController {
constructor(private readonly feedService: FeedService) {} constructor(private readonly feedService: FeedService) {}
@Get() @Get()
@ApiOperation({ summary: 'Get filtered feed of profiles' }) @ApiOperation({ summary: 'Get filtered feed (requires profileId)' })
getFeed( @ApiOkResponse({
@CurrentUser('id') userId: string, schema: {
@Query() filter: FeedFilterDto, properties: {
) { items: { type: 'array', items: { $ref: getSchemaPath(ProfileResponseDto) } },
page: { type: 'number', example: 1 },
limit: { type: 'number', example: 20 },
},
required: ['items', 'page', 'limit'],
},
})
getFeed(@CurrentUser('id') userId: string, @Query() filter: FeedFilterDto) {
return this.feedService.getFeed(userId, filter); return this.feedService.getFeed(userId, filter);
} }
} }

View File

@@ -1,31 +1,33 @@
import { Injectable } from '@nestjs/common'; import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { and, eq, gte, ilike, inArray, lte, ne, notInArray, or, sql } from 'drizzle-orm'; import { and, eq, gte, ilike, inArray, lte, ne, notInArray, or, sql } from 'drizzle-orm';
import { DrizzleService } from '../../database/drizzle.service'; import { DrizzleService } from '../../database/drizzle.service';
import { like, match, profile, profileTag, tag, user } from '../../database/schema'; import { like, profile, profileMedia, profileTag, tag, user } from '../../database/schema';
import { FeedFilterDto } from './dto/feed-filter.dto'; import { FeedFilterDto } from './dto/feed-filter.dto';
@Injectable() @Injectable()
export class FeedService { export class FeedService {
constructor(private readonly drizzleService: DrizzleService) {} constructor(private readonly drizzleService: DrizzleService) {}
async getFeed(currentUserId: string, filter: FeedFilterDto) { async getFeed(userId: string, filter: FeedFilterDto) {
const { page = 1, limit = 20, cityId, districtId, ageMin, ageMax, keyword, tagIds } = filter; const { profileId, page = 1, limit = 20, cityId, districtId, ageMin, ageMax, keyword, tagIds } = filter;
await this.assertProfileOwnership(userId, profileId);
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const alreadyInteracted = await this.drizzleService.db const alreadyInteracted = await this.drizzleService.db
.select({ targetUser: like.targetUser }) .select({ targetProfileId: like.targetProfileId })
.from(like) .from(like)
.where(eq(like.sourceUser, currentUserId)); .where(eq(like.sourceProfileId, profileId));
const interactedIds = alreadyInteracted.map((r) => r.targetUser); const interactedIds = alreadyInteracted.map((r) => r.targetProfileId);
const conditions: any[] = [ const conditions: any[] = [
ne(profile.userId, currentUserId), ne(profile.id, profileId),
ne(user.status, 'banned'), ne(user.status, 'banned'),
]; ];
if (interactedIds.length > 0) { if (interactedIds.length > 0) {
conditions.push(notInArray(profile.userId, interactedIds)); conditions.push(notInArray(profile.id, interactedIds));
} }
if (cityId) conditions.push(eq(profile.cityId, cityId)); if (cityId) conditions.push(eq(profile.cityId, cityId));
@@ -52,19 +54,15 @@ export class FeedService {
); );
} }
let profileIds: string[] | null = null;
if (tagIds?.length) { if (tagIds?.length) {
const tagMatches = await this.drizzleService.db const tagMatches = await this.drizzleService.db
.select({ profileId: profileTag.profileId }) .select({ profileId: profileTag.profileId })
.from(profileTag) .from(profileTag)
.where(inArray(profileTag.tagId, tagIds)); .where(inArray(profileTag.tagId, tagIds));
profileIds = tagMatches.map((r) => r.profileId);
if (profileIds.length > 0) { const matchedIds = tagMatches.map((r) => r.profileId);
conditions.push(inArray(profile.id, profileIds)); if (matchedIds.length === 0) return { items: [], page, limit };
} else { conditions.push(inArray(profile.id, matchedIds));
return { data: [], total: 0, page, limit };
}
} }
const rows = await this.drizzleService.db const rows = await this.drizzleService.db
@@ -73,6 +71,7 @@ export class FeedService {
userId: profile.userId, userId: profile.userId,
name: profile.name, name: profile.name,
birthDate: profile.birthDate, birthDate: profile.birthDate,
gender: profile.gender,
cityId: profile.cityId, cityId: profile.cityId,
districtId: profile.districtId, districtId: profile.districtId,
description: profile.description, description: profile.description,
@@ -89,18 +88,23 @@ export class FeedService {
const enriched = await Promise.all( const enriched = await Promise.all(
rows.map(async (p) => { rows.map(async (p) => {
const tags = await this.drizzleService.db const [tags, media] = await Promise.all([
this.drizzleService.db
.select({ id: tag.id, value: tag.value }) .select({ id: tag.id, value: tag.value })
.from(profileTag) .from(profileTag)
.innerJoin(tag, eq(tag.id, profileTag.tagId)) .innerJoin(tag, eq(tag.id, profileTag.tagId))
.where(eq(profileTag.profileId, p.id)); .where(eq(profileTag.profileId, p.id)),
this.drizzleService.db
const age = this.calculateAge(p.birthDate); .select()
return { ...p, age, tags }; .from(profileMedia)
.where(eq(profileMedia.profileId, p.id))
.orderBy(profileMedia.sortOrder),
]);
return { ...p, age: this.calculateAge(p.birthDate), tags, media };
}), }),
); );
return { data: enriched, page, limit }; return { items: enriched, page, limit };
} }
private calculateAge(birthDate: string): number { private calculateAge(birthDate: string): number {
@@ -111,4 +115,15 @@ export class FeedService {
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--; if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
return age; return age;
} }
private async assertProfileOwnership(userId: string, profileId: string) {
const [found] = await this.drizzleService.db
.select({ userId: profile.userId })
.from(profile)
.where(eq(profile.id, profileId))
.limit(1);
if (!found) throw new NotFoundException('Profile not found');
if (found.userId !== userId) throw new ForbiddenException('Profile does not belong to you');
}
} }

View File

@@ -0,0 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
export class GreetingDto {
@ApiProperty() id: string;
@ApiProperty() text: string;
}

View File

@@ -1,10 +1,12 @@
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiBody, ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '../../common/decorators/public.decorator'; import { Public } from '../../common/decorators/public.decorator';
import { Roles } from '../../common/decorators/roles.decorator'; import { Roles } from '../../common/decorators/roles.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RolesGuard } from '../../common/guards/roles.guard'; import { RolesGuard } from '../../common/guards/roles.guard';
import { GreetingsService } from './greetings.service'; import { GreetingsService } from './greetings.service';
import { GreetingDto } from './dto/greeting-response.dto';
import { MessageResponseDto } from '../../common/dto/message-response.dto';
@ApiTags('greetings') @ApiTags('greetings')
@Controller('greetings') @Controller('greetings')
@@ -14,6 +16,7 @@ export class GreetingsController {
@Public() @Public()
@Get() @Get()
@ApiOperation({ summary: 'Get all greeting phrases' }) @ApiOperation({ summary: 'Get all greeting phrases' })
@ApiOkResponse({ type: [GreetingDto] })
findAll() { findAll() {
return this.greetingsService.findAll(); return this.greetingsService.findAll();
} }
@@ -23,6 +26,8 @@ export class GreetingsController {
@Roles('admin') @Roles('admin')
@Post() @Post()
@ApiOperation({ summary: 'Add greeting phrase (admin only)' }) @ApiOperation({ summary: 'Add greeting phrase (admin only)' })
@ApiBody({ schema: { type: 'object', properties: { text: { type: 'string', example: 'Привет!' } }, required: ['text'] } })
@ApiCreatedResponse({ type: GreetingDto })
create(@Body('text') text: string) { create(@Body('text') text: string) {
return this.greetingsService.create(text); return this.greetingsService.create(text);
} }
@@ -32,6 +37,7 @@ export class GreetingsController {
@Roles('admin') @Roles('admin')
@Delete(':id') @Delete(':id')
@ApiOperation({ summary: 'Delete greeting phrase (admin only)' }) @ApiOperation({ summary: 'Delete greeting phrase (admin only)' })
@ApiOkResponse({ type: MessageResponseDto })
delete(@Param('id') id: string) { delete(@Param('id') id: string) {
return this.greetingsService.delete(id); return this.greetingsService.delete(id);
} }

View File

@@ -2,9 +2,13 @@ import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsUUID } from 'class-validator'; import { IsEnum, IsUUID } from 'class-validator';
export class CreateLikeDto { export class CreateLikeDto {
@ApiProperty() @ApiProperty({ description: 'Your profile ID' })
@IsUUID() @IsUUID()
targetUserId: string; sourceProfileId: string;
@ApiProperty({ description: 'Target profile ID' })
@IsUUID()
targetProfileId: string;
@ApiProperty({ enum: ['like', 'dislike'] }) @ApiProperty({ enum: ['like', 'dislike'] })
@IsEnum(['like', 'dislike']) @IsEnum(['like', 'dislike'])

View File

@@ -0,0 +1,21 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class LikeDto {
@ApiProperty() id: string;
@ApiProperty() sourceProfileId: string;
@ApiProperty() targetProfileId: string;
@ApiProperty({ enum: ['like', 'dislike'] }) type: string;
@ApiProperty() createdAt: string;
}
export class MatchDto {
@ApiProperty() id: string;
@ApiProperty() profile1Id: string;
@ApiProperty() profile2Id: string;
@ApiProperty() createdAt: string;
}
export class CreateLikeResponseDto {
@ApiProperty({ type: LikeDto }) like: LikeDto;
@ApiPropertyOptional({ type: MatchDto, nullable: true }) match: MatchDto | null;
}

View File

@@ -1,8 +1,9 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CreateLikeDto } from './dto/create-like.dto'; import { CreateLikeDto } from './dto/create-like.dto';
import { CreateLikeResponseDto, MatchDto } from './dto/likes-response.dto';
import { LikesService } from './likes.service'; import { LikesService } from './likes.service';
@ApiTags('likes') @ApiTags('likes')
@@ -13,17 +14,19 @@ export class LikesController {
constructor(private readonly likesService: LikesService) {} constructor(private readonly likesService: LikesService) {}
@Post() @Post()
@ApiOperation({ summary: 'Like or dislike a user' }) @ApiOperation({ summary: 'Like or dislike a profile' })
createLike( @ApiCreatedResponse({ type: CreateLikeResponseDto })
@CurrentUser('id') userId: string, createLike(@CurrentUser('id') userId: string, @Body() dto: CreateLikeDto) {
@Body() dto: CreateLikeDto,
) {
return this.likesService.createLike(userId, dto); return this.likesService.createLike(userId, dto);
} }
@Get('matches') @Get('matches')
@ApiOperation({ summary: 'Get my matches' }) @ApiOperation({ summary: 'Get matches for a profile' })
getMyMatches(@CurrentUser('id') userId: string) { @ApiOkResponse({ type: [MatchDto] })
return this.likesService.getMyMatches(userId); getMyMatches(
@CurrentUser('id') userId: string,
@Query('profileId') profileId: string,
) {
return this.likesService.getMyMatches(userId, profileId);
} }
} }

View File

@@ -1,13 +1,10 @@
import { import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
BadRequestException,
Injectable,
} from '@nestjs/common';
import { and, eq, or } from 'drizzle-orm'; import { and, eq, or } from 'drizzle-orm';
import { ConfigService } from '@nestjs/config';
import { DrizzleService } from '../../database/drizzle.service'; import { DrizzleService } from '../../database/drizzle.service';
import { like, match, user } from '../../database/schema'; import { like, match, profile, user } from '../../database/schema';
import { NotificationsService } from '../../notifications/notifications.service'; import { NotificationsService } from '../../notifications/notifications.service';
import { RedisService } from '../../redis/redis.service'; import { RedisService } from '../../redis/redis.service';
import { ConfigService } from '@nestjs/config';
import { CreateLikeDto } from './dto/create-like.dto'; import { CreateLikeDto } from './dto/create-like.dto';
@Injectable() @Injectable()
@@ -19,16 +16,18 @@ export class LikesService {
private readonly configService: ConfigService, private readonly configService: ConfigService,
) {} ) {}
async createLike(sourceUserId: string, dto: CreateLikeDto) { async createLike(userId: string, dto: CreateLikeDto) {
if (sourceUserId === dto.targetUserId) { await this.assertProfileOwnership(userId, dto.sourceProfileId);
if (dto.sourceProfileId === dto.targetProfileId) {
throw new BadRequestException('Cannot like yourself'); throw new BadRequestException('Cannot like yourself');
} }
const maxMatches = this.configService.get<number>('app.maxMatchesBeforePause'); const maxMatches = this.configService.get<number>('app.maxMatchesBeforePause');
const activeMatchesCount = await this.getActiveMatchesCount(sourceUserId); const activeMatchesCount = await this.getMatchesCount(dto.sourceProfileId);
if (activeMatchesCount >= maxMatches) { if (activeMatchesCount >= maxMatches) {
throw new BadRequestException( throw new BadRequestException(
`You have ${activeMatchesCount} matches. Resolve them before searching for new ones.`, `Profile has ${activeMatchesCount} matches. Resolve them before searching for new ones.`,
); );
} }
@@ -37,92 +36,99 @@ export class LikesService {
.from(like) .from(like)
.where( .where(
and( and(
eq(like.sourceUser, sourceUserId), eq(like.sourceProfileId, dto.sourceProfileId),
eq(like.targetUser, dto.targetUserId), eq(like.targetProfileId, dto.targetProfileId),
), ),
) )
.limit(1); .limit(1);
if (existing.length > 0) { if (existing.length > 0) throw new BadRequestException('Already reacted to this profile');
throw new BadRequestException('Already reacted to this user');
}
const [newLike] = await this.drizzleService.db const [newLike] = await this.drizzleService.db
.insert(like) .insert(like)
.values({ .values({
sourceUser: sourceUserId, sourceProfileId: dto.sourceProfileId,
targetUser: dto.targetUserId, targetProfileId: dto.targetProfileId,
type: dto.type, type: dto.type,
}) })
.returning(); .returning();
if (dto.type === 'like') { if (dto.type === 'like') {
return this.checkAndCreateMatch(sourceUserId, dto.targetUserId, newLike); return this.checkAndCreateMatch(dto.sourceProfileId, dto.targetProfileId, newLike);
} }
return { like: newLike, match: null }; return { like: newLike, match: null };
} }
private async checkAndCreateMatch(userId1: string, userId2: string, newLike: any) { async getMyMatches(userId: string, profileId: string) {
await this.assertProfileOwnership(userId, profileId);
return this.drizzleService.db
.select()
.from(match)
.where(or(eq(match.profile1Id, profileId), eq(match.profile2Id, profileId)))
.orderBy(match.createdAt);
}
private async checkAndCreateMatch(profileId1: string, profileId2: string, newLike: any) {
const reverseLike = await this.drizzleService.db const reverseLike = await this.drizzleService.db
.select() .select()
.from(like) .from(like)
.where( .where(
and( and(
eq(like.sourceUser, userId2), eq(like.sourceProfileId, profileId2),
eq(like.targetUser, userId1), eq(like.targetProfileId, profileId1),
eq(like.type, 'like'), eq(like.type, 'like'),
), ),
) )
.limit(1); .limit(1);
if (reverseLike.length === 0) { if (reverseLike.length === 0) return { like: newLike, match: null };
return { like: newLike, match: null };
}
const existingMatch = await this.drizzleService.db const existingMatch = await this.drizzleService.db
.select() .select()
.from(match) .from(match)
.where( .where(
or( or(
and(eq(match.user1Id, userId1), eq(match.user2Id, userId2)), and(eq(match.profile1Id, profileId1), eq(match.profile2Id, profileId2)),
and(eq(match.user1Id, userId2), eq(match.user2Id, userId1)), and(eq(match.profile1Id, profileId2), eq(match.profile2Id, profileId1)),
), ),
) )
.limit(1); .limit(1);
if (existingMatch.length > 0) { if (existingMatch.length > 0) return { like: newLike, match: existingMatch[0] };
return { like: newLike, match: existingMatch[0] };
}
const [newMatch] = await this.drizzleService.db const [newMatch] = await this.drizzleService.db
.insert(match) .insert(match)
.values({ user1Id: userId1, user2Id: userId2 }) .values({ profile1Id: profileId1, profile2Id: profileId2 })
.returning(); .returning();
await this.notifyMatch(userId1, userId2, newMatch.id); await this.notifyMatch(profileId1, profileId2, newMatch.id);
return { like: newLike, match: newMatch }; return { like: newLike, match: newMatch };
} }
private async getActiveMatchesCount(userId: string): Promise<number> { private async getMatchesCount(profileId: string): Promise<number> {
const matches = await this.drizzleService.db const matches = await this.drizzleService.db
.select({ id: match.id }) .select({ id: match.id })
.from(match) .from(match)
.where( .where(or(eq(match.profile1Id, profileId), eq(match.profile2Id, profileId)));
or(eq(match.user1Id, userId), eq(match.user2Id, userId)),
);
return matches.length; return matches.length;
} }
private async notifyMatch(userId1: string, userId2: string, matchId: string) { private async notifyMatch(profileId1: string, profileId2: string, matchId: string) {
const users = await this.drizzleService.db const profiles = await this.drizzleService.db
.select({ id: user.id, fcmToken: user.fcmToken }) .select({ userId: profile.userId })
.from(user) .from(profile)
.where(or(eq(user.id, userId1), eq(user.id, userId2))); .where(or(eq(profile.id, profileId1), eq(profile.id, profileId2)));
for (const u of users) { for (const p of profiles) {
if (u.fcmToken) { const [u] = await this.drizzleService.db
.select({ fcmToken: user.fcmToken })
.from(user)
.where(eq(user.id, p.userId))
.limit(1);
if (u?.fcmToken) {
await this.notificationsService.sendPushNotification( await this.notificationsService.sendPushNotification(
u.fcmToken, u.fcmToken,
'New Match!', 'New Match!',
@@ -132,16 +138,20 @@ export class LikesService {
} }
} }
await this.redisService.publish('match:created', JSON.stringify({ matchId, userId1, userId2 })); await this.redisService.publish(
'match:created',
JSON.stringify({ matchId, profileId1, profileId2 }),
);
} }
async getMyMatches(userId: string) { private async assertProfileOwnership(userId: string, profileId: string) {
return this.drizzleService.db const [found] = await this.drizzleService.db
.select() .select({ userId: profile.userId })
.from(match) .from(profile)
.where( .where(eq(profile.id, profileId))
or(eq(match.user1Id, userId), eq(match.user2Id, userId)), .limit(1);
)
.orderBy(match.createdAt); if (!found) throw new NotFoundException('Profile not found');
if (found.userId !== userId) throw new ForbiddenException('Profile does not belong to you');
} }
} }

View File

@@ -1,55 +1,54 @@
import { import { Controller, Delete, Get, Param, Post, Query, Req, UseGuards } from '@nestjs/common';
Controller, import { ApiBearerAuth, ApiConsumes, ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
Delete,
Get,
Param,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FastifyRequest } from 'fastify'; import { FastifyRequest } from 'fastify';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { MediaService } from './media.service'; import { MediaService } from './media.service';
import { MediaItemDto } from '../profiles/dto/profile-response.dto';
import { MessageResponseDto } from '../../common/dto/message-response.dto';
@ApiTags('media') @ApiTags('media')
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('media') @Controller('profiles/:profileId/media')
export class MediaController { export class MediaController {
constructor(private readonly mediaService: MediaService) {} constructor(private readonly mediaService: MediaService) {}
@Post('upload') @Post('upload')
@ApiOperation({ summary: 'Upload photo or video' }) @ApiOperation({ summary: 'Upload photo / video / audio to profile' })
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiCreatedResponse({ type: MediaItemDto })
async upload( async upload(
@CurrentUser('id') userId: string, @CurrentUser('id') userId: string,
@Param('profileId') profileId: string,
@Req() req: FastifyRequest, @Req() req: FastifyRequest,
@Query('type') type: 'photo' | 'video' = 'photo', @Query('type') type: 'photo' | 'video' | 'audio' = 'photo',
) { ) {
const data = await (req as any).file(); const data = await (req as any).file();
if (!data) { if (!data) throw new Error('No file provided');
throw new Error('No file provided');
}
const buffer = await data.toBuffer(); const buffer = await data.toBuffer();
return this.mediaService.uploadMedia( return this.mediaService.upload(
userId, userId,
profileId,
{ buffer, originalname: data.filename, mimetype: data.mimetype }, { buffer, originalname: data.filename, mimetype: data.mimetype },
type, type,
); );
} }
@Get() @Get()
@ApiOperation({ summary: 'Get my media' }) @ApiOperation({ summary: 'Get all media for a profile' })
getMyMedia(@CurrentUser('id') userId: string) { @ApiOkResponse({ type: [MediaItemDto] })
return this.mediaService.getByUserId(userId); getMedia(@Param('profileId') profileId: string) {
return this.mediaService.getByProfileId(profileId);
} }
@Delete(':id') @Delete(':mediaId')
@ApiOperation({ summary: 'Delete media' }) @ApiOperation({ summary: 'Delete media item' })
deleteMedia(@CurrentUser('id') userId: string, @Param('id') id: string) { @ApiOkResponse({ type: MessageResponseDto })
return this.mediaService.deleteMedia(userId, id); deleteMedia(
@CurrentUser('id') userId: string,
@Param('mediaId') mediaId: string,
) {
return this.mediaService.delete(userId, mediaId);
} }
} }

View File

@@ -1,7 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { DrizzleService } from '../../database/drizzle.service'; import { DrizzleService } from '../../database/drizzle.service';
import { media } from '../../database/schema'; import { profile, profileMedia } from '../../database/schema';
import { StorageService } from '../../storage/storage.service'; import { StorageService } from '../../storage/storage.service';
@Injectable() @Injectable()
@@ -11,50 +11,75 @@ export class MediaService {
private readonly storageService: StorageService, private readonly storageService: StorageService,
) {} ) {}
async uploadMedia( async upload(
userId: string, userId: string,
profileId: string,
file: { buffer: Buffer; originalname: string; mimetype: string }, file: { buffer: Buffer; originalname: string; mimetype: string },
type: 'photo' | 'video', type: 'photo' | 'video' | 'audio',
) { ) {
const folder = type === 'photo' ? 'photos' : 'videos'; await this.assertOwnership(userId, profileId);
const folder = type;
const objectName = await this.storageService.uploadFile( const objectName = await this.storageService.uploadFile(
file.buffer, file.buffer,
file.originalname, file.originalname,
file.mimetype, file.mimetype,
folder, folder,
); );
const publicUrl = this.storageService.getPublicUrl(objectName); const path = this.storageService.getPublicUrl(objectName);
const existing = await this.drizzleService.db
.select({ sortOrder: profileMedia.sortOrder })
.from(profileMedia)
.where(eq(profileMedia.profileId, profileId))
.orderBy(profileMedia.sortOrder);
const nextOrder = existing.length > 0
? (existing[existing.length - 1].sortOrder ?? 0) + 1
: 0;
const [newMedia] = await this.drizzleService.db const [newMedia] = await this.drizzleService.db
.insert(media) .insert(profileMedia)
.values({ userId, path: publicUrl, type }) .values({ profileId, path, type, sortOrder: nextOrder } as any)
.returning(); .returning();
return newMedia; return newMedia;
} }
async getByUserId(userId: string) { async getByProfileId(profileId: string) {
return this.drizzleService.db return this.drizzleService.db
.select() .select()
.from(media) .from(profileMedia)
.where(eq(media.userId, userId)); .where(eq(profileMedia.profileId, profileId))
.orderBy(profileMedia.sortOrder);
} }
async deleteMedia(userId: string, mediaId: string) { async delete(userId: string, mediaId: string) {
const [found] = await this.drizzleService.db const [found] = await this.drizzleService.db
.select() .select()
.from(media) .from(profileMedia)
.where(eq(media.id, mediaId)) .where(eq(profileMedia.id, mediaId))
.limit(1); .limit(1);
if (!found || found.userId !== userId) { if (!found) throw new NotFoundException('Media not found');
throw new NotFoundException('Media not found');
} await this.assertOwnership(userId, found.profileId);
const objectName = found.path.split('/').slice(-2).join('/'); const objectName = found.path.split('/').slice(-2).join('/');
await this.storageService.deleteFile(objectName).catch(() => {}); await this.storageService.deleteFile(objectName).catch(() => {});
await this.drizzleService.db.delete(profileMedia).where(eq(profileMedia.id, mediaId));
await this.drizzleService.db.delete(media).where(eq(media.id, mediaId));
return { message: 'Media deleted' }; return { message: 'Media deleted' };
} }
private async assertOwnership(userId: string, profileId: string) {
const [found] = await this.drizzleService.db
.select({ userId: profile.userId })
.from(profile)
.where(eq(profile.id, profileId))
.limit(1);
if (!found) throw new NotFoundException('Profile not found');
if (found.userId !== userId) throw new ForbiddenException('Profile does not belong to you');
}
} }

View File

@@ -0,0 +1,46 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CityDto {
@ApiProperty() id: string;
@ApiProperty() name: string;
@ApiProperty() lat: string;
@ApiProperty() lng: string;
}
export class DistrictDto {
@ApiProperty() id: string;
@ApiProperty() cityId: string;
@ApiProperty() name: string;
}
export class TagDto {
@ApiProperty() id: string;
@ApiProperty() value: string;
}
export class MediaItemDto {
@ApiProperty() id: string;
@ApiProperty() profileId: string;
@ApiProperty() path: string;
@ApiProperty({ enum: ['photo', 'video', 'audio'] }) type: string;
@ApiProperty() sortOrder: number;
}
export class ProfileResponseDto {
@ApiProperty() id: string;
@ApiProperty() userId: string;
@ApiProperty() name: string;
@ApiProperty({ example: '1995-06-15' }) birthDate: string;
@ApiProperty({ enum: ['male', 'female'] }) gender: string;
@ApiPropertyOptional({ nullable: true }) cityId: string | null;
@ApiPropertyOptional({ nullable: true }) districtId: string | null;
@ApiPropertyOptional({ nullable: true }) description: string | null;
@ApiPropertyOptional({ nullable: true }) nation: string | null;
@ApiPropertyOptional({ nullable: true }) height: number | null;
@ApiPropertyOptional({ nullable: true }) weight: number | null;
@ApiPropertyOptional({ nullable: true }) activeChatId: string | null;
@ApiPropertyOptional({ type: CityDto, nullable: true }) city: CityDto | null;
@ApiPropertyOptional({ type: DistrictDto, nullable: true }) district: DistrictDto | null;
@ApiProperty({ type: [TagDto] }) tags: TagDto[];
@ApiProperty({ type: [MediaItemDto] }) media: MediaItemDto[];
}

View File

@@ -1,10 +1,12 @@
import { Body, Controller, Get, Param, Post, Put, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CreateProfileDto } from './dto/create-profile.dto'; import { CreateProfileDto } from './dto/create-profile.dto';
import { UpdateProfileDto } from './dto/update-profile.dto'; import { UpdateProfileDto } from './dto/update-profile.dto';
import { ProfileResponseDto } from './dto/profile-response.dto';
import { ProfilesService } from './profiles.service'; import { ProfilesService } from './profiles.service';
import { MessageResponseDto } from '../../common/dto/message-response.dto';
@ApiTags('profiles') @ApiTags('profiles')
@ApiBearerAuth() @ApiBearerAuth()
@@ -14,32 +16,41 @@ export class ProfilesController {
constructor(private readonly profilesService: ProfilesService) {} constructor(private readonly profilesService: ProfilesService) {}
@Post() @Post()
@ApiOperation({ summary: 'Create my profile' }) @ApiOperation({ summary: 'Create a new profile (one user can have many)' })
create( @ApiCreatedResponse({ type: ProfileResponseDto })
@CurrentUser('id') userId: string, create(@CurrentUser('id') userId: string, @Body() dto: CreateProfileDto) {
@Body() dto: CreateProfileDto,
) {
return this.profilesService.create(userId, dto); return this.profilesService.create(userId, dto);
} }
@Put() @Get('my')
@ApiOperation({ summary: 'Update my profile' }) @ApiOperation({ summary: 'Get all my profiles' })
@ApiOkResponse({ type: [ProfileResponseDto] })
getMyProfiles(@CurrentUser('id') userId: string) {
return this.profilesService.findAllByUserId(userId);
}
@Put(':profileId')
@ApiOperation({ summary: 'Update profile by ID (must be owner)' })
@ApiOkResponse({ type: ProfileResponseDto })
update( update(
@CurrentUser('id') userId: string, @CurrentUser('id') userId: string,
@Param('profileId') profileId: string,
@Body() dto: UpdateProfileDto, @Body() dto: UpdateProfileDto,
) { ) {
return this.profilesService.update(userId, dto); return this.profilesService.update(userId, profileId, dto);
} }
@Get('me') @Get(':profileId')
@ApiOperation({ summary: 'Get my profile' })
getMyProfile(@CurrentUser('id') userId: string) {
return this.profilesService.findByUserId(userId);
}
@Get(':id')
@ApiOperation({ summary: 'Get profile by ID' }) @ApiOperation({ summary: 'Get profile by ID' })
findOne(@Param('id') id: string) { @ApiOkResponse({ type: ProfileResponseDto })
return this.profilesService.findByProfileId(id); findOne(@Param('profileId') profileId: string) {
return this.profilesService.findByProfileId(profileId);
}
@Delete(':profileId')
@ApiOperation({ summary: 'Delete profile (must be owner)' })
@ApiOkResponse({ type: MessageResponseDto })
delete(@CurrentUser('id') userId: string, @Param('profileId') profileId: string) {
return this.profilesService.delete(userId, profileId);
} }
} }

View File

@@ -1,12 +1,7 @@
import { import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { eq, inArray } from 'drizzle-orm'; import { eq, inArray } from 'drizzle-orm';
import { DrizzleService } from '../../database/drizzle.service'; import { DrizzleService } from '../../database/drizzle.service';
import { profile, profileTag, tag, media, city, cityDistrict } from '../../database/schema'; import { profile, profileMedia, profileTag, tag, city, cityDistrict } from '../../database/schema';
import { CreateProfileDto } from './dto/create-profile.dto'; import { CreateProfileDto } from './dto/create-profile.dto';
import { UpdateProfileDto } from './dto/update-profile.dto'; import { UpdateProfileDto } from './dto/update-profile.dto';
@@ -15,14 +10,6 @@ export class ProfilesService {
constructor(private readonly drizzleService: DrizzleService) {} constructor(private readonly drizzleService: DrizzleService) {}
async create(userId: string, dto: CreateProfileDto) { async create(userId: string, dto: CreateProfileDto) {
const existing = await this.drizzleService.db
.select({ id: profile.id })
.from(profile)
.where(eq(profile.userId, userId))
.limit(1);
if (existing.length > 0) throw new ConflictException('Profile already exists');
const [newProfile] = await this.drizzleService.db const [newProfile] = await this.drizzleService.db
.insert(profile) .insert(profile)
.values({ .values({
@@ -46,40 +33,36 @@ export class ProfilesService {
return this.findByProfileId(newProfile.id); return this.findByProfileId(newProfile.id);
} }
async update(userId: string, dto: UpdateProfileDto) { async update(userId: string, profileId: string, dto: UpdateProfileDto) {
const [found] = await this.drizzleService.db await this.assertOwnership(userId, profileId);
.select({ id: profile.id })
.from(profile)
.where(eq(profile.userId, userId))
.limit(1);
if (!found) throw new NotFoundException('Profile not found');
const { tagIds, ...fields } = dto; const { tagIds, ...fields } = dto;
const updateFields: any = {};
for (const [k, v] of Object.entries(fields)) {
if (v !== undefined) updateFields[k] = v;
}
if (Object.keys(fields).length > 0) { if (Object.keys(updateFields).length > 0) {
await this.drizzleService.db await this.drizzleService.db
.update(profile) .update(profile)
.set(fields) .set(updateFields as any)
.where(eq(profile.id, found.id)); .where(eq(profile.id, profileId));
} }
if (tagIds !== undefined) { if (tagIds !== undefined) {
await this.setTags(found.id, tagIds); await this.setTags(profileId, tagIds);
} }
return this.findByProfileId(found.id); return this.findByProfileId(profileId);
} }
async findByUserId(userId: string) { async findAllByUserId(userId: string) {
const [found] = await this.drizzleService.db const profiles = await this.drizzleService.db
.select({ id: profile.id }) .select()
.from(profile) .from(profile)
.where(eq(profile.userId, userId)) .where(eq(profile.userId, userId));
.limit(1);
if (!found) throw new NotFoundException('Profile not found'); return Promise.all(profiles.map((p) => this.findByProfileId(p.id)));
return this.findByProfileId(found.id);
} }
async findByProfileId(profileId: string) { async findByProfileId(profileId: string) {
@@ -93,18 +76,43 @@ export class ProfilesService {
if (!found) throw new NotFoundException('Profile not found'); if (!found) throw new NotFoundException('Profile not found');
const tags = await this.drizzleService.db const [tags, media] = await Promise.all([
this.drizzleService.db
.select({ id: tag.id, value: tag.value }) .select({ id: tag.id, value: tag.value })
.from(profileTag) .from(profileTag)
.innerJoin(tag, eq(tag.id, profileTag.tagId)) .innerJoin(tag, eq(tag.id, profileTag.tagId))
.where(eq(profileTag.profileId, profileId)); .where(eq(profileTag.profileId, profileId)),
this.drizzleService.db
const medias = await this.drizzleService.db
.select() .select()
.from(media) .from(profileMedia)
.where(eq(media.userId, found.profile.userId)); .where(eq(profileMedia.profileId, profileId))
.orderBy(profileMedia.sortOrder),
]);
return { ...found.profile, city: found.city, district: found.city_district, tags, media: medias }; return {
...found.profile,
city: found.city,
district: found.city_district,
tags,
media,
};
}
async delete(userId: string, profileId: string) {
await this.assertOwnership(userId, profileId);
await this.drizzleService.db.delete(profile).where(eq(profile.id, profileId));
return { message: 'Profile deleted' };
}
private async assertOwnership(userId: string, profileId: string) {
const [found] = await this.drizzleService.db
.select({ userId: profile.userId })
.from(profile)
.where(eq(profile.id, profileId))
.limit(1);
if (!found) throw new NotFoundException('Profile not found');
if (found.userId !== userId) throw new ForbiddenException('Profile does not belong to you');
} }
private async setTags(profileId: string, tagIds: string[]) { private async setTags(profileId: string, tagIds: string[]) {
@@ -113,9 +121,9 @@ export class ProfilesService {
.where(eq(profileTag.profileId, profileId)); .where(eq(profileTag.profileId, profileId));
if (tagIds.length > 0) { if (tagIds.length > 0) {
await this.drizzleService.db.insert(profileTag).values( await this.drizzleService.db
tagIds.map((tagId) => ({ profileId, tagId })), .insert(profileTag)
); .values(tagIds.map((tagId) => ({ profileId, tagId })));
} }
} }
} }

View File

@@ -2,6 +2,10 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
export class CreateReportDto { export class CreateReportDto {
@ApiProperty({ description: 'Your profile ID' })
@IsUUID()
sourceProfileId: string;
@ApiProperty() @ApiProperty()
@IsUUID() @IsUUID()
entityId: string; entityId: string;

View File

@@ -0,0 +1,9 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ReportDto {
@ApiProperty() id: string;
@ApiProperty() sourceProfileId: string;
@ApiProperty() entityId: string;
@ApiProperty({ enum: ['profile', 'message'] }) entityType: string;
@ApiPropertyOptional({ nullable: true }) description: string | null;
}

View File

@@ -1,10 +1,11 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator'; import { Roles } from '../../common/decorators/roles.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RolesGuard } from '../../common/guards/roles.guard'; import { RolesGuard } from '../../common/guards/roles.guard';
import { CreateReportDto } from './dto/create-report.dto'; import { CreateReportDto } from './dto/create-report.dto';
import { ReportDto } from './dto/report-response.dto';
import { ReportsService } from './reports.service'; import { ReportsService } from './reports.service';
@ApiTags('reports') @ApiTags('reports')
@@ -16,10 +17,8 @@ export class ReportsController {
@Post() @Post()
@ApiOperation({ summary: 'Submit a report' }) @ApiOperation({ summary: 'Submit a report' })
create( @ApiCreatedResponse({ type: ReportDto })
@CurrentUser('id') userId: string, create(@CurrentUser('id') userId: string, @Body() dto: CreateReportDto) {
@Body() dto: CreateReportDto,
) {
return this.reportsService.create(userId, dto); return this.reportsService.create(userId, dto);
} }
@@ -27,6 +26,7 @@ export class ReportsController {
@Roles('admin', 'moderator') @Roles('admin', 'moderator')
@UseGuards(RolesGuard) @UseGuards(RolesGuard)
@ApiOperation({ summary: 'Get all reports (admin/moderator)' }) @ApiOperation({ summary: 'Get all reports (admin/moderator)' })
@ApiOkResponse({ type: [ReportDto] })
getAll() { getAll() {
return this.reportsService.getAll(); return this.reportsService.getAll();
} }

View File

@@ -1,23 +1,26 @@
import { Injectable } from '@nestjs/common'; import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { DrizzleService } from '../../database/drizzle.service';
import { report } from '../../database/schema';
import { CreateReportDto } from './dto/create-report.dto';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { DrizzleService } from '../../database/drizzle.service';
import { profile, report } from '../../database/schema';
import { CreateReportDto } from './dto/create-report.dto';
@Injectable() @Injectable()
export class ReportsService { export class ReportsService {
constructor(private readonly drizzleService: DrizzleService) {} constructor(private readonly drizzleService: DrizzleService) {}
async create(userId: string, dto: CreateReportDto) { async create(userId: string, dto: CreateReportDto) {
await this.assertProfileOwnership(userId, dto.sourceProfileId);
const [newReport] = await this.drizzleService.db const [newReport] = await this.drizzleService.db
.insert(report) .insert(report)
.values({ .values({
sourceUser: userId, sourceProfileId: dto.sourceProfileId,
entityId: dto.entityId, entityId: dto.entityId,
entityType: dto.entityType, entityType: dto.entityType,
description: dto.description || null, description: dto.description || null,
} as any) } as any)
.returning(); .returning();
return newReport; return newReport;
} }
@@ -25,10 +28,14 @@ export class ReportsService {
return this.drizzleService.db.select().from(report).orderBy(report.id); return this.drizzleService.db.select().from(report).orderBy(report.id);
} }
async getByUser(userId: string) { private async assertProfileOwnership(userId: string, profileId: string) {
return this.drizzleService.db const [found] = await this.drizzleService.db
.select() .select({ userId: profile.userId })
.from(report) .from(profile)
.where(eq(report.sourceUser, userId)); .where(eq(profile.id, profileId))
.limit(1);
if (!found) throw new NotFoundException('Profile not found');
if (found.userId !== userId) throw new ForbiddenException('Profile does not belong to you');
} }
} }

View File

@@ -0,0 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
export class TagResponseDto {
@ApiProperty() id: string;
@ApiProperty() value: string;
}

View File

@@ -1,10 +1,12 @@
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiBody, ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '../../common/decorators/public.decorator'; import { Public } from '../../common/decorators/public.decorator';
import { Roles } from '../../common/decorators/roles.decorator'; import { Roles } from '../../common/decorators/roles.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RolesGuard } from '../../common/guards/roles.guard'; import { RolesGuard } from '../../common/guards/roles.guard';
import { TagsService } from './tags.service'; import { TagsService } from './tags.service';
import { TagResponseDto } from './dto/tag-response.dto';
import { MessageResponseDto } from '../../common/dto/message-response.dto';
@ApiTags('tags') @ApiTags('tags')
@Controller('tags') @Controller('tags')
@@ -14,6 +16,7 @@ export class TagsController {
@Public() @Public()
@Get() @Get()
@ApiOperation({ summary: 'Get all tags' }) @ApiOperation({ summary: 'Get all tags' })
@ApiOkResponse({ type: [TagResponseDto] })
findAll() { findAll() {
return this.tagsService.findAll(); return this.tagsService.findAll();
} }
@@ -23,6 +26,8 @@ export class TagsController {
@Roles('admin') @Roles('admin')
@Post() @Post()
@ApiOperation({ summary: 'Create tag (admin only)' }) @ApiOperation({ summary: 'Create tag (admin only)' })
@ApiBody({ schema: { type: 'object', properties: { value: { type: 'string', example: 'Спорт' } }, required: ['value'] } })
@ApiCreatedResponse({ type: TagResponseDto })
create(@Body('value') value: string) { create(@Body('value') value: string) {
return this.tagsService.create(value); return this.tagsService.create(value);
} }
@@ -32,6 +37,7 @@ export class TagsController {
@Roles('admin') @Roles('admin')
@Delete(':id') @Delete(':id')
@ApiOperation({ summary: 'Delete tag (admin only)' }) @ApiOperation({ summary: 'Delete tag (admin only)' })
@ApiOkResponse({ type: MessageResponseDto })
delete(@Param('id') id: string) { delete(@Param('id') id: string) {
return this.tagsService.delete(id); return this.tagsService.delete(id);
} }

View File

@@ -0,0 +1,27 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class RoleDto {
@ApiProperty() id: string;
@ApiProperty() name: string;
}
export class ProfileSummaryDto {
@ApiProperty() id: string;
@ApiProperty() name: string;
@ApiProperty({ enum: ['male', 'female'] }) gender: string;
}
export class UserResponseDto {
@ApiProperty() id: string;
@ApiProperty() phone: string;
@ApiProperty({ enum: ['active', 'banned', 'pending'] }) status: string;
@ApiPropertyOptional({ nullable: true }) roleId: string | null;
@ApiPropertyOptional({ nullable: true }) tariffId: string | null;
@ApiPropertyOptional({ nullable: true }) paymentId: string | null;
@ApiPropertyOptional({ nullable: true }) fcmToken: string | null;
}
export class MeResponseDto extends UserResponseDto {
@ApiPropertyOptional({ type: RoleDto, nullable: true }) role: RoleDto | null;
@ApiProperty({ type: [ProfileSummaryDto] }) profiles: ProfileSummaryDto[];
}

View File

@@ -1,10 +1,12 @@
import { Controller, Get, Param, Patch, UseGuards } from '@nestjs/common'; import { Controller, Get, Param, Patch, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator'; import { Roles } from '../../common/decorators/roles.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RolesGuard } from '../../common/guards/roles.guard'; import { RolesGuard } from '../../common/guards/roles.guard';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
import { MeResponseDto, UserResponseDto } from './dto/user-response.dto';
import { MessageResponseDto } from '../../common/dto/message-response.dto';
@ApiTags('users') @ApiTags('users')
@ApiBearerAuth() @ApiBearerAuth()
@@ -14,13 +16,15 @@ export class UsersController {
constructor(private readonly usersService: UsersService) {} constructor(private readonly usersService: UsersService) {}
@Get('me') @Get('me')
@ApiOperation({ summary: 'Get current user profile' }) @ApiOperation({ summary: 'Get current user with profile list' })
@ApiOkResponse({ type: MeResponseDto })
getMe(@CurrentUser('id') userId: string) { getMe(@CurrentUser('id') userId: string) {
return this.usersService.getMyProfile(userId); return this.usersService.getMe(userId);
} }
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Get user by ID' }) @ApiOperation({ summary: 'Get user by ID' })
@ApiOkResponse({ type: UserResponseDto })
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {
return this.usersService.findById(id); return this.usersService.findById(id);
} }
@@ -28,7 +32,8 @@ export class UsersController {
@Patch(':id/ban') @Patch(':id/ban')
@Roles('admin', 'moderator') @Roles('admin', 'moderator')
@UseGuards(RolesGuard) @UseGuards(RolesGuard)
@ApiOperation({ summary: 'Ban user (admin/moderator only)' }) @ApiOperation({ summary: 'Ban user' })
@ApiOkResponse({ type: MessageResponseDto })
ban(@Param('id') id: string) { ban(@Param('id') id: string) {
return this.usersService.banUser(id); return this.usersService.banUser(id);
} }
@@ -36,7 +41,8 @@ export class UsersController {
@Patch(':id/activate') @Patch(':id/activate')
@Roles('admin', 'moderator') @Roles('admin', 'moderator')
@UseGuards(RolesGuard) @UseGuards(RolesGuard)
@ApiOperation({ summary: 'Activate user (admin/moderator only)' }) @ApiOperation({ summary: 'Activate user' })
@ApiOkResponse({ type: MessageResponseDto })
activate(@Param('id') id: string) { activate(@Param('id') id: string) {
return this.usersService.activateUser(id); return this.usersService.activateUser(id);
} }

View File

@@ -1,7 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { DrizzleService } from '../../database/drizzle.service'; import { DrizzleService } from '../../database/drizzle.service';
import { user, profile, media, role } from '../../database/schema'; import { user, profile, role } from '../../database/schema';
@Injectable() @Injectable()
export class UsersService { export class UsersService {
@@ -19,38 +19,23 @@ export class UsersService {
return rest; return rest;
} }
async findByIdWithProfile(id: string) { async getMe(userId: string) {
const [found] = await this.drizzleService.db const [found] = await this.drizzleService.db
.select() .select()
.from(user) .from(user)
.leftJoin(profile, eq(profile.userId, user.id))
.where(eq(user.id, id))
.limit(1);
if (!found) throw new NotFoundException('User not found');
return found;
}
async getMyProfile(userId: string) {
const result = await this.drizzleService.db
.select()
.from(user)
.leftJoin(profile, eq(profile.userId, user.id))
.leftJoin(role, eq(role.id, user.roleId)) .leftJoin(role, eq(role.id, user.roleId))
.where(eq(user.id, userId)) .where(eq(user.id, userId))
.limit(1); .limit(1);
if (!result.length) throw new NotFoundException('User not found'); if (!found) throw new NotFoundException('User not found');
const row = result[0]; const { password, ...userFields } = found.user;
const { password, ...userFields } = row.user;
return { ...userFields, profile: row.profile, role: row.role };
}
async getMediaByUserId(userId: string) { const profiles = await this.drizzleService.db
return this.drizzleService.db .select({ id: profile.id, name: profile.name, gender: profile.gender })
.select() .from(profile)
.from(media) .where(eq(profile.userId, userId));
.where(eq(media.userId, userId));
return { ...userFields, role: found.role, profiles };
} }
async banUser(userId: string) { async banUser(userId: string) {