feat: expand backend admin marketplace and scaling
فشلت بعض الفحوصات
/ deploy (push) Failing after 1m22s
فشلت بعض الفحوصات
/ deploy (push) Failing after 1m22s
هذا الالتزام موجود في:
44
.env.example
44
.env.example
@@ -1,9 +1,12 @@
|
|||||||
|
# For mobile/LAN testing set PUBLIC_BASE_URL to your machine IP, not localhost.
|
||||||
|
# Example: PUBLIC_BASE_URL=http://192.168.1.12:4000
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
PORT=4000
|
PORT=4000
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PUBLIC_BASE_URL=http://localhost:4000
|
PUBLIC_BASE_URL=http://localhost:4000
|
||||||
RESPONSE_ENVELOPE_ENABLED=false
|
RESPONSE_ENVELOPE_ENABLED=false
|
||||||
GLOBAL_PREFIX=api/v1
|
GLOBAL_PREFIX=api/v1
|
||||||
|
# Add every frontend origin used by web/mobile debug tools.
|
||||||
CORS_ORIGINS=http://localhost:3000,http://192.168.1.14:3000,http://192.168.1.14:5173
|
CORS_ORIGINS=http://localhost:3000,http://192.168.1.14:3000,http://192.168.1.14:5173
|
||||||
MONGODB_URI=mongodb://127.0.0.1:27017/oudelaa
|
MONGODB_URI=mongodb://127.0.0.1:27017/oudelaa
|
||||||
JWT_ACCESS_SECRET=change_me_access_secret
|
JWT_ACCESS_SECRET=change_me_access_secret
|
||||||
@@ -11,6 +14,7 @@ JWT_ACCESS_EXPIRES_IN=15m
|
|||||||
JWT_REFRESH_SECRET=change_me_refresh_secret
|
JWT_REFRESH_SECRET=change_me_refresh_secret
|
||||||
JWT_REFRESH_EXPIRES_IN=30d
|
JWT_REFRESH_EXPIRES_IN=30d
|
||||||
BCRYPT_SALT_ROUNDS=12
|
BCRYPT_SALT_ROUNDS=12
|
||||||
|
REFRESH_TOKEN_HASH_SECRET=
|
||||||
PASSWORD_RESET_CODE_EXPIRES_MINUTES=10
|
PASSWORD_RESET_CODE_EXPIRES_MINUTES=10
|
||||||
PASSWORD_RESET_MAX_ATTEMPTS=5
|
PASSWORD_RESET_MAX_ATTEMPTS=5
|
||||||
PASSWORD_RESET_TOKEN_SECRET=
|
PASSWORD_RESET_TOKEN_SECRET=
|
||||||
@@ -21,9 +25,43 @@ SWAGGER_TITLE=Oudelaa API
|
|||||||
SWAGGER_DESCRIPTION=Social media backend API documentation
|
SWAGGER_DESCRIPTION=Social media backend API documentation
|
||||||
SWAGGER_VERSION=1.0.0
|
SWAGGER_VERSION=1.0.0
|
||||||
SWAGGER_PATH=docs
|
SWAGGER_PATH=docs
|
||||||
|
LOG_LEVEL=log
|
||||||
|
REQUEST_LOGGING_ENABLED=true
|
||||||
|
FEED_CACHE_ENABLED=true
|
||||||
|
FEED_CACHE_USER_TTL_SECONDS=15
|
||||||
|
FEED_CACHE_TRENDING_TTL_SECONDS=30
|
||||||
|
|
||||||
|
REDIS_ENABLED=false
|
||||||
|
REDIS_URL=
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_USERNAME=
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
REDIS_DB=0
|
||||||
|
REDIS_KEY_PREFIX=oudelaa
|
||||||
|
REDIS_SOCKET_ADAPTER_ENABLED=false
|
||||||
|
|
||||||
|
QUEUE_ENABLED=false
|
||||||
|
QUEUE_NAME=app-jobs
|
||||||
|
QUEUE_DEFAULT_ATTEMPTS=3
|
||||||
|
QUEUE_DEFAULT_BACKOFF_MS=1000
|
||||||
|
QUEUE_REMOVE_ON_COMPLETE=true
|
||||||
|
QUEUE_WORKER_CONCURRENCY=5
|
||||||
|
|
||||||
|
STORAGE_PROVIDER=local
|
||||||
|
STORAGE_BASE_PATH=uploads
|
||||||
|
# Leave empty for local storage unless you want a dedicated CDN/base URL.
|
||||||
|
STORAGE_PUBLIC_BASE_URL=
|
||||||
|
S3_BUCKET=
|
||||||
|
S3_REGION=auto
|
||||||
|
S3_ENDPOINT=
|
||||||
|
S3_ACCESS_KEY_ID=
|
||||||
|
S3_SECRET_ACCESS_KEY=
|
||||||
|
S3_FORCE_PATH_STYLE=false
|
||||||
|
|
||||||
GOOGLE_CLIENT_ID=your_google_client_id
|
GOOGLE_CLIENT_ID=your_google_client_id
|
||||||
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
||||||
|
# Match this to the same reachable host/IP used in PUBLIC_BASE_URL.
|
||||||
GOOGLE_CALLBACK_URL=http://192.168.1.14:4000/api/v1/auth/google/callback
|
GOOGLE_CALLBACK_URL=http://192.168.1.14:4000/api/v1/auth/google/callback
|
||||||
EMAIL_ENABLED=false
|
EMAIL_ENABLED=false
|
||||||
EMAIL_SMTP_HOST=smtp.gmail.com
|
EMAIL_SMTP_HOST=smtp.gmail.com
|
||||||
@@ -33,6 +71,11 @@ EMAIL_SMTP_USER=
|
|||||||
EMAIL_SMTP_PASS=
|
EMAIL_SMTP_PASS=
|
||||||
EMAIL_FROM_NAME=Oudelaa
|
EMAIL_FROM_NAME=Oudelaa
|
||||||
EMAIL_FROM_EMAIL=
|
EMAIL_FROM_EMAIL=
|
||||||
|
AI_MUSIC_ENABLED=false
|
||||||
|
AI_MUSIC_API_KEY=
|
||||||
|
AI_MUSIC_PROJECT_ID=
|
||||||
|
AI_MUSIC_LOCATION=us-central1
|
||||||
|
AI_MUSIC_MODEL=lyria-002
|
||||||
|
|
||||||
|
|
||||||
SUPERADMIN_EMAIL=admin@oudelaa.com
|
SUPERADMIN_EMAIL=admin@oudelaa.com
|
||||||
@@ -41,4 +84,3 @@ SUPERADMIN_ACCESS_SECRET=change_me_superadmin_access_secret
|
|||||||
SUPERADMIN_ACCESS_EXPIRES_IN=15m
|
SUPERADMIN_ACCESS_EXPIRES_IN=15m
|
||||||
SUPERADMIN_REFRESH_SECRET=change_me_superadmin_refresh_secret
|
SUPERADMIN_REFRESH_SECRET=change_me_superadmin_refresh_secret
|
||||||
SUPERADMIN_REFRESH_EXPIRES_IN=30d
|
SUPERADMIN_REFRESH_EXPIRES_IN=30d
|
||||||
|
|
||||||
|
|||||||
6
.gitignore
مباع
6
.gitignore
مباع
@@ -10,3 +10,9 @@ uploads
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
*.log
|
||||||
|
*.err.log
|
||||||
|
|
||||||
|
# Local workspace
|
||||||
|
.vscode/
|
||||||
|
oudelaa_dashboard/
|
||||||
|
|||||||
118
PERFORMANCE_TESTING.md
Normal file
118
PERFORMANCE_TESTING.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# Performance Testing
|
||||||
|
|
||||||
|
This project now includes built-in scripts to check correctness, startup time, endpoint latency, and basic load.
|
||||||
|
|
||||||
|
## 1. Correctness first
|
||||||
|
|
||||||
|
Run these before any performance test:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run build
|
||||||
|
npm test -- --runInBand
|
||||||
|
npm run test:e2e -- --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Startup time
|
||||||
|
|
||||||
|
Build the app, then measure cold start:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run build
|
||||||
|
npm run perf:startup
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional parameters:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node scripts/startup-benchmark.js --port 4200 --timeout 45000
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Health endpoint load
|
||||||
|
|
||||||
|
If the API is already running on port `4000`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run perf:health
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs a simple GET benchmark against `http://127.0.0.1:4000/api/v1/health`.
|
||||||
|
|
||||||
|
## 4. Custom endpoint load
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node scripts/load-test.js --url http://127.0.0.1:4000/api/v1/health --duration 20 --concurrency 50
|
||||||
|
node scripts/load-test.js --url http://127.0.0.1:4000/api/v1/feed/trending --header "Authorization: Bearer YOUR_TOKEN" --duration 30 --concurrency 25
|
||||||
|
node scripts/load-test.js --url http://127.0.0.1:4000/api/v1/auth/login --method POST --body "{\"email\":\"user@example.com\",\"password\":\"secret\"}" --duration 20 --concurrency 10
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported options:
|
||||||
|
|
||||||
|
- `--url`
|
||||||
|
- `--method`
|
||||||
|
- `--duration`
|
||||||
|
- `--concurrency`
|
||||||
|
- `--timeout`
|
||||||
|
- `--warmup`
|
||||||
|
- `--header "Key: Value"`
|
||||||
|
- `--body`
|
||||||
|
- `--body-file`
|
||||||
|
|
||||||
|
## 5. What to watch
|
||||||
|
|
||||||
|
- `requestsPerSecond`: throughput
|
||||||
|
- `successRate`: percentage of successful requests
|
||||||
|
- `non2xxCount`: server or validation failures
|
||||||
|
- `timeoutCount`: slow requests
|
||||||
|
- `latencyMs.p95` and `latencyMs.p99`: tail latency under load
|
||||||
|
|
||||||
|
## 6. Practical test order
|
||||||
|
|
||||||
|
1. `build`
|
||||||
|
2. unit tests
|
||||||
|
3. e2e tests
|
||||||
|
4. startup benchmark
|
||||||
|
5. health benchmark
|
||||||
|
6. authenticated benchmarks for hot endpoints:
|
||||||
|
- `auth/login`
|
||||||
|
- `feed/me`
|
||||||
|
- `feed/trending`
|
||||||
|
- `posts`
|
||||||
|
- `chat/messages`
|
||||||
|
- `notifications`
|
||||||
|
|
||||||
|
## 7. Real API benchmark examples
|
||||||
|
|
||||||
|
Login:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node scripts/load-test.js --url http://127.0.0.1:4000/api/v1/auth/login --method POST --header "Content-Type: application/json" --body "{\"email\":\"user@example.com\",\"password\":\"secret123\"}" --duration 20 --concurrency 10
|
||||||
|
```
|
||||||
|
|
||||||
|
Trending feed with bearer token:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node scripts/load-test.js --url http://127.0.0.1:4000/api/v1/feed/trending --header "Authorization: Bearer YOUR_ACCESS_TOKEN" --duration 30 --concurrency 25
|
||||||
|
```
|
||||||
|
|
||||||
|
Authenticated user feed:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node scripts/load-test.js --url http://127.0.0.1:4000/api/v1/feed/me?limit=20 --header "Authorization: Bearer YOUR_ACCESS_TOKEN" --duration 30 --concurrency 15
|
||||||
|
```
|
||||||
|
|
||||||
|
Notifications:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node scripts/load-test.js --url http://127.0.0.1:4000/api/v1/notifications --header "Authorization: Bearer YOUR_ACCESS_TOKEN" --duration 20 --concurrency 15
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Limits
|
||||||
|
|
||||||
|
These scripts are useful local benchmarks, not full production profiling. They do not replace:
|
||||||
|
|
||||||
|
- database profiling
|
||||||
|
- CPU and memory profiling
|
||||||
|
- distributed load tools like k6 or Gatling
|
||||||
|
- multi-instance tests behind a reverse proxy
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
# Oudelaa Backend
|
# Oudelaa Backend
|
||||||
|
|
||||||
Production-oriented NestJS backend for a social media platform.
|
Production-oriented NestJS backend for a social media platform.
|
||||||
|
|
||||||
|
Frontend handoff notes and WebSocket event docs live in [docs/FRONTEND_INTEGRATION.md](docs/FRONTEND_INTEGRATION.md).
|
||||||
|
|||||||
85
SCALING_SETUP.md
Normal file
85
SCALING_SETUP.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Scaling Setup
|
||||||
|
|
||||||
|
This codebase now supports optional Redis, BullMQ, S3-compatible storage, structured JSON logging, and feed caching.
|
||||||
|
|
||||||
|
## What was added
|
||||||
|
|
||||||
|
- Redis-backed cache and rate limiting fallback to in-memory
|
||||||
|
- Optional Socket.IO Redis adapter
|
||||||
|
- Optional BullMQ queue for outbox processing
|
||||||
|
- Pluggable storage layer with:
|
||||||
|
- `local`
|
||||||
|
- `s3` compatible providers such as AWS S3 or Cloudflare R2
|
||||||
|
- Feed response caching with versioned invalidation
|
||||||
|
- Fast refresh-token fingerprinting to reduce bcrypt load
|
||||||
|
- JSON request logging
|
||||||
|
|
||||||
|
## Feature flags
|
||||||
|
|
||||||
|
### Redis
|
||||||
|
|
||||||
|
```env
|
||||||
|
REDIS_ENABLED=true
|
||||||
|
REDIS_URL=redis://127.0.0.1:6379
|
||||||
|
REDIS_KEY_PREFIX=oudelaa
|
||||||
|
REDIS_SOCKET_ADAPTER_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Queue
|
||||||
|
|
||||||
|
```env
|
||||||
|
QUEUE_ENABLED=true
|
||||||
|
QUEUE_NAME=app-jobs
|
||||||
|
QUEUE_DEFAULT_ATTEMPTS=3
|
||||||
|
QUEUE_DEFAULT_BACKOFF_MS=1000
|
||||||
|
QUEUE_WORKER_CONCURRENCY=5
|
||||||
|
```
|
||||||
|
|
||||||
|
Queue processing falls back to in-process execution when Redis/queue is disabled.
|
||||||
|
|
||||||
|
### S3 / R2
|
||||||
|
|
||||||
|
```env
|
||||||
|
STORAGE_PROVIDER=s3
|
||||||
|
STORAGE_BASE_PATH=uploads
|
||||||
|
STORAGE_PUBLIC_BASE_URL=https://cdn.example.com
|
||||||
|
S3_BUCKET=oudelaa
|
||||||
|
S3_REGION=auto
|
||||||
|
S3_ENDPOINT=https://<account-or-endpoint>
|
||||||
|
S3_ACCESS_KEY_ID=...
|
||||||
|
S3_SECRET_ACCESS_KEY=...
|
||||||
|
S3_FORCE_PATH_STYLE=false
|
||||||
|
```
|
||||||
|
|
||||||
|
For Cloudflare R2, `S3_REGION=auto` is acceptable and `STORAGE_PUBLIC_BASE_URL` should usually point to the CDN/custom domain.
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
```env
|
||||||
|
LOG_LEVEL=log
|
||||||
|
REQUEST_LOGGING_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Feed cache
|
||||||
|
|
||||||
|
```env
|
||||||
|
FEED_CACHE_ENABLED=true
|
||||||
|
FEED_CACHE_USER_TTL_SECONDS=15
|
||||||
|
FEED_CACHE_TRENDING_TTL_SECONDS=30
|
||||||
|
```
|
||||||
|
|
||||||
|
## Practical rollout order
|
||||||
|
|
||||||
|
1. Enable JSON logging in staging
|
||||||
|
2. Enable Redis cache and Redis rate limiting
|
||||||
|
3. Enable BullMQ queue for outbox jobs
|
||||||
|
4. Move uploads to S3/R2
|
||||||
|
5. Enable Socket.IO Redis adapter when running multiple instances
|
||||||
|
6. Run authenticated load tests against `auth`, `feed`, `posts`, `chat`, and `notifications`
|
||||||
|
|
||||||
|
## Current limitations
|
||||||
|
|
||||||
|
- The app is still a modular monolith, not separate microservices yet
|
||||||
|
- Feed caching is versioned invalidation plus TTL, not full fan-out precomputation
|
||||||
|
- Marketplace images are still URL-based data, not binary upload pipelines
|
||||||
|
- Local tests do not replace full production benchmarking
|
||||||
248
docs/FRONTEND_INTEGRATION.md
Normal file
248
docs/FRONTEND_INTEGRATION.md
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
# Frontend Integration
|
||||||
|
|
||||||
|
## Network setup
|
||||||
|
|
||||||
|
When testing from a phone or another device on the same LAN:
|
||||||
|
|
||||||
|
1. Set `HOST=0.0.0.0`
|
||||||
|
2. Set `PUBLIC_BASE_URL` to the machine IP, not `localhost`
|
||||||
|
3. Add frontend origins to `CORS_ORIGINS`
|
||||||
|
4. Keep `GOOGLE_CALLBACK_URL` on the same reachable host if Google auth is used
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```env
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PUBLIC_BASE_URL=http://192.168.1.12:4000
|
||||||
|
CORS_ORIGINS=http://192.168.1.12:3000,http://192.168.1.12:5173
|
||||||
|
GOOGLE_CALLBACK_URL=http://192.168.1.12:4000/api/v1/auth/google/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
With `PUBLIC_BASE_URL` configured, file fields such as `avatar`, `coverImage`, `imageUrls`, `videoUrl`, `audioUrl`, `thumbnailUrl`, `mediaUrl`, and marketplace images are returned as absolute URLs.
|
||||||
|
|
||||||
|
## Pagination contract
|
||||||
|
|
||||||
|
List endpoints now keep the legacy fields and also return a unified `pagination` object.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [],
|
||||||
|
"count": 0,
|
||||||
|
"page": 1,
|
||||||
|
"limit": 20,
|
||||||
|
"total": 0,
|
||||||
|
"totalPages": 1,
|
||||||
|
"nextCursor": null,
|
||||||
|
"pagination": {
|
||||||
|
"mode": "offset",
|
||||||
|
"page": 1,
|
||||||
|
"limit": 20,
|
||||||
|
"count": 0,
|
||||||
|
"total": 0,
|
||||||
|
"totalPages": 1,
|
||||||
|
"hasNextPage": false,
|
||||||
|
"hasPreviousPage": false,
|
||||||
|
"nextPage": null,
|
||||||
|
"previousPage": null,
|
||||||
|
"currentCursor": null,
|
||||||
|
"nextCursor": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Offset endpoints use `page` and `limit`
|
||||||
|
- Cursor-aware endpoints also return `nextCursor`
|
||||||
|
- Existing clients can keep reading the old top-level fields
|
||||||
|
- `sortOrder=asc|desc` is available on paginated endpoints
|
||||||
|
|
||||||
|
## Filter and sorting contract
|
||||||
|
|
||||||
|
Boolean query filters are parsed consistently now. Send:
|
||||||
|
|
||||||
|
- `?isActive=true`
|
||||||
|
- `?isActive=false`
|
||||||
|
- `?read=true`
|
||||||
|
- `?read=false`
|
||||||
|
- `?followingOnly=true`
|
||||||
|
|
||||||
|
Common conventions:
|
||||||
|
|
||||||
|
- `page`, `limit`, `sortOrder`
|
||||||
|
- `sortOrder` defaults to `desc`
|
||||||
|
- `sortBy` is supported on selected endpoints with endpoint-specific allowed values
|
||||||
|
|
||||||
|
Supported filters:
|
||||||
|
|
||||||
|
- `GET /marketplace/home`
|
||||||
|
- `listingsLimit`, `instrumentsLimit`, `repairShopsLimit`, `onlyActive`
|
||||||
|
- `GET /users`
|
||||||
|
- `q`, `isVerified`, `musicRole`, `experienceLevel`, `isPrivate`, `hasAvatar`, `sortBy`
|
||||||
|
- `sortBy`: `createdAt`, `name`, `username`, `followersCount`, `postsCount`
|
||||||
|
- `GET /users/discover`
|
||||||
|
- `q`, `musicRole`, `experienceLevel`, `hasAvatarOnly`, `includeRoleBuckets`, `sortBy`, `sortOrder`
|
||||||
|
- `GET /users/:id/profile-overview`
|
||||||
|
- returns `stats`, `contentCounts`, `tabs`, and `viewerState`
|
||||||
|
- `GET /posts/user/:userId`
|
||||||
|
- `visibility`, `postType`, `q`, `hashtag`, `sortBy`
|
||||||
|
- `sortBy`: `createdAt`, `updatedAt`, `likesCount`, `commentsCount`, `savesCount`, `shareCount`, `viewCount`, `playCount`
|
||||||
|
- `GET /posts/reels`
|
||||||
|
- `visibility`, `authorId`, `q`, `sortBy`
|
||||||
|
- `GET /marketplace/listings`
|
||||||
|
- `q`, `minPrice`, `maxPrice`, `isActive`, `listingCategory`, `condition`, `instrumentType`, `sortBy`
|
||||||
|
- `listingCategory`: `musical_instrument`, `accessory`, `audio_gear`, `sheet_music`, `other`
|
||||||
|
- `GET /marketplace/instruments`
|
||||||
|
- `q`, `minPrice`, `maxPrice`, `isActive`, `condition`, `instrumentType`, `sortBy`
|
||||||
|
- this route is now a musical-instruments-only view of marketplace listings
|
||||||
|
- `sortBy`: `createdAt`, `updatedAt`, `price`, `title`
|
||||||
|
- `GET /marketplace/repair-shops`
|
||||||
|
- `q`, `isActive`, `sortBy`
|
||||||
|
- `sortBy`: `createdAt`, `updatedAt`, `name`
|
||||||
|
- `GET /notifications`
|
||||||
|
- `read`, `type`, `resourceType`, `sortOrder`
|
||||||
|
- `GET /comments/post/:postId`
|
||||||
|
- `page`, `limit`, `sortOrder`
|
||||||
|
|
||||||
|
## WebSocket auth
|
||||||
|
|
||||||
|
Both namespaces accept the JWT access token in one of these places:
|
||||||
|
|
||||||
|
- `auth.token`
|
||||||
|
- `Authorization` header as `Bearer <token>`
|
||||||
|
|
||||||
|
## Chat namespace
|
||||||
|
|
||||||
|
Namespace: `/chat`
|
||||||
|
|
||||||
|
Client emits:
|
||||||
|
|
||||||
|
- `join_conversation`
|
||||||
|
- payload: `{ "conversationId": "..." }`
|
||||||
|
- `send_message`
|
||||||
|
- payload matches `SendMessageDto`
|
||||||
|
- `typing`
|
||||||
|
- payload: `{ "conversationId": "...", "isTyping": true }`
|
||||||
|
- `mark_seen`
|
||||||
|
- payload: `{ "messageId": "...", "conversationId": "..." }`
|
||||||
|
|
||||||
|
Server emits:
|
||||||
|
|
||||||
|
- `presence`
|
||||||
|
- payload: `{ "userId": "...", "online": true }`
|
||||||
|
- `joined_conversation`
|
||||||
|
- payload: `{ "conversationId": "..." }`
|
||||||
|
- `new_message`
|
||||||
|
- payload: message object
|
||||||
|
- `typing`
|
||||||
|
- payload: `{ "conversationId": "...", "userId": "...", "isTyping": true }`
|
||||||
|
- `message_seen`
|
||||||
|
- payload: `{ "messageId": "...", "userId": "..." }`
|
||||||
|
|
||||||
|
## Notifications namespace
|
||||||
|
|
||||||
|
Namespace: `/notifications`
|
||||||
|
|
||||||
|
Server emits:
|
||||||
|
|
||||||
|
- `notification_created`
|
||||||
|
- payload: notification object
|
||||||
|
- `notifications_unread_count`
|
||||||
|
- payload: `{ "unreadCount": 3 }`
|
||||||
|
|
||||||
|
Notification types currently used:
|
||||||
|
|
||||||
|
- `like`
|
||||||
|
- `comment`
|
||||||
|
- `follow`
|
||||||
|
- `message`
|
||||||
|
- `save`
|
||||||
|
- `share`
|
||||||
|
- `mention`
|
||||||
|
|
||||||
|
## Mentions
|
||||||
|
|
||||||
|
Posts and comments support:
|
||||||
|
|
||||||
|
- `taggedUserIds`: official tagged users by Mongo id
|
||||||
|
- `mentionUsernames`: usernames such as `["rami_sabry"]`
|
||||||
|
|
||||||
|
The backend also extracts `@username` from `content` automatically and emits mention notifications for matched users.
|
||||||
|
|
||||||
|
## Marketplace split
|
||||||
|
|
||||||
|
Marketplace is now separated from musical instruments at the API contract level:
|
||||||
|
|
||||||
|
- `GET /marketplace/listings`
|
||||||
|
- general sale listings across all listing categories
|
||||||
|
- `GET /marketplace/instruments`
|
||||||
|
- only musical instruments
|
||||||
|
- `GET /marketplace/repair-shops`
|
||||||
|
- service providers and maintenance shops
|
||||||
|
- `GET /marketplace/home`
|
||||||
|
- frontend-friendly grouped payload with `categories`, `summary`, `filters.listingCategories`, `featuredShops`, and `sections`
|
||||||
|
|
||||||
|
Admin endpoints follow the same split:
|
||||||
|
|
||||||
|
- `POST /marketplace/admin/listings`
|
||||||
|
- `GET /marketplace/admin/listings/me`
|
||||||
|
- `PATCH /marketplace/admin/listings/:id`
|
||||||
|
- `DELETE /marketplace/admin/listings/:id`
|
||||||
|
|
||||||
|
The older `/marketplace/admin/instruments*` routes still exist and always force `listingCategory=musical_instrument` for backward compatibility.
|
||||||
|
|
||||||
|
Marketplace shop ownership rule:
|
||||||
|
|
||||||
|
- each admin can own one repair-shop / marketplace shop record only
|
||||||
|
- if the admin already created one, the frontend should call update endpoints instead of create
|
||||||
|
|
||||||
|
Listing responses now also include:
|
||||||
|
|
||||||
|
- `shop`
|
||||||
|
- `{ adminId, name, username, avatar }`
|
||||||
|
- `storeName`
|
||||||
|
- direct shortcut for list cards
|
||||||
|
- `condition`
|
||||||
|
- `new`, `like_new`, `used`, `refurbished`
|
||||||
|
- `instrumentType`
|
||||||
|
- free-text type such as `oud`, `piano`, or `violin`
|
||||||
|
|
||||||
|
Search on marketplace listings now matches:
|
||||||
|
|
||||||
|
- listing title
|
||||||
|
- listing description
|
||||||
|
- instrument type
|
||||||
|
- shop name
|
||||||
|
|
||||||
|
## Talent discovery
|
||||||
|
|
||||||
|
The talents screen can now be built from:
|
||||||
|
|
||||||
|
- `GET /users/discover`
|
||||||
|
- paginated talent cards
|
||||||
|
- `roleBuckets` counts for tabs such as instrumentalists, singers, producers, and lyricists
|
||||||
|
- `GET /users/:id/profile-overview`
|
||||||
|
- summary for the profile header and tab counts
|
||||||
|
|
||||||
|
`profile-overview` returns:
|
||||||
|
|
||||||
|
- `stats.followersCount`
|
||||||
|
- `stats.followingCount`
|
||||||
|
- `stats.postsCount`
|
||||||
|
- `stats.collaborationsCount`
|
||||||
|
- `contentCounts.reels`
|
||||||
|
- `contentCounts.audio`
|
||||||
|
- `contentCounts.other`
|
||||||
|
- `viewerState.following`
|
||||||
|
|
||||||
|
## Smoke / e2e
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
The smoke suite covers health, auth verification, profile setup, image upload, post creation, feed retrieval, notifications, and comment mentions.
|
||||||
2032
package-lock.json
مولّد
2032
package-lock.json
مولّد
تم حذف اختلاف الملف لأن الملف كبير جداً
تحميل الاختلاف
10
package.json
10
package.json
@@ -12,9 +12,14 @@
|
|||||||
"lint": "eslint \"src/**/*.ts\" --fix",
|
"lint": "eslint \"src/**/*.ts\" --fix",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --runInBand --config ./test/jest-e2e.json",
|
||||||
|
"perf:load": "node scripts/load-test.js",
|
||||||
|
"perf:health": "node scripts/load-test.js --url http://127.0.0.1:4000/api/v1/health --duration 15 --concurrency 20",
|
||||||
|
"perf:startup": "node scripts/startup-benchmark.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1041.0",
|
||||||
|
"@aws-sdk/lib-storage": "^3.1041.0",
|
||||||
"@nestjs/common": "^10.4.0",
|
"@nestjs/common": "^10.4.0",
|
||||||
"@nestjs/config": "^3.2.3",
|
"@nestjs/config": "^3.2.3",
|
||||||
"@nestjs/core": "^10.4.0",
|
"@nestjs/core": "^10.4.0",
|
||||||
@@ -25,11 +30,14 @@
|
|||||||
"@nestjs/platform-socket.io": "^10.4.0",
|
"@nestjs/platform-socket.io": "^10.4.0",
|
||||||
"@nestjs/swagger": "^8.1.0",
|
"@nestjs/swagger": "^8.1.0",
|
||||||
"@nestjs/websockets": "^10.4.0",
|
"@nestjs/websockets": "^10.4.0",
|
||||||
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"@types/passport-google-oauth20": "^2.0.17",
|
"@types/passport-google-oauth20": "^2.0.17",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
|
"bullmq": "^5.76.5",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"google-auth-library": "^10.6.2",
|
"google-auth-library": "^10.6.2",
|
||||||
|
"ioredis": "^5.10.1",
|
||||||
"joi": "^17.13.3",
|
"joi": "^17.13.3",
|
||||||
"mongoose": "^8.6.0",
|
"mongoose": "^8.6.0",
|
||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^8.0.5",
|
||||||
|
|||||||
تم حذف اختلاف الملف لأن الملف كبير جداً
تحميل الاختلاف
260
scripts/load-test.js
Normal file
260
scripts/load-test.js
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
const { readFileSync } = require('fs');
|
||||||
|
const { performance } = require('perf_hooks');
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const options = {
|
||||||
|
url: 'http://127.0.0.1:4000/api/v1/health',
|
||||||
|
method: 'GET',
|
||||||
|
duration: 15,
|
||||||
|
concurrency: 20,
|
||||||
|
timeout: 5000,
|
||||||
|
warmup: 5,
|
||||||
|
headers: {},
|
||||||
|
body: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const arg = argv[index];
|
||||||
|
const next = argv[index + 1];
|
||||||
|
|
||||||
|
if (arg === '--url' && next) {
|
||||||
|
options.url = next;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === '--method' && next) {
|
||||||
|
options.method = next.toUpperCase();
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === '--duration' && next) {
|
||||||
|
options.duration = Number(next);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === '--concurrency' && next) {
|
||||||
|
options.concurrency = Number(next);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === '--timeout' && next) {
|
||||||
|
options.timeout = Number(next);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === '--warmup' && next) {
|
||||||
|
options.warmup = Number(next);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === '--header' && next) {
|
||||||
|
const separatorIndex = next.indexOf(':');
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
throw new Error(`Invalid header format: ${next}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = next.slice(0, separatorIndex).trim();
|
||||||
|
const value = next.slice(separatorIndex + 1).trim();
|
||||||
|
options.headers[key] = value;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === '--body' && next) {
|
||||||
|
options.body = next;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === '--body-file' && next) {
|
||||||
|
options.body = readFileSync(next, 'utf8');
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(options.duration) || options.duration <= 0) {
|
||||||
|
throw new Error('duration must be a positive number of seconds');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(options.concurrency) || options.concurrency <= 0) {
|
||||||
|
throw new Error('concurrency must be a positive integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(options.timeout) || options.timeout <= 0) {
|
||||||
|
throw new Error('timeout must be a positive number of milliseconds');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(options.warmup) || options.warmup < 0) {
|
||||||
|
throw new Error('warmup must be zero or a positive integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.body && !options.headers['Content-Type']) {
|
||||||
|
options.headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function percentile(sortedValues, p) {
|
||||||
|
if (!sortedValues.length) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rank = Math.ceil((p / 100) * sortedValues.length) - 1;
|
||||||
|
const index = Math.min(sortedValues.length - 1, Math.max(0, rank));
|
||||||
|
return sortedValues[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
function average(values) {
|
||||||
|
if (!values.length) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = values.reduce((sum, value) => sum + value, 0);
|
||||||
|
return total / values.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printUsage() {
|
||||||
|
console.log('Usage: node scripts/load-test.js --url <url> [--duration 15] [--concurrency 20]');
|
||||||
|
console.log('Optional: --method POST --header "Authorization: Bearer <token>" --body "{\"key\":\"value\"}"');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function warmup(options) {
|
||||||
|
if (options.warmup === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < options.warmup; index += 1) {
|
||||||
|
const response = await fetch(options.url, {
|
||||||
|
method: options.method,
|
||||||
|
headers: options.headers,
|
||||||
|
body: options.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
await response.arrayBuffer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runWorker(options, results, endAt) {
|
||||||
|
while (Date.now() < endAt) {
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), options.timeout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(options.url, {
|
||||||
|
method: options.method,
|
||||||
|
headers: options.headers,
|
||||||
|
body: options.body,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.arrayBuffer();
|
||||||
|
const durationMs = performance.now() - startedAt;
|
||||||
|
|
||||||
|
results.latencies.push(durationMs);
|
||||||
|
results.totalRequests += 1;
|
||||||
|
results.totalBytes += payload.byteLength;
|
||||||
|
|
||||||
|
const statusKey = String(response.status);
|
||||||
|
results.statusCounts[statusKey] = (results.statusCounts[statusKey] ?? 0) + 1;
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
results.successCount += 1;
|
||||||
|
} else {
|
||||||
|
results.non2xxCount += 1;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const durationMs = performance.now() - startedAt;
|
||||||
|
results.latencies.push(durationMs);
|
||||||
|
results.totalRequests += 1;
|
||||||
|
|
||||||
|
if (error && typeof error === 'object' && error.name === 'AbortError') {
|
||||||
|
results.timeoutCount += 1;
|
||||||
|
} else {
|
||||||
|
results.networkErrorCount += 1;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSummary(options, results, totalDurationMs) {
|
||||||
|
const latencies = [...results.latencies].sort((left, right) => left - right);
|
||||||
|
const requestsPerSecond = results.totalRequests / (totalDurationMs / 1000);
|
||||||
|
const successRate = results.totalRequests === 0 ? 0 : (results.successCount / results.totalRequests) * 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
target: options.url,
|
||||||
|
method: options.method,
|
||||||
|
durationSeconds: Number((totalDurationMs / 1000).toFixed(2)),
|
||||||
|
concurrency: options.concurrency,
|
||||||
|
totalRequests: results.totalRequests,
|
||||||
|
successCount: results.successCount,
|
||||||
|
non2xxCount: results.non2xxCount,
|
||||||
|
timeoutCount: results.timeoutCount,
|
||||||
|
networkErrorCount: results.networkErrorCount,
|
||||||
|
requestsPerSecond: Number(requestsPerSecond.toFixed(2)),
|
||||||
|
successRate: Number(successRate.toFixed(2)),
|
||||||
|
transferredBytes: results.totalBytes,
|
||||||
|
latencyMs: {
|
||||||
|
min: Number((latencies[0] ?? 0).toFixed(2)),
|
||||||
|
avg: Number(average(latencies).toFixed(2)),
|
||||||
|
p50: Number(percentile(latencies, 50).toFixed(2)),
|
||||||
|
p90: Number(percentile(latencies, 90).toFixed(2)),
|
||||||
|
p95: Number(percentile(latencies, 95).toFixed(2)),
|
||||||
|
p99: Number(percentile(latencies, 99).toFixed(2)),
|
||||||
|
max: Number((latencies[latencies.length - 1] ?? 0).toFixed(2)),
|
||||||
|
},
|
||||||
|
statusCounts: results.statusCounts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (process.argv.includes('--help')) {
|
||||||
|
printUsage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
|
console.log(`Warmup: ${options.warmup} request(s) to ${options.url}`);
|
||||||
|
await warmup(options);
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
latencies: [],
|
||||||
|
totalRequests: 0,
|
||||||
|
successCount: 0,
|
||||||
|
non2xxCount: 0,
|
||||||
|
timeoutCount: 0,
|
||||||
|
networkErrorCount: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
statusCounts: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const endAt = Date.now() + options.duration * 1000;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: options.concurrency }, () => runWorker(options, results, endAt)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalDurationMs = performance.now() - startedAt;
|
||||||
|
const summary = buildSummary(options, results, totalDurationMs);
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(JSON.stringify(summary, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
150
scripts/startup-benchmark.js
Normal file
150
scripts/startup-benchmark.js
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
const { existsSync } = require('fs');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const { performance } = require('perf_hooks');
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const options = {
|
||||||
|
entry: 'dist/main.js',
|
||||||
|
port: 4100,
|
||||||
|
timeout: 30000,
|
||||||
|
path: '/api/v1/health',
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const arg = argv[index];
|
||||||
|
const next = argv[index + 1];
|
||||||
|
|
||||||
|
if (arg === '--entry' && next) {
|
||||||
|
options.entry = next;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === '--port' && next) {
|
||||||
|
options.port = Number(next);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === '--timeout' && next) {
|
||||||
|
options.timeout = Number(next);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === '--path' && next) {
|
||||||
|
options.path = next.startsWith('/') ? next : `/${next}`;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(options.entry)) {
|
||||||
|
throw new Error(`Entry file not found: ${options.entry}. Run "npm run build" first.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(options.port) || options.port <= 0) {
|
||||||
|
throw new Error('port must be a positive integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(options.timeout) || options.timeout <= 0) {
|
||||||
|
throw new Error('timeout must be a positive number of milliseconds');
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForHealth(url, timeoutMs) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { method: 'GET' });
|
||||||
|
if (response.ok) {
|
||||||
|
const body = await response.text();
|
||||||
|
return { status: response.status, body };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastError instanceof Error) {
|
||||||
|
throw new Error(`Startup timeout. Last error: ${lastError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Startup timeout. Health endpoint did not become ready.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function terminate(child) {
|
||||||
|
if (child.exitCode !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
child.kill();
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
child.once('exit', resolve);
|
||||||
|
setTimeout(resolve, 5000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
const url = `http://127.0.0.1:${options.port}${options.path}`;
|
||||||
|
const stdoutLines = [];
|
||||||
|
const stderrLines = [];
|
||||||
|
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const child = spawn(process.execPath, [options.entry], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PORT: String(options.port),
|
||||||
|
PUBLIC_BASE_URL: `http://127.0.0.1:${options.port}`,
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdout.on('data', (chunk) => {
|
||||||
|
stdoutLines.push(...String(chunk).split(/\r?\n/).filter(Boolean));
|
||||||
|
if (stdoutLines.length > 20) {
|
||||||
|
stdoutLines.splice(0, stdoutLines.length - 20);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on('data', (chunk) => {
|
||||||
|
stderrLines.push(...String(chunk).split(/\r?\n/).filter(Boolean));
|
||||||
|
if (stderrLines.length > 20) {
|
||||||
|
stderrLines.splice(0, stderrLines.length - 20);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const healthResult = await waitForHealth(url, options.timeout);
|
||||||
|
const readyMs = performance.now() - startedAt;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
entry: options.entry,
|
||||||
|
url,
|
||||||
|
startupMs: Number(readyMs.toFixed(2)),
|
||||||
|
healthStatus: healthResult.status,
|
||||||
|
recentStdout: stdoutLines,
|
||||||
|
recentStderr: stderrLines,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await terminate(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -6,6 +6,11 @@ import { AppService } from './app.service';
|
|||||||
import configuration from './config/configuration';
|
import configuration from './config/configuration';
|
||||||
import { validationSchema } from './config/validation.schema';
|
import { validationSchema } from './config/validation.schema';
|
||||||
import { DatabaseModule } from './database/database.module';
|
import { DatabaseModule } from './database/database.module';
|
||||||
|
import { CacheModule } from './infrastructure/cache/cache.module';
|
||||||
|
import { LoggingModule } from './infrastructure/logging/logging.module';
|
||||||
|
import { QueueModule } from './infrastructure/queue/queue.module';
|
||||||
|
import { RedisModule } from './infrastructure/redis/redis.module';
|
||||||
|
import { StorageModule } from './infrastructure/storage/storage.module';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
import { AuditModule } from './modules/audit/audit.module';
|
import { AuditModule } from './modules/audit/audit.module';
|
||||||
import { ChatModule } from './modules/chat/chat.module';
|
import { ChatModule } from './modules/chat/chat.module';
|
||||||
@@ -19,6 +24,7 @@ import { NotificationsModule } from './modules/notifications/notifications.modul
|
|||||||
import { OutboxModule } from './modules/outbox/outbox.module';
|
import { OutboxModule } from './modules/outbox/outbox.module';
|
||||||
import { PostsModule } from './modules/posts/posts.module';
|
import { PostsModule } from './modules/posts/posts.module';
|
||||||
import { SavesModule } from './modules/saves/saves.module';
|
import { SavesModule } from './modules/saves/saves.module';
|
||||||
|
import { SuperAdminModule } from './modules/superadmin/superadmin.module';
|
||||||
import { UsersModule } from './modules/users/users.module';
|
import { UsersModule } from './modules/users/users.module';
|
||||||
import { ThrottleGuard } from './common/guards/throttle.guard';
|
import { ThrottleGuard } from './common/guards/throttle.guard';
|
||||||
|
|
||||||
@@ -30,6 +36,11 @@ import { ThrottleGuard } from './common/guards/throttle.guard';
|
|||||||
load: [configuration],
|
load: [configuration],
|
||||||
validationSchema,
|
validationSchema,
|
||||||
}),
|
}),
|
||||||
|
LoggingModule,
|
||||||
|
RedisModule,
|
||||||
|
CacheModule,
|
||||||
|
StorageModule,
|
||||||
|
QueueModule,
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
@@ -45,6 +56,7 @@ import { ThrottleGuard } from './common/guards/throttle.guard';
|
|||||||
MediaModule,
|
MediaModule,
|
||||||
MarketplaceModule,
|
MarketplaceModule,
|
||||||
SavesModule,
|
SavesModule,
|
||||||
|
SuperAdminModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { SuperAdminPermission } from '../../modules/superadmin/superadmin-permissions';
|
||||||
|
|
||||||
|
export const SUPERADMIN_PERMISSIONS_KEY = 'superadmin_permissions';
|
||||||
|
|
||||||
|
export const SuperAdminPermissions = (...permissions: SuperAdminPermission[]) =>
|
||||||
|
SetMetadata(SUPERADMIN_PERMISSIONS_KEY, permissions);
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
import { SortOrder } from '../enums/sort-order.enum';
|
||||||
import { APP_CONSTANTS } from '../../config/constants';
|
import { APP_CONSTANTS } from '../../config/constants';
|
||||||
|
|
||||||
export class PaginationQueryDto {
|
export class PaginationQueryDto {
|
||||||
|
@ApiPropertyOptional({ default: APP_CONSTANTS.DEFAULT_PAGE })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
page?: number = APP_CONSTANTS.DEFAULT_PAGE;
|
page?: number = APP_CONSTANTS.DEFAULT_PAGE;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: APP_CONSTANTS.DEFAULT_LIMIT, maximum: APP_CONSTANTS.MAX_LIMIT })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -16,7 +20,20 @@ export class PaginationQueryDto {
|
|||||||
@Max(APP_CONSTANTS.MAX_LIMIT)
|
@Max(APP_CONSTANTS.MAX_LIMIT)
|
||||||
limit?: number = APP_CONSTANTS.DEFAULT_LIMIT;
|
limit?: number = APP_CONSTANTS.DEFAULT_LIMIT;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Cursor token for cursor-based endpoints' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: SortOrder,
|
||||||
|
default: SortOrder.DESC,
|
||||||
|
description: 'Used by offset-based list endpoints. Cursor feeds may ignore it.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) =>
|
||||||
|
typeof value === 'string' ? value.trim().toLowerCase() : value,
|
||||||
|
)
|
||||||
|
@IsEnum(SortOrder)
|
||||||
|
sortOrder?: SortOrder = SortOrder.DESC;
|
||||||
}
|
}
|
||||||
|
|||||||
5
src/common/enums/moderation-status.enum.ts
Normal file
5
src/common/enums/moderation-status.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export enum ModerationStatus {
|
||||||
|
ACTIVE = 'active',
|
||||||
|
HIDDEN = 'hidden',
|
||||||
|
FLAGGED = 'flagged',
|
||||||
|
}
|
||||||
@@ -3,4 +3,7 @@ export enum NotificationType {
|
|||||||
COMMENT = 'comment',
|
COMMENT = 'comment',
|
||||||
FOLLOW = 'follow',
|
FOLLOW = 'follow',
|
||||||
MESSAGE = 'message',
|
MESSAGE = 'message',
|
||||||
|
SAVE = 'save',
|
||||||
|
SHARE = 'share',
|
||||||
|
MENTION = 'mention',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export enum PostType {
|
export enum PostType {
|
||||||
TEXT = 'text',
|
TEXT = 'text',
|
||||||
|
IMAGE = 'image',
|
||||||
VIDEO = 'video',
|
VIDEO = 'video',
|
||||||
AUDIO = 'audio',
|
AUDIO = 'audio',
|
||||||
}
|
}
|
||||||
|
|||||||
5
src/common/enums/repair-request-status.enum.ts
Normal file
5
src/common/enums/repair-request-status.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export enum RepairRequestStatus {
|
||||||
|
PENDING = 'pending',
|
||||||
|
ACCEPTED = 'accepted',
|
||||||
|
COMPLETED = 'completed',
|
||||||
|
}
|
||||||
4
src/common/enums/sort-order.enum.ts
Normal file
4
src/common/enums/sort-order.enum.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export enum SortOrder {
|
||||||
|
ASC = 'asc',
|
||||||
|
DESC = 'desc',
|
||||||
|
}
|
||||||
33
src/common/guards/superadmin-permissions.guard.ts
Normal file
33
src/common/guards/superadmin-permissions.guard.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { JwtPayload } from '../interfaces/jwt-payload.interface';
|
||||||
|
import { SUPERADMIN_PERMISSIONS_KEY } from '../decorators/superadmin-permissions.decorator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SuperAdminPermissionsGuard implements CanActivate {
|
||||||
|
constructor(private readonly reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
|
||||||
|
SUPERADMIN_PERMISSIONS_KEY,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!requiredPermissions?.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<{ user?: JwtPayload }>();
|
||||||
|
const payload = request.user;
|
||||||
|
const grantedPermissions = new Set(payload?.permissions ?? []);
|
||||||
|
const hasAllPermissions = requiredPermissions.every((permission) =>
|
||||||
|
grantedPermissions.has(permission),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasAllPermissions) {
|
||||||
|
throw new ForbiddenException('Missing superadmin permission');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,20 +6,17 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { AppCacheService } from '../../infrastructure/cache/app-cache.service';
|
||||||
import { THROTTLE_META_KEY, ThrottleMeta } from '../decorators/throttle.decorator';
|
import { THROTTLE_META_KEY, ThrottleMeta } from '../decorators/throttle.decorator';
|
||||||
|
|
||||||
type Bucket = {
|
|
||||||
count: number;
|
|
||||||
resetAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ThrottleGuard implements CanActivate {
|
export class ThrottleGuard implements CanActivate {
|
||||||
private readonly buckets = new Map<string, Bucket>();
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly cacheService: AppCacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
constructor(private readonly reflector: Reflector) {}
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
|
||||||
const meta = this.reflector.getAllAndOverride<ThrottleMeta>(THROTTLE_META_KEY, [
|
const meta = this.reflector.getAllAndOverride<ThrottleMeta>(THROTTLE_META_KEY, [
|
||||||
context.getHandler(),
|
context.getHandler(),
|
||||||
context.getClass(),
|
context.getClass(),
|
||||||
@@ -28,23 +25,25 @@ export class ThrottleGuard implements CanActivate {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const req = context.switchToHttp().getRequest<Request & { ip?: string; originalUrl?: string }>();
|
const req = context.switchToHttp().getRequest<
|
||||||
const ip = req.ip ?? 'unknown';
|
Request & {
|
||||||
const route = req.originalUrl ?? 'unknown-route';
|
ip?: string;
|
||||||
const key = `${ip}:${route}`;
|
originalUrl?: string;
|
||||||
const now = Date.now();
|
baseUrl?: string;
|
||||||
const existing = this.buckets.get(key);
|
route?: { path?: string };
|
||||||
|
user?: { sub?: string };
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
const actorKey = req.user?.sub ?? req.ip ?? 'unknown';
|
||||||
|
const routePath = `${req.baseUrl ?? ''}${req.route?.path ?? req.originalUrl ?? 'unknown-route'}`;
|
||||||
|
const windowSeconds = Math.max(1, Math.ceil(meta.windowMs / 1000));
|
||||||
|
const bucketKey = `rate-limit:${routePath}:${actorKey}`;
|
||||||
|
const currentCount = await this.cacheService.incr(bucketKey, windowSeconds);
|
||||||
|
|
||||||
if (!existing || now > existing.resetAt) {
|
if (currentCount > meta.limit) {
|
||||||
this.buckets.set(key, { count: 1, resetAt: now + meta.windowMs });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existing.count >= meta.limit) {
|
|
||||||
throw new HttpException('Too many requests, please try again later', HttpStatus.TOO_MANY_REQUESTS);
|
throw new HttpException('Too many requests, please try again later', HttpStatus.TOO_MANY_REQUESTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
existing.count += 1;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ export interface JwtPayload {
|
|||||||
role?: string;
|
role?: string;
|
||||||
tokenType: 'access' | 'refresh' | 'superadmin_access' | 'superadmin_refresh';
|
tokenType: 'access' | 'refresh' | 'superadmin_access' | 'superadmin_refresh';
|
||||||
email?: string;
|
email?: string;
|
||||||
|
permissions?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/common/utils/array-transform.util.spec.ts
Normal file
21
src/common/utils/array-transform.util.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { toNumberArray, toStringArray } from './array-transform.util';
|
||||||
|
|
||||||
|
describe('array transform utils', () => {
|
||||||
|
it('wraps a single string as an array', () => {
|
||||||
|
expect(toStringArray({ value: '69e8d1f7d1f72ba6416d864b' } as any)).toEqual([
|
||||||
|
'69e8d1f7d1f72ba6416d864b',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a JSON string array', () => {
|
||||||
|
expect(toStringArray({ value: '["a","b"]' } as any)).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps array input as an array', () => {
|
||||||
|
expect(toStringArray({ value: ['a', 'b'] } as any)).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses numeric arrays from JSON strings', () => {
|
||||||
|
expect(toNumberArray({ value: '[1,2,3]' } as any)).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
69
src/common/utils/array-transform.util.ts
Normal file
69
src/common/utils/array-transform.util.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { TransformFnParams } from 'class-transformer';
|
||||||
|
|
||||||
|
const parseArrayInput = (value: unknown): unknown[] | unknown | undefined => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.flatMap((item) => {
|
||||||
|
const parsed = parseArrayInput(item);
|
||||||
|
if (typeof parsed === 'undefined') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.isArray(parsed) ? parsed : [parsed];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||||
|
try {
|
||||||
|
return parseArrayInput(JSON.parse(trimmed));
|
||||||
|
} catch {
|
||||||
|
return [trimmed];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.includes(',')) {
|
||||||
|
return trimmed
|
||||||
|
.split(',')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [trimmed];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toStringArray = ({ value }: TransformFnParams): string[] | unknown | undefined => {
|
||||||
|
const parsed = parseArrayInput(value);
|
||||||
|
if (typeof parsed === 'undefined') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.map((item) => String(item).trim()).filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toNumberArray = ({ value }: TransformFnParams): number[] | unknown | undefined => {
|
||||||
|
const parsed = parseArrayInput(value);
|
||||||
|
if (typeof parsed === 'undefined') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.map((item) => Number(item));
|
||||||
|
};
|
||||||
@@ -1,7 +1,24 @@
|
|||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { createHmac, timingSafeEqual } from 'crypto';
|
||||||
|
|
||||||
export const hashValue = async (value: string, saltRounds: number): Promise<string> =>
|
export const hashValue = async (value: string, saltRounds: number): Promise<string> =>
|
||||||
bcrypt.hash(value, saltRounds);
|
bcrypt.hash(value, saltRounds);
|
||||||
|
|
||||||
export const compareHash = async (value: string, hashedValue: string): Promise<boolean> =>
|
export const compareHash = async (value: string, hashedValue: string): Promise<boolean> =>
|
||||||
bcrypt.compare(value, hashedValue);
|
bcrypt.compare(value, hashedValue);
|
||||||
|
|
||||||
|
export const hashHighEntropyValue = (value: string, secret: string): string =>
|
||||||
|
`sha256:${createHmac('sha256', secret).update(value).digest('hex')}`;
|
||||||
|
|
||||||
|
export const compareStoredHighEntropyValue = async (
|
||||||
|
value: string,
|
||||||
|
storedValue: string,
|
||||||
|
secret: string,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (storedValue.startsWith('sha256:')) {
|
||||||
|
const nextValue = hashHighEntropyValue(value, secret);
|
||||||
|
return timingSafeEqual(Buffer.from(nextValue), Buffer.from(storedValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
return compareHash(value, storedValue);
|
||||||
|
};
|
||||||
|
|||||||
37
src/common/utils/pagination.util.spec.ts
Normal file
37
src/common/utils/pagination.util.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { buildPaginatedResponse } from './pagination.util';
|
||||||
|
|
||||||
|
describe('pagination util', () => {
|
||||||
|
it('builds offset pagination metadata', () => {
|
||||||
|
const result = buildPaginatedResponse(['a', 'b'], {
|
||||||
|
page: 2,
|
||||||
|
limit: 2,
|
||||||
|
total: 5,
|
||||||
|
offset: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.count).toBe(2);
|
||||||
|
expect(result.totalPages).toBe(3);
|
||||||
|
expect(result.pagination.hasNextPage).toBe(true);
|
||||||
|
expect(result.pagination.hasPreviousPage).toBe(true);
|
||||||
|
expect(result.pagination.nextPage).toBe(3);
|
||||||
|
expect(result.pagination.previousPage).toBe(1);
|
||||||
|
expect(result.pagination.mode).toBe('offset');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds cursor pagination metadata', () => {
|
||||||
|
const result = buildPaginatedResponse(['a'], {
|
||||||
|
page: 1,
|
||||||
|
limit: 2,
|
||||||
|
total: 3,
|
||||||
|
offset: 0,
|
||||||
|
currentCursor: 'cursor-a',
|
||||||
|
nextCursor: 'cursor-b',
|
||||||
|
mode: 'cursor',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.nextCursor).toBe('cursor-b');
|
||||||
|
expect(result.pagination.currentCursor).toBe('cursor-a');
|
||||||
|
expect(result.pagination.nextCursor).toBe('cursor-b');
|
||||||
|
expect(result.pagination.mode).toBe('cursor');
|
||||||
|
});
|
||||||
|
});
|
||||||
70
src/common/utils/pagination.util.ts
Normal file
70
src/common/utils/pagination.util.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
export type PaginatedResponseOptions = {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
offset: number;
|
||||||
|
currentCursor?: string | null;
|
||||||
|
nextCursor?: string | null;
|
||||||
|
mode?: 'offset' | 'cursor';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaginatedResponse<T> = {
|
||||||
|
items: T[];
|
||||||
|
count: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
nextCursor: string | null;
|
||||||
|
pagination: {
|
||||||
|
mode: 'offset' | 'cursor';
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
count: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
hasPreviousPage: boolean;
|
||||||
|
nextPage: number | null;
|
||||||
|
previousPage: number | null;
|
||||||
|
currentCursor: string | null;
|
||||||
|
nextCursor: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildPaginatedResponse = <T>(
|
||||||
|
items: T[],
|
||||||
|
options: PaginatedResponseOptions,
|
||||||
|
): PaginatedResponse<T> => {
|
||||||
|
const count = items.length;
|
||||||
|
const totalPages = Math.ceil(options.total / options.limit) || 1;
|
||||||
|
const hasNextPage = options.offset + count < options.total;
|
||||||
|
const hasPreviousPage = options.offset > 0;
|
||||||
|
const mode =
|
||||||
|
options.mode ??
|
||||||
|
(options.currentCursor !== undefined || options.nextCursor !== undefined ? 'cursor' : 'offset');
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
count,
|
||||||
|
page: options.page,
|
||||||
|
limit: options.limit,
|
||||||
|
total: options.total,
|
||||||
|
totalPages,
|
||||||
|
nextCursor: options.nextCursor ?? null,
|
||||||
|
pagination: {
|
||||||
|
mode,
|
||||||
|
page: options.page,
|
||||||
|
limit: options.limit,
|
||||||
|
count,
|
||||||
|
total: options.total,
|
||||||
|
totalPages,
|
||||||
|
hasNextPage,
|
||||||
|
hasPreviousPage,
|
||||||
|
nextPage: hasNextPage ? options.page + 1 : null,
|
||||||
|
previousPage: hasPreviousPage ? Math.max(1, options.page - 1) : null,
|
||||||
|
currentCursor: options.currentCursor ?? null,
|
||||||
|
nextCursor: options.nextCursor ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
29
src/common/utils/public-url.util.spec.ts
Normal file
29
src/common/utils/public-url.util.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { resolveManagedFileUrl, resolveManagedFileUrls } from './public-url.util';
|
||||||
|
|
||||||
|
describe('public url util', () => {
|
||||||
|
const originalPublicBaseUrl = process.env.PUBLIC_BASE_URL;
|
||||||
|
const originalStorageBasePath = process.env.STORAGE_BASE_PATH;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.PUBLIC_BASE_URL = 'http://192.168.1.12:4000';
|
||||||
|
process.env.STORAGE_BASE_PATH = 'uploads';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.PUBLIC_BASE_URL = originalPublicBaseUrl;
|
||||||
|
process.env.STORAGE_BASE_PATH = originalStorageBasePath;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a local managed file url', () => {
|
||||||
|
expect(resolveManagedFileUrl('/uploads/posts/images/file.png')).toBe(
|
||||||
|
'http://192.168.1.12:4000/uploads/posts/images/file.png',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves arrays of local managed file urls', () => {
|
||||||
|
expect(resolveManagedFileUrls(['/uploads/a.png', '/uploads/b.png'])).toEqual([
|
||||||
|
'http://192.168.1.12:4000/uploads/a.png',
|
||||||
|
'http://192.168.1.12:4000/uploads/b.png',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
27
src/common/utils/public-url.util.ts
Normal file
27
src/common/utils/public-url.util.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
const getUploadsBasePath = (): string =>
|
||||||
|
`/${(process.env.STORAGE_BASE_PATH ?? 'uploads').replace(/^\/+|\/+$/g, '')}/`;
|
||||||
|
|
||||||
|
export const resolveManagedFileUrl = (fileUrl: unknown): unknown => {
|
||||||
|
if (typeof fileUrl !== 'string' || !fileUrl.trim()) {
|
||||||
|
return fileUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fileUrl.startsWith(getUploadsBasePath())) {
|
||||||
|
return fileUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = (process.env.PUBLIC_BASE_URL ?? '').replace(/\/$/, '');
|
||||||
|
if (!baseUrl) {
|
||||||
|
return fileUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${baseUrl}${fileUrl}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveManagedFileUrls = (fileUrls: unknown): unknown => {
|
||||||
|
if (!Array.isArray(fileUrls)) {
|
||||||
|
return fileUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileUrls.map((fileUrl) => resolveManagedFileUrl(fileUrl));
|
||||||
|
};
|
||||||
16
src/common/utils/query-transform.util.spec.ts
Normal file
16
src/common/utils/query-transform.util.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { toBoolean } from './query-transform.util';
|
||||||
|
|
||||||
|
describe('query transform util', () => {
|
||||||
|
it('converts "true" and "false" string values correctly', () => {
|
||||||
|
expect(toBoolean({ value: 'true' })).toBe(true);
|
||||||
|
expect(toBoolean({ value: 'TRUE' })).toBe(true);
|
||||||
|
expect(toBoolean({ value: 'false' })).toBe(false);
|
||||||
|
expect(toBoolean({ value: 'FALSE' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves unrelated values unchanged', () => {
|
||||||
|
expect(toBoolean({ value: '0' })).toBe('0');
|
||||||
|
expect(toBoolean({ value: 'hello' })).toBe('hello');
|
||||||
|
expect(toBoolean({ value: 1 })).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
17
src/common/utils/query-transform.util.ts
Normal file
17
src/common/utils/query-transform.util.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export const toBoolean = ({ value }: { value: unknown }): unknown => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (normalized === 'true') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (normalized === 'false') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === true || value === false) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
13
src/common/utils/sort.util.spec.ts
Normal file
13
src/common/utils/sort.util.spec.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { SortOrder } from '../enums/sort-order.enum';
|
||||||
|
import { resolveMongoSortDirection } from './sort.util';
|
||||||
|
|
||||||
|
describe('sort util', () => {
|
||||||
|
it('returns ascending for asc', () => {
|
||||||
|
expect(resolveMongoSortDirection(SortOrder.ASC)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns descending by default', () => {
|
||||||
|
expect(resolveMongoSortDirection(undefined)).toBe(-1);
|
||||||
|
expect(resolveMongoSortDirection(SortOrder.DESC)).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
7
src/common/utils/sort.util.ts
Normal file
7
src/common/utils/sort.util.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { SortOrder } from '../enums/sort-order.enum';
|
||||||
|
|
||||||
|
export type MongoSortDirection = 1 | -1;
|
||||||
|
|
||||||
|
export const resolveMongoSortDirection = (
|
||||||
|
sortOrder?: SortOrder | null,
|
||||||
|
): MongoSortDirection => (sortOrder === SortOrder.ASC ? 1 : -1);
|
||||||
101
src/common/utils/waveform.util.ts
Normal file
101
src/common/utils/waveform.util.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
const DEFAULT_SAMPLES = 48;
|
||||||
|
|
||||||
|
const scaleToRange = (values: number[]): number[] => {
|
||||||
|
if (!values.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const max = Math.max(...values);
|
||||||
|
if (max <= 0) {
|
||||||
|
return values.map(() => 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return values.map((value) => Math.max(0, Math.min(100, Math.round((value / max) * 100))));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeWaveformPeaks = (
|
||||||
|
input: number[] | undefined,
|
||||||
|
maxSamples = DEFAULT_SAMPLES,
|
||||||
|
): number[] => {
|
||||||
|
if (!input?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleaned = input
|
||||||
|
.map((value) => (Number.isFinite(value) ? Math.abs(value) : 0))
|
||||||
|
.filter((value) => value > 0);
|
||||||
|
|
||||||
|
if (!cleaned.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleaned.length <= maxSamples) {
|
||||||
|
return scaleToRange(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowSize = cleaned.length / maxSamples;
|
||||||
|
const compressed: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < maxSamples; i += 1) {
|
||||||
|
const start = Math.floor(i * windowSize);
|
||||||
|
const end = Math.min(cleaned.length, Math.floor((i + 1) * windowSize));
|
||||||
|
const slice = cleaned.slice(start, Math.max(start + 1, end));
|
||||||
|
const peak = Math.max(...slice);
|
||||||
|
compressed.push(peak);
|
||||||
|
}
|
||||||
|
|
||||||
|
return scaleToRange(compressed);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateWaveformPeaksFromBuffer = (
|
||||||
|
buffer: Buffer,
|
||||||
|
samples = DEFAULT_SAMPLES,
|
||||||
|
): number[] => {
|
||||||
|
if (!buffer.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkSize = Math.max(1, Math.ceil(buffer.length / samples));
|
||||||
|
const peaks: number[] = [];
|
||||||
|
|
||||||
|
for (let offset = 0; offset < buffer.length; offset += chunkSize) {
|
||||||
|
const chunk = buffer.subarray(offset, Math.min(buffer.length, offset + chunkSize));
|
||||||
|
let total = 0;
|
||||||
|
let localPeak = 0;
|
||||||
|
|
||||||
|
for (const byte of chunk) {
|
||||||
|
const centered = Math.abs(byte - 128);
|
||||||
|
total += centered;
|
||||||
|
localPeak = Math.max(localPeak, centered);
|
||||||
|
}
|
||||||
|
|
||||||
|
const average = chunk.length ? total / chunk.length : 0;
|
||||||
|
peaks.push(Math.max(average, localPeak * 0.65));
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeWaveformPeaks(peaks, samples);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateWaveformPeaksFromSeed = (
|
||||||
|
seed: string,
|
||||||
|
samples = DEFAULT_SAMPLES,
|
||||||
|
): number[] => {
|
||||||
|
const source = seed.trim() || 'audio';
|
||||||
|
let hash = 2166136261;
|
||||||
|
|
||||||
|
for (let i = 0; i < source.length; i += 1) {
|
||||||
|
hash ^= source.charCodeAt(i);
|
||||||
|
hash = Math.imul(hash, 16777619);
|
||||||
|
}
|
||||||
|
|
||||||
|
const peaks: number[] = [];
|
||||||
|
let state = hash >>> 0;
|
||||||
|
for (let i = 0; i < samples; i += 1) {
|
||||||
|
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
|
||||||
|
const base = 18 + (state % 65);
|
||||||
|
const accent = i % 6 === 0 ? 18 : i % 3 === 0 ? 10 : 0;
|
||||||
|
peaks.push(base + accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeWaveformPeaks(peaks, samples);
|
||||||
|
};
|
||||||
@@ -47,8 +47,61 @@ export default () => ({
|
|||||||
fromName: process.env.EMAIL_FROM_NAME ?? 'Oudelaa',
|
fromName: process.env.EMAIL_FROM_NAME ?? 'Oudelaa',
|
||||||
fromEmail: process.env.EMAIL_FROM_EMAIL ?? process.env.EMAIL_SMTP_USER ?? '',
|
fromEmail: process.env.EMAIL_FROM_EMAIL ?? process.env.EMAIL_SMTP_USER ?? '',
|
||||||
},
|
},
|
||||||
|
aiMusic: {
|
||||||
|
enabled: (process.env.AI_MUSIC_ENABLED ?? 'false').toLowerCase() === 'true',
|
||||||
|
apiKey: process.env.AI_MUSIC_API_KEY ?? '',
|
||||||
|
projectId: process.env.AI_MUSIC_PROJECT_ID ?? '',
|
||||||
|
location: process.env.AI_MUSIC_LOCATION ?? 'us-central1',
|
||||||
|
model: process.env.AI_MUSIC_MODEL ?? 'lyria-002',
|
||||||
|
},
|
||||||
security: {
|
security: {
|
||||||
bcryptSaltRounds: Number(process.env.BCRYPT_SALT_ROUNDS ?? 12),
|
bcryptSaltRounds: Number(process.env.BCRYPT_SALT_ROUNDS ?? 12),
|
||||||
|
refreshTokenHashSecret:
|
||||||
|
process.env.REFRESH_TOKEN_HASH_SECRET ?? process.env.JWT_REFRESH_SECRET ?? '',
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
enabled: (process.env.REDIS_ENABLED ?? 'false').toLowerCase() === 'true',
|
||||||
|
url: process.env.REDIS_URL ?? '',
|
||||||
|
host: process.env.REDIS_HOST ?? '127.0.0.1',
|
||||||
|
port: Number(process.env.REDIS_PORT ?? 6379),
|
||||||
|
username: process.env.REDIS_USERNAME ?? '',
|
||||||
|
password: process.env.REDIS_PASSWORD ?? '',
|
||||||
|
db: Number(process.env.REDIS_DB ?? 0),
|
||||||
|
keyPrefix: process.env.REDIS_KEY_PREFIX ?? 'oudelaa',
|
||||||
|
socketAdapterEnabled:
|
||||||
|
(process.env.REDIS_SOCKET_ADAPTER_ENABLED ?? 'false').toLowerCase() === 'true',
|
||||||
|
},
|
||||||
|
queue: {
|
||||||
|
enabled: (process.env.QUEUE_ENABLED ?? 'false').toLowerCase() === 'true',
|
||||||
|
name: process.env.QUEUE_NAME ?? 'app-jobs',
|
||||||
|
defaultJobAttempts: Number(process.env.QUEUE_DEFAULT_ATTEMPTS ?? 3),
|
||||||
|
defaultJobBackoffMs: Number(process.env.QUEUE_DEFAULT_BACKOFF_MS ?? 1000),
|
||||||
|
removeOnComplete:
|
||||||
|
(process.env.QUEUE_REMOVE_ON_COMPLETE ?? 'true').toLowerCase() === 'true',
|
||||||
|
workerConcurrency: Number(process.env.QUEUE_WORKER_CONCURRENCY ?? 5),
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
provider: process.env.STORAGE_PROVIDER ?? 'local',
|
||||||
|
basePath: process.env.STORAGE_BASE_PATH ?? 'uploads',
|
||||||
|
publicBaseUrl: process.env.STORAGE_PUBLIC_BASE_URL ?? '',
|
||||||
|
s3: {
|
||||||
|
bucket: process.env.S3_BUCKET ?? '',
|
||||||
|
region: process.env.S3_REGION ?? 'auto',
|
||||||
|
endpoint: process.env.S3_ENDPOINT ?? '',
|
||||||
|
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? '',
|
||||||
|
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? '',
|
||||||
|
forcePathStyle:
|
||||||
|
(process.env.S3_FORCE_PATH_STYLE ?? 'false').toLowerCase() === 'true',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
logging: {
|
||||||
|
level: process.env.LOG_LEVEL ?? 'log',
|
||||||
|
requestEnabled: (process.env.REQUEST_LOGGING_ENABLED ?? 'true').toLowerCase() === 'true',
|
||||||
|
},
|
||||||
|
feedCache: {
|
||||||
|
enabled: (process.env.FEED_CACHE_ENABLED ?? 'true').toLowerCase() === 'true',
|
||||||
|
userFeedTtlSeconds: Number(process.env.FEED_CACHE_USER_TTL_SECONDS ?? 15),
|
||||||
|
trendingTtlSeconds: Number(process.env.FEED_CACHE_TRENDING_TTL_SECONDS ?? 30),
|
||||||
},
|
},
|
||||||
passwordReset: {
|
passwordReset: {
|
||||||
codeExpiresMinutes: Number(process.env.PASSWORD_RESET_CODE_EXPIRES_MINUTES ?? 10),
|
codeExpiresMinutes: Number(process.env.PASSWORD_RESET_CODE_EXPIRES_MINUTES ?? 10),
|
||||||
|
|||||||
@@ -30,7 +30,42 @@ export const validationSchema = Joi.object({
|
|||||||
EMAIL_SMTP_PASS: Joi.string().allow('').optional(),
|
EMAIL_SMTP_PASS: Joi.string().allow('').optional(),
|
||||||
EMAIL_FROM_NAME: Joi.string().default('Oudelaa'),
|
EMAIL_FROM_NAME: Joi.string().default('Oudelaa'),
|
||||||
EMAIL_FROM_EMAIL: Joi.string().allow('').optional(),
|
EMAIL_FROM_EMAIL: Joi.string().allow('').optional(),
|
||||||
|
AI_MUSIC_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
|
||||||
|
AI_MUSIC_API_KEY: Joi.string().allow('').optional(),
|
||||||
|
AI_MUSIC_PROJECT_ID: Joi.string().allow('').optional(),
|
||||||
|
AI_MUSIC_LOCATION: Joi.string().default('us-central1'),
|
||||||
|
AI_MUSIC_MODEL: Joi.string().default('lyria-002'),
|
||||||
BCRYPT_SALT_ROUNDS: Joi.number().min(8).max(15).default(12),
|
BCRYPT_SALT_ROUNDS: Joi.number().min(8).max(15).default(12),
|
||||||
|
REFRESH_TOKEN_HASH_SECRET: Joi.string().allow('').optional(),
|
||||||
|
REDIS_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
|
||||||
|
REDIS_URL: Joi.string().allow('').optional(),
|
||||||
|
REDIS_HOST: Joi.string().default('127.0.0.1'),
|
||||||
|
REDIS_PORT: Joi.number().default(6379),
|
||||||
|
REDIS_USERNAME: Joi.string().allow('').optional(),
|
||||||
|
REDIS_PASSWORD: Joi.string().allow('').optional(),
|
||||||
|
REDIS_DB: Joi.number().min(0).default(0),
|
||||||
|
REDIS_KEY_PREFIX: Joi.string().default('oudelaa'),
|
||||||
|
REDIS_SOCKET_ADAPTER_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
|
||||||
|
QUEUE_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
|
||||||
|
QUEUE_NAME: Joi.string().default('app-jobs'),
|
||||||
|
QUEUE_DEFAULT_ATTEMPTS: Joi.number().min(1).max(20).default(3),
|
||||||
|
QUEUE_DEFAULT_BACKOFF_MS: Joi.number().min(100).max(600000).default(1000),
|
||||||
|
QUEUE_REMOVE_ON_COMPLETE: Joi.boolean().truthy('true').falsy('false').default(true),
|
||||||
|
QUEUE_WORKER_CONCURRENCY: Joi.number().min(1).max(100).default(5),
|
||||||
|
STORAGE_PROVIDER: Joi.string().valid('local', 's3').default('local'),
|
||||||
|
STORAGE_BASE_PATH: Joi.string().default('uploads'),
|
||||||
|
STORAGE_PUBLIC_BASE_URL: Joi.string().allow('').optional(),
|
||||||
|
S3_BUCKET: Joi.string().allow('').optional(),
|
||||||
|
S3_REGION: Joi.string().allow('').default('auto'),
|
||||||
|
S3_ENDPOINT: Joi.string().allow('').optional(),
|
||||||
|
S3_ACCESS_KEY_ID: Joi.string().allow('').optional(),
|
||||||
|
S3_SECRET_ACCESS_KEY: Joi.string().allow('').optional(),
|
||||||
|
S3_FORCE_PATH_STYLE: Joi.boolean().truthy('true').falsy('false').default(false),
|
||||||
|
LOG_LEVEL: Joi.string().valid('error', 'warn', 'log', 'debug', 'verbose').default('log'),
|
||||||
|
REQUEST_LOGGING_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true),
|
||||||
|
FEED_CACHE_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true),
|
||||||
|
FEED_CACHE_USER_TTL_SECONDS: Joi.number().min(1).max(3600).default(15),
|
||||||
|
FEED_CACHE_TRENDING_TTL_SECONDS: Joi.number().min(1).max(3600).default(30),
|
||||||
PASSWORD_RESET_CODE_EXPIRES_MINUTES: Joi.number().min(1).max(60).default(10),
|
PASSWORD_RESET_CODE_EXPIRES_MINUTES: Joi.number().min(1).max(60).default(10),
|
||||||
PASSWORD_RESET_MAX_ATTEMPTS: Joi.number().min(1).max(10).default(5),
|
PASSWORD_RESET_MAX_ATTEMPTS: Joi.number().min(1).max(10).default(5),
|
||||||
PASSWORD_RESET_TOKEN_SECRET: Joi.string().allow('').optional(),
|
PASSWORD_RESET_TOKEN_SECRET: Joi.string().allow('').optional(),
|
||||||
|
|||||||
102
src/infrastructure/cache/app-cache.service.ts
مباع
Normal file
102
src/infrastructure/cache/app-cache.service.ts
مباع
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { RedisService } from '../redis/redis.service';
|
||||||
|
|
||||||
|
type MemoryEntry = {
|
||||||
|
value: string;
|
||||||
|
expiresAt: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppCacheService {
|
||||||
|
private readonly memory = new Map<string, MemoryEntry>();
|
||||||
|
|
||||||
|
constructor(private readonly redisService: RedisService) {}
|
||||||
|
|
||||||
|
async get<T>(key: string): Promise<T | null> {
|
||||||
|
const redis = this.redisService.getClient();
|
||||||
|
const fullKey = this.buildKey(key);
|
||||||
|
|
||||||
|
if (redis) {
|
||||||
|
const value = await redis.get(fullKey);
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return JSON.parse(value) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memoryEntry = this.memory.get(fullKey);
|
||||||
|
if (!memoryEntry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (memoryEntry.expiresAt && memoryEntry.expiresAt <= Date.now()) {
|
||||||
|
this.memory.delete(fullKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(memoryEntry.value) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||||
|
const redis = this.redisService.getClient();
|
||||||
|
const fullKey = this.buildKey(key);
|
||||||
|
const serialized = JSON.stringify(value);
|
||||||
|
|
||||||
|
if (redis) {
|
||||||
|
if (ttlSeconds && ttlSeconds > 0) {
|
||||||
|
await redis.set(fullKey, serialized, 'EX', ttlSeconds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await redis.set(fullKey, serialized);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = ttlSeconds && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null;
|
||||||
|
this.memory.set(fullKey, { value: serialized, expiresAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key: string): Promise<void> {
|
||||||
|
const redis = this.redisService.getClient();
|
||||||
|
const fullKey = this.buildKey(key);
|
||||||
|
if (redis) {
|
||||||
|
await redis.del(fullKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.memory.delete(fullKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remember<T>(key: string, ttlSeconds: number, factory: () => Promise<T>): Promise<T> {
|
||||||
|
const cached = await this.get<T>(key);
|
||||||
|
if (cached !== null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = await factory();
|
||||||
|
await this.set(key, value, ttlSeconds);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async incr(key: string, ttlSeconds?: number): Promise<number> {
|
||||||
|
const redis = this.redisService.getClient();
|
||||||
|
const fullKey = this.buildKey(key);
|
||||||
|
|
||||||
|
if (redis) {
|
||||||
|
const nextValue = await redis.incr(fullKey);
|
||||||
|
if (ttlSeconds && ttlSeconds > 0 && nextValue === 1) {
|
||||||
|
await redis.expire(fullKey, ttlSeconds);
|
||||||
|
}
|
||||||
|
return nextValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.get<number>(key);
|
||||||
|
const nextValue = (existing ?? 0) + 1;
|
||||||
|
await this.set(key, nextValue, ttlSeconds);
|
||||||
|
return nextValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildKey(key: string): string {
|
||||||
|
return `${this.redisService.getKeyPrefix()}:${key}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/infrastructure/cache/cache.module.ts
مباع
Normal file
10
src/infrastructure/cache/cache.module.ts
مباع
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { AppCacheService } from './app-cache.service';
|
||||||
|
import { FeedVersionService } from './feed-version.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [AppCacheService, FeedVersionService],
|
||||||
|
exports: [AppCacheService, FeedVersionService],
|
||||||
|
})
|
||||||
|
export class CacheModule {}
|
||||||
23
src/infrastructure/cache/feed-version.service.ts
مباع
Normal file
23
src/infrastructure/cache/feed-version.service.ts
مباع
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AppCacheService } from './app-cache.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FeedVersionService {
|
||||||
|
private static readonly GLOBAL_VERSION_KEY = 'feed:global:version';
|
||||||
|
|
||||||
|
constructor(private readonly cacheService: AppCacheService) {}
|
||||||
|
|
||||||
|
async getGlobalVersion(): Promise<number> {
|
||||||
|
const current = await this.cacheService.get<number>(FeedVersionService.GLOBAL_VERSION_KEY);
|
||||||
|
if (typeof current === 'number' && current > 0) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.cacheService.set(FeedVersionService.GLOBAL_VERSION_KEY, 1);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async bumpGlobalVersion(): Promise<number> {
|
||||||
|
return this.cacheService.incr(FeedVersionService.GLOBAL_VERSION_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/infrastructure/logging/app-logger.service.ts
Normal file
91
src/infrastructure/logging/app-logger.service.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { Injectable, LoggerService } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
type AppLogLevel = 'error' | 'warn' | 'log' | 'debug' | 'verbose';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppLoggerService implements LoggerService {
|
||||||
|
private readonly levelPriority: Record<AppLogLevel, number> = {
|
||||||
|
error: 0,
|
||||||
|
warn: 1,
|
||||||
|
log: 2,
|
||||||
|
debug: 3,
|
||||||
|
verbose: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
|
log(message: any, context?: string): void {
|
||||||
|
this.write('log', message, undefined, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: any, trace?: string, context?: string): void {
|
||||||
|
this.write('error', message, trace, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(message: any, context?: string): void {
|
||||||
|
this.write('warn', message, undefined, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(message: any, context?: string): void {
|
||||||
|
this.write('debug', message, undefined, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
verbose(message: any, context?: string): void {
|
||||||
|
this.write('verbose', message, undefined, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
logHttp(payload: Record<string, unknown>): void {
|
||||||
|
this.write('log', 'http_request', undefined, 'HttpLogger', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private write(
|
||||||
|
level: AppLogLevel,
|
||||||
|
message: any,
|
||||||
|
trace?: string,
|
||||||
|
context?: string,
|
||||||
|
extra: Record<string, unknown> = {},
|
||||||
|
): void {
|
||||||
|
if (!this.shouldLog(level)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry: Record<string, unknown> = {
|
||||||
|
level,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
context: context ?? 'Application',
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof message === 'string') {
|
||||||
|
entry.message = message;
|
||||||
|
} else if (message instanceof Error) {
|
||||||
|
entry.message = message.message;
|
||||||
|
entry.errorName = message.name;
|
||||||
|
entry.stack = message.stack;
|
||||||
|
} else {
|
||||||
|
entry.message = 'structured_log';
|
||||||
|
entry.payload = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trace) {
|
||||||
|
entry.trace = trace;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialized = `${JSON.stringify(entry)}\n`;
|
||||||
|
if (level === 'error') {
|
||||||
|
process.stderr.write(serialized);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(serialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldLog(level: AppLogLevel): boolean {
|
||||||
|
const configuredLevel =
|
||||||
|
(this.configService.get<string>('logging.level', { infer: true }) as AppLogLevel | undefined) ??
|
||||||
|
'log';
|
||||||
|
|
||||||
|
return this.levelPriority[level] <= this.levelPriority[configuredLevel];
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/infrastructure/logging/logging.module.ts
Normal file
9
src/infrastructure/logging/logging.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { AppLoggerService } from './app-logger.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [AppLoggerService],
|
||||||
|
exports: [AppLoggerService],
|
||||||
|
})
|
||||||
|
export class LoggingModule {}
|
||||||
145
src/infrastructure/queue/app-queue.service.ts
Normal file
145
src/infrastructure/queue/app-queue.service.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
OnApplicationBootstrap,
|
||||||
|
OnModuleDestroy,
|
||||||
|
OnModuleInit,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { JobsOptions, Queue, Worker } from 'bullmq';
|
||||||
|
import { AppLoggerService } from '../logging/app-logger.service';
|
||||||
|
import { RedisService } from '../redis/redis.service';
|
||||||
|
|
||||||
|
type JobProcessor = (payload: Record<string, unknown>) => Promise<void>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppQueueService
|
||||||
|
implements OnModuleInit, OnApplicationBootstrap, OnModuleDestroy
|
||||||
|
{
|
||||||
|
private readonly processors = new Map<string, JobProcessor>();
|
||||||
|
private queue: Queue | null = null;
|
||||||
|
private worker: Worker | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly redisService: RedisService,
|
||||||
|
private readonly logger: AppLoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
// Intentionally empty. Processors are usually registered by other providers before bootstrap.
|
||||||
|
}
|
||||||
|
|
||||||
|
onApplicationBootstrap(): void {
|
||||||
|
if (!this.isQueueEnabled() || !this.redisService.isEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueName = this.getQueueName();
|
||||||
|
const queueConnection = this.redisService.createQueueClient();
|
||||||
|
const workerConnection = this.redisService.createQueueClient();
|
||||||
|
if (!queueConnection || !workerConnection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.queue = new Queue(queueName, {
|
||||||
|
connection: queueConnection,
|
||||||
|
defaultJobOptions: this.getDefaultJobOptions(),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.worker = new Worker(
|
||||||
|
queueName,
|
||||||
|
async (job) => {
|
||||||
|
const processor = this.processors.get(job.name);
|
||||||
|
if (!processor) {
|
||||||
|
throw new Error(`No processor registered for job "${job.name}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await processor(job.data as Record<string, unknown>);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
connection: workerConnection,
|
||||||
|
concurrency:
|
||||||
|
this.configService.get<number>('queue.workerConcurrency', { infer: true }) ?? 5,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.worker.on('failed', (job, error) => {
|
||||||
|
this.logger.error(
|
||||||
|
{
|
||||||
|
queue: queueName,
|
||||||
|
jobName: job?.name,
|
||||||
|
jobId: job?.id,
|
||||||
|
error: error.message,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
AppQueueService.name,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProcessor(jobName: string, processor: JobProcessor): void {
|
||||||
|
this.processors.set(jobName, processor);
|
||||||
|
}
|
||||||
|
|
||||||
|
async enqueue(
|
||||||
|
jobName: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
options: JobsOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
|
if (this.queue) {
|
||||||
|
await this.queue.add(jobName, payload, {
|
||||||
|
...this.getDefaultJobOptions(),
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processor = this.processors.get(jobName);
|
||||||
|
if (!processor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void processor(payload).catch((error: Error) => {
|
||||||
|
this.logger.error(
|
||||||
|
{
|
||||||
|
jobName,
|
||||||
|
payload,
|
||||||
|
error: error.message,
|
||||||
|
},
|
||||||
|
error.stack,
|
||||||
|
AppQueueService.name,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
await this.worker?.close();
|
||||||
|
await this.queue?.close();
|
||||||
|
this.worker = null;
|
||||||
|
this.queue = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isQueueEnabled(): boolean {
|
||||||
|
return this.configService.get<boolean>('queue.enabled', { infer: true }) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getQueueName(): string {
|
||||||
|
return this.configService.get<string>('queue.name', { infer: true }) ?? 'app-jobs';
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultJobOptions(): JobsOptions {
|
||||||
|
return {
|
||||||
|
attempts:
|
||||||
|
this.configService.get<number>('queue.defaultJobAttempts', { infer: true }) ?? 3,
|
||||||
|
backoff: {
|
||||||
|
type: 'exponential',
|
||||||
|
delay:
|
||||||
|
this.configService.get<number>('queue.defaultJobBackoffMs', { infer: true }) ?? 1000,
|
||||||
|
},
|
||||||
|
removeOnComplete:
|
||||||
|
this.configService.get<boolean>('queue.removeOnComplete', { infer: true }) ?? true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/infrastructure/queue/queue.module.ts
Normal file
9
src/infrastructure/queue/queue.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { AppQueueService } from './app-queue.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [AppQueueService],
|
||||||
|
exports: [AppQueueService],
|
||||||
|
})
|
||||||
|
export class QueueModule {}
|
||||||
9
src/infrastructure/redis/redis.module.ts
Normal file
9
src/infrastructure/redis/redis.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { RedisService } from './redis.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [RedisService],
|
||||||
|
exports: [RedisService],
|
||||||
|
})
|
||||||
|
export class RedisModule {}
|
||||||
79
src/infrastructure/redis/redis.service.ts
Normal file
79
src/infrastructure/redis/redis.service.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import Redis, { RedisOptions } from 'ioredis';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RedisService implements OnModuleDestroy {
|
||||||
|
private client: Redis | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
|
isEnabled(): boolean {
|
||||||
|
return this.configService.get<boolean>('redis.enabled', { infer: true }) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getKeyPrefix(): string {
|
||||||
|
return this.configService.get<string>('redis.keyPrefix', { infer: true }) ?? 'oudelaa';
|
||||||
|
}
|
||||||
|
|
||||||
|
getClient(): Redis | null {
|
||||||
|
if (!this.isEnabled()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.client) {
|
||||||
|
this.client = this.createClient({ maxRetriesPerRequest: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
createPubSubClients(): { pubClient: Redis; subClient: Redis } | null {
|
||||||
|
if (!this.isEnabled()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pubClient = this.createClient({ maxRetriesPerRequest: null });
|
||||||
|
const subClient = pubClient.duplicate();
|
||||||
|
return { pubClient, subClient };
|
||||||
|
}
|
||||||
|
|
||||||
|
createQueueClient(): Redis | null {
|
||||||
|
if (!this.isEnabled()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.createClient({ maxRetriesPerRequest: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy(): void {
|
||||||
|
if (this.client) {
|
||||||
|
void this.client.quit().catch(() => this.client?.disconnect());
|
||||||
|
this.client = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createClient(overrides: Partial<RedisOptions> = {}): Redis {
|
||||||
|
const url = this.configService.get<string>('redis.url', { infer: true }) ?? '';
|
||||||
|
const baseOptions: RedisOptions = {
|
||||||
|
host: this.configService.get<string>('redis.host', { infer: true }) ?? '127.0.0.1',
|
||||||
|
port: this.configService.get<number>('redis.port', { infer: true }) ?? 6379,
|
||||||
|
username: this.configService.get<string>('redis.username', { infer: true }) || undefined,
|
||||||
|
password: this.configService.get<string>('redis.password', { infer: true }) || undefined,
|
||||||
|
db: this.configService.get<number>('redis.db', { infer: true }) ?? 0,
|
||||||
|
lazyConnect: false,
|
||||||
|
enableReadyCheck: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
return new Redis(url, {
|
||||||
|
...overrides,
|
||||||
|
lazyConnect: false,
|
||||||
|
enableReadyCheck: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Redis(baseOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/infrastructure/socket/redis-io.adapter.ts
Normal file
45
src/infrastructure/socket/redis-io.adapter.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { INestApplicationContext } from '@nestjs/common';
|
||||||
|
import { IoAdapter } from '@nestjs/platform-socket.io';
|
||||||
|
import { createAdapter } from '@socket.io/redis-adapter';
|
||||||
|
import { ServerOptions } from 'socket.io';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { RedisService } from '../redis/redis.service';
|
||||||
|
|
||||||
|
export class RedisIoAdapter extends IoAdapter {
|
||||||
|
private adapterConstructor: ReturnType<typeof createAdapter> | null = null;
|
||||||
|
private pubClient: Redis | null = null;
|
||||||
|
private subClient: Redis | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
app: INestApplicationContext,
|
||||||
|
private readonly redisService: RedisService,
|
||||||
|
) {
|
||||||
|
super(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectToRedis(): Promise<void> {
|
||||||
|
const clients = this.redisService.createPubSubClients();
|
||||||
|
if (!clients) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pubClient = clients.pubClient;
|
||||||
|
this.subClient = clients.subClient;
|
||||||
|
this.adapterConstructor = createAdapter(this.pubClient as any, this.subClient as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
createIOServer(port: number, options?: ServerOptions) {
|
||||||
|
const server = super.createIOServer(port, options);
|
||||||
|
if (this.adapterConstructor) {
|
||||||
|
server.adapter(this.adapterConstructor);
|
||||||
|
}
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await this.pubClient?.quit().catch(() => this.pubClient?.disconnect());
|
||||||
|
await this.subClient?.quit().catch(() => this.subClient?.disconnect());
|
||||||
|
this.pubClient = null;
|
||||||
|
this.subClient = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
210
src/infrastructure/storage/managed-storage.service.ts
Normal file
210
src/infrastructure/storage/managed-storage.service.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import { BadRequestException, Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||||
|
import { Upload } from '@aws-sdk/lib-storage';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { mkdir, unlink, writeFile } from 'fs/promises';
|
||||||
|
import { join, posix } from 'path';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ManagedStorageService implements OnModuleDestroy {
|
||||||
|
private s3Client: S3Client | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
|
async saveFile(params: {
|
||||||
|
folderSegments: string[];
|
||||||
|
extension: string;
|
||||||
|
buffer: Buffer;
|
||||||
|
contentType?: string;
|
||||||
|
fileNamePrefix?: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const fileName = `${params.fileNamePrefix ?? 'file'}-${randomUUID()}${params.extension}`;
|
||||||
|
const provider = this.getProvider();
|
||||||
|
const basePath = this.getBasePath();
|
||||||
|
const normalizedSegments = params.folderSegments.map((segment) =>
|
||||||
|
segment.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''),
|
||||||
|
);
|
||||||
|
const objectKey = posix.join(basePath, ...normalizedSegments, fileName);
|
||||||
|
|
||||||
|
if (provider === 's3') {
|
||||||
|
const client = this.getS3Client();
|
||||||
|
const upload = new Upload({
|
||||||
|
client,
|
||||||
|
params: {
|
||||||
|
Bucket: this.getS3Bucket(),
|
||||||
|
Key: objectKey,
|
||||||
|
Body: params.buffer,
|
||||||
|
ContentType: params.contentType || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await upload.done();
|
||||||
|
return this.resolvePublicUrl(objectKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadDir = join(process.cwd(), ...objectKey.split('/').slice(0, -1));
|
||||||
|
await mkdir(uploadDir, { recursive: true });
|
||||||
|
await writeFile(join(process.cwd(), ...objectKey.split('/')), params.buffer);
|
||||||
|
return `/${objectKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFile(fileUrl?: string): Promise<void> {
|
||||||
|
if (!fileUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.getProvider() === 's3') {
|
||||||
|
const objectKey = this.resolveS3ObjectKey(fileUrl);
|
||||||
|
if (!objectKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = this.getS3Client();
|
||||||
|
await client.send(
|
||||||
|
new DeleteObjectCommand({
|
||||||
|
Bucket: this.getS3Bucket(),
|
||||||
|
Key: objectKey,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativePath = this.resolveLocalRelativePath(fileUrl);
|
||||||
|
if (!relativePath || relativePath.includes('..')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await unlink(join(process.cwd(), relativePath.replace(/\//g, '\\')));
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup failures for already-missing files.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy(): void {
|
||||||
|
this.s3Client = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getProvider(): 'local' | 's3' {
|
||||||
|
return (this.configService.get<string>('storage.provider', { infer: true }) as
|
||||||
|
| 'local'
|
||||||
|
| 's3'
|
||||||
|
| undefined) ?? 'local';
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBasePath(): string {
|
||||||
|
return (this.configService.get<string>('storage.basePath', { infer: true }) ?? 'uploads')
|
||||||
|
.replace(/\\/g, '/')
|
||||||
|
.replace(/^\/+|\/+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private getS3Bucket(): string {
|
||||||
|
const bucket = this.configService.get<string>('storage.s3.bucket', { infer: true }) ?? '';
|
||||||
|
if (!bucket) {
|
||||||
|
throw new BadRequestException('S3 bucket is not configured');
|
||||||
|
}
|
||||||
|
return bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getS3Client(): S3Client {
|
||||||
|
if (this.s3Client) {
|
||||||
|
return this.s3Client;
|
||||||
|
}
|
||||||
|
|
||||||
|
const region = this.configService.get<string>('storage.s3.region', { infer: true }) ?? 'auto';
|
||||||
|
const endpoint = this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? '';
|
||||||
|
const accessKeyId =
|
||||||
|
this.configService.get<string>('storage.s3.accessKeyId', { infer: true }) ?? '';
|
||||||
|
const secretAccessKey =
|
||||||
|
this.configService.get<string>('storage.s3.secretAccessKey', { infer: true }) ?? '';
|
||||||
|
const forcePathStyle =
|
||||||
|
this.configService.get<boolean>('storage.s3.forcePathStyle', { infer: true }) ?? false;
|
||||||
|
|
||||||
|
if (!endpoint || !accessKeyId || !secretAccessKey) {
|
||||||
|
throw new BadRequestException('S3 storage settings are not fully configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.s3Client = new S3Client({
|
||||||
|
region,
|
||||||
|
endpoint,
|
||||||
|
forcePathStyle,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.s3Client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolvePublicUrl(objectKey: string): string {
|
||||||
|
const publicBaseUrl =
|
||||||
|
(this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? '').replace(
|
||||||
|
/\/$/,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
if (publicBaseUrl) {
|
||||||
|
return `${publicBaseUrl}/${objectKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = (this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? '').replace(
|
||||||
|
/\/$/,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
const bucket = this.getS3Bucket();
|
||||||
|
const forcePathStyle =
|
||||||
|
this.configService.get<boolean>('storage.s3.forcePathStyle', { infer: true }) ?? false;
|
||||||
|
|
||||||
|
if (!endpoint) {
|
||||||
|
throw new BadRequestException('storage.publicBaseUrl or storage.s3.endpoint is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return forcePathStyle ? `${endpoint}/${bucket}/${objectKey}` : `${endpoint}/${objectKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveLocalRelativePath(fileUrl: string): string | null {
|
||||||
|
const normalizedUrl = fileUrl.split('?')[0].split('#')[0];
|
||||||
|
if (!normalizedUrl.startsWith('/')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedPrefix = `/${this.getBasePath()}/`;
|
||||||
|
if (!normalizedUrl.startsWith(expectedPrefix) && normalizedUrl !== `/${this.getBasePath()}`) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedUrl.replace(/^\/+/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveS3ObjectKey(fileUrl: string): string | null {
|
||||||
|
const normalizedUrl = fileUrl.split('?')[0].split('#')[0];
|
||||||
|
const publicBaseUrl =
|
||||||
|
(this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? '').replace(
|
||||||
|
/\/$/,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (publicBaseUrl && normalizedUrl.startsWith(`${publicBaseUrl}/`)) {
|
||||||
|
return normalizedUrl.slice(publicBaseUrl.length + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = (this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? '').replace(
|
||||||
|
/\/$/,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
const bucket = this.getS3Bucket();
|
||||||
|
const forcePathStyle =
|
||||||
|
this.configService.get<boolean>('storage.s3.forcePathStyle', { infer: true }) ?? false;
|
||||||
|
|
||||||
|
if (endpoint && normalizedUrl.startsWith(`${endpoint}/`)) {
|
||||||
|
const pathPart = normalizedUrl.slice(endpoint.length + 1);
|
||||||
|
if (forcePathStyle) {
|
||||||
|
const expectedPrefix = `${bucket}/`;
|
||||||
|
return pathPart.startsWith(expectedPrefix) ? pathPart.slice(expectedPrefix.length) : null;
|
||||||
|
}
|
||||||
|
return pathPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/infrastructure/storage/storage.module.ts
Normal file
9
src/infrastructure/storage/storage.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { ManagedStorageService } from './managed-storage.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [ManagedStorageService],
|
||||||
|
exports: [ManagedStorageService],
|
||||||
|
})
|
||||||
|
export class StorageModule {}
|
||||||
47
src/main.ts
47
src/main.ts
@@ -9,14 +9,27 @@ import { existsSync, mkdirSync } from 'fs';
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { ResponseEnvelopeInterceptor } from './common/interceptors/response-envelope.interceptor';
|
import { ResponseEnvelopeInterceptor } from './common/interceptors/response-envelope.interceptor';
|
||||||
|
import { AppLoggerService } from './infrastructure/logging/app-logger.service';
|
||||||
|
import { RedisService } from './infrastructure/redis/redis.service';
|
||||||
|
import { RedisIoAdapter } from './infrastructure/socket/redis-io.adapter';
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule, { bufferLogs: true });
|
||||||
const configService = app.get(ConfigService);
|
const configService = app.get(ConfigService);
|
||||||
|
const appLogger = app.get(AppLoggerService);
|
||||||
|
app.useLogger(appLogger);
|
||||||
const corsOrigins = configService.get<string[]>('cors.origins', []);
|
const corsOrigins = configService.get<string[]>('cors.origins', []);
|
||||||
const uploadsDir = join(process.cwd(), 'uploads');
|
const storageProvider = configService.get<string>('storage.provider', { infer: true }) ?? 'local';
|
||||||
|
const storageBasePath =
|
||||||
|
(configService.get<string>('storage.basePath', { infer: true }) ?? 'uploads').replace(
|
||||||
|
/^\/+|\/+$/g,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
const publicBaseUrl =
|
||||||
|
(configService.get<string>('publicBaseUrl', { infer: true }) ?? '').replace(/\/$/, '');
|
||||||
|
const uploadsDir = join(process.cwd(), storageBasePath);
|
||||||
|
|
||||||
if (!existsSync(uploadsDir)) {
|
if (storageProvider === 'local' && !existsSync(uploadsDir)) {
|
||||||
mkdirSync(uploadsDir, { recursive: true });
|
mkdirSync(uploadsDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,15 +56,13 @@ async function bootstrap(): Promise<void> {
|
|||||||
res.setHeader('x-request-id', requestId);
|
res.setHeader('x-request-id', requestId);
|
||||||
|
|
||||||
res.on('finish', () => {
|
res.on('finish', () => {
|
||||||
const log = {
|
appLogger.logHttp({
|
||||||
level: 'info',
|
|
||||||
requestId,
|
requestId,
|
||||||
method: req.method,
|
method: req.method,
|
||||||
path: req.originalUrl,
|
path: req.originalUrl,
|
||||||
statusCode: res.statusCode,
|
statusCode: res.statusCode,
|
||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
};
|
});
|
||||||
console.log(JSON.stringify(log));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
next();
|
next();
|
||||||
@@ -62,7 +73,18 @@ async function bootstrap(): Promise<void> {
|
|||||||
app.useGlobalInterceptors(new ResponseEnvelopeInterceptor());
|
app.useGlobalInterceptors(new ResponseEnvelopeInterceptor());
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use('/uploads', express.static(uploadsDir));
|
if (storageProvider === 'local') {
|
||||||
|
app.use(`/${storageBasePath}`, express.static(uploadsDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
const redisEnabled = configService.get<boolean>('redis.enabled', { infer: true }) ?? false;
|
||||||
|
const socketAdapterEnabled =
|
||||||
|
configService.get<boolean>('redis.socketAdapterEnabled', { infer: true }) ?? false;
|
||||||
|
if (redisEnabled && socketAdapterEnabled) {
|
||||||
|
const redisIoAdapter = new RedisIoAdapter(app, app.get(RedisService));
|
||||||
|
await redisIoAdapter.connectToRedis();
|
||||||
|
app.useWebSocketAdapter(redisIoAdapter);
|
||||||
|
}
|
||||||
|
|
||||||
const swaggerConfig = new DocumentBuilder()
|
const swaggerConfig = new DocumentBuilder()
|
||||||
.setTitle(configService.get<string>('swagger.title', 'Oudelaa API'))
|
.setTitle(configService.get<string>('swagger.title', 'Oudelaa API'))
|
||||||
@@ -78,7 +100,16 @@ async function bootstrap(): Promise<void> {
|
|||||||
|
|
||||||
const port = configService.get<number>('port', 4000);
|
const port = configService.get<number>('port', 4000);
|
||||||
const host = configService.get<string>('host', '0.0.0.0');
|
const host = configService.get<string>('host', '0.0.0.0');
|
||||||
|
if (host === '0.0.0.0' && publicBaseUrl.includes('localhost')) {
|
||||||
|
appLogger.warn(
|
||||||
|
`PUBLIC_BASE_URL is set to "${publicBaseUrl}". Mobile devices on the LAN will not be able to open uploaded files until this is changed to your machine IP, for example http://192.168.x.x:${port}`,
|
||||||
|
'Bootstrap',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await app.listen(port, host);
|
await app.listen(port, host);
|
||||||
|
appLogger.log(`Server listening on http://${host}:${port}`, 'Bootstrap');
|
||||||
|
appLogger.log(`Resolved PUBLIC_BASE_URL=${publicBaseUrl || `http://localhost:${port}`}`, 'Bootstrap');
|
||||||
}
|
}
|
||||||
|
|
||||||
void bootstrap();
|
void bootstrap();
|
||||||
|
|||||||
22
src/modules/audit/audit.controller.ts
Normal file
22
src/modules/audit/audit.controller.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator';
|
||||||
|
import { SuperAdminPermissionsGuard } from '../../common/guards/superadmin-permissions.guard';
|
||||||
|
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
|
||||||
|
import { AuditService } from './audit.service';
|
||||||
|
import { AuditQueryDto } from './dto/audit-query.dto';
|
||||||
|
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
|
||||||
|
|
||||||
|
@ApiTags('Audit')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||||
|
@Controller('audit/superadmin')
|
||||||
|
export class AuditController {
|
||||||
|
constructor(private readonly auditService: AuditService) {}
|
||||||
|
|
||||||
|
@Get('logs')
|
||||||
|
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.AUDIT_READ)
|
||||||
|
async listLogs(@Query() query: AuditQueryDto) {
|
||||||
|
return this.auditService.listSuperAdminLogs(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { MongooseModule } from '@nestjs/mongoose';
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
|
import { AuditController } from './audit.controller';
|
||||||
import { AuditRepository } from './audit.repository';
|
import { AuditRepository } from './audit.repository';
|
||||||
import { AuditService } from './audit.service';
|
import { AuditService } from './audit.service';
|
||||||
import { AuditLog, AuditLogSchema } from './schemas/audit-log.schema';
|
import { AuditLog, AuditLogSchema } from './schemas/audit-log.schema';
|
||||||
@@ -13,6 +14,7 @@ import { AuditLog, AuditLogSchema } from './schemas/audit-log.schema';
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
|
controllers: [AuditController],
|
||||||
providers: [AuditRepository, AuditService],
|
providers: [AuditRepository, AuditService],
|
||||||
exports: [AuditService],
|
exports: [AuditService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -26,4 +26,17 @@ export class AuditRepository {
|
|||||||
metadata: payload.metadata ?? {},
|
metadata: payload.metadata ?? {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findMany(
|
||||||
|
filter: Record<string, unknown>,
|
||||||
|
skip: number,
|
||||||
|
limit: number,
|
||||||
|
sort: Record<string, 1 | -1> = { createdAt: -1 },
|
||||||
|
): Promise<AuditLogDocument[]> {
|
||||||
|
return this.auditModel.find(filter).sort(sort).skip(skip).limit(limit).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async count(filter: Record<string, unknown>): Promise<number> {
|
||||||
|
return this.auditModel.countDocuments(filter).exec();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
||||||
|
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
||||||
import { AuditRepository } from './audit.repository';
|
import { AuditRepository } from './audit.repository';
|
||||||
|
import { AuditQueryDto } from './dto/audit-query.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuditService {
|
export class AuditService {
|
||||||
@@ -21,4 +24,41 @@ export class AuditService {
|
|||||||
metadata,
|
metadata,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listSuperAdminLogs(query: AuditQueryDto) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const limit = query.limit ?? 20;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
const filter: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (query.q?.trim()) {
|
||||||
|
filter.$or = [
|
||||||
|
{ action: { $regex: query.q.trim(), $options: 'i' } },
|
||||||
|
{ targetType: { $regex: query.q.trim(), $options: 'i' } },
|
||||||
|
{ targetId: { $regex: query.q.trim(), $options: 'i' } },
|
||||||
|
{ actorIdentifier: { $regex: query.q.trim(), $options: 'i' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.actorType) {
|
||||||
|
filter.actorType = query.actorType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.targetType?.trim()) {
|
||||||
|
filter.targetType = query.targetType.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.auditRepository.findMany(filter, skip, limit, sort),
|
||||||
|
this.auditRepository.count(filter),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return buildPaginatedResponse(items, {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
offset: skip,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
src/modules/audit/dto/audit-query.dto.ts
Normal file
22
src/modules/audit/dto/audit-query.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsEnum, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||||
|
|
||||||
|
const ACTOR_TYPES = ['user', 'superadmin', 'system'] as const;
|
||||||
|
|
||||||
|
export class AuditQueryDto extends PaginationQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Search in action, targetType, targetId, or actorIdentifier' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
q?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ACTOR_TYPES })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ACTOR_TYPES)
|
||||||
|
actorType?: (typeof ACTOR_TYPES)[number];
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
targetType?: string;
|
||||||
|
}
|
||||||
@@ -3,7 +3,10 @@ import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
|||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { Throttle } from '../../common/decorators/throttle.decorator';
|
import { Throttle } from '../../common/decorators/throttle.decorator';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { SuperAdminPermissionsGuard } from '../../common/guards/superadmin-permissions.guard';
|
||||||
|
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
|
||||||
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||||
@@ -18,6 +21,7 @@ import { SendEmailVerificationDto } from './dto/send-email-verification.dto';
|
|||||||
import { SuperAdminLoginDto } from './dto/super-admin-login.dto';
|
import { SuperAdminLoginDto } from './dto/super-admin-login.dto';
|
||||||
import { VerifyEmailDto } from './dto/verify-email.dto';
|
import { VerifyEmailDto } from './dto/verify-email.dto';
|
||||||
import { VerifyResetCodeDto } from './dto/verify-reset-code.dto';
|
import { VerifyResetCodeDto } from './dto/verify-reset-code.dto';
|
||||||
|
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
|
||||||
|
|
||||||
@ApiTags('Auth')
|
@ApiTags('Auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@@ -151,4 +155,24 @@ export class AuthController {
|
|||||||
await this.authService.revokeUserSession(user.sub, jti);
|
await this.authService.revokeUserSession(user.sub, jti);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||||
|
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.SESSIONS_MANAGE)
|
||||||
|
@Get('superadmin/sessions')
|
||||||
|
async listSuperAdminSessions(@CurrentUser() user: JwtPayload) {
|
||||||
|
return this.authService.listSuperAdminSessions(user.email ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||||
|
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.SESSIONS_MANAGE)
|
||||||
|
@Post('superadmin/sessions/:sessionId/revoke')
|
||||||
|
async revokeSuperAdminSession(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
) {
|
||||||
|
await this.authService.revokeSuperAdminSession(user.email ?? '', sessionId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,11 +91,13 @@ export class AuthRepository {
|
|||||||
|
|
||||||
async createSuperAdminRefreshToken(
|
async createSuperAdminRefreshToken(
|
||||||
adminEmail: string,
|
adminEmail: string,
|
||||||
|
jti: string,
|
||||||
tokenHash: string,
|
tokenHash: string,
|
||||||
expiresAt: Date,
|
expiresAt: Date,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.superAdminRefreshTokenModel.create({
|
await this.superAdminRefreshTokenModel.create({
|
||||||
adminEmail: adminEmail.toLowerCase(),
|
adminEmail: adminEmail.toLowerCase(),
|
||||||
|
jti,
|
||||||
tokenHash,
|
tokenHash,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
});
|
});
|
||||||
@@ -108,12 +110,28 @@ export class AuthRepository {
|
|||||||
.exec();
|
.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findActiveSuperAdminTokenByJti(
|
||||||
|
adminEmail: string,
|
||||||
|
jti: string,
|
||||||
|
): Promise<SuperAdminRefreshTokenDocument | null> {
|
||||||
|
return this.superAdminRefreshTokenModel
|
||||||
|
.findOne({ adminEmail: adminEmail.toLowerCase(), jti, revoked: false })
|
||||||
|
.select('+tokenHash')
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
async revokeAllSuperAdminTokens(adminEmail: string): Promise<void> {
|
async revokeAllSuperAdminTokens(adminEmail: string): Promise<void> {
|
||||||
await this.superAdminRefreshTokenModel
|
await this.superAdminRefreshTokenModel
|
||||||
.updateMany({ adminEmail: adminEmail.toLowerCase(), revoked: false }, { revoked: true })
|
.updateMany({ adminEmail: adminEmail.toLowerCase(), revoked: false }, { revoked: true })
|
||||||
.exec();
|
.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async revokeSuperAdminTokenByJti(adminEmail: string, jti: string): Promise<void> {
|
||||||
|
await this.superAdminRefreshTokenModel
|
||||||
|
.updateOne({ adminEmail: adminEmail.toLowerCase(), jti, revoked: false }, { revoked: true })
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
async removeExpiredAndRevokedSuperAdmin(adminEmail: string): Promise<void> {
|
async removeExpiredAndRevokedSuperAdmin(adminEmail: string): Promise<void> {
|
||||||
await this.superAdminRefreshTokenModel
|
await this.superAdminRefreshTokenModel
|
||||||
.deleteMany({
|
.deleteMany({
|
||||||
@@ -123,6 +141,34 @@ export class AuthRepository {
|
|||||||
.exec();
|
.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listSuperAdminSessions(adminEmail: string): Promise<SuperAdminRefreshTokenDocument[]> {
|
||||||
|
return this.superAdminRefreshTokenModel
|
||||||
|
.find({
|
||||||
|
adminEmail: adminEmail.toLowerCase(),
|
||||||
|
revoked: false,
|
||||||
|
expiresAt: { $gt: new Date() },
|
||||||
|
})
|
||||||
|
.select('adminEmail jti expiresAt createdAt')
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeSuperAdminSessionById(adminEmail: string, sessionId: string): Promise<boolean> {
|
||||||
|
const updated = await this.superAdminRefreshTokenModel
|
||||||
|
.findOneAndUpdate(
|
||||||
|
{
|
||||||
|
_id: new Types.ObjectId(sessionId),
|
||||||
|
adminEmail: adminEmail.toLowerCase(),
|
||||||
|
revoked: false,
|
||||||
|
},
|
||||||
|
{ revoked: true },
|
||||||
|
{ new: false },
|
||||||
|
)
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
return !!updated;
|
||||||
|
}
|
||||||
|
|
||||||
async invalidateActivePasswordResetCodes(userId: string): Promise<void> {
|
async invalidateActivePasswordResetCodes(userId: string): Promise<void> {
|
||||||
await this.passwordResetCodeModel
|
await this.passwordResetCodeModel
|
||||||
.updateMany(
|
.updateMany(
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { randomBytes, randomInt, randomUUID } from 'crypto';
|
import { randomBytes, randomInt, randomUUID } from 'crypto';
|
||||||
import { OAuth2Client } from 'google-auth-library';
|
import { OAuth2Client } from 'google-auth-library';
|
||||||
import { compareHash, hashValue } from '../../common/utils/hash.util';
|
import {
|
||||||
|
compareHash,
|
||||||
|
compareStoredHighEntropyValue,
|
||||||
|
hashHighEntropyValue,
|
||||||
|
hashValue,
|
||||||
|
} from '../../common/utils/hash.util';
|
||||||
import { EmailService } from '../email/email.service';
|
import { EmailService } from '../email/email.service';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||||
@@ -25,6 +30,7 @@ import { VerifyEmailDto } from './dto/verify-email.dto';
|
|||||||
import { VerifyResetCodeDto } from './dto/verify-reset-code.dto';
|
import { VerifyResetCodeDto } from './dto/verify-reset-code.dto';
|
||||||
import { AuthRepository } from './auth.repository';
|
import { AuthRepository } from './auth.repository';
|
||||||
import { AuthResult, TokenPair } from './types/token-pair.type';
|
import { AuthResult, TokenPair } from './types/token-pair.type';
|
||||||
|
import { DEFAULT_SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -56,16 +62,10 @@ export class AuthService {
|
|||||||
username: generatedUsername,
|
username: generatedUsername,
|
||||||
password: passwordHash,
|
password: passwordHash,
|
||||||
});
|
});
|
||||||
const code = await this.issueEmailVerificationCode(user.id, user.email);
|
return {
|
||||||
const response: { message: string; email: string; debugCode?: string } = {
|
message: 'Registration successful. Account is pending SuperAdmin verification.',
|
||||||
message: 'Registration successful. Verify your email with the code sent.',
|
|
||||||
email: user.email,
|
email: user.email,
|
||||||
};
|
};
|
||||||
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
|
|
||||||
if (nodeEnv !== 'production') {
|
|
||||||
response.debugCode = code;
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async registerBasic(dto: RegisterBasicDto): Promise<{ message: string; email: string; debugCode?: string }> {
|
async registerBasic(dto: RegisterBasicDto): Promise<{ message: string; email: string; debugCode?: string }> {
|
||||||
@@ -84,16 +84,10 @@ export class AuthService {
|
|||||||
password: passwordHash,
|
password: passwordHash,
|
||||||
});
|
});
|
||||||
|
|
||||||
const code = await this.issueEmailVerificationCode(user.id, user.email);
|
return {
|
||||||
const response: { message: string; email: string; debugCode?: string } = {
|
message: 'Registration successful. Account is pending SuperAdmin verification.',
|
||||||
message: 'Registration successful. Verify your email with the code sent.',
|
|
||||||
email: user.email,
|
email: user.email,
|
||||||
};
|
};
|
||||||
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
|
|
||||||
if (nodeEnv !== 'production') {
|
|
||||||
response.debugCode = code;
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async login(dto: LoginDto): Promise<AuthResult> {
|
async login(dto: LoginDto): Promise<AuthResult> {
|
||||||
@@ -105,7 +99,7 @@ export class AuthService {
|
|||||||
throw new ForbiddenException('Account is disabled');
|
throw new ForbiddenException('Account is disabled');
|
||||||
}
|
}
|
||||||
if (!user.isVerified) {
|
if (!user.isVerified) {
|
||||||
throw new ForbiddenException('Email not verified');
|
throw new ForbiddenException('Account is pending SuperAdmin verification');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMatch = await compareHash(dto.password, user.password);
|
const isMatch = await compareHash(dto.password, user.password);
|
||||||
@@ -123,71 +117,20 @@ export class AuthService {
|
|||||||
): Promise<{ message: string; debugCode?: string }> {
|
): Promise<{ message: string; debugCode?: string }> {
|
||||||
const normalizedEmail = dto.email.toLowerCase();
|
const normalizedEmail = dto.email.toLowerCase();
|
||||||
const user = await this.usersService.findByEmail(normalizedEmail);
|
const user = await this.usersService.findByEmail(normalizedEmail);
|
||||||
const message = 'If this email exists, a verification code was sent';
|
const message = 'Account verification is managed by SuperAdmin';
|
||||||
if (!user || user.isDisabled) {
|
if (!user || user.isDisabled) {
|
||||||
return { message };
|
return { message };
|
||||||
}
|
}
|
||||||
if (user.isVerified) {
|
if (user.isVerified) {
|
||||||
return { message: 'Email is already verified' };
|
return { message: 'Account is already verified' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const code = await this.issueEmailVerificationCode(user.id, user.email);
|
return { message: 'Account is pending SuperAdmin verification' };
|
||||||
const response: { message: string; debugCode?: string } = { message };
|
|
||||||
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
|
|
||||||
if (nodeEnv !== 'production') {
|
|
||||||
response.debugCode = code;
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyEmail(dto: VerifyEmailDto): Promise<AuthResult & { message: string }> {
|
async verifyEmail(_dto: VerifyEmailDto): Promise<{ message: string }> {
|
||||||
const normalizedEmail = dto.email.toLowerCase();
|
|
||||||
const user = await this.usersService.findByEmail(normalizedEmail);
|
|
||||||
if (!user || user.isDisabled) {
|
|
||||||
throw new UnauthorizedException('Invalid or expired verification code');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.isVerified) {
|
|
||||||
const tokens = await this.generateAndStoreTokenPair(user.id, user.username, user.role ?? 'user');
|
|
||||||
const safeUser = await this.usersService.findByIdOrFail(user.id);
|
|
||||||
return {
|
|
||||||
message: 'Email already verified',
|
|
||||||
...tokens,
|
|
||||||
user: safeUser.toObject() as unknown as Record<string, unknown>,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const codeRecord = await this.authRepository.findLatestActiveEmailVerificationCode(user.id);
|
|
||||||
if (!codeRecord) {
|
|
||||||
throw new UnauthorizedException('Invalid or expired verification code');
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxAttempts = this.configService.get<number>('emailVerification.maxAttempts', { infer: true });
|
|
||||||
if (codeRecord.attempts >= maxAttempts) {
|
|
||||||
await this.authRepository.markEmailVerificationCodeUsed(codeRecord.id);
|
|
||||||
throw new UnauthorizedException('Verification code attempts exceeded');
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMatch = await compareHash(dto.code, codeRecord.codeHash);
|
|
||||||
if (!isMatch) {
|
|
||||||
await this.authRepository.incrementEmailVerificationAttempts(codeRecord.id);
|
|
||||||
if (codeRecord.attempts + 1 >= maxAttempts) {
|
|
||||||
await this.authRepository.markEmailVerificationCodeUsed(codeRecord.id);
|
|
||||||
}
|
|
||||||
throw new UnauthorizedException('Invalid or expired verification code');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.usersService.markEmailVerified(user.id);
|
|
||||||
await this.authRepository.markEmailVerificationCodeUsed(codeRecord.id);
|
|
||||||
await this.authRepository.markAllEmailVerificationCodesUsedByUser(user.id);
|
|
||||||
|
|
||||||
const safeUser = await this.usersService.findByIdOrFail(user.id);
|
|
||||||
const tokens = await this.generateAndStoreTokenPair(safeUser.id, safeUser.username, safeUser.role ?? 'user');
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: 'Email verified successfully',
|
message: 'Account verification is managed by SuperAdmin',
|
||||||
...tokens,
|
|
||||||
user: safeUser.toObject() as unknown as Record<string, unknown>,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +152,11 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('Refresh token reuse detected');
|
throw new UnauthorizedException('Refresh token reuse detected');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMatch = await compareHash(dto.refreshToken, tokenRecord.tokenHash);
|
const isMatch = await compareStoredHighEntropyValue(
|
||||||
|
dto.refreshToken,
|
||||||
|
tokenRecord.tokenHash,
|
||||||
|
this.getRefreshTokenHashSecret(),
|
||||||
|
);
|
||||||
if (!isMatch) {
|
if (!isMatch) {
|
||||||
await this.authRepository.markCompromisedAndRevokeAll(decoded.sub);
|
await this.authRepository.markCompromisedAndRevokeAll(decoded.sub);
|
||||||
throw new UnauthorizedException('Refresh token reuse detected');
|
throw new UnauthorizedException('Refresh token reuse detected');
|
||||||
@@ -265,7 +212,7 @@ export class AuthService {
|
|||||||
email: googleUser.email,
|
email: googleUser.email,
|
||||||
password: passwordHash,
|
password: passwordHash,
|
||||||
avatar: googleUser.avatar ?? '',
|
avatar: googleUser.avatar ?? '',
|
||||||
isVerified: true,
|
isVerified: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +224,10 @@ export class AuthService {
|
|||||||
throw new ForbiddenException('Account is disabled');
|
throw new ForbiddenException('Account is disabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!user.isVerified) {
|
||||||
|
throw new ForbiddenException('Account is pending SuperAdmin verification');
|
||||||
|
}
|
||||||
|
|
||||||
const tokens = await this.generateAndStoreTokenPair(user.id, user.username, user.role ?? 'user');
|
const tokens = await this.generateAndStoreTokenPair(user.id, user.username, user.role ?? 'user');
|
||||||
const safeUser = await this.usersService.findByIdOrFail(user.id);
|
const safeUser = await this.usersService.findByIdOrFail(user.id);
|
||||||
return { ...tokens, user: safeUser.toObject() as unknown as Record<string, unknown> };
|
return { ...tokens, user: safeUser.toObject() as unknown as Record<string, unknown> };
|
||||||
@@ -345,43 +296,51 @@ export class AuthService {
|
|||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
superAdmin: { email: string };
|
superAdmin: { email: string };
|
||||||
}> {
|
}> {
|
||||||
const decoded = this.jwtService.verify<{ email: string; tokenType: string }>(dto.refreshToken, {
|
const decoded = this.jwtService.verify<{ email: string; tokenType: string; jti?: string }>(
|
||||||
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
|
dto.refreshToken,
|
||||||
});
|
{
|
||||||
|
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (decoded.tokenType !== 'superadmin_refresh' || !decoded.email) {
|
if (decoded.tokenType !== 'superadmin_refresh' || !decoded.email || !decoded.jti) {
|
||||||
throw new UnauthorizedException('Invalid superadmin refresh token');
|
throw new UnauthorizedException('Invalid superadmin refresh token');
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeTokens = await this.authRepository.findActiveSuperAdminTokens(decoded.email);
|
const tokenRecord = await this.authRepository.findActiveSuperAdminTokenByJti(
|
||||||
if (!activeTokens.length) {
|
decoded.email,
|
||||||
throw new UnauthorizedException('Invalid superadmin refresh token');
|
decoded.jti,
|
||||||
|
);
|
||||||
|
if (!tokenRecord) {
|
||||||
|
await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
|
||||||
|
throw new UnauthorizedException('Superadmin refresh token reuse detected');
|
||||||
}
|
}
|
||||||
|
|
||||||
let validTokenFound = false;
|
const isMatch = await compareStoredHighEntropyValue(
|
||||||
for (const token of activeTokens) {
|
dto.refreshToken,
|
||||||
const isMatch = await compareHash(dto.refreshToken, token.tokenHash);
|
tokenRecord.tokenHash,
|
||||||
if (isMatch) {
|
this.getRefreshTokenHashSecret(),
|
||||||
validTokenFound = true;
|
);
|
||||||
break;
|
if (!isMatch) {
|
||||||
}
|
await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
|
||||||
|
throw new UnauthorizedException('Superadmin refresh token reuse detected');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!validTokenFound) {
|
await this.authRepository.revokeSuperAdminTokenByJti(decoded.email, decoded.jti);
|
||||||
throw new UnauthorizedException('Invalid superadmin refresh token');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
|
|
||||||
const tokens = await this.generateAndStoreSuperAdminTokenPair(decoded.email);
|
const tokens = await this.generateAndStoreSuperAdminTokenPair(decoded.email);
|
||||||
return { ...tokens, superAdmin: { email: decoded.email } };
|
return { ...tokens, superAdmin: { email: decoded.email } };
|
||||||
}
|
}
|
||||||
|
|
||||||
async superAdminLogout(dto: RefreshTokenDto): Promise<void> {
|
async superAdminLogout(dto: RefreshTokenDto): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const decoded = this.jwtService.verify<{ email: string }>(dto.refreshToken, {
|
const decoded = this.jwtService.verify<{ email: string; jti?: string }>(dto.refreshToken, {
|
||||||
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
|
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
|
||||||
});
|
});
|
||||||
await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
|
if (decoded.jti) {
|
||||||
|
await this.authRepository.revokeSuperAdminTokenByJti(decoded.email, decoded.jti);
|
||||||
|
} else {
|
||||||
|
await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
|
||||||
|
}
|
||||||
await this.authRepository.removeExpiredAndRevokedSuperAdmin(decoded.email);
|
await this.authRepository.removeExpiredAndRevokedSuperAdmin(decoded.email);
|
||||||
} catch {
|
} catch {
|
||||||
throw new BadRequestException('Invalid superadmin refresh token');
|
throw new BadRequestException('Invalid superadmin refresh token');
|
||||||
@@ -403,6 +362,27 @@ export class AuthService {
|
|||||||
await this.authRepository.revokeUserTokenByJti(userId, jti);
|
await this.authRepository.revokeUserTokenByJti(userId, jti);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listSuperAdminSessions(
|
||||||
|
adminEmail: string,
|
||||||
|
): Promise<{ items: Array<{ id: string; jti: string; createdAt: Date; expiresAt: Date }> }> {
|
||||||
|
const sessions = await this.authRepository.listSuperAdminSessions(adminEmail);
|
||||||
|
return {
|
||||||
|
items: sessions.map((session) => ({
|
||||||
|
id: session.id,
|
||||||
|
jti: session.jti,
|
||||||
|
createdAt: (session as unknown as { createdAt: Date }).createdAt,
|
||||||
|
expiresAt: session.expiresAt,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeSuperAdminSession(adminEmail: string, sessionId: string): Promise<void> {
|
||||||
|
const revoked = await this.authRepository.revokeSuperAdminSessionById(adminEmail, sessionId);
|
||||||
|
if (!revoked) {
|
||||||
|
throw new BadRequestException('Superadmin session not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async forgotPassword(dto: ForgotPasswordDto): Promise<{ message: string; debugCode?: string }> {
|
async forgotPassword(dto: ForgotPasswordDto): Promise<{ message: string; debugCode?: string }> {
|
||||||
const normalizedEmail = dto.email.toLowerCase();
|
const normalizedEmail = dto.email.toLowerCase();
|
||||||
const user = await this.usersService.findByEmail(normalizedEmail);
|
const user = await this.usersService.findByEmail(normalizedEmail);
|
||||||
@@ -549,8 +529,7 @@ export class AuthService {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
const tokenHash = hashHighEntropyValue(refreshToken, this.getRefreshTokenHashSecret());
|
||||||
const tokenHash = await hashValue(refreshToken, saltRounds);
|
|
||||||
|
|
||||||
const refreshExpiresIn = this.configService.get<string>('jwt.refreshExpiresIn', {
|
const refreshExpiresIn = this.configService.get<string>('jwt.refreshExpiresIn', {
|
||||||
infer: true,
|
infer: true,
|
||||||
@@ -563,6 +542,8 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async generateAndStoreSuperAdminTokenPair(adminEmail: string): Promise<TokenPair> {
|
private async generateAndStoreSuperAdminTokenPair(adminEmail: string): Promise<TokenPair> {
|
||||||
|
const permissions = this.getSuperAdminPermissions();
|
||||||
|
const refreshJti = randomUUID();
|
||||||
const [accessToken, refreshToken] = await Promise.all([
|
const [accessToken, refreshToken] = await Promise.all([
|
||||||
this.jwtService.signAsync(
|
this.jwtService.signAsync(
|
||||||
{
|
{
|
||||||
@@ -571,6 +552,7 @@ export class AuthService {
|
|||||||
email: adminEmail.toLowerCase(),
|
email: adminEmail.toLowerCase(),
|
||||||
role: 'superadmin',
|
role: 'superadmin',
|
||||||
tokenType: 'superadmin_access',
|
tokenType: 'superadmin_access',
|
||||||
|
permissions,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
secret: this.configService.get<string>('superAdmin.accessSecret', { infer: true }),
|
secret: this.configService.get<string>('superAdmin.accessSecret', { infer: true }),
|
||||||
@@ -584,6 +566,7 @@ export class AuthService {
|
|||||||
email: adminEmail.toLowerCase(),
|
email: adminEmail.toLowerCase(),
|
||||||
role: 'superadmin',
|
role: 'superadmin',
|
||||||
tokenType: 'superadmin_refresh',
|
tokenType: 'superadmin_refresh',
|
||||||
|
jti: refreshJti,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
|
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
|
||||||
@@ -592,8 +575,7 @@ export class AuthService {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
const tokenHash = hashHighEntropyValue(refreshToken, this.getRefreshTokenHashSecret());
|
||||||
const tokenHash = await hashValue(refreshToken, saltRounds);
|
|
||||||
const refreshExpiresIn = this.configService.get<string>('superAdmin.refreshExpiresIn', {
|
const refreshExpiresIn = this.configService.get<string>('superAdmin.refreshExpiresIn', {
|
||||||
infer: true,
|
infer: true,
|
||||||
});
|
});
|
||||||
@@ -601,6 +583,7 @@ export class AuthService {
|
|||||||
|
|
||||||
await this.authRepository.createSuperAdminRefreshToken(
|
await this.authRepository.createSuperAdminRefreshToken(
|
||||||
adminEmail,
|
adminEmail,
|
||||||
|
refreshJti,
|
||||||
tokenHash,
|
tokenHash,
|
||||||
new Date(Date.now() + refreshExpiresInMs),
|
new Date(Date.now() + refreshExpiresInMs),
|
||||||
);
|
);
|
||||||
@@ -647,6 +630,18 @@ export class AuthService {
|
|||||||
return String(randomInt(100000, 1000000));
|
return String(randomInt(100000, 1000000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getRefreshTokenHashSecret(): string {
|
||||||
|
return (
|
||||||
|
this.configService.get<string>('security.refreshTokenHashSecret', { infer: true }) ??
|
||||||
|
this.configService.get<string>('jwt.refreshSecret', { infer: true }) ??
|
||||||
|
''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSuperAdminPermissions(): string[] {
|
||||||
|
return [...DEFAULT_SUPERADMIN_PERMISSIONS];
|
||||||
|
}
|
||||||
|
|
||||||
private async issueEmailVerificationCode(userId: string, email: string): Promise<string> {
|
private async issueEmailVerificationCode(userId: string, email: string): Promise<string> {
|
||||||
const code = this.generateResetCode();
|
const code = this.generateResetCode();
|
||||||
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Transform, Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { ExperienceLevel } from '../../../common/enums/experience-level.enum';
|
import { ExperienceLevel } from '../../../common/enums/experience-level.enum';
|
||||||
import { MusicRole } from '../../../common/enums/music-role.enum';
|
import { MusicRole } from '../../../common/enums/music-role.enum';
|
||||||
|
import { toBoolean } from '../../../common/utils/query-transform.util';
|
||||||
|
|
||||||
export class RegisterDto {
|
export class RegisterDto {
|
||||||
@ApiProperty({ example: 'john@example.com' })
|
@ApiProperty({ example: 'john@example.com' })
|
||||||
@@ -78,6 +79,7 @@ export class RegisterDto {
|
|||||||
|
|
||||||
@ApiProperty({ required: false, default: false })
|
@ApiProperty({ required: false, default: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@Transform(toBoolean)
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isPrivate?: boolean;
|
isPrivate?: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export class EmailVerificationCode {
|
|||||||
@Prop({ required: true, select: false })
|
@Prop({ required: true, select: false })
|
||||||
codeHash!: string;
|
codeHash!: string;
|
||||||
|
|
||||||
@Prop({ required: true, index: true })
|
@Prop({ required: true })
|
||||||
expiresAt!: Date;
|
expiresAt!: Date;
|
||||||
|
|
||||||
@Prop({ default: 0, min: 0 })
|
@Prop({ default: 0, min: 0 })
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export class PasswordResetCode {
|
|||||||
@Prop({ required: true, select: false })
|
@Prop({ required: true, select: false })
|
||||||
codeHash!: string;
|
codeHash!: string;
|
||||||
|
|
||||||
@Prop({ required: true, index: true })
|
@Prop({ required: true })
|
||||||
expiresAt!: Date;
|
expiresAt!: Date;
|
||||||
|
|
||||||
@Prop({ default: 0, min: 0 })
|
@Prop({ default: 0, min: 0 })
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export class SuperAdminRefreshToken {
|
|||||||
@Prop({ required: true, trim: true, lowercase: true, index: true })
|
@Prop({ required: true, trim: true, lowercase: true, index: true })
|
||||||
adminEmail!: string;
|
adminEmail!: string;
|
||||||
|
|
||||||
|
@Prop({ required: true, trim: true, index: true })
|
||||||
|
jti!: string;
|
||||||
|
|
||||||
@Prop({ required: true, select: false })
|
@Prop({ required: true, select: false })
|
||||||
tokenHash!: string;
|
tokenHash!: string;
|
||||||
|
|
||||||
@@ -20,4 +23,5 @@ export class SuperAdminRefreshToken {
|
|||||||
|
|
||||||
export const SuperAdminRefreshTokenSchema = SchemaFactory.createForClass(SuperAdminRefreshToken);
|
export const SuperAdminRefreshTokenSchema = SchemaFactory.createForClass(SuperAdminRefreshToken);
|
||||||
SuperAdminRefreshTokenSchema.index({ adminEmail: 1, revoked: 1 });
|
SuperAdminRefreshTokenSchema.index({ adminEmail: 1, revoked: 1 });
|
||||||
|
SuperAdminRefreshTokenSchema.index({ adminEmail: 1, jti: 1 }, { unique: true, sparse: true });
|
||||||
SuperAdminRefreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
SuperAdminRefreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { MongooseModule } from '@nestjs/mongoose';
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { ChatController } from './chat.controller';
|
import { ChatController } from './chat.controller';
|
||||||
import { ChatGateway } from './chat.gateway';
|
import { ChatGateway } from './chat.gateway';
|
||||||
@@ -15,6 +16,7 @@ import { Message, MessageSchema } from './schemas/message.schema';
|
|||||||
imports: [
|
imports: [
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
JwtModule.register({}),
|
JwtModule.register({}),
|
||||||
|
NotificationsModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
MongooseModule.forFeature([
|
MongooseModule.forFeature([
|
||||||
{ name: Conversation.name, schema: ConversationSchema },
|
{ name: Conversation.name, schema: ConversationSchema },
|
||||||
|
|||||||
@@ -51,11 +51,16 @@ export class ChatRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findConversationsForUser(userId: string, skip: number, limit: number): Promise<ConversationDocument[]> {
|
async findConversationsForUser(
|
||||||
|
userId: string,
|
||||||
|
skip: number,
|
||||||
|
limit: number,
|
||||||
|
sort: Record<string, 1 | -1> = { lastMessageAt: -1, updatedAt: -1 },
|
||||||
|
): Promise<ConversationDocument[]> {
|
||||||
return this.conversationModel
|
return this.conversationModel
|
||||||
.find({ participantIds: new Types.ObjectId(userId) })
|
.find({ participantIds: new Types.ObjectId(userId) })
|
||||||
.populate({ path: 'participantIds', select: 'name username stageName avatar isVerified isDisabled' })
|
.populate({ path: 'participantIds', select: 'name username stageName avatar isVerified isDisabled' })
|
||||||
.sort({ lastMessageAt: -1, updatedAt: -1 })
|
.sort(sort)
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.exec();
|
.exec();
|
||||||
@@ -83,11 +88,16 @@ export class ChatRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findMessages(conversationId: string, skip: number, limit: number): Promise<MessageDocument[]> {
|
async findMessages(
|
||||||
|
conversationId: string,
|
||||||
|
skip: number,
|
||||||
|
limit: number,
|
||||||
|
sort: Record<string, 1 | -1> = { createdAt: -1 },
|
||||||
|
): Promise<MessageDocument[]> {
|
||||||
return this.messageModel
|
return this.messageModel
|
||||||
.find({ conversationId: new Types.ObjectId(conversationId) })
|
.find({ conversationId: new Types.ObjectId(conversationId) })
|
||||||
.populate({ path: 'senderId', select: 'name username stageName avatar isVerified' })
|
.populate({ path: 'senderId', select: 'name username stageName avatar isVerified' })
|
||||||
.sort({ createdAt: -1 })
|
.sort(sort)
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.exec();
|
.exec();
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { Types } from 'mongoose';
|
import { Types } from 'mongoose';
|
||||||
import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util';
|
import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util';
|
||||||
|
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
||||||
|
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
||||||
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { UsersRepository } from '../users/users.repository';
|
import { UsersRepository } from '../users/users.repository';
|
||||||
import { CreateConversationDto } from './dto/create-conversation.dto';
|
import { CreateConversationDto } from './dto/create-conversation.dto';
|
||||||
import { MessageQueryDto } from './dto/message-query.dto';
|
import { MessageQueryDto } from './dto/message-query.dto';
|
||||||
@@ -9,9 +12,12 @@ import { ChatRepository } from './chat.repository';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ChatService {
|
export class ChatService {
|
||||||
|
private readonly logger = new Logger(ChatService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly chatRepository: ChatRepository,
|
private readonly chatRepository: ChatRepository,
|
||||||
private readonly usersRepository: UsersRepository,
|
private readonly usersRepository: UsersRepository,
|
||||||
|
private readonly notificationsService: NotificationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createConversation(currentUserId: string, dto: CreateConversationDto) {
|
async createConversation(currentUserId: string, dto: CreateConversationDto) {
|
||||||
@@ -61,9 +67,13 @@ export class ChatService {
|
|||||||
const limit = query.limit ?? 20;
|
const limit = query.limit ?? 20;
|
||||||
const cursorOffset = decodeOffsetCursor(query.cursor);
|
const cursorOffset = decodeOffsetCursor(query.cursor);
|
||||||
const skip = cursorOffset ?? (page - 1) * limit;
|
const skip = cursorOffset ?? (page - 1) * limit;
|
||||||
|
const direction = resolveMongoSortDirection(query.sortOrder);
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.chatRepository.findConversationsForUser(currentUserId, skip, limit),
|
this.chatRepository.findConversationsForUser(currentUserId, skip, limit, {
|
||||||
|
lastMessageAt: direction,
|
||||||
|
updatedAt: direction,
|
||||||
|
}),
|
||||||
this.chatRepository.countConversationsForUser(currentUserId),
|
this.chatRepository.countConversationsForUser(currentUserId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -78,14 +88,15 @@ export class ChatService {
|
|||||||
const nextOffset = skip + mappedItems.length;
|
const nextOffset = skip + mappedItems.length;
|
||||||
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
||||||
|
|
||||||
return {
|
return buildPaginatedResponse(mappedItems, {
|
||||||
items: mappedItems,
|
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
total,
|
total,
|
||||||
totalPages: Math.ceil(total / limit) || 1,
|
offset: skip,
|
||||||
|
currentCursor: query.cursor ?? null,
|
||||||
nextCursor,
|
nextCursor,
|
||||||
};
|
mode: 'cursor',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMessages(currentUserId: string, conversationId: string, query: MessageQueryDto) {
|
async getMessages(currentUserId: string, conversationId: string, query: MessageQueryDto) {
|
||||||
@@ -94,9 +105,10 @@ export class ChatService {
|
|||||||
const limit = query.limit ?? 20;
|
const limit = query.limit ?? 20;
|
||||||
const cursorOffset = decodeOffsetCursor(query.cursor);
|
const cursorOffset = decodeOffsetCursor(query.cursor);
|
||||||
const skip = cursorOffset ?? (page - 1) * limit;
|
const skip = cursorOffset ?? (page - 1) * limit;
|
||||||
|
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.chatRepository.findMessages(conversation.id, skip, limit),
|
this.chatRepository.findMessages(conversation.id, skip, limit, sort),
|
||||||
this.chatRepository.countMessages(conversation.id),
|
this.chatRepository.countMessages(conversation.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -104,14 +116,15 @@ export class ChatService {
|
|||||||
const nextOffset = skip + items.length;
|
const nextOffset = skip + items.length;
|
||||||
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
||||||
|
|
||||||
return {
|
return buildPaginatedResponse(items, {
|
||||||
items,
|
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
total,
|
total,
|
||||||
totalPages: Math.ceil(total / limit) || 1,
|
offset: skip,
|
||||||
|
currentCursor: query.cursor ?? null,
|
||||||
nextCursor,
|
nextCursor,
|
||||||
};
|
mode: 'cursor',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMessage(currentUserId: string, dto: SendMessageDto) {
|
async sendMessage(currentUserId: string, dto: SendMessageDto) {
|
||||||
@@ -144,6 +157,12 @@ export class ChatService {
|
|||||||
currentUserId,
|
currentUserId,
|
||||||
preview,
|
preview,
|
||||||
);
|
);
|
||||||
|
await this.dispatchMessageNotifications(
|
||||||
|
currentUserId,
|
||||||
|
conversation.participantIds.map((id) => id.toString()),
|
||||||
|
conversation.id,
|
||||||
|
preview,
|
||||||
|
);
|
||||||
|
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
@@ -247,4 +266,32 @@ export class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async dispatchMessageNotifications(
|
||||||
|
actorId: string,
|
||||||
|
participantIds: string[],
|
||||||
|
conversationId: string,
|
||||||
|
previewText: string,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const recipientId of participantIds) {
|
||||||
|
if (recipientId === actorId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.notificationsService.createMessageNotification(
|
||||||
|
actorId,
|
||||||
|
recipientId,
|
||||||
|
conversationId,
|
||||||
|
previewText.slice(0, 160),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Message notification failed for actor=${actorId} recipient=${recipientId}: ${
|
||||||
|
error instanceof Error ? error.message : 'unknown error'
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
import { IsArray, IsBoolean, IsOptional, IsString, Length } from 'class-validator';
|
import { IsArray, IsBoolean, IsOptional, IsString, Length } from 'class-validator';
|
||||||
|
import { toBoolean } from '../../../common/utils/query-transform.util';
|
||||||
|
|
||||||
export class CreateConversationDto {
|
export class CreateConversationDto {
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@@ -8,6 +10,7 @@ export class CreateConversationDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({ default: false })
|
@ApiPropertyOptional({ default: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@Transform(toBoolean)
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isGroup?: boolean;
|
isGroup?: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||||
import { HydratedDocument, Types } from 'mongoose';
|
import { HydratedDocument, Types } from 'mongoose';
|
||||||
|
import { resolveManagedFileUrl } from '../../../common/utils/public-url.util';
|
||||||
import { User } from '../../users/schemas/user.schema';
|
import { User } from '../../users/schemas/user.schema';
|
||||||
|
|
||||||
export type MessageDocument = HydratedDocument<Message>;
|
export type MessageDocument = HydratedDocument<Message>;
|
||||||
@@ -31,3 +32,11 @@ export class Message {
|
|||||||
export const MessageSchema = SchemaFactory.createForClass(Message);
|
export const MessageSchema = SchemaFactory.createForClass(Message);
|
||||||
MessageSchema.index({ conversationId: 1, createdAt: -1 });
|
MessageSchema.index({ conversationId: 1, createdAt: -1 });
|
||||||
MessageSchema.index({ conversationId: 1, isUnsent: 1, createdAt: -1 });
|
MessageSchema.index({ conversationId: 1, isUnsent: 1, createdAt: -1 });
|
||||||
|
|
||||||
|
const transformManagedMessageFiles = (_doc: unknown, ret: any) => {
|
||||||
|
ret.mediaUrl = resolveManagedFileUrl(ret.mediaUrl);
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
|
||||||
|
MessageSchema.set('toJSON', { transform: transformManagedMessageFiles });
|
||||||
|
MessageSchema.set('toObject', { transform: transformManagedMessageFiles });
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Controller, Delete, Get, Param, Post, Query, Body, UseGuards } from '@nestjs/common';
|
import { Controller, Delete, Get, Param, Post, Query, Body, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { SuperAdminPermissionsGuard } from '../../common/guards/superadmin-permissions.guard';
|
||||||
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
|
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
|
||||||
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||||
|
import { AdminCommentQueryDto } from './dto/admin-comment-query.dto';
|
||||||
import { CommentQueryDto } from './dto/comment-query.dto';
|
import { CommentQueryDto } from './dto/comment-query.dto';
|
||||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||||
import { CommentsService } from './comments.service';
|
import { CommentsService } from './comments.service';
|
||||||
|
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
|
||||||
|
|
||||||
@ApiTags('Comments')
|
@ApiTags('Comments')
|
||||||
@Controller('comments')
|
@Controller('comments')
|
||||||
@@ -34,6 +38,14 @@ export class CommentsController {
|
|||||||
return this.commentsService.findReplies(commentId, query);
|
return this.commentsService.findReplies(commentId, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||||
|
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE)
|
||||||
|
@Get('admin')
|
||||||
|
async adminList(@Query() query: AdminCommentQueryDto) {
|
||||||
|
return this.commentsService.findPlatformComments(query);
|
||||||
|
}
|
||||||
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Delete(':commentId')
|
@Delete(':commentId')
|
||||||
@@ -42,7 +54,8 @@ export class CommentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(SuperAdminJwtAuthGuard)
|
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||||
|
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE)
|
||||||
@Delete('admin/:commentId')
|
@Delete('admin/:commentId')
|
||||||
async adminRemove(@CurrentUser() user: JwtPayload, @Param('commentId') commentId: string) {
|
async adminRemove(@CurrentUser() user: JwtPayload, @Param('commentId') commentId: string) {
|
||||||
return this.commentsService.removeBySuperAdmin(user.email ?? user.sub, commentId);
|
return this.commentsService.removeBySuperAdmin(user.email ?? user.sub, commentId);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { MongooseModule } from '@nestjs/mongoose';
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { PostsModule } from '../posts/posts.module';
|
import { PostsModule } from '../posts/posts.module';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
import { Comment, CommentSchema } from './schemas/comment.schema';
|
import { Comment, CommentSchema } from './schemas/comment.schema';
|
||||||
import { CommentsController } from './comments.controller';
|
import { CommentsController } from './comments.controller';
|
||||||
import { CommentsService } from './comments.service';
|
import { CommentsService } from './comments.service';
|
||||||
@@ -12,6 +14,8 @@ import { CommentsRepository } from './comments.repository';
|
|||||||
AuditModule,
|
AuditModule,
|
||||||
MongooseModule.forFeature([{ name: Comment.name, schema: CommentSchema }]),
|
MongooseModule.forFeature([{ name: Comment.name, schema: CommentSchema }]),
|
||||||
PostsModule,
|
PostsModule,
|
||||||
|
NotificationsModule,
|
||||||
|
UsersModule,
|
||||||
],
|
],
|
||||||
controllers: [CommentsController],
|
controllers: [CommentsController],
|
||||||
providers: [CommentsService, CommentsRepository],
|
providers: [CommentsService, CommentsRepository],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectModel } from '@nestjs/mongoose';
|
import { InjectModel } from '@nestjs/mongoose';
|
||||||
import { ClientSession, FilterQuery, Model, Types } from 'mongoose';
|
import { ClientSession, FilterQuery, Model, Types } from 'mongoose';
|
||||||
|
import { ModerationStatus } from '../../common/enums/moderation-status.enum';
|
||||||
import { Comment, CommentDocument } from './schemas/comment.schema';
|
import { Comment, CommentDocument } from './schemas/comment.schema';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -8,6 +9,14 @@ export class CommentsRepository {
|
|||||||
constructor(@InjectModel(Comment.name) private readonly commentModel: Model<CommentDocument>) {}
|
constructor(@InjectModel(Comment.name) private readonly commentModel: Model<CommentDocument>) {}
|
||||||
|
|
||||||
private withActiveFilter<T extends FilterQuery<CommentDocument>>(filter: T): FilterQuery<CommentDocument> {
|
private withActiveFilter<T extends FilterQuery<CommentDocument>>(filter: T): FilterQuery<CommentDocument> {
|
||||||
|
return {
|
||||||
|
...filter,
|
||||||
|
isDeleted: { $ne: true },
|
||||||
|
moderationStatus: { $ne: ModerationStatus.HIDDEN },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private withAdminFilter<T extends FilterQuery<CommentDocument>>(filter: T): FilterQuery<CommentDocument> {
|
||||||
return {
|
return {
|
||||||
...filter,
|
...filter,
|
||||||
isDeleted: { $ne: true },
|
isDeleted: { $ne: true },
|
||||||
@@ -15,15 +24,24 @@ export class CommentsRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
payload: { postId: string; authorId: string; content: string; parentCommentId?: string },
|
payload: {
|
||||||
|
postId: string;
|
||||||
|
authorId: string;
|
||||||
|
content: string;
|
||||||
|
mentionUsernames?: string[];
|
||||||
|
parentCommentId?: string;
|
||||||
|
},
|
||||||
session?: ClientSession,
|
session?: ClientSession,
|
||||||
) {
|
) {
|
||||||
return this.commentModel.create({
|
const doc = new this.commentModel({
|
||||||
postId: new Types.ObjectId(payload.postId),
|
postId: new Types.ObjectId(payload.postId),
|
||||||
authorId: new Types.ObjectId(payload.authorId),
|
authorId: new Types.ObjectId(payload.authorId),
|
||||||
content: payload.content,
|
content: payload.content,
|
||||||
|
mentionUsernames: payload.mentionUsernames ?? [],
|
||||||
...(payload.parentCommentId ? { parentCommentId: new Types.ObjectId(payload.parentCommentId) } : {}),
|
...(payload.parentCommentId ? { parentCommentId: new Types.ObjectId(payload.parentCommentId) } : {}),
|
||||||
}, { session });
|
});
|
||||||
|
|
||||||
|
return session ? doc.save({ session }) : doc.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(commentId: string): Promise<CommentDocument | null> {
|
async findById(commentId: string): Promise<CommentDocument | null> {
|
||||||
@@ -55,11 +73,31 @@ export class CommentsRepository {
|
|||||||
return !!updated;
|
return !!updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findMany(filter: FilterQuery<CommentDocument>, skip: number, limit: number) {
|
async findMany(
|
||||||
|
filter: FilterQuery<CommentDocument>,
|
||||||
|
skip: number,
|
||||||
|
limit: number,
|
||||||
|
sort: Record<string, 1 | -1> = { createdAt: -1 },
|
||||||
|
) {
|
||||||
return this.commentModel
|
return this.commentModel
|
||||||
.find(this.withActiveFilter(filter))
|
.find(this.withActiveFilter(filter))
|
||||||
.populate({ path: 'authorId', select: 'name username avatar stageName isVerified' })
|
.populate({ path: 'authorId', select: 'name username avatar stageName isVerified' })
|
||||||
.sort({ createdAt: -1 })
|
.sort(sort)
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findManyAdmin(
|
||||||
|
filter: FilterQuery<CommentDocument>,
|
||||||
|
skip: number,
|
||||||
|
limit: number,
|
||||||
|
sort: Record<string, 1 | -1> = { createdAt: -1 },
|
||||||
|
) {
|
||||||
|
return this.commentModel
|
||||||
|
.find(this.withAdminFilter(filter))
|
||||||
|
.populate({ path: 'authorId', select: 'name username avatar stageName isVerified' })
|
||||||
|
.sort(sort)
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.exec();
|
.exec();
|
||||||
@@ -69,6 +107,31 @@ export class CommentsRepository {
|
|||||||
return this.commentModel.countDocuments(this.withActiveFilter(filter)).exec();
|
return this.commentModel.countDocuments(this.withActiveFilter(filter)).exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async countAdmin(filter: FilterQuery<CommentDocument>): Promise<number> {
|
||||||
|
return this.commentModel.countDocuments(this.withAdminFilter(filter)).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateModerationStatus(
|
||||||
|
commentId: string,
|
||||||
|
payload: Pick<Comment, 'moderationStatus' | 'moderationReason'>,
|
||||||
|
): Promise<CommentDocument | null> {
|
||||||
|
if (!Types.ObjectId.isValid(commentId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.commentModel
|
||||||
|
.findByIdAndUpdate(
|
||||||
|
commentId,
|
||||||
|
{
|
||||||
|
moderationStatus: payload.moderationStatus,
|
||||||
|
moderationReason: payload.moderationReason,
|
||||||
|
},
|
||||||
|
{ new: true },
|
||||||
|
)
|
||||||
|
.populate({ path: 'authorId', select: 'name username avatar stageName isVerified' })
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
async countByPost(postId: string): Promise<number> {
|
async countByPost(postId: string): Promise<number> {
|
||||||
return this.commentModel
|
return this.commentModel
|
||||||
.countDocuments({ postId: new Types.ObjectId(postId), isDeleted: { $ne: true } })
|
.countDocuments({ postId: new Types.ObjectId(postId), isDeleted: { $ne: true } })
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Types } from 'mongoose';
|
||||||
|
import { ModerationStatus } from '../../common/enums/moderation-status.enum';
|
||||||
|
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
||||||
|
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
||||||
|
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { PostsRepository } from '../posts/posts.repository';
|
import { PostsRepository } from '../posts/posts.repository';
|
||||||
|
import { UsersRepository } from '../users/users.repository';
|
||||||
|
import { AdminCommentQueryDto } from './dto/admin-comment-query.dto';
|
||||||
import { CommentQueryDto } from './dto/comment-query.dto';
|
import { CommentQueryDto } from './dto/comment-query.dto';
|
||||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||||
import { CommentsRepository } from './comments.repository';
|
import { CommentsRepository } from './comments.repository';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommentsService {
|
export class CommentsService {
|
||||||
|
private readonly logger = new Logger(CommentsService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly commentsRepository: CommentsRepository,
|
private readonly commentsRepository: CommentsRepository,
|
||||||
private readonly postsRepository: PostsRepository,
|
private readonly postsRepository: PostsRepository,
|
||||||
private readonly auditService: AuditService,
|
private readonly auditService: AuditService,
|
||||||
|
private readonly feedVersionService: FeedVersionService,
|
||||||
|
private readonly notificationsService: NotificationsService,
|
||||||
|
private readonly usersRepository: UsersRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(userId: string, dto: CreateCommentDto) {
|
async create(userId: string, dto: CreateCommentDto) {
|
||||||
@@ -19,20 +32,42 @@ export class CommentsService {
|
|||||||
throw new NotFoundException('Post not found');
|
throw new NotFoundException('Post not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let parentRecipientId = '';
|
||||||
if (dto.parentCommentId) {
|
if (dto.parentCommentId) {
|
||||||
const parent = await this.commentsRepository.findById(dto.parentCommentId);
|
const parent = await this.commentsRepository.findById(dto.parentCommentId);
|
||||||
if (!parent || parent.postId.toString() !== dto.postId) {
|
if (!parent || parent.postId.toString() !== dto.postId) {
|
||||||
throw new NotFoundException('Parent comment not found');
|
throw new NotFoundException('Parent comment not found');
|
||||||
}
|
}
|
||||||
|
parentRecipientId = parent.authorId.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const content = dto.content.trim();
|
||||||
|
const mentionResolution = await this.resolveMentionTargets(dto.mentionUsernames, content, userId);
|
||||||
const comment = await this.commentsRepository.create({
|
const comment = await this.commentsRepository.create({
|
||||||
postId: dto.postId,
|
postId: dto.postId,
|
||||||
authorId: userId,
|
authorId: userId,
|
||||||
content: dto.content,
|
content,
|
||||||
|
mentionUsernames: mentionResolution.mentionUsernames,
|
||||||
parentCommentId: dto.parentCommentId,
|
parentCommentId: dto.parentCommentId,
|
||||||
});
|
});
|
||||||
await this.syncCommentsCount(dto.postId);
|
await this.syncCommentsCount(dto.postId);
|
||||||
|
await this.feedVersionService.bumpGlobalVersion();
|
||||||
|
const postAuthorId = this.extractEntityId(post.authorId);
|
||||||
|
const previewText = content.slice(0, 160);
|
||||||
|
const commentNotificationRecipients = await this.dispatchCommentNotifications(
|
||||||
|
userId,
|
||||||
|
postAuthorId,
|
||||||
|
parentRecipientId,
|
||||||
|
dto.postId,
|
||||||
|
previewText,
|
||||||
|
);
|
||||||
|
await this.notifyMentionedUsers(
|
||||||
|
userId,
|
||||||
|
dto.postId,
|
||||||
|
mentionResolution.mentionedUsers,
|
||||||
|
previewText,
|
||||||
|
commentNotificationRecipients,
|
||||||
|
);
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +83,7 @@ export class CommentsService {
|
|||||||
|
|
||||||
await this.commentsRepository.deleteById(commentId, userId);
|
await this.commentsRepository.deleteById(commentId, userId);
|
||||||
await this.syncCommentsCount(comment.postId.toString());
|
await this.syncCommentsCount(comment.postId.toString());
|
||||||
|
await this.feedVersionService.bumpGlobalVersion();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +95,7 @@ export class CommentsService {
|
|||||||
|
|
||||||
await this.commentsRepository.deleteById(commentId, superAdminIdentifier);
|
await this.commentsRepository.deleteById(commentId, superAdminIdentifier);
|
||||||
await this.syncCommentsCount(comment.postId.toString());
|
await this.syncCommentsCount(comment.postId.toString());
|
||||||
|
await this.feedVersionService.bumpGlobalVersion();
|
||||||
await this.auditService.logSuperAdminAction(
|
await this.auditService.logSuperAdminAction(
|
||||||
superAdminIdentifier,
|
superAdminIdentifier,
|
||||||
'comment_delete',
|
'comment_delete',
|
||||||
@@ -70,45 +107,281 @@ export class CommentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async findByPost(postId: string, query: CommentQueryDto) {
|
async findByPost(postId: string, query: CommentQueryDto) {
|
||||||
|
if (!Types.ObjectId.isValid(postId)) {
|
||||||
|
throw new BadRequestException('Invalid post id');
|
||||||
|
}
|
||||||
|
|
||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
const limit = query.limit ?? 20;
|
const limit = query.limit ?? 20;
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
const postObjectId = new Types.ObjectId(postId);
|
||||||
|
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.commentsRepository.findMany({ postId, parentCommentId: { $exists: false } }, skip, limit),
|
this.commentsRepository.findMany(
|
||||||
this.commentsRepository.count({ postId, parentCommentId: { $exists: false } }),
|
{
|
||||||
|
postId: postObjectId,
|
||||||
|
$or: [{ parentCommentId: { $exists: false } }, { parentCommentId: null }],
|
||||||
|
},
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
sort,
|
||||||
|
),
|
||||||
|
this.commentsRepository.count({
|
||||||
|
postId: postObjectId,
|
||||||
|
$or: [{ parentCommentId: { $exists: false } }, { parentCommentId: null }],
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return buildPaginatedResponse(items, {
|
||||||
items,
|
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
total,
|
total,
|
||||||
totalPages: Math.ceil(total / limit) || 1,
|
offset: skip,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findReplies(parentCommentId: string, query: CommentQueryDto) {
|
async findReplies(parentCommentId: string, query: CommentQueryDto) {
|
||||||
|
if (!Types.ObjectId.isValid(parentCommentId)) {
|
||||||
|
throw new BadRequestException('Invalid parent comment id');
|
||||||
|
}
|
||||||
|
|
||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
const limit = query.limit ?? 20;
|
const limit = query.limit ?? 20;
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
const parentObjectId = new Types.ObjectId(parentCommentId);
|
||||||
|
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.commentsRepository.findMany({ parentCommentId }, skip, limit),
|
this.commentsRepository.findMany({ parentCommentId: parentObjectId }, skip, limit, sort),
|
||||||
this.commentsRepository.count({ parentCommentId }),
|
this.commentsRepository.count({ parentCommentId: parentObjectId }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return buildPaginatedResponse(items, {
|
||||||
items,
|
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
total,
|
total,
|
||||||
totalPages: Math.ceil(total / limit) || 1,
|
offset: skip,
|
||||||
};
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPlatformComments(query: AdminCommentQueryDto) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const limit = query.limit ?? 20;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
const filter: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (query.postId) {
|
||||||
|
filter.postId = new Types.ObjectId(query.postId);
|
||||||
|
}
|
||||||
|
if (query.authorId) {
|
||||||
|
filter.authorId = new Types.ObjectId(query.authorId);
|
||||||
|
}
|
||||||
|
if (query.q?.trim()) {
|
||||||
|
filter.content = { $regex: query.q.trim(), $options: 'i' };
|
||||||
|
}
|
||||||
|
if (query.moderationStatus) {
|
||||||
|
filter.moderationStatus = query.moderationStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.commentsRepository.findManyAdmin(filter, skip, limit, sort),
|
||||||
|
this.commentsRepository.countAdmin(filter),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return buildPaginatedResponse(items, {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
offset: skip,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateModerationStatusBySuperAdmin(
|
||||||
|
superAdminIdentifier: string,
|
||||||
|
commentId: string,
|
||||||
|
dto: { status: ModerationStatus; reason?: string },
|
||||||
|
) {
|
||||||
|
const comment = await this.commentsRepository.findById(commentId);
|
||||||
|
if (!comment) {
|
||||||
|
throw new NotFoundException('Comment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.commentsRepository.updateModerationStatus(commentId, {
|
||||||
|
moderationStatus: dto.status,
|
||||||
|
moderationReason: dto.reason?.trim() ?? '',
|
||||||
|
});
|
||||||
|
if (!updated) {
|
||||||
|
throw new NotFoundException('Comment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.feedVersionService.bumpGlobalVersion();
|
||||||
|
await this.auditService.logSuperAdminAction(
|
||||||
|
superAdminIdentifier,
|
||||||
|
'comment_moderation_status_update',
|
||||||
|
'comment',
|
||||||
|
commentId,
|
||||||
|
{
|
||||||
|
previousStatus: comment.moderationStatus ?? ModerationStatus.ACTIVE,
|
||||||
|
nextStatus: dto.status,
|
||||||
|
reason: dto.reason?.trim() ?? '',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async syncCommentsCount(postId: string): Promise<void> {
|
private async syncCommentsCount(postId: string): Promise<void> {
|
||||||
const totalComments = await this.commentsRepository.countByPost(postId);
|
const totalComments = await this.commentsRepository.countByPost(postId);
|
||||||
await this.postsRepository.setCommentsCount(postId, totalComments);
|
await this.postsRepository.setCommentsCount(postId, totalComments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async dispatchCommentNotifications(
|
||||||
|
actorId: string,
|
||||||
|
postAuthorId: string,
|
||||||
|
parentRecipientId: string,
|
||||||
|
postId: string,
|
||||||
|
previewText: string,
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
const recipients = new Set<string>();
|
||||||
|
if (postAuthorId && postAuthorId !== actorId) {
|
||||||
|
recipients.add(postAuthorId);
|
||||||
|
}
|
||||||
|
if (parentRecipientId && parentRecipientId !== actorId) {
|
||||||
|
recipients.add(parentRecipientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const recipientId of recipients) {
|
||||||
|
try {
|
||||||
|
await this.notificationsService.createCommentNotification(actorId, recipientId, postId, {
|
||||||
|
resourceType: 'post',
|
||||||
|
previewText,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Comment notification failed for actor=${actorId} recipient=${recipientId}: ${
|
||||||
|
error instanceof Error ? error.message : 'unknown error'
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return recipients;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeMentionUsernames(input: string[] = []): string[] {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
input
|
||||||
|
.map((username) => username?.trim().replace(/^@+/, '').toLowerCase())
|
||||||
|
.filter((username): username is string => !!username),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractMentions(content: string): string[] {
|
||||||
|
const matches = content.match(/@[\p{L}\p{N}_.]+/gu) ?? [];
|
||||||
|
return this.normalizeMentionUsernames(matches.map((item) => item.replace('@', '')));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveMentionTargets(
|
||||||
|
explicitMentionUsernames: string[] | undefined,
|
||||||
|
content: string,
|
||||||
|
authorId: string,
|
||||||
|
): Promise<{
|
||||||
|
mentionUsernames: string[];
|
||||||
|
mentionedUsers: Array<{ id: string; username: string }>;
|
||||||
|
}> {
|
||||||
|
const mergedMentionUsernames = Array.from(
|
||||||
|
new Set([
|
||||||
|
...this.extractMentions(content),
|
||||||
|
...this.normalizeMentionUsernames(explicitMentionUsernames ?? []),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mergedMentionUsernames.length > 30) {
|
||||||
|
throw new BadRequestException('You can mention up to 30 users only');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mergedMentionUsernames.length) {
|
||||||
|
return { mentionUsernames: [], mentionedUsers: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await this.usersRepository.findByUsernames(mergedMentionUsernames);
|
||||||
|
const userByUsername = new Map(
|
||||||
|
users.map((user) => [user.username.toLowerCase(), { id: user.id, username: user.username.toLowerCase() }]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mentionedUsers = mergedMentionUsernames
|
||||||
|
.map((username) => userByUsername.get(username))
|
||||||
|
.filter((user): user is { id: string; username: string } => !!user)
|
||||||
|
.filter((user) => user.id !== authorId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mentionUsernames: mentionedUsers.map((user) => user.username),
|
||||||
|
mentionedUsers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async notifyMentionedUsers(
|
||||||
|
actorId: string,
|
||||||
|
postId: string,
|
||||||
|
mentionedUsers: Array<{ id: string; username: string }>,
|
||||||
|
previewText: string,
|
||||||
|
excludedRecipientIds: Set<string>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!mentionedUsers.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mentionedUser of mentionedUsers) {
|
||||||
|
if (excludedRecipientIds.has(mentionedUser.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.notificationsService.createMentionNotification(actorId, mentionedUser.id, postId, {
|
||||||
|
resourceType: 'comment',
|
||||||
|
previewText,
|
||||||
|
deepLink: `/posts/${postId}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Comment mention notification failed for actor=${actorId} recipient=${mentionedUser.id}: ${
|
||||||
|
error instanceof Error ? error.message : 'unknown error'
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractEntityId(value: unknown): string {
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Types.ObjectId) {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const candidate = value as { _id?: unknown; id?: unknown };
|
||||||
|
if (candidate._id instanceof Types.ObjectId) {
|
||||||
|
return candidate._id.toString();
|
||||||
|
}
|
||||||
|
if (typeof candidate._id === 'string') {
|
||||||
|
return candidate._id;
|
||||||
|
}
|
||||||
|
if (typeof candidate.id === 'string') {
|
||||||
|
return candidate.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
src/modules/comments/dto/admin-comment-query.dto.ts
Normal file
26
src/modules/comments/dto/admin-comment-query.dto.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsEnum, IsMongoId, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { ModerationStatus } from '../../../common/enums/moderation-status.enum';
|
||||||
|
import { CommentQueryDto } from './comment-query.dto';
|
||||||
|
|
||||||
|
export class AdminCommentQueryDto extends CommentQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Search comment content' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
q?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Optional post filter' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsMongoId()
|
||||||
|
postId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Optional author filter' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsMongoId()
|
||||||
|
authorId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ModerationStatus, description: 'Optional moderation status filter' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ModerationStatus)
|
||||||
|
moderationStatus?: ModerationStatus;
|
||||||
|
}
|
||||||
@@ -1,3 +1,10 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||||
|
|
||||||
export class CommentQueryDto extends PaginationQueryDto {}
|
export class CommentQueryDto extends PaginationQueryDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Use asc to display oldest comments first, or desc for newest first',
|
||||||
|
default: 'desc',
|
||||||
|
})
|
||||||
|
declare sortOrder: PaginationQueryDto['sortOrder'];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsMongoId, IsOptional, IsString, Length } from 'class-validator';
|
import { Transform } from 'class-transformer';
|
||||||
|
import { ArrayMaxSize, IsArray, IsMongoId, IsOptional, IsString, Length } from 'class-validator';
|
||||||
|
import { toStringArray } from '../../../common/utils/array-transform.util';
|
||||||
|
|
||||||
export class CreateCommentDto {
|
export class CreateCommentDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -15,4 +17,13 @@ export class CreateCommentDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsMongoId()
|
@IsMongoId()
|
||||||
parentCommentId?: string;
|
parentCommentId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [String], description: 'Mention usernames like rami_sabry (max 30)' })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toStringArray)
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(30)
|
||||||
|
@IsString({ each: true })
|
||||||
|
@Length(1, 30, { each: true })
|
||||||
|
mentionUsernames?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||||
import { HydratedDocument, Types } from 'mongoose';
|
import { HydratedDocument, Types } from 'mongoose';
|
||||||
|
import { ModerationStatus } from '../../../common/enums/moderation-status.enum';
|
||||||
import { Post } from '../../posts/schemas/post.schema';
|
import { Post } from '../../posts/schemas/post.schema';
|
||||||
import { User } from '../../users/schemas/user.schema';
|
import { User } from '../../users/schemas/user.schema';
|
||||||
|
|
||||||
@@ -19,6 +20,20 @@ export class Comment {
|
|||||||
@Prop({ required: true, maxlength: 1000 })
|
@Prop({ required: true, maxlength: 1000 })
|
||||||
content!: string;
|
content!: string;
|
||||||
|
|
||||||
|
@Prop({ type: [String], default: [] })
|
||||||
|
mentionUsernames!: string[];
|
||||||
|
|
||||||
|
@Prop({
|
||||||
|
type: String,
|
||||||
|
enum: Object.values(ModerationStatus),
|
||||||
|
default: ModerationStatus.ACTIVE,
|
||||||
|
index: true,
|
||||||
|
})
|
||||||
|
moderationStatus!: ModerationStatus;
|
||||||
|
|
||||||
|
@Prop({ default: '', maxlength: 300 })
|
||||||
|
moderationReason!: string;
|
||||||
|
|
||||||
@Prop({ default: false, index: true })
|
@Prop({ default: false, index: true })
|
||||||
isDeleted!: boolean;
|
isDeleted!: boolean;
|
||||||
|
|
||||||
@@ -32,3 +47,4 @@ export class Comment {
|
|||||||
export const CommentSchema = SchemaFactory.createForClass(Comment);
|
export const CommentSchema = SchemaFactory.createForClass(Comment);
|
||||||
CommentSchema.index({ postId: 1, createdAt: -1 });
|
CommentSchema.index({ postId: 1, createdAt: -1 });
|
||||||
CommentSchema.index({ postId: 1, parentCommentId: 1, isDeleted: 1, createdAt: -1 });
|
CommentSchema.index({ postId: 1, parentCommentId: 1, isDeleted: 1, createdAt: -1 });
|
||||||
|
CommentSchema.index({ moderationStatus: 1, createdAt: -1 });
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||||
import { IsBoolean, IsEnum, IsNumber, IsOptional, Max, Min } from 'class-validator';
|
import { IsBoolean, IsEnum, IsNumber, IsOptional, Max, Min } from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Transform, Type } from 'class-transformer';
|
||||||
import { PostType } from '../../../common/enums/post-type.enum';
|
import { PostType } from '../../../common/enums/post-type.enum';
|
||||||
|
import { toBoolean } from '../../../common/utils/query-transform.util';
|
||||||
|
|
||||||
export class FeedQueryDto extends PaginationQueryDto {
|
export class FeedQueryDto extends PaginationQueryDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -9,7 +10,7 @@ export class FeedQueryDto extends PaginationQueryDto {
|
|||||||
preferredPostType?: PostType;
|
preferredPostType?: PostType;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Boolean)
|
@Transform(toBoolean)
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
followingOnly?: boolean;
|
followingOnly?: boolean;
|
||||||
|
|
||||||
@@ -19,4 +20,16 @@ export class FeedQueryDto extends PaginationQueryDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
@Max(500)
|
@Max(500)
|
||||||
radiusKm?: number;
|
radiusKm?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toBoolean)
|
||||||
|
@IsBoolean()
|
||||||
|
includeSuggestions?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(2)
|
||||||
|
@Max(10)
|
||||||
|
suggestionInterval?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export class FeedController {
|
|||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Get('trending')
|
@Get('trending')
|
||||||
async trending(@Query() query: FeedQueryDto) {
|
async trending(@CurrentUser() user: JwtPayload, @Query() query: FeedQueryDto) {
|
||||||
return this.feedService.getTrending(query);
|
return this.feedService.getTrending(user.sub, query);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { MongooseModule } from '@nestjs/mongoose';
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
|
import { FollowsModule } from '../follows/follows.module';
|
||||||
|
import { LikesModule } from '../likes/likes.module';
|
||||||
|
import { MarketplaceModule } from '../marketplace/marketplace.module';
|
||||||
import { Follow, FollowSchema } from '../follows/schemas/follow.schema';
|
import { Follow, FollowSchema } from '../follows/schemas/follow.schema';
|
||||||
import { Post, PostSchema } from '../posts/schemas/post.schema';
|
import { Post, PostSchema } from '../posts/schemas/post.schema';
|
||||||
|
import { SavesModule } from '../saves/saves.module';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { FeedController } from './feed.controller';
|
import { FeedController } from './feed.controller';
|
||||||
import { FeedService } from './feed.service';
|
import { FeedService } from './feed.service';
|
||||||
@@ -10,6 +14,10 @@ import { FeedRepository } from './feed.repository';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
UsersModule,
|
UsersModule,
|
||||||
|
LikesModule,
|
||||||
|
SavesModule,
|
||||||
|
FollowsModule,
|
||||||
|
MarketplaceModule,
|
||||||
MongooseModule.forFeature([
|
MongooseModule.forFeature([
|
||||||
{ name: Post.name, schema: PostSchema },
|
{ name: Post.name, schema: PostSchema },
|
||||||
{ name: Follow.name, schema: FollowSchema },
|
{ name: Follow.name, schema: FollowSchema },
|
||||||
|
|||||||
@@ -42,11 +42,23 @@ export class FeedRepository {
|
|||||||
.exec();
|
.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
async findTrendingPublicPosts(skip: number, limit: number): Promise<PostDocument[]> {
|
async findTrendingPublicPosts(
|
||||||
|
filter: FilterQuery<PostDocument>,
|
||||||
|
skip: number,
|
||||||
|
limit: number,
|
||||||
|
): Promise<PostDocument[]> {
|
||||||
return this.postModel
|
return this.postModel
|
||||||
.find({ visibility: 'public', isDeleted: { $ne: true } })
|
.find({ ...filter, isDeleted: { $ne: true } })
|
||||||
.populate({ path: 'authorId', select: 'name username stageName avatar isVerified isDisabled' })
|
.populate({ path: 'authorId', select: 'name username stageName avatar isVerified isDisabled' })
|
||||||
.sort({ likesCount: -1, commentsCount: -1, savesCount: -1, createdAt: -1 })
|
.sort({
|
||||||
|
shareCount: -1,
|
||||||
|
likesCount: -1,
|
||||||
|
commentsCount: -1,
|
||||||
|
savesCount: -1,
|
||||||
|
viewCount: -1,
|
||||||
|
playCount: -1,
|
||||||
|
createdAt: -1,
|
||||||
|
})
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.exec();
|
.exec();
|
||||||
|
|||||||
@@ -1,21 +1,97 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Types } from 'mongoose';
|
import { Types } from 'mongoose';
|
||||||
import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util';
|
|
||||||
import { PostType } from '../../common/enums/post-type.enum';
|
import { PostType } from '../../common/enums/post-type.enum';
|
||||||
import { PostVisibility } from '../../common/enums/post-visibility.enum';
|
import { PostVisibility } from '../../common/enums/post-visibility.enum';
|
||||||
import { UsersRepository } from '../users/users.repository';
|
import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util';
|
||||||
|
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
||||||
|
import { AppCacheService } from '../../infrastructure/cache/app-cache.service';
|
||||||
|
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
|
||||||
|
import { FollowsService } from '../follows/follows.service';
|
||||||
|
import { LikesRepository } from '../likes/likes.repository';
|
||||||
|
import { MarketplaceService } from '../marketplace/marketplace.service';
|
||||||
|
import { SavesRepository } from '../saves/saves.repository';
|
||||||
import { UserDocument } from '../users/schemas/user.schema';
|
import { UserDocument } from '../users/schemas/user.schema';
|
||||||
|
import { UsersRepository } from '../users/users.repository';
|
||||||
import { FeedQueryDto } from './dto/feed-query.dto';
|
import { FeedQueryDto } from './dto/feed-query.dto';
|
||||||
import { FeedRepository } from './feed.repository';
|
import { FeedRepository } from './feed.repository';
|
||||||
|
|
||||||
|
type FeedPostItem = Record<string, unknown> & {
|
||||||
|
feedItemType: 'post';
|
||||||
|
feedScore?: number;
|
||||||
|
likedByMe: boolean;
|
||||||
|
savedByMe: boolean;
|
||||||
|
followingAuthor: boolean;
|
||||||
|
isOwnPost: boolean;
|
||||||
|
canComment: boolean;
|
||||||
|
canMessage: boolean;
|
||||||
|
engagement: {
|
||||||
|
likesCount: number;
|
||||||
|
commentsCount: number;
|
||||||
|
savesCount: number;
|
||||||
|
shareCount: number;
|
||||||
|
viewCount: number;
|
||||||
|
playCount: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type FeedCardItem =
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
feedItemType: 'suggested_users';
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
items: Array<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
feedItemType: 'featured_marketplace';
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
listings: Array<Record<string, unknown>>;
|
||||||
|
musicalInstruments: Array<Record<string, unknown>>;
|
||||||
|
instruments: Array<Record<string, unknown>>;
|
||||||
|
repairShops: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FeedService {
|
export class FeedService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly feedRepository: FeedRepository,
|
private readonly feedRepository: FeedRepository,
|
||||||
private readonly usersRepository: UsersRepository,
|
private readonly usersRepository: UsersRepository,
|
||||||
|
private readonly cacheService: AppCacheService,
|
||||||
|
private readonly feedVersionService: FeedVersionService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly likesRepository: LikesRepository,
|
||||||
|
private readonly savesRepository: SavesRepository,
|
||||||
|
private readonly followsService: FollowsService,
|
||||||
|
private readonly marketplaceService: MarketplaceService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getMyFeed(currentUserId: string, query: FeedQueryDto) {
|
async getMyFeed(currentUserId: string, query: FeedQueryDto) {
|
||||||
|
const cacheEnabled =
|
||||||
|
this.configService.get<boolean>('feedCache.enabled', { infer: true }) ?? true;
|
||||||
|
const globalVersion = cacheEnabled ? await this.feedVersionService.getGlobalVersion() : 0;
|
||||||
|
const includeSuggestions = this.shouldIncludeSuggestions(query);
|
||||||
|
const cacheKey = this.buildCacheKey('me', {
|
||||||
|
currentUserId,
|
||||||
|
globalVersion,
|
||||||
|
page: query.page ?? 1,
|
||||||
|
limit: query.limit ?? 20,
|
||||||
|
cursor: query.cursor ?? '',
|
||||||
|
followingOnly: query.followingOnly ?? false,
|
||||||
|
radiusKm: query.radiusKm ?? 30,
|
||||||
|
preferredPostType: query.preferredPostType ?? '',
|
||||||
|
includeSuggestions,
|
||||||
|
suggestionInterval: query.suggestionInterval ?? 4,
|
||||||
|
});
|
||||||
|
if (cacheEnabled) {
|
||||||
|
const cached = await this.cacheService.get<Record<string, unknown>>(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const currentUser = await this.usersRepository.findById(currentUserId);
|
const currentUser = await this.usersRepository.findById(currentUserId);
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
throw new NotFoundException('Current user not found');
|
throw new NotFoundException('Current user not found');
|
||||||
@@ -26,20 +102,10 @@ export class FeedService {
|
|||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
const followingOnly = query.followingOnly ?? false;
|
const followingOnly = query.followingOnly ?? false;
|
||||||
const radiusKm = query.radiusKm ?? 30;
|
const radiusKm = query.radiusKm ?? 30;
|
||||||
|
const skip = cursorOffset ?? (page - 1) * limit;
|
||||||
|
|
||||||
const followingIds = await this.feedRepository.findFollowingIds(currentUserId);
|
const followingIds = await this.feedRepository.findFollowingIds(currentUserId);
|
||||||
const visibleAuthorIds = followingOnly ? [currentUserId, ...followingIds] : null;
|
const filter = this.buildVisiblePostsFilter(currentUserId, followingIds, followingOnly);
|
||||||
|
|
||||||
const filter: Record<string, unknown> = {
|
|
||||||
$or: [
|
|
||||||
{ visibility: PostVisibility.PUBLIC },
|
|
||||||
{ authorId: new Types.ObjectId(currentUserId) },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (visibleAuthorIds) {
|
|
||||||
filter.authorId = { $in: visibleAuthorIds.map((id) => new Types.ObjectId(id)) };
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300));
|
const candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300));
|
||||||
|
|
||||||
@@ -64,48 +130,268 @@ export class FeedService {
|
|||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
b.score - a.score ||
|
b.score - a.score ||
|
||||||
new Date((b.post as any).createdAt ?? 0).getTime() - new Date((a.post as any).createdAt ?? 0).getTime(),
|
new Date((b.post as any).createdAt ?? 0).getTime() -
|
||||||
|
new Date((a.post as any).createdAt ?? 0).getTime(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const total = scored.length;
|
const total = scored.length;
|
||||||
const skip = cursorOffset ?? (page - 1) * limit;
|
const pagedPosts = scored.slice(skip, skip + limit).map((entry) => ({
|
||||||
const items = scored.slice(skip, skip + limit).map((entry) => ({
|
...(entry.post.toObject() as unknown as Record<string, unknown>),
|
||||||
...entry.post.toObject(),
|
|
||||||
feedScore: Number(entry.score.toFixed(3)),
|
feedScore: Number(entry.score.toFixed(3)),
|
||||||
}));
|
}));
|
||||||
const nextOffset = skip + items.length;
|
const decoratedPosts = await this.decoratePostsForViewer(currentUserId, pagedPosts, followingIds);
|
||||||
|
const items = includeSuggestions
|
||||||
|
? await this.mixHomeFeedItems(currentUserId, decoratedPosts, query.suggestionInterval ?? 4)
|
||||||
|
: decoratedPosts;
|
||||||
|
const nextOffset = skip + pagedPosts.length;
|
||||||
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
||||||
|
|
||||||
return {
|
const result = buildPaginatedResponse(items, {
|
||||||
items,
|
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
total,
|
total,
|
||||||
totalPages: Math.ceil(total / limit) || 1,
|
offset: skip,
|
||||||
|
currentCursor: query.cursor ?? null,
|
||||||
nextCursor,
|
nextCursor,
|
||||||
};
|
mode: 'cursor',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cacheEnabled) {
|
||||||
|
await this.cacheService.set(
|
||||||
|
cacheKey,
|
||||||
|
result,
|
||||||
|
this.configService.get<number>('feedCache.userFeedTtlSeconds', { infer: true }) ?? 15,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTrending(query: FeedQueryDto) {
|
async getTrending(currentUserId: string, query: FeedQueryDto) {
|
||||||
|
const cacheEnabled =
|
||||||
|
this.configService.get<boolean>('feedCache.enabled', { infer: true }) ?? true;
|
||||||
|
const globalVersion = cacheEnabled ? await this.feedVersionService.getGlobalVersion() : 0;
|
||||||
|
const cacheKey = this.buildCacheKey('trending', {
|
||||||
|
currentUserId,
|
||||||
|
globalVersion,
|
||||||
|
page: query.page ?? 1,
|
||||||
|
limit: query.limit ?? 20,
|
||||||
|
cursor: query.cursor ?? '',
|
||||||
|
preferredPostType: query.preferredPostType ?? '',
|
||||||
|
});
|
||||||
|
if (cacheEnabled) {
|
||||||
|
const cached = await this.cacheService.get<Record<string, unknown>>(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const limit = query.limit ?? 20;
|
const limit = query.limit ?? 20;
|
||||||
const cursorOffset = decodeOffsetCursor(query.cursor);
|
const cursorOffset = decodeOffsetCursor(query.cursor);
|
||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
const skip = cursorOffset ?? (page - 1) * limit;
|
const skip = cursorOffset ?? (page - 1) * limit;
|
||||||
|
const followingIds = await this.feedRepository.findFollowingIds(currentUserId);
|
||||||
|
const trendingFilter: Record<string, unknown> = { visibility: PostVisibility.PUBLIC };
|
||||||
|
if (query.preferredPostType) {
|
||||||
|
trendingFilter.postType = query.preferredPostType;
|
||||||
|
}
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [rows, total] = await Promise.all([
|
||||||
this.feedRepository.findTrendingPublicPosts(skip, limit),
|
this.feedRepository.findTrendingPublicPosts(trendingFilter, skip, limit),
|
||||||
this.feedRepository.count({ visibility: PostVisibility.PUBLIC }),
|
this.feedRepository.count(trendingFilter),
|
||||||
]);
|
]);
|
||||||
const nextOffset = skip + items.length;
|
const decoratedPosts = await this.decoratePostsForViewer(
|
||||||
|
currentUserId,
|
||||||
|
rows.map((item) => item.toObject() as unknown as Record<string, unknown>),
|
||||||
|
followingIds,
|
||||||
|
);
|
||||||
|
const nextOffset = skip + rows.length;
|
||||||
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
||||||
|
|
||||||
return {
|
const result = buildPaginatedResponse(decoratedPosts, {
|
||||||
items,
|
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
total,
|
total,
|
||||||
totalPages: Math.ceil(total / limit) || 1,
|
offset: skip,
|
||||||
|
currentCursor: query.cursor ?? null,
|
||||||
nextCursor,
|
nextCursor,
|
||||||
|
mode: 'cursor',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cacheEnabled) {
|
||||||
|
await this.cacheService.set(
|
||||||
|
cacheKey,
|
||||||
|
result,
|
||||||
|
this.configService.get<number>('feedCache.trendingTtlSeconds', { infer: true }) ?? 30,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async decoratePostsForViewer(
|
||||||
|
currentUserId: string,
|
||||||
|
items: Array<Record<string, unknown>>,
|
||||||
|
followingIds: string[],
|
||||||
|
): Promise<FeedPostItem[]> {
|
||||||
|
const postIds = items
|
||||||
|
.map((item) => this.extractEntityId(item._id ?? item.id))
|
||||||
|
.filter(Boolean);
|
||||||
|
const followingSet = new Set(followingIds);
|
||||||
|
const [likedPostIds, savedPostIds] = await Promise.all([
|
||||||
|
this.likesRepository.findLikedPostIds(currentUserId, postIds),
|
||||||
|
this.savesRepository.findSavedPostIds(currentUserId, postIds),
|
||||||
|
]);
|
||||||
|
const likedSet = new Set(likedPostIds);
|
||||||
|
const savedSet = new Set(savedPostIds);
|
||||||
|
|
||||||
|
return items.map((item) => {
|
||||||
|
const postId = this.extractEntityId(item._id ?? item.id);
|
||||||
|
const authorId = this.extractEntityId(item.authorId);
|
||||||
|
const likesCount = Number(item.likesCount ?? 0);
|
||||||
|
const commentsCount = Number(item.commentsCount ?? 0);
|
||||||
|
const savesCount = Number(item.savesCount ?? 0);
|
||||||
|
const shareCount = Number(item.shareCount ?? 0);
|
||||||
|
const viewCount = Number(item.viewCount ?? 0);
|
||||||
|
const playCount = Number(item.playCount ?? 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
id: postId,
|
||||||
|
feedItemType: 'post',
|
||||||
|
likedByMe: likedSet.has(postId),
|
||||||
|
savedByMe: savedSet.has(postId),
|
||||||
|
followingAuthor: !!authorId && followingSet.has(authorId),
|
||||||
|
isOwnPost: authorId === currentUserId,
|
||||||
|
canComment: true,
|
||||||
|
canMessage: !!authorId && authorId !== currentUserId,
|
||||||
|
engagement: {
|
||||||
|
likesCount,
|
||||||
|
commentsCount,
|
||||||
|
savesCount,
|
||||||
|
shareCount,
|
||||||
|
viewCount,
|
||||||
|
playCount,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mixHomeFeedItems(
|
||||||
|
currentUserId: string,
|
||||||
|
posts: FeedPostItem[],
|
||||||
|
suggestionInterval: number,
|
||||||
|
): Promise<Array<FeedPostItem | FeedCardItem>> {
|
||||||
|
const cards = await this.buildHomeCards(currentUserId);
|
||||||
|
if (!cards.length) {
|
||||||
|
return posts;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Array<FeedPostItem | FeedCardItem> = [];
|
||||||
|
let cardIndex = 0;
|
||||||
|
|
||||||
|
for (let index = 0; index < posts.length; index += 1) {
|
||||||
|
result.push(posts[index]);
|
||||||
|
if ((index + 1) % suggestionInterval === 0 && cardIndex < cards.length) {
|
||||||
|
result.push(cards[cardIndex]);
|
||||||
|
cardIndex += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (cardIndex < cards.length) {
|
||||||
|
result.push(cards[cardIndex]);
|
||||||
|
cardIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildHomeCards(currentUserId: string): Promise<FeedCardItem[]> {
|
||||||
|
const [suggestions, listings, instruments, repairShops] = await Promise.all([
|
||||||
|
this.followsService.getSuggestions(currentUserId, {
|
||||||
|
page: 1,
|
||||||
|
limit: 5,
|
||||||
|
}),
|
||||||
|
this.marketplaceService.getPublicListings({
|
||||||
|
page: 1,
|
||||||
|
limit: 3,
|
||||||
|
isActive: true,
|
||||||
|
} as any),
|
||||||
|
this.marketplaceService.getPublicInstruments({
|
||||||
|
page: 1,
|
||||||
|
limit: 3,
|
||||||
|
isActive: true,
|
||||||
|
} as any),
|
||||||
|
this.marketplaceService.getPublicRepairShops({
|
||||||
|
page: 1,
|
||||||
|
limit: 2,
|
||||||
|
isActive: true,
|
||||||
|
} as any),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cards: FeedCardItem[] = [];
|
||||||
|
if (Array.isArray(suggestions.items) && suggestions.items.length > 0) {
|
||||||
|
cards.push({
|
||||||
|
id: `suggested-users:${currentUserId}`,
|
||||||
|
feedItemType: 'suggested_users',
|
||||||
|
title: 'Suggested creators',
|
||||||
|
subtitle: 'People you may want to follow',
|
||||||
|
items: suggestions.items.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
following: false,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(listings.items?.length ?? 0) > 0 ||
|
||||||
|
(instruments.items?.length ?? 0) > 0 ||
|
||||||
|
(repairShops.items?.length ?? 0) > 0
|
||||||
|
) {
|
||||||
|
cards.push({
|
||||||
|
id: `featured-marketplace:${currentUserId}`,
|
||||||
|
feedItemType: 'featured_marketplace',
|
||||||
|
title: 'Explore marketplace',
|
||||||
|
subtitle: 'Featured listings, musical instruments, and repair shops',
|
||||||
|
listings: (listings.items ?? []) as unknown as Array<Record<string, unknown>>,
|
||||||
|
musicalInstruments: (instruments.items ?? []) as unknown as Array<Record<string, unknown>>,
|
||||||
|
instruments: (instruments.items ?? []) as unknown as Array<Record<string, unknown>>,
|
||||||
|
repairShops: (repairShops.items ?? []) as unknown as Array<Record<string, unknown>>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildVisiblePostsFilter(
|
||||||
|
currentUserId: string,
|
||||||
|
followingIds: string[],
|
||||||
|
followingOnly: boolean,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const currentUserObjectId = new Types.ObjectId(currentUserId);
|
||||||
|
const followingObjectIds = followingIds.map((id) => new Types.ObjectId(id));
|
||||||
|
|
||||||
|
if (followingOnly) {
|
||||||
|
return {
|
||||||
|
$or: [
|
||||||
|
{ authorId: currentUserObjectId },
|
||||||
|
{
|
||||||
|
authorId: { $in: followingObjectIds },
|
||||||
|
visibility: { $in: [PostVisibility.PUBLIC, PostVisibility.FOLLOWERS] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
$or: [
|
||||||
|
{ visibility: PostVisibility.PUBLIC },
|
||||||
|
{ authorId: currentUserObjectId },
|
||||||
|
{
|
||||||
|
authorId: { $in: followingObjectIds },
|
||||||
|
visibility: PostVisibility.FOLLOWERS,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,13 +399,13 @@ export class FeedService {
|
|||||||
currentUser: UserDocument;
|
currentUser: UserDocument;
|
||||||
currentUserId: string;
|
currentUserId: string;
|
||||||
followingIds: string[];
|
followingIds: string[];
|
||||||
post: any;
|
post: Record<string, any>;
|
||||||
preferredPostType?: PostType;
|
preferredPostType?: PostType;
|
||||||
radiusKm: number;
|
radiusKm: number;
|
||||||
}): number {
|
}): number {
|
||||||
const { currentUser, currentUserId, followingIds, post, preferredPostType, radiusKm } = input;
|
const { currentUser, currentUserId, followingIds, post, preferredPostType, radiusKm } = input;
|
||||||
const author: any = post.authorId;
|
const author = post.authorId;
|
||||||
const authorId = typeof author === 'string' ? author : author?._id?.toString?.() ?? '';
|
const authorId = this.extractEntityId(author);
|
||||||
const isOwnPost = authorId === currentUserId;
|
const isOwnPost = authorId === currentUserId;
|
||||||
const isFollowing = followingIds.includes(authorId);
|
const isFollowing = followingIds.includes(authorId);
|
||||||
|
|
||||||
@@ -127,10 +413,16 @@ export class FeedService {
|
|||||||
const ageHours = ageMs / (1000 * 60 * 60);
|
const ageHours = ageMs / (1000 * 60 * 60);
|
||||||
const freshness = Math.max(0, 36 - ageHours);
|
const freshness = Math.max(0, 36 - ageHours);
|
||||||
|
|
||||||
const engagement = post.likesCount * 3 + post.commentsCount * 4 + post.savesCount * 5;
|
const engagement =
|
||||||
|
Number(post.likesCount ?? 0) * 3 +
|
||||||
|
Number(post.commentsCount ?? 0) * 4 +
|
||||||
|
Number(post.savesCount ?? 0) * 5 +
|
||||||
|
Number(post.shareCount ?? 0) * 6 +
|
||||||
|
Number(post.viewCount ?? 0) * 0.15 +
|
||||||
|
Number(post.playCount ?? 0) * 0.25;
|
||||||
const hashtagMatches = this.intersectionCount(
|
const hashtagMatches = this.intersectionCount(
|
||||||
this.buildPreferenceTokens(currentUser),
|
this.buildPreferenceTokens(currentUser),
|
||||||
(post.hashtags ?? []).map((x: string) => x.toLowerCase()),
|
(post.hashtags ?? []).map((value: string) => value.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
const distanceKm = this.computeDistanceKm(
|
const distanceKm = this.computeDistanceKm(
|
||||||
@@ -209,4 +501,45 @@ export class FeedService {
|
|||||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
return earthKm * c;
|
return earthKm * c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildCacheKey(scope: string, input: Record<string, unknown>): string {
|
||||||
|
return `feed:${scope}:${JSON.stringify(input)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldIncludeSuggestions(query: FeedQueryDto): boolean {
|
||||||
|
return (
|
||||||
|
query.includeSuggestions === true &&
|
||||||
|
!(query.cursor ?? '').trim() &&
|
||||||
|
(query.page ?? 1) === 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractEntityId(value: unknown): string {
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Types.ObjectId) {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const candidate = value as { _id?: unknown; id?: unknown };
|
||||||
|
if (candidate._id instanceof Types.ObjectId) {
|
||||||
|
return candidate._id.toString();
|
||||||
|
}
|
||||||
|
if (typeof candidate._id === 'string') {
|
||||||
|
return candidate._id;
|
||||||
|
}
|
||||||
|
if (typeof candidate.id === 'string') {
|
||||||
|
return candidate.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ export class FollowsController {
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Get('followers/:userId')
|
@Get('followers/:userId')
|
||||||
async followers(@Param('userId') userId: string, @Query() query: PaginationQueryDto) {
|
async followers(@Param('userId') userId: string, @Query() query: PaginationQueryDto) {
|
||||||
return this.followsService.getFollowers(userId, query.page, query.limit);
|
return this.followsService.getFollowers(userId, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Get('following/:userId')
|
@Get('following/:userId')
|
||||||
async following(@Param('userId') userId: string, @Query() query: PaginationQueryDto) {
|
async following(@Param('userId') userId: string, @Query() query: PaginationQueryDto) {
|
||||||
return this.followsService.getFollowing(userId, query.page, query.limit);
|
return this.followsService.getFollowing(userId, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -47,6 +47,6 @@ export class FollowsController {
|
|||||||
@Get('suggestions')
|
@Get('suggestions')
|
||||||
@Throttle(60, 60_000)
|
@Throttle(60, 60_000)
|
||||||
async suggestions(@CurrentUser() user: JwtPayload, @Query() query: PaginationQueryDto) {
|
async suggestions(@CurrentUser() user: JwtPayload, @Query() query: PaginationQueryDto) {
|
||||||
return this.followsService.getSuggestions(user.sub, query.page, query.limit);
|
return this.followsService.getSuggestions(user.sub, query);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,12 +38,17 @@ export class FollowsRepository {
|
|||||||
await this.followModel.findByIdAndDelete(id, { session }).exec();
|
await this.followModel.findByIdAndDelete(id, { session }).exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
async findMany(filter: FilterQuery<FollowDocument>, skip: number, limit: number): Promise<FollowDocument[]> {
|
async findMany(
|
||||||
|
filter: FilterQuery<FollowDocument>,
|
||||||
|
skip: number,
|
||||||
|
limit: number,
|
||||||
|
sort: Record<string, 1 | -1> = { createdAt: -1 },
|
||||||
|
): Promise<FollowDocument[]> {
|
||||||
return this.followModel
|
return this.followModel
|
||||||
.find(filter)
|
.find(filter)
|
||||||
.populate({ path: 'followerId', select: 'name username stageName avatar isVerified isDisabled' })
|
.populate({ path: 'followerId', select: 'name username stageName avatar isVerified isDisabled' })
|
||||||
.populate({ path: 'followingId', select: 'name username stageName avatar isVerified isDisabled' })
|
.populate({ path: 'followingId', select: 'name username stageName avatar isVerified isDisabled' })
|
||||||
.sort({ createdAt: -1 })
|
.sort(sort)
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.exec();
|
.exec();
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ describe('FollowsService', () => {
|
|||||||
followsRepository as any,
|
followsRepository as any,
|
||||||
usersRepository as any,
|
usersRepository as any,
|
||||||
outboxService as any,
|
outboxService as any,
|
||||||
|
{ bumpGlobalVersion: jest.fn().mockResolvedValue(1) } as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(service.toggleFollow(currentUserId, { targetUserId })).resolves.toEqual({
|
await expect(service.toggleFollow(currentUserId, { targetUserId })).resolves.toEqual({
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { Types } from 'mongoose';
|
import { Types } from 'mongoose';
|
||||||
|
import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
|
||||||
|
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
||||||
|
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
||||||
|
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
|
||||||
import { OutboxService } from '../outbox/outbox.service';
|
import { OutboxService } from '../outbox/outbox.service';
|
||||||
import { UsersRepository } from '../users/users.repository';
|
import { UsersRepository } from '../users/users.repository';
|
||||||
import { UserDocument } from '../users/schemas/user.schema';
|
import { UserDocument } from '../users/schemas/user.schema';
|
||||||
@@ -14,6 +18,7 @@ export class FollowsService {
|
|||||||
private readonly followsRepository: FollowsRepository,
|
private readonly followsRepository: FollowsRepository,
|
||||||
private readonly usersRepository: UsersRepository,
|
private readonly usersRepository: UsersRepository,
|
||||||
private readonly outboxService: OutboxService,
|
private readonly outboxService: OutboxService,
|
||||||
|
private readonly feedVersionService: FeedVersionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async toggleFollow(currentUserId: string, dto: ToggleFollowDto) {
|
async toggleFollow(currentUserId: string, dto: ToggleFollowDto) {
|
||||||
@@ -37,11 +42,13 @@ export class FollowsService {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
await this.followsRepository.deleteById(existing.id);
|
await this.followsRepository.deleteById(existing.id);
|
||||||
await this.syncFollowCounts(currentUserId, targetUserId);
|
await this.syncFollowCounts(currentUserId, targetUserId);
|
||||||
|
await this.feedVersionService.bumpGlobalVersion();
|
||||||
return { following: false };
|
return { following: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const follow = await this.followsRepository.create(currentUserId, targetUserId);
|
const follow = await this.followsRepository.create(currentUserId, targetUserId);
|
||||||
await this.syncFollowCounts(currentUserId, targetUserId);
|
await this.syncFollowCounts(currentUserId, targetUserId);
|
||||||
|
await this.feedVersionService.bumpGlobalVersion();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.outboxService.enqueueFollowNotification(currentUserId, targetUserId, follow.id);
|
await this.outboxService.enqueueFollowNotification(currentUserId, targetUserId, follow.id);
|
||||||
@@ -56,36 +63,40 @@ export class FollowsService {
|
|||||||
return { following: true };
|
return { following: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFollowers(userId: string, page = 1, limit = 20) {
|
async getFollowers(userId: string, query: PaginationQueryDto) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const limit = query.limit ?? 20;
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.followsRepository.findMany({ followingId: userId }, skip, limit),
|
this.followsRepository.findMany({ followingId: userId }, skip, limit, sort),
|
||||||
this.followsRepository.count({ followingId: userId }),
|
this.followsRepository.count({ followingId: userId }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return buildPaginatedResponse(items, {
|
||||||
items,
|
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
total,
|
total,
|
||||||
totalPages: Math.ceil(total / limit) || 1,
|
offset: skip,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFollowing(userId: string, page = 1, limit = 20) {
|
async getFollowing(userId: string, query: PaginationQueryDto) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const limit = query.limit ?? 20;
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.followsRepository.findMany({ followerId: userId }, skip, limit),
|
this.followsRepository.findMany({ followerId: userId }, skip, limit, sort),
|
||||||
this.followsRepository.count({ followerId: userId }),
|
this.followsRepository.count({ followerId: userId }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return buildPaginatedResponse(items, {
|
||||||
items,
|
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
total,
|
total,
|
||||||
totalPages: Math.ceil(total / limit) || 1,
|
offset: skip,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFollowStatus(currentUserId: string, targetUserId: string) {
|
async getFollowStatus(currentUserId: string, targetUserId: string) {
|
||||||
@@ -104,7 +115,9 @@ export class FollowsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSuggestions(currentUserId: string, page = 1, limit = 20) {
|
async getSuggestions(currentUserId: string, query: PaginationQueryDto) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const limit = query.limit ?? 20;
|
||||||
const currentUser = await this.usersRepository.findById(currentUserId);
|
const currentUser = await this.usersRepository.findById(currentUserId);
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
throw new NotFoundException('Current user not found');
|
throw new NotFoundException('Current user not found');
|
||||||
@@ -128,6 +141,10 @@ export class FollowsService {
|
|||||||
}))
|
}))
|
||||||
.sort((a, b) => b.score - a.score || b.user.followersCount - a.user.followersCount);
|
.sort((a, b) => b.score - a.score || b.user.followersCount - a.user.followersCount);
|
||||||
|
|
||||||
|
if (query.sortOrder === 'asc') {
|
||||||
|
ranked.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
const total = ranked.length;
|
const total = ranked.length;
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
const items = ranked.slice(skip, skip + limit).map((entry) => ({
|
const items = ranked.slice(skip, skip + limit).map((entry) => ({
|
||||||
@@ -136,13 +153,12 @@ export class FollowsService {
|
|||||||
reasons: this.buildSuggestionReasons(currentUser, entry.user),
|
reasons: this.buildSuggestionReasons(currentUser, entry.user),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return buildPaginatedResponse(items, {
|
||||||
items,
|
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
total,
|
total,
|
||||||
totalPages: Math.ceil(total / limit) || 1,
|
offset: skip,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private calculateSuggestionScore(currentUser: UserDocument, candidate: UserDocument): number {
|
private calculateSuggestionScore(currentUser: UserDocument, candidate: UserDocument): number {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { MongooseModule } from '@nestjs/mongoose';
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
import { CommentsModule } from '../comments/comments.module';
|
import { CommentsModule } from '../comments/comments.module';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { PostsModule } from '../posts/posts.module';
|
import { PostsModule } from '../posts/posts.module';
|
||||||
import { Like, LikeSchema } from './schemas/like.schema';
|
import { Like, LikeSchema } from './schemas/like.schema';
|
||||||
import { LikesController } from './likes.controller';
|
import { LikesController } from './likes.controller';
|
||||||
@@ -12,9 +13,10 @@ import { LikesService } from './likes.service';
|
|||||||
MongooseModule.forFeature([{ name: Like.name, schema: LikeSchema }]),
|
MongooseModule.forFeature([{ name: Like.name, schema: LikeSchema }]),
|
||||||
PostsModule,
|
PostsModule,
|
||||||
CommentsModule,
|
CommentsModule,
|
||||||
|
NotificationsModule,
|
||||||
],
|
],
|
||||||
controllers: [LikesController],
|
controllers: [LikesController],
|
||||||
providers: [LikesService, LikesRepository],
|
providers: [LikesService, LikesRepository],
|
||||||
exports: [LikesService],
|
exports: [LikesService, LikesRepository],
|
||||||
})
|
})
|
||||||
export class LikesModule {}
|
export class LikesModule {}
|
||||||
|
|||||||
@@ -25,6 +25,24 @@ export class LikesRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findLikedPostIds(userId: string, postIds: string[]): Promise<string[]> {
|
||||||
|
if (!postIds.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.likeModel
|
||||||
|
.find({
|
||||||
|
userId: new Types.ObjectId(userId),
|
||||||
|
targetType: 'post',
|
||||||
|
targetId: { $in: postIds.map((id) => new Types.ObjectId(id)) },
|
||||||
|
})
|
||||||
|
.select({ targetId: 1 })
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
return rows.map((row) => row.targetId.toString());
|
||||||
|
}
|
||||||
|
|
||||||
async deleteById(id: string): Promise<void> {
|
async deleteById(id: string): Promise<void> {
|
||||||
await this.likeModel.findByIdAndDelete(id).exec();
|
await this.likeModel.findByIdAndDelete(id).exec();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ describe('LikesService', () => {
|
|||||||
likesRepository as any,
|
likesRepository as any,
|
||||||
postsRepository as any,
|
postsRepository as any,
|
||||||
commentsRepository as any,
|
commentsRepository as any,
|
||||||
|
{ bumpGlobalVersion: jest.fn() } as any,
|
||||||
|
{ createLikeNotification: jest.fn() } as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Types } from 'mongoose';
|
||||||
|
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
|
||||||
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { CommentsRepository } from '../comments/comments.repository';
|
import { CommentsRepository } from '../comments/comments.repository';
|
||||||
import { PostsRepository } from '../posts/posts.repository';
|
import { PostsRepository } from '../posts/posts.repository';
|
||||||
import { LikesRepository } from './likes.repository';
|
import { LikesRepository } from './likes.repository';
|
||||||
@@ -6,10 +9,14 @@ import { ToggleLikeDto } from './dto/toggle-like.dto';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LikesService {
|
export class LikesService {
|
||||||
|
private readonly logger = new Logger(LikesService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly likesRepository: LikesRepository,
|
private readonly likesRepository: LikesRepository,
|
||||||
private readonly postsRepository: PostsRepository,
|
private readonly postsRepository: PostsRepository,
|
||||||
private readonly commentsRepository: CommentsRepository,
|
private readonly commentsRepository: CommentsRepository,
|
||||||
|
private readonly feedVersionService: FeedVersionService,
|
||||||
|
private readonly notificationsService: NotificationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async toggle(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> {
|
async toggle(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> {
|
||||||
@@ -19,6 +26,7 @@ export class LikesService {
|
|||||||
|
|
||||||
async like(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> {
|
async like(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> {
|
||||||
await this.assertTargetExists(dto);
|
await this.assertTargetExists(dto);
|
||||||
|
const notificationContext = await this.resolveNotificationContext(dto);
|
||||||
|
|
||||||
const existing = await this.likesRepository.findOne(userId, dto.targetId, dto.targetType);
|
const existing = await this.likesRepository.findOne(userId, dto.targetId, dto.targetType);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -29,6 +37,26 @@ export class LikesService {
|
|||||||
if (dto.targetType === 'post') {
|
if (dto.targetType === 'post') {
|
||||||
await this.postsRepository.incrementLikesCount(dto.targetId, 1);
|
await this.postsRepository.incrementLikesCount(dto.targetId, 1);
|
||||||
}
|
}
|
||||||
|
await this.feedVersionService.bumpGlobalVersion();
|
||||||
|
if (notificationContext.recipientId && notificationContext.recipientId !== userId) {
|
||||||
|
try {
|
||||||
|
await this.notificationsService.createLikeNotification(
|
||||||
|
userId,
|
||||||
|
notificationContext.recipientId,
|
||||||
|
dto.targetId,
|
||||||
|
{
|
||||||
|
resourceType: dto.targetType,
|
||||||
|
previewText: notificationContext.previewText,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Like notification failed for actor=${userId} recipient=${notificationContext.recipientId}: ${
|
||||||
|
error instanceof Error ? error.message : 'unknown error'
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { liked: true, targetId: dto.targetId, targetType: dto.targetType };
|
return { liked: true, targetId: dto.targetId, targetType: dto.targetType };
|
||||||
}
|
}
|
||||||
@@ -45,6 +73,7 @@ export class LikesService {
|
|||||||
if (dto.targetType === 'post') {
|
if (dto.targetType === 'post') {
|
||||||
await this.postsRepository.incrementLikesCount(dto.targetId, -1);
|
await this.postsRepository.incrementLikesCount(dto.targetId, -1);
|
||||||
}
|
}
|
||||||
|
await this.feedVersionService.bumpGlobalVersion();
|
||||||
|
|
||||||
return { liked: false, targetId: dto.targetId, targetType: dto.targetType };
|
return { liked: false, targetId: dto.targetId, targetType: dto.targetType };
|
||||||
}
|
}
|
||||||
@@ -75,4 +104,51 @@ export class LikesService {
|
|||||||
const comment = await this.commentsRepository.findById(dto.targetId);
|
const comment = await this.commentsRepository.findById(dto.targetId);
|
||||||
return !!comment;
|
return !!comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveNotificationContext(
|
||||||
|
dto: ToggleLikeDto,
|
||||||
|
): Promise<{ recipientId: string; previewText: string }> {
|
||||||
|
if (dto.targetType === 'post') {
|
||||||
|
const post = await this.postsRepository.findById(dto.targetId);
|
||||||
|
return {
|
||||||
|
recipientId: this.extractEntityId(post?.authorId),
|
||||||
|
previewText: (post?.content ?? '').slice(0, 140),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = await this.commentsRepository.findById(dto.targetId);
|
||||||
|
return {
|
||||||
|
recipientId: comment?.authorId?.toString?.() ?? '',
|
||||||
|
previewText: (comment?.content ?? '').slice(0, 140),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractEntityId(value: unknown): string {
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Types.ObjectId) {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const candidate = value as { _id?: unknown; id?: unknown };
|
||||||
|
if (candidate._id instanceof Types.ObjectId) {
|
||||||
|
return candidate._id.toString();
|
||||||
|
}
|
||||||
|
if (typeof candidate._id === 'string') {
|
||||||
|
return candidate._id;
|
||||||
|
}
|
||||||
|
if (typeof candidate.id === 'string') {
|
||||||
|
return candidate.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { Transform, Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
ArrayMaxSize,
|
ArrayMaxSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
@@ -10,6 +11,10 @@ import {
|
|||||||
MaxLength,
|
MaxLength,
|
||||||
Min,
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { MarketplaceListingCondition } from '../enums/marketplace-listing-condition.enum';
|
||||||
|
import { MarketplaceListingCategory } from '../enums/marketplace-listing-category.enum';
|
||||||
|
import { toStringArray } from '../../../common/utils/array-transform.util';
|
||||||
|
import { toBoolean } from '../../../common/utils/query-transform.util';
|
||||||
|
|
||||||
export class CreateInstrumentDto {
|
export class CreateInstrumentDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -38,13 +43,27 @@ export class CreateInstrumentDto {
|
|||||||
quantity!: number;
|
quantity!: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@Transform(toStringArray)
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ArrayMaxSize(5)
|
@ArrayMaxSize(5)
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
imageUrls?: string[];
|
imageUrls?: string[];
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Boolean)
|
@Transform(toBoolean)
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(MarketplaceListingCondition)
|
||||||
|
condition?: MarketplaceListingCondition;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(80)
|
||||||
|
instrumentType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(MarketplaceListingCategory)
|
||||||
|
listingCategory?: MarketplaceListingCategory;
|
||||||
}
|
}
|
||||||
|
|||||||
74
src/modules/marketplace/dto/create-repair-shop.dto.ts
Normal file
74
src/modules/marketplace/dto/create-repair-shop.dto.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUrl,
|
||||||
|
Length,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { toStringArray } from '../../../common/utils/array-transform.util';
|
||||||
|
import { toBoolean } from '../../../common/utils/query-transform.util';
|
||||||
|
|
||||||
|
export class CreateRepairShopDto {
|
||||||
|
@IsString()
|
||||||
|
@Length(2, 120)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 2000)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toStringArray)
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(20)
|
||||||
|
@IsString({ each: true })
|
||||||
|
services?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 40)
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 40)
|
||||||
|
whatsapp?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toStringArray)
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(8)
|
||||||
|
@IsUrl({ require_tld: false }, { each: true })
|
||||||
|
imageUrls?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 160)
|
||||||
|
location?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-90)
|
||||||
|
@Max(90)
|
||||||
|
latitude?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-180)
|
||||||
|
@Max(180)
|
||||||
|
longitude?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toBoolean)
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
@@ -1,29 +1,60 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsBoolean, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import { IsBoolean, IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||||
|
import { toBoolean } from '../../../common/utils/query-transform.util';
|
||||||
|
import { MarketplaceListingCondition } from '../enums/marketplace-listing-condition.enum';
|
||||||
|
import { MarketplaceListingCategory } from '../enums/marketplace-listing-category.enum';
|
||||||
|
|
||||||
|
export const INSTRUMENT_SORT_FIELDS = ['createdAt', 'updatedAt', 'price', 'title'] as const;
|
||||||
|
export type InstrumentSortField = (typeof INSTRUMENT_SORT_FIELDS)[number];
|
||||||
|
|
||||||
export class InstrumentQueryDto extends PaginationQueryDto {
|
export class InstrumentQueryDto extends PaginationQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Search by title or description' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
q?: string;
|
q?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 0 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
minPrice?: number;
|
minPrice?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 0 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
maxPrice?: number;
|
maxPrice?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Boolean)
|
@Transform(toBoolean)
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: MarketplaceListingCondition })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(MarketplaceListingCondition)
|
||||||
|
condition?: MarketplaceListingCondition;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by instrument type such as oud, piano, violin' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
instrumentType?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: INSTRUMENT_SORT_FIELDS, default: 'createdAt' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(INSTRUMENT_SORT_FIELDS)
|
||||||
|
sortBy?: InstrumentSortField;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: MarketplaceListingCategory })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(MarketplaceListingCategory)
|
||||||
|
listingCategory?: MarketplaceListingCategory;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
|||||||
36
src/modules/marketplace/dto/marketplace-home-query.dto.ts
Normal file
36
src/modules/marketplace/dto/marketplace-home-query.dto.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import { IsBoolean, IsNumber, IsOptional, Max, Min } from 'class-validator';
|
||||||
|
import { toBoolean } from '../../../common/utils/query-transform.util';
|
||||||
|
|
||||||
|
export class MarketplaceHomeQueryDto {
|
||||||
|
@ApiPropertyOptional({ minimum: 1, maximum: 20, default: 6 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(20)
|
||||||
|
listingsLimit?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 1, maximum: 20, default: 6 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(20)
|
||||||
|
instrumentsLimit?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 1, maximum: 20, default: 4 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(20)
|
||||||
|
repairShopsLimit?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: true })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toBoolean)
|
||||||
|
@IsBoolean()
|
||||||
|
onlyActive?: boolean;
|
||||||
|
}
|
||||||
33
src/modules/marketplace/dto/repair-shop-query.dto.ts
Normal file
33
src/modules/marketplace/dto/repair-shop-query.dto.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import { IsBoolean, IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||||
|
import { toBoolean } from '../../../common/utils/query-transform.util';
|
||||||
|
|
||||||
|
export const REPAIR_SHOP_SORT_FIELDS = ['createdAt', 'updatedAt', 'name'] as const;
|
||||||
|
export type RepairShopSortField = (typeof REPAIR_SHOP_SORT_FIELDS)[number];
|
||||||
|
|
||||||
|
export class RepairShopQueryDto extends PaginationQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Search by name, description, services, or location' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
q?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: true })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toBoolean)
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: REPAIR_SHOP_SORT_FIELDS, default: 'createdAt' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(REPAIR_SHOP_SORT_FIELDS)
|
||||||
|
sortBy?: RepairShopSortField;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(200)
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
14
src/modules/marketplace/dto/update-marketplace-status.dto.ts
Normal file
14
src/modules/marketplace/dto/update-marketplace-status.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateMarketplaceStatusDto {
|
||||||
|
@ApiProperty({ example: false })
|
||||||
|
@IsBoolean()
|
||||||
|
isActive!: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Listing disabled pending verification' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(1200)
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
4
src/modules/marketplace/dto/update-repair-shop.dto.ts
Normal file
4
src/modules/marketplace/dto/update-repair-shop.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateRepairShopDto } from './create-repair-shop.dto';
|
||||||
|
|
||||||
|
export class UpdateRepairShopDto extends PartialType(CreateRepairShopDto) {}
|
||||||
51
src/modules/marketplace/dto/update-shop-profile.dto.ts
Normal file
51
src/modules/marketplace/dto/update-shop-profile.dto.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
IsArray,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUrl,
|
||||||
|
Length,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { toStringArray } from '../../../common/utils/array-transform.util';
|
||||||
|
|
||||||
|
export class UpdateShopProfileDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(2, 120)
|
||||||
|
shopName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 2000)
|
||||||
|
shopDescription?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toStringArray)
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(8)
|
||||||
|
@IsUrl({ require_tld: false }, { each: true })
|
||||||
|
shopImageUrls?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 160)
|
||||||
|
shopLocation?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-90)
|
||||||
|
@Max(90)
|
||||||
|
shopLatitude?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-180)
|
||||||
|
@Max(180)
|
||||||
|
shopLongitude?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export enum MarketplaceListingCategory {
|
||||||
|
MUSICAL_INSTRUMENT = 'musical_instrument',
|
||||||
|
ACCESSORY = 'accessory',
|
||||||
|
AUDIO_GEAR = 'audio_gear',
|
||||||
|
SHEET_MUSIC = 'sheet_music',
|
||||||
|
OTHER = 'other',
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export enum MarketplaceListingCondition {
|
||||||
|
NEW = 'new',
|
||||||
|
LIKE_NEW = 'like_new',
|
||||||
|
USED = 'used',
|
||||||
|
REFURBISHED = 'refurbished',
|
||||||
|
}
|
||||||
@@ -1,52 +1,461 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
import {
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UploadedFiles,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileFieldsInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
import { Roles } from '../../common/decorators/roles.decorator';
|
import { Roles } from '../../common/decorators/roles.decorator';
|
||||||
|
import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator';
|
||||||
import { Throttle } from '../../common/decorators/throttle.decorator';
|
import { Throttle } from '../../common/decorators/throttle.decorator';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||||
|
import { SuperAdminPermissionsGuard } from '../../common/guards/superadmin-permissions.guard';
|
||||||
|
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
|
||||||
import { UserRole } from '../../common/enums/user-role.enum';
|
import { UserRole } from '../../common/enums/user-role.enum';
|
||||||
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||||
import { CreateInstrumentDto } from './dto/create-instrument.dto';
|
import { CreateInstrumentDto } from './dto/create-instrument.dto';
|
||||||
|
import { CreateRepairShopDto } from './dto/create-repair-shop.dto';
|
||||||
import { InstrumentQueryDto } from './dto/instrument-query.dto';
|
import { InstrumentQueryDto } from './dto/instrument-query.dto';
|
||||||
|
import { MarketplaceHomeQueryDto } from './dto/marketplace-home-query.dto';
|
||||||
|
import { RepairShopQueryDto } from './dto/repair-shop-query.dto';
|
||||||
|
import { UpdateShopProfileDto } from './dto/update-shop-profile.dto';
|
||||||
import { UpdateInstrumentDto } from './dto/update-instrument.dto';
|
import { UpdateInstrumentDto } from './dto/update-instrument.dto';
|
||||||
|
import { UpdateMarketplaceStatusDto } from './dto/update-marketplace-status.dto';
|
||||||
|
import { UpdateRepairShopDto } from './dto/update-repair-shop.dto';
|
||||||
import { MarketplaceService } from './marketplace.service';
|
import { MarketplaceService } from './marketplace.service';
|
||||||
|
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
|
||||||
|
|
||||||
@ApiTags('Marketplace')
|
@ApiTags('Marketplace')
|
||||||
@Controller('marketplace')
|
@Controller('marketplace')
|
||||||
export class MarketplaceController {
|
export class MarketplaceController {
|
||||||
constructor(private readonly marketplaceService: MarketplaceService) {}
|
constructor(private readonly marketplaceService: MarketplaceService) {}
|
||||||
|
|
||||||
|
@Get('home')
|
||||||
|
async getMarketplaceHome(@Query() query: MarketplaceHomeQueryDto) {
|
||||||
|
return this.marketplaceService.getHome(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('listings')
|
||||||
|
async listPublicListings(@Query() query: InstrumentQueryDto) {
|
||||||
|
return this.marketplaceService.getPublicListings(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('listings/:id')
|
||||||
|
async findListing(@Param('id') listingId: string) {
|
||||||
|
return this.marketplaceService.findListingById(listingId);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('instruments')
|
@Get('instruments')
|
||||||
async listPublic(@Query() query: InstrumentQueryDto) {
|
async listPublic(@Query() query: InstrumentQueryDto) {
|
||||||
return this.marketplaceService.getPublic(query);
|
return this.marketplaceService.getPublicInstruments(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('instruments/:id')
|
@Get('instruments/:id')
|
||||||
async findOne(@Param('id') instrumentId: string) {
|
async findOne(@Param('id') instrumentId: string) {
|
||||||
return this.marketplaceService.findById(instrumentId);
|
return this.marketplaceService.findInstrumentById(instrumentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('repair-shops')
|
||||||
|
async listPublicRepairShops(@Query() query: RepairShopQueryDto) {
|
||||||
|
return this.marketplaceService.getPublicRepairShops(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('repair-shops/:id')
|
||||||
|
async findRepairShop(@Param('id') repairShopId: string) {
|
||||||
|
return this.marketplaceService.findRepairShopById(repairShopId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('shops/:adminId')
|
||||||
|
async getShopByAdminId(@Param('adminId') adminId: string) {
|
||||||
|
return this.marketplaceService.getShopProfileByAdminId(adminId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||||
|
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
|
||||||
|
@Get('superadmin/listings')
|
||||||
|
async listAllListingsForSuperAdmin(@Query() query: InstrumentQueryDto) {
|
||||||
|
return this.marketplaceService.getListingsForSuperAdmin(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||||
|
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
|
||||||
|
@Patch('superadmin/listings/:id/status')
|
||||||
|
async updateListingStatusBySuperAdmin(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id') listingId: string,
|
||||||
|
@Body() dto: UpdateMarketplaceStatusDto,
|
||||||
|
) {
|
||||||
|
return this.marketplaceService.updateListingStatusBySuperAdmin(
|
||||||
|
user.email ?? user.sub,
|
||||||
|
listingId,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||||
|
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
|
||||||
|
@Delete('superadmin/listings/:id')
|
||||||
|
async deleteListingBySuperAdmin(@CurrentUser() user: JwtPayload, @Param('id') listingId: string) {
|
||||||
|
await this.marketplaceService.removeListingBySuperAdmin(user.email ?? user.sub, listingId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||||
|
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
|
||||||
|
@Get('superadmin/repair-shops')
|
||||||
|
async listAllRepairShopsForSuperAdmin(@Query() query: RepairShopQueryDto) {
|
||||||
|
return this.marketplaceService.getRepairShopsForSuperAdmin(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||||
|
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
|
||||||
|
@Patch('superadmin/repair-shops/:id/status')
|
||||||
|
async updateRepairShopStatusBySuperAdmin(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id') repairShopId: string,
|
||||||
|
@Body() dto: UpdateMarketplaceStatusDto,
|
||||||
|
) {
|
||||||
|
return this.marketplaceService.updateRepairShopStatusBySuperAdmin(
|
||||||
|
user.email ?? user.sub,
|
||||||
|
repairShopId,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||||
|
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
|
||||||
|
@Delete('superadmin/repair-shops/:id')
|
||||||
|
async deleteRepairShopBySuperAdmin(@CurrentUser() user: JwtPayload, @Param('id') repairShopId: string) {
|
||||||
|
await this.marketplaceService.removeRepairShopBySuperAdmin(user.email ?? user.sub, repairShopId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||||
|
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
|
||||||
|
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }]))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', example: 'Professional Oud' },
|
||||||
|
description: { type: 'string', example: 'Well-maintained oud for studio work' },
|
||||||
|
price: { type: 'number', example: 3500 },
|
||||||
|
currency: { type: 'string', example: 'SAR' },
|
||||||
|
quantity: { type: 'number', example: 1 },
|
||||||
|
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||||
|
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
|
||||||
|
isActive: { type: 'boolean', example: true },
|
||||||
|
condition: { type: 'string', example: 'used' },
|
||||||
|
instrumentType: { type: 'string', example: 'Oud' },
|
||||||
|
listingCategory: { type: 'string', example: 'musical_instrument' },
|
||||||
|
},
|
||||||
|
required: ['title', 'price', 'quantity'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@Post('superadmin/admins/:adminId/listings')
|
||||||
|
@Throttle(40, 60_000)
|
||||||
|
async createListingBySuperAdmin(
|
||||||
|
@Param('adminId') adminId: string,
|
||||||
|
@Body() dto: CreateInstrumentDto,
|
||||||
|
@UploadedFiles()
|
||||||
|
files?: {
|
||||||
|
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.marketplaceService.createListingBySuperAdmin(adminId, dto, files?.imageFiles ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||||
|
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
|
||||||
|
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }]))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', example: 'Concert Guitar' },
|
||||||
|
description: { type: 'string', example: 'Acoustic guitar in excellent condition' },
|
||||||
|
price: { type: 'number', example: 2100 },
|
||||||
|
currency: { type: 'string', example: 'SAR' },
|
||||||
|
quantity: { type: 'number', example: 1 },
|
||||||
|
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||||
|
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
|
||||||
|
isActive: { type: 'boolean', example: true },
|
||||||
|
condition: { type: 'string', example: 'used' },
|
||||||
|
instrumentType: { type: 'string', example: 'Guitar' },
|
||||||
|
},
|
||||||
|
required: ['title', 'price', 'quantity'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@Post('superadmin/admins/:adminId/instruments')
|
||||||
|
@Throttle(40, 60_000)
|
||||||
|
async createInstrumentBySuperAdmin(
|
||||||
|
@Param('adminId') adminId: string,
|
||||||
|
@Body() dto: CreateInstrumentDto,
|
||||||
|
@UploadedFiles()
|
||||||
|
files?: {
|
||||||
|
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.marketplaceService.createInstrumentBySuperAdmin(adminId, dto, files?.imageFiles ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||||
|
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
|
||||||
|
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 8 }]))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string', example: 'Fix Strings Workshop' },
|
||||||
|
description: { type: 'string', example: 'Repair shop for oud and violin' },
|
||||||
|
services: { type: 'array', items: { type: 'string' } },
|
||||||
|
phone: { type: 'string', example: '+966500000000' },
|
||||||
|
whatsapp: { type: 'string', example: '+966500000000' },
|
||||||
|
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||||
|
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
|
||||||
|
location: { type: 'string', example: 'Riyadh' },
|
||||||
|
latitude: { type: 'number', example: 24.7136 },
|
||||||
|
longitude: { type: 'number', example: 46.6753 },
|
||||||
|
isActive: { type: 'boolean', example: true },
|
||||||
|
},
|
||||||
|
required: ['name'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@Post('superadmin/admins/:adminId/repair-shops')
|
||||||
|
@Throttle(30, 60_000)
|
||||||
|
async createRepairShopBySuperAdmin(
|
||||||
|
@Param('adminId') adminId: string,
|
||||||
|
@Body() dto: CreateRepairShopDto,
|
||||||
|
@UploadedFiles()
|
||||||
|
files?: {
|
||||||
|
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.marketplaceService.createRepairShopBySuperAdmin(adminId, dto, files?.imageFiles ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||||
|
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
|
||||||
|
@UseInterceptors(FileFieldsInterceptor([{ name: 'shopImageFiles', maxCount: 8 }]))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
shopName: { type: 'string', example: 'Awtarna Store' },
|
||||||
|
shopDescription: { type: 'string', example: 'Trusted marketplace shop profile' },
|
||||||
|
shopImageUrls: { type: 'array', items: { type: 'string' } },
|
||||||
|
shopImageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
|
||||||
|
shopLocation: { type: 'string', example: 'Riyadh' },
|
||||||
|
shopLatitude: { type: 'number', example: 24.7136 },
|
||||||
|
shopLongitude: { type: 'number', example: 46.6753 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@Patch('superadmin/admins/:adminId/shop-profile')
|
||||||
|
@Throttle(30, 60_000)
|
||||||
|
async updateShopProfileBySuperAdmin(
|
||||||
|
@Param('adminId') adminId: string,
|
||||||
|
@Body() dto: UpdateShopProfileDto,
|
||||||
|
@UploadedFiles()
|
||||||
|
files?: {
|
||||||
|
shopImageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.marketplaceService.updateShopProfileBySuperAdmin(
|
||||||
|
adminId,
|
||||||
|
dto,
|
||||||
|
files?.shopImageFiles ?? [],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Roles(UserRole.ADMIN)
|
@Roles(UserRole.ADMIN)
|
||||||
|
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }]))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', example: 'Professional Oud' },
|
||||||
|
description: { type: 'string', example: 'Well-maintained oud for studio work' },
|
||||||
|
price: { type: 'number', example: 3500 },
|
||||||
|
currency: { type: 'string', example: 'SAR' },
|
||||||
|
quantity: { type: 'number', example: 1 },
|
||||||
|
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||||
|
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
|
||||||
|
isActive: { type: 'boolean', example: true },
|
||||||
|
condition: { type: 'string', example: 'used' },
|
||||||
|
instrumentType: { type: 'string', example: 'Oud' },
|
||||||
|
listingCategory: { type: 'string', example: 'musical_instrument' },
|
||||||
|
},
|
||||||
|
required: ['title', 'price', 'quantity'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@Post('admin/listings')
|
||||||
|
@Throttle(40, 60_000)
|
||||||
|
async createListingByAdmin(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Body() dto: CreateInstrumentDto,
|
||||||
|
@UploadedFiles()
|
||||||
|
files?: {
|
||||||
|
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.marketplaceService.createListingByAdmin(user.sub, dto, files?.imageFiles ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }]))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', example: 'Updated listing title' },
|
||||||
|
description: { type: 'string', example: 'Updated description' },
|
||||||
|
price: { type: 'number', example: 3200 },
|
||||||
|
currency: { type: 'string', example: 'SAR' },
|
||||||
|
quantity: { type: 'number', example: 1 },
|
||||||
|
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||||
|
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
|
||||||
|
isActive: { type: 'boolean', example: true },
|
||||||
|
instrumentType: { type: 'string', example: 'Oud' },
|
||||||
|
listingCategory: { type: 'string', example: 'musical_instrument' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@Patch('admin/listings/:id')
|
||||||
|
@Throttle(60, 60_000)
|
||||||
|
async updateListingByAdmin(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id') listingId: string,
|
||||||
|
@Body() dto: UpdateInstrumentDto,
|
||||||
|
@UploadedFiles()
|
||||||
|
files?: {
|
||||||
|
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.marketplaceService.updateListingByAdmin(user.sub, listingId, dto, files?.imageFiles ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@Delete('admin/listings/:id')
|
||||||
|
@Throttle(40, 60_000)
|
||||||
|
async removeListingByAdmin(@CurrentUser() user: JwtPayload, @Param('id') listingId: string) {
|
||||||
|
await this.marketplaceService.removeListingByAdmin(user.sub, listingId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@Get('admin/listings/me')
|
||||||
|
async myListings(@CurrentUser() user: JwtPayload, @Query() query: InstrumentQueryDto) {
|
||||||
|
return this.marketplaceService.getMyListings(user.sub, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }]))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', example: 'Concert Guitar' },
|
||||||
|
description: { type: 'string', example: 'Acoustic guitar in excellent condition' },
|
||||||
|
price: { type: 'number', example: 2100 },
|
||||||
|
currency: { type: 'string', example: 'SAR' },
|
||||||
|
quantity: { type: 'number', example: 1 },
|
||||||
|
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||||
|
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
|
||||||
|
isActive: { type: 'boolean', example: true },
|
||||||
|
condition: { type: 'string', example: 'used' },
|
||||||
|
instrumentType: { type: 'string', example: 'Guitar' },
|
||||||
|
},
|
||||||
|
required: ['title', 'price', 'quantity'],
|
||||||
|
},
|
||||||
|
})
|
||||||
@Post('admin/instruments')
|
@Post('admin/instruments')
|
||||||
@Throttle(40, 60_000)
|
@Throttle(40, 60_000)
|
||||||
async createByAdmin(@CurrentUser() user: JwtPayload, @Body() dto: CreateInstrumentDto) {
|
async createByAdmin(
|
||||||
return this.marketplaceService.createByAdmin(user.sub, dto);
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Body() dto: CreateInstrumentDto,
|
||||||
|
@UploadedFiles()
|
||||||
|
files?: {
|
||||||
|
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.marketplaceService.createInstrumentByAdmin(user.sub, dto, files?.imageFiles ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Roles(UserRole.ADMIN)
|
@Roles(UserRole.ADMIN)
|
||||||
|
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }]))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', example: 'Updated instrument title' },
|
||||||
|
description: { type: 'string', example: 'Updated instrument description' },
|
||||||
|
price: { type: 'number', example: 2400 },
|
||||||
|
quantity: { type: 'number', example: 1 },
|
||||||
|
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||||
|
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
|
||||||
|
condition: { type: 'string', example: 'used' },
|
||||||
|
instrumentType: { type: 'string', example: 'Violin' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@Patch('admin/instruments/:id')
|
@Patch('admin/instruments/:id')
|
||||||
@Throttle(60, 60_000)
|
@Throttle(60, 60_000)
|
||||||
async updateByAdmin(
|
async updateByAdmin(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Param('id') instrumentId: string,
|
@Param('id') instrumentId: string,
|
||||||
@Body() dto: UpdateInstrumentDto,
|
@Body() dto: UpdateInstrumentDto,
|
||||||
|
@UploadedFiles()
|
||||||
|
files?: {
|
||||||
|
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
return this.marketplaceService.updateByAdmin(user.sub, instrumentId, dto);
|
return this.marketplaceService.updateInstrumentByAdmin(
|
||||||
|
user.sub,
|
||||||
|
instrumentId,
|
||||||
|
dto,
|
||||||
|
files?.imageFiles ?? [],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -55,7 +464,7 @@ export class MarketplaceController {
|
|||||||
@Delete('admin/instruments/:id')
|
@Delete('admin/instruments/:id')
|
||||||
@Throttle(40, 60_000)
|
@Throttle(40, 60_000)
|
||||||
async removeByAdmin(@CurrentUser() user: JwtPayload, @Param('id') instrumentId: string) {
|
async removeByAdmin(@CurrentUser() user: JwtPayload, @Param('id') instrumentId: string) {
|
||||||
await this.marketplaceService.removeByAdmin(user.sub, instrumentId);
|
await this.marketplaceService.removeInstrumentByAdmin(user.sub, instrumentId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +473,147 @@ export class MarketplaceController {
|
|||||||
@Roles(UserRole.ADMIN)
|
@Roles(UserRole.ADMIN)
|
||||||
@Get('admin/instruments/me')
|
@Get('admin/instruments/me')
|
||||||
async myInstruments(@CurrentUser() user: JwtPayload, @Query() query: InstrumentQueryDto) {
|
async myInstruments(@CurrentUser() user: JwtPayload, @Query() query: InstrumentQueryDto) {
|
||||||
return this.marketplaceService.getMine(user.sub, query);
|
return this.marketplaceService.getMyInstruments(user.sub, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 8 }]))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string', example: 'Fix Strings Workshop' },
|
||||||
|
description: { type: 'string', example: 'Repair shop for oud and violin' },
|
||||||
|
services: { type: 'array', items: { type: 'string' } },
|
||||||
|
phone: { type: 'string', example: '+966500000000' },
|
||||||
|
whatsapp: { type: 'string', example: '+966500000000' },
|
||||||
|
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||||
|
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
|
||||||
|
location: { type: 'string', example: 'Riyadh' },
|
||||||
|
latitude: { type: 'number', example: 24.7136 },
|
||||||
|
longitude: { type: 'number', example: 46.6753 },
|
||||||
|
isActive: { type: 'boolean', example: true },
|
||||||
|
},
|
||||||
|
required: ['name'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@Post('admin/repair-shops')
|
||||||
|
@Throttle(30, 60_000)
|
||||||
|
async createRepairShop(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Body() dto: CreateRepairShopDto,
|
||||||
|
@UploadedFiles()
|
||||||
|
files?: {
|
||||||
|
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.marketplaceService.createRepairShop(user.sub, dto, files?.imageFiles ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 8 }]))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string', example: 'Updated shop name' },
|
||||||
|
description: { type: 'string', example: 'Updated repair shop description' },
|
||||||
|
services: { type: 'array', items: { type: 'string' } },
|
||||||
|
phone: { type: 'string', example: '+966500000000' },
|
||||||
|
whatsapp: { type: 'string', example: '+966500000000' },
|
||||||
|
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||||
|
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
|
||||||
|
location: { type: 'string', example: 'Jeddah' },
|
||||||
|
latitude: { type: 'number', example: 21.5433 },
|
||||||
|
longitude: { type: 'number', example: 39.1728 },
|
||||||
|
isActive: { type: 'boolean', example: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@Patch('admin/repair-shops/:id')
|
||||||
|
@Throttle(40, 60_000)
|
||||||
|
async updateRepairShop(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id') repairShopId: string,
|
||||||
|
@Body() dto: UpdateRepairShopDto,
|
||||||
|
@UploadedFiles()
|
||||||
|
files?: {
|
||||||
|
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.marketplaceService.updateRepairShop(
|
||||||
|
user.sub,
|
||||||
|
repairShopId,
|
||||||
|
dto,
|
||||||
|
files?.imageFiles ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@Delete('admin/repair-shops/:id')
|
||||||
|
@Throttle(30, 60_000)
|
||||||
|
async deleteRepairShop(@CurrentUser() user: JwtPayload, @Param('id') repairShopId: string) {
|
||||||
|
await this.marketplaceService.removeRepairShop(user.sub, repairShopId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@Get('admin/repair-shops/me')
|
||||||
|
async myRepairShops(@CurrentUser() user: JwtPayload, @Query() query: RepairShopQueryDto) {
|
||||||
|
return this.marketplaceService.getMyRepairShops(user.sub, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@Get('admin/shop-profile/me')
|
||||||
|
async myShopProfile(@CurrentUser() user: JwtPayload) {
|
||||||
|
return this.marketplaceService.getMyShopProfile(user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@UseInterceptors(FileFieldsInterceptor([{ name: 'shopImageFiles', maxCount: 8 }]))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
shopName: { type: 'string', example: 'Awtarna Store' },
|
||||||
|
shopDescription: { type: 'string', example: 'Trusted marketplace shop profile' },
|
||||||
|
shopImageUrls: { type: 'array', items: { type: 'string' } },
|
||||||
|
shopImageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
|
||||||
|
shopLocation: { type: 'string', example: 'Riyadh' },
|
||||||
|
shopLatitude: { type: 'number', example: 24.7136 },
|
||||||
|
shopLongitude: { type: 'number', example: 46.6753 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@Patch('admin/shop-profile')
|
||||||
|
@Throttle(30, 60_000)
|
||||||
|
async updateShopProfile(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Body() dto: UpdateShopProfileDto,
|
||||||
|
@UploadedFiles()
|
||||||
|
files?: {
|
||||||
|
shopImageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.marketplaceService.updateMyShopProfile(
|
||||||
|
user.sub,
|
||||||
|
dto,
|
||||||
|
files?.shopImageFiles ?? [],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { MongooseModule } from '@nestjs/mongoose';
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import { SuperAdminCase, SuperAdminCaseSchema } from '../superadmin/schemas/superadmin-case.schema';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { MarketplaceController } from './marketplace.controller';
|
import { MarketplaceController } from './marketplace.controller';
|
||||||
import { MarketplaceRepository } from './marketplace.repository';
|
import { MarketplaceRepository } from './marketplace.repository';
|
||||||
import { MarketplaceService } from './marketplace.service';
|
import { MarketplaceService } from './marketplace.service';
|
||||||
import { Instrument, InstrumentSchema } from './schemas/instrument.schema';
|
import { Instrument, InstrumentSchema } from './schemas/instrument.schema';
|
||||||
|
import { RepairShop, RepairShopSchema } from './schemas/repair-shop.schema';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
AuditModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
MongooseModule.forFeature([{ name: Instrument.name, schema: InstrumentSchema }]),
|
MongooseModule.forFeature([
|
||||||
|
{ name: Instrument.name, schema: InstrumentSchema },
|
||||||
|
{ name: RepairShop.name, schema: RepairShopSchema },
|
||||||
|
{ name: SuperAdminCase.name, schema: SuperAdminCaseSchema },
|
||||||
|
]),
|
||||||
],
|
],
|
||||||
controllers: [MarketplaceController],
|
controllers: [MarketplaceController],
|
||||||
providers: [MarketplaceService, MarketplaceRepository],
|
providers: [MarketplaceService, MarketplaceRepository],
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { InjectModel } from '@nestjs/mongoose';
|
import { InjectModel } from '@nestjs/mongoose';
|
||||||
import { FilterQuery, Model, Types, UpdateQuery } from 'mongoose';
|
import { FilterQuery, Model, Types, UpdateQuery } from 'mongoose';
|
||||||
import { Instrument, InstrumentDocument } from './schemas/instrument.schema';
|
import { Instrument, InstrumentDocument } from './schemas/instrument.schema';
|
||||||
|
import { RepairShop, RepairShopDocument } from './schemas/repair-shop.schema';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MarketplaceRepository {
|
export class MarketplaceRepository {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectModel(Instrument.name)
|
@InjectModel(Instrument.name)
|
||||||
private readonly instrumentModel: Model<InstrumentDocument>,
|
private readonly instrumentModel: Model<InstrumentDocument>,
|
||||||
|
@InjectModel(RepairShop.name)
|
||||||
|
private readonly repairShopModel: Model<RepairShopDocument>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(ownerAdminId: string, payload: Partial<Instrument>): Promise<InstrumentDocument> {
|
async create(ownerAdminId: string, payload: Partial<Instrument>): Promise<InstrumentDocument> {
|
||||||
@@ -23,7 +26,7 @@ export class MarketplaceRepository {
|
|||||||
}
|
}
|
||||||
return this.instrumentModel
|
return this.instrumentModel
|
||||||
.findById(instrumentId)
|
.findById(instrumentId)
|
||||||
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled' })
|
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
|
||||||
.exec();
|
.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +40,7 @@ export class MarketplaceRepository {
|
|||||||
|
|
||||||
return this.instrumentModel
|
return this.instrumentModel
|
||||||
.findByIdAndUpdate(instrumentId, payload, { new: true })
|
.findByIdAndUpdate(instrumentId, payload, { new: true })
|
||||||
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled' })
|
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
|
||||||
.exec();
|
.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,11 +55,12 @@ export class MarketplaceRepository {
|
|||||||
filter: FilterQuery<InstrumentDocument>,
|
filter: FilterQuery<InstrumentDocument>,
|
||||||
skip: number,
|
skip: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
|
sort: Record<string, 1 | -1> = { createdAt: -1 },
|
||||||
): Promise<InstrumentDocument[]> {
|
): Promise<InstrumentDocument[]> {
|
||||||
return this.instrumentModel
|
return this.instrumentModel
|
||||||
.find(filter)
|
.find(filter)
|
||||||
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled' })
|
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
|
||||||
.sort({ createdAt: -1 })
|
.sort(sort)
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.exec();
|
.exec();
|
||||||
@@ -66,6 +70,7 @@ export class MarketplaceRepository {
|
|||||||
filter: FilterQuery<InstrumentDocument>,
|
filter: FilterQuery<InstrumentDocument>,
|
||||||
skip: number,
|
skip: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
|
sort: Record<string, 1 | -1> = { createdAt: -1 },
|
||||||
): Promise<Record<string, unknown>[]> {
|
): Promise<Record<string, unknown>[]> {
|
||||||
return this.instrumentModel
|
return this.instrumentModel
|
||||||
.aggregate([
|
.aggregate([
|
||||||
@@ -80,7 +85,7 @@ export class MarketplaceRepository {
|
|||||||
},
|
},
|
||||||
{ $unwind: '$ownerAdmin' },
|
{ $unwind: '$ownerAdmin' },
|
||||||
{ $match: { 'ownerAdmin.isDisabled': false } },
|
{ $match: { 'ownerAdmin.isDisabled': false } },
|
||||||
{ $sort: { createdAt: -1 } },
|
{ $sort: sort },
|
||||||
{ $skip: skip },
|
{ $skip: skip },
|
||||||
{ $limit: limit },
|
{ $limit: limit },
|
||||||
{
|
{
|
||||||
@@ -93,6 +98,9 @@ export class MarketplaceRepository {
|
|||||||
quantity: 1,
|
quantity: 1,
|
||||||
imageUrls: 1,
|
imageUrls: 1,
|
||||||
isActive: 1,
|
isActive: 1,
|
||||||
|
condition: 1,
|
||||||
|
instrumentType: 1,
|
||||||
|
listingCategory: 1,
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
updatedAt: 1,
|
updatedAt: 1,
|
||||||
ownerAdminId: {
|
ownerAdminId: {
|
||||||
@@ -101,6 +109,7 @@ export class MarketplaceRepository {
|
|||||||
username: '$ownerAdmin.username',
|
username: '$ownerAdmin.username',
|
||||||
email: '$ownerAdmin.email',
|
email: '$ownerAdmin.email',
|
||||||
avatar: '$ownerAdmin.avatar',
|
avatar: '$ownerAdmin.avatar',
|
||||||
|
shopName: '$ownerAdmin.shopName',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -132,4 +141,147 @@ export class MarketplaceRepository {
|
|||||||
async count(filter: FilterQuery<InstrumentDocument>): Promise<number> {
|
async count(filter: FilterQuery<InstrumentDocument>): Promise<number> {
|
||||||
return this.instrumentModel.countDocuments(filter).exec();
|
return this.instrumentModel.countDocuments(filter).exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createRepairShop(
|
||||||
|
ownerAdminId: string,
|
||||||
|
payload: Partial<RepairShop>,
|
||||||
|
): Promise<RepairShopDocument> {
|
||||||
|
return this.repairShopModel.create({
|
||||||
|
...payload,
|
||||||
|
ownerAdminId: new Types.ObjectId(ownerAdminId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findRepairShopByOwnerAdminId(ownerAdminId: string): Promise<RepairShopDocument | null> {
|
||||||
|
if (!Types.ObjectId.isValid(ownerAdminId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.repairShopModel
|
||||||
|
.findOne({ ownerAdminId: new Types.ObjectId(ownerAdminId) })
|
||||||
|
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findRepairShopById(repairShopId: string): Promise<RepairShopDocument | null> {
|
||||||
|
if (!Types.ObjectId.isValid(repairShopId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.repairShopModel
|
||||||
|
.findById(repairShopId)
|
||||||
|
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRepairShopById(
|
||||||
|
repairShopId: string,
|
||||||
|
payload: UpdateQuery<RepairShopDocument>,
|
||||||
|
): Promise<RepairShopDocument | null> {
|
||||||
|
if (!Types.ObjectId.isValid(repairShopId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.repairShopModel
|
||||||
|
.findByIdAndUpdate(repairShopId, payload, { new: true })
|
||||||
|
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRepairShopById(repairShopId: string): Promise<RepairShopDocument | null> {
|
||||||
|
if (!Types.ObjectId.isValid(repairShopId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.repairShopModel.findByIdAndDelete(repairShopId).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findManyRepairShops(
|
||||||
|
filter: FilterQuery<RepairShopDocument>,
|
||||||
|
skip: number,
|
||||||
|
limit: number,
|
||||||
|
sort: Record<string, 1 | -1> = { createdAt: -1 },
|
||||||
|
): Promise<RepairShopDocument[]> {
|
||||||
|
return this.repairShopModel
|
||||||
|
.find(filter)
|
||||||
|
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
|
||||||
|
.sort(sort)
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findManyRepairShopsPublic(
|
||||||
|
filter: FilterQuery<RepairShopDocument>,
|
||||||
|
skip: number,
|
||||||
|
limit: number,
|
||||||
|
sort: Record<string, 1 | -1> = { createdAt: -1 },
|
||||||
|
): Promise<Record<string, unknown>[]> {
|
||||||
|
return this.repairShopModel
|
||||||
|
.aggregate([
|
||||||
|
{ $match: filter },
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: 'users',
|
||||||
|
localField: 'ownerAdminId',
|
||||||
|
foreignField: '_id',
|
||||||
|
as: 'ownerAdmin',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $unwind: '$ownerAdmin' },
|
||||||
|
{ $match: { 'ownerAdmin.isDisabled': false } },
|
||||||
|
{ $sort: sort },
|
||||||
|
{ $skip: skip },
|
||||||
|
{ $limit: limit },
|
||||||
|
{
|
||||||
|
$project: {
|
||||||
|
_id: 1,
|
||||||
|
name: 1,
|
||||||
|
description: 1,
|
||||||
|
services: 1,
|
||||||
|
phone: 1,
|
||||||
|
whatsapp: 1,
|
||||||
|
imageUrls: 1,
|
||||||
|
location: 1,
|
||||||
|
latitude: 1,
|
||||||
|
longitude: 1,
|
||||||
|
isActive: 1,
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
ownerAdminId: {
|
||||||
|
_id: '$ownerAdmin._id',
|
||||||
|
name: '$ownerAdmin.name',
|
||||||
|
username: '$ownerAdmin.username',
|
||||||
|
email: '$ownerAdmin.email',
|
||||||
|
avatar: '$ownerAdmin.avatar',
|
||||||
|
shopName: '$ownerAdmin.shopName',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async countRepairShopsPublic(filter: FilterQuery<RepairShopDocument>): Promise<number> {
|
||||||
|
const rows = await this.repairShopModel
|
||||||
|
.aggregate([
|
||||||
|
{ $match: filter },
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: 'users',
|
||||||
|
localField: 'ownerAdminId',
|
||||||
|
foreignField: '_id',
|
||||||
|
as: 'ownerAdmin',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $unwind: '$ownerAdmin' },
|
||||||
|
{ $match: { 'ownerAdmin.isDisabled': false } },
|
||||||
|
{ $count: 'count' },
|
||||||
|
])
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
return rows[0]?.count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async countRepairShops(filter: FilterQuery<RepairShopDocument>): Promise<number> {
|
||||||
|
return this.repairShopModel.countDocuments(filter).exec();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
لم تُعرض بعض الملفات لأن الكثير من الملفات تغيرت في هذا الاختلاف إظهار المزيد
المرجع في مشكلة جديدة
حظر مستخدم