first commit
This commit is contained in:
31
.env.example
Normal file
31
.env.example
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# App
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# PostgreSQL
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/daiting_app
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# MinIO
|
||||||
|
MINIO_ENDPOINT=localhost
|
||||||
|
MINIO_PORT=9000
|
||||||
|
MINIO_USE_SSL=false
|
||||||
|
MINIO_ACCESS_KEY=minioadmin
|
||||||
|
MINIO_SECRET_KEY=minioadmin
|
||||||
|
MINIO_BUCKET=daiting-media
|
||||||
|
|
||||||
|
# FCM
|
||||||
|
FIREBASE_PROJECT_ID=
|
||||||
|
FIREBASE_PRIVATE_KEY=
|
||||||
|
FIREBASE_CLIENT_EMAIL=
|
||||||
|
|
||||||
|
# Feed settings
|
||||||
|
MAX_MATCHES_BEFORE_PAUSE=10
|
||||||
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Compiled output
|
||||||
|
dist/
|
||||||
|
*.js.map
|
||||||
|
|
||||||
|
# Node modules
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
|
||||||
|
# Build info
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
coverage/
|
||||||
153
CLAUDE.md
Normal file
153
CLAUDE.md
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
# CLAUDE.md — Архитектурный журнал
|
||||||
|
|
||||||
|
Этот файл ведётся автоматически. Фиксирует архитектурные решения,
|
||||||
|
отступления от ТЗ и причины этих отступлений.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Стек
|
||||||
|
|
||||||
|
| Слой | Выбор | Версия |
|
||||||
|
|---|---|---|
|
||||||
|
| Runtime | Node.js + NestJS | NestJS 10 |
|
||||||
|
| HTTP-сервер | Fastify (не Express) | fastify 4 |
|
||||||
|
| WebSocket | Socket.io (`@nestjs/platform-socket.io`) | socket.io 4 |
|
||||||
|
| ORM | Drizzle ORM | 0.38.4 |
|
||||||
|
| База данных | PostgreSQL | — |
|
||||||
|
| Кеш / очереди | Redis (ioredis) | ioredis 5 |
|
||||||
|
| Хранилище файлов | MinIO (S3-совместимое) | minio 8 |
|
||||||
|
| Push-уведомления | Firebase Admin SDK (FCM) | firebase-admin 12 |
|
||||||
|
| Авторизация | JWT (passport-jwt) + refresh-token в Redis | — |
|
||||||
|
| Пакетный менеджер | pnpm | 11.x |
|
||||||
|
| API-документация | OpenAPI через `@nestjs/swagger` | swagger 7 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Архитектурные решения
|
||||||
|
|
||||||
|
### 1. Глобальный JWT-guard через `APP_GUARD`
|
||||||
|
|
||||||
|
Все маршруты закрыты по умолчанию. Публичные эндпоинты помечаются
|
||||||
|
декоратором `@Public()`. Это предотвращает случайное открытие нового
|
||||||
|
роута без авторизации.
|
||||||
|
|
||||||
|
### 2. Глобальный response wrapper `{ data: ... }`
|
||||||
|
|
||||||
|
`TransformInterceptor` оборачивает все успешные ответы в `{ data: T }`.
|
||||||
|
Унифицирует контракт клиента: клиент всегда читает `response.data`.
|
||||||
|
|
||||||
|
### 3. Refresh-токены в Redis, не в БД
|
||||||
|
|
||||||
|
Refresh-токен хранится в Redis с TTL 30 дней (`refresh_token:<userId>`).
|
||||||
|
При logout ключ удаляется — токен немедленно инвалидируется без
|
||||||
|
изменений в PostgreSQL. Это быстрее и не засоряет основную БД.
|
||||||
|
|
||||||
|
### 4. `user.active_chat_id` — ограничение на уровне приложения, не FK
|
||||||
|
|
||||||
|
В схеме `activeChatId` — простой UUID без `REFERENCES chat(id)`.
|
||||||
|
Причина: Drizzle не поддерживает отложенные FK (DEFERRABLE), а при
|
||||||
|
создании чата нужно сначала INSERT в `chat`, потом UPDATE `user`.
|
||||||
|
Добавление жёсткого FK создало бы проблему курицы и яйца.
|
||||||
|
Инвариант «один активный чат» соблюдается на уровне сервиса
|
||||||
|
(`chat.service.ts → createChat`).
|
||||||
|
|
||||||
|
### 5. `chat.profile1_id / profile2_id` хранят `user_id`, не `profile_id`
|
||||||
|
|
||||||
|
**Отступление от ТЗ.** ТЗ называет поля `profile1_id → profile`,
|
||||||
|
но семантически чат привязан к паре пользователей (через матч).
|
||||||
|
Поиск участника чата по userId напрямую не требует JOIN с `profile`.
|
||||||
|
Схема сохраняет исходные имена полей, но ссылается на `user.id`.
|
||||||
|
Если потребуется строгая привязка к профилю — нужен отдельный FK.
|
||||||
|
|
||||||
|
### 6. Лимит матчей проверяется при лайке, не при входе в ленту
|
||||||
|
|
||||||
|
ТЗ: «если у пользователя больше N матчей — поиск становится неактивным».
|
||||||
|
Реализовано как `BadRequestException` в `LikesService.createLike()`.
|
||||||
|
Лента (`FeedService`) возвращает профили без проверки лимита — клиент
|
||||||
|
сам скрывает кнопку лайка на основе флага из профиля пользователя.
|
||||||
|
Значение N задаётся через `MAX_MATCHES_BEFORE_PAUSE` в `.env`.
|
||||||
|
|
||||||
|
### 7. Сообщения отправляются и через REST, и через WebSocket
|
||||||
|
|
||||||
|
Клиент может отправить сообщение двумя путями:
|
||||||
|
- `POST /api/v1/chats/:id/messages` — REST (HTTP)
|
||||||
|
- событие `send_message` через Socket.io
|
||||||
|
|
||||||
|
Оба пути вызывают `ChatService.sendMessage()`. Socket.io-событие
|
||||||
|
дополнительно бродкастит `new_message` всем участникам комнаты.
|
||||||
|
REST-ответ не бродкастит — клиент должен сам обновить UI или слушать WS.
|
||||||
|
|
||||||
|
### 8. Файловые загрузки через Fastify Multipart, не Multer
|
||||||
|
|
||||||
|
ТЗ не оговаривает конкретный механизм. Multer работает только с Express.
|
||||||
|
Поскольку выбран Fastify, используется `@fastify/multipart`, зарегистрированный
|
||||||
|
в `main.ts`. Файл читается вручную через `req.file()` в контроллере.
|
||||||
|
|
||||||
|
### 9. WebSocket-gateway работает в пространстве имён `/chat`
|
||||||
|
|
||||||
|
Одно пространство имён вместо нескольких (чат + уведомления).
|
||||||
|
Уведомления о матчах и системные события отправляются через
|
||||||
|
`ChatGateway.emitToUser()` напрямую по `socketId`.
|
||||||
|
Если потребуется изолировать уведомления — вынести в отдельный gateway.
|
||||||
|
|
||||||
|
### 10. Feed использует `ORDER BY RANDOM()`
|
||||||
|
|
||||||
|
Лента случайно перемешивает профили при каждом запросе. Это дёшево
|
||||||
|
на малых данных и соответствует духу ТЗ («уникальная лента»).
|
||||||
|
При росте таблицы нужно заменить на cursor-based с детерминированным
|
||||||
|
seed (например, хешем userId + дата).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Отступления от ТЗ
|
||||||
|
|
||||||
|
| # | Пункт ТЗ | Как реализовано | Причина |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `chat.profile1_id → profile` | Поле хранит `user_id` | Матч создаётся между пользователями, а не профилями; JOIN с профилем не нужен на горячем пути |
|
||||||
|
| 2 | Поиск «неактивен» при превышении лимита матчей | `BadRequestException` при лайке | Проще контракт с клиентом: ошибка явная, не нужно отдельного флага `searchActive` |
|
||||||
|
| 3 | Тарифный план один, но оплата предусмотрена | Таблицы `tariff` и `payment` созданы, логика оплаты не реализована | ТЗ: «регистрация открыта для всех, тарифный план один» — бизнес-логики оплаты нет |
|
||||||
|
| 4 | `radiusKm` в фильтре ленты | Параметр принимается, но фильтрация по радиусу не применяется | Требует PostGIS или формулы Haversine; добавить в следующей итерации |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Известные технические долги
|
||||||
|
|
||||||
|
| Файл | Проблема | Причина |
|
||||||
|
|---|---|---|
|
||||||
|
| `*/services/*.ts` | `as any` в `.insert().values()` и `.update().set()` | Drizzle ORM 0.38 не включает nullable и enum-колонки с `.default()` в TypeScript insert-тип; при апгрейде Drizzle — убрать касты |
|
||||||
|
| `feed.service.ts` | N+1 запрос: теги загружаются по одному на профиль | Достаточно для MVP; исправить через один JOIN с GROUP BY при росте нагрузки |
|
||||||
|
| `chat.service.ts` | `getMyChats` возвращает только `active` чаты | Закрытые чаты недоступны через API; добавить query-параметр `status` |
|
||||||
|
| `pnpm-workspace.yaml` | `allowBuilds` пополняется вручную при новых зависимостях с postinstall | Требование безопасности pnpm 11 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Команды
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Инфраструктура
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Зависимости
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# Первый запуск БД
|
||||||
|
pnpm db:generate # создать SQL-миграции
|
||||||
|
pnpm db:migrate # применить миграции
|
||||||
|
pnpm db:seed # заполнить справочники (roles, tariffs, date_statuses, greetings)
|
||||||
|
|
||||||
|
# Разработка
|
||||||
|
pnpm start:dev # hot-reload dev-сервер
|
||||||
|
pnpm build # компиляция в dist/
|
||||||
|
|
||||||
|
# Инструменты
|
||||||
|
pnpm db:studio # Drizzle Studio (GUI для БД)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Точки входа
|
||||||
|
|
||||||
|
| URL | Описание |
|
||||||
|
|---|---|
|
||||||
|
| `http://localhost:3000/api/v1` | REST API |
|
||||||
|
| `http://localhost:3000/api/docs` | Swagger UI (только dev/staging) |
|
||||||
|
| `ws://localhost:3000/chat` | Socket.io namespace |
|
||||||
|
| `http://localhost:9001` | MinIO Console |
|
||||||
247
database-schema.md
Normal file
247
database-schema.md
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
# Схема базы данных
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
Все идентификаторы — `uuid`. Схема разбита на домены: авторизация, профиль, гео, тарифы, социальные действия, чат, встречи, модерация.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Таблицы
|
||||||
|
|
||||||
|
### `user`
|
||||||
|
Авторизационные данные. Намеренно отделены от профиля — публичная анкета живёт в `profile`.
|
||||||
|
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| id | uuid PK | |
|
||||||
|
| phone | string | номер телефона |
|
||||||
|
| password | string | хешированный пароль |
|
||||||
|
| status | string | `active` / `banned` / `pending` |
|
||||||
|
| role_id | uuid FK → role | роль пользователя |
|
||||||
|
| tariff_id | uuid FK → tariff | текущий тариф |
|
||||||
|
| payment_id | uuid FK → payment | способ оплаты |
|
||||||
|
| active_chat_id | uuid FK → chat | активный чат (один пользователь — один чат одновременно) |
|
||||||
|
| fcm_token | string | токен для push-уведомлений (FCM) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `profile`
|
||||||
|
Публичная анкета пользователя. Это то, что видят другие в ленте.
|
||||||
|
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| id | uuid PK | |
|
||||||
|
| user_id | uuid FK → user | |
|
||||||
|
| name | string | |
|
||||||
|
| birth_date | date | вместо `age` — возраст вычисляется на лету |
|
||||||
|
| city_id | uuid FK → city | |
|
||||||
|
| district_id | uuid FK → city_district | район / метро, опционально |
|
||||||
|
| description | string | текст анкеты |
|
||||||
|
| nation | string | национальность |
|
||||||
|
| height | float | опционально |
|
||||||
|
| weight | float | опционально |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `media`
|
||||||
|
Фотографии и видео пользователя в анкете. Файлы хранятся в MinIO, здесь только путь.
|
||||||
|
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| id | uuid PK | |
|
||||||
|
| user_id | uuid FK → user | |
|
||||||
|
| path | string | URL в MinIO |
|
||||||
|
| type | string | `photo` / `video` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `tag`
|
||||||
|
Справочник интересов / тегов.
|
||||||
|
|
||||||
|
| Поле | Тип |
|
||||||
|
|---|---|
|
||||||
|
| id | uuid PK |
|
||||||
|
| value | string |
|
||||||
|
|
||||||
|
### `profile_tag`
|
||||||
|
Связь M:M между профилем и тегами.
|
||||||
|
|
||||||
|
| Поле | Тип |
|
||||||
|
|---|---|
|
||||||
|
| profile_id | uuid FK → profile |
|
||||||
|
| tag_id | uuid FK → tag |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `city`
|
||||||
|
Справочник городов.
|
||||||
|
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| id | uuid PK | |
|
||||||
|
| name | string | |
|
||||||
|
| lat | decimal | широта центра города |
|
||||||
|
| lng | decimal | долгота центра города |
|
||||||
|
|
||||||
|
### `city_district`
|
||||||
|
Районы и станции метро внутри города.
|
||||||
|
|
||||||
|
| Поле | Тип |
|
||||||
|
|---|---|
|
||||||
|
| id | uuid PK |
|
||||||
|
| city_id | uuid FK → city |
|
||||||
|
| name | string |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `tariff`
|
||||||
|
Тарифные планы подписки.
|
||||||
|
|
||||||
|
| Поле | Тип |
|
||||||
|
|---|---|
|
||||||
|
| id | uuid PK |
|
||||||
|
| name | string |
|
||||||
|
| price_per_month | decimal |
|
||||||
|
| price_per_year | decimal |
|
||||||
|
|
||||||
|
### `payment`
|
||||||
|
Платёжные данные пользователя.
|
||||||
|
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| id | uuid PK | |
|
||||||
|
| user_id | uuid FK → user | |
|
||||||
|
| provider | string | название платёжной системы |
|
||||||
|
| credentials | string | токен / идентификатор в платёжной системе |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `role`
|
||||||
|
Роли пользователей: `user`, `moderator`, `admin`.
|
||||||
|
|
||||||
|
| Поле | Тип |
|
||||||
|
|---|---|
|
||||||
|
| id | uuid PK |
|
||||||
|
| name | string |
|
||||||
|
|
||||||
|
### `permission`
|
||||||
|
Права доступа, привязанные к роли. Используется для логики AdminPanel.
|
||||||
|
|
||||||
|
| Поле | Тип |
|
||||||
|
|---|---|
|
||||||
|
| id | uuid PK |
|
||||||
|
| role_id | uuid FK → role |
|
||||||
|
| name | string |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `like`
|
||||||
|
Лайк или дизлайк от одного пользователя другому. При взаимном лайке создаётся `match`.
|
||||||
|
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| id | uuid PK | |
|
||||||
|
| source_user | uuid FK → user | |
|
||||||
|
| target_user | uuid FK → user | |
|
||||||
|
| type | enum | `like` / `dislike` |
|
||||||
|
| created_at | timestamp | |
|
||||||
|
|
||||||
|
### `match`
|
||||||
|
Взаимный лайк. После создания открывается возможность чата.
|
||||||
|
|
||||||
|
| Поле | Тип |
|
||||||
|
|---|---|
|
||||||
|
| id | uuid PK |
|
||||||
|
| user1_id | uuid FK → user |
|
||||||
|
| user2_id | uuid FK → user |
|
||||||
|
| created_at | timestamp |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `chat`
|
||||||
|
Чат между двумя профилями. Один пользователь может иметь только один активный чат одновременно (`user.active_chat_id`).
|
||||||
|
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| id | uuid PK | |
|
||||||
|
| profile1_id | uuid FK → profile | |
|
||||||
|
| profile2_id | uuid FK → profile | |
|
||||||
|
| status | string | `active` / `closed` |
|
||||||
|
|
||||||
|
### `message`
|
||||||
|
Сообщения внутри чата. Доставка в реальном времени через Socket.io. Поддерживает текст и медиавложения.
|
||||||
|
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| id | uuid PK | |
|
||||||
|
| chat_id | uuid FK → chat | |
|
||||||
|
| user_id | uuid FK → user | |
|
||||||
|
| text | string | текст сообщения, опционально |
|
||||||
|
| media_url | string | URL файла в MinIO, опционально |
|
||||||
|
| media_type | enum | `photo` / `voice` / `video`, опционально |
|
||||||
|
| created_at | timestamp | |
|
||||||
|
|
||||||
|
### `greetings`
|
||||||
|
Справочник готовых приветственных фраз. Пользователь выбирает из списка при открытии нового чата. Не привязан ни к чату, ни к пользователю — это просто набор текстов.
|
||||||
|
|
||||||
|
| Поле | Тип |
|
||||||
|
|---|---|
|
||||||
|
| id | uuid PK |
|
||||||
|
| text | string |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `report`
|
||||||
|
Жалобы пользователей. `entity_type` указывает на что именно жалоба.
|
||||||
|
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| id | uuid PK | |
|
||||||
|
| source_user | uuid FK → user | кто подал жалобу |
|
||||||
|
| entity_id | uuid | id объекта жалобы |
|
||||||
|
| entity_type | enum | `profile` / `message` |
|
||||||
|
| description | string | текст жалобы |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `date`
|
||||||
|
Реальная офлайн-встреча двух пользователей. Создаётся по договорённости в чате.
|
||||||
|
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| id | uuid PK | |
|
||||||
|
| user1_id | uuid FK → user | |
|
||||||
|
| user2_id | uuid FK → user | |
|
||||||
|
| lat | decimal | координаты места встречи |
|
||||||
|
| lng | decimal | |
|
||||||
|
| time | timestamp | дата и время встречи |
|
||||||
|
| status_id | uuid FK → date_status | |
|
||||||
|
|
||||||
|
### `date_status`
|
||||||
|
Справочник статусов встречи.
|
||||||
|
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| id | uuid PK | |
|
||||||
|
| text | string | `pending` / `confirmed` / `cancelled` / `rescheduled` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Связи
|
||||||
|
|
||||||
|
```
|
||||||
|
user ──────────── profile (1:1)
|
||||||
|
user ──────────── media (1:N)
|
||||||
|
user ──────────── role (N:1)
|
||||||
|
user ──────────── tariff (N:1)
|
||||||
|
user ──────────── payment (1:1)
|
||||||
|
user ──────────── chat (активный чат, 1:1)
|
||||||
|
profile ────────── tag (M:M через profile_tag)
|
||||||
|
profile ────────── city (N:1)
|
||||||
|
profile ────────── city_district (N:1, опционально)
|
||||||
|
city ───────────── city_district (1:N)
|
||||||
|
role ───────────── permission (1:N)
|
||||||
|
chat ───────────── message (1:N)
|
||||||
|
date ───────────── date_status (N:1)
|
||||||
|
report ─────────── user (N:1)
|
||||||
|
```
|
||||||
43
docker-compose.yml
Normal file
43
docker-compose.yml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: daiting-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: daiting_app
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
ports:
|
||||||
|
- '5432:5432'
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: daiting-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '6379:6379'
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
container_name: daiting-minio
|
||||||
|
restart: unless-stopped
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: minioadmin
|
||||||
|
MINIO_ROOT_PASSWORD: minioadmin
|
||||||
|
ports:
|
||||||
|
- '9000:9000'
|
||||||
|
- '9001:9001'
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
|
minio_data:
|
||||||
13
drizzle.config.ts
Normal file
13
drizzle.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Config } from 'drizzle-kit';
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
export default {
|
||||||
|
schema: './src/database/schema/index.ts',
|
||||||
|
out: './src/database/migrations',
|
||||||
|
dialect: 'postgresql',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/daiting_app',
|
||||||
|
},
|
||||||
|
} satisfies Config;
|
||||||
8
nest-cli.json
Normal file
8
nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
94
package.json
Normal file
94
package.json
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"name": "daiting-app-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Dating app backend API",
|
||||||
|
"author": "",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\"",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "drizzle-kit migrate",
|
||||||
|
"db:push": "drizzle-kit push",
|
||||||
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"db:seed": "ts-node -r tsconfig-paths/register src/database/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/multipart": "^8.3.0",
|
||||||
|
"@fastify/static": "^7.0.4",
|
||||||
|
"@nestjs/common": "^10.4.15",
|
||||||
|
"@nestjs/config": "^3.3.0",
|
||||||
|
"@nestjs/core": "^10.4.15",
|
||||||
|
"@nestjs/jwt": "^10.2.0",
|
||||||
|
"@nestjs/passport": "^10.0.3",
|
||||||
|
"@nestjs/platform-fastify": "^10.4.15",
|
||||||
|
"@nestjs/platform-socket.io": "^10.4.15",
|
||||||
|
"@nestjs/swagger": "^7.4.2",
|
||||||
|
"@nestjs/websockets": "^10.4.15",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"drizzle-orm": "^0.38.3",
|
||||||
|
"fastify": "^4.28.1",
|
||||||
|
"firebase-admin": "^12.7.0",
|
||||||
|
"ioredis": "^5.4.1",
|
||||||
|
"minio": "^8.0.3",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"pg": "^8.13.1",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"socket.io": "^4.8.1",
|
||||||
|
"uuid": "^11.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^10.4.9",
|
||||||
|
"@nestjs/schematics": "^10.2.3",
|
||||||
|
"@nestjs/testing": "^10.4.15",
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/dotenv": "^8.2.3",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
|
"@types/node": "^22.10.2",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@types/pg": "^8.11.10",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.18.2",
|
||||||
|
"@typescript-eslint/parser": "^8.18.2",
|
||||||
|
"drizzle-kit": "^0.29.1",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
8438
pnpm-lock.yaml
generated
Normal file
8438
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
11
pnpm-workspace.yaml
Normal file
11
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- "@nestjs/core"
|
||||||
|
- bcrypt
|
||||||
|
- esbuild
|
||||||
|
- protobufjs
|
||||||
|
|
||||||
|
allowBuilds:
|
||||||
|
"@nestjs/core": true
|
||||||
|
bcrypt: true
|
||||||
|
esbuild: true
|
||||||
|
protobufjs: true
|
||||||
76
src/app.module.ts
Normal file
76
src/app.module.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
|
|
||||||
|
import {
|
||||||
|
appConfig,
|
||||||
|
databaseConfig,
|
||||||
|
firebaseConfig,
|
||||||
|
jwtConfig,
|
||||||
|
minioConfig,
|
||||||
|
redisConfig,
|
||||||
|
} from './config';
|
||||||
|
|
||||||
|
import { DatabaseModule } from './database/database.module';
|
||||||
|
import { RedisModule } from './redis/redis.module';
|
||||||
|
import { NotificationsModule } from './notifications/notifications.module';
|
||||||
|
import { StorageModule } from './storage/storage.module';
|
||||||
|
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { UsersModule } from './modules/users/users.module';
|
||||||
|
import { ProfilesModule } from './modules/profiles/profiles.module';
|
||||||
|
import { MediaModule } from './modules/media/media.module';
|
||||||
|
import { FeedModule } from './modules/feed/feed.module';
|
||||||
|
import { LikesModule } from './modules/likes/likes.module';
|
||||||
|
import { ChatModule } from './modules/chat/chat.module';
|
||||||
|
import { DatesModule } from './modules/dates/dates.module';
|
||||||
|
import { ReportsModule } from './modules/reports/reports.module';
|
||||||
|
import { TagsModule } from './modules/tags/tags.module';
|
||||||
|
import { CitiesModule } from './modules/cities/cities.module';
|
||||||
|
import { GreetingsModule } from './modules/greetings/greetings.module';
|
||||||
|
import { GatewaysModule } from './gateways/gateways.module';
|
||||||
|
|
||||||
|
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
||||||
|
import { AllExceptionsFilter } from './common/filters/http-exception.filter';
|
||||||
|
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
load: [appConfig, databaseConfig, redisConfig, jwtConfig, minioConfig, firebaseConfig],
|
||||||
|
}),
|
||||||
|
DatabaseModule,
|
||||||
|
RedisModule,
|
||||||
|
NotificationsModule,
|
||||||
|
StorageModule,
|
||||||
|
AuthModule,
|
||||||
|
UsersModule,
|
||||||
|
ProfilesModule,
|
||||||
|
MediaModule,
|
||||||
|
FeedModule,
|
||||||
|
LikesModule,
|
||||||
|
ChatModule,
|
||||||
|
DatesModule,
|
||||||
|
ReportsModule,
|
||||||
|
TagsModule,
|
||||||
|
CitiesModule,
|
||||||
|
GreetingsModule,
|
||||||
|
GatewaysModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: JwtAuthGuard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: APP_FILTER,
|
||||||
|
useClass: AllExceptionsFilter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: APP_INTERCEPTOR,
|
||||||
|
useClass: TransformInterceptor,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
55
src/auth/auth.controller.ts
Normal file
55
src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, 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 { Public } from '../common/decorators/public.decorator';
|
||||||
|
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||||
|
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
@ApiTags('auth')
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('register')
|
||||||
|
@ApiOperation({ summary: 'Register new user' })
|
||||||
|
register(@Body() dto: RegisterDto) {
|
||||||
|
return this.authService.register(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('login')
|
||||||
|
@ApiOperation({ summary: 'Login with phone and password' })
|
||||||
|
login(@Body() dto: LoginDto) {
|
||||||
|
return this.authService.login(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post('logout')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Logout current user' })
|
||||||
|
logout(@CurrentUser('id') userId: string) {
|
||||||
|
return this.authService.logout(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('refresh')
|
||||||
|
@ApiOperation({ summary: 'Refresh access token' })
|
||||||
|
refresh(@Body() dto: RefreshTokenDto) {
|
||||||
|
return this.authService.refreshTokens(dto.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post('fcm-token')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Update FCM push token' })
|
||||||
|
updateFcmToken(
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
@Body('fcmToken') fcmToken: string,
|
||||||
|
) {
|
||||||
|
return this.authService.updateFcmToken(userId, fcmToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/auth/auth.module.ts
Normal file
25
src/auth/auth.module.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
PassportModule,
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
secret: configService.get<string>('jwt.secret'),
|
||||||
|
signOptions: { expiresIn: configService.get<string>('jwt.expiresIn') },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, JwtStrategy],
|
||||||
|
exports: [AuthService, JwtModule],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
160
src/auth/auth.service.ts
Normal file
160
src/auth/auth.service.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { DrizzleService } from '../database/drizzle.service';
|
||||||
|
import { role, user } from '../database/schema';
|
||||||
|
import { RedisService } from '../redis/redis.service';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { RegisterDto } from './dto/register.dto';
|
||||||
|
import { JwtPayload } from './strategies/jwt.strategy';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private readonly drizzleService: DrizzleService,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly redisService: RedisService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async register(dto: RegisterDto) {
|
||||||
|
const existing = await this.drizzleService.db
|
||||||
|
.select({ id: user.id })
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.phone, dto.phone))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
throw new ConflictException('Phone number already registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [userRole] = await this.drizzleService.db
|
||||||
|
.select()
|
||||||
|
.from(role)
|
||||||
|
.where(eq(role.name, 'user'))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(dto.password, 10);
|
||||||
|
|
||||||
|
const [newUser] = await this.drizzleService.db
|
||||||
|
.insert(user)
|
||||||
|
.values({
|
||||||
|
phone: dto.phone,
|
||||||
|
password: hashedPassword,
|
||||||
|
status: 'active' as any,
|
||||||
|
roleId: userRole?.id || null,
|
||||||
|
} as any)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return this.generateTokens(newUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(dto: LoginDto) {
|
||||||
|
const [foundUser] = await this.drizzleService.db
|
||||||
|
.select()
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.phone, dto.phone))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!foundUser) {
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundUser.status === 'banned') {
|
||||||
|
throw new UnauthorizedException('Account is banned');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPasswordValid = await bcrypt.compare(dto.password, foundUser.password);
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRoleName = await this.getUserRoleName(foundUser.roleId);
|
||||||
|
return this.generateTokens(foundUser, userRoleName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(userId: string) {
|
||||||
|
await this.redisService.del(`refresh_token:${userId}`);
|
||||||
|
return { message: 'Logged out successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshTokens(refreshToken: string) {
|
||||||
|
try {
|
||||||
|
const payload = this.jwtService.verify<JwtPayload & { type: string }>(refreshToken, {
|
||||||
|
secret: this.configService.get<string>('jwt.secret'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (payload.type !== 'refresh') {
|
||||||
|
throw new UnauthorizedException('Invalid token type');
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedToken = await this.redisService.get(`refresh_token:${payload.sub}`);
|
||||||
|
if (!storedToken || storedToken !== refreshToken) {
|
||||||
|
throw new UnauthorizedException('Refresh token expired or invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [foundUser] = await this.drizzleService.db
|
||||||
|
.select()
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, payload.sub))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!foundUser) throw new UnauthorizedException();
|
||||||
|
|
||||||
|
const userRoleName = await this.getUserRoleName(foundUser.roleId);
|
||||||
|
return this.generateTokens(foundUser, userRoleName);
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('Invalid or expired refresh token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFcmToken(userId: string, fcmToken: string) {
|
||||||
|
await this.drizzleService.db
|
||||||
|
.update(user)
|
||||||
|
.set({ fcmToken } as any)
|
||||||
|
.where(eq(user.id, userId));
|
||||||
|
return { message: 'FCM token updated' };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getUserRoleName(roleId: string | null): Promise<string> {
|
||||||
|
if (!roleId) return 'user';
|
||||||
|
const [r] = await this.drizzleService.db
|
||||||
|
.select({ name: role.name })
|
||||||
|
.from(role)
|
||||||
|
.where(eq(role.id, roleId))
|
||||||
|
.limit(1);
|
||||||
|
return r?.name || 'user';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateTokens(userEntity: any, roleName?: string) {
|
||||||
|
const resolvedRole = roleName || (await this.getUserRoleName(userEntity.roleId));
|
||||||
|
|
||||||
|
const payload: JwtPayload = {
|
||||||
|
sub: userEntity.id,
|
||||||
|
phone: userEntity.phone,
|
||||||
|
role: resolvedRole,
|
||||||
|
};
|
||||||
|
|
||||||
|
const accessToken = this.jwtService.sign(payload);
|
||||||
|
const refreshToken = this.jwtService.sign(
|
||||||
|
{ ...payload, type: 'refresh' },
|
||||||
|
{ expiresIn: '30d' },
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.redisService.set(
|
||||||
|
`refresh_token:${userEntity.id}`,
|
||||||
|
refreshToken,
|
||||||
|
'EX',
|
||||||
|
60 * 60 * 24 * 30,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { accessToken, refreshToken };
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/auth/dto/login.dto.ts
Normal file
14
src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@ApiProperty({ example: '+79991234567' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
phone: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'StrongPass123!' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
9
src/auth/dto/refresh-token.dto.ts
Normal file
9
src/auth/dto/refresh-token.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsString, IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
|
export class RefreshTokenDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
15
src/auth/dto/register.dto.ts
Normal file
15
src/auth/dto/register.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsString, Matches, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class RegisterDto {
|
||||||
|
@ApiProperty({ example: '+79991234567' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Matches(/^\+?[1-9]\d{6,14}$/, { message: 'Invalid phone number' })
|
||||||
|
phone: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'StrongPass123!' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
41
src/auth/strategies/jwt.strategy.ts
Normal file
41
src/auth/strategies/jwt.strategy.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { DrizzleService } from '../../database/drizzle.service';
|
||||||
|
import { user } from '../../database/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export interface JwtPayload {
|
||||||
|
sub: string;
|
||||||
|
phone: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor(
|
||||||
|
configService: ConfigService,
|
||||||
|
private readonly drizzleService: DrizzleService,
|
||||||
|
) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: configService.get<string>('jwt.secret'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: JwtPayload) {
|
||||||
|
const [foundUser] = await this.drizzleService.db
|
||||||
|
.select()
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, payload.sub))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!foundUser || foundUser.status === 'banned') {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: foundUser.id, phone: foundUser.phone, role: payload.role, status: foundUser.status };
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/common/decorators/current-user.decorator.ts
Normal file
9
src/common/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const CurrentUser = createParamDecorator(
|
||||||
|
(data: string, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
const user = request.user;
|
||||||
|
return data ? user?.[data] : user;
|
||||||
|
},
|
||||||
|
);
|
||||||
4
src/common/decorators/public.decorator.ts
Normal file
4
src/common/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
4
src/common/decorators/roles.decorator.ts
Normal file
4
src/common/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
41
src/common/filters/http-exception.filter.ts
Normal file
41
src/common/filters/http-exception.filter.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
ArgumentsHost,
|
||||||
|
Catch,
|
||||||
|
ExceptionFilter,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
|
@Catch()
|
||||||
|
export class AllExceptionsFilter implements ExceptionFilter {
|
||||||
|
private readonly logger = new Logger(AllExceptionsFilter.name);
|
||||||
|
|
||||||
|
catch(exception: unknown, host: ArgumentsHost) {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
const reply = ctx.getResponse<FastifyReply>();
|
||||||
|
const request = ctx.getRequest<FastifyRequest>();
|
||||||
|
|
||||||
|
const status =
|
||||||
|
exception instanceof HttpException
|
||||||
|
? exception.getStatus()
|
||||||
|
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
|
||||||
|
const message =
|
||||||
|
exception instanceof HttpException
|
||||||
|
? exception.getResponse()
|
||||||
|
: 'Internal server error';
|
||||||
|
|
||||||
|
if (status >= 500) {
|
||||||
|
this.logger.error(`${request.method} ${request.url}`, exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.status(status).send({
|
||||||
|
statusCode: status,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
path: request.url,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/common/guards/jwt-auth.guard.ts
Normal file
25
src/common/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||||
|
constructor(private reflector: Reflector) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext) {
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (isPublic) return true;
|
||||||
|
return super.canActivate(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRequest(err: any, user: any) {
|
||||||
|
if (err || !user) throw err || new UnauthorizedException();
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/common/guards/roles.guard.ts
Normal file
18
src/common/guards/roles.guard.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RolesGuard implements CanActivate {
|
||||||
|
constructor(private reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (!requiredRoles) return true;
|
||||||
|
const { user } = context.switchToHttp().getRequest();
|
||||||
|
return requiredRoles.includes(user?.role);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/common/interceptors/transform.interceptor.ts
Normal file
14
src/common/interceptors/transform.interceptor.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
|
export interface Response<T> {
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
|
||||||
|
intercept(_context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
|
||||||
|
return next.handle().pipe(map((data) => ({ data })));
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/config/app.config.ts
Normal file
7
src/config/app.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { registerAs } from '@nestjs/config';
|
||||||
|
|
||||||
|
export default registerAs('app', () => ({
|
||||||
|
nodeEnv: process.env.NODE_ENV || 'development',
|
||||||
|
port: parseInt(process.env.PORT, 10) || 3000,
|
||||||
|
maxMatchesBeforePause: parseInt(process.env.MAX_MATCHES_BEFORE_PAUSE, 10) || 10,
|
||||||
|
}));
|
||||||
5
src/config/database.config.ts
Normal file
5
src/config/database.config.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { registerAs } from '@nestjs/config';
|
||||||
|
|
||||||
|
export default registerAs('database', () => ({
|
||||||
|
url: process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/daiting_app',
|
||||||
|
}));
|
||||||
7
src/config/firebase.config.ts
Normal file
7
src/config/firebase.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { registerAs } from '@nestjs/config';
|
||||||
|
|
||||||
|
export default registerAs('firebase', () => ({
|
||||||
|
projectId: process.env.FIREBASE_PROJECT_ID || '',
|
||||||
|
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n') || '',
|
||||||
|
clientEmail: process.env.FIREBASE_CLIENT_EMAIL || '',
|
||||||
|
}));
|
||||||
6
src/config/index.ts
Normal file
6
src/config/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export { default as appConfig } from './app.config';
|
||||||
|
export { default as databaseConfig } from './database.config';
|
||||||
|
export { default as redisConfig } from './redis.config';
|
||||||
|
export { default as jwtConfig } from './jwt.config';
|
||||||
|
export { default as minioConfig } from './minio.config';
|
||||||
|
export { default as firebaseConfig } from './firebase.config';
|
||||||
6
src/config/jwt.config.ts
Normal file
6
src/config/jwt.config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { registerAs } from '@nestjs/config';
|
||||||
|
|
||||||
|
export default registerAs('jwt', () => ({
|
||||||
|
secret: process.env.JWT_SECRET || 'fallback-secret',
|
||||||
|
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
|
||||||
|
}));
|
||||||
10
src/config/minio.config.ts
Normal file
10
src/config/minio.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { registerAs } from '@nestjs/config';
|
||||||
|
|
||||||
|
export default registerAs('minio', () => ({
|
||||||
|
endpoint: process.env.MINIO_ENDPOINT || 'localhost',
|
||||||
|
port: parseInt(process.env.MINIO_PORT, 10) || 9000,
|
||||||
|
useSSL: process.env.MINIO_USE_SSL === 'true',
|
||||||
|
accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
|
||||||
|
secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin',
|
||||||
|
bucket: process.env.MINIO_BUCKET || 'daiting-media',
|
||||||
|
}));
|
||||||
7
src/config/redis.config.ts
Normal file
7
src/config/redis.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { registerAs } from '@nestjs/config';
|
||||||
|
|
||||||
|
export default registerAs('redis', () => ({
|
||||||
|
host: process.env.REDIS_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.REDIS_PORT, 10) || 6379,
|
||||||
|
password: process.env.REDIS_PASSWORD || undefined,
|
||||||
|
}));
|
||||||
11
src/database/database.module.ts
Normal file
11
src/database/database.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { DrizzleService } from './drizzle.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
providers: [DrizzleService],
|
||||||
|
exports: [DrizzleService],
|
||||||
|
})
|
||||||
|
export class DatabaseModule {}
|
||||||
27
src/database/drizzle.service.ts
Normal file
27
src/database/drizzle.service.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Injectable, OnModuleDestroy, OnModuleInit, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { drizzle, NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import * as schema from './schema';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DrizzleService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(DrizzleService.name);
|
||||||
|
private pool: Pool;
|
||||||
|
db: NodePgDatabase<typeof schema>;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
const url = this.configService.get<string>('database.url');
|
||||||
|
this.pool = new Pool({ connectionString: url });
|
||||||
|
this.db = drizzle(this.pool, { schema });
|
||||||
|
await this.pool.connect();
|
||||||
|
this.logger.log('Database connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.pool.end();
|
||||||
|
this.logger.log('Database disconnected');
|
||||||
|
}
|
||||||
|
}
|
||||||
268
src/database/migrations/0000_romantic_morg.sql
Normal file
268
src/database/migrations/0000_romantic_morg.sql
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
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"."like_type" AS ENUM('like', 'dislike');--> 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,
|
||||||
|
"role_id" uuid NOT NULL,
|
||||||
|
"name" varchar(100) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "role" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" varchar(50) NOT NULL,
|
||||||
|
CONSTRAINT "role_name_unique" UNIQUE("name")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "tariff" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" varchar(100) NOT NULL,
|
||||||
|
"price_per_month" numeric(10, 2) NOT NULL,
|
||||||
|
"price_per_year" numeric(10, 2) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "city" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" varchar(200) NOT NULL,
|
||||||
|
"lat" numeric(10, 7) NOT NULL,
|
||||||
|
"lng" numeric(10, 7) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "city_district" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"city_id" uuid NOT NULL,
|
||||||
|
"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,
|
||||||
|
"provider" varchar(100) NOT NULL,
|
||||||
|
"credentials" text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "user" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"phone" varchar(20) NOT NULL,
|
||||||
|
"password" text NOT NULL,
|
||||||
|
"status" "user_status" DEFAULT 'pending' NOT NULL,
|
||||||
|
"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,
|
||||||
|
"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")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "profile_tag" (
|
||||||
|
"profile_id" uuid NOT NULL,
|
||||||
|
"tag_id" uuid NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "tag" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"value" varchar(100) NOT NULL,
|
||||||
|
CONSTRAINT "tag_value_unique" UNIQUE("value")
|
||||||
|
);
|
||||||
|
--> 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,
|
||||||
|
"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,
|
||||||
|
"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,
|
||||||
|
"lat" numeric(10, 7) NOT NULL,
|
||||||
|
"lng" numeric(10, 7) NOT NULL,
|
||||||
|
"time" timestamp with time zone NOT NULL,
|
||||||
|
"status_id" uuid
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "date_status" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"text" text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "report" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"source_user" uuid NOT NULL,
|
||||||
|
"entity_id" uuid NOT NULL,
|
||||||
|
"entity_type" "report_entity_type" NOT NULL,
|
||||||
|
"description" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "permission" ADD CONSTRAINT "permission_role_id_role_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."role"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "city_district" ADD CONSTRAINT "city_district_city_id_city_id_fk" FOREIGN KEY ("city_id") REFERENCES "public"."city"("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 "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
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "user" ADD CONSTRAINT "user_role_id_role_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."role"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "user" ADD CONSTRAINT "user_tariff_id_tariff_id_fk" FOREIGN KEY ("tariff_id") REFERENCES "public"."tariff"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
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
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "profile" ADD CONSTRAINT "profile_city_id_city_id_fk" FOREIGN KEY ("city_id") REFERENCES "public"."city"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "profile" ADD CONSTRAINT "profile_district_id_city_district_id_fk" FOREIGN KEY ("district_id") REFERENCES "public"."city_district"("id") ON DELETE set null 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
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "profile_tag" ADD CONSTRAINT "profile_tag_tag_id_tag_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tag"("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_source_user_user_id_fk" FOREIGN KEY ("source_user") REFERENCES "public"."user"("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;
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "date" ADD CONSTRAINT "date_status_id_date_status_id_fk" FOREIGN KEY ("status_id") REFERENCES "public"."date_status"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
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;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
3
src/database/migrations/0001_brown_marrow.sql
Normal file
3
src/database/migrations/0001_brown_marrow.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
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;
|
||||||
1139
src/database/migrations/meta/0000_snapshot.json
Normal file
1139
src/database/migrations/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1154
src/database/migrations/meta/0001_snapshot.json
Normal file
1154
src/database/migrations/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
20
src/database/migrations/meta/_journal.json
Normal file
20
src/database/migrations/meta/_journal.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780401435523,
|
||||||
|
"tag": "0000_romantic_morg",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780403477744,
|
||||||
|
"tag": "0001_brown_marrow",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
28
src/database/schema/chat.schema.ts
Normal file
28
src/database/schema/chat.schema.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
export const chatStatusEnum = pgEnum('chat_status', ['active', 'closed']);
|
||||||
|
export const mediaTypeEnum = pgEnum('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(),
|
||||||
|
status: chatStatusEnum('status').notNull().default('active'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const message = pgTable('message', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
chatId: uuid('chat_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => chat.id, { onDelete: 'cascade' }),
|
||||||
|
userId: uuid('user_id').notNull(),
|
||||||
|
text: text('text'),
|
||||||
|
mediaUrl: text('media_url'),
|
||||||
|
mediaType: mediaTypeEnum('media_type'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const greetings = pgTable('greetings', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
text: text('text').notNull(),
|
||||||
|
});
|
||||||
16
src/database/schema/city.schema.ts
Normal file
16
src/database/schema/city.schema.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { decimal, pgTable, uuid, varchar } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
export const city = pgTable('city', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
name: varchar('name', { length: 200 }).notNull(),
|
||||||
|
lat: decimal('lat', { precision: 10, scale: 7 }).notNull(),
|
||||||
|
lng: decimal('lng', { precision: 10, scale: 7 }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cityDistrict = pgTable('city_district', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
cityId: uuid('city_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => city.id, { onDelete: 'cascade' }),
|
||||||
|
name: varchar('name', { length: 200 }).notNull(),
|
||||||
|
});
|
||||||
21
src/database/schema/date.schema.ts
Normal file
21
src/database/schema/date.schema.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { decimal, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||||
|
import { user } from './user.schema';
|
||||||
|
|
||||||
|
export const dateStatus = pgTable('date_status', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
text: text('text').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const date = pgTable('date', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
user1Id: uuid('user1_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
user2Id: uuid('user2_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.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(),
|
||||||
|
statusId: uuid('status_id').references(() => dateStatus.id, { onDelete: 'set null' }),
|
||||||
|
});
|
||||||
9
src/database/schema/index.ts
Normal file
9
src/database/schema/index.ts
Normal file
@@ -0,0 +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 './date.schema';
|
||||||
|
export * from './report.schema';
|
||||||
45
src/database/schema/profile.schema.ts
Normal file
45
src/database/schema/profile.schema.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { date, doublePrecision, 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 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(),
|
||||||
|
gender: genderEnum('gender').notNull(),
|
||||||
|
cityId: uuid('city_id').references(() => city.id, { onDelete: 'set null' }),
|
||||||
|
districtId: uuid('district_id').references(() => cityDistrict.id, { onDelete: 'set null' }),
|
||||||
|
description: text('description'),
|
||||||
|
nation: varchar('nation', { length: 100 }),
|
||||||
|
height: doublePrecision('height'),
|
||||||
|
weight: doublePrecision('weight'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tag = pgTable('tag', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
value: varchar('value', { length: 100 }).notNull().unique(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const profileTag = pgTable('profile_tag', {
|
||||||
|
profileId: uuid('profile_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => profile.id, { onDelete: 'cascade' }),
|
||||||
|
tagId: uuid('tag_id')
|
||||||
|
.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(),
|
||||||
|
});
|
||||||
14
src/database/schema/report.schema.ts
Normal file
14
src/database/schema/report.schema.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { pgEnum, pgTable, text, uuid } from 'drizzle-orm/pg-core';
|
||||||
|
import { user } from './user.schema';
|
||||||
|
|
||||||
|
export const reportEntityTypeEnum = pgEnum('report_entity_type', ['profile', 'message']);
|
||||||
|
|
||||||
|
export const report = pgTable('report', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
sourceUser: uuid('source_user')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
entityId: uuid('entity_id').notNull(),
|
||||||
|
entityType: reportEntityTypeEnum('entity_type').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
});
|
||||||
14
src/database/schema/role.schema.ts
Normal file
14
src/database/schema/role.schema.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { pgTable, uuid, varchar } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
export const role = pgTable('role', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
name: varchar('name', { length: 50 }).notNull().unique(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const permission = pgTable('permission', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
roleId: uuid('role_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => role.id, { onDelete: 'cascade' }),
|
||||||
|
name: varchar('name', { length: 100 }).notNull(),
|
||||||
|
});
|
||||||
27
src/database/schema/social.schema.ts
Normal file
27
src/database/schema/social.schema.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { pgEnum, pgTable, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||||
|
import { user } from './user.schema';
|
||||||
|
|
||||||
|
export const likeTypeEnum = pgEnum('like_type', ['like', 'dislike']);
|
||||||
|
|
||||||
|
export const like = pgTable('like', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
sourceUser: uuid('source_user')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
targetUser: uuid('target_user')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.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')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
user2Id: uuid('user2_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
8
src/database/schema/tariff.schema.ts
Normal file
8
src/database/schema/tariff.schema.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { decimal, pgTable, uuid, varchar } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
export const tariff = pgTable('tariff', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
name: varchar('name', { length: 100 }).notNull(),
|
||||||
|
pricePerMonth: decimal('price_per_month', { precision: 10, scale: 2 }).notNull(),
|
||||||
|
pricePerYear: decimal('price_per_year', { precision: 10, scale: 2 }).notNull(),
|
||||||
|
});
|
||||||
26
src/database/schema/user.schema.ts
Normal file
26
src/database/schema/user.schema.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { pgEnum, pgTable, text, uuid, varchar } from 'drizzle-orm/pg-core';
|
||||||
|
import { role } from './role.schema';
|
||||||
|
import { tariff } from './tariff.schema';
|
||||||
|
|
||||||
|
export const userStatusEnum = pgEnum('user_status', ['active', 'banned', 'pending']);
|
||||||
|
|
||||||
|
export const user = pgTable('user', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
phone: varchar('phone', { length: 20 }).notNull().unique(),
|
||||||
|
password: text('password').notNull(),
|
||||||
|
status: userStatusEnum('status').notNull().default('pending'),
|
||||||
|
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'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const payment = pgTable('payment', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: uuid('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
provider: varchar('provider', { length: 100 }).notNull(),
|
||||||
|
credentials: text('credentials').notNull(),
|
||||||
|
});
|
||||||
57
src/database/seed.ts
Normal file
57
src/database/seed.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
import * as schema from './schema';
|
||||||
|
import { role, tariff, dateStatus, greetings } from './schema';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
|
const db = drizzle(pool, { schema });
|
||||||
|
|
||||||
|
console.log('Seeding roles...');
|
||||||
|
await db
|
||||||
|
.insert(role)
|
||||||
|
.values([
|
||||||
|
{ name: 'user' },
|
||||||
|
{ name: 'moderator' },
|
||||||
|
{ name: 'admin' },
|
||||||
|
])
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
console.log('Seeding tariffs...');
|
||||||
|
await db
|
||||||
|
.insert(tariff)
|
||||||
|
.values([
|
||||||
|
{ name: 'Standard', pricePerMonth: '9.99', pricePerYear: '89.99' },
|
||||||
|
])
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
console.log('Seeding date statuses...');
|
||||||
|
await db
|
||||||
|
.insert(dateStatus)
|
||||||
|
.values([
|
||||||
|
{ text: 'pending' },
|
||||||
|
{ text: 'confirmed' },
|
||||||
|
{ text: 'cancelled' },
|
||||||
|
{ text: 'rescheduled' },
|
||||||
|
])
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
console.log('Seeding greetings...');
|
||||||
|
await db
|
||||||
|
.insert(greetings)
|
||||||
|
.values([
|
||||||
|
{ text: 'Hi! I noticed your profile and would love to chat 😊' },
|
||||||
|
{ text: 'Hey there! Want to grab a coffee sometime?' },
|
||||||
|
{ text: 'Hello! Your interests caught my eye. Let\'s talk!' },
|
||||||
|
{ text: 'Hi! I think we might have a lot in common 🙂' },
|
||||||
|
])
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
console.log('Seed complete!');
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
seed().catch(console.error);
|
||||||
122
src/gateways/chat.gateway.ts
Normal file
122
src/gateways/chat.gateway.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import {
|
||||||
|
ConnectedSocket,
|
||||||
|
MessageBody,
|
||||||
|
OnGatewayConnection,
|
||||||
|
OnGatewayDisconnect,
|
||||||
|
SubscribeMessage,
|
||||||
|
WebSocketGateway,
|
||||||
|
WebSocketServer,
|
||||||
|
WsException,
|
||||||
|
} from '@nestjs/websockets';
|
||||||
|
import { Server, Socket } from 'socket.io';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { ChatService } from '../modules/chat/chat.service';
|
||||||
|
import { SendMessageDto } from '../modules/chat/dto/send-message.dto';
|
||||||
|
|
||||||
|
@WebSocketGateway({
|
||||||
|
cors: {
|
||||||
|
origin: '*',
|
||||||
|
credentials: true,
|
||||||
|
},
|
||||||
|
namespace: 'chat',
|
||||||
|
})
|
||||||
|
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||||
|
@WebSocketServer()
|
||||||
|
server: Server;
|
||||||
|
|
||||||
|
private readonly logger = new Logger(ChatGateway.name);
|
||||||
|
private connectedUsers = new Map<string, string>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly chatService: ChatService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handleConnection(client: Socket) {
|
||||||
|
try {
|
||||||
|
const token =
|
||||||
|
client.handshake.auth?.token ||
|
||||||
|
client.handshake.headers?.authorization?.replace('Bearer ', '');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
client.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = this.jwtService.verify(token, {
|
||||||
|
secret: this.configService.get<string>('jwt.secret'),
|
||||||
|
});
|
||||||
|
|
||||||
|
client.data.userId = payload.sub;
|
||||||
|
this.connectedUsers.set(payload.sub, client.id);
|
||||||
|
this.logger.log(`User ${payload.sub} connected via WebSocket`);
|
||||||
|
} catch {
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDisconnect(client: Socket) {
|
||||||
|
const userId = client.data.userId;
|
||||||
|
if (userId) {
|
||||||
|
this.connectedUsers.delete(userId);
|
||||||
|
this.logger.log(`User ${userId} disconnected`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('join_chat')
|
||||||
|
async handleJoinChat(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() data: { chatId: string },
|
||||||
|
) {
|
||||||
|
const userId = client.data.userId;
|
||||||
|
if (!userId) throw new WsException('Unauthorized');
|
||||||
|
await client.join(`chat:${data.chatId}`);
|
||||||
|
return { event: 'joined_chat', chatId: data.chatId };
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('leave_chat')
|
||||||
|
async handleLeaveChat(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() data: { chatId: string },
|
||||||
|
) {
|
||||||
|
await client.leave(`chat:${data.chatId}`);
|
||||||
|
return { event: 'left_chat', chatId: data.chatId };
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('send_message')
|
||||||
|
async handleSendMessage(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() data: { chatId: string } & SendMessageDto,
|
||||||
|
) {
|
||||||
|
const userId = client.data.userId;
|
||||||
|
if (!userId) throw new WsException('Unauthorized');
|
||||||
|
|
||||||
|
const { chatId, ...msgDto } = data;
|
||||||
|
const newMessage = await this.chatService.sendMessage(userId, chatId, msgDto);
|
||||||
|
|
||||||
|
this.server.to(`chat:${chatId}`).emit('new_message', newMessage);
|
||||||
|
return newMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('typing')
|
||||||
|
handleTyping(
|
||||||
|
@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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
emitToUser(userId: string, event: string, data: any) {
|
||||||
|
const socketId = this.connectedUsers.get(userId);
|
||||||
|
if (socketId) {
|
||||||
|
this.server.to(socketId).emit(event, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/gateways/gateways.module.ts
Normal file
11
src/gateways/gateways.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ChatGateway } from './chat.gateway';
|
||||||
|
import { ChatModule } from '../modules/chat/chat.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ChatModule, AuthModule],
|
||||||
|
providers: [ChatGateway],
|
||||||
|
exports: [ChatGateway],
|
||||||
|
})
|
||||||
|
export class GatewaysModule {}
|
||||||
59
src/main.ts
Normal file
59
src/main.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import multipart from '@fastify/multipart';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
|
AppModule,
|
||||||
|
new FastifyAdapter({ logger: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const configService = app.get(ConfigService);
|
||||||
|
const port = configService.get<number>('app.port');
|
||||||
|
const nodeEnv = configService.get<string>('app.nodeEnv');
|
||||||
|
|
||||||
|
await app.register(multipart as any, {
|
||||||
|
limits: { fileSize: 50 * 1024 * 1024 },
|
||||||
|
});
|
||||||
|
|
||||||
|
app.enableCors({
|
||||||
|
origin: '*',
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||||
|
});
|
||||||
|
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
|
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
transformOptions: { enableImplicitConversion: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nodeEnv !== 'production') {
|
||||||
|
const swaggerConfig = new DocumentBuilder()
|
||||||
|
.setTitle('Daiting App API')
|
||||||
|
.setDescription('REST API for Daiting mobile application')
|
||||||
|
.setVersion('1.0')
|
||||||
|
.addBearerAuth()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||||
|
SwaggerModule.setup('api/docs', app, document, {
|
||||||
|
swaggerOptions: { persistAuthorization: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.listen(port, '0.0.0.0');
|
||||||
|
console.log(`🚀 Server running on http://localhost:${port}/api/v1`);
|
||||||
|
console.log(`📄 Swagger docs: http://localhost:${port}/api/docs`);
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
69
src/modules/chat/chat.controller.ts
Normal file
69
src/modules/chat/chat.controller.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { 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';
|
||||||
|
|
||||||
|
@ApiTags('chat')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('chats')
|
||||||
|
export class ChatController {
|
||||||
|
constructor(private readonly chatService: ChatService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Open a chat for a match' })
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':chatId/messages')
|
||||||
|
@ApiOperation({ summary: 'Get chat messages' })
|
||||||
|
getMessages(
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
@Param('chatId') chatId: string,
|
||||||
|
@Query('page') page = 1,
|
||||||
|
@Query('limit') limit = 50,
|
||||||
|
) {
|
||||||
|
return this.chatService.getChatMessages(userId, chatId, +page, +limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':chatId/messages')
|
||||||
|
@ApiOperation({ summary: 'Send a message' })
|
||||||
|
sendMessage(
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
@Param('chatId') chatId: string,
|
||||||
|
@Body() dto: SendMessageDto,
|
||||||
|
) {
|
||||||
|
return this.chatService.sendMessage(userId, chatId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':chatId')
|
||||||
|
@ApiOperation({ summary: 'Close a chat' })
|
||||||
|
closeChat(
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
@Param('chatId') chatId: string,
|
||||||
|
) {
|
||||||
|
return this.chatService.closeChat(userId, chatId);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/modules/chat/chat.module.ts
Normal file
10
src/modules/chat/chat.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ChatController } from './chat.controller';
|
||||||
|
import { ChatService } from './chat.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ChatController],
|
||||||
|
providers: [ChatService],
|
||||||
|
exports: [ChatService],
|
||||||
|
})
|
||||||
|
export class ChatModule {}
|
||||||
189
src/modules/chat/chat.service.ts
Normal file
189
src/modules/chat/chat.service.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { and, eq, or } from 'drizzle-orm';
|
||||||
|
import { DrizzleService } from '../../database/drizzle.service';
|
||||||
|
import { chat, match, message, profile, user } from '../../database/schema';
|
||||||
|
import { NotificationsService } from '../../notifications/notifications.service';
|
||||||
|
import { CreateChatDto } from './dto/create-chat.dto';
|
||||||
|
import { SendMessageDto } from './dto/send-message.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ChatService {
|
||||||
|
constructor(
|
||||||
|
private readonly drizzleService: DrizzleService,
|
||||||
|
private readonly notificationsService: NotificationsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createChat(userId: string, dto: CreateChatDto) {
|
||||||
|
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)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!foundMatch) throw new NotFoundException('Match not found');
|
||||||
|
|
||||||
|
const existingChat = await this.drizzleService.db
|
||||||
|
.select()
|
||||||
|
.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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.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))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (currentUser[0]?.activeChatId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'You already have 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,
|
||||||
|
status: 'active',
|
||||||
|
} as any)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await this.drizzleService.db
|
||||||
|
.update(user)
|
||||||
|
.set({ activeChatId: newChat.id } as any)
|
||||||
|
.where(or(eq(user.id, foundMatch.user1Id), eq(user.id, foundMatch.user2Id)));
|
||||||
|
|
||||||
|
return newChat;
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeChat(userId: string, chatId: string) {
|
||||||
|
const [foundChat] = await this.drizzleService.db
|
||||||
|
.select()
|
||||||
|
.from(chat)
|
||||||
|
.where(eq(chat.id, chatId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!foundChat) throw new NotFoundException('Chat not found');
|
||||||
|
if (foundChat.profile1Id !== userId && foundChat.profile2Id !== userId) {
|
||||||
|
throw new ForbiddenException('Not a chat participant');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.drizzleService.db
|
||||||
|
.update(chat)
|
||||||
|
.set({ status: 'closed' } as any)
|
||||||
|
.where(eq(chat.id, chatId));
|
||||||
|
|
||||||
|
await this.drizzleService.db
|
||||||
|
.update(user)
|
||||||
|
.set({ activeChatId: null } as any)
|
||||||
|
.where(or(eq(user.id, foundChat.profile1Id), eq(user.id, foundChat.profile2Id)));
|
||||||
|
|
||||||
|
return { message: 'Chat closed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyChats(userId: string) {
|
||||||
|
return this.drizzleService.db
|
||||||
|
.select()
|
||||||
|
.from(chat)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
or(eq(chat.profile1Id, userId), eq(chat.profile2Id, userId)),
|
||||||
|
eq(chat.status, 'active'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChatMessages(userId: string, chatId: string, page = 1, limit = 50) {
|
||||||
|
const [foundChat] = await this.drizzleService.db
|
||||||
|
.select()
|
||||||
|
.from(chat)
|
||||||
|
.where(eq(chat.id, chatId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!foundChat) throw new NotFoundException('Chat not found');
|
||||||
|
if (foundChat.profile1Id !== userId && foundChat.profile2Id !== userId) {
|
||||||
|
throw new ForbiddenException('Not a chat participant');
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
return this.drizzleService.db
|
||||||
|
.select()
|
||||||
|
.from(message)
|
||||||
|
.where(eq(message.chatId, chatId))
|
||||||
|
.orderBy(message.createdAt)
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(userId: string, chatId: string, dto: SendMessageDto) {
|
||||||
|
const [foundChat] = await this.drizzleService.db
|
||||||
|
.select()
|
||||||
|
.from(chat)
|
||||||
|
.where(and(eq(chat.id, chatId), eq(chat.status, 'active')))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!foundChat) throw new NotFoundException('Active chat not found');
|
||||||
|
if (foundChat.profile1Id !== userId && foundChat.profile2Id !== userId) {
|
||||||
|
throw new ForbiddenException('Not a chat participant');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dto.text && !dto.mediaUrl) {
|
||||||
|
throw new BadRequestException('Message must have text or media');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [newMessage] = await this.drizzleService.db
|
||||||
|
.insert(message)
|
||||||
|
.values({
|
||||||
|
chatId,
|
||||||
|
userId,
|
||||||
|
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 [recipient] = await this.drizzleService.db
|
||||||
|
.select({ fcmToken: user.fcmToken })
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, recipientId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (recipient?.fcmToken) {
|
||||||
|
await this.notificationsService.sendPushNotification(
|
||||||
|
recipient.fcmToken,
|
||||||
|
'New message',
|
||||||
|
dto.text?.substring(0, 100) || 'Media message',
|
||||||
|
{ chatId, messageId: newMessage.id, type: 'message' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/modules/chat/dto/create-chat.dto.ts
Normal file
8
src/modules/chat/dto/create-chat.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateChatDto {
|
||||||
|
@ApiProperty({ description: 'Match ID to open chat for' })
|
||||||
|
@IsUUID()
|
||||||
|
matchId: string;
|
||||||
|
}
|
||||||
19
src/modules/chat/dto/send-message.dto.ts
Normal file
19
src/modules/chat/dto/send-message.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class SendMessageDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
text?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
mediaUrl?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['photo', 'voice', 'video'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['photo', 'voice', 'video'])
|
||||||
|
mediaType?: 'photo' | 'voice' | 'video';
|
||||||
|
}
|
||||||
45
src/modules/cities/cities.controller.ts
Normal file
45
src/modules/cities/cities.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, 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';
|
||||||
|
|
||||||
|
@ApiTags('cities')
|
||||||
|
@Controller('cities')
|
||||||
|
export class CitiesController {
|
||||||
|
constructor(private readonly citiesService: CitiesService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Get all cities' })
|
||||||
|
findAll() {
|
||||||
|
return this.citiesService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get(':cityId/districts')
|
||||||
|
@ApiOperation({ summary: 'Get districts for a city' })
|
||||||
|
findDistricts(@Param('cityId') cityId: string) {
|
||||||
|
return this.citiesService.findDistricts(cityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('admin')
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Create city (admin only)' })
|
||||||
|
createCity(@Body() body: { name: string; lat: number; lng: number }) {
|
||||||
|
return this.citiesService.createCity(body.name, body.lat, body.lng);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('admin')
|
||||||
|
@Post(':cityId/districts')
|
||||||
|
@ApiOperation({ summary: 'Create district (admin only)' })
|
||||||
|
createDistrict(@Param('cityId') cityId: string, @Body() body: { name: string }) {
|
||||||
|
return this.citiesService.createDistrict(cityId, body.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/modules/cities/cities.module.ts
Normal file
9
src/modules/cities/cities.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CitiesController } from './cities.controller';
|
||||||
|
import { CitiesService } from './cities.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [CitiesController],
|
||||||
|
providers: [CitiesService],
|
||||||
|
})
|
||||||
|
export class CitiesModule {}
|
||||||
37
src/modules/cities/cities.service.ts
Normal file
37
src/modules/cities/cities.service.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { DrizzleService } from '../../database/drizzle.service';
|
||||||
|
import { city, cityDistrict } from '../../database/schema';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CitiesService {
|
||||||
|
constructor(private readonly drizzleService: DrizzleService) {}
|
||||||
|
|
||||||
|
async findAll() {
|
||||||
|
return this.drizzleService.db.select().from(city).orderBy(city.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findDistricts(cityId: string) {
|
||||||
|
return this.drizzleService.db
|
||||||
|
.select()
|
||||||
|
.from(cityDistrict)
|
||||||
|
.where(eq(cityDistrict.cityId, cityId))
|
||||||
|
.orderBy(cityDistrict.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCity(name: string, lat: number, lng: number) {
|
||||||
|
const [newCity] = await this.drizzleService.db
|
||||||
|
.insert(city)
|
||||||
|
.values({ name, lat: lat.toString(), lng: lng.toString() })
|
||||||
|
.returning();
|
||||||
|
return newCity;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDistrict(cityId: string, name: string) {
|
||||||
|
const [newDistrict] = await this.drizzleService.db
|
||||||
|
.insert(cityDistrict)
|
||||||
|
.values({ cityId, name })
|
||||||
|
.returning();
|
||||||
|
return newDistrict;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/modules/dates/dates.controller.ts
Normal file
46
src/modules/dates/dates.controller.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, 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';
|
||||||
|
|
||||||
|
@ApiTags('dates')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('dates')
|
||||||
|
export class DatesController {
|
||||||
|
constructor(private readonly datesService: DatesService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Propose a date/meetup' })
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/status')
|
||||||
|
@ApiOperation({ summary: 'Update date status' })
|
||||||
|
updateStatus(
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateDateStatusDto,
|
||||||
|
) {
|
||||||
|
return this.datesService.updateStatus(userId, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('statuses')
|
||||||
|
@ApiOperation({ summary: 'Get available date statuses' })
|
||||||
|
getStatuses() {
|
||||||
|
return this.datesService.getStatuses();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/modules/dates/dates.module.ts
Normal file
9
src/modules/dates/dates.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DatesController } from './dates.controller';
|
||||||
|
import { DatesService } from './dates.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [DatesController],
|
||||||
|
providers: [DatesService],
|
||||||
|
})
|
||||||
|
export class DatesModule {}
|
||||||
72
src/modules/dates/dates.service.ts
Normal file
72
src/modules/dates/dates.service.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
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 { CreateDateDto } from './dto/create-date.dto';
|
||||||
|
import { UpdateDateStatusDto } from './dto/update-date-status.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DatesService {
|
||||||
|
constructor(private readonly drizzleService: DrizzleService) {}
|
||||||
|
|
||||||
|
async create(userId: string, dto: CreateDateDto) {
|
||||||
|
let statusId = dto.statusId;
|
||||||
|
|
||||||
|
if (!statusId) {
|
||||||
|
const [pending] = await this.drizzleService.db
|
||||||
|
.select({ id: dateStatus.id })
|
||||||
|
.from(dateStatus)
|
||||||
|
.where(eq(dateStatus.text, 'pending'))
|
||||||
|
.limit(1);
|
||||||
|
statusId = pending?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [newDate] = await this.drizzleService.db
|
||||||
|
.insert(date)
|
||||||
|
.values({
|
||||||
|
user1Id: userId,
|
||||||
|
user2Id: dto.partnerId,
|
||||||
|
lat: dto.lat.toString(),
|
||||||
|
lng: dto.lng.toString(),
|
||||||
|
time: new Date(dto.time),
|
||||||
|
statusId: statusId || null,
|
||||||
|
} as any)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return newDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyDates(userId: string) {
|
||||||
|
return this.drizzleService.db
|
||||||
|
.select()
|
||||||
|
.from(date)
|
||||||
|
.leftJoin(dateStatus, eq(dateStatus.id, date.statusId))
|
||||||
|
.where(or(eq(date.user1Id, userId), eq(date.user2Id, userId)))
|
||||||
|
.orderBy(date.time);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(userId: string, dateId: string, dto: UpdateDateStatusDto) {
|
||||||
|
const [found] = await this.drizzleService.db
|
||||||
|
.select()
|
||||||
|
.from(date)
|
||||||
|
.where(eq(date.id, dateId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!found) throw new NotFoundException('Date not found');
|
||||||
|
if (found.user1Id !== userId && found.user2Id !== userId) {
|
||||||
|
throw new ForbiddenException('Not a participant');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await this.drizzleService.db
|
||||||
|
.update(date)
|
||||||
|
.set({ statusId: dto.statusId } as any)
|
||||||
|
.where(eq(date.id, dateId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatuses() {
|
||||||
|
return this.drizzleService.db.select().from(dateStatus);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/modules/dates/dto/create-date.dto.ts
Normal file
25
src/modules/dates/dto/create-date.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsDateString, IsNumber, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateDateDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsUUID()
|
||||||
|
partnerId: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsNumber()
|
||||||
|
lat: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsNumber()
|
||||||
|
lng: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsDateString()
|
||||||
|
time: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
statusId?: string;
|
||||||
|
}
|
||||||
8
src/modules/dates/dto/update-date-status.dto.ts
Normal file
8
src/modules/dates/dto/update-date-status.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateDateStatusDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsUUID()
|
||||||
|
statusId: string;
|
||||||
|
}
|
||||||
63
src/modules/feed/dto/feed-filter.dto.ts
Normal file
63
src/modules/feed/dto/feed-filter.dto.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsArray, IsNumber, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class FeedFilterDto {
|
||||||
|
@ApiPropertyOptional({ default: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: 20 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(50)
|
||||||
|
limit?: number = 20;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'City UUID filter' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
cityId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'District UUID filter' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
districtId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Search radius in km' })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(500)
|
||||||
|
radiusKm?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(18)
|
||||||
|
ageMin?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Max(100)
|
||||||
|
ageMax?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Search keyword in description/name' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
keyword?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [String], description: 'Tag IDs to filter' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID(undefined, { each: true })
|
||||||
|
tagIds?: string[];
|
||||||
|
}
|
||||||
23
src/modules/feed/feed.controller.ts
Normal file
23
src/modules/feed/feed.controller.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } 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';
|
||||||
|
|
||||||
|
@ApiTags('feed')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@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,
|
||||||
|
) {
|
||||||
|
return this.feedService.getFeed(userId, filter);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/modules/feed/feed.module.ts
Normal file
9
src/modules/feed/feed.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { FeedController } from './feed.controller';
|
||||||
|
import { FeedService } from './feed.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [FeedController],
|
||||||
|
providers: [FeedService],
|
||||||
|
})
|
||||||
|
export class FeedModule {}
|
||||||
114
src/modules/feed/feed.service.ts
Normal file
114
src/modules/feed/feed.service.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { Injectable } 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 { 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;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const alreadyInteracted = await this.drizzleService.db
|
||||||
|
.select({ targetUser: like.targetUser })
|
||||||
|
.from(like)
|
||||||
|
.where(eq(like.sourceUser, currentUserId));
|
||||||
|
|
||||||
|
const interactedIds = alreadyInteracted.map((r) => r.targetUser);
|
||||||
|
|
||||||
|
const conditions: any[] = [
|
||||||
|
ne(profile.userId, currentUserId),
|
||||||
|
ne(user.status, 'banned'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (interactedIds.length > 0) {
|
||||||
|
conditions.push(notInArray(profile.userId, interactedIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cityId) conditions.push(eq(profile.cityId, cityId));
|
||||||
|
if (districtId) conditions.push(eq(profile.districtId, districtId));
|
||||||
|
|
||||||
|
if (ageMin) {
|
||||||
|
const maxBirthDate = new Date();
|
||||||
|
maxBirthDate.setFullYear(maxBirthDate.getFullYear() - ageMin);
|
||||||
|
conditions.push(lte(profile.birthDate, maxBirthDate.toISOString().split('T')[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ageMax) {
|
||||||
|
const minBirthDate = new Date();
|
||||||
|
minBirthDate.setFullYear(minBirthDate.getFullYear() - ageMax);
|
||||||
|
conditions.push(gte(profile.birthDate, minBirthDate.toISOString().split('T')[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyword) {
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
ilike(profile.name, `%${keyword}%`),
|
||||||
|
ilike(profile.description, `%${keyword}%`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 rows = await this.drizzleService.db
|
||||||
|
.select({
|
||||||
|
id: profile.id,
|
||||||
|
userId: profile.userId,
|
||||||
|
name: profile.name,
|
||||||
|
birthDate: profile.birthDate,
|
||||||
|
cityId: profile.cityId,
|
||||||
|
districtId: profile.districtId,
|
||||||
|
description: profile.description,
|
||||||
|
nation: profile.nation,
|
||||||
|
height: profile.height,
|
||||||
|
weight: profile.weight,
|
||||||
|
})
|
||||||
|
.from(profile)
|
||||||
|
.innerJoin(user, eq(user.id, profile.userId))
|
||||||
|
.where(and(...conditions))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
.orderBy(sql`RANDOM()`);
|
||||||
|
|
||||||
|
const enriched = await Promise.all(
|
||||||
|
rows.map(async (p) => {
|
||||||
|
const tags = await 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 };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { data: enriched, page, limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateAge(birthDate: string): number {
|
||||||
|
const birth = new Date(birthDate);
|
||||||
|
const today = new Date();
|
||||||
|
let age = today.getFullYear() - birth.getFullYear();
|
||||||
|
const m = today.getMonth() - birth.getMonth();
|
||||||
|
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
|
||||||
|
return age;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/modules/greetings/greetings.controller.ts
Normal file
38
src/modules/greetings/greetings.controller.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, 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';
|
||||||
|
|
||||||
|
@ApiTags('greetings')
|
||||||
|
@Controller('greetings')
|
||||||
|
export class GreetingsController {
|
||||||
|
constructor(private readonly greetingsService: GreetingsService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Get all greeting phrases' })
|
||||||
|
findAll() {
|
||||||
|
return this.greetingsService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('admin')
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Add greeting phrase (admin only)' })
|
||||||
|
create(@Body('text') text: string) {
|
||||||
|
return this.greetingsService.create(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('admin')
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Delete greeting phrase (admin only)' })
|
||||||
|
delete(@Param('id') id: string) {
|
||||||
|
return this.greetingsService.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/modules/greetings/greetings.module.ts
Normal file
9
src/modules/greetings/greetings.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { GreetingsController } from './greetings.controller';
|
||||||
|
import { GreetingsService } from './greetings.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [GreetingsController],
|
||||||
|
providers: [GreetingsService],
|
||||||
|
})
|
||||||
|
export class GreetingsModule {}
|
||||||
26
src/modules/greetings/greetings.service.ts
Normal file
26
src/modules/greetings/greetings.service.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { DrizzleService } from '../../database/drizzle.service';
|
||||||
|
import { greetings } from '../../database/schema';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GreetingsService {
|
||||||
|
constructor(private readonly drizzleService: DrizzleService) {}
|
||||||
|
|
||||||
|
async findAll() {
|
||||||
|
return this.drizzleService.db.select().from(greetings);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(text: string) {
|
||||||
|
const [newGreeting] = await this.drizzleService.db
|
||||||
|
.insert(greetings)
|
||||||
|
.values({ text })
|
||||||
|
.returning();
|
||||||
|
return newGreeting;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string) {
|
||||||
|
await this.drizzleService.db.delete(greetings).where(eq(greetings.id, id));
|
||||||
|
return { message: 'Greeting deleted' };
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/modules/likes/dto/create-like.dto.ts
Normal file
12
src/modules/likes/dto/create-like.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsEnum, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateLikeDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsUUID()
|
||||||
|
targetUserId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['like', 'dislike'] })
|
||||||
|
@IsEnum(['like', 'dislike'])
|
||||||
|
type: 'like' | 'dislike';
|
||||||
|
}
|
||||||
29
src/modules/likes/likes.controller.ts
Normal file
29
src/modules/likes/likes.controller.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, 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 { LikesService } from './likes.service';
|
||||||
|
|
||||||
|
@ApiTags('likes')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('likes')
|
||||||
|
export class LikesController {
|
||||||
|
constructor(private readonly likesService: LikesService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Like or dislike a user' })
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/modules/likes/likes.module.ts
Normal file
9
src/modules/likes/likes.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { LikesController } from './likes.controller';
|
||||||
|
import { LikesService } from './likes.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [LikesController],
|
||||||
|
providers: [LikesService],
|
||||||
|
})
|
||||||
|
export class LikesModule {}
|
||||||
147
src/modules/likes/likes.service.ts
Normal file
147
src/modules/likes/likes.service.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { and, eq, or } from 'drizzle-orm';
|
||||||
|
import { DrizzleService } from '../../database/drizzle.service';
|
||||||
|
import { like, match, 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()
|
||||||
|
export class LikesService {
|
||||||
|
constructor(
|
||||||
|
private readonly drizzleService: DrizzleService,
|
||||||
|
private readonly notificationsService: NotificationsService,
|
||||||
|
private readonly redisService: RedisService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createLike(sourceUserId: string, dto: CreateLikeDto) {
|
||||||
|
if (sourceUserId === dto.targetUserId) {
|
||||||
|
throw new BadRequestException('Cannot like yourself');
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxMatches = this.configService.get<number>('app.maxMatchesBeforePause');
|
||||||
|
const activeMatchesCount = await this.getActiveMatchesCount(sourceUserId);
|
||||||
|
if (activeMatchesCount >= maxMatches) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`You have ${activeMatchesCount} matches. Resolve them before searching for new ones.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.drizzleService.db
|
||||||
|
.select()
|
||||||
|
.from(like)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(like.sourceUser, sourceUserId),
|
||||||
|
eq(like.targetUser, dto.targetUserId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
throw new BadRequestException('Already reacted to this user');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [newLike] = await this.drizzleService.db
|
||||||
|
.insert(like)
|
||||||
|
.values({
|
||||||
|
sourceUser: sourceUserId,
|
||||||
|
targetUser: dto.targetUserId,
|
||||||
|
type: dto.type,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (dto.type === 'like') {
|
||||||
|
return this.checkAndCreateMatch(sourceUserId, dto.targetUserId, newLike);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { like: newLike, match: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkAndCreateMatch(userId1: string, userId2: string, newLike: any) {
|
||||||
|
const reverseLike = await this.drizzleService.db
|
||||||
|
.select()
|
||||||
|
.from(like)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(like.sourceUser, userId2),
|
||||||
|
eq(like.targetUser, userId1),
|
||||||
|
eq(like.type, 'like'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
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)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingMatch.length > 0) {
|
||||||
|
return { like: newLike, match: existingMatch[0] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [newMatch] = await this.drizzleService.db
|
||||||
|
.insert(match)
|
||||||
|
.values({ user1Id: userId1, user2Id: userId2 })
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await this.notifyMatch(userId1, userId2, newMatch.id);
|
||||||
|
|
||||||
|
return { like: newLike, match: newMatch };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getActiveMatchesCount(userId: 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)),
|
||||||
|
);
|
||||||
|
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)));
|
||||||
|
|
||||||
|
for (const u of users) {
|
||||||
|
if (u.fcmToken) {
|
||||||
|
await this.notificationsService.sendPushNotification(
|
||||||
|
u.fcmToken,
|
||||||
|
'New Match!',
|
||||||
|
'You have a new match! Start chatting now.',
|
||||||
|
{ matchId, type: 'match' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.redisService.publish('match:created', JSON.stringify({ matchId, userId1, userId2 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyMatches(userId: string) {
|
||||||
|
return this.drizzleService.db
|
||||||
|
.select()
|
||||||
|
.from(match)
|
||||||
|
.where(
|
||||||
|
or(eq(match.user1Id, userId), eq(match.user2Id, userId)),
|
||||||
|
)
|
||||||
|
.orderBy(match.createdAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/modules/media/media.controller.ts
Normal file
55
src/modules/media/media.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiConsumes, 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';
|
||||||
|
|
||||||
|
@ApiTags('media')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('media')
|
||||||
|
export class MediaController {
|
||||||
|
constructor(private readonly mediaService: MediaService) {}
|
||||||
|
|
||||||
|
@Post('upload')
|
||||||
|
@ApiOperation({ summary: 'Upload photo or video' })
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
async upload(
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
@Req() req: FastifyRequest,
|
||||||
|
@Query('type') type: 'photo' | 'video' = 'photo',
|
||||||
|
) {
|
||||||
|
const data = await (req as any).file();
|
||||||
|
if (!data) {
|
||||||
|
throw new Error('No file provided');
|
||||||
|
}
|
||||||
|
const buffer = await data.toBuffer();
|
||||||
|
return this.mediaService.uploadMedia(
|
||||||
|
userId,
|
||||||
|
{ buffer, originalname: data.filename, mimetype: data.mimetype },
|
||||||
|
type,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Get my media' })
|
||||||
|
getMyMedia(@CurrentUser('id') userId: string) {
|
||||||
|
return this.mediaService.getByUserId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Delete media' })
|
||||||
|
deleteMedia(@CurrentUser('id') userId: string, @Param('id') id: string) {
|
||||||
|
return this.mediaService.deleteMedia(userId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/modules/media/media.module.ts
Normal file
10
src/modules/media/media.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MediaController } from './media.controller';
|
||||||
|
import { MediaService } from './media.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [MediaController],
|
||||||
|
providers: [MediaService],
|
||||||
|
exports: [MediaService],
|
||||||
|
})
|
||||||
|
export class MediaModule {}
|
||||||
60
src/modules/media/media.service.ts
Normal file
60
src/modules/media/media.service.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { DrizzleService } from '../../database/drizzle.service';
|
||||||
|
import { media } from '../../database/schema';
|
||||||
|
import { StorageService } from '../../storage/storage.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MediaService {
|
||||||
|
constructor(
|
||||||
|
private readonly drizzleService: DrizzleService,
|
||||||
|
private readonly storageService: StorageService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async uploadMedia(
|
||||||
|
userId: string,
|
||||||
|
file: { buffer: Buffer; originalname: string; mimetype: string },
|
||||||
|
type: 'photo' | 'video',
|
||||||
|
) {
|
||||||
|
const folder = type === 'photo' ? 'photos' : 'videos';
|
||||||
|
const objectName = await this.storageService.uploadFile(
|
||||||
|
file.buffer,
|
||||||
|
file.originalname,
|
||||||
|
file.mimetype,
|
||||||
|
folder,
|
||||||
|
);
|
||||||
|
const publicUrl = this.storageService.getPublicUrl(objectName);
|
||||||
|
|
||||||
|
const [newMedia] = await this.drizzleService.db
|
||||||
|
.insert(media)
|
||||||
|
.values({ userId, path: publicUrl, type })
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return newMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByUserId(userId: string) {
|
||||||
|
return this.drizzleService.db
|
||||||
|
.select()
|
||||||
|
.from(media)
|
||||||
|
.where(eq(media.userId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMedia(userId: string, mediaId: string) {
|
||||||
|
const [found] = await this.drizzleService.db
|
||||||
|
.select()
|
||||||
|
.from(media)
|
||||||
|
.where(eq(media.id, mediaId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!found || found.userId !== userId) {
|
||||||
|
throw new NotFoundException('Media not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectName = found.path.split('/').slice(-2).join('/');
|
||||||
|
await this.storageService.deleteFile(objectName).catch(() => {});
|
||||||
|
|
||||||
|
await this.drizzleService.db.delete(media).where(eq(media.id, mediaId));
|
||||||
|
return { message: 'Media deleted' };
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/modules/profiles/dto/create-profile.dto.ts
Normal file
53
src/modules/profiles/dto/create-profile.dto.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsArray, IsDateString, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateProfileDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '1995-06-15' })
|
||||||
|
@IsDateString()
|
||||||
|
birthDate: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['male', 'female'] })
|
||||||
|
@IsEnum(['male', 'female'])
|
||||||
|
gender: 'male' | 'female';
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
cityId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
districtId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
nation?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
height?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
weight?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [String] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID(undefined, { each: true })
|
||||||
|
tagIds?: string[];
|
||||||
|
}
|
||||||
4
src/modules/profiles/dto/update-profile.dto.ts
Normal file
4
src/modules/profiles/dto/update-profile.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateProfileDto } from './create-profile.dto';
|
||||||
|
|
||||||
|
export class UpdateProfileDto extends PartialType(CreateProfileDto) {}
|
||||||
45
src/modules/profiles/profiles.controller.ts
Normal file
45
src/modules/profiles/profiles.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, 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 { ProfilesService } from './profiles.service';
|
||||||
|
|
||||||
|
@ApiTags('profiles')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('profiles')
|
||||||
|
export class ProfilesController {
|
||||||
|
constructor(private readonly profilesService: ProfilesService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Create my profile' })
|
||||||
|
create(
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
@Body() dto: CreateProfileDto,
|
||||||
|
) {
|
||||||
|
return this.profilesService.create(userId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put()
|
||||||
|
@ApiOperation({ summary: 'Update my profile' })
|
||||||
|
update(
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
@Body() dto: UpdateProfileDto,
|
||||||
|
) {
|
||||||
|
return this.profilesService.update(userId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
@ApiOperation({ summary: 'Get my profile' })
|
||||||
|
getMyProfile(@CurrentUser('id') userId: string) {
|
||||||
|
return this.profilesService.findByUserId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get profile by ID' })
|
||||||
|
findOne(@Param('id') id: string) {
|
||||||
|
return this.profilesService.findByProfileId(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/modules/profiles/profiles.module.ts
Normal file
10
src/modules/profiles/profiles.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ProfilesController } from './profiles.controller';
|
||||||
|
import { ProfilesService } from './profiles.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ProfilesController],
|
||||||
|
providers: [ProfilesService],
|
||||||
|
exports: [ProfilesService],
|
||||||
|
})
|
||||||
|
export class ProfilesModule {}
|
||||||
121
src/modules/profiles/profiles.service.ts
Normal file
121
src/modules/profiles/profiles.service.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
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 { CreateProfileDto } from './dto/create-profile.dto';
|
||||||
|
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
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({
|
||||||
|
userId,
|
||||||
|
name: dto.name,
|
||||||
|
birthDate: dto.birthDate,
|
||||||
|
gender: dto.gender,
|
||||||
|
cityId: dto.cityId || null,
|
||||||
|
districtId: dto.districtId || null,
|
||||||
|
description: dto.description || null,
|
||||||
|
nation: dto.nation || null,
|
||||||
|
height: dto.height || null,
|
||||||
|
weight: dto.weight || null,
|
||||||
|
} as any)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (dto.tagIds?.length) {
|
||||||
|
await this.setTags(newProfile.id, dto.tagIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
const { tagIds, ...fields } = dto;
|
||||||
|
|
||||||
|
if (Object.keys(fields).length > 0) {
|
||||||
|
await this.drizzleService.db
|
||||||
|
.update(profile)
|
||||||
|
.set(fields)
|
||||||
|
.where(eq(profile.id, found.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagIds !== undefined) {
|
||||||
|
await this.setTags(found.id, tagIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findByProfileId(found.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUserId(userId: string) {
|
||||||
|
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');
|
||||||
|
return this.findByProfileId(found.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByProfileId(profileId: string) {
|
||||||
|
const [found] = await this.drizzleService.db
|
||||||
|
.select()
|
||||||
|
.from(profile)
|
||||||
|
.leftJoin(city, eq(city.id, profile.cityId))
|
||||||
|
.leftJoin(cityDistrict, eq(cityDistrict.id, profile.districtId))
|
||||||
|
.where(eq(profile.id, profileId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!found) throw new NotFoundException('Profile not found');
|
||||||
|
|
||||||
|
const tags = await 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
|
||||||
|
.select()
|
||||||
|
.from(media)
|
||||||
|
.where(eq(media.userId, found.profile.userId));
|
||||||
|
|
||||||
|
return { ...found.profile, city: found.city, district: found.city_district, tags, media: medias };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setTags(profileId: string, tagIds: string[]) {
|
||||||
|
await this.drizzleService.db
|
||||||
|
.delete(profileTag)
|
||||||
|
.where(eq(profileTag.profileId, profileId));
|
||||||
|
|
||||||
|
if (tagIds.length > 0) {
|
||||||
|
await this.drizzleService.db.insert(profileTag).values(
|
||||||
|
tagIds.map((tagId) => ({ profileId, tagId })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/modules/reports/dto/create-report.dto.ts
Normal file
17
src/modules/reports/dto/create-report.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateReportDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsUUID()
|
||||||
|
entityId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['profile', 'message'] })
|
||||||
|
@IsEnum(['profile', 'message'])
|
||||||
|
entityType: 'profile' | 'message';
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
33
src/modules/reports/reports.controller.ts
Normal file
33
src/modules/reports/reports.controller.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, 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 { ReportsService } from './reports.service';
|
||||||
|
|
||||||
|
@ApiTags('reports')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('reports')
|
||||||
|
export class ReportsController {
|
||||||
|
constructor(private readonly reportsService: ReportsService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Submit a report' })
|
||||||
|
create(
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
@Body() dto: CreateReportDto,
|
||||||
|
) {
|
||||||
|
return this.reportsService.create(userId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Roles('admin', 'moderator')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@ApiOperation({ summary: 'Get all reports (admin/moderator)' })
|
||||||
|
getAll() {
|
||||||
|
return this.reportsService.getAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/modules/reports/reports.module.ts
Normal file
9
src/modules/reports/reports.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ReportsController } from './reports.controller';
|
||||||
|
import { ReportsService } from './reports.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ReportsController],
|
||||||
|
providers: [ReportsService],
|
||||||
|
})
|
||||||
|
export class ReportsModule {}
|
||||||
34
src/modules/reports/reports.service.ts
Normal file
34
src/modules/reports/reports.service.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DrizzleService } from '../../database/drizzle.service';
|
||||||
|
import { report } from '../../database/schema';
|
||||||
|
import { CreateReportDto } from './dto/create-report.dto';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ReportsService {
|
||||||
|
constructor(private readonly drizzleService: DrizzleService) {}
|
||||||
|
|
||||||
|
async create(userId: string, dto: CreateReportDto) {
|
||||||
|
const [newReport] = await this.drizzleService.db
|
||||||
|
.insert(report)
|
||||||
|
.values({
|
||||||
|
sourceUser: userId,
|
||||||
|
entityId: dto.entityId,
|
||||||
|
entityType: dto.entityType,
|
||||||
|
description: dto.description || null,
|
||||||
|
} as any)
|
||||||
|
.returning();
|
||||||
|
return newReport;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAll() {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/modules/tags/tags.controller.ts
Normal file
38
src/modules/tags/tags.controller.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, 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';
|
||||||
|
|
||||||
|
@ApiTags('tags')
|
||||||
|
@Controller('tags')
|
||||||
|
export class TagsController {
|
||||||
|
constructor(private readonly tagsService: TagsService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Get all tags' })
|
||||||
|
findAll() {
|
||||||
|
return this.tagsService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('admin')
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Create tag (admin only)' })
|
||||||
|
create(@Body('value') value: string) {
|
||||||
|
return this.tagsService.create(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('admin')
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Delete tag (admin only)' })
|
||||||
|
delete(@Param('id') id: string) {
|
||||||
|
return this.tagsService.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/modules/tags/tags.module.ts
Normal file
9
src/modules/tags/tags.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TagsController } from './tags.controller';
|
||||||
|
import { TagsService } from './tags.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [TagsController],
|
||||||
|
providers: [TagsService],
|
||||||
|
})
|
||||||
|
export class TagsModule {}
|
||||||
34
src/modules/tags/tags.service.ts
Normal file
34
src/modules/tags/tags.service.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { ConflictException, Injectable } from '@nestjs/common';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { DrizzleService } from '../../database/drizzle.service';
|
||||||
|
import { tag } from '../../database/schema';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TagsService {
|
||||||
|
constructor(private readonly drizzleService: DrizzleService) {}
|
||||||
|
|
||||||
|
async findAll() {
|
||||||
|
return this.drizzleService.db.select().from(tag).orderBy(tag.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(value: string) {
|
||||||
|
const existing = await this.drizzleService.db
|
||||||
|
.select()
|
||||||
|
.from(tag)
|
||||||
|
.where(eq(tag.value, value))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length > 0) throw new ConflictException('Tag already exists');
|
||||||
|
|
||||||
|
const [newTag] = await this.drizzleService.db
|
||||||
|
.insert(tag)
|
||||||
|
.values({ value })
|
||||||
|
.returning();
|
||||||
|
return newTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string) {
|
||||||
|
await this.drizzleService.db.delete(tag).where(eq(tag.id, id));
|
||||||
|
return { message: 'Tag deleted' };
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/modules/users/dto/update-user.dto.ts
Normal file
9
src/modules/users/dto/update-user.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateUserDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
fcmToken?: string;
|
||||||
|
}
|
||||||
43
src/modules/users/users.controller.ts
Normal file
43
src/modules/users/users.controller.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Controller, Get, Param, Patch, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, 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';
|
||||||
|
|
||||||
|
@ApiTags('users')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('users')
|
||||||
|
export class UsersController {
|
||||||
|
constructor(private readonly usersService: UsersService) {}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
@ApiOperation({ summary: 'Get current user profile' })
|
||||||
|
getMe(@CurrentUser('id') userId: string) {
|
||||||
|
return this.usersService.getMyProfile(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get user by ID' })
|
||||||
|
findOne(@Param('id') id: string) {
|
||||||
|
return this.usersService.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/ban')
|
||||||
|
@Roles('admin', 'moderator')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@ApiOperation({ summary: 'Ban user (admin/moderator only)' })
|
||||||
|
ban(@Param('id') id: string) {
|
||||||
|
return this.usersService.banUser(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/activate')
|
||||||
|
@Roles('admin', 'moderator')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@ApiOperation({ summary: 'Activate user (admin/moderator only)' })
|
||||||
|
activate(@Param('id') id: string) {
|
||||||
|
return this.usersService.activateUser(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/modules/users/users.module.ts
Normal file
10
src/modules/users/users.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { UsersController } from './users.controller';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [UsersController],
|
||||||
|
providers: [UsersService],
|
||||||
|
exports: [UsersService],
|
||||||
|
})
|
||||||
|
export class UsersModule {}
|
||||||
71
src/modules/users/users.service.ts
Normal file
71
src/modules/users/users.service.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService {
|
||||||
|
constructor(private readonly drizzleService: DrizzleService) {}
|
||||||
|
|
||||||
|
async findById(id: string) {
|
||||||
|
const [found] = await this.drizzleService.db
|
||||||
|
.select()
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!found) throw new NotFoundException('User not found');
|
||||||
|
const { password, ...rest } = found;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByIdWithProfile(id: 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMediaByUserId(userId: string) {
|
||||||
|
return this.drizzleService.db
|
||||||
|
.select()
|
||||||
|
.from(media)
|
||||||
|
.where(eq(media.userId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async banUser(userId: string) {
|
||||||
|
await this.drizzleService.db
|
||||||
|
.update(user)
|
||||||
|
.set({ status: 'banned' } as any)
|
||||||
|
.where(eq(user.id, userId));
|
||||||
|
return { message: 'User banned' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async activateUser(userId: string) {
|
||||||
|
await this.drizzleService.db
|
||||||
|
.update(user)
|
||||||
|
.set({ status: 'active' } as any)
|
||||||
|
.where(eq(user.id, userId));
|
||||||
|
return { message: 'User activated' };
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/notifications/notifications.module.ts
Normal file
11
src/notifications/notifications.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { NotificationsService } from './notifications.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
providers: [NotificationsService],
|
||||||
|
exports: [NotificationsService],
|
||||||
|
})
|
||||||
|
export class NotificationsModule {}
|
||||||
57
src/notifications/notifications.service.ts
Normal file
57
src/notifications/notifications.service.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as admin from 'firebase-admin';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationsService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(NotificationsService.name);
|
||||||
|
private app: admin.app.App;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
const projectId = this.configService.get<string>('firebase.projectId');
|
||||||
|
const privateKey = this.configService.get<string>('firebase.privateKey');
|
||||||
|
const clientEmail = this.configService.get<string>('firebase.clientEmail');
|
||||||
|
|
||||||
|
if (!projectId || !privateKey || !clientEmail) {
|
||||||
|
this.logger.warn('Firebase credentials not configured — push notifications disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (admin.apps.length === 0) {
|
||||||
|
this.app = admin.initializeApp({
|
||||||
|
credential: admin.credential.cert({ projectId, privateKey, clientEmail }),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.app = admin.apps[0];
|
||||||
|
}
|
||||||
|
this.logger.log('Firebase initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendPushNotification(fcmToken: string, title: string, body: string, data?: Record<string, string>) {
|
||||||
|
if (!this.app || !fcmToken) return;
|
||||||
|
try {
|
||||||
|
await admin.messaging(this.app).send({
|
||||||
|
token: fcmToken,
|
||||||
|
notification: { title, body },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to send push notification: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMulticast(fcmTokens: string[], title: string, body: string, data?: Record<string, string>) {
|
||||||
|
if (!this.app || !fcmTokens.length) return;
|
||||||
|
try {
|
||||||
|
await admin.messaging(this.app).sendEachForMulticast({
|
||||||
|
tokens: fcmTokens,
|
||||||
|
notification: { title, body },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to send multicast: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/redis/redis.module.ts
Normal file
11
src/redis/redis.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { RedisService } from './redis.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
providers: [RedisService],
|
||||||
|
exports: [RedisService],
|
||||||
|
})
|
||||||
|
export class RedisModule {}
|
||||||
27
src/redis/redis.service.ts
Normal file
27
src/redis/redis.service.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Injectable, OnModuleDestroy, OnModuleInit, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RedisService extends Redis implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(RedisService.name);
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
super({
|
||||||
|
host: configService.get<string>('redis.host'),
|
||||||
|
port: configService.get<number>('redis.port'),
|
||||||
|
password: configService.get<string>('redis.password') || undefined,
|
||||||
|
lazyConnect: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.connect();
|
||||||
|
this.logger.log('Redis connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.quit();
|
||||||
|
this.logger.log('Redis disconnected');
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user