Compare commits
4 Commits
main
...
bd-migrate
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1662bb7dc8 | ||
|
|
8b852c9f28 | ||
|
|
102b6b4026 | ||
|
|
bc3e48bcad |
27
CLAUDE.md
27
CLAUDE.md
@@ -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`
|
||||
@@ -103,7 +128,7 @@ seed (например, хешем userId + дата).
|
||||
|
||||
| # | Пункт ТЗ | Как реализовано | Причина |
|
||||
|---|---|---|---|
|
||||
| 1 | `chat.profile1_id → profile` | Поле хранит `user_id` | Матч создаётся между пользователями, а не профилями; JOIN с профилем не нужен на горячем пути |
|
||||
| 1 | `chat.profile1_id → profile` | Реализовано корректно после рефакторинга | — |
|
||||
| 2 | Поиск «неактивен» при превышении лимита матчей | `BadRequestException` при лайке | Проще контракт с клиентом: ошибка явная, не нужно отдельного флага `searchActive` |
|
||||
| 3 | Тарифный план один, но оплата предусмотрена | Таблицы `tariff` и `payment` созданы, логика оплаты не реализована | ТЗ: «регистрация открыта для всех, тарифный план один» — бизнес-логики оплаты нет |
|
||||
| 4 | `radiusKm` в фильтре ленты | Параметр принимается, но фильтрация по радиусу не применяется | Требует PostGIS или формулы Haversine; добавить в следующей итерации |
|
||||
|
||||
341
frontend-starter-prompt.md
Normal file
341
frontend-starter-prompt.md
Normal 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`).
|
||||
@@ -1,12 +1,14 @@
|
||||
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 { LoginDto } from './dto/login.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { TokensResponseDto } from './dto/tokens-response.dto';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { MessageResponseDto } from '../common/dto/message-response.dto';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
@@ -16,6 +18,7 @@ export class AuthController {
|
||||
@Public()
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: 'Register new user' })
|
||||
@ApiCreatedResponse({ type: TokensResponseDto })
|
||||
register(@Body() dto: RegisterDto) {
|
||||
return this.authService.register(dto);
|
||||
}
|
||||
@@ -23,6 +26,7 @@ export class AuthController {
|
||||
@Public()
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: 'Login with phone and password' })
|
||||
@ApiCreatedResponse({ type: TokensResponseDto })
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.authService.login(dto);
|
||||
}
|
||||
@@ -31,6 +35,7 @@ export class AuthController {
|
||||
@Post('logout')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Logout current user' })
|
||||
@ApiOkResponse({ type: MessageResponseDto })
|
||||
logout(@CurrentUser('id') userId: string) {
|
||||
return this.authService.logout(userId);
|
||||
}
|
||||
@@ -38,6 +43,7 @@ export class AuthController {
|
||||
@Public()
|
||||
@Post('refresh')
|
||||
@ApiOperation({ summary: 'Refresh access token' })
|
||||
@ApiCreatedResponse({ type: TokensResponseDto })
|
||||
refresh(@Body() dto: RefreshTokenDto) {
|
||||
return this.authService.refreshTokens(dto.refreshToken);
|
||||
}
|
||||
@@ -46,6 +52,8 @@ export class AuthController {
|
||||
@Post('fcm-token')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Update FCM push token' })
|
||||
@ApiBody({ schema: { type: 'object', properties: { fcmToken: { type: 'string', example: 'firebase-token-abc123' } }, required: ['fcmToken'] } })
|
||||
@ApiOkResponse({ type: MessageResponseDto })
|
||||
updateFcmToken(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body('fcmToken') fcmToken: string,
|
||||
|
||||
9
src/auth/dto/tokens-response.dto.ts
Normal file
9
src/auth/dto/tokens-response.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class TokensResponseDto {
|
||||
@ApiProperty({ example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' })
|
||||
accessToken: string;
|
||||
|
||||
@ApiProperty({ example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' })
|
||||
refreshToken: string;
|
||||
}
|
||||
9
src/common/decorators/current-profile.decorator.ts
Normal file
9
src/common/decorators/current-profile.decorator.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
6
src/common/dto/message-response.dto.ts
Normal file
6
src/common/dto/message-response.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class MessageResponseDto {
|
||||
@ApiProperty({ example: 'Operation successful' })
|
||||
message: string;
|
||||
}
|
||||
36
src/common/guards/profile-owner.guard.ts
Normal file
36
src/common/guards/profile-owner.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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"."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"."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 TABLE IF NOT EXISTS "permission" (
|
||||
"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
|
||||
);
|
||||
--> 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" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
@@ -72,30 +52,31 @@ CREATE TABLE IF NOT EXISTS "user" (
|
||||
"role_id" uuid,
|
||||
"tariff_id" uuid,
|
||||
"payment_id" uuid,
|
||||
"active_chat_id" uuid,
|
||||
"fcm_token" text,
|
||||
CONSTRAINT "user_phone_unique" UNIQUE("phone")
|
||||
);
|
||||
--> 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" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"name" varchar(100) NOT NULL,
|
||||
"birth_date" date NOT NULL,
|
||||
"gender" "gender" NOT NULL,
|
||||
"city_id" uuid,
|
||||
"district_id" uuid,
|
||||
"description" text,
|
||||
"nation" varchar(100),
|
||||
"height" 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
|
||||
CREATE TABLE IF NOT EXISTS "profile_tag" (
|
||||
@@ -111,23 +92,45 @@ CREATE TABLE IF NOT EXISTS "tag" (
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "like" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"source_user" uuid NOT NULL,
|
||||
"target_user" uuid NOT NULL,
|
||||
"source_profile_id" uuid NOT NULL,
|
||||
"target_profile_id" uuid NOT NULL,
|
||||
"type" "like_type" NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "match" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user1_id" uuid NOT NULL,
|
||||
"user2_id" uuid NOT NULL,
|
||||
"profile1_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
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "date" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user1_id" uuid NOT NULL,
|
||||
"user2_id" uuid NOT NULL,
|
||||
"profile1_id" uuid NOT NULL,
|
||||
"profile2_id" uuid NOT NULL,
|
||||
"lat" numeric(10, 7) NOT NULL,
|
||||
"lng" numeric(10, 7) NOT NULL,
|
||||
"time" timestamp with time zone NOT NULL,
|
||||
@@ -141,7 +144,7 @@ CREATE TABLE IF NOT EXISTS "date_status" (
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "report" (
|
||||
"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_type" "report_entity_type" NOT NULL,
|
||||
"description" text
|
||||
@@ -159,12 +162,6 @@ 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 "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
|
||||
@@ -183,12 +180,6 @@ EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> 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
|
||||
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
|
||||
@@ -207,6 +198,12 @@ EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> 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
|
||||
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
|
||||
@@ -220,37 +217,61 @@ EXCEPTION
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
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
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
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
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
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
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
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
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
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
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
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
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
@@ -262,7 +283,7 @@ EXCEPTION
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
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
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
@@ -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;
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "3d66c7d2-fc68-4c66-ad86-4f558d519225",
|
||||
"id": "7caebd65-9149-400c-92fa-1981f0e4ea72",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -206,144 +206,6 @@
|
||||
"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": {},
|
||||
"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": {
|
||||
"name": "payment",
|
||||
"schema": "",
|
||||
@@ -445,12 +307,6 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"active_chat_id": {
|
||||
"name": "active_chat_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"fcm_token": {
|
||||
"name": "fcm_token",
|
||||
"type": "text",
|
||||
@@ -501,58 +357,6 @@
|
||||
"checkConstraints": {},
|
||||
"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": {
|
||||
"name": "profile",
|
||||
"schema": "",
|
||||
@@ -582,6 +386,13 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"gender": {
|
||||
"name": "gender",
|
||||
"type": "gender",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"city_id": {
|
||||
"name": "city_id",
|
||||
"type": "uuid",
|
||||
@@ -617,6 +428,12 @@
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"active_chat_id": {
|
||||
"name": "active_chat_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
@@ -662,15 +479,67 @@
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"profile_user_id_unique": {
|
||||
"name": "profile_user_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"user_id"
|
||||
]
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"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": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
@@ -772,14 +641,14 @@
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"source_user": {
|
||||
"name": "source_user",
|
||||
"source_profile_id": {
|
||||
"name": "source_profile_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"target_user": {
|
||||
"name": "target_user",
|
||||
"target_profile_id": {
|
||||
"name": "target_profile_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
@@ -801,12 +670,12 @@
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"like_source_user_user_id_fk": {
|
||||
"name": "like_source_user_user_id_fk",
|
||||
"like_source_profile_id_profile_id_fk": {
|
||||
"name": "like_source_profile_id_profile_id_fk",
|
||||
"tableFrom": "like",
|
||||
"tableTo": "user",
|
||||
"tableTo": "profile",
|
||||
"columnsFrom": [
|
||||
"source_user"
|
||||
"source_profile_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
@@ -814,12 +683,12 @@
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"like_target_user_user_id_fk": {
|
||||
"name": "like_target_user_user_id_fk",
|
||||
"like_target_profile_id_profile_id_fk": {
|
||||
"name": "like_target_profile_id_profile_id_fk",
|
||||
"tableFrom": "like",
|
||||
"tableTo": "user",
|
||||
"tableTo": "profile",
|
||||
"columnsFrom": [
|
||||
"target_user"
|
||||
"target_profile_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
@@ -845,14 +714,14 @@
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"user1_id": {
|
||||
"name": "user1_id",
|
||||
"profile1_id": {
|
||||
"name": "profile1_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user2_id": {
|
||||
"name": "user2_id",
|
||||
"profile2_id": {
|
||||
"name": "profile2_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
@@ -867,12 +736,12 @@
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"match_user1_id_user_id_fk": {
|
||||
"name": "match_user1_id_user_id_fk",
|
||||
"match_profile1_id_profile_id_fk": {
|
||||
"name": "match_profile1_id_profile_id_fk",
|
||||
"tableFrom": "match",
|
||||
"tableTo": "user",
|
||||
"tableTo": "profile",
|
||||
"columnsFrom": [
|
||||
"user1_id"
|
||||
"profile1_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
@@ -880,12 +749,190 @@
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"match_user2_id_user_id_fk": {
|
||||
"name": "match_user2_id_user_id_fk",
|
||||
"match_profile2_id_profile_id_fk": {
|
||||
"name": "match_profile2_id_profile_id_fk",
|
||||
"tableFrom": "match",
|
||||
"tableTo": "user",
|
||||
"tableTo": "profile",
|
||||
"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": [
|
||||
"id"
|
||||
@@ -911,14 +958,14 @@
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"user1_id": {
|
||||
"name": "user1_id",
|
||||
"profile1_id": {
|
||||
"name": "profile1_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user2_id": {
|
||||
"name": "user2_id",
|
||||
"profile2_id": {
|
||||
"name": "profile2_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
@@ -950,12 +997,12 @@
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"date_user1_id_user_id_fk": {
|
||||
"name": "date_user1_id_user_id_fk",
|
||||
"date_profile1_id_profile_id_fk": {
|
||||
"name": "date_profile1_id_profile_id_fk",
|
||||
"tableFrom": "date",
|
||||
"tableTo": "user",
|
||||
"tableTo": "profile",
|
||||
"columnsFrom": [
|
||||
"user1_id"
|
||||
"profile1_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
@@ -963,12 +1010,12 @@
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"date_user2_id_user_id_fk": {
|
||||
"name": "date_user2_id_user_id_fk",
|
||||
"date_profile2_id_profile_id_fk": {
|
||||
"name": "date_profile2_id_profile_id_fk",
|
||||
"tableFrom": "date",
|
||||
"tableTo": "user",
|
||||
"tableTo": "profile",
|
||||
"columnsFrom": [
|
||||
"user2_id"
|
||||
"profile2_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
@@ -1033,8 +1080,8 @@
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"source_user": {
|
||||
"name": "source_user",
|
||||
"source_profile_id": {
|
||||
"name": "source_profile_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
@@ -1061,12 +1108,12 @@
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"report_source_user_user_id_fk": {
|
||||
"name": "report_source_user_user_id_fk",
|
||||
"report_source_profile_id_profile_id_fk": {
|
||||
"name": "report_source_profile_id_profile_id_fk",
|
||||
"tableFrom": "report",
|
||||
"tableTo": "user",
|
||||
"tableTo": "profile",
|
||||
"columnsFrom": [
|
||||
"source_user"
|
||||
"source_profile_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
@@ -1083,23 +1130,6 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"name": "user_status",
|
||||
"schema": "public",
|
||||
@@ -1109,6 +1139,23 @@
|
||||
"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": {
|
||||
"name": "like_type",
|
||||
"schema": "public",
|
||||
@@ -1117,6 +1164,23 @@
|
||||
"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": {
|
||||
"name": "report_entity_type",
|
||||
"schema": "public",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,15 +5,8 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1780401435523,
|
||||
"tag": "0000_romantic_morg",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1780403477744,
|
||||
"tag": "0001_brown_marrow",
|
||||
"when": 1780405352119,
|
||||
"tag": "0000_quick_silver_samurai",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
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 mediaTypeEnum = pgEnum('media_type', ['photo', 'voice', 'video']);
|
||||
export const messageMediaTypeEnum = pgEnum('message_media_type', ['photo', 'voice', 'video']);
|
||||
|
||||
export const chat = pgTable('chat', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
profile1Id: uuid('profile1_id').notNull(),
|
||||
profile2Id: uuid('profile2_id').notNull(),
|
||||
profile1Id: uuid('profile1_id')
|
||||
.notNull()
|
||||
.references(() => profile.id, { onDelete: 'cascade' }),
|
||||
profile2Id: uuid('profile2_id')
|
||||
.notNull()
|
||||
.references(() => profile.id, { onDelete: 'cascade' }),
|
||||
status: chatStatusEnum('status').notNull().default('active'),
|
||||
});
|
||||
|
||||
@@ -15,10 +20,12 @@ export const message = pgTable('message', {
|
||||
chatId: uuid('chat_id')
|
||||
.notNull()
|
||||
.references(() => chat.id, { onDelete: 'cascade' }),
|
||||
userId: uuid('user_id').notNull(),
|
||||
profileId: uuid('profile_id')
|
||||
.notNull()
|
||||
.references(() => profile.id, { onDelete: 'cascade' }),
|
||||
text: text('text'),
|
||||
mediaUrl: text('media_url'),
|
||||
mediaType: mediaTypeEnum('media_type'),
|
||||
mediaType: messageMediaTypeEnum('media_type'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
@@ -8,12 +8,12 @@ export const dateStatus = pgTable('date_status', {
|
||||
|
||||
export const date = pgTable('date', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
user1Id: uuid('user1_id')
|
||||
profile1Id: uuid('profile1_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
user2Id: uuid('user2_id')
|
||||
.references(() => profile.id, { onDelete: 'cascade' }),
|
||||
profile2Id: uuid('profile2_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
.references(() => profile.id, { onDelete: 'cascade' }),
|
||||
lat: decimal('lat', { precision: 10, scale: 7 }).notNull(),
|
||||
lng: decimal('lng', { precision: 10, scale: 7 }).notNull(),
|
||||
time: timestamp('time', { withTimezone: true }).notNull(),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export * from './role.schema';
|
||||
export * from './tariff.schema';
|
||||
export * from './city.schema';
|
||||
export * from './chat.schema';
|
||||
export * from './user.schema';
|
||||
export * from './profile.schema';
|
||||
export * from './social.schema';
|
||||
export * from './chat.schema';
|
||||
export * from './date.schema';
|
||||
export * from './report.schema';
|
||||
|
||||
@@ -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 { city, cityDistrict } from './city.schema';
|
||||
|
||||
export const genderEnum = pgEnum('gender', ['male', 'female']);
|
||||
export const profileMediaTypeEnum = pgEnum('profile_media_type', ['photo', 'video', 'audio']);
|
||||
|
||||
export const profile = pgTable('profile', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
name: varchar('name', { length: 100 }).notNull(),
|
||||
birthDate: date('birth_date').notNull(),
|
||||
@@ -19,6 +19,18 @@ export const profile = pgTable('profile', {
|
||||
nation: varchar('nation', { length: 100 }),
|
||||
height: doublePrecision('height'),
|
||||
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', {
|
||||
@@ -34,12 +46,3 @@ export const profileTag = pgTable('profile_tag', {
|
||||
.notNull()
|
||||
.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(),
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
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 report = pgTable('report', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
sourceUser: uuid('source_user')
|
||||
sourceProfileId: uuid('source_profile_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
.references(() => profile.id, { onDelete: 'cascade' }),
|
||||
entityId: uuid('entity_id').notNull(),
|
||||
entityType: reportEntityTypeEnum('entity_type').notNull(),
|
||||
description: text('description'),
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
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 like = pgTable('like', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
sourceUser: uuid('source_user')
|
||||
sourceProfileId: uuid('source_profile_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
targetUser: uuid('target_user')
|
||||
.references(() => profile.id, { onDelete: 'cascade' }),
|
||||
targetProfileId: uuid('target_profile_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
.references(() => profile.id, { onDelete: 'cascade' }),
|
||||
type: likeTypeEnum('type').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const match = pgTable('match', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
user1Id: uuid('user1_id')
|
||||
profile1Id: uuid('profile1_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
user2Id: uuid('user2_id')
|
||||
.references(() => profile.id, { onDelete: 'cascade' }),
|
||||
profile2Id: uuid('profile2_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
.references(() => profile.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@ export const user = pgTable('user', {
|
||||
roleId: uuid('role_id').references(() => role.id, { onDelete: 'set null' }),
|
||||
tariffId: uuid('tariff_id').references(() => tariff.id, { onDelete: 'set null' }),
|
||||
paymentId: uuid('payment_id'),
|
||||
activeChatId: uuid('active_chat_id'),
|
||||
fcmToken: text('fcm_token'),
|
||||
});
|
||||
|
||||
|
||||
@@ -16,10 +16,7 @@ import { ChatService } from '../modules/chat/chat.service';
|
||||
import { SendMessageDto } from '../modules/chat/dto/send-message.dto';
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: {
|
||||
origin: '*',
|
||||
credentials: true,
|
||||
},
|
||||
cors: { origin: '*', credentials: true },
|
||||
namespace: 'chat',
|
||||
})
|
||||
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@@ -27,7 +24,8 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
server: Server;
|
||||
|
||||
private readonly logger = new Logger(ChatGateway.name);
|
||||
private connectedUsers = new Map<string, string>();
|
||||
// profileId → socketId
|
||||
private connectedProfiles = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
@@ -41,28 +39,30 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
client.handshake.auth?.token ||
|
||||
client.handshake.headers?.authorization?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
if (!token) { client.disconnect(); return; }
|
||||
|
||||
const payload = this.jwtService.verify(token, {
|
||||
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;
|
||||
this.connectedUsers.set(payload.sub, client.id);
|
||||
this.logger.log(`User ${payload.sub} connected via WebSocket`);
|
||||
client.data.profileId = profileId;
|
||||
this.connectedProfiles.set(profileId, client.id);
|
||||
this.logger.log(`Profile ${profileId} (user ${payload.sub}) connected`);
|
||||
} catch {
|
||||
client.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket) {
|
||||
const userId = client.data.userId;
|
||||
if (userId) {
|
||||
this.connectedUsers.delete(userId);
|
||||
this.logger.log(`User ${userId} disconnected`);
|
||||
const profileId = client.data.profileId;
|
||||
if (profileId) {
|
||||
this.connectedProfiles.delete(profileId);
|
||||
this.logger.log(`Profile ${profileId} disconnected`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,8 +71,7 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() data: { chatId: string },
|
||||
) {
|
||||
const userId = client.data.userId;
|
||||
if (!userId) throw new WsException('Unauthorized');
|
||||
if (!client.data.profileId) throw new WsException('Unauthorized');
|
||||
await client.join(`chat:${data.chatId}`);
|
||||
return { event: 'joined_chat', chatId: data.chatId };
|
||||
}
|
||||
@@ -91,12 +90,11 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() data: { chatId: string } & SendMessageDto,
|
||||
) {
|
||||
const userId = client.data.userId;
|
||||
if (!userId) throw new WsException('Unauthorized');
|
||||
const { userId, profileId } = client.data;
|
||||
if (!userId || !profileId) throw new WsException('Unauthorized');
|
||||
|
||||
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);
|
||||
return newMessage;
|
||||
}
|
||||
@@ -106,17 +104,12 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() data: { chatId: string; isTyping: boolean },
|
||||
) {
|
||||
const userId = client.data.userId;
|
||||
client.to(`chat:${data.chatId}`).emit('user_typing', {
|
||||
userId,
|
||||
isTyping: data.isTyping,
|
||||
});
|
||||
const { profileId } = client.data;
|
||||
client.to(`chat:${data.chatId}`).emit('user_typing', { profileId, isTyping: data.isTyping });
|
||||
}
|
||||
|
||||
emitToUser(userId: string, event: string, data: any) {
|
||||
const socketId = this.connectedUsers.get(userId);
|
||||
if (socketId) {
|
||||
this.server.to(socketId).emit(event, data);
|
||||
}
|
||||
emitToProfile(profileId: string, event: string, data: any) {
|
||||
const socketId = this.connectedProfiles.get(profileId);
|
||||
if (socketId) this.server.to(socketId).emit(event, data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { ChatService } from './chat.service';
|
||||
import { CreateChatDto } from './dto/create-chat.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')
|
||||
@ApiBearerAuth()
|
||||
@@ -24,46 +17,56 @@ export class ChatController {
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Open a chat for a match' })
|
||||
createChat(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() dto: CreateChatDto,
|
||||
) {
|
||||
@ApiCreatedResponse({ type: ChatDto })
|
||||
createChat(@CurrentUser('id') userId: string, @Body() dto: CreateChatDto) {
|
||||
return this.chatService.createChat(userId, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get my active chats' })
|
||||
getMyChats(@CurrentUser('id') userId: string) {
|
||||
return this.chatService.getMyChats(userId);
|
||||
@ApiOperation({ summary: 'Get active chats for a profile' })
|
||||
@ApiOkResponse({ type: [ChatDto] })
|
||||
getChats(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query('profileId') profileId: string,
|
||||
) {
|
||||
return this.chatService.getChatsForProfile(userId, profileId);
|
||||
}
|
||||
|
||||
@Get(':chatId/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(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query('profileId') profileId: string,
|
||||
@Param('chatId') chatId: string,
|
||||
@Query('page') page = 1,
|
||||
@Query('limit') limit = 50,
|
||||
) {
|
||||
return this.chatService.getChatMessages(userId, chatId, +page, +limit);
|
||||
return this.chatService.getChatMessages(userId, profileId, chatId, +page, +limit);
|
||||
}
|
||||
|
||||
@Post(':chatId/messages')
|
||||
@ApiOperation({ summary: 'Send a message' })
|
||||
@ApiCreatedResponse({ type: MessageDto })
|
||||
sendMessage(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query('profileId') profileId: string,
|
||||
@Param('chatId') chatId: string,
|
||||
@Body() dto: SendMessageDto,
|
||||
) {
|
||||
return this.chatService.sendMessage(userId, chatId, dto);
|
||||
return this.chatService.sendMessage(userId, profileId, chatId, dto);
|
||||
}
|
||||
|
||||
@Delete(':chatId')
|
||||
@ApiOperation({ summary: 'Close a chat' })
|
||||
@ApiOkResponse({ type: MessageResponseDto })
|
||||
closeChat(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query('profileId') profileId: string,
|
||||
@Param('chatId') chatId: string,
|
||||
) {
|
||||
return this.chatService.closeChat(userId, chatId);
|
||||
return this.chatService.closeChat(userId, profileId, chatId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,13 +19,15 @@ export class ChatService {
|
||||
) {}
|
||||
|
||||
async createChat(userId: string, dto: CreateChatDto) {
|
||||
await this.assertProfileOwnership(userId, dto.profileId);
|
||||
|
||||
const [foundMatch] = await this.drizzleService.db
|
||||
.select()
|
||||
.from(match)
|
||||
.where(
|
||||
and(
|
||||
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);
|
||||
@@ -37,50 +39,46 @@ export class ChatService {
|
||||
.from(chat)
|
||||
.where(
|
||||
or(
|
||||
and(
|
||||
eq(chat.profile1Id, foundMatch.user1Id),
|
||||
eq(chat.profile2Id, foundMatch.user2Id),
|
||||
),
|
||||
and(
|
||||
eq(chat.profile1Id, foundMatch.user2Id),
|
||||
eq(chat.profile2Id, foundMatch.user1Id),
|
||||
),
|
||||
and(eq(chat.profile1Id, foundMatch.profile1Id), eq(chat.profile2Id, foundMatch.profile2Id)),
|
||||
and(eq(chat.profile1Id, foundMatch.profile2Id), eq(chat.profile2Id, foundMatch.profile1Id)),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingChat.length > 0) return existingChat[0];
|
||||
|
||||
const currentUser = await this.drizzleService.db
|
||||
.select({ activeChatId: user.activeChatId })
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
const [currentProfile] = await this.drizzleService.db
|
||||
.select({ activeChatId: profile.activeChatId })
|
||||
.from(profile)
|
||||
.where(eq(profile.id, dto.profileId))
|
||||
.limit(1);
|
||||
|
||||
if (currentUser[0]?.activeChatId) {
|
||||
if (currentProfile?.activeChatId) {
|
||||
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
|
||||
.insert(chat)
|
||||
.values({
|
||||
profile1Id: foundMatch.user1Id,
|
||||
profile2Id: foundMatch.user2Id,
|
||||
profile1Id: foundMatch.profile1Id,
|
||||
profile2Id: foundMatch.profile2Id,
|
||||
status: 'active',
|
||||
} as any)
|
||||
.returning();
|
||||
|
||||
await this.drizzleService.db
|
||||
.update(user)
|
||||
.update(profile)
|
||||
.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;
|
||||
}
|
||||
|
||||
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
|
||||
.select()
|
||||
.from(chat)
|
||||
@@ -88,7 +86,7 @@ export class ChatService {
|
||||
.limit(1);
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -98,26 +96,29 @@ export class ChatService {
|
||||
.where(eq(chat.id, chatId));
|
||||
|
||||
await this.drizzleService.db
|
||||
.update(user)
|
||||
.update(profile)
|
||||
.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' };
|
||||
}
|
||||
|
||||
async getMyChats(userId: string) {
|
||||
async getChatsForProfile(userId: string, profileId: string) {
|
||||
await this.assertProfileOwnership(userId, profileId);
|
||||
return this.drizzleService.db
|
||||
.select()
|
||||
.from(chat)
|
||||
.where(
|
||||
and(
|
||||
or(eq(chat.profile1Id, userId), eq(chat.profile2Id, userId)),
|
||||
or(eq(chat.profile1Id, profileId), eq(chat.profile2Id, profileId)),
|
||||
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
|
||||
.select()
|
||||
.from(chat)
|
||||
@@ -125,7 +126,7 @@ export class ChatService {
|
||||
.limit(1);
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -139,7 +140,9 @@ export class ChatService {
|
||||
.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
|
||||
.select()
|
||||
.from(chat)
|
||||
@@ -147,7 +150,7 @@ export class ChatService {
|
||||
.limit(1);
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -159,31 +162,50 @@ export class ChatService {
|
||||
.insert(message)
|
||||
.values({
|
||||
chatId,
|
||||
userId,
|
||||
profileId,
|
||||
text: dto.text || null,
|
||||
mediaUrl: dto.mediaUrl || null,
|
||||
mediaType: dto.mediaType || null,
|
||||
} as any)
|
||||
.returning();
|
||||
|
||||
const recipientId =
|
||||
foundChat.profile1Id === userId ? foundChat.profile2Id : foundChat.profile1Id;
|
||||
const recipientProfileId =
|
||||
foundChat.profile1Id === profileId ? foundChat.profile2Id : foundChat.profile1Id;
|
||||
|
||||
const [recipient] = await this.drizzleService.db
|
||||
.select({ fcmToken: user.fcmToken })
|
||||
.from(user)
|
||||
.where(eq(user.id, recipientId))
|
||||
const [recipientProfile] = await this.drizzleService.db
|
||||
.select({ userId: profile.userId })
|
||||
.from(profile)
|
||||
.where(eq(profile.id, recipientProfileId))
|
||||
.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(
|
||||
recipient.fcmToken,
|
||||
recipientUser.fcmToken,
|
||||
'New message',
|
||||
dto.text?.substring(0, 100) || 'Media message',
|
||||
{ chatId, messageId: newMessage.id, type: 'message' },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
18
src/modules/chat/dto/chat-response.dto.ts
Normal file
18
src/modules/chat/dto/chat-response.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateChatDto {
|
||||
@ApiProperty({ description: 'Your profile ID' })
|
||||
@IsUUID()
|
||||
profileId: string;
|
||||
|
||||
@ApiProperty({ description: 'Match ID to open chat for' })
|
||||
@IsUUID()
|
||||
matchId: string;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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 { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||
import { CitiesService } from './cities.service';
|
||||
import { CityResponseDto, DistrictResponseDto } from './dto/city-response.dto';
|
||||
import { CreateCityDto, CreateDistrictDto } from './dto/create-city.dto';
|
||||
|
||||
@ApiTags('cities')
|
||||
@Controller('cities')
|
||||
@@ -14,6 +16,7 @@ export class CitiesController {
|
||||
@Public()
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get all cities' })
|
||||
@ApiOkResponse({ type: [CityResponseDto] })
|
||||
findAll() {
|
||||
return this.citiesService.findAll();
|
||||
}
|
||||
@@ -21,6 +24,7 @@ export class CitiesController {
|
||||
@Public()
|
||||
@Get(':cityId/districts')
|
||||
@ApiOperation({ summary: 'Get districts for a city' })
|
||||
@ApiOkResponse({ type: [DistrictResponseDto] })
|
||||
findDistricts(@Param('cityId') cityId: string) {
|
||||
return this.citiesService.findDistricts(cityId);
|
||||
}
|
||||
@@ -30,7 +34,8 @@ export class CitiesController {
|
||||
@Roles('admin')
|
||||
@Post()
|
||||
@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);
|
||||
}
|
||||
|
||||
@@ -39,7 +44,8 @@ export class CitiesController {
|
||||
@Roles('admin')
|
||||
@Post(':cityId/districts')
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
14
src/modules/cities/dto/city-response.dto.ts
Normal file
14
src/modules/cities/dto/city-response.dto.ts
Normal 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;
|
||||
}
|
||||
17
src/modules/cities/dto/create-city.dto.ts
Normal file
17
src/modules/cities/dto/create-city.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { DatesService } from './dates.service';
|
||||
import { CreateDateDto } from './dto/create-date.dto';
|
||||
import { UpdateDateStatusDto } from './dto/update-date-status.dto';
|
||||
import { DateDto, DateStatusDto, DateWithStatusDto } from './dto/dates-response.dto';
|
||||
|
||||
@ApiTags('dates')
|
||||
@ApiBearerAuth()
|
||||
@@ -14,32 +15,37 @@ export class DatesController {
|
||||
constructor(private readonly datesService: DatesService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Propose a date/meetup' })
|
||||
create(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() dto: CreateDateDto,
|
||||
) {
|
||||
@ApiOperation({ summary: 'Propose a meetup' })
|
||||
@ApiCreatedResponse({ type: DateDto })
|
||||
create(@CurrentUser('id') userId: string, @Body() dto: CreateDateDto) {
|
||||
return this.datesService.create(userId, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get my dates' })
|
||||
getMyDates(@CurrentUser('id') userId: string) {
|
||||
return this.datesService.getMyDates(userId);
|
||||
@ApiOperation({ summary: 'Get dates for a profile' })
|
||||
@ApiOkResponse({ type: [DateWithStatusDto] })
|
||||
getDates(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query('profileId') profileId: string,
|
||||
) {
|
||||
return this.datesService.getForProfile(userId, profileId);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@ApiOperation({ summary: 'Update date status' })
|
||||
@ApiOkResponse({ type: DateDto })
|
||||
updateStatus(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query('profileId') profileId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateDateStatusDto,
|
||||
) {
|
||||
return this.datesService.updateStatus(userId, id, dto);
|
||||
return this.datesService.updateStatus(userId, profileId, id, dto);
|
||||
}
|
||||
|
||||
@Get('statuses')
|
||||
@ApiOperation({ summary: 'Get available date statuses' })
|
||||
@ApiOkResponse({ type: [DateStatusDto] })
|
||||
getStatuses() {
|
||||
return this.datesService.getStatuses();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { and, eq, or } from 'drizzle-orm';
|
||||
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 { UpdateDateStatusDto } from './dto/update-date-status.dto';
|
||||
|
||||
@@ -10,8 +10,9 @@ export class DatesService {
|
||||
constructor(private readonly drizzleService: DrizzleService) {}
|
||||
|
||||
async create(userId: string, dto: CreateDateDto) {
|
||||
let statusId = dto.statusId;
|
||||
await this.assertProfileOwnership(userId, dto.profileId);
|
||||
|
||||
let statusId = dto.statusId;
|
||||
if (!statusId) {
|
||||
const [pending] = await this.drizzleService.db
|
||||
.select({ id: dateStatus.id })
|
||||
@@ -24,8 +25,8 @@ export class DatesService {
|
||||
const [newDate] = await this.drizzleService.db
|
||||
.insert(date)
|
||||
.values({
|
||||
user1Id: userId,
|
||||
user2Id: dto.partnerId,
|
||||
profile1Id: dto.profileId,
|
||||
profile2Id: dto.partnerProfileId,
|
||||
lat: dto.lat.toString(),
|
||||
lng: dto.lng.toString(),
|
||||
time: new Date(dto.time),
|
||||
@@ -36,16 +37,19 @@ export class DatesService {
|
||||
return newDate;
|
||||
}
|
||||
|
||||
async getMyDates(userId: string) {
|
||||
async getForProfile(userId: string, profileId: string) {
|
||||
await this.assertProfileOwnership(userId, profileId);
|
||||
return this.drizzleService.db
|
||||
.select()
|
||||
.from(date)
|
||||
.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);
|
||||
}
|
||||
|
||||
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
|
||||
.select()
|
||||
.from(date)
|
||||
@@ -53,7 +57,7 @@ export class DatesService {
|
||||
.limit(1);
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -69,4 +73,15 @@ export class DatesService {
|
||||
async getStatuses() {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,13 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsDateString, IsNumber, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateDateDto {
|
||||
@ApiProperty()
|
||||
@ApiProperty({ description: 'Your profile ID' })
|
||||
@IsUUID()
|
||||
partnerId: string;
|
||||
profileId: string;
|
||||
|
||||
@ApiProperty({ description: 'Partner profile ID' })
|
||||
@IsUUID()
|
||||
partnerProfileId: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
|
||||
21
src/modules/dates/dto/dates-response.dto.ts
Normal file
21
src/modules/dates/dto/dates-response.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsNumber, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||
|
||||
export class FeedFilterDto {
|
||||
@ApiProperty({ description: 'Your profile ID' })
|
||||
@IsUUID()
|
||||
profileId: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@@ -18,24 +22,16 @@ export class FeedFilterDto {
|
||||
@Max(50)
|
||||
limit?: number = 20;
|
||||
|
||||
@ApiPropertyOptional({ description: 'City UUID filter' })
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
cityId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'District UUID filter' })
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
districtId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Search radius in km' })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(500)
|
||||
radiusKm?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@@ -50,12 +46,12 @@ export class FeedFilterDto {
|
||||
@Max(100)
|
||||
ageMax?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Search keyword in description/name' })
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
keyword?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Tag IDs to filter' })
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID(undefined, { each: true })
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
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 { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { FeedFilterDto } from './dto/feed-filter.dto';
|
||||
import { FeedService } from './feed.service';
|
||||
import { ProfileResponseDto } from '../profiles/dto/profile-response.dto';
|
||||
|
||||
@ApiTags('feed')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiExtraModels(ProfileResponseDto)
|
||||
@Controller('feed')
|
||||
export class FeedController {
|
||||
constructor(private readonly feedService: FeedService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get filtered feed of profiles' })
|
||||
getFeed(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query() filter: FeedFilterDto,
|
||||
) {
|
||||
@ApiOperation({ summary: 'Get filtered feed (requires profileId)' })
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { 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';
|
||||
|
||||
@Injectable()
|
||||
export class FeedService {
|
||||
constructor(private readonly drizzleService: DrizzleService) {}
|
||||
|
||||
async getFeed(currentUserId: string, filter: FeedFilterDto) {
|
||||
const { page = 1, limit = 20, cityId, districtId, ageMin, ageMax, keyword, tagIds } = filter;
|
||||
async getFeed(userId: string, filter: FeedFilterDto) {
|
||||
const { profileId, page = 1, limit = 20, cityId, districtId, ageMin, ageMax, keyword, tagIds } = filter;
|
||||
await this.assertProfileOwnership(userId, profileId);
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const alreadyInteracted = await this.drizzleService.db
|
||||
.select({ targetUser: like.targetUser })
|
||||
.select({ targetProfileId: like.targetProfileId })
|
||||
.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[] = [
|
||||
ne(profile.userId, currentUserId),
|
||||
ne(profile.id, profileId),
|
||||
ne(user.status, 'banned'),
|
||||
];
|
||||
|
||||
if (interactedIds.length > 0) {
|
||||
conditions.push(notInArray(profile.userId, interactedIds));
|
||||
conditions.push(notInArray(profile.id, interactedIds));
|
||||
}
|
||||
|
||||
if (cityId) conditions.push(eq(profile.cityId, cityId));
|
||||
@@ -52,19 +54,15 @@ export class FeedService {
|
||||
);
|
||||
}
|
||||
|
||||
let profileIds: string[] | null = null;
|
||||
|
||||
if (tagIds?.length) {
|
||||
const tagMatches = await this.drizzleService.db
|
||||
.select({ profileId: profileTag.profileId })
|
||||
.from(profileTag)
|
||||
.where(inArray(profileTag.tagId, tagIds));
|
||||
profileIds = tagMatches.map((r) => r.profileId);
|
||||
if (profileIds.length > 0) {
|
||||
conditions.push(inArray(profile.id, profileIds));
|
||||
} else {
|
||||
return { data: [], total: 0, page, limit };
|
||||
}
|
||||
|
||||
const matchedIds = tagMatches.map((r) => r.profileId);
|
||||
if (matchedIds.length === 0) return { items: [], page, limit };
|
||||
conditions.push(inArray(profile.id, matchedIds));
|
||||
}
|
||||
|
||||
const rows = await this.drizzleService.db
|
||||
@@ -73,6 +71,7 @@ export class FeedService {
|
||||
userId: profile.userId,
|
||||
name: profile.name,
|
||||
birthDate: profile.birthDate,
|
||||
gender: profile.gender,
|
||||
cityId: profile.cityId,
|
||||
districtId: profile.districtId,
|
||||
description: profile.description,
|
||||
@@ -89,18 +88,23 @@ export class FeedService {
|
||||
|
||||
const enriched = await Promise.all(
|
||||
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 })
|
||||
.from(profileTag)
|
||||
.innerJoin(tag, eq(tag.id, profileTag.tagId))
|
||||
.where(eq(profileTag.profileId, p.id));
|
||||
|
||||
const age = this.calculateAge(p.birthDate);
|
||||
return { ...p, age, tags };
|
||||
.where(eq(profileTag.profileId, p.id)),
|
||||
this.drizzleService.db
|
||||
.select()
|
||||
.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 {
|
||||
@@ -111,4 +115,15 @@ export class FeedService {
|
||||
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) 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');
|
||||
}
|
||||
}
|
||||
|
||||
6
src/modules/greetings/dto/greeting-response.dto.ts
Normal file
6
src/modules/greetings/dto/greeting-response.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class GreetingDto {
|
||||
@ApiProperty() id: string;
|
||||
@ApiProperty() text: string;
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
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 { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||
import { GreetingsService } from './greetings.service';
|
||||
import { GreetingDto } from './dto/greeting-response.dto';
|
||||
import { MessageResponseDto } from '../../common/dto/message-response.dto';
|
||||
|
||||
@ApiTags('greetings')
|
||||
@Controller('greetings')
|
||||
@@ -14,6 +16,7 @@ export class GreetingsController {
|
||||
@Public()
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get all greeting phrases' })
|
||||
@ApiOkResponse({ type: [GreetingDto] })
|
||||
findAll() {
|
||||
return this.greetingsService.findAll();
|
||||
}
|
||||
@@ -23,6 +26,8 @@ export class GreetingsController {
|
||||
@Roles('admin')
|
||||
@Post()
|
||||
@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) {
|
||||
return this.greetingsService.create(text);
|
||||
}
|
||||
@@ -32,6 +37,7 @@ export class GreetingsController {
|
||||
@Roles('admin')
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete greeting phrase (admin only)' })
|
||||
@ApiOkResponse({ type: MessageResponseDto })
|
||||
delete(@Param('id') id: string) {
|
||||
return this.greetingsService.delete(id);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,13 @@ import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEnum, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateLikeDto {
|
||||
@ApiProperty()
|
||||
@ApiProperty({ description: 'Your profile ID' })
|
||||
@IsUUID()
|
||||
targetUserId: string;
|
||||
sourceProfileId: string;
|
||||
|
||||
@ApiProperty({ description: 'Target profile ID' })
|
||||
@IsUUID()
|
||||
targetProfileId: string;
|
||||
|
||||
@ApiProperty({ enum: ['like', 'dislike'] })
|
||||
@IsEnum(['like', 'dislike'])
|
||||
|
||||
21
src/modules/likes/dto/likes-response.dto.ts
Normal file
21
src/modules/likes/dto/likes-response.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CreateLikeDto } from './dto/create-like.dto';
|
||||
import { CreateLikeResponseDto, MatchDto } from './dto/likes-response.dto';
|
||||
import { LikesService } from './likes.service';
|
||||
|
||||
@ApiTags('likes')
|
||||
@@ -13,17 +14,19 @@ export class LikesController {
|
||||
constructor(private readonly likesService: LikesService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Like or dislike a user' })
|
||||
createLike(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() dto: CreateLikeDto,
|
||||
) {
|
||||
@ApiOperation({ summary: 'Like or dislike a profile' })
|
||||
@ApiCreatedResponse({ type: CreateLikeResponseDto })
|
||||
createLike(@CurrentUser('id') userId: string, @Body() dto: CreateLikeDto) {
|
||||
return this.likesService.createLike(userId, dto);
|
||||
}
|
||||
|
||||
@Get('matches')
|
||||
@ApiOperation({ summary: 'Get my matches' })
|
||||
getMyMatches(@CurrentUser('id') userId: string) {
|
||||
return this.likesService.getMyMatches(userId);
|
||||
@ApiOperation({ summary: 'Get matches for a profile' })
|
||||
@ApiOkResponse({ type: [MatchDto] })
|
||||
getMyMatches(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query('profileId') profileId: string,
|
||||
) {
|
||||
return this.likesService.getMyMatches(userId, profileId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { and, eq, or } from 'drizzle-orm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
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 { RedisService } from '../../redis/redis.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { CreateLikeDto } from './dto/create-like.dto';
|
||||
|
||||
@Injectable()
|
||||
@@ -19,16 +16,18 @@ export class LikesService {
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async createLike(sourceUserId: string, dto: CreateLikeDto) {
|
||||
if (sourceUserId === dto.targetUserId) {
|
||||
async createLike(userId: string, dto: CreateLikeDto) {
|
||||
await this.assertProfileOwnership(userId, dto.sourceProfileId);
|
||||
|
||||
if (dto.sourceProfileId === dto.targetProfileId) {
|
||||
throw new BadRequestException('Cannot like yourself');
|
||||
}
|
||||
|
||||
const maxMatches = this.configService.get<number>('app.maxMatchesBeforePause');
|
||||
const activeMatchesCount = await this.getActiveMatchesCount(sourceUserId);
|
||||
const activeMatchesCount = await this.getMatchesCount(dto.sourceProfileId);
|
||||
if (activeMatchesCount >= maxMatches) {
|
||||
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)
|
||||
.where(
|
||||
and(
|
||||
eq(like.sourceUser, sourceUserId),
|
||||
eq(like.targetUser, dto.targetUserId),
|
||||
eq(like.sourceProfileId, dto.sourceProfileId),
|
||||
eq(like.targetProfileId, dto.targetProfileId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
throw new BadRequestException('Already reacted to this user');
|
||||
}
|
||||
if (existing.length > 0) throw new BadRequestException('Already reacted to this profile');
|
||||
|
||||
const [newLike] = await this.drizzleService.db
|
||||
.insert(like)
|
||||
.values({
|
||||
sourceUser: sourceUserId,
|
||||
targetUser: dto.targetUserId,
|
||||
sourceProfileId: dto.sourceProfileId,
|
||||
targetProfileId: dto.targetProfileId,
|
||||
type: dto.type,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (dto.type === 'like') {
|
||||
return this.checkAndCreateMatch(sourceUserId, dto.targetUserId, newLike);
|
||||
return this.checkAndCreateMatch(dto.sourceProfileId, dto.targetProfileId, newLike);
|
||||
}
|
||||
|
||||
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
|
||||
.select()
|
||||
.from(like)
|
||||
.where(
|
||||
and(
|
||||
eq(like.sourceUser, userId2),
|
||||
eq(like.targetUser, userId1),
|
||||
eq(like.sourceProfileId, profileId2),
|
||||
eq(like.targetProfileId, profileId1),
|
||||
eq(like.type, 'like'),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (reverseLike.length === 0) {
|
||||
return { like: newLike, match: null };
|
||||
}
|
||||
if (reverseLike.length === 0) return { like: newLike, match: null };
|
||||
|
||||
const existingMatch = await this.drizzleService.db
|
||||
.select()
|
||||
.from(match)
|
||||
.where(
|
||||
or(
|
||||
and(eq(match.user1Id, userId1), eq(match.user2Id, userId2)),
|
||||
and(eq(match.user1Id, userId2), eq(match.user2Id, userId1)),
|
||||
and(eq(match.profile1Id, profileId1), eq(match.profile2Id, profileId2)),
|
||||
and(eq(match.profile1Id, profileId2), eq(match.profile2Id, profileId1)),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingMatch.length > 0) {
|
||||
return { like: newLike, match: existingMatch[0] };
|
||||
}
|
||||
if (existingMatch.length > 0) return { like: newLike, match: existingMatch[0] };
|
||||
|
||||
const [newMatch] = await this.drizzleService.db
|
||||
.insert(match)
|
||||
.values({ user1Id: userId1, user2Id: userId2 })
|
||||
.values({ profile1Id: profileId1, profile2Id: profileId2 })
|
||||
.returning();
|
||||
|
||||
await this.notifyMatch(userId1, userId2, newMatch.id);
|
||||
await this.notifyMatch(profileId1, profileId2, newMatch.id);
|
||||
|
||||
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
|
||||
.select({ id: match.id })
|
||||
.from(match)
|
||||
.where(
|
||||
or(eq(match.user1Id, userId), eq(match.user2Id, userId)),
|
||||
);
|
||||
.where(or(eq(match.profile1Id, profileId), eq(match.profile2Id, profileId)));
|
||||
return matches.length;
|
||||
}
|
||||
|
||||
private async notifyMatch(userId1: string, userId2: string, matchId: string) {
|
||||
const users = await this.drizzleService.db
|
||||
.select({ id: user.id, fcmToken: user.fcmToken })
|
||||
.from(user)
|
||||
.where(or(eq(user.id, userId1), eq(user.id, userId2)));
|
||||
private async notifyMatch(profileId1: string, profileId2: string, matchId: string) {
|
||||
const profiles = await this.drizzleService.db
|
||||
.select({ userId: profile.userId })
|
||||
.from(profile)
|
||||
.where(or(eq(profile.id, profileId1), eq(profile.id, profileId2)));
|
||||
|
||||
for (const u of users) {
|
||||
if (u.fcmToken) {
|
||||
for (const p of profiles) {
|
||||
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(
|
||||
u.fcmToken,
|
||||
'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) {
|
||||
return this.drizzleService.db
|
||||
.select()
|
||||
.from(match)
|
||||
.where(
|
||||
or(eq(match.user1Id, userId), eq(match.user2Id, userId)),
|
||||
)
|
||||
.orderBy(match.createdAt);
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +1,54 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Controller, Delete, Get, Param, Post, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiConsumes, ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { MediaService } from './media.service';
|
||||
import { MediaItemDto } from '../profiles/dto/profile-response.dto';
|
||||
import { MessageResponseDto } from '../../common/dto/message-response.dto';
|
||||
|
||||
@ApiTags('media')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('media')
|
||||
@Controller('profiles/:profileId/media')
|
||||
export class MediaController {
|
||||
constructor(private readonly mediaService: MediaService) {}
|
||||
|
||||
@Post('upload')
|
||||
@ApiOperation({ summary: 'Upload photo or video' })
|
||||
@ApiOperation({ summary: 'Upload photo / video / audio to profile' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiCreatedResponse({ type: MediaItemDto })
|
||||
async upload(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('profileId') profileId: string,
|
||||
@Req() req: FastifyRequest,
|
||||
@Query('type') type: 'photo' | 'video' = 'photo',
|
||||
@Query('type') type: 'photo' | 'video' | 'audio' = 'photo',
|
||||
) {
|
||||
const data = await (req as any).file();
|
||||
if (!data) {
|
||||
throw new Error('No file provided');
|
||||
}
|
||||
if (!data) throw new Error('No file provided');
|
||||
const buffer = await data.toBuffer();
|
||||
return this.mediaService.uploadMedia(
|
||||
return this.mediaService.upload(
|
||||
userId,
|
||||
profileId,
|
||||
{ buffer, originalname: data.filename, mimetype: data.mimetype },
|
||||
type,
|
||||
);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get my media' })
|
||||
getMyMedia(@CurrentUser('id') userId: string) {
|
||||
return this.mediaService.getByUserId(userId);
|
||||
@ApiOperation({ summary: 'Get all media for a profile' })
|
||||
@ApiOkResponse({ type: [MediaItemDto] })
|
||||
getMedia(@Param('profileId') profileId: string) {
|
||||
return this.mediaService.getByProfileId(profileId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete media' })
|
||||
deleteMedia(@CurrentUser('id') userId: string, @Param('id') id: string) {
|
||||
return this.mediaService.deleteMedia(userId, id);
|
||||
@Delete(':mediaId')
|
||||
@ApiOperation({ summary: 'Delete media item' })
|
||||
@ApiOkResponse({ type: MessageResponseDto })
|
||||
deleteMedia(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('mediaId') mediaId: string,
|
||||
) {
|
||||
return this.mediaService.delete(userId, mediaId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { DrizzleService } from '../../database/drizzle.service';
|
||||
import { media } from '../../database/schema';
|
||||
import { profile, profileMedia } from '../../database/schema';
|
||||
import { StorageService } from '../../storage/storage.service';
|
||||
|
||||
@Injectable()
|
||||
@@ -11,50 +11,75 @@ export class MediaService {
|
||||
private readonly storageService: StorageService,
|
||||
) {}
|
||||
|
||||
async uploadMedia(
|
||||
async upload(
|
||||
userId: string,
|
||||
profileId: 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(
|
||||
file.buffer,
|
||||
file.originalname,
|
||||
file.mimetype,
|
||||
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
|
||||
.insert(media)
|
||||
.values({ userId, path: publicUrl, type })
|
||||
.insert(profileMedia)
|
||||
.values({ profileId, path, type, sortOrder: nextOrder } as any)
|
||||
.returning();
|
||||
|
||||
return newMedia;
|
||||
}
|
||||
|
||||
async getByUserId(userId: string) {
|
||||
async getByProfileId(profileId: string) {
|
||||
return this.drizzleService.db
|
||||
.select()
|
||||
.from(media)
|
||||
.where(eq(media.userId, userId));
|
||||
.from(profileMedia)
|
||||
.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
|
||||
.select()
|
||||
.from(media)
|
||||
.where(eq(media.id, mediaId))
|
||||
.from(profileMedia)
|
||||
.where(eq(profileMedia.id, mediaId))
|
||||
.limit(1);
|
||||
|
||||
if (!found || found.userId !== userId) {
|
||||
throw new NotFoundException('Media not found');
|
||||
}
|
||||
if (!found) throw new NotFoundException('Media not found');
|
||||
|
||||
await this.assertOwnership(userId, found.profileId);
|
||||
|
||||
const objectName = found.path.split('/').slice(-2).join('/');
|
||||
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' };
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
46
src/modules/profiles/dto/profile-response.dto.ts
Normal file
46
src/modules/profiles/dto/profile-response.dto.ts
Normal 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[];
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CreateProfileDto } from './dto/create-profile.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
||||
import { ProfilesService } from './profiles.service';
|
||||
import { MessageResponseDto } from '../../common/dto/message-response.dto';
|
||||
|
||||
@ApiTags('profiles')
|
||||
@ApiBearerAuth()
|
||||
@@ -14,32 +16,41 @@ export class ProfilesController {
|
||||
constructor(private readonly profilesService: ProfilesService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create my profile' })
|
||||
create(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() dto: CreateProfileDto,
|
||||
) {
|
||||
@ApiOperation({ summary: 'Create a new profile (one user can have many)' })
|
||||
@ApiCreatedResponse({ type: ProfileResponseDto })
|
||||
create(@CurrentUser('id') userId: string, @Body() dto: CreateProfileDto) {
|
||||
return this.profilesService.create(userId, dto);
|
||||
}
|
||||
|
||||
@Put()
|
||||
@ApiOperation({ summary: 'Update my profile' })
|
||||
@Get('my')
|
||||
@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(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('profileId') profileId: string,
|
||||
@Body() dto: UpdateProfileDto,
|
||||
) {
|
||||
return this.profilesService.update(userId, dto);
|
||||
return this.profilesService.update(userId, profileId, dto);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@ApiOperation({ summary: 'Get my profile' })
|
||||
getMyProfile(@CurrentUser('id') userId: string) {
|
||||
return this.profilesService.findByUserId(userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Get(':profileId')
|
||||
@ApiOperation({ summary: 'Get profile by ID' })
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.profilesService.findByProfileId(id);
|
||||
@ApiOkResponse({ type: ProfileResponseDto })
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
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 { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
|
||||
@@ -15,14 +10,6 @@ export class ProfilesService {
|
||||
constructor(private readonly drizzleService: DrizzleService) {}
|
||||
|
||||
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
|
||||
.insert(profile)
|
||||
.values({
|
||||
@@ -46,40 +33,36 @@ export class ProfilesService {
|
||||
return this.findByProfileId(newProfile.id);
|
||||
}
|
||||
|
||||
async update(userId: string, dto: UpdateProfileDto) {
|
||||
const [found] = await this.drizzleService.db
|
||||
.select({ id: profile.id })
|
||||
.from(profile)
|
||||
.where(eq(profile.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!found) throw new NotFoundException('Profile not found');
|
||||
async update(userId: string, profileId: string, dto: UpdateProfileDto) {
|
||||
await this.assertOwnership(userId, profileId);
|
||||
|
||||
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
|
||||
.update(profile)
|
||||
.set(fields)
|
||||
.where(eq(profile.id, found.id));
|
||||
.set(updateFields as any)
|
||||
.where(eq(profile.id, profileId));
|
||||
}
|
||||
|
||||
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) {
|
||||
const [found] = await this.drizzleService.db
|
||||
.select({ id: profile.id })
|
||||
async findAllByUserId(userId: string) {
|
||||
const profiles = await this.drizzleService.db
|
||||
.select()
|
||||
.from(profile)
|
||||
.where(eq(profile.userId, userId))
|
||||
.limit(1);
|
||||
.where(eq(profile.userId, userId));
|
||||
|
||||
if (!found) throw new NotFoundException('Profile not found');
|
||||
return this.findByProfileId(found.id);
|
||||
return Promise.all(profiles.map((p) => this.findByProfileId(p.id)));
|
||||
}
|
||||
|
||||
async findByProfileId(profileId: string) {
|
||||
@@ -93,18 +76,43 @@ export class ProfilesService {
|
||||
|
||||
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 })
|
||||
.from(profileTag)
|
||||
.innerJoin(tag, eq(tag.id, profileTag.tagId))
|
||||
.where(eq(profileTag.profileId, profileId));
|
||||
|
||||
const medias = await this.drizzleService.db
|
||||
.where(eq(profileTag.profileId, profileId)),
|
||||
this.drizzleService.db
|
||||
.select()
|
||||
.from(media)
|
||||
.where(eq(media.userId, found.profile.userId));
|
||||
.from(profileMedia)
|
||||
.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[]) {
|
||||
@@ -113,9 +121,9 @@ export class ProfilesService {
|
||||
.where(eq(profileTag.profileId, profileId));
|
||||
|
||||
if (tagIds.length > 0) {
|
||||
await this.drizzleService.db.insert(profileTag).values(
|
||||
tagIds.map((tagId) => ({ profileId, tagId })),
|
||||
);
|
||||
await this.drizzleService.db
|
||||
.insert(profileTag)
|
||||
.values(tagIds.map((tagId) => ({ profileId, tagId })));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateReportDto {
|
||||
@ApiProperty({ description: 'Your profile ID' })
|
||||
@IsUUID()
|
||||
sourceProfileId: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsUUID()
|
||||
entityId: string;
|
||||
|
||||
9
src/modules/reports/dto/report-response.dto.ts
Normal file
9
src/modules/reports/dto/report-response.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
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 { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||
import { CreateReportDto } from './dto/create-report.dto';
|
||||
import { ReportDto } from './dto/report-response.dto';
|
||||
import { ReportsService } from './reports.service';
|
||||
|
||||
@ApiTags('reports')
|
||||
@@ -16,10 +17,8 @@ export class ReportsController {
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Submit a report' })
|
||||
create(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() dto: CreateReportDto,
|
||||
) {
|
||||
@ApiCreatedResponse({ type: ReportDto })
|
||||
create(@CurrentUser('id') userId: string, @Body() dto: CreateReportDto) {
|
||||
return this.reportsService.create(userId, dto);
|
||||
}
|
||||
|
||||
@@ -27,6 +26,7 @@ export class ReportsController {
|
||||
@Roles('admin', 'moderator')
|
||||
@UseGuards(RolesGuard)
|
||||
@ApiOperation({ summary: 'Get all reports (admin/moderator)' })
|
||||
@ApiOkResponse({ type: [ReportDto] })
|
||||
getAll() {
|
||||
return this.reportsService.getAll();
|
||||
}
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DrizzleService } from '../../database/drizzle.service';
|
||||
import { report } from '../../database/schema';
|
||||
import { CreateReportDto } from './dto/create-report.dto';
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
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()
|
||||
export class ReportsService {
|
||||
constructor(private readonly drizzleService: DrizzleService) {}
|
||||
|
||||
async create(userId: string, dto: CreateReportDto) {
|
||||
await this.assertProfileOwnership(userId, dto.sourceProfileId);
|
||||
|
||||
const [newReport] = await this.drizzleService.db
|
||||
.insert(report)
|
||||
.values({
|
||||
sourceUser: userId,
|
||||
sourceProfileId: dto.sourceProfileId,
|
||||
entityId: dto.entityId,
|
||||
entityType: dto.entityType,
|
||||
description: dto.description || null,
|
||||
} as any)
|
||||
.returning();
|
||||
|
||||
return newReport;
|
||||
}
|
||||
|
||||
@@ -25,10 +28,14 @@ export class ReportsService {
|
||||
return this.drizzleService.db.select().from(report).orderBy(report.id);
|
||||
}
|
||||
|
||||
async getByUser(userId: string) {
|
||||
return this.drizzleService.db
|
||||
.select()
|
||||
.from(report)
|
||||
.where(eq(report.sourceUser, userId));
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
6
src/modules/tags/dto/tag-response.dto.ts
Normal file
6
src/modules/tags/dto/tag-response.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class TagResponseDto {
|
||||
@ApiProperty() id: string;
|
||||
@ApiProperty() value: string;
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
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 { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||
import { TagsService } from './tags.service';
|
||||
import { TagResponseDto } from './dto/tag-response.dto';
|
||||
import { MessageResponseDto } from '../../common/dto/message-response.dto';
|
||||
|
||||
@ApiTags('tags')
|
||||
@Controller('tags')
|
||||
@@ -14,6 +16,7 @@ export class TagsController {
|
||||
@Public()
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get all tags' })
|
||||
@ApiOkResponse({ type: [TagResponseDto] })
|
||||
findAll() {
|
||||
return this.tagsService.findAll();
|
||||
}
|
||||
@@ -23,6 +26,8 @@ export class TagsController {
|
||||
@Roles('admin')
|
||||
@Post()
|
||||
@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) {
|
||||
return this.tagsService.create(value);
|
||||
}
|
||||
@@ -32,6 +37,7 @@ export class TagsController {
|
||||
@Roles('admin')
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete tag (admin only)' })
|
||||
@ApiOkResponse({ type: MessageResponseDto })
|
||||
delete(@Param('id') id: string) {
|
||||
return this.tagsService.delete(id);
|
||||
}
|
||||
|
||||
27
src/modules/users/dto/user-response.dto.ts
Normal file
27
src/modules/users/dto/user-response.dto.ts
Normal 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[];
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
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 { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||
import { UsersService } from './users.service';
|
||||
import { MeResponseDto, UserResponseDto } from './dto/user-response.dto';
|
||||
import { MessageResponseDto } from '../../common/dto/message-response.dto';
|
||||
|
||||
@ApiTags('users')
|
||||
@ApiBearerAuth()
|
||||
@@ -14,13 +16,15 @@ export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@Get('me')
|
||||
@ApiOperation({ summary: 'Get current user profile' })
|
||||
@ApiOperation({ summary: 'Get current user with profile list' })
|
||||
@ApiOkResponse({ type: MeResponseDto })
|
||||
getMe(@CurrentUser('id') userId: string) {
|
||||
return this.usersService.getMyProfile(userId);
|
||||
return this.usersService.getMe(userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get user by ID' })
|
||||
@ApiOkResponse({ type: UserResponseDto })
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.usersService.findById(id);
|
||||
}
|
||||
@@ -28,7 +32,8 @@ export class UsersController {
|
||||
@Patch(':id/ban')
|
||||
@Roles('admin', 'moderator')
|
||||
@UseGuards(RolesGuard)
|
||||
@ApiOperation({ summary: 'Ban user (admin/moderator only)' })
|
||||
@ApiOperation({ summary: 'Ban user' })
|
||||
@ApiOkResponse({ type: MessageResponseDto })
|
||||
ban(@Param('id') id: string) {
|
||||
return this.usersService.banUser(id);
|
||||
}
|
||||
@@ -36,7 +41,8 @@ export class UsersController {
|
||||
@Patch(':id/activate')
|
||||
@Roles('admin', 'moderator')
|
||||
@UseGuards(RolesGuard)
|
||||
@ApiOperation({ summary: 'Activate user (admin/moderator only)' })
|
||||
@ApiOperation({ summary: 'Activate user' })
|
||||
@ApiOkResponse({ type: MessageResponseDto })
|
||||
activate(@Param('id') id: string) {
|
||||
return this.usersService.activateUser(id);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { DrizzleService } from '../../database/drizzle.service';
|
||||
import { user, profile, media, role } from '../../database/schema';
|
||||
import { user, profile, role } from '../../database/schema';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
@@ -19,38 +19,23 @@ export class UsersService {
|
||||
return rest;
|
||||
}
|
||||
|
||||
async findByIdWithProfile(id: string) {
|
||||
async getMe(userId: string) {
|
||||
const [found] = await this.drizzleService.db
|
||||
.select()
|
||||
.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))
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!result.length) throw new NotFoundException('User not found');
|
||||
const row = result[0];
|
||||
const { password, ...userFields } = row.user;
|
||||
return { ...userFields, profile: row.profile, role: row.role };
|
||||
}
|
||||
if (!found) throw new NotFoundException('User not found');
|
||||
const { password, ...userFields } = found.user;
|
||||
|
||||
async getMediaByUserId(userId: string) {
|
||||
return this.drizzleService.db
|
||||
.select()
|
||||
.from(media)
|
||||
.where(eq(media.userId, userId));
|
||||
const profiles = await this.drizzleService.db
|
||||
.select({ id: profile.id, name: profile.name, gender: profile.gender })
|
||||
.from(profile)
|
||||
.where(eq(profile.userId, userId));
|
||||
|
||||
return { ...userFields, role: found.role, profiles };
|
||||
}
|
||||
|
||||
async banUser(userId: string) {
|
||||
|
||||
Reference in New Issue
Block a user