diff --git a/.env.example b/.env.example index d571624..850b020 100644 --- a/.env.example +++ b/.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 PORT=4000 HOST=0.0.0.0 PUBLIC_BASE_URL=http://localhost:4000 RESPONSE_ENVELOPE_ENABLED=false 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 MONGODB_URI=mongodb://127.0.0.1:27017/oudelaa 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_EXPIRES_IN=30d BCRYPT_SALT_ROUNDS=12 +REFRESH_TOKEN_HASH_SECRET= PASSWORD_RESET_CODE_EXPIRES_MINUTES=10 PASSWORD_RESET_MAX_ATTEMPTS=5 PASSWORD_RESET_TOKEN_SECRET= @@ -21,9 +25,43 @@ SWAGGER_TITLE=Oudelaa API SWAGGER_DESCRIPTION=Social media backend API documentation SWAGGER_VERSION=1.0.0 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_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 EMAIL_ENABLED=false EMAIL_SMTP_HOST=smtp.gmail.com @@ -33,6 +71,11 @@ EMAIL_SMTP_USER= EMAIL_SMTP_PASS= EMAIL_FROM_NAME=Oudelaa 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 @@ -41,4 +84,3 @@ SUPERADMIN_ACCESS_SECRET=change_me_superadmin_access_secret SUPERADMIN_ACCESS_EXPIRES_IN=15m SUPERADMIN_REFRESH_SECRET=change_me_superadmin_refresh_secret SUPERADMIN_REFRESH_EXPIRES_IN=30d - diff --git a/.gitignore b/.gitignore index d3f5c1d..b265bf9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,9 @@ uploads npm-debug.log* yarn-debug.log* yarn-error.log* +*.log +*.err.log + +# Local workspace +.vscode/ +oudelaa_dashboard/ diff --git a/PERFORMANCE_TESTING.md b/PERFORMANCE_TESTING.md new file mode 100644 index 0000000..9ee9dca --- /dev/null +++ b/PERFORMANCE_TESTING.md @@ -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 diff --git a/README.md b/README.md index 825b7cc..c6d5b9f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ # Oudelaa Backend 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). diff --git a/SCALING_SETUP.md b/SCALING_SETUP.md new file mode 100644 index 0000000..a2772ae --- /dev/null +++ b/SCALING_SETUP.md @@ -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:// +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 diff --git a/docs/FRONTEND_INTEGRATION.md b/docs/FRONTEND_INTEGRATION.md new file mode 100644 index 0000000..47999c8 --- /dev/null +++ b/docs/FRONTEND_INTEGRATION.md @@ -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 ` + +## 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. diff --git a/package-lock.json b/package-lock.json index 93ed528..be25494 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "1.0.0", "license": "UNLICENSED", "dependencies": { + "@aws-sdk/client-s3": "^3.1041.0", + "@aws-sdk/lib-storage": "^3.1041.0", "@nestjs/common": "^10.4.0", "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.4.0", @@ -19,11 +21,14 @@ "@nestjs/platform-socket.io": "^10.4.0", "@nestjs/swagger": "^8.1.0", "@nestjs/websockets": "^10.4.0", + "@socket.io/redis-adapter": "^8.3.0", "@types/passport-google-oauth20": "^2.0.17", "bcrypt": "^5.1.1", + "bullmq": "^5.76.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "google-auth-library": "^10.6.2", + "ioredis": "^5.10.1", "joi": "^17.13.3", "mongoose": "^8.6.0", "nodemailer": "^8.0.5", @@ -219,6 +224,893 @@ "tslib": "^2.1.0" } }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1041.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1041.0.tgz", + "integrity": "sha512-sQV14bIqslnBHuSlLMD+fc3pH+ajop6vnrFlJ4wM4JDqcYwVik4O+9srnZUrkesFw5y+CN0GfOQ06CAgtC4mjQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/credential-provider-node": "^3.972.39", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.10", + "@aws-sdk/middleware-expect-continue": "^3.972.10", + "@aws-sdk/middleware-flexible-checksums": "^3.974.16", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-location-constraint": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-sdk-s3": "^3.972.37", + "@aws-sdk/middleware-ssec": "^3.972.10", + "@aws-sdk/middleware-user-agent": "^3.972.38", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/signature-v4-multi-region": "^3.996.25", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.24", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/eventstream-serde-browser": "^4.2.14", + "@smithy/eventstream-serde-config-resolver": "^4.3.14", + "@smithy/eventstream-serde-node": "^4.2.14", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-blob-browser": "^4.2.15", + "@smithy/hash-node": "^4.2.14", + "@smithy/hash-stream-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/md5-js": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.7", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", + "@smithy/util-stream": "^4.5.25", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.974.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.8.tgz", + "integrity": "sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.22", + "@smithy/core": "^3.23.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.7.tgz", + "integrity": "sha512-QUagVVBbC8gODCF6e1aV0mE2TXWB9Opz4k8EJFdNrujUVQm5R4AjJa1mpOqzwOuROBzqJU9zawzig7M96L8Ejg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.34.tgz", + "integrity": "sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.36.tgz", + "integrity": "sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/types": "^3.973.8", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/util-stream": "^4.5.25", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.38.tgz", + "integrity": "sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/credential-provider-env": "^3.972.34", + "@aws-sdk/credential-provider-http": "^3.972.36", + "@aws-sdk/credential-provider-login": "^3.972.38", + "@aws-sdk/credential-provider-process": "^3.972.34", + "@aws-sdk/credential-provider-sso": "^3.972.38", + "@aws-sdk/credential-provider-web-identity": "^3.972.38", + "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/types": "^3.973.8", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.38.tgz", + "integrity": "sha512-g1NosS8qe4OF++G2UFCM5ovSkgipC7YYor5KCWatG0UoMSO5YFj9C8muePlyVmOBV/WTI16Jo3/s1NUo/o1Bww==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.39.tgz", + "integrity": "sha512-HEswDQyxUtadoZ/bJsPPENHg7R0Lzym5LuMksJeHvqhCOpP+rtkDLKI4/ZChH4w3cf5kG8n6bZuI8PzajoiqMg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.34", + "@aws-sdk/credential-provider-http": "^3.972.36", + "@aws-sdk/credential-provider-ini": "^3.972.38", + "@aws-sdk/credential-provider-process": "^3.972.34", + "@aws-sdk/credential-provider-sso": "^3.972.38", + "@aws-sdk/credential-provider-web-identity": "^3.972.38", + "@aws-sdk/types": "^3.973.8", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.34.tgz", + "integrity": "sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.38.tgz", + "integrity": "sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/token-providers": "3.1041.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.38.tgz", + "integrity": "sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/lib-storage": { + "version": "3.1041.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.1041.0.tgz", + "integrity": "sha512-kDJVrZTzRdeFFEppKQVbXzXOCwEzxUsBGIblH0OaeJbaOV5//ZphqxhznMd3QWckqicbIuShJWkmnQeBt+VmBw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "buffer": "5.6.0", + "events": "3.3.0", + "stream-browserify": "3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-s3": "^3.1041.0" + } + }, + "node_modules/@aws-sdk/lib-storage/node_modules/buffer": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", + "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" + } + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.10.tgz", + "integrity": "sha512-Vbc2frZH7wXlMNd+ZZSXUEs/l1Sv8Jj4zUnIfwrYF5lwaLdXHZ9xx4U3rjUcaye3HRhFVc+E5DbBxpRAbB16BA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-config-provider": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.10.tgz", + "integrity": "sha512-2Yn0f1Qiq/DjxYR3wfI3LokXnjOhFM7Ssn4LTdFDIxRMCE6I32MAsVnhPX1cUZsuVA9tiZtwwhlSLAtFGxAZlQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.974.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.16.tgz", + "integrity": "sha512-6ru8doI0/XzszqLIPXf0E/V7HhAw1Pu94010XCKYtBUfD0LxF0BuOzrUf8OQGR6j2o6wgKTHUniOmndQycHwCA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/crc64-nvme": "^3.972.7", + "@aws-sdk/types": "^3.973.8", + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-stream": "^4.5.25", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.10.tgz", + "integrity": "sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.10.tgz", + "integrity": "sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.10.tgz", + "integrity": "sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.11.tgz", + "integrity": "sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.37.tgz", + "integrity": "sha512-Km7M+i8DrLArVzrid1gfxeGhYHBd3uxvE77g0s5a52zPSVosxzQBnJ0gwWb6NIp/DOk8gsBMhi7V+cpJG0ndTA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/core": "^3.23.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-stream": "^4.5.25", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.10.tgz", + "integrity": "sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.38.tgz", + "integrity": "sha512-iz+B29TXcAZsJpwB+AwG/TTGA5l/VnmMZ2UxtiySOZjI6gCdmviXPwdgzcmuazMy16rXoPY4mYCGe7zdNKfx5A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@smithy/core": "^3.23.17", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-retry": "^4.3.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.6.tgz", + "integrity": "sha512-WBDnqatJl+kGObpfmfSxqnXeYTu3Me8wx8WCtvoxX3pfWrrTv8I4WTMSSs7PZqcRcVh8WeUKMgGFjMG+52SR1w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-user-agent": "^3.972.38", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/signature-v4-multi-region": "^3.996.25", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.24", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.7", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.13.tgz", + "integrity": "sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/config-resolver": "^4.4.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.25.tgz", + "integrity": "sha512-+CMIt3e1VzlklAECmG+DtP1sV8iKq25FuA0OKpnJ4KA0kxUtd7CgClY7/RU6VzJBQwbN4EJ9Ue6plvqx1qGadw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.37", + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1041.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1041.0.tgz", + "integrity": "sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", + "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz", + "integrity": "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-endpoints": "^3.4.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.10.tgz", + "integrity": "sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.973.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.24.tgz", + "integrity": "sha512-ZWwlkjcIp7cEL8ZfTpTAPNkwx25p7xol0xlKoWVVf22+nsjwmLcHYtTPjIV1cSpmB/b6DaK4cb1fSkvCXHgRdw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "^3.972.38", + "@aws-sdk/types": "^3.973.8", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-config-provider": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.22.tgz", + "integrity": "sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==", + "license": "Apache-2.0", + "dependencies": { + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -1129,6 +2021,12 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1781,6 +2679,84 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@nestjs/cli": { "version": "10.4.9", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz", @@ -2263,6 +3239,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@nuxtjs/opencollective": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz", @@ -2370,12 +3358,780 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz", + "integrity": "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.3.tgz", + "integrity": "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.17", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.17.tgz", + "integrity": "sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.23.17", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.17.tgz", + "integrity": "sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-stream": "^4.5.25", + "@smithy/util-utf8": "^4.2.2", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.14.tgz", + "integrity": "sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.14.tgz", + "integrity": "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.1", + "@smithy/util-hex-encoding": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.14.tgz", + "integrity": "sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.14.tgz", + "integrity": "sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.14.tgz", + "integrity": "sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.14.tgz", + "integrity": "sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.2.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.17", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.17.tgz", + "integrity": "sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.14", + "@smithy/querystring-builder": "^4.2.14", + "@smithy/types": "^4.14.1", + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.15", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.15.tgz", + "integrity": "sha512-0PJ4Al3fg2nM4qKrAIxyNcApgqHAXcBkN8FeizOz69z0rb26uZ6lMESYtxegaTlXB5Hj84JfwMPavMrwDMjucA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.2.2", + "@smithy/chunked-blob-reader-native": "^4.2.3", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.14.tgz", + "integrity": "sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.14.tgz", + "integrity": "sha512-tw4GANWkZPb6+BdD4Fgucqzey2+r73Z/GRo9zklsCdwrnxxumUV83ZIaBDdudV4Ylazw3EPTiJZhpX42105ruQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.14.tgz", + "integrity": "sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/md5-js": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.14.tgz", + "integrity": "sha512-V2v0vx+h0iUSNG1Alt+GNBMSLGCrl9iVsdd+Ap67HPM9PN479x12V8LkuMoKImNZxn3MXeuyUjls+/7ZACZghA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.14.tgz", + "integrity": "sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.32", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.32.tgz", + "integrity": "sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.17", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-middleware": "^4.2.14", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.7.tgz", + "integrity": "sha512-bRt6ZImqVSeTk39Nm81K20ObIiAZ3WefY7G6+iz/0tZjs4dgRRjvRX2sgsH+zi6iDCRR/aQvQofLKxxz4rPBZg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/service-error-classification": "^4.3.1", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.20", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.20.tgz", + "integrity": "sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.17", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.14.tgz", + "integrity": "sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.14", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.14.tgz", + "integrity": "sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.6.1.tgz", + "integrity": "sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.14", + "@smithy/querystring-builder": "^4.2.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.14.tgz", + "integrity": "sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.14.tgz", + "integrity": "sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.14.tgz", + "integrity": "sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "@smithy/util-uri-escape": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.14.tgz", + "integrity": "sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.3.1.tgz", + "integrity": "sha512-aUQuDGh760ts/8MU+APjIZhlLPKhIIfqyzZaJikLEIMrdxFvxuLYD0WxWzaYWpmLbQlXDe9p7EWM3HsBe0K6Gw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.9.tgz", + "integrity": "sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.14.tgz", + "integrity": "sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-uri-escape": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.12.13", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.13.tgz", + "integrity": "sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.17", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-stream": "^4.5.25", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", + "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.14.tgz", + "integrity": "sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", + "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", + "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", + "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", + "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.49", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.49.tgz", + "integrity": "sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.54", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.54.tgz", + "integrity": "sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.17", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.4.2.tgz", + "integrity": "sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.14.tgz", + "integrity": "sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.8.tgz", + "integrity": "sha512-LUIxbTBi+OpvXpg91poGA6BdyoleMDLnfXjVDqyi2RvZmTveY5loE/FgYUBCR5LU2BThW2SoZRh8dTIIy38IPw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.3.1", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.25", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.25.tgz", + "integrity": "sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/types": "^4.14.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", + "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.3.0.tgz", + "integrity": "sha512-JyjYmLAfS+pdxF92o4yLgEoy0zhayKTw73FU1aofLWwLcJw7iSqIY2exGmMTrl/lmZugP5p/zxdFSippJDfKWA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", + "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "license": "MIT" }, + "node_modules/@socket.io/redis-adapter": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@socket.io/redis-adapter/-/redis-adapter-8.3.0.tgz", + "integrity": "sha512-ly0cra+48hDmChxmIpnESKrc94LjRL80TEmZVscuQ/WWkRP81nNj8W8cCGMqbI4L6NCuAaPRSzZF1a9GlAxxnA==", + "license": "MIT", + "dependencies": { + "debug": "~4.3.1", + "notepack.io": "~3.0.1", + "uid2": "1.0.0" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "socket.io-adapter": "^2.5.4" + } + }, + "node_modules/@socket.io/redis-adapter/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@socket.io/redis-adapter/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@socket.io/redis-adapter/node_modules/uid2": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-1.0.0.tgz", + "integrity": "sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/@tokenizer/inflate": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", @@ -3606,6 +5362,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -3733,6 +5495,23 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/bullmq": { + "version": "5.76.5", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.76.5.tgz", + "integrity": "sha512-2OKJP2+ckc+TygsWdxxeZYYgM9xYnVXgIAx+perflhamZ6FEBu/cSrvpqM8++fJI5OgsIFLfxA9UO7BDZ74Inw==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.10.1", + "msgpackr": "1.11.12", + "node-abort-controller": "3.1.1", + "semver": "7.7.4", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=12.22.0" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -4056,6 +5835,15 @@ "node": ">=0.8" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -4310,6 +6098,18 @@ "dev": true, "license": "MIT" }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4422,6 +6222,15 @@ "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", "license": "MIT" }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -5031,7 +6840,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" @@ -5201,6 +7009,42 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", + "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz", + "integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.5", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -6250,6 +8094,53 @@ "node": ">=12.0.0" } }, + "node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -7465,12 +9356,24 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -7548,6 +9451,15 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.8", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", @@ -7943,6 +9855,37 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/msgpackr": { + "version": "1.11.12", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz", + "integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, "node_modules/multer": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", @@ -7995,7 +9938,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", - "dev": true, "license": "MIT" }, "node_modules/node-addon-api": { @@ -8076,6 +10018,21 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -8124,6 +10081,12 @@ "node": ">=0.10.0" } }, + "node_modules/notepack.io": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-3.0.1.tgz", + "integrity": "sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==", + "license": "MIT" + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -8444,6 +10407,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -8831,6 +10809,27 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -9331,6 +11330,7 @@ "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", "license": "MIT", + "peer": true, "dependencies": { "debug": "~4.4.1", "ws": "~8.18.3" @@ -9488,6 +11488,12 @@ "node": ">=8" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -9497,6 +11503,16 @@ "node": ">= 0.8" } }, + "node_modules/stream-browserify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", + "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.4", + "readable-stream": "^3.5.0" + } + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -9617,6 +11633,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", + "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/strtok3": { "version": "10.3.5", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", diff --git a/package.json b/package.json index f6bff5f..b851fa9 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,14 @@ "lint": "eslint \"src/**/*.ts\" --fix", "test": "jest", "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": { + "@aws-sdk/client-s3": "^3.1041.0", + "@aws-sdk/lib-storage": "^3.1041.0", "@nestjs/common": "^10.4.0", "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.4.0", @@ -25,11 +30,14 @@ "@nestjs/platform-socket.io": "^10.4.0", "@nestjs/swagger": "^8.1.0", "@nestjs/websockets": "^10.4.0", + "@socket.io/redis-adapter": "^8.3.0", "@types/passport-google-oauth20": "^2.0.17", "bcrypt": "^5.1.1", + "bullmq": "^5.76.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "google-auth-library": "^10.6.2", + "ioredis": "^5.10.1", "joi": "^17.13.3", "mongoose": "^8.6.0", "nodemailer": "^8.0.5", diff --git a/postman/Oudelaa-Auth-Users-Posts.postman_collection.json b/postman/Oudelaa-Auth-Users-Posts.postman_collection.json index 4dbd597..2515af9 100644 --- a/postman/Oudelaa-Auth-Users-Posts.postman_collection.json +++ b/postman/Oudelaa-Auth-Users-Posts.postman_collection.json @@ -1,2745 +1,5714 @@ { - "info": { - "name": "Oudelaa Auth Users Posts", - "description": "Postman collection for auth, users, posts modules.", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" - }, - "item": [ - { - "name": "Auth", - "item": [ - { - "name": "Register Basic", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": "{{baseUrl}}/auth/register-basic", - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"{{registerEmail}}\",\n \"password\": \"StrongPass123!\",\n \"confirmPassword\": \"StrongPass123!\"\n}" - } - }, - "event": [ - { - "listen": "prerequest", - "script": { - "type": "text/javascript", - "exec": [ - "const ts = Date.now();", - "if (!pm.environment.get('registerEmail')) { pm.environment.set('registerEmail', `test_${ts}@example.com`); }" - ] - } - }, - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 201', function () { pm.response.to.have.status(201); });", - "const json = pm.response.json();", - "pm.expect(json.message).to.exist;", - "pm.expect(json.email).to.exist;", - "if (json.debugCode) { pm.environment.set('emailVerificationCode', json.debugCode); }" - ] - } - } - ] - }, - { - "name": "Register", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": "{{baseUrl}}/auth/register", - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Test User\",\n \"stageName\": \"Artist One\",\n \"username\": \"{{registerUsername}}\",\n \"email\": \"{{registerEmail}}\",\n \"password\": \"StrongPass123!\",\n \"confirmPassword\": \"StrongPass123!\",\n \"musicRoles\": [\"singer\", \"composer\"],\n \"musicGenres\": [\"Tarab\", \"Pop\"],\n \"favoriteInstruments\": [\"Oud\", \"Piano\"],\n \"favoriteMaqamat\": [\"Bayati\", \"Rast\"],\n \"location\": \"Riyadh, Saudi Arabia\",\n \"latitude\": 24.7136,\n \"longitude\": 46.6753,\n \"isPrivate\": false\n}" - } - }, - "event": [ - { - "listen": "prerequest", - "script": { - "type": "text/javascript", - "exec": [ - "const ts = Date.now();", - "if (!pm.environment.get('registerEmail')) { pm.environment.set('registerEmail', `test_${ts}@example.com`); }", - "if (!pm.environment.get('registerUsername')) { pm.environment.set('registerUsername', `test_user_${ts}`); }" - ] - } - }, - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 201', function () { pm.response.to.have.status(201); });", - "const json = pm.response.json();", - "pm.expect(json.message).to.exist;", - "pm.expect(json.email).to.exist;", - "if (json.debugCode) { pm.environment.set('emailVerificationCode', json.debugCode); }" - ] - } - } - ] - }, - { - "name": "Login", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": "{{baseUrl}}/auth/login", - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"{{registerEmail}}\",\n \"password\": \"StrongPass123!\"\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.user).to.exist;", - "const uid = json.user._id || json.user.id;", - "pm.environment.set('accessToken', json.accessToken);", - "pm.environment.set('refreshToken', json.refreshToken);", - "pm.environment.set('userId', uid);", - "pm.environment.set('currentUserId', uid);" - ] - } - } - ] - }, - { - "name": "Target User Login", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": "{{baseUrl}}/auth/login", - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"{{targetLoginEmail}}\",\n \"password\": \"{{targetLoginPassword}}\"\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.user).to.exist;", - "const uid = json.user._id || json.user.id;", - "pm.environment.set('targetAccessToken', json.accessToken);", - "pm.environment.set('targetRefreshToken', json.refreshToken);", - "pm.environment.set('targetUserId', uid);" - ] - } - } - ] - }, - { - "name": "Admin Login", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": "{{baseUrl}}/auth/login", - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"{{adminEmail}}\",\n \"password\": \"{{adminPassword}}\"\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.environment.set('adminAccessToken', json.accessToken);", - "pm.environment.set('adminRefreshToken', json.refreshToken);", - "pm.environment.set('adminUserId', (json.user && (json.user._id || json.user.id)) || pm.environment.get('adminUserId'));" - ] - } - } - ] - }, - { - "name": "Refresh", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": "{{baseUrl}}/auth/refresh", - "body": { - "mode": "raw", - "raw": "{\n \"refreshToken\": \"{{refreshToken}}\"\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.user).to.exist;", - "pm.environment.set('accessToken', json.accessToken);", - "pm.environment.set('refreshToken', json.refreshToken);", - "pm.environment.set('userId', json.user._id || json.user.id);" - ] - } - } - ] - }, - { - "name": "Logout", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": "{{baseUrl}}/auth/logout", - "body": { - "mode": "raw", - "raw": "{\n \"refreshToken\": \"{{refreshToken}}\"\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.message).to.eql('Logged out successfully');" - ] - } - } - ] - }, - { - "name": "SuperAdmin", - "item": [ - { - "name": "SuperAdmin Login", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": "{{baseUrl}}/auth/superadmin/login", - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"admin@oudelaa.com\",\n \"password\": \"SuperAdminStrongPass123!\"\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.accessToken).to.exist;", - "pm.expect(json.refreshToken).to.exist;", - "pm.environment.set('superAdminAccessToken', json.accessToken);", - "pm.environment.set('superAdminRefreshToken', json.refreshToken);" - ] - } - } - ] - }, - { - "name": "SuperAdmin Refresh", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": "{{baseUrl}}/auth/superadmin/refresh", - "body": { - "mode": "raw", - "raw": "{\n \"refreshToken\": \"{{superAdminRefreshToken}}\"\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.accessToken).to.exist;", - "pm.expect(json.refreshToken).to.exist;", - "pm.environment.set('superAdminAccessToken', json.accessToken);", - "pm.environment.set('superAdminRefreshToken', json.refreshToken);" - ] - } - } - ] - }, - { - "name": "SuperAdmin Logout", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": "{{baseUrl}}/auth/superadmin/logout", - "body": { - "mode": "raw", - "raw": "{\n \"refreshToken\": \"{{superAdminRefreshToken}}\"\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.message).to.eql('Superadmin logged out successfully');" - ] - } - } - ] - } - ] - }, - { - "name": "Google OAuth Start (Browser)", - "request": { - "method": "GET", - "header": [], - "url": "{{baseUrl}}/auth/google", - "description": "Open this request in browser flow to start Google OAuth redirect." - } - }, - { - "name": "Google OAuth Callback (Browser Redirect)", - "request": { - "method": "GET", - "header": [], - "url": "{{baseUrl}}/auth/google/callback", - "description": "Callback endpoint is called by Google redirect after browser login. Do not call directly from Postman." - } - }, - { - "name": "Google Token Login (Mobile - Recommended)", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": "{{baseUrl}}/auth/google/token", - "description": "Mobile-style Google login (Instagram-like): send Google idToken from app SDK.", - "body": { - "mode": "raw", - "raw": "{\n \"idToken\": \"{{googleIdToken}}\"\n}" - } - }, - "event": [ - { - "listen": "prerequest", - "script": { - "type": "text/javascript", - "exec": [ - "const idToken = (pm.environment.get('googleIdToken') || '').trim();", - "if (!idToken) {", - " throw new Error('googleIdToken is empty. Paste Google idToken from Flutter/Google SDK into environment variable googleIdToken.');", - "}" - ] - } - }, - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.accessToken).to.exist;", - "pm.expect(json.refreshToken).to.exist;", - "pm.expect(json.user).to.exist;", - "const uid = json.user._id || json.user.id;", - "pm.environment.set('accessToken', json.accessToken);", - "pm.environment.set('refreshToken', json.refreshToken);", - "pm.environment.set('userId', uid);", - "pm.environment.set('currentUserId', uid);" - ] - } - } - ] - }, - { - "name": "List My Sessions", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/auth/sessions" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.items).to.be.an('array');", - "if (json.items && json.items.length > 0) { pm.environment.set('sessionJti', json.items[0].jti); }" - ] - } - } - ] - }, - { - "name": "Revoke My Session", - "request": { - "method": "POST", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/auth/sessions/{{sessionJti}}/revoke" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.success).to.eql(true);" - ] - } - } - ] - }, - { - "name": "Forgot Password", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": "{{baseUrl}}/auth/forgot-password", - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"{{registerEmail}}\"\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.message).to.exist;", - "if (json.debugCode) { pm.environment.set('resetCode', json.debugCode); }" - ] - } - } - ] - }, - { - "name": "Verify Reset Code", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": "{{baseUrl}}/auth/verify-reset-code", - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"{{registerEmail}}\",\n \"code\": \"{{resetCode}}\"\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.resetToken).to.exist;", - "pm.environment.set('resetToken', json.resetToken);" - ] - } - } - ] - }, - { - "name": "Reset Password", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": "{{baseUrl}}/auth/reset-password", - "body": { - "mode": "raw", - "raw": "{\n \"resetToken\": \"{{resetToken}}\",\n \"newPassword\": \"{{newPassword}}\",\n \"confirmPassword\": \"{{newPassword}}\"\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.message).to.eql('Password reset successfully');" - ] - } - } - ] - }, - { - "name": "Send Email Verification", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": "{{baseUrl}}/auth/send-email-verification", - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"{{registerEmail}}\"\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.message).to.exist;", - "if (json.debugCode) { pm.environment.set('emailVerificationCode', json.debugCode); }" - ] - } - } - ] - }, - { - "name": "Verify Email", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": "{{baseUrl}}/auth/verify-email", - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"{{registerEmail}}\",\n \"code\": \"{{emailVerificationCode}}\"\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.message).to.exist;", - "if (json.accessToken) { pm.environment.set('accessToken', json.accessToken); }", - "if (json.refreshToken) { pm.environment.set('refreshToken', json.refreshToken); }", - "if (json.user) { const uid = json.user._id || json.user.id; pm.environment.set('userId', uid); pm.environment.set('currentUserId', uid); }" - ] - } - } - ] - } - ] - }, - { - "name": "Users", - "item": [ - { - "name": "SuperAdmin List Admins", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{superAdminAccessToken}}" - } - ], - "url": "{{baseUrl}}/users/admin/admins?page=1&limit=20" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.items).to.be.an('array');", - "if (json.items && json.items.length > 0) { pm.environment.set('adminUserId', json.items[0]._id || json.items[0].id); }" - ] - } - } - ] - }, - { - "name": "SuperAdmin Update Admin", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{superAdminAccessToken}}" - } - ], - "url": "{{baseUrl}}/users/admin/admins/{{adminUserId}}", - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Store Admin Updated\",\n \"stageName\": \"Music Store Owner\",\n \"bio\": \"Admin managing marketplace tools\",\n \"location\": \"Riyadh, Saudi Arabia\"\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json).to.have.property('role');", - "pm.expect(json.role).to.eql('admin');" - ] - } - } - ] - }, - { - "name": "SuperAdmin Delete Admin", - "request": { - "method": "DELETE", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{superAdminAccessToken}}" - } - ], - "url": "{{baseUrl}}/users/admin/admins/{{adminUserId}}" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.message).to.eql('Admin deleted successfully');" - ] - } - } - ] - }, - { - "name": "SuperAdmin Create Admin", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{superAdminAccessToken}}" - } - ], - "url": "{{baseUrl}}/users/admin/create-admin", - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Store Admin\",\n \"username\": \"{{adminUsername}}\",\n \"email\": \"{{adminEmail}}\",\n \"password\": \"{{adminPassword}}\",\n \"confirmPassword\": \"{{adminPassword}}\"\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 201', function () { pm.response.to.have.status(201); });", - "const json = pm.response.json();", - "pm.environment.set('adminUserId', json._id || json.id);" - ] - } - } - ] - }, - { - "name": "SuperAdmin Set User Role", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{superAdminAccessToken}}" - } - ], - "url": "{{baseUrl}}/users/admin/{{adminUserId}}/role", - "body": { - "mode": "raw", - "raw": "{\n \"role\": \"admin\"\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.role).to.eql('admin');" - ] - } - } - ] - }, - { - "name": "Search Users", - "request": { - "method": "GET", - "url": "{{baseUrl}}/users?page=1&limit=10", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ] - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "const currentUserId = pm.environment.get('currentUserId');", - "if (json.items && json.items.length > 0) {", - " const target = json.items.find(u => (u._id || u.id) !== currentUserId) || json.items[0];", - " const targetId = target._id || target.id;", - " pm.environment.set('targetUserId', targetId);", - "}" - ] - } - } - ] - }, - { - "name": "Profile Setup", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/users/me/profile-setup", - "body": { - "mode": "formdata", - "formdata": [ - { - "key": "stageName", - "value": "Artist One", - "type": "text" - }, - { - "key": "avatarFile", - "type": "file", - "src": [] - }, - { - "key": "bio", - "value": "Short bio", - "type": "text" - }, - { - "key": "location", - "value": "Riyadh, Saudi Arabia", - "type": "text" - }, - { - "key": "latitude", - "value": "24.7136", - "type": "text" - }, - { - "key": "longitude", - "value": "46.6753", - "type": "text" - } - ] - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.location).to.eql('Riyadh, Saudi Arabia');", - "pm.expect(json.latitude).to.eql(24.7136);", - "pm.expect(json.longitude).to.eql(46.6753);", - "if (json.avatar) { pm.expect(json.avatar).to.be.a('string'); }" - ] - } - } - ] - }, - { - "name": "Music Setup", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/users/me/music-setup", - "body": { - "mode": "raw", - "raw": "{\n \"musicRoles\": [\"instrumentalist\", \"composer\"],\n \"musicGenres\": [\"Tarab\", \"Pop\"],\n \"experienceLevel\": \"intermediate\",\n \"favoriteInstruments\": [\"Oud\", \"Piano\"],\n \"favoriteMaqamat\": [\"Bayati\", \"Rast\"]\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.musicRoles).to.include('instrumentalist');", - "pm.expect(json.musicRoles).to.include('composer');", - "pm.expect(json.experienceLevel).to.eql('intermediate');", - "pm.expect(json).to.not.have.property('isInstrumentalist');", - "pm.expect(json).to.not.have.property('isSinger');", - "pm.expect(json).to.not.have.property('isComposer');", - "pm.expect(json).to.not.have.property('isLyricist');" - ] - } - } - ] - }, - { - "name": "Update Me", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/users/me", - "body": { - "mode": "raw", - "raw": "{\n \"bio\": \"Updated from Postman\",\n \"avatar\": \"https://cdn.example.com/avatar.jpg\",\n \"stageName\": \"Artist One Updated\",\n \"musicGenres\": [\"Tarab\", \"Khaleeji\"],\n \"favoriteInstruments\": [\"Oud\"],\n \"favoriteMaqamat\": [\"Hijaz\"],\n \"location\": \"Jeddah, Saudi Arabia\"\n}" - } - } - }, - { - "name": "Get User By Id", - "request": { - "method": "GET", - "url": "{{baseUrl}}/users/{{userId}}", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ] - } - }, - { - "name": "Admin Get Users", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{superAdminAccessToken}}" - } - ], - "url": "{{baseUrl}}/users/admin?page=1&limit=10" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "if (json.items && json.items.length > 0) { pm.environment.set('adminUserId', json.items[0]._id || json.items[0].id); }" - ] - } - } - ] - }, - { - "name": "Admin Get User By Id", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{superAdminAccessToken}}" - } - ], - "url": "{{baseUrl}}/users/admin/{{adminUserId}}" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json._id || json.id).to.exist;" - ] - } - } - ] - }, - { - "name": "Admin Update User", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{superAdminAccessToken}}" - } - ], - "url": "{{baseUrl}}/users/admin/{{adminUserId}}", - "body": { - "mode": "raw", - "raw": "{\n \"stageName\": \"Updated by SuperAdmin\",\n \"bio\": \"Profile updated by admin\"\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.stageName).to.eql('Updated by SuperAdmin');" - ] - } - } - ] - }, - { - "name": "Admin Disable User", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{superAdminAccessToken}}" - } - ], - "url": "{{baseUrl}}/users/admin/{{adminUserId}}/disable", - "body": { - "mode": "raw", - "raw": "{\n \"reason\": \"Violation of community guidelines\"\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.isDisabled).to.eql(true);" - ] - } - } - ] - }, - { - "name": "Admin Enable User", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{superAdminAccessToken}}" - } - ], - "url": "{{baseUrl}}/users/admin/{{adminUserId}}/enable" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.isDisabled).to.eql(false);" - ] - } - } - ] - }, - { - "name": "Admin Delete User", - "request": { - "method": "DELETE", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{superAdminAccessToken}}" - } - ], - "url": "{{baseUrl}}/users/admin/{{adminUserId}}" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.message).to.eql('User deleted successfully');" - ] - } - } - ] - } - ] - }, - { - "name": "Posts", - "item": [ - { - "name": "Create Post", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/posts", - "body": { - "mode": "raw", - "raw": "{\n \"content\": \"First text post\",\n \"visibility\": \"public\"\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 201', function () { pm.response.to.have.status(201); });", - "const json = pm.response.json();", - "pm.environment.set('postId', json._id || json.id);" - ] - } - } - ] - }, - { - "name": "Get User Posts", - "request": { - "method": "GET", - "url": "{{baseUrl}}/posts/user/{{userId}}?page=1&limit=10", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ] - } - }, - { - "name": "Get Post By Id", - "request": { - "method": "GET", - "url": "{{baseUrl}}/posts/{{postId}}", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ] - } - }, - { - "name": "Update Post", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/posts/{{postId}}", - "body": { - "mode": "raw", - "raw": "{\n \"content\": \"Updated post content\"\n}" - } - } - }, - { - "name": "Delete Post", - "request": { - "method": "DELETE", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/posts/{{postId}}" - } - } - ] - }, - { - "name": "Comments", - "item": [ - { - "name": "Create Comment", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/comments", - "body": { - "mode": "raw", - "raw": "{\n \"postId\": \"{{postId}}\",\n \"content\": \"Awesome post!\"\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 201', function () { pm.response.to.have.status(201); });", - "const json = pm.response.json();", - "pm.environment.set('commentId', json._id || json.id);" - ] - } - } - ] - }, - { - "name": "Get Post Comments", - "request": { - "method": "GET", - "url": "{{baseUrl}}/comments/post/{{postId}}?page=1&limit=10", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ] - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });" - ] - } - } - ] - }, - { - "name": "Get Comment Replies", - "request": { - "method": "GET", - "url": "{{baseUrl}}/comments/{{commentId}}/replies?page=1&limit=10", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ] - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });" - ] - } - } - ] - }, - { - "name": "Delete Comment", - "request": { - "method": "DELETE", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/comments/{{commentId}}" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });" - ] - } - } - ] - }, - { - "name": "Admin Delete Comment", - "request": { - "method": "DELETE", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{superAdminAccessToken}}" - } - ], - "url": "{{baseUrl}}/comments/admin/{{commentId}}" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.success).to.eql(true);" - ] - } - } - ] - } - ] - }, - { - "name": "Likes", - "item": [ - { - "name": "Like Post", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/likes", - "body": { - "mode": "raw", - "raw": "{\n \"targetId\": \"{{postId}}\",\n \"targetType\": \"post\"\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 201', function () { pm.response.to.have.status(201); });", - "const json = pm.response.json();", - "pm.expect(json.liked).to.eql(true);" - ] - } - } - ] - }, - { - "name": "Unlike Post", - "request": { - "method": "DELETE", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/likes/post/{{postId}}" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.liked).to.eql(false);" - ] - } - } - ] - }, - { - "name": "Get Post Like Status", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/likes/status/post/{{postId}}" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json).to.have.property('liked');" - ] - } - } - ] - }, - { - "name": "Like Comment", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/likes", - "body": { - "mode": "raw", - "raw": "{\n \"targetId\": \"{{commentId}}\",\n \"targetType\": \"comment\"\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 201', function () { pm.response.to.have.status(201); });", - "const json = pm.response.json();", - "pm.expect(json.liked).to.eql(true);" - ] - } - } - ] - }, - { - "name": "Unlike Comment", - "request": { - "method": "DELETE", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/likes/comment/{{commentId}}" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.liked).to.eql(false);" - ] - } - } - ] - }, - { - "name": "Get Comment Like Status", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/likes/status/comment/{{commentId}}" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json).to.have.property('liked');" - ] - } - } - ] - } - ] - }, - { - "name": "Saves", - "item": [ - { - "name": "Save Post", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/saves", - "body": { - "mode": "raw", - "raw": "{\n \"postId\": \"{{postId}}\"\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 201', function () { pm.response.to.have.status(201); });", - "const json = pm.response.json();", - "pm.expect(json.saved).to.eql(true);" - ] - } - } - ] - }, - { - "name": "Unsave Post", - "request": { - "method": "DELETE", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/saves/{{postId}}" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.saved).to.eql(false);" - ] - } - } - ] - }, - { - "name": "Get Save Status", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/saves/status/{{postId}}" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json).to.have.property('saved');" - ] - } - } - ] - }, - { - "name": "Get My Saved Posts", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/saves/me?page=1&limit=10" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });" - ] - } - } - ] - } - ] - }, - { - "name": "Follows", - "item": [ - { - "name": "Toggle Follow User", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/follows/toggle", - "body": { - "mode": "raw", - "raw": "{\n \"targetUserId\": \"{{targetUserId}}\"\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 201', function () { pm.response.to.have.status(201); });", - "const json = pm.response.json();", - "pm.expect(json).to.have.property('following');", - "pm.environment.set('lastFollowingState', json.following ? 'following' : 'not_following');" - ] - } - } - ] - }, - { - "name": "Get Followers", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/follows/followers/{{userId}}?page=1&limit=10" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });" - ] - } - } - ] - }, - { - "name": "Get Following", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/follows/following/{{userId}}?page=1&limit=10" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });" - ] - } - } - ] - }, - { - "name": "Get Follow Status", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/follows/status/{{targetUserId}}" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json).to.have.property('following');" - ] - } - } - ] - }, - { - "name": "Get Follow Suggestions", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/follows/suggestions?page=1&limit=10" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.items).to.be.an('array');" - ] - } - } - ] - } - ] - }, - { - "name": "Notifications", - "item": [ - { - "name": "Get My Notifications", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/notifications?page=1&limit=20" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.items).to.be.an('array');", - "if (json.items && json.items.length > 0) { pm.environment.set('notificationId', json.items[0]._id || json.items[0].id); }" - ] - } - } - ] - }, - { - "name": "Get Target User Notifications", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{targetAccessToken}}" - } - ], - "url": "{{baseUrl}}/notifications?page=1&limit=20&read=false" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.items).to.be.an('array');", - "pm.environment.set('notificationUnreadCount', json.unreadCount || 0);", - "const followNotification = (json.items || []).find(item => item.type === 'follow');", - "if (followNotification) { pm.environment.set('notificationId', followNotification._id || followNotification.id); }" - ] - } - } - ] - }, - { - "name": "Get Unread Notifications Count", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/notifications/unread-count" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json).to.have.property('unreadCount');" - ] - } - } - ] - }, - { - "name": "Get Target User Unread Notifications Count", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{targetAccessToken}}" - } - ], - "url": "{{baseUrl}}/notifications/unread-count" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json).to.have.property('unreadCount');", - "pm.environment.set('notificationUnreadCount', json.unreadCount || 0);" - ] - } - } - ] - }, - { - "name": "Mark Notification Read", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/notifications/{{notificationId}}/read" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json).to.have.property('unreadCount');" - ] - } - } - ] - }, - { - "name": "Mark Target Notification Read", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{targetAccessToken}}" - } - ], - "url": "{{baseUrl}}/notifications/{{notificationId}}/read" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json).to.have.property('unreadCount');", - "pm.environment.set('notificationUnreadCount', json.unreadCount || 0);" - ] - } - } - ] - }, - { - "name": "Mark All Notifications Read", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/notifications/read-all" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.unreadCount).to.eql(0);" - ] - } - } - ] - }, - { - "name": "Mark All Target Notifications Read", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{targetAccessToken}}" - } - ], - "url": "{{baseUrl}}/notifications/read-all" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.unreadCount).to.eql(0);", - "pm.environment.set('notificationUnreadCount', 0);" - ] - } - } - ] - } - ] - }, - { - "name": "Chat", - "item": [ - { - "name": "Create Conversation", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/chat/conversations", - "body": { - "mode": "raw", - "raw": "{\n \"participantIds\": [\"{{targetUserId}}\"],\n \"isGroup\": false\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 201', function () { pm.response.to.have.status(201); });", - "const json = pm.response.json();", - "pm.environment.set('conversationId', json._id || json.id);" - ] - } - } - ] - }, - { - "name": "Get My Conversations", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/chat/conversations?limit=20&cursor={{chatConversationsCursor}}" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "if (json.items && json.items.length > 0) { pm.environment.set('conversationId', json.items[0]._id || json.items[0].id); }", - "pm.environment.set('chatConversationsCursor', json.nextCursor || '');" - ] - } - } - ] - }, - { - "name": "Send Message", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/chat/messages", - "body": { - "mode": "raw", - "raw": "{\n \"conversationId\": \"{{conversationId}}\",\n \"messageType\": \"text\",\n \"content\": \"Hello from chat\"\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 201', function () { pm.response.to.have.status(201); });", - "const json = pm.response.json();", - "pm.environment.set('messageId', json._id || json.id);" - ] - } - } - ] - }, - { - "name": "Get Conversation Messages", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/chat/conversations/{{conversationId}}/messages?limit=20&cursor={{chatMessagesCursor}}" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.environment.set('chatMessagesCursor', json.nextCursor || '');" - ] - } - } - ] - }, - { - "name": "Mark Message Seen", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/chat/messages/{{messageId}}/seen" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });" - ] - } - } - ] - }, - { - "name": "Unsend Message", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/chat/messages/{{messageId}}/unsend" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });" - ] - } - } - ] - }, - { - "name": "Block User In Chat", - "request": { - "method": "POST", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/chat/blocks/{{targetUserId}}" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 201', function () { pm.response.to.have.status(201); });", - "const json = pm.response.json();", - "pm.expect(json.blocked).to.eql(true);" - ] - } - } - ] - }, - { - "name": "Unblock User In Chat", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/chat/blocks/{{targetUserId}}/unblock" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });" - ] - } - } - ] - }, - { - "name": "Get Chat Block Status", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/chat/blocks/status/{{targetUserId}}" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json).to.have.property('iBlocked');", - "pm.expect(json).to.have.property('blockedMe');" - ] - } - } - ] - }, - { - "name": "Get My Chat Blocks", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/chat/blocks" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.items).to.be.an('array');" - ] - } - } - ] - } - ] - }, - { - "name": "Feed", - "item": [ - { - "name": "Get My Feed", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/feed/me?limit=20&cursor={{feedCursor}}&radiusKm=30" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.items).to.be.an('array');", - "pm.environment.set('feedCursor', json.nextCursor || '');" - ] - } - } - ] - }, - { - "name": "Get My Feed Preferred Type", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/feed/me?limit=20&cursor={{feedCursor}}&preferredPostType=video&followingOnly=false&radiusKm=50" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.environment.set('feedCursor', json.nextCursor || '');" - ] - } - } - ] - }, - { - "name": "Get Trending Feed", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{accessToken}}" - } - ], - "url": "{{baseUrl}}/feed/trending?limit=20&cursor={{feedCursor}}" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.items).to.be.an('array');", - "pm.environment.set('feedCursor', json.nextCursor || '');" - ] - } - } - ] - } - ] - }, - { - "name": "Marketplace", - "item": [ - { - "name": "Admin Create Instrument", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{adminAccessToken}}" - } - ], - "url": "{{baseUrl}}/marketplace/admin/instruments", - "body": { - "mode": "raw", - "raw": "{\n \"title\": \"Professional Oud\",\n \"description\": \"Handmade oud for studio and stage\",\n \"price\": 4200,\n \"currency\": \"SAR\",\n \"quantity\": 3,\n \"imageUrls\": [\n \"https://cdn.example.com/instruments/oud-1.jpg\"\n ],\n \"isActive\": true\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 201', function () { pm.response.to.have.status(201); });", - "const json = pm.response.json();", - "pm.environment.set('instrumentId', json._id || json.id);" - ] - } - } - ] - }, - { - "name": "Admin Update Instrument", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{adminAccessToken}}" - } - ], - "url": "{{baseUrl}}/marketplace/admin/instruments/{{instrumentId}}", - "body": { - "mode": "raw", - "raw": "{\n \"price\": 3999,\n \"quantity\": 5,\n \"isActive\": true\n}" - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });" - ] - } - } - ] - }, - { - "name": "Admin Get My Instruments", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{adminAccessToken}}" - } - ], - "url": "{{baseUrl}}/marketplace/admin/instruments/me?page=1&limit=20" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.items).to.be.an('array');", - "if (json.items && json.items.length > 0) { pm.environment.set('instrumentId', json.items[0]._id || json.items[0].id); }" - ] - } - } - ] - }, - { - "name": "Public List Instruments", - "request": { - "method": "GET", - "header": [], - "url": "{{baseUrl}}/marketplace/instruments?page=1&limit=20&isActive=true" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.items).to.be.an('array');", - "if (json.items && json.items.length > 0) { pm.environment.set('instrumentId', json.items[0]._id || json.items[0].id); }" - ] - } - } - ] - }, - { - "name": "Public Get Instrument By Id", - "request": { - "method": "GET", - "header": [], - "url": "{{baseUrl}}/marketplace/instruments/{{instrumentId}}" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });" - ] - } - } - ] - }, - { - "name": "Admin Delete Instrument", - "request": { - "method": "DELETE", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{adminAccessToken}}" - } - ], - "url": "{{baseUrl}}/marketplace/admin/instruments/{{instrumentId}}" - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", - "const json = pm.response.json();", - "pm.expect(json.success).to.eql(true);" - ] - } - } - ] - } - ] - } - ], - "variable": [ - { - "key": "baseUrl", - "value": "http://192.168.1.9:4001/api/v1", - "type": "string" - }, - { - "key": "accessToken", - "value": "", - "type": "string" - }, - { - "key": "refreshToken", - "value": "", - "type": "string" - }, - { - "key": "superAdminAccessToken", - "value": "", - "type": "string" - }, - { - "key": "superAdminRefreshToken", - "value": "", - "type": "string" - }, - { - "key": "targetAccessToken", - "value": "", - "type": "string" - }, - { - "key": "targetRefreshToken", - "value": "", - "type": "string" - }, - { - "key": "userId", - "value": "", - "type": "string" - }, - { - "key": "currentUserId", - "value": "", - "type": "string" - }, - { - "key": "targetUserId", - "value": "", - "type": "string" - }, - { - "key": "lastFollowingState", - "value": "", - "type": "string" - }, - { - "key": "notificationUnreadCount", - "value": "", - "type": "string" - }, - { - "key": "notificationId", - "value": "", - "type": "string" - }, - { - "key": "postId", - "value": "", - "type": "string" - }, - { - "key": "commentId", - "value": "", - "type": "string" - }, - { - "key": "conversationId", - "value": "", - "type": "string" - }, - { - "key": "messageId", - "value": "", - "type": "string" - }, - { - "key": "sessionJti", - "value": "", - "type": "string" - }, - { - "key": "registerEmail", - "value": "", - "type": "string" - }, - { - "key": "registerUsername", - "value": "", - "type": "string" - }, - { - "key": "adminUserId", - "value": "", - "type": "string" - }, - { - "key": "targetLoginEmail", - "value": "", - "type": "string" - }, - { - "key": "targetLoginPassword", - "value": "", - "type": "string" - }, - { - "key": "resetCode", - "value": "", - "type": "string" - }, - { - "key": "resetToken", - "value": "", - "type": "string" - }, - { - "key": "emailVerificationCode", - "value": "", - "type": "string" - }, - { - "key": "googleIdToken", - "value": "", - "type": "string" - }, - { - "key": "newPassword", - "value": "NewStrongPass123!", - "type": "string" - }, - { - "key": "feedCursor", - "value": "", - "type": "string" - }, - { - "key": "chatConversationsCursor", - "value": "", - "type": "string" - }, - { - "key": "chatMessagesCursor", - "value": "", - "type": "string" - }, - { - "key": "adminAccessToken", - "value": "", - "type": "string" - }, - { - "key": "adminRefreshToken", - "value": "", - "type": "string" - }, - { - "key": "adminEmail", - "value": "store_admin@example.com", - "type": "string" - }, - { - "key": "adminUsername", - "value": "store_admin_01", - "type": "string" - }, - { - "key": "adminPassword", - "value": "AdminStrongPass123!", - "type": "string" - }, - { - "key": "instrumentId", - "value": "", - "type": "string" - } - ] + "info": { + "name": "Oudelaa Auth Users Posts", + "description": "Postman collection for auth, users, posts, feed, notifications, chat, and marketplace modules with frontend-friendly filters, sorting, mixed home feed, comment mentions, post engagement counters, a separated marketplace/listings contract, and one-shop-per-admin marketplace rules.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "Auth", + "item": [ + { + "name": "Register Basic", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/auth/register-basic", + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"{{registerEmail}}\",\n \"password\": \"StrongPass123!\",\n \"confirmPassword\": \"StrongPass123!\"\n}" + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "const ts = Date.now();", + "if (!pm.environment.get(\u0027registerEmail\u0027)) { pm.environment.set(\u0027registerEmail\u0027, `test_${ts}@example.com`); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 201\u0027, function () { pm.response.to.have.status(201); });", + "const json = pm.response.json();", + "pm.expect(json.message).to.exist;", + "pm.expect(json.email).to.exist;", + "if (json.debugCode) { pm.environment.set(\u0027emailVerificationCode\u0027, json.debugCode); }" + ] + } + } + ] + }, + { + "name": "Register", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/auth/register", + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Test User\",\n \"stageName\": \"Artist One\",\n \"username\": \"{{registerUsername}}\",\n \"email\": \"{{registerEmail}}\",\n \"password\": \"StrongPass123!\",\n \"confirmPassword\": \"StrongPass123!\",\n \"musicRoles\": [\"singer\", \"composer\"],\n \"musicGenres\": [\"Tarab\", \"Pop\"],\n \"favoriteInstruments\": [\"Oud\", \"Piano\"],\n \"favoriteMaqamat\": [\"Bayati\", \"Rast\"],\n \"location\": \"Riyadh, Saudi Arabia\",\n \"latitude\": 24.7136,\n \"longitude\": 46.6753,\n \"isPrivate\": false\n}" + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "const ts = Date.now();", + "if (!pm.environment.get(\u0027registerEmail\u0027)) { pm.environment.set(\u0027registerEmail\u0027, `test_${ts}@example.com`); }", + "if (!pm.environment.get(\u0027registerUsername\u0027)) { pm.environment.set(\u0027registerUsername\u0027, `test_user_${ts}`); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 201\u0027, function () { pm.response.to.have.status(201); });", + "const json = pm.response.json();", + "pm.expect(json.message).to.exist;", + "pm.expect(json.email).to.exist;", + "if (json.debugCode) { pm.environment.set(\u0027emailVerificationCode\u0027, json.debugCode); }" + ] + } + } + ] + }, + { + "name": "Login", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/auth/login", + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"{{registerEmail}}\",\n \"password\": \"StrongPass123!\"\n}" + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "const missing = [];", + "if (!pm.environment.get(\u0027accessToken\u0027)) missing.push(\u0027accessToken\u0027);", + "if (!pm.environment.get(\u0027postId\u0027)) missing.push(\u0027postId\u0027);", + "if (missing.length) { throw new Error(\u0027Missing environment vars: \u0027 + missing.join(\u0027, \u0027)); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.user).to.exist;", + "const uid = json.user._id || json.user.id;", + "pm.environment.set(\u0027accessToken\u0027, json.accessToken);", + "pm.environment.set(\u0027refreshToken\u0027, json.refreshToken);", + "pm.environment.set(\u0027userId\u0027, uid);", + "pm.environment.set(\u0027currentUserId\u0027, uid);" + ] + } + } + ] + }, + { + "name": "Target User Login", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/auth/login", + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"{{targetLoginEmail}}\",\n \"password\": \"{{targetLoginPassword}}\"\n}" + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "const missing = [];", + "if (!pm.environment.get(\u0027accessToken\u0027)) missing.push(\u0027accessToken\u0027);", + "if (!pm.environment.get(\u0027postId\u0027)) missing.push(\u0027postId\u0027);", + "if (missing.length) { throw new Error(\u0027Missing environment vars: \u0027 + missing.join(\u0027, \u0027)); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.user).to.exist;", + "const uid = json.user._id || json.user.id;", + "pm.environment.set(\u0027targetAccessToken\u0027, json.accessToken);", + "pm.environment.set(\u0027targetRefreshToken\u0027, json.refreshToken);", + "pm.environment.set(\u0027targetUserId\u0027, uid);" + ] + } + } + ] + }, + { + "name": "Admin Login", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/auth/login", + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"{{adminEmail}}\",\n \"password\": \"{{adminPassword}}\"\n}" + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "const missing = [];", + "if (!pm.environment.get(\u0027accessToken\u0027)) missing.push(\u0027accessToken\u0027);", + "if (!pm.environment.get(\u0027postId\u0027)) missing.push(\u0027postId\u0027);", + "if (missing.length) { throw new Error(\u0027Missing environment vars: \u0027 + missing.join(\u0027, \u0027)); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.environment.set(\u0027adminAccessToken\u0027, json.accessToken);", + "pm.environment.set(\u0027adminRefreshToken\u0027, json.refreshToken);", + "pm.environment.set(\u0027adminUserId\u0027, (json.user \u0026\u0026 (json.user._id || json.user.id)) || pm.environment.get(\u0027adminUserId\u0027));" + ] + } + } + ] + }, + { + "name": "Refresh", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/auth/refresh", + "body": { + "mode": "raw", + "raw": "{\n \"refreshToken\": \"{{refreshToken}}\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.user).to.exist;", + "pm.environment.set(\u0027accessToken\u0027, json.accessToken);", + "pm.environment.set(\u0027refreshToken\u0027, json.refreshToken);", + "pm.environment.set(\u0027userId\u0027, json.user._id || json.user.id);" + ] + } + } + ] + }, + { + "name": "Logout", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/auth/logout", + "body": { + "mode": "raw", + "raw": "{\n \"refreshToken\": \"{{refreshToken}}\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.message).to.eql(\u0027Logged out successfully\u0027);" + ] + } + } + ] + }, + { + "name": "SuperAdmin", + "item": [ + { + "name": "SuperAdmin Login", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/auth/superadmin/login", + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"admin@oudelaa.com\",\n \"password\": \"SuperAdminStrongPass123!\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.accessToken).to.exist;", + "pm.expect(json.refreshToken).to.exist;", + "pm.environment.set(\u0027superAdminAccessToken\u0027, json.accessToken);", + "pm.environment.set(\u0027superAdminRefreshToken\u0027, json.refreshToken);" + ] + } + } + ] + }, + { + "name": "SuperAdmin Refresh", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/auth/superadmin/refresh", + "body": { + "mode": "raw", + "raw": "{\n \"refreshToken\": \"{{superAdminRefreshToken}}\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.accessToken).to.exist;", + "pm.expect(json.refreshToken).to.exist;", + "pm.environment.set(\u0027superAdminAccessToken\u0027, json.accessToken);", + "pm.environment.set(\u0027superAdminRefreshToken\u0027, json.refreshToken);" + ] + } + } + ] + }, + { + "name": "SuperAdmin Logout", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/auth/superadmin/logout", + "body": { + "mode": "raw", + "raw": "{\n \"refreshToken\": \"{{superAdminRefreshToken}}\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.message).to.eql(\u0027Superadmin logged out successfully\u0027);" + ] + } + } + ] + } + ] + }, + { + "name": "Google OAuth Start (Browser)", + "request": { + "method": "GET", + "header": [ + + ], + "url": "{{baseUrl}}/auth/google", + "description": "Open this request in browser flow to start Google OAuth redirect." + } + }, + { + "name": "Google OAuth Callback (Browser Redirect)", + "request": { + "method": "GET", + "header": [ + + ], + "url": "{{baseUrl}}/auth/google/callback", + "description": "Callback endpoint is called by Google redirect after browser login. Do not call directly from Postman." + } + }, + { + "name": "Google Token Login (Mobile - Recommended)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/auth/google/token", + "description": "Mobile-style Google login (Instagram-like): send Google idToken from app SDK.", + "body": { + "mode": "raw", + "raw": "{\n \"idToken\": \"{{googleIdToken}}\"\n}" + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "const idToken = (pm.environment.get(\u0027googleIdToken\u0027) || \u0027\u0027).trim();", + "if (!idToken) {", + " throw new Error(\u0027googleIdToken is empty. Paste Google idToken from Flutter/Google SDK into environment variable googleIdToken.\u0027);", + "}" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.accessToken).to.exist;", + "pm.expect(json.refreshToken).to.exist;", + "pm.expect(json.user).to.exist;", + "const uid = json.user._id || json.user.id;", + "pm.environment.set(\u0027accessToken\u0027, json.accessToken);", + "pm.environment.set(\u0027refreshToken\u0027, json.refreshToken);", + "pm.environment.set(\u0027userId\u0027, uid);", + "pm.environment.set(\u0027currentUserId\u0027, uid);" + ] + } + } + ] + }, + { + "name": "List My Sessions", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/auth/sessions" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an(\u0027array\u0027);", + "pm.expect(json.pagination).to.exist;", + "if (json.items \u0026\u0026 json.items.length \u003e 0) { pm.environment.set(\u0027sessionJti\u0027, json.items[0].jti); }" + ] + } + } + ] + }, + { + "name": "Revoke My Session", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/auth/sessions/{{sessionJti}}/revoke" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.success).to.eql(true);" + ] + } + } + ] + }, + { + "name": "Forgot Password", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/auth/forgot-password", + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"{{registerEmail}}\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.message).to.exist;", + "if (json.debugCode) { pm.environment.set(\u0027resetCode\u0027, json.debugCode); }" + ] + } + } + ] + }, + { + "name": "Verify Reset Code", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/auth/verify-reset-code", + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"{{registerEmail}}\",\n \"code\": \"{{resetCode}}\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.resetToken).to.exist;", + "pm.environment.set(\u0027resetToken\u0027, json.resetToken);" + ] + } + } + ] + }, + { + "name": "Reset Password", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/auth/reset-password", + "body": { + "mode": "raw", + "raw": "{\n \"resetToken\": \"{{resetToken}}\",\n \"newPassword\": \"{{newPassword}}\",\n \"confirmPassword\": \"{{newPassword}}\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.message).to.eql(\u0027Password reset successfully\u0027);" + ] + } + } + ] + }, + { + "name": "Send Email Verification", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/auth/send-email-verification", + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"{{registerEmail}}\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.message).to.exist;", + "if (json.debugCode) { pm.environment.set(\u0027emailVerificationCode\u0027, json.debugCode); }" + ] + } + } + ] + }, + { + "name": "Verify Email", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/auth/verify-email", + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"{{registerEmail}}\",\n \"code\": \"{{emailVerificationCode}}\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.message).to.exist;", + "if (json.accessToken) { pm.environment.set(\u0027accessToken\u0027, json.accessToken); }", + "if (json.refreshToken) { pm.environment.set(\u0027refreshToken\u0027, json.refreshToken); }", + "if (json.user) { const uid = json.user._id || json.user.id; pm.environment.set(\u0027userId\u0027, uid); pm.environment.set(\u0027currentUserId\u0027, uid); }" + ] + } + } + ] + } + ] + }, + { + "name": "Users", + "item": [ + { + "name": "SuperAdmin List Admins", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{superAdminAccessToken}}" + } + ], + "url": "{{baseUrl}}/users/admin/admins?page=1\u0026limit=20" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an(\u0027array\u0027);", + "pm.expect(json.pagination).to.exist;", + "if (json.items \u0026\u0026 json.items.length \u003e 0) { pm.environment.set(\u0027adminUserId\u0027, json.items[0]._id || json.items[0].id); }" + ] + } + } + ] + }, + { + "name": "SuperAdmin Update Admin", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{superAdminAccessToken}}" + } + ], + "url": "{{baseUrl}}/users/admin/admins/{{adminUserId}}", + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Store Admin Updated\",\n \"stageName\": \"Music Store Owner\",\n \"bio\": \"Admin managing marketplace tools\",\n \"location\": \"Riyadh, Saudi Arabia\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json).to.have.property(\u0027role\u0027);", + "pm.expect(json.role).to.eql(\u0027admin\u0027);" + ] + } + } + ] + }, + { + "name": "SuperAdmin Delete Admin", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{superAdminAccessToken}}" + } + ], + "url": "{{baseUrl}}/users/admin/admins/{{adminUserId}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.message).to.eql(\u0027Admin deleted successfully\u0027);" + ] + } + } + ] + }, + { + "name": "SuperAdmin Create Admin", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{superAdminAccessToken}}" + } + ], + "url": "{{baseUrl}}/users/admin/create-admin", + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Store Admin\",\n \"username\": \"{{adminUsername}}\",\n \"email\": \"{{adminEmail}}\",\n \"password\": \"{{adminPassword}}\",\n \"confirmPassword\": \"{{adminPassword}}\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 201\u0027, function () { pm.response.to.have.status(201); });", + "const json = pm.response.json();", + "pm.environment.set(\u0027adminUserId\u0027, json._id || json.id);" + ] + } + } + ] + }, + { + "name": "SuperAdmin Set User Role", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{superAdminAccessToken}}" + } + ], + "url": "{{baseUrl}}/users/admin/{{adminUserId}}/role", + "body": { + "mode": "raw", + "raw": "{\n \"role\": \"admin\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.role).to.eql(\u0027admin\u0027);" + ] + } + } + ] + }, + { + "name": "Search Users", + "request": { + "method": "GET", + "url": "{{baseUrl}}/users?q={{usersQuery}}\u0026isVerified={{usersVerified}}\u0026musicRole={{talentRole}}\u0026experienceLevel={{talentExperienceLevel}}\u0026hasAvatar={{usersHasAvatar}}\u0026sortBy={{usersSortBy}}\u0026sortOrder={{listSortOrder}}\u0026page=1\u0026limit=20", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.pagination).to.exist;", + "const currentUserId = pm.environment.get(\u0027currentUserId\u0027);", + "if (json.items \u0026\u0026 json.items.length \u003e 0) {", + " const target = json.items.find(u =\u003e (u._id || u.id) !== currentUserId) || json.items[0];", + " const targetId = target._id || target.id;", + " pm.environment.set(\u0027targetUserId\u0027, targetId);", + " if (target.username) { pm.environment.set(\u0027targetUsername\u0027, target.username); }", + "}" + ] + } + } + ] + }, + { + "name": "Profile Setup", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/users/me/profile-setup", + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "stageName", + "value": "Artist One", + "type": "text" + }, + { + "key": "avatarFile", + "type": "file", + "src": [ + + ] + }, + { + "key": "bio", + "value": "Short bio", + "type": "text" + }, + { + "key": "location", + "value": "Riyadh, Saudi Arabia", + "type": "text" + }, + { + "key": "latitude", + "value": "24.7136", + "type": "text" + }, + { + "key": "coverImageFile", + "type": "file", + "src": [ + + ], + "disabled": true + }, + { + "key": "longitude", + "value": "46.6753", + "type": "text" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.location).to.eql(\u0027Riyadh, Saudi Arabia\u0027);", + "pm.expect(json.latitude).to.eql(24.7136);", + "pm.expect(json.longitude).to.eql(46.6753);", + "if (json.avatar) { pm.expect(json.avatar).to.be.a(\u0027string\u0027); }", + "if (json.coverImage) { pm.expect(json.coverImage).to.be.a(\u0027string\u0027); }" + ] + } + } + ] + }, + { + "name": "Music Setup", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/users/me/music-setup", + "body": { + "mode": "raw", + "raw": "{\n \"musicRoles\": [\"instrumentalist\", \"composer\"],\n \"musicGenres\": [\"Tarab\", \"Pop\"],\n \"experienceLevel\": \"intermediate\",\n \"favoriteInstruments\": [\"Oud\", \"Piano\"],\n \"favoriteMaqamat\": [\"Bayati\", \"Rast\"]\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.musicRoles).to.include(\u0027instrumentalist\u0027);", + "pm.expect(json.musicRoles).to.include(\u0027composer\u0027);", + "pm.expect(json.experienceLevel).to.eql(\u0027intermediate\u0027);", + "pm.expect(json).to.not.have.property(\u0027isInstrumentalist\u0027);", + "pm.expect(json).to.not.have.property(\u0027isSinger\u0027);", + "pm.expect(json).to.not.have.property(\u0027isComposer\u0027);", + "pm.expect(json).to.not.have.property(\u0027isLyricist\u0027);" + ] + } + } + ] + }, + { + "name": "Update Me", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/users/me", + "body": { + "mode": "raw", + "raw": "{\n \"bio\": \"Updated from Postman\",\n \"avatar\": \"https://cdn.example.com/avatar.jpg\",\n \"stageName\": \"Artist One Updated\",\n \"musicGenres\": [\"Tarab\", \"Khaleeji\"],\n \"favoriteInstruments\": [\"Oud\"],\n \"favoriteMaqamat\": [\"Hijaz\"],\n \"location\": \"Jeddah, Saudi Arabia\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.bio).to.eql(\u0027Updated from Postman\u0027);", + "if (json.coverImage) { pm.expect(json.coverImage).to.be.a(\u0027string\u0027); }" + ] + } + } + ] + }, + { + "name": "Update Me With Files", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/users/me", + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "stageName", + "value": "Artist One Files", + "type": "text" + }, + { + "key": "bio", + "value": "Updated from Postman with files", + "type": "text" + }, + { + "key": "location", + "value": "Riyadh, Saudi Arabia", + "type": "text" + }, + { + "key": "avatar", + "value": "https://cdn.example.com/avatar.jpg", + "type": "text" + }, + { + "key": "avatarFile", + "type": "file", + "src": [ + + ] + }, + { + "key": "coverImageFile", + "type": "file", + "src": [ + + ], + "disabled": true + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.stageName).to.eql(\u0027Artist One Files\u0027);", + "if (json.avatar) { pm.expect(json.avatar).to.be.a(\u0027string\u0027); }", + "if (json.coverImage) { pm.expect(json.coverImage).to.be.a(\u0027string\u0027); }" + ] + } + } + ] + }, + { + "name": "Get User By Id", + "request": { + "method": "GET", + "url": "{{baseUrl}}/users/{{userId}}", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ] + } + }, + { + "name": "Admin Get Users", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{superAdminAccessToken}}" + } + ], + "url": "{{baseUrl}}/users/admin?page=1\u0026limit=10" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "if (json.items \u0026\u0026 json.items.length \u003e 0) { pm.environment.set(\u0027adminUserId\u0027, json.items[0]._id || json.items[0].id); }" + ] + } + } + ] + }, + { + "name": "Admin Get User By Id", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{superAdminAccessToken}}" + } + ], + "url": "{{baseUrl}}/users/admin/{{adminUserId}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json._id || json.id).to.exist;" + ] + } + } + ] + }, + { + "name": "Admin Update User", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{superAdminAccessToken}}" + } + ], + "url": "{{baseUrl}}/users/admin/{{adminUserId}}", + "body": { + "mode": "raw", + "raw": "{\n \"stageName\": \"Updated by SuperAdmin\",\n \"bio\": \"Profile updated by admin\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.stageName).to.eql(\u0027Updated by SuperAdmin\u0027);" + ] + } + } + ] + }, + { + "name": "Admin Disable User", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{superAdminAccessToken}}" + } + ], + "url": "{{baseUrl}}/users/admin/{{adminUserId}}/disable", + "body": { + "mode": "raw", + "raw": "{\n \"reason\": \"Violation of community guidelines\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.isDisabled).to.eql(true);" + ] + } + } + ] + }, + { + "name": "Admin Enable User", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{superAdminAccessToken}}" + } + ], + "url": "{{baseUrl}}/users/admin/{{adminUserId}}/enable" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.isDisabled).to.eql(false);" + ] + } + } + ] + }, + { + "name": "Admin Delete User", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{superAdminAccessToken}}" + } + ], + "url": "{{baseUrl}}/users/admin/{{adminUserId}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.message).to.eql(\u0027User deleted successfully\u0027);" + ] + } + } + ] + }, + { + "name": "Discover Talents", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/users/discover?q={{usersQuery}}\u0026musicRole={{talentRole}}\u0026experienceLevel={{talentExperienceLevel}}\u0026hasAvatarOnly={{usersHasAvatar}}\u0026includeRoleBuckets=true\u0026sortBy=followersCount\u0026sortOrder={{listSortOrder}}\u0026page=1\u0026limit=8" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an(\u0027array\u0027);", + "pm.expect(json).to.have.property(\u0027roleBuckets\u0027);", + "pm.expect(json.pagination).to.exist;", + "if (json.items \u0026\u0026 json.items.length \u003e 0) { pm.environment.set(\u0027targetUserId\u0027, json.items[0]._id || json.items[0].id); }" + ] + } + } + ] + }, + { + "name": "Get Profile Overview", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/users/{{targetUserId}}/profile-overview" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json).to.have.property(\u0027stats\u0027);", + "pm.expect(json).to.have.property(\u0027contentCounts\u0027);", + "pm.expect(json).to.have.property(\u0027viewerState\u0027);" + ] + } + } + ] + } + ] + }, + { + "name": "Posts", + "item": [ + { + "name": "Create Post (Own)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/posts", + "body": { + "mode": "raw", + "raw": "{\n \"content\": \"First text post with mention @{{targetUsername}} #music\",\n \"taggedUserIds\": [\"{{targetUserId}}\"],\n \"mentionUsernames\": [\"{{targetUsername}}\"],\n \"location\": \"Riyadh, Saudi Arabia\",\n \"latitude\": 24.7136,\n \"longitude\": 46.6753,\n \"visibility\": \"public\"\n}" + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (!pm.environment.get(\u0027accessToken\u0027)) { throw new Error(\u0027Missing environment var: accessToken\u0027); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 201\u0027, function () { pm.response.to.have.status(201); });", + "const json = pm.response.json();", + "const pid = json._id || json.id;", + "pm.environment.set(\u0027postId\u0027, pid);", + "pm.environment.set(\u0027ownPostId\u0027, pid);" + ] + } + } + ] + }, + { + "name": "Create Carousel Post (Instagram Style)", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/posts", + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "content", + "value": "Weekend jam session #oud #music", + "type": "text" + }, + { + "key": "visibility", + "value": "public", + "type": "text" + }, + { + "key": "taggedUserIds", + "value": "{{targetUserId}}", + "type": "text" + }, + { + "key": "mentionUsernames", + "value": "{{targetUsername}}", + "type": "text" + }, + { + "key": "location", + "value": "Riyadh, Saudi Arabia", + "type": "text" + }, + { + "key": "latitude", + "value": "24.7136", + "type": "text" + }, + { + "key": "longitude", + "value": "46.6753", + "type": "text" + }, + { + "key": "imageFiles", + "type": "file", + "src": [ + + ] + }, + { + "key": "imageFiles", + "type": "file", + "src": [ + + ] + } + ] + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (!pm.environment.get(\u0027accessToken\u0027)) { throw new Error(\u0027Missing environment var: accessToken\u0027); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 201\u0027, function () { pm.response.to.have.status(201); });", + "const json = pm.response.json();", + "pm.expect(json.postType).to.eql(\u0027image\u0027);", + "pm.expect(json.imageUrls).to.be.an(\u0027array\u0027);", + "pm.expect(json.imageUrls.length).to.be.greaterThan(0);", + "const pid = json._id || json.id;", + "pm.environment.set(\u0027postId\u0027, pid);", + "pm.environment.set(\u0027ownPostId\u0027, pid);" + ] + } + } + ] + }, + { + "name": "Update Carousel Post (Instagram Style)", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/posts/{{postId}}", + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "content", + "value": "Updated carousel caption #oud #live", + "type": "text" + }, + { + "key": "imageFiles", + "type": "file", + "src": [ + + ] + } + ] + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "const missing = [];", + "if (!pm.environment.get(\u0027accessToken\u0027)) missing.push(\u0027accessToken\u0027);", + "if (!pm.environment.get(\u0027postId\u0027)) missing.push(\u0027postId\u0027);", + "if (missing.length) { throw new Error(\u0027Missing environment vars: \u0027 + missing.join(\u0027, \u0027)); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.postType).to.eql(\u0027image\u0027);", + "pm.expect(json.imageUrls).to.be.an(\u0027array\u0027);" + ] + } + } + ] + }, + { + "name": "Get User Posts (Own)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/posts/user/{{userId}}?visibility={{postVisibility}}\u0026sortBy={{postSortBy}}\u0026sortOrder={{listSortOrder}}\u0026page=1\u0026limit=10", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ] + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "const missing = [];", + "if (!pm.environment.get(\u0027accessToken\u0027)) missing.push(\u0027accessToken\u0027);", + "if (!pm.environment.get(\u0027userId\u0027)) missing.push(\u0027userId\u0027);", + "if (missing.length) { throw new Error(\u0027Missing environment vars: \u0027 + missing.join(\u0027, \u0027)); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an(\u0027array\u0027);", + "pm.expect(json.pagination).to.exist;", + "if (!pm.environment.get(\u0027postId\u0027) \u0026\u0026 json.items \u0026\u0026 json.items.length \u003e 0) {", + " pm.environment.set(\u0027postId\u0027, json.items[0]._id || json.items[0].id);", + " pm.environment.set(\u0027ownPostId\u0027, json.items[0]._id || json.items[0].id);", + "}" + ] + } + } + ] + }, + { + "name": "Get User Posts Filtered", + "request": { + "method": "GET", + "url": "{{baseUrl}}/posts/user/{{userId}}?visibility={{postVisibility}}\u0026postType={{postTypeFilter}}\u0026q={{postSearchQuery}}\u0026hashtag={{postHashtag}}\u0026sortBy={{postSortBy}}\u0026sortOrder={{listSortOrder}}\u0026page=1\u0026limit=10", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an(\u0027array\u0027);", + "pm.expect(json.pagination).to.exist;" + ] + } + } + ] + }, + { + "name": "Get Post By Id", + "request": { + "method": "GET", + "url": "{{baseUrl}}/posts/{{postId}}", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json._id || json.id).to.exist;", + "pm.expect(json).to.have.property(\u0027content\u0027);" + ] + } + } + ] + }, + { + "name": "Register Post View", + "request": { + "method": "POST", + "url": "{{baseUrl}}/posts/{{postId}}/view", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.success).to.eql(true);", + "pm.expect(json).to.have.property(\u0027viewCount\u0027);", + "pm.environment.set(\u0027lastViewCount\u0027, json.viewCount);" + ] + } + } + ] + }, + { + "name": "Register Post Share", + "request": { + "method": "POST", + "url": "{{baseUrl}}/posts/{{postId}}/share", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.success).to.eql(true);", + "pm.expect(json).to.have.property(\u0027shareCount\u0027);", + "pm.environment.set(\u0027lastShareCount\u0027, json.shareCount);" + ] + } + } + ] + }, + { + "name": "Update Post", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/posts/{{postId}}", + "body": { + "mode": "raw", + "raw": "{\n \"content\": \"Updated post content with @{{targetUsername}} #live\",\n \"taggedUserIds\": [\"{{targetUserId}}\"],\n \"mentionUsernames\": [\"{{targetUsername}}\"],\n \"location\": \"Jeddah, Saudi Arabia\",\n \"latitude\": 21.5433,\n \"longitude\": 39.1728\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.content).to.eql(\u0027Updated post content with @artist_one #live\u0027);" + ] + } + } + ] + }, + { + "name": "Create Video Post (Own)", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/posts", + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "content", + "value": "Video post #music", + "type": "text" + }, + { + "key": "visibility", + "value": "public", + "type": "text" + }, + { + "key": "durationSeconds", + "value": "42", + "type": "text" + }, + { + "key": "thumbnailUrl", + "value": "https://cdn.example.com/video-cover.jpg", + "type": "text" + }, + { + "key": "style", + "value": "Sharqi", + "type": "text" + }, + { + "key": "maqam", + "value": "Hijaz", + "type": "text" + }, + { + "key": "mentionUsernames", + "value": "{{targetUsername}}", + "type": "text" + }, + { + "key": "rhythmSignature", + "value": "6/8", + "type": "text" + }, + { + "key": "location", + "value": "Riyadh, Saudi Arabia", + "type": "text" + }, + { + "key": "latitude", + "value": "24.7136", + "type": "text" + }, + { + "key": "longitude", + "value": "46.6753", + "type": "text" + }, + { + "key": "videoFile", + "type": "file", + "src": [ + + ] + } + ] + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (!pm.environment.get(\u0027accessToken\u0027)) { throw new Error(\u0027Missing environment var: accessToken\u0027); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 201\u0027, function () { pm.response.to.have.status(201); });", + "const json = pm.response.json();", + "pm.expect(json.postType).to.eql(\u0027video\u0027);", + "pm.expect(json.durationSeconds).to.eql(42);", + "pm.expect(json.thumbnailUrl).to.eql(\u0027https://cdn.example.com/video-cover.jpg\u0027);", + "pm.expect(json.style).to.eql(\u0027Sharqi\u0027);", + "pm.expect(json.maqam).to.eql(\u0027Hijaz\u0027);", + "pm.expect(json.rhythmSignature).to.eql(\u00276/8\u0027);", + "pm.environment.set(\u0027postId\u0027, json._id || json.id);", + "pm.environment.set(\u0027ownPostId\u0027, json._id || json.id);" + ] + } + } + ] + }, + { + "name": "Create Reel (Video First)", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/posts/reels", + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "content", + "value": "New reel from oud session #reel", + "type": "text" + }, + { + "key": "visibility", + "value": "public", + "type": "text" + }, + { + "key": "durationSeconds", + "value": "30", + "type": "text" + }, + { + "key": "thumbnailUrl", + "value": "https://cdn.example.com/reel-cover.jpg", + "type": "text" + }, + { + "key": "style", + "value": "Sharqi", + "type": "text" + }, + { + "key": "maqam", + "value": "Hijaz", + "type": "text" + }, + { + "key": "rhythmSignature", + "value": "6/8", + "type": "text" + }, + { + "key": "videoFile", + "type": "file", + "src": [ + + ] + } + ] + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (!pm.environment.get(\u0027accessToken\u0027)) { throw new Error(\u0027Missing environment var: accessToken\u0027); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 201\u0027, function () { pm.response.to.have.status(201); });", + "const json = pm.response.json();", + "pm.expect(json.postType).to.eql(\u0027video\u0027);", + "pm.expect(json.durationSeconds).to.eql(30);", + "pm.expect(json.thumbnailUrl).to.eql(\u0027https://cdn.example.com/reel-cover.jpg\u0027);", + "pm.expect(json.style).to.eql(\u0027Sharqi\u0027);", + "pm.expect(json.maqam).to.eql(\u0027Hijaz\u0027);", + "pm.expect(json.rhythmSignature).to.eql(\u00276/8\u0027);", + "pm.environment.set(\u0027postId\u0027, json._id || json.id);", + "pm.environment.set(\u0027ownPostId\u0027, json._id || json.id);" + ] + } + } + ] + }, + { + "name": "Get Reels", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/posts/reels?visibility={{postVisibility}}\u0026authorId={{userId}}\u0026sortBy={{postSortBy}}\u0026sortOrder={{listSortOrder}}\u0026page=1\u0026limit=10" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an(\u0027array\u0027);", + "pm.expect(json.pagination).to.exist;" + ] + } + } + ] + }, + { + "name": "Get Reels Filtered", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/posts/reels?visibility={{postVisibility}}\u0026authorId={{userId}}\u0026q={{reelQuery}}\u0026sortBy={{postSortBy}}\u0026sortOrder={{listSortOrder}}\u0026page=1\u0026limit=10" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an(\u0027array\u0027);", + "pm.expect(json.pagination).to.exist;" + ] + } + } + ] + }, + { + "name": "Create Audio Post (Own)", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/posts", + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "content", + "value": "Audio post #oud", + "type": "text" + }, + { + "key": "visibility", + "value": "public", + "type": "text" + }, + { + "key": "durationSeconds", + "value": "54", + "type": "text" + }, + { + "key": "thumbnailUrl", + "value": "https://cdn.example.com/audio-cover.jpg", + "type": "text" + }, + { + "key": "style", + "value": "Sharqi", + "type": "text" + }, + { + "key": "maqam", + "value": "Hijaz", + "type": "text" + }, + { + "key": "rhythmSignature", + "value": "6/8", + "type": "text" + }, + { + "key": "audioFile", + "type": "file", + "src": [ + + ] + } + ] + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (!pm.environment.get(\u0027accessToken\u0027)) { throw new Error(\u0027Missing environment var: accessToken\u0027); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 201\u0027, function () { pm.response.to.have.status(201); });", + "const json = pm.response.json();", + "pm.expect(json.postType).to.eql(\u0027audio\u0027);", + "pm.expect(json.durationSeconds).to.eql(54);", + "pm.expect(json.thumbnailUrl).to.eql(\u0027https://cdn.example.com/audio-cover.jpg\u0027);", + "pm.expect(json.style).to.eql(\u0027Sharqi\u0027);", + "pm.expect(json.maqam).to.eql(\u0027Hijaz\u0027);", + "pm.expect(json.rhythmSignature).to.eql(\u00276/8\u0027);", + "pm.environment.set(\u0027postId\u0027, json._id || json.id);", + "pm.environment.set(\u0027ownPostId\u0027, json._id || json.id);" + ] + } + } + ] + }, + { + "name": "Create Audio Post (URL + Waveform)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/posts", + "body": { + "mode": "raw", + "raw": "{\n \"content\": \"Audio post with waveform #oud #hijaz\",\n \"audioUrl\": \"https://cdn.example.com/audio-sample.mp3\",\n \"durationSeconds\": 54,\n \"thumbnailUrl\": \"https://cdn.example.com/audio-cover.jpg\",\n \"style\": \"Sharqi\",\n \"maqam\": \"Hijaz\",\n \"rhythmSignature\": \"6/8\",\n \"waveformPeaks\": [12, 38, 27, 49, 22, 44],\n \"visibility\": \"public\"\n}" + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (!pm.environment.get(\u0027accessToken\u0027)) { throw new Error(\u0027Missing environment var: accessToken\u0027); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 201\u0027, function () { pm.response.to.have.status(201); });", + "const json = pm.response.json();", + "pm.expect(json.postType).to.eql(\u0027audio\u0027);", + "pm.expect(json.waveformPeaks).to.be.an(\u0027array\u0027);", + "pm.expect(json.waveformPeaks.length).to.eql(6);", + "pm.expect(json.maqam).to.eql(\u0027Hijaz\u0027);", + "pm.environment.set(\u0027postId\u0027, json._id || json.id);", + "pm.environment.set(\u0027ownPostId\u0027, json._id || json.id);" + ] + } + } + ] + }, + { + "name": "Register Post Play (Audio/Video)", + "request": { + "method": "POST", + "url": "{{baseUrl}}/posts/{{postId}}/play", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.success).to.eql(true);", + "pm.expect(json).to.have.property(\u0027playCount\u0027);", + "pm.environment.set(\u0027lastPlayCount\u0027, json.playCount);" + ] + } + } + ] + }, + { + "name": "Create Invalid Post (Video+Audio)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/posts", + "body": { + "mode": "raw", + "raw": "{\n \"content\": \"Invalid post\",\n \"videoUrl\": \"https://cdn.example.com/video.mp4\",\n \"audioUrl\": \"https://cdn.example.com/audio.mp3\",\n \"visibility\": \"public\"\n}" + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (!pm.environment.get(\u0027accessToken\u0027)) { throw new Error(\u0027Missing environment var: accessToken\u0027); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 400\u0027, function () { pm.response.to.have.status(400); });" + ] + } + } + ] + }, + { + "name": "Update Post Video (Form-Data)", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/posts/{{postId}}", + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "content", + "value": "Updated with video file", + "type": "text" + }, + { + "key": "durationSeconds", + "value": "58", + "type": "text" + }, + { + "key": "thumbnailUrl", + "value": "https://cdn.example.com/video-cover-updated.jpg", + "type": "text" + }, + { + "key": "style", + "value": "Contemporary", + "type": "text" + }, + { + "key": "maqam", + "value": "Bayati", + "type": "text" + }, + { + "key": "rhythmSignature", + "value": "4/4", + "type": "text" + }, + { + "key": "videoFile", + "type": "file", + "src": [ + + ] + } + ] + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "const missing = [];", + "if (!pm.environment.get(\u0027accessToken\u0027)) missing.push(\u0027accessToken\u0027);", + "if (!pm.environment.get(\u0027postId\u0027)) missing.push(\u0027postId\u0027);", + "if (missing.length) { throw new Error(\u0027Missing environment vars: \u0027 + missing.join(\u0027, \u0027)); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.postType).to.eql(\u0027video\u0027);", + "pm.expect(json.durationSeconds).to.eql(58);", + "pm.expect(json.thumbnailUrl).to.eql(\u0027https://cdn.example.com/video-cover-updated.jpg\u0027);", + "pm.expect(json.style).to.eql(\u0027Contemporary\u0027);", + "pm.expect(json.maqam).to.eql(\u0027Bayati\u0027);", + "pm.expect(json.rhythmSignature).to.eql(\u00274/4\u0027);" + ] + } + } + ] + }, + { + "name": "Update Post Audio (Form-Data)", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/posts/{{postId}}", + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "content", + "value": "Updated with audio file", + "type": "text" + }, + { + "key": "durationSeconds", + "value": "61", + "type": "text" + }, + { + "key": "thumbnailUrl", + "value": "https://cdn.example.com/audio-cover-updated.jpg", + "type": "text" + }, + { + "key": "style", + "value": "Tarab", + "type": "text" + }, + { + "key": "maqam", + "value": "Nahawand", + "type": "text" + }, + { + "key": "rhythmSignature", + "value": "6/8", + "type": "text" + }, + { + "key": "audioFile", + "type": "file", + "src": [ + + ] + } + ] + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "const missing = [];", + "if (!pm.environment.get(\u0027accessToken\u0027)) missing.push(\u0027accessToken\u0027);", + "if (!pm.environment.get(\u0027postId\u0027)) missing.push(\u0027postId\u0027);", + "if (missing.length) { throw new Error(\u0027Missing environment vars: \u0027 + missing.join(\u0027, \u0027)); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.postType).to.eql(\u0027audio\u0027);", + "pm.expect(json.durationSeconds).to.eql(61);", + "pm.expect(json.thumbnailUrl).to.eql(\u0027https://cdn.example.com/audio-cover-updated.jpg\u0027);", + "pm.expect(json.style).to.eql(\u0027Tarab\u0027);", + "pm.expect(json.maqam).to.eql(\u0027Nahawand\u0027);", + "pm.expect(json.rhythmSignature).to.eql(\u00276/8\u0027);" + ] + } + } + ] + }, + { + "name": "Delete Post (Cleanup - Run Last)", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/posts/{{postId}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.success).to.eql(true);" + ] + } + } + ] + } + ] + }, + { + "name": "Comments", + "item": [ + { + "name": "Create Comment", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/comments", + "body": { + "mode": "raw", + "raw": "{\n \"postId\": \"{{postId}}\",\n \"content\": \"Awesome post!\"\n}" + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "const missing = [];", + "if (!pm.environment.get(\u0027accessToken\u0027)) missing.push(\u0027accessToken\u0027);", + "if (!pm.environment.get(\u0027postId\u0027)) missing.push(\u0027postId\u0027);", + "if (missing.length) { throw new Error(\u0027Missing environment vars: \u0027 + missing.join(\u0027, \u0027)); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 201\u0027, function () { pm.response.to.have.status(201); });", + "const json = pm.response.json();", + "const cid = json._id || json.id;", + "pm.environment.set(\u0027commentId\u0027, cid);", + "pm.environment.set(\u0027ownCommentId\u0027, cid);" + ] + } + } + ] + }, + { + "name": "Create Comment With Mention", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/comments", + "body": { + "mode": "raw", + "raw": "{\n \"postId\": \"{{postId}}\",\n \"content\": \"Awesome post @{{targetUsername}}!\",\n \"mentionUsernames\": [\"{{targetUsername}}\"]\n}" + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "const missing = [];", + "if (!pm.environment.get(\u0027accessToken\u0027)) missing.push(\u0027accessToken\u0027);", + "if (!pm.environment.get(\u0027postId\u0027)) missing.push(\u0027postId\u0027);", + "if (!pm.environment.get(\u0027targetUsername\u0027)) missing.push(\u0027targetUsername\u0027);", + "if (missing.length) { throw new Error(\u0027Missing environment vars: \u0027 + missing.join(\u0027, \u0027)); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 201\u0027, function () { pm.response.to.have.status(201); });", + "const json = pm.response.json();", + "const cid = json._id || json.id;", + "pm.environment.set(\u0027commentId\u0027, cid);", + "pm.expect(json.mentionUsernames || []).to.include(pm.environment.get(\u0027targetUsername\u0027).toLowerCase());" + ] + } + } + ] + }, + { + "name": "Get Post Comments", + "request": { + "method": "GET", + "url": "{{baseUrl}}/comments/post/{{postId}}?page=1\u0026limit=10\u0026sortOrder={{listSortOrder}}", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an(\u0027array\u0027);", + "pm.expect(json.pagination).to.exist;" + ] + } + } + ] + }, + { + "name": "Get Comment Replies", + "request": { + "method": "GET", + "url": "{{baseUrl}}/comments/{{commentId}}/replies?page=1\u0026limit=10\u0026sortOrder={{listSortOrder}}", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an(\u0027array\u0027);", + "pm.expect(json.pagination).to.exist;" + ] + } + } + ] + }, + { + "name": "Delete Comment", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/comments/{{commentId}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });" + ] + } + } + ] + }, + { + "name": "Admin Delete Comment", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{superAdminAccessToken}}" + } + ], + "url": "{{baseUrl}}/comments/admin/{{commentId}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.success).to.eql(true);" + ] + } + } + ] + } + ] + }, + { + "name": "Likes", + "item": [ + { + "name": "Like Post (Set liked=true)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/likes", + "body": { + "mode": "raw", + "raw": "{\n \"targetId\": \"{{postId}}\",\n \"targetType\": \"post\"\n}" + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "const missing = [];", + "if (!pm.environment.get(\u0027accessToken\u0027)) missing.push(\u0027accessToken\u0027);", + "if (!pm.environment.get(\u0027postId\u0027)) missing.push(\u0027postId\u0027);", + "if (missing.length) { throw new Error(\u0027Missing environment vars: \u0027 + missing.join(\u0027, \u0027)); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 201\u0027, function () { pm.response.to.have.status(201); });", + "const json = pm.response.json();", + "pm.expect(json.liked).to.eql(true);" + ] + } + } + ] + }, + { + "name": "Unlike Post (Set liked=false)", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/likes/post/{{postId}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.liked).to.eql(false);" + ] + } + } + ] + }, + { + "name": "Get Post Like Status", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/likes/status/post/{{postId}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json).to.have.property(\u0027liked\u0027);" + ] + } + } + ] + }, + { + "name": "Like Comment", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/likes", + "body": { + "mode": "raw", + "raw": "{\n \"targetId\": \"{{commentId}}\",\n \"targetType\": \"comment\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 201\u0027, function () { pm.response.to.have.status(201); });", + "const json = pm.response.json();", + "pm.expect(json.liked).to.eql(true);" + ] + } + } + ] + }, + { + "name": "Unlike Comment", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/likes/comment/{{commentId}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.liked).to.eql(false);" + ] + } + } + ] + }, + { + "name": "Get Comment Like Status", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/likes/status/comment/{{commentId}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json).to.have.property(\u0027liked\u0027);" + ] + } + } + ] + } + ] + }, + { + "name": "Saves", + "item": [ + { + "name": "Save Post (Set saved=true)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/saves", + "body": { + "mode": "raw", + "raw": "{\n \"postId\": \"{{postId}}\"\n}" + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "const missing = [];", + "if (!pm.environment.get(\u0027accessToken\u0027)) missing.push(\u0027accessToken\u0027);", + "if (!pm.environment.get(\u0027postId\u0027)) missing.push(\u0027postId\u0027);", + "if (missing.length) { throw new Error(\u0027Missing environment vars: \u0027 + missing.join(\u0027, \u0027)); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 201\u0027, function () { pm.response.to.have.status(201); });", + "const json = pm.response.json();", + "pm.expect(json.saved).to.eql(true);" + ] + } + } + ] + }, + { + "name": "Unsave Post (Set saved=false)", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/saves/{{postId}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.saved).to.eql(false);" + ] + } + } + ] + }, + { + "name": "Get Save Status", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/saves/status/{{postId}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json).to.have.property(\u0027saved\u0027);" + ] + } + } + ] + }, + { + "name": "Get My Saved Posts", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/saves/me?page=1\u0026limit=10\u0026sortOrder={{listSortOrder}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an(\u0027array\u0027);", + "pm.expect(json.pagination).to.exist;" + ] + } + } + ] + } + ] + }, + { + "name": "Follows", + "item": [ + { + "name": "Toggle Follow User", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/follows/toggle", + "body": { + "mode": "raw", + "raw": "{\n \"targetUserId\": \"{{targetUserId}}\"\n}" + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "const missing = [];", + "if (!pm.environment.get(\u0027accessToken\u0027)) missing.push(\u0027accessToken\u0027);", + "if (!pm.environment.get(\u0027targetUserId\u0027)) missing.push(\u0027targetUserId\u0027);", + "if (missing.length) { throw new Error(\u0027Missing environment vars: \u0027 + missing.join(\u0027, \u0027)); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 201\u0027, function () { pm.response.to.have.status(201); });", + "const json = pm.response.json();", + "pm.expect(json).to.have.property(\u0027following\u0027);", + "pm.environment.set(\u0027lastFollowingState\u0027, json.following ? \u0027following\u0027 : \u0027not_following\u0027);" + ] + } + } + ] + }, + { + "name": "Get Followers", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/follows/followers/{{userId}}?page=1\u0026limit=10\u0026sortOrder={{listSortOrder}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an(\u0027array\u0027);", + "pm.expect(json.pagination).to.exist;" + ] + } + } + ] + }, + { + "name": "Get Following", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/follows/following/{{userId}}?page=1\u0026limit=10\u0026sortOrder={{listSortOrder}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an(\u0027array\u0027);", + "pm.expect(json.pagination).to.exist;" + ] + } + } + ] + }, + { + "name": "Get Follow Status", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/follows/status/{{targetUserId}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json).to.have.property(\u0027following\u0027);" + ] + } + } + ] + }, + { + "name": "Get Follow Suggestions", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/follows/suggestions?page=1\u0026limit=10\u0026sortOrder={{listSortOrder}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an(\u0027array\u0027);", + "pm.expect(json.pagination).to.exist;" + ] + } + } + ] + } + ] + }, + { + "name": "Notifications", + "item": [ + { + "name": "Get My Notifications", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/notifications?read={{notificationRead}}\u0026sortOrder={{listSortOrder}}\u0026page=1\u0026limit=20" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an(\u0027array\u0027);", + "pm.expect(json.pagination).to.exist;", + "if (json.items \u0026\u0026 json.items.length \u003e 0) {", + " pm.environment.set(\u0027notificationId\u0027, json.items[0]._id || json.items[0].id);", + " pm.expect(json.items[0]).to.have.property(\u0027title\u0027);", + " pm.expect(json.items[0]).to.have.property(\u0027resourceType\u0027);", + " pm.expect(json.items[0]).to.have.property(\u0027deepLink\u0027);", + "}" + ] + } + } + ] + }, + { + "name": "Get My Notifications Filtered", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/notifications?read={{notificationRead}}\u0026type={{notificationType}}\u0026resourceType={{notificationResourceType}}\u0026sortOrder={{listSortOrder}}\u0026page=1\u0026limit=20" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an(\u0027array\u0027);", + "pm.expect(json.pagination).to.exist;" + ] + } + } + ] + }, + { + "name": "Get Target User Notifications", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{targetAccessToken}}" + } + ], + "url": "{{baseUrl}}/notifications?page=1\u0026limit=20\u0026read=false\u0026sortOrder={{listSortOrder}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an(\u0027array\u0027);", + "pm.expect(json.pagination).to.exist;", + "pm.environment.set(\u0027notificationUnreadCount\u0027, json.unreadCount || 0);", + "const followNotification = (json.items || []).find(item =\u003e item.type === \u0027follow\u0027);", + "if (followNotification) {", + " pm.environment.set(\u0027notificationId\u0027, followNotification._id || followNotification.id);", + " pm.expect(followNotification).to.have.property(\u0027title\u0027);", + " pm.expect(followNotification).to.have.property(\u0027resourceType\u0027);", + " pm.expect(followNotification).to.have.property(\u0027deepLink\u0027);", + "}" + ] + } + } + ] + }, + { + "name": "Get Unread Notifications Count", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/notifications/unread-count" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json).to.have.property(\u0027unreadCount\u0027);" + ] + } + } + ] + }, + { + "name": "Get Target User Unread Notifications Count", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{targetAccessToken}}" + } + ], + "url": "{{baseUrl}}/notifications/unread-count" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json).to.have.property(\u0027unreadCount\u0027);", + "pm.environment.set(\u0027notificationUnreadCount\u0027, json.unreadCount || 0);" + ] + } + } + ] + }, + { + "name": "Mark Notification Read", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/notifications/{{notificationId}}/read" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json).to.have.property(\u0027unreadCount\u0027);" + ] + } + } + ] + }, + { + "name": "Mark Target Notification Read", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{targetAccessToken}}" + } + ], + "url": "{{baseUrl}}/notifications/{{notificationId}}/read" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json).to.have.property(\u0027unreadCount\u0027);", + "pm.environment.set(\u0027notificationUnreadCount\u0027, json.unreadCount || 0);" + ] + } + } + ] + }, + { + "name": "Mark All Notifications Read", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/notifications/read-all" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.unreadCount).to.eql(0);" + ] + } + } + ] + }, + { + "name": "Mark All Target Notifications Read", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{targetAccessToken}}" + } + ], + "url": "{{baseUrl}}/notifications/read-all" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.unreadCount).to.eql(0);", + "pm.environment.set(\u0027notificationUnreadCount\u0027, 0);" + ] + } + } + ] + } + ] + }, + { + "name": "Chat", + "item": [ + { + "name": "Create Conversation", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/chat/conversations", + "body": { + "mode": "raw", + "raw": "{\n \"participantIds\": [\"{{targetUserId}}\"],\n \"isGroup\": false\n}" + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "const missing = [];", + "if (!pm.environment.get(\u0027accessToken\u0027)) missing.push(\u0027accessToken\u0027);", + "if (!pm.environment.get(\u0027targetUserId\u0027)) missing.push(\u0027targetUserId\u0027);", + "if (missing.length) { throw new Error(\u0027Missing environment vars: \u0027 + missing.join(\u0027, \u0027)); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 201\u0027, function () { pm.response.to.have.status(201); });", + "const json = pm.response.json();", + "pm.environment.set(\u0027conversationId\u0027, json._id || json.id);" + ] + } + } + ] + }, + { + "name": "Get My Conversations", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/chat/conversations?limit=20\u0026cursor={{chatConversationsCursor}}\u0026sortOrder={{listSortOrder}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.pagination).to.exist;", + "if (json.items \u0026\u0026 json.items.length \u003e 0) { pm.environment.set(\u0027conversationId\u0027, json.items[0]._id || json.items[0].id); }", + "pm.environment.set(\u0027chatConversationsCursor\u0027, json.nextCursor || \u0027\u0027);" + ] + } + } + ] + }, + { + "name": "Send Message", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/chat/messages", + "body": { + "mode": "raw", + "raw": "{\n \"conversationId\": \"{{conversationId}}\",\n \"messageType\": \"text\",\n \"content\": \"Hello from chat\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 201\u0027, function () { pm.response.to.have.status(201); });", + "const json = pm.response.json();", + "pm.environment.set(\u0027messageId\u0027, json._id || json.id);" + ] + } + } + ] + }, + { + "name": "Get Conversation Messages", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/chat/conversations/{{conversationId}}/messages?limit=20\u0026cursor={{chatMessagesCursor}}\u0026sortOrder={{listSortOrder}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.pagination).to.exist;", + "pm.environment.set(\u0027chatMessagesCursor\u0027, json.nextCursor || \u0027\u0027);" + ] + } + } + ] + }, + { + "name": "Mark Message Seen", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/chat/messages/{{messageId}}/seen" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });" + ] + } + } + ] + }, + { + "name": "Unsend Message", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/chat/messages/{{messageId}}/unsend" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });" + ] + } + } + ] + }, + { + "name": "Block User In Chat", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/chat/blocks/{{targetUserId}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 201\u0027, function () { pm.response.to.have.status(201); });", + "const json = pm.response.json();", + "pm.expect(json.blocked).to.eql(true);" + ] + } + } + ] + }, + { + "name": "Unblock User In Chat", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/chat/blocks/{{targetUserId}}/unblock" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });" + ] + } + } + ] + }, + { + "name": "Get Chat Block Status", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/chat/blocks/status/{{targetUserId}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json).to.have.property(\u0027iBlocked\u0027);", + "pm.expect(json).to.have.property(\u0027blockedMe\u0027);" + ] + } + } + ] + }, + { + "name": "Get My Chat Blocks", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/chat/blocks" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an(\u0027array\u0027);" + ] + } + } + ] + } + ] + }, + { + "name": "Feed", + "item": [ + { + "name": "Get My Feed Mixed Home", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/feed/me?page=1\u0026limit=20\u0026includeSuggestions={{feedIncludeSuggestions}}\u0026suggestionInterval={{feedSuggestionInterval}}\u0026followingOnly={{feedFollowingOnly}}\u0026radiusKm={{feedRadiusKm}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an(\u0027array\u0027);", + "if (json.items \u0026\u0026 json.items.length \u003e 0) {", + " pm.expect(json.items[0]).to.have.property(\u0027feedItemType\u0027);", + "}", + "const hasMixedItems = (json.items || []).some(item =\u003e item.feedItemType === \u0027suggested_users\u0027 || item.feedItemType === \u0027featured_marketplace\u0027);", + "pm.environment.set(\u0027homeFeedHasMixedItems\u0027, hasMixedItems ? \u0027true\u0027 : \u0027false\u0027);" + ] + } + } + ] + }, + { + "name": "Get My Feed", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/feed/me?limit=20\u0026cursor={{feedCursor}}\u0026followingOnly={{feedFollowingOnly}}\u0026radiusKm={{feedRadiusKm}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an(\u0027array\u0027);", + "pm.expect(json.pagination).to.exist;", + "const firstPost = (json.items || []).find(item =\u003e item.feedItemType === \u0027post\u0027);", + "if (firstPost) {", + " pm.expect(firstPost).to.have.property(\u0027likedByMe\u0027);", + " pm.expect(firstPost).to.have.property(\u0027savedByMe\u0027);", + " pm.expect(firstPost).to.have.property(\u0027engagement\u0027);", + "}", + "pm.environment.set(\u0027feedCursor\u0027, json.nextCursor || \u0027\u0027);" + ] + } + } + ] + }, + { + "name": "Get My Feed Preferred Type", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/feed/me?limit=20\u0026cursor={{feedCursor}}\u0026preferredPostType={{feedPreferredPostType}}\u0026followingOnly={{feedFollowingOnly}}\u0026radiusKm={{feedRadiusKm}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.pagination).to.exist;", + "const firstPost = (json.items || []).find(item =\u003e item.feedItemType === \u0027post\u0027);", + "if (firstPost) { pm.expect(firstPost).to.have.property(\u0027engagement\u0027); }", + "pm.environment.set(\u0027feedCursor\u0027, json.nextCursor || \u0027\u0027);" + ] + } + } + ] + }, + { + "name": "Get Trending Feed", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/feed/trending?limit=20\u0026cursor={{feedCursor}}\u0026preferredPostType={{feedPreferredPostType}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an(\u0027array\u0027);", + "pm.expect(json.pagination).to.exist;", + "const firstPost = (json.items || []).find(item =\u003e item.feedItemType === \u0027post\u0027);", + "if (firstPost) {", + " pm.expect(firstPost).to.have.property(\u0027likedByMe\u0027);", + " pm.expect(firstPost).to.have.property(\u0027savedByMe\u0027);", + "}", + "pm.environment.set(\u0027feedCursor\u0027, json.nextCursor || \u0027\u0027);" + ] + } + } + ] + } + ] + }, + { + "name": "Smoke", + "item": [ + { + "name": "Login (Smoke)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/auth/login", + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"{{registerEmail}}\",\n \"password\": \"StrongPass123!\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "const uid = json.user._id || json.user.id;", + "pm.environment.set(\u0027accessToken\u0027, json.accessToken);", + "pm.environment.set(\u0027refreshToken\u0027, json.refreshToken);", + "pm.environment.set(\u0027userId\u0027, uid);" + ] + } + } + ] + }, + { + "name": "Get User Posts (Smoke)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/posts/user/{{userId}}?page=1\u0026limit=10", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ] + } + }, + { + "name": "Get My Feed (Smoke)", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/feed/me?limit=20\u0026cursor={{feedCursor}}\u0026radiusKm=30" + } + } + ] + }, + { + "name": "Full E2E", + "item": [ + { + "name": "Create Post (Own) - E2E", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/posts", + "body": { + "mode": "raw", + "raw": "{\n \"content\": \"E2E text post\",\n \"visibility\": \"public\"\n}" + } + } + }, + { + "name": "Create Comment - E2E", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/comments", + "body": { + "mode": "raw", + "raw": "{\n \"postId\": \"{{postId}}\",\n \"content\": \"E2E comment\"\n}" + } + } + }, + { + "name": "Like Post - E2E", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/likes", + "body": { + "mode": "raw", + "raw": "{\n \"targetId\": \"{{postId}}\",\n \"targetType\": \"post\"\n}" + } + } + }, + { + "name": "Save Post - E2E", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/saves", + "body": { + "mode": "raw", + "raw": "{\n \"postId\": \"{{postId}}\"\n}" + } + } + }, + { + "name": "Delete Post - E2E Cleanup", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/posts/{{postId}}" + } + } + ] + }, + { + "name": "Negative Tests", + "item": [ + { + "name": "Create Invalid Post (Video+Audio) - Negative", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/posts", + "body": { + "mode": "raw", + "raw": "{\n \"content\": \"Invalid post\",\n \"videoUrl\": \"https://cdn.example.com/video.mp4\",\n \"audioUrl\": \"https://cdn.example.com/audio.mp3\",\n \"visibility\": \"public\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 400\u0027, function () { pm.response.to.have.status(400); });" + ] + } + } + ] + }, + { + "name": "Create Post Without Token - Negative", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/posts", + "body": { + "mode": "raw", + "raw": "{\n \"content\": \"No token request\",\n \"visibility\": \"public\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 401\u0027, function () { pm.response.to.have.status(401); });" + ] + } + } + ] + } + ] + }, + { + "name": "Cleanup", + "item": [ + { + "name": "Delete Own Comment (if exists)", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/comments/{{ownCommentId}}" + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (!pm.environment.get(\u0027ownCommentId\u0027)) { postman.setNextRequest(\u0027Delete Own Post (if exists)\u0027); }" + ] + } + } + ] + }, + { + "name": "Delete Own Post (if exists)", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/posts/{{ownPostId}}" + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (!pm.environment.get(\u0027ownPostId\u0027)) { postman.setNextRequest(\u0027Delete Instrument (if exists)\u0027); }" + ] + } + } + ] + }, + { + "name": "Delete Instrument (if exists)", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{adminAccessToken}}" + } + ], + "url": "{{baseUrl}}/marketplace/admin/instruments/{{instrumentId}}" + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (!pm.environment.get(\u0027instrumentId\u0027)) { postman.setNextRequest(null); }" + ] + } + } + ] + } + ] + }, + { + "name": "AI Music", + "item": [ + { + "name": "Generate Music From Text (Gemini)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/media/ai/text-to-music", + "body": { + "mode": "raw", + "raw": "{\n \"prompt\": \"{{aiMusicPrompt}}\",\n \"durationSeconds\": {{aiMusicDuration}},\n \"seed\": {{aiMusicSeed}}\n}" + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (!pm.environment.get(\u0027aiMusicPrompt\u0027)) { pm.environment.set(\u0027aiMusicPrompt\u0027, \u0027Calm oud melody with light percussion and emotional Arabic mood\u0027); }", + "if (!pm.environment.get(\u0027aiMusicDuration\u0027)) { pm.environment.set(\u0027aiMusicDuration\u0027, \u002712\u0027); }", + "if (!pm.environment.get(\u0027aiMusicSeed\u0027)) { pm.environment.set(\u0027aiMusicSeed\u0027, \u002742\u0027); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 201\u0027, function () { pm.response.to.have.status(201); });", + "const json = pm.response.json();", + "pm.expect(json.audioUrl).to.exist;", + "pm.expect(json.mimeType).to.exist;", + "pm.expect(json.sizeBytes).to.be.a(\u0027number\u0027);", + "pm.environment.set(\u0027generatedMusicUrl\u0027, json.audioUrl);" + ] + } + } + ] + } + ] + }, + { + "name": "Marketplace", + "item": [ + { + "name": "01 Marketplace Overview", + "item": [ + { + "name": "Public Marketplace Home", + "request": { + "method": "GET", + "header": [ + + ], + "url": "{{baseUrl}}/marketplace/home?listingsLimit=4\u0026instrumentsLimit=4\u0026repairShopsLimit=3\u0026onlyActive=true" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json).to.have.property(\u0027categories\u0027);", + "pm.expect(json).to.have.property(\u0027summary\u0027);", + "pm.expect(json).to.have.property(\u0027featuredShops\u0027);", + "pm.expect(json.filters).to.have.property(\u0027listingCategories\u0027);", + "pm.expect(json.sections).to.have.property(\u0027listings\u0027);", + "pm.expect(json.sections).to.have.property(\u0027musicalInstruments\u0027);", + "pm.expect(json.sections).to.have.property(\u0027repairShops\u0027);" + ] + } + } + ] + } + ] + }, + { + "name": "02 Shop Profile", + "item": [ + { + "name": "Admin", + "item": [ + { + "name": "Admin Update Shop Profile", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{adminAccessToken}}" + } + ], + "url": "{{baseUrl}}/marketplace/admin/shop-profile", + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "shopName", + "value": "Awtarna Store", + "type": "text" + }, + { + "key": "shopDescription", + "value": "Trusted marketplace shop profile", + "type": "text" + }, + { + "key": "shopLocation", + "value": "Riyadh", + "type": "text" + }, + { + "key": "shopLatitude", + "value": "24.7136", + "type": "text" + }, + { + "key": "shopLongitude", + "value": "46.6753", + "type": "text" + }, + { + "key": "shopImageFiles", + "type": "file", + "src": [ + + ], + "disabled": true + }, + { + "key": "shopImageFiles", + "type": "file", + "src": [ + + ], + "disabled": true + } + ] + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (!pm.environment.get(\u0027adminAccessToken\u0027)) { throw new Error(\u0027Missing environment var: adminAccessToken\u0027); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.shopName).to.eql(\u0027Oudelaa Music Store\u0027);" + ] + } + } + ] + }, + { + "name": "Admin Get My Shop Profile", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{adminAccessToken}}" + } + ], + "url": "{{baseUrl}}/marketplace/admin/shop-profile/me" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json).to.have.property(\u0027shopName\u0027);" + ] + } + } + ] + } + ] + }, + { + "name": "Public", + "item": [ + { + "name": "Public Get Shop By Admin Id", + "request": { + "method": "GET", + "header": [ + + ], + "url": "{{baseUrl}}/marketplace/shops/{{adminUserId}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json).to.have.property(\u0027shopName\u0027);" + ] + } + } + ] + } + ] + } + ] + }, + { + "name": "03 Repair Shops", + "item": [ + { + "name": "Admin", + "item": [ + { + "name": "Admin Create Repair Shop (One Shop Per Admin)", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{adminAccessToken}}" + } + ], + "url": "{{baseUrl}}/marketplace/admin/repair-shops", + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "name", + "value": "Fix Strings Workshop", + "type": "text" + }, + { + "key": "description", + "value": "Repair shop for oud and violin", + "type": "text" + }, + { + "key": "services", + "value": "repair,setup,cleaning", + "type": "text" + }, + { + "key": "phone", + "value": "+966500000000", + "type": "text" + }, + { + "key": "whatsapp", + "value": "+966500000000", + "type": "text" + }, + { + "key": "location", + "value": "Riyadh", + "type": "text" + }, + { + "key": "latitude", + "value": "24.7136", + "type": "text" + }, + { + "key": "longitude", + "value": "46.6753", + "type": "text" + }, + { + "key": "isActive", + "value": "true", + "type": "text" + }, + { + "key": "imageFiles", + "type": "file", + "src": [ + + ], + "disabled": true + }, + { + "key": "imageFiles", + "type": "file", + "src": [ + + ], + "disabled": true + } + ] + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (!pm.environment.get(\u0027adminAccessToken\u0027)) { throw new Error(\u0027Missing environment var: adminAccessToken\u0027); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 201\u0027, function () { pm.response.to.have.status(201); });", + "const json = pm.response.json();", + "pm.environment.set(\u0027repairShopId\u0027, json._id || json.id);", + "pm.expect(json.name).to.eql(\u0027Oudelaa Repair Center\u0027);" + ] + } + } + ] + }, + { + "name": "Admin Create Second Repair Shop Should Fail", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{adminAccessToken}}" + } + ], + "url": "{{baseUrl}}/marketplace/admin/repair-shops", + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "name", + "value": "Fix Strings Workshop", + "type": "text" + }, + { + "key": "description", + "value": "Repair shop for oud and violin", + "type": "text" + }, + { + "key": "services", + "value": "repair,setup,cleaning", + "type": "text" + }, + { + "key": "phone", + "value": "+966500000000", + "type": "text" + }, + { + "key": "whatsapp", + "value": "+966500000000", + "type": "text" + }, + { + "key": "location", + "value": "Riyadh", + "type": "text" + }, + { + "key": "latitude", + "value": "24.7136", + "type": "text" + }, + { + "key": "longitude", + "value": "46.6753", + "type": "text" + }, + { + "key": "isActive", + "value": "true", + "type": "text" + }, + { + "key": "imageFiles", + "type": "file", + "src": [ + + ], + "disabled": true + }, + { + "key": "imageFiles", + "type": "file", + "src": [ + + ], + "disabled": true + } + ] + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (!pm.environment.get(\u0027adminAccessToken\u0027)) { throw new Error(\u0027Missing environment var: adminAccessToken\u0027); }", + "if (!pm.environment.get(\u0027repairShopId\u0027)) { throw new Error(\u0027Create the first repair shop before running this negative test.\u0027); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 400\u0027, function () { pm.response.to.have.status(400); });", + "const json = pm.response.json();", + "const messages = Array.isArray(json.message) ? json.message : [json.message];", + "pm.expect(messages.join(\u0027 \u0027)).to.include(\u0027Each admin can create one marketplace shop only\u0027);" + ] + } + } + ] + }, + { + "name": "Admin Update Repair Shop", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{adminAccessToken}}" + } + ], + "url": "{{baseUrl}}/marketplace/admin/repair-shops/{{repairShopId}}", + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "name", + "value": "Updated Fix Strings Workshop", + "type": "text" + }, + { + "key": "description", + "value": "Updated repair shop description", + "type": "text" + }, + { + "key": "services", + "value": "repair,setup,polishing", + "type": "text" + }, + { + "key": "phone", + "value": "+966511111111", + "type": "text" + }, + { + "key": "whatsapp", + "value": "+966511111111", + "type": "text" + }, + { + "key": "location", + "value": "Jeddah", + "type": "text" + }, + { + "key": "latitude", + "value": "21.5433", + "type": "text" + }, + { + "key": "longitude", + "value": "39.1728", + "type": "text" + }, + { + "key": "isActive", + "value": "true", + "type": "text" + }, + { + "key": "imageFiles", + "type": "file", + "src": [ + + ], + "disabled": true + }, + { + "key": "imageFiles", + "type": "file", + "src": [ + + ], + "disabled": true + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });" + ] + } + } + ] + }, + { + "name": "Admin Get My Repair Shops", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{adminAccessToken}}" + } + ], + "url": "{{baseUrl}}/marketplace/admin/repair-shops/me?q={{repairShopQuery}}\u0026isActive={{repairShopIsActive}}\u0026sortBy={{repairShopSortBy}}\u0026sortOrder={{listSortOrder}}\u0026page=1\u0026limit=20" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an(\u0027array\u0027);", + "pm.expect(json.pagination).to.exist;", + "if (json.items \u0026\u0026 json.items.length \u003e 0) { pm.environment.set(\u0027repairShopId\u0027, json.items[0]._id || json.items[0].id); }" + ] + } + } + ] + }, + { + "name": "Admin Delete Repair Shop", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{adminAccessToken}}" + } + ], + "url": "{{baseUrl}}/marketplace/admin/repair-shops/{{repairShopId}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.success).to.eql(true);" + ] + } + } + ] + } + ] + }, + { + "name": "Public", + "item": [ + { + "name": "Public List Repair Shops", + "request": { + "method": "GET", + "header": [ + + ], + "url": "{{baseUrl}}/marketplace/repair-shops?q={{repairShopQuery}}\u0026isActive={{repairShopIsActive}}\u0026sortBy={{repairShopSortBy}}\u0026sortOrder={{listSortOrder}}\u0026page=1\u0026limit=20" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an(\u0027array\u0027);", + "pm.expect(json.pagination).to.exist;", + "if (json.items \u0026\u0026 json.items.length \u003e 0) { pm.environment.set(\u0027repairShopId\u0027, json.items[0]._id || json.items[0].id); }" + ] + } + } + ] + }, + { + "name": "Public Get Repair Shop By Id", + "request": { + "method": "GET", + "header": [ + + ], + "url": "{{baseUrl}}/marketplace/repair-shops/{{repairShopId}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });" + ] + } + } + ] + } + ] + } + ] + }, + { + "name": "04 Musical Instruments", + "item": [ + { + "name": "Admin", + "item": [ + { + "name": "Admin Create Instrument", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{adminAccessToken}}" + } + ], + "url": "{{baseUrl}}/marketplace/admin/instruments", + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "title", + "value": "Concert Guitar", + "type": "text" + }, + { + "key": "description", + "value": "Acoustic guitar in excellent condition", + "type": "text" + }, + { + "key": "price", + "value": "2100", + "type": "text" + }, + { + "key": "currency", + "value": "SAR", + "type": "text" + }, + { + "key": "quantity", + "value": "1", + "type": "text" + }, + { + "key": "condition", + "value": "used", + "type": "text" + }, + { + "key": "instrumentType", + "value": "Guitar", + "type": "text" + }, + { + "key": "imageFiles", + "type": "file", + "src": [ + + ], + "disabled": true + }, + { + "key": "imageFiles", + "type": "file", + "src": [ + + ], + "disabled": true + } + ] + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (!pm.environment.get(\u0027adminAccessToken\u0027)) { throw new Error(\u0027Missing environment var: adminAccessToken\u0027); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 201\u0027, function () { pm.response.to.have.status(201); });", + "const json = pm.response.json();", + "pm.environment.set(\u0027instrumentId\u0027, json._id || json.id);" + ] + } + } + ] + }, + { + "name": "Admin Update Instrument", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{adminAccessToken}}" + } + ], + "url": "{{baseUrl}}/marketplace/admin/instruments/{{instrumentId}}", + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "title", + "value": "Updated Concert Guitar", + "type": "text" + }, + { + "key": "description", + "value": "Updated instrument description", + "type": "text" + }, + { + "key": "price", + "value": "2400", + "type": "text" + }, + { + "key": "currency", + "value": "SAR", + "type": "text" + }, + { + "key": "quantity", + "value": "1", + "type": "text" + }, + { + "key": "condition", + "value": "used", + "type": "text" + }, + { + "key": "instrumentType", + "value": "Violin", + "type": "text" + }, + { + "key": "imageFiles", + "type": "file", + "src": [ + + ], + "disabled": true + }, + { + "key": "imageFiles", + "type": "file", + "src": [ + + ], + "disabled": true + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });" + ] + } + } + ] + }, + { + "name": "Admin Get My Instruments", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{adminAccessToken}}" + } + ], + "url": "{{baseUrl}}/marketplace/admin/instruments/me?q={{marketplaceQuery}}\u0026minPrice={{marketplaceMinPrice}}\u0026maxPrice={{marketplaceMaxPrice}}\u0026isActive={{marketplaceIsActive}}\u0026condition={{marketplaceCondition}}\u0026instrumentType={{marketplaceInstrumentType}}\u0026sortBy={{marketplaceSortBy}}\u0026sortOrder={{listSortOrder}}\u0026page=1\u0026limit=20" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an(\u0027array\u0027);", + "pm.expect(json.pagination).to.exist;", + "if (json.items \u0026\u0026 json.items.length \u003e 0) { pm.environment.set(\u0027instrumentId\u0027, json.items[0]._id || json.items[0].id); }" + ] + } + } + ] + }, + { + "name": "Admin Delete Instrument", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{adminAccessToken}}" + } + ], + "url": "{{baseUrl}}/marketplace/admin/instruments/{{instrumentId}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.success).to.eql(true);" + ] + } + } + ] + } + ] + }, + { + "name": "Public", + "item": [ + { + "name": "Public List Instruments", + "request": { + "method": "GET", + "header": [ + + ], + "url": "{{baseUrl}}/marketplace/instruments?q={{marketplaceQuery}}\u0026minPrice={{marketplaceMinPrice}}\u0026maxPrice={{marketplaceMaxPrice}}\u0026isActive={{marketplaceIsActive}}\u0026condition={{marketplaceCondition}}\u0026instrumentType={{marketplaceInstrumentType}}\u0026sortBy={{marketplaceSortBy}}\u0026sortOrder={{listSortOrder}}\u0026page=1\u0026limit=20" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an(\u0027array\u0027);", + "pm.expect(json.pagination).to.exist;", + "if (json.items \u0026\u0026 json.items.length \u003e 0) { pm.environment.set(\u0027instrumentId\u0027, json.items[0]._id || json.items[0].id); }" + ] + } + } + ] + }, + { + "name": "Public Get Instrument By Id", + "request": { + "method": "GET", + "header": [ + + ], + "url": "{{baseUrl}}/marketplace/instruments/{{instrumentId}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });" + ] + } + } + ] + } + ] + } + ] + }, + { + "name": "05 General Marketplace", + "item": [ + { + "name": "Admin", + "item": [ + { + "name": "Admin Create Listing", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{adminAccessToken}}" + } + ], + "url": "{{baseUrl}}/marketplace/admin/listings", + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "title", + "value": "Microphone SM58", + "type": "text" + }, + { + "key": "description", + "value": "Durable dynamic microphone for studio and stage", + "type": "text" + }, + { + "key": "price", + "value": "450", + "type": "text" + }, + { + "key": "currency", + "value": "SAR", + "type": "text" + }, + { + "key": "quantity", + "value": "1", + "type": "text" + }, + { + "key": "condition", + "value": "used", + "type": "text" + }, + { + "key": "instrumentType", + "value": "Microphone", + "type": "text" + }, + { + "key": "listingCategory", + "value": "audio_gear", + "type": "text" + }, + { + "key": "isActive", + "value": "true", + "type": "text" + }, + { + "key": "imageFiles", + "type": "file", + "src": [ + + ], + "disabled": true + }, + { + "key": "imageFiles", + "type": "file", + "src": [ + + ], + "disabled": true + } + ] + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (!pm.environment.get(\u0027adminAccessToken\u0027)) { throw new Error(\u0027Missing environment var: adminAccessToken\u0027); }" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 201\u0027, function () { pm.response.to.have.status(201); });", + "const json = pm.response.json();", + "pm.environment.set(\u0027listingId\u0027, json._id || json.id);", + "pm.expect(json.listingCategory).to.eql(\u0027accessory\u0027);" + ] + } + } + ] + }, + { + "name": "Admin Update Listing", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{adminAccessToken}}" + } + ], + "url": "{{baseUrl}}/marketplace/admin/listings/{{listingId}}", + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "title", + "value": "Updated Microphone SM58", + "type": "text" + }, + { + "key": "description", + "value": "Updated marketplace listing description", + "type": "text" + }, + { + "key": "price", + "value": "500", + "type": "text" + }, + { + "key": "currency", + "value": "SAR", + "type": "text" + }, + { + "key": "quantity", + "value": "1", + "type": "text" + }, + { + "key": "condition", + "value": "used", + "type": "text" + }, + { + "key": "instrumentType", + "value": "Microphone", + "type": "text" + }, + { + "key": "listingCategory", + "value": "audio_gear", + "type": "text" + }, + { + "key": "isActive", + "value": "true", + "type": "text" + }, + { + "key": "imageFiles", + "type": "file", + "src": [ + + ], + "disabled": true + }, + { + "key": "imageFiles", + "type": "file", + "src": [ + + ], + "disabled": true + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });" + ] + } + } + ] + }, + { + "name": "Admin Get My Listings", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{adminAccessToken}}" + } + ], + "url": "{{baseUrl}}/marketplace/admin/listings/me?q={{marketplaceQuery}}\u0026minPrice={{marketplaceMinPrice}}\u0026maxPrice={{marketplaceMaxPrice}}\u0026isActive={{marketplaceIsActive}}\u0026listingCategory={{marketplaceListingCategory}}\u0026condition={{marketplaceCondition}}\u0026instrumentType={{marketplaceInstrumentType}}\u0026sortBy={{marketplaceSortBy}}\u0026sortOrder={{listSortOrder}}\u0026page=1\u0026limit=20" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an(\u0027array\u0027);", + "pm.expect(json.pagination).to.exist;", + "if (json.items \u0026\u0026 json.items.length \u003e 0) { pm.environment.set(\u0027listingId\u0027, json.items[0]._id || json.items[0].id); }" + ] + } + } + ] + }, + { + "name": "Admin Delete Listing", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{adminAccessToken}}" + } + ], + "url": "{{baseUrl}}/marketplace/admin/listings/{{listingId}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.success).to.eql(true);" + ] + } + } + ] + } + ] + }, + { + "name": "Public", + "item": [ + { + "name": "Public List Listings", + "request": { + "method": "GET", + "header": [ + + ], + "url": "{{baseUrl}}/marketplace/listings?q={{marketplaceQuery}}\u0026minPrice={{marketplaceMinPrice}}\u0026maxPrice={{marketplaceMaxPrice}}\u0026isActive={{marketplaceIsActive}}\u0026listingCategory={{marketplaceListingCategory}}\u0026condition={{marketplaceCondition}}\u0026instrumentType={{marketplaceInstrumentType}}\u0026sortBy={{marketplaceSortBy}}\u0026sortOrder={{listSortOrder}}\u0026page=1\u0026limit=20" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items).to.be.an(\u0027array\u0027);", + "pm.expect(json.pagination).to.exist;", + "if (json.items \u0026\u0026 json.items.length \u003e 0) { pm.environment.set(\u0027listingId\u0027, json.items[0]._id || json.items[0].id); }" + ] + } + } + ] + }, + { + "name": "Public Get Listing By Id", + "request": { + "method": "GET", + "header": [ + + ], + "url": "{{baseUrl}}/marketplace/listings/{{listingId}}" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });" + ] + } + } + ] + } + ] + } + ] + } + ] + } + ], + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:4000/api/v1", + "type": "string" + }, + { + "key": "accessToken", + "value": "", + "type": "string" + }, + { + "key": "refreshToken", + "value": "", + "type": "string" + }, + { + "key": "superAdminAccessToken", + "value": "", + "type": "string" + }, + { + "key": "superAdminRefreshToken", + "value": "", + "type": "string" + }, + { + "key": "targetAccessToken", + "value": "", + "type": "string" + }, + { + "key": "targetRefreshToken", + "value": "", + "type": "string" + }, + { + "key": "userId", + "value": "", + "type": "string" + }, + { + "key": "currentUserId", + "value": "", + "type": "string" + }, + { + "key": "targetUserId", + "value": "", + "type": "string" + }, + { + "key": "targetUsername", + "value": "", + "type": "string" + }, + { + "key": "lastFollowingState", + "value": "", + "type": "string" + }, + { + "key": "notificationUnreadCount", + "value": "", + "type": "string" + }, + { + "key": "notificationId", + "value": "", + "type": "string" + }, + { + "key": "postId", + "value": "", + "type": "string" + }, + { + "key": "ownPostId", + "value": "", + "type": "string" + }, + { + "key": "targetPostId", + "value": "", + "type": "string" + }, + { + "key": "adminPostId", + "value": "", + "type": "string" + }, + { + "key": "commentId", + "value": "", + "type": "string" + }, + { + "key": "ownCommentId", + "value": "", + "type": "string" + }, + { + "key": "conversationId", + "value": "", + "type": "string" + }, + { + "key": "messageId", + "value": "", + "type": "string" + }, + { + "key": "sessionJti", + "value": "", + "type": "string" + }, + { + "key": "registerEmail", + "value": "", + "type": "string" + }, + { + "key": "registerUsername", + "value": "", + "type": "string" + }, + { + "key": "adminUserId", + "value": "", + "type": "string" + }, + { + "key": "targetLoginEmail", + "value": "", + "type": "string" + }, + { + "key": "targetLoginPassword", + "value": "", + "type": "string" + }, + { + "key": "resetCode", + "value": "", + "type": "string" + }, + { + "key": "resetToken", + "value": "", + "type": "string" + }, + { + "key": "emailVerificationCode", + "value": "", + "type": "string" + }, + { + "key": "googleIdToken", + "value": "", + "type": "string" + }, + { + "key": "newPassword", + "value": "NewStrongPass123!", + "type": "string" + }, + { + "key": "feedCursor", + "value": "", + "type": "string" + }, + { + "key": "chatConversationsCursor", + "value": "", + "type": "string" + }, + { + "key": "chatMessagesCursor", + "value": "", + "type": "string" + }, + { + "key": "adminAccessToken", + "value": "", + "type": "string" + }, + { + "key": "adminRefreshToken", + "value": "", + "type": "string" + }, + { + "key": "adminEmail", + "value": "store_admin@example.com", + "type": "string" + }, + { + "key": "adminUsername", + "value": "store_admin_01", + "type": "string" + }, + { + "key": "adminPassword", + "value": "AdminStrongPass123!", + "type": "string" + }, + { + "key": "usersQuery", + "value": "user", + "type": "string" + }, + { + "key": "usersVerified", + "value": "true", + "type": "string" + }, + { + "key": "usersSortBy", + "value": "createdAt", + "type": "string" + }, + { + "key": "listSortOrder", + "value": "desc", + "type": "string" + }, + { + "key": "postVisibility", + "value": "public", + "type": "string" + }, + { + "key": "postSortBy", + "value": "createdAt", + "type": "string" + }, + { + "key": "postTypeFilter", + "value": "image", + "type": "string" + }, + { + "key": "postSearchQuery", + "value": "music", + "type": "string" + }, + { + "key": "postHashtag", + "value": "music", + "type": "string" + }, + { + "key": "reelQuery", + "value": "reel", + "type": "string" + }, + { + "key": "notificationRead", + "value": "false", + "type": "string" + }, + { + "key": "notificationType", + "value": "mention", + "type": "string" + }, + { + "key": "notificationResourceType", + "value": "post", + "type": "string" + }, + { + "key": "feedPreferredPostType", + "value": "video", + "type": "string" + }, + { + "key": "feedIncludeSuggestions", + "value": "true", + "type": "string" + }, + { + "key": "feedSuggestionInterval", + "value": "4", + "type": "string" + }, + { + "key": "feedFollowingOnly", + "value": "false", + "type": "string" + }, + { + "key": "feedRadiusKm", + "value": "30", + "type": "string" + }, + { + "key": "homeFeedHasMixedItems", + "value": "", + "type": "string" + }, + { + "key": "lastViewCount", + "value": "", + "type": "string" + }, + { + "key": "lastPlayCount", + "value": "", + "type": "string" + }, + { + "key": "lastShareCount", + "value": "", + "type": "string" + }, + { + "key": "marketplaceQuery", + "value": "oud", + "type": "string" + }, + { + "key": "marketplaceMinPrice", + "value": "1000", + "type": "string" + }, + { + "key": "marketplaceMaxPrice", + "value": "5000", + "type": "string" + }, + { + "key": "marketplaceIsActive", + "value": "true", + "type": "string" + }, + { + "key": "marketplaceSortBy", + "value": "price", + "type": "string" + }, + { + "key": "repairShopQuery", + "value": "riyadh", + "type": "string" + }, + { + "key": "repairShopIsActive", + "value": "true", + "type": "string" + }, + { + "key": "repairShopSortBy", + "value": "name", + "type": "string" + }, + { + "key": "instrumentId", + "value": "", + "type": "string" + }, + { + "key": "repairShopId", + "value": "", + "type": "string" + }, + { + "key": "generatedMusicUrl", + "value": "", + "type": "string" + }, + { + "key": "aiMusicPrompt", + "value": "Calm oud melody with light percussion and emotional Arabic mood", + "type": "string" + }, + { + "key": "aiMusicDuration", + "value": "12", + "type": "string" + }, + { + "key": "aiMusicSeed", + "value": "42", + "type": "string" + }, + { + "key": "listingId", + "value": "", + "type": "string" + }, + { + "key": "marketplaceListingCategory", + "value": "accessory", + "type": "string" + }, + { + "key": "marketplaceCondition", + "value": "used", + "type": "string" + }, + { + "key": "marketplaceInstrumentType", + "value": "oud", + "type": "string" + }, + { + "key": "talentRole", + "value": "instrumentalist", + "type": "string" + }, + { + "key": "talentExperienceLevel", + "value": "", + "type": "string" + }, + { + "key": "usersHasAvatar", + "value": "true", + "type": "string" + } + ] } diff --git a/scripts/load-test.js b/scripts/load-test.js new file mode 100644 index 0000000..7a5fe5a --- /dev/null +++ b/scripts/load-test.js @@ -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 [--duration 15] [--concurrency 20]'); + console.log('Optional: --method POST --header "Authorization: Bearer " --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; +}); diff --git a/scripts/startup-benchmark.js b/scripts/startup-benchmark.js new file mode 100644 index 0000000..31b70af --- /dev/null +++ b/scripts/startup-benchmark.js @@ -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; +}); diff --git a/src/app.module.ts b/src/app.module.ts index b5adde8..76e97df 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,6 +6,11 @@ import { AppService } from './app.service'; import configuration from './config/configuration'; import { validationSchema } from './config/validation.schema'; 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 { AuditModule } from './modules/audit/audit.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 { PostsModule } from './modules/posts/posts.module'; import { SavesModule } from './modules/saves/saves.module'; +import { SuperAdminModule } from './modules/superadmin/superadmin.module'; import { UsersModule } from './modules/users/users.module'; import { ThrottleGuard } from './common/guards/throttle.guard'; @@ -30,6 +36,11 @@ import { ThrottleGuard } from './common/guards/throttle.guard'; load: [configuration], validationSchema, }), + LoggingModule, + RedisModule, + CacheModule, + StorageModule, + QueueModule, DatabaseModule, AuditModule, UsersModule, @@ -45,6 +56,7 @@ import { ThrottleGuard } from './common/guards/throttle.guard'; MediaModule, MarketplaceModule, SavesModule, + SuperAdminModule, ], controllers: [AppController], providers: [ diff --git a/src/common/decorators/superadmin-permissions.decorator.ts b/src/common/decorators/superadmin-permissions.decorator.ts new file mode 100644 index 0000000..1fbb130 --- /dev/null +++ b/src/common/decorators/superadmin-permissions.decorator.ts @@ -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); diff --git a/src/common/dto/pagination-query.dto.ts b/src/common/dto/pagination-query.dto.ts index 38f4cf2..3ebe7f9 100644 --- a/src/common/dto/pagination-query.dto.ts +++ b/src/common/dto/pagination-query.dto.ts @@ -1,14 +1,18 @@ -import { Type } from 'class-transformer'; -import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +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'; export class PaginationQueryDto { + @ApiPropertyOptional({ default: APP_CONSTANTS.DEFAULT_PAGE }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number = APP_CONSTANTS.DEFAULT_PAGE; + @ApiPropertyOptional({ default: APP_CONSTANTS.DEFAULT_LIMIT, maximum: APP_CONSTANTS.MAX_LIMIT }) @IsOptional() @Type(() => Number) @IsInt() @@ -16,7 +20,20 @@ export class PaginationQueryDto { @Max(APP_CONSTANTS.MAX_LIMIT) limit?: number = APP_CONSTANTS.DEFAULT_LIMIT; + @ApiPropertyOptional({ description: 'Cursor token for cursor-based endpoints' }) @IsOptional() @IsString() 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; } diff --git a/src/common/enums/moderation-status.enum.ts b/src/common/enums/moderation-status.enum.ts new file mode 100644 index 0000000..7712224 --- /dev/null +++ b/src/common/enums/moderation-status.enum.ts @@ -0,0 +1,5 @@ +export enum ModerationStatus { + ACTIVE = 'active', + HIDDEN = 'hidden', + FLAGGED = 'flagged', +} diff --git a/src/common/enums/notification-type.enum.ts b/src/common/enums/notification-type.enum.ts index 2aba8d8..2e8bbed 100644 --- a/src/common/enums/notification-type.enum.ts +++ b/src/common/enums/notification-type.enum.ts @@ -3,4 +3,7 @@ export enum NotificationType { COMMENT = 'comment', FOLLOW = 'follow', MESSAGE = 'message', + SAVE = 'save', + SHARE = 'share', + MENTION = 'mention', } diff --git a/src/common/enums/post-type.enum.ts b/src/common/enums/post-type.enum.ts index 994cd65..965366c 100644 --- a/src/common/enums/post-type.enum.ts +++ b/src/common/enums/post-type.enum.ts @@ -1,5 +1,6 @@ export enum PostType { TEXT = 'text', + IMAGE = 'image', VIDEO = 'video', AUDIO = 'audio', } diff --git a/src/common/enums/repair-request-status.enum.ts b/src/common/enums/repair-request-status.enum.ts new file mode 100644 index 0000000..5c3adcb --- /dev/null +++ b/src/common/enums/repair-request-status.enum.ts @@ -0,0 +1,5 @@ +export enum RepairRequestStatus { + PENDING = 'pending', + ACCEPTED = 'accepted', + COMPLETED = 'completed', +} diff --git a/src/common/enums/sort-order.enum.ts b/src/common/enums/sort-order.enum.ts new file mode 100644 index 0000000..cabb1f2 --- /dev/null +++ b/src/common/enums/sort-order.enum.ts @@ -0,0 +1,4 @@ +export enum SortOrder { + ASC = 'asc', + DESC = 'desc', +} diff --git a/src/common/guards/superadmin-permissions.guard.ts b/src/common/guards/superadmin-permissions.guard.ts new file mode 100644 index 0000000..c5a0f07 --- /dev/null +++ b/src/common/guards/superadmin-permissions.guard.ts @@ -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( + 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; + } +} diff --git a/src/common/guards/throttle.guard.ts b/src/common/guards/throttle.guard.ts index b3edf79..f47ebb4 100644 --- a/src/common/guards/throttle.guard.ts +++ b/src/common/guards/throttle.guard.ts @@ -6,20 +6,17 @@ import { Injectable, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; +import { AppCacheService } from '../../infrastructure/cache/app-cache.service'; import { THROTTLE_META_KEY, ThrottleMeta } from '../decorators/throttle.decorator'; -type Bucket = { - count: number; - resetAt: number; -}; - @Injectable() export class ThrottleGuard implements CanActivate { - private readonly buckets = new Map(); + constructor( + private readonly reflector: Reflector, + private readonly cacheService: AppCacheService, + ) {} - constructor(private readonly reflector: Reflector) {} - - canActivate(context: ExecutionContext): boolean { + async canActivate(context: ExecutionContext): Promise { const meta = this.reflector.getAllAndOverride(THROTTLE_META_KEY, [ context.getHandler(), context.getClass(), @@ -28,23 +25,25 @@ export class ThrottleGuard implements CanActivate { return true; } - const req = context.switchToHttp().getRequest(); - const ip = req.ip ?? 'unknown'; - const route = req.originalUrl ?? 'unknown-route'; - const key = `${ip}:${route}`; - const now = Date.now(); - const existing = this.buckets.get(key); + const req = context.switchToHttp().getRequest< + Request & { + ip?: string; + originalUrl?: string; + baseUrl?: string; + 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) { - this.buckets.set(key, { count: 1, resetAt: now + meta.windowMs }); - return true; - } - - if (existing.count >= meta.limit) { + if (currentCount > meta.limit) { throw new HttpException('Too many requests, please try again later', HttpStatus.TOO_MANY_REQUESTS); } - existing.count += 1; return true; } } diff --git a/src/common/interfaces/jwt-payload.interface.ts b/src/common/interfaces/jwt-payload.interface.ts index ed2ff9f..3f112da 100644 --- a/src/common/interfaces/jwt-payload.interface.ts +++ b/src/common/interfaces/jwt-payload.interface.ts @@ -4,4 +4,5 @@ export interface JwtPayload { role?: string; tokenType: 'access' | 'refresh' | 'superadmin_access' | 'superadmin_refresh'; email?: string; + permissions?: string[]; } diff --git a/src/common/utils/array-transform.util.spec.ts b/src/common/utils/array-transform.util.spec.ts new file mode 100644 index 0000000..c6ee5ed --- /dev/null +++ b/src/common/utils/array-transform.util.spec.ts @@ -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]); + }); +}); diff --git a/src/common/utils/array-transform.util.ts b/src/common/utils/array-transform.util.ts new file mode 100644 index 0000000..67b78ef --- /dev/null +++ b/src/common/utils/array-transform.util.ts @@ -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)); +}; diff --git a/src/common/utils/hash.util.ts b/src/common/utils/hash.util.ts index a4ff91a..a25a3e4 100644 --- a/src/common/utils/hash.util.ts +++ b/src/common/utils/hash.util.ts @@ -1,7 +1,24 @@ import * as bcrypt from 'bcrypt'; +import { createHmac, timingSafeEqual } from 'crypto'; export const hashValue = async (value: string, saltRounds: number): Promise => bcrypt.hash(value, saltRounds); export const compareHash = async (value: string, hashedValue: string): Promise => 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 => { + if (storedValue.startsWith('sha256:')) { + const nextValue = hashHighEntropyValue(value, secret); + return timingSafeEqual(Buffer.from(nextValue), Buffer.from(storedValue)); + } + + return compareHash(value, storedValue); +}; diff --git a/src/common/utils/pagination.util.spec.ts b/src/common/utils/pagination.util.spec.ts new file mode 100644 index 0000000..9822d38 --- /dev/null +++ b/src/common/utils/pagination.util.spec.ts @@ -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'); + }); +}); diff --git a/src/common/utils/pagination.util.ts b/src/common/utils/pagination.util.ts new file mode 100644 index 0000000..b944858 --- /dev/null +++ b/src/common/utils/pagination.util.ts @@ -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 = { + 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 = ( + items: T[], + options: PaginatedResponseOptions, +): PaginatedResponse => { + 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, + }, + }; +}; diff --git a/src/common/utils/public-url.util.spec.ts b/src/common/utils/public-url.util.spec.ts new file mode 100644 index 0000000..5cc6c64 --- /dev/null +++ b/src/common/utils/public-url.util.spec.ts @@ -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', + ]); + }); +}); diff --git a/src/common/utils/public-url.util.ts b/src/common/utils/public-url.util.ts new file mode 100644 index 0000000..ea260f1 --- /dev/null +++ b/src/common/utils/public-url.util.ts @@ -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)); +}; diff --git a/src/common/utils/query-transform.util.spec.ts b/src/common/utils/query-transform.util.spec.ts new file mode 100644 index 0000000..c7cde8e --- /dev/null +++ b/src/common/utils/query-transform.util.spec.ts @@ -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); + }); +}); diff --git a/src/common/utils/query-transform.util.ts b/src/common/utils/query-transform.util.ts new file mode 100644 index 0000000..269115c --- /dev/null +++ b/src/common/utils/query-transform.util.ts @@ -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; +}; diff --git a/src/common/utils/sort.util.spec.ts b/src/common/utils/sort.util.spec.ts new file mode 100644 index 0000000..668701a --- /dev/null +++ b/src/common/utils/sort.util.spec.ts @@ -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); + }); +}); diff --git a/src/common/utils/sort.util.ts b/src/common/utils/sort.util.ts new file mode 100644 index 0000000..b17b629 --- /dev/null +++ b/src/common/utils/sort.util.ts @@ -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); diff --git a/src/common/utils/waveform.util.ts b/src/common/utils/waveform.util.ts new file mode 100644 index 0000000..ab0bda8 --- /dev/null +++ b/src/common/utils/waveform.util.ts @@ -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); +}; diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 051a469..521bbe9 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -47,8 +47,61 @@ export default () => ({ fromName: process.env.EMAIL_FROM_NAME ?? 'Oudelaa', 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: { 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: { codeExpiresMinutes: Number(process.env.PASSWORD_RESET_CODE_EXPIRES_MINUTES ?? 10), diff --git a/src/config/validation.schema.ts b/src/config/validation.schema.ts index 3b52d0f..e694dca 100644 --- a/src/config/validation.schema.ts +++ b/src/config/validation.schema.ts @@ -30,7 +30,42 @@ export const validationSchema = Joi.object({ EMAIL_SMTP_PASS: Joi.string().allow('').optional(), EMAIL_FROM_NAME: Joi.string().default('Oudelaa'), 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), + 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_MAX_ATTEMPTS: Joi.number().min(1).max(10).default(5), PASSWORD_RESET_TOKEN_SECRET: Joi.string().allow('').optional(), diff --git a/src/infrastructure/cache/app-cache.service.ts b/src/infrastructure/cache/app-cache.service.ts new file mode 100644 index 0000000..fd2367f --- /dev/null +++ b/src/infrastructure/cache/app-cache.service.ts @@ -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(); + + constructor(private readonly redisService: RedisService) {} + + async get(key: string): Promise { + 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(key: string, value: T, ttlSeconds?: number): Promise { + 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 { + const redis = this.redisService.getClient(); + const fullKey = this.buildKey(key); + if (redis) { + await redis.del(fullKey); + return; + } + + this.memory.delete(fullKey); + } + + async remember(key: string, ttlSeconds: number, factory: () => Promise): Promise { + const cached = await this.get(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 { + 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(key); + const nextValue = (existing ?? 0) + 1; + await this.set(key, nextValue, ttlSeconds); + return nextValue; + } + + private buildKey(key: string): string { + return `${this.redisService.getKeyPrefix()}:${key}`; + } +} diff --git a/src/infrastructure/cache/cache.module.ts b/src/infrastructure/cache/cache.module.ts new file mode 100644 index 0000000..20f4a63 --- /dev/null +++ b/src/infrastructure/cache/cache.module.ts @@ -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 {} diff --git a/src/infrastructure/cache/feed-version.service.ts b/src/infrastructure/cache/feed-version.service.ts new file mode 100644 index 0000000..b905dd6 --- /dev/null +++ b/src/infrastructure/cache/feed-version.service.ts @@ -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 { + const current = await this.cacheService.get(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 { + return this.cacheService.incr(FeedVersionService.GLOBAL_VERSION_KEY); + } +} diff --git a/src/infrastructure/logging/app-logger.service.ts b/src/infrastructure/logging/app-logger.service.ts new file mode 100644 index 0000000..99a8518 --- /dev/null +++ b/src/infrastructure/logging/app-logger.service.ts @@ -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 = { + 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): void { + this.write('log', 'http_request', undefined, 'HttpLogger', payload); + } + + private write( + level: AppLogLevel, + message: any, + trace?: string, + context?: string, + extra: Record = {}, + ): void { + if (!this.shouldLog(level)) { + return; + } + + const entry: Record = { + 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('logging.level', { infer: true }) as AppLogLevel | undefined) ?? + 'log'; + + return this.levelPriority[level] <= this.levelPriority[configuredLevel]; + } +} diff --git a/src/infrastructure/logging/logging.module.ts b/src/infrastructure/logging/logging.module.ts new file mode 100644 index 0000000..1e0eef5 --- /dev/null +++ b/src/infrastructure/logging/logging.module.ts @@ -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 {} diff --git a/src/infrastructure/queue/app-queue.service.ts b/src/infrastructure/queue/app-queue.service.ts new file mode 100644 index 0000000..ca7e3b2 --- /dev/null +++ b/src/infrastructure/queue/app-queue.service.ts @@ -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) => Promise; + +@Injectable() +export class AppQueueService + implements OnModuleInit, OnApplicationBootstrap, OnModuleDestroy +{ + private readonly processors = new Map(); + 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); + }, + { + connection: workerConnection, + concurrency: + this.configService.get('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, + options: JobsOptions = {}, + ): Promise { + 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 { + await this.worker?.close(); + await this.queue?.close(); + this.worker = null; + this.queue = null; + } + + private isQueueEnabled(): boolean { + return this.configService.get('queue.enabled', { infer: true }) ?? false; + } + + private getQueueName(): string { + return this.configService.get('queue.name', { infer: true }) ?? 'app-jobs'; + } + + private getDefaultJobOptions(): JobsOptions { + return { + attempts: + this.configService.get('queue.defaultJobAttempts', { infer: true }) ?? 3, + backoff: { + type: 'exponential', + delay: + this.configService.get('queue.defaultJobBackoffMs', { infer: true }) ?? 1000, + }, + removeOnComplete: + this.configService.get('queue.removeOnComplete', { infer: true }) ?? true, + }; + } +} diff --git a/src/infrastructure/queue/queue.module.ts b/src/infrastructure/queue/queue.module.ts new file mode 100644 index 0000000..d49713e --- /dev/null +++ b/src/infrastructure/queue/queue.module.ts @@ -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 {} diff --git a/src/infrastructure/redis/redis.module.ts b/src/infrastructure/redis/redis.module.ts new file mode 100644 index 0000000..b9cfabf --- /dev/null +++ b/src/infrastructure/redis/redis.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { RedisService } from './redis.service'; + +@Global() +@Module({ + providers: [RedisService], + exports: [RedisService], +}) +export class RedisModule {} diff --git a/src/infrastructure/redis/redis.service.ts b/src/infrastructure/redis/redis.service.ts new file mode 100644 index 0000000..6f5360f --- /dev/null +++ b/src/infrastructure/redis/redis.service.ts @@ -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('redis.enabled', { infer: true }) ?? false; + } + + getKeyPrefix(): string { + return this.configService.get('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 = {}): Redis { + const url = this.configService.get('redis.url', { infer: true }) ?? ''; + const baseOptions: RedisOptions = { + host: this.configService.get('redis.host', { infer: true }) ?? '127.0.0.1', + port: this.configService.get('redis.port', { infer: true }) ?? 6379, + username: this.configService.get('redis.username', { infer: true }) || undefined, + password: this.configService.get('redis.password', { infer: true }) || undefined, + db: this.configService.get('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); + } +} diff --git a/src/infrastructure/socket/redis-io.adapter.ts b/src/infrastructure/socket/redis-io.adapter.ts new file mode 100644 index 0000000..d06e7b0 --- /dev/null +++ b/src/infrastructure/socket/redis-io.adapter.ts @@ -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 | null = null; + private pubClient: Redis | null = null; + private subClient: Redis | null = null; + + constructor( + app: INestApplicationContext, + private readonly redisService: RedisService, + ) { + super(app); + } + + async connectToRedis(): Promise { + 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 { + await this.pubClient?.quit().catch(() => this.pubClient?.disconnect()); + await this.subClient?.quit().catch(() => this.subClient?.disconnect()); + this.pubClient = null; + this.subClient = null; + } +} diff --git a/src/infrastructure/storage/managed-storage.service.ts b/src/infrastructure/storage/managed-storage.service.ts new file mode 100644 index 0000000..618be35 --- /dev/null +++ b/src/infrastructure/storage/managed-storage.service.ts @@ -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 { + 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 { + 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('storage.provider', { infer: true }) as + | 'local' + | 's3' + | undefined) ?? 'local'; + } + + private getBasePath(): string { + return (this.configService.get('storage.basePath', { infer: true }) ?? 'uploads') + .replace(/\\/g, '/') + .replace(/^\/+|\/+$/g, ''); + } + + private getS3Bucket(): string { + const bucket = this.configService.get('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('storage.s3.region', { infer: true }) ?? 'auto'; + const endpoint = this.configService.get('storage.s3.endpoint', { infer: true }) ?? ''; + const accessKeyId = + this.configService.get('storage.s3.accessKeyId', { infer: true }) ?? ''; + const secretAccessKey = + this.configService.get('storage.s3.secretAccessKey', { infer: true }) ?? ''; + const forcePathStyle = + this.configService.get('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('storage.publicBaseUrl', { infer: true }) ?? '').replace( + /\/$/, + '', + ); + if (publicBaseUrl) { + return `${publicBaseUrl}/${objectKey}`; + } + + const endpoint = (this.configService.get('storage.s3.endpoint', { infer: true }) ?? '').replace( + /\/$/, + '', + ); + const bucket = this.getS3Bucket(); + const forcePathStyle = + this.configService.get('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('storage.publicBaseUrl', { infer: true }) ?? '').replace( + /\/$/, + '', + ); + + if (publicBaseUrl && normalizedUrl.startsWith(`${publicBaseUrl}/`)) { + return normalizedUrl.slice(publicBaseUrl.length + 1); + } + + const endpoint = (this.configService.get('storage.s3.endpoint', { infer: true }) ?? '').replace( + /\/$/, + '', + ); + const bucket = this.getS3Bucket(); + const forcePathStyle = + this.configService.get('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; + } +} diff --git a/src/infrastructure/storage/storage.module.ts b/src/infrastructure/storage/storage.module.ts new file mode 100644 index 0000000..dfdd5f1 --- /dev/null +++ b/src/infrastructure/storage/storage.module.ts @@ -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 {} diff --git a/src/main.ts b/src/main.ts index a22c0e3..4a0e6eb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,14 +9,27 @@ import { existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import { AppModule } from './app.module'; 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 { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { bufferLogs: true }); const configService = app.get(ConfigService); + const appLogger = app.get(AppLoggerService); + app.useLogger(appLogger); const corsOrigins = configService.get('cors.origins', []); - const uploadsDir = join(process.cwd(), 'uploads'); + const storageProvider = configService.get('storage.provider', { infer: true }) ?? 'local'; + const storageBasePath = + (configService.get('storage.basePath', { infer: true }) ?? 'uploads').replace( + /^\/+|\/+$/g, + '', + ); + const publicBaseUrl = + (configService.get('publicBaseUrl', { infer: true }) ?? '').replace(/\/$/, ''); + const uploadsDir = join(process.cwd(), storageBasePath); - if (!existsSync(uploadsDir)) { + if (storageProvider === 'local' && !existsSync(uploadsDir)) { mkdirSync(uploadsDir, { recursive: true }); } @@ -43,15 +56,13 @@ async function bootstrap(): Promise { res.setHeader('x-request-id', requestId); res.on('finish', () => { - const log = { - level: 'info', + appLogger.logHttp({ requestId, method: req.method, path: req.originalUrl, statusCode: res.statusCode, durationMs: Date.now() - startedAt, - }; - console.log(JSON.stringify(log)); + }); }); next(); @@ -62,7 +73,18 @@ async function bootstrap(): Promise { app.useGlobalInterceptors(new ResponseEnvelopeInterceptor()); } - app.use('/uploads', express.static(uploadsDir)); + if (storageProvider === 'local') { + app.use(`/${storageBasePath}`, express.static(uploadsDir)); + } + + const redisEnabled = configService.get('redis.enabled', { infer: true }) ?? false; + const socketAdapterEnabled = + configService.get('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() .setTitle(configService.get('swagger.title', 'Oudelaa API')) @@ -78,7 +100,16 @@ async function bootstrap(): Promise { const port = configService.get('port', 4000); const host = configService.get('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); + appLogger.log(`Server listening on http://${host}:${port}`, 'Bootstrap'); + appLogger.log(`Resolved PUBLIC_BASE_URL=${publicBaseUrl || `http://localhost:${port}`}`, 'Bootstrap'); } void bootstrap(); diff --git a/src/modules/audit/audit.controller.ts b/src/modules/audit/audit.controller.ts new file mode 100644 index 0000000..ae9a38b --- /dev/null +++ b/src/modules/audit/audit.controller.ts @@ -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); + } +} diff --git a/src/modules/audit/audit.module.ts b/src/modules/audit/audit.module.ts index 83447c2..8db70f3 100644 --- a/src/modules/audit/audit.module.ts +++ b/src/modules/audit/audit.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; +import { AuditController } from './audit.controller'; import { AuditRepository } from './audit.repository'; import { AuditService } from './audit.service'; 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], exports: [AuditService], }) diff --git a/src/modules/audit/audit.repository.ts b/src/modules/audit/audit.repository.ts index 63fd6f2..6ff4e3e 100644 --- a/src/modules/audit/audit.repository.ts +++ b/src/modules/audit/audit.repository.ts @@ -26,4 +26,17 @@ export class AuditRepository { metadata: payload.metadata ?? {}, }); } + + async findMany( + filter: Record, + skip: number, + limit: number, + sort: Record = { createdAt: -1 }, + ): Promise { + return this.auditModel.find(filter).sort(sort).skip(skip).limit(limit).exec(); + } + + async count(filter: Record): Promise { + return this.auditModel.countDocuments(filter).exec(); + } } diff --git a/src/modules/audit/audit.service.ts b/src/modules/audit/audit.service.ts index 6818607..afa368b 100644 --- a/src/modules/audit/audit.service.ts +++ b/src/modules/audit/audit.service.ts @@ -1,5 +1,8 @@ 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 { AuditQueryDto } from './dto/audit-query.dto'; @Injectable() export class AuditService { @@ -21,4 +24,41 @@ export class AuditService { metadata, }); } + + async listSuperAdminLogs(query: AuditQueryDto) { + const page = query.page ?? 1; + const limit = query.limit ?? 20; + const skip = (page - 1) * limit; + const filter: Record = {}; + + 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; + 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, + }); + } } diff --git a/src/modules/audit/dto/audit-query.dto.ts b/src/modules/audit/dto/audit-query.dto.ts new file mode 100644 index 0000000..6d28300 --- /dev/null +++ b/src/modules/audit/dto/audit-query.dto.ts @@ -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; +} diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index d08ec01..8441fa0 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -3,7 +3,10 @@ import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { Request } from 'express'; import { Throttle } from '../../common/decorators/throttle.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 { 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 { AuthService } from './auth.service'; 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 { VerifyEmailDto } from './dto/verify-email.dto'; import { VerifyResetCodeDto } from './dto/verify-reset-code.dto'; +import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions'; @ApiTags('Auth') @Controller('auth') @@ -151,4 +155,24 @@ export class AuthController { await this.authService.revokeUserSession(user.sub, jti); 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 }; + } } diff --git a/src/modules/auth/auth.repository.ts b/src/modules/auth/auth.repository.ts index e6c3c98..723cb9e 100644 --- a/src/modules/auth/auth.repository.ts +++ b/src/modules/auth/auth.repository.ts @@ -91,11 +91,13 @@ export class AuthRepository { async createSuperAdminRefreshToken( adminEmail: string, + jti: string, tokenHash: string, expiresAt: Date, ): Promise { await this.superAdminRefreshTokenModel.create({ adminEmail: adminEmail.toLowerCase(), + jti, tokenHash, expiresAt, }); @@ -108,12 +110,28 @@ export class AuthRepository { .exec(); } + async findActiveSuperAdminTokenByJti( + adminEmail: string, + jti: string, + ): Promise { + return this.superAdminRefreshTokenModel + .findOne({ adminEmail: adminEmail.toLowerCase(), jti, revoked: false }) + .select('+tokenHash') + .exec(); + } + async revokeAllSuperAdminTokens(adminEmail: string): Promise { await this.superAdminRefreshTokenModel .updateMany({ adminEmail: adminEmail.toLowerCase(), revoked: false }, { revoked: true }) .exec(); } + async revokeSuperAdminTokenByJti(adminEmail: string, jti: string): Promise { + await this.superAdminRefreshTokenModel + .updateOne({ adminEmail: adminEmail.toLowerCase(), jti, revoked: false }, { revoked: true }) + .exec(); + } + async removeExpiredAndRevokedSuperAdmin(adminEmail: string): Promise { await this.superAdminRefreshTokenModel .deleteMany({ @@ -123,6 +141,34 @@ export class AuthRepository { .exec(); } + async listSuperAdminSessions(adminEmail: string): Promise { + 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 { + 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 { await this.passwordResetCodeModel .updateMany( diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index af3b8c1..a07be21 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -9,7 +9,12 @@ import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { randomBytes, randomInt, randomUUID } from 'crypto'; 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 { UsersService } from '../users/users.service'; 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 { AuthRepository } from './auth.repository'; import { AuthResult, TokenPair } from './types/token-pair.type'; +import { DEFAULT_SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions'; @Injectable() export class AuthService { @@ -56,16 +62,10 @@ export class AuthService { username: generatedUsername, password: passwordHash, }); - const code = await this.issueEmailVerificationCode(user.id, user.email); - const response: { message: string; email: string; debugCode?: string } = { - message: 'Registration successful. Verify your email with the code sent.', + return { + message: 'Registration successful. Account is pending SuperAdmin verification.', email: user.email, }; - const nodeEnv = this.configService.get('nodeEnv', { infer: true }); - if (nodeEnv !== 'production') { - response.debugCode = code; - } - return response; } async registerBasic(dto: RegisterBasicDto): Promise<{ message: string; email: string; debugCode?: string }> { @@ -84,16 +84,10 @@ export class AuthService { password: passwordHash, }); - const code = await this.issueEmailVerificationCode(user.id, user.email); - const response: { message: string; email: string; debugCode?: string } = { - message: 'Registration successful. Verify your email with the code sent.', + return { + message: 'Registration successful. Account is pending SuperAdmin verification.', email: user.email, }; - const nodeEnv = this.configService.get('nodeEnv', { infer: true }); - if (nodeEnv !== 'production') { - response.debugCode = code; - } - return response; } async login(dto: LoginDto): Promise { @@ -105,7 +99,7 @@ export class AuthService { throw new ForbiddenException('Account is disabled'); } 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); @@ -123,71 +117,20 @@ export class AuthService { ): Promise<{ message: string; debugCode?: string }> { const normalizedEmail = dto.email.toLowerCase(); 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) { return { message }; } if (user.isVerified) { - return { message: 'Email is already verified' }; + return { message: 'Account is already verified' }; } - const code = await this.issueEmailVerificationCode(user.id, user.email); - const response: { message: string; debugCode?: string } = { message }; - const nodeEnv = this.configService.get('nodeEnv', { infer: true }); - if (nodeEnv !== 'production') { - response.debugCode = code; - } - return response; + return { message: 'Account is pending SuperAdmin verification' }; } - async verifyEmail(dto: VerifyEmailDto): Promise { - 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, - }; - } - - const codeRecord = await this.authRepository.findLatestActiveEmailVerificationCode(user.id); - if (!codeRecord) { - throw new UnauthorizedException('Invalid or expired verification code'); - } - - const maxAttempts = this.configService.get('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'); - + async verifyEmail(_dto: VerifyEmailDto): Promise<{ message: string }> { return { - message: 'Email verified successfully', - ...tokens, - user: safeUser.toObject() as unknown as Record, + message: 'Account verification is managed by SuperAdmin', }; } @@ -209,7 +152,11 @@ export class AuthService { 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) { await this.authRepository.markCompromisedAndRevokeAll(decoded.sub); throw new UnauthorizedException('Refresh token reuse detected'); @@ -265,7 +212,7 @@ export class AuthService { email: googleUser.email, password: passwordHash, avatar: googleUser.avatar ?? '', - isVerified: true, + isVerified: false, }); } @@ -277,6 +224,10 @@ export class AuthService { 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 safeUser = await this.usersService.findByIdOrFail(user.id); return { ...tokens, user: safeUser.toObject() as unknown as Record }; @@ -345,43 +296,51 @@ export class AuthService { refreshToken: string; superAdmin: { email: string }; }> { - const decoded = this.jwtService.verify<{ email: string; tokenType: string }>(dto.refreshToken, { - secret: this.configService.get('superAdmin.refreshSecret', { infer: true }), - }); + const decoded = this.jwtService.verify<{ email: string; tokenType: string; jti?: string }>( + dto.refreshToken, + { + secret: this.configService.get('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'); } - const activeTokens = await this.authRepository.findActiveSuperAdminTokens(decoded.email); - if (!activeTokens.length) { - throw new UnauthorizedException('Invalid superadmin refresh token'); + const tokenRecord = await this.authRepository.findActiveSuperAdminTokenByJti( + decoded.email, + decoded.jti, + ); + if (!tokenRecord) { + await this.authRepository.revokeAllSuperAdminTokens(decoded.email); + throw new UnauthorizedException('Superadmin refresh token reuse detected'); } - let validTokenFound = false; - for (const token of activeTokens) { - const isMatch = await compareHash(dto.refreshToken, token.tokenHash); - if (isMatch) { - validTokenFound = true; - break; - } + const isMatch = await compareStoredHighEntropyValue( + dto.refreshToken, + tokenRecord.tokenHash, + this.getRefreshTokenHashSecret(), + ); + if (!isMatch) { + await this.authRepository.revokeAllSuperAdminTokens(decoded.email); + throw new UnauthorizedException('Superadmin refresh token reuse detected'); } - if (!validTokenFound) { - throw new UnauthorizedException('Invalid superadmin refresh token'); - } - - await this.authRepository.revokeAllSuperAdminTokens(decoded.email); + await this.authRepository.revokeSuperAdminTokenByJti(decoded.email, decoded.jti); const tokens = await this.generateAndStoreSuperAdminTokenPair(decoded.email); return { ...tokens, superAdmin: { email: decoded.email } }; } async superAdminLogout(dto: RefreshTokenDto): Promise { 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('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); } catch { throw new BadRequestException('Invalid superadmin refresh token'); @@ -403,6 +362,27 @@ export class AuthService { 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 { + 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 }> { const normalizedEmail = dto.email.toLowerCase(); const user = await this.usersService.findByEmail(normalizedEmail); @@ -549,8 +529,7 @@ export class AuthService { ), ]); - const saltRounds = this.configService.get('security.bcryptSaltRounds', { infer: true }); - const tokenHash = await hashValue(refreshToken, saltRounds); + const tokenHash = hashHighEntropyValue(refreshToken, this.getRefreshTokenHashSecret()); const refreshExpiresIn = this.configService.get('jwt.refreshExpiresIn', { infer: true, @@ -563,6 +542,8 @@ export class AuthService { } private async generateAndStoreSuperAdminTokenPair(adminEmail: string): Promise { + const permissions = this.getSuperAdminPermissions(); + const refreshJti = randomUUID(); const [accessToken, refreshToken] = await Promise.all([ this.jwtService.signAsync( { @@ -571,6 +552,7 @@ export class AuthService { email: adminEmail.toLowerCase(), role: 'superadmin', tokenType: 'superadmin_access', + permissions, }, { secret: this.configService.get('superAdmin.accessSecret', { infer: true }), @@ -584,6 +566,7 @@ export class AuthService { email: adminEmail.toLowerCase(), role: 'superadmin', tokenType: 'superadmin_refresh', + jti: refreshJti, }, { secret: this.configService.get('superAdmin.refreshSecret', { infer: true }), @@ -592,8 +575,7 @@ export class AuthService { ), ]); - const saltRounds = this.configService.get('security.bcryptSaltRounds', { infer: true }); - const tokenHash = await hashValue(refreshToken, saltRounds); + const tokenHash = hashHighEntropyValue(refreshToken, this.getRefreshTokenHashSecret()); const refreshExpiresIn = this.configService.get('superAdmin.refreshExpiresIn', { infer: true, }); @@ -601,6 +583,7 @@ export class AuthService { await this.authRepository.createSuperAdminRefreshToken( adminEmail, + refreshJti, tokenHash, new Date(Date.now() + refreshExpiresInMs), ); @@ -647,6 +630,18 @@ export class AuthService { return String(randomInt(100000, 1000000)); } + private getRefreshTokenHashSecret(): string { + return ( + this.configService.get('security.refreshTokenHashSecret', { infer: true }) ?? + this.configService.get('jwt.refreshSecret', { infer: true }) ?? + '' + ); + } + + private getSuperAdminPermissions(): string[] { + return [...DEFAULT_SUPERADMIN_PERMISSIONS]; + } + private async issueEmailVerificationCode(userId: string, email: string): Promise { const code = this.generateResetCode(); const saltRounds = this.configService.get('security.bcryptSaltRounds', { infer: true }); diff --git a/src/modules/auth/dto/register.dto.ts b/src/modules/auth/dto/register.dto.ts index 05c6016..e025d61 100644 --- a/src/modules/auth/dto/register.dto.ts +++ b/src/modules/auth/dto/register.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; import { IsArray, IsBoolean, @@ -15,6 +15,7 @@ import { } from 'class-validator'; import { ExperienceLevel } from '../../../common/enums/experience-level.enum'; import { MusicRole } from '../../../common/enums/music-role.enum'; +import { toBoolean } from '../../../common/utils/query-transform.util'; export class RegisterDto { @ApiProperty({ example: 'john@example.com' }) @@ -78,6 +79,7 @@ export class RegisterDto { @ApiProperty({ required: false, default: false }) @IsOptional() + @Transform(toBoolean) @IsBoolean() isPrivate?: boolean; diff --git a/src/modules/auth/schemas/email-verification-code.schema.ts b/src/modules/auth/schemas/email-verification-code.schema.ts index 8ea7ebd..c47663b 100644 --- a/src/modules/auth/schemas/email-verification-code.schema.ts +++ b/src/modules/auth/schemas/email-verification-code.schema.ts @@ -12,7 +12,7 @@ export class EmailVerificationCode { @Prop({ required: true, select: false }) codeHash!: string; - @Prop({ required: true, index: true }) + @Prop({ required: true }) expiresAt!: Date; @Prop({ default: 0, min: 0 }) diff --git a/src/modules/auth/schemas/password-reset-code.schema.ts b/src/modules/auth/schemas/password-reset-code.schema.ts index 616e37e..e61fa6e 100644 --- a/src/modules/auth/schemas/password-reset-code.schema.ts +++ b/src/modules/auth/schemas/password-reset-code.schema.ts @@ -12,7 +12,7 @@ export class PasswordResetCode { @Prop({ required: true, select: false }) codeHash!: string; - @Prop({ required: true, index: true }) + @Prop({ required: true }) expiresAt!: Date; @Prop({ default: 0, min: 0 }) diff --git a/src/modules/auth/schemas/super-admin-refresh-token.schema.ts b/src/modules/auth/schemas/super-admin-refresh-token.schema.ts index f3336bb..012e822 100644 --- a/src/modules/auth/schemas/super-admin-refresh-token.schema.ts +++ b/src/modules/auth/schemas/super-admin-refresh-token.schema.ts @@ -8,6 +8,9 @@ export class SuperAdminRefreshToken { @Prop({ required: true, trim: true, lowercase: true, index: true }) adminEmail!: string; + @Prop({ required: true, trim: true, index: true }) + jti!: string; + @Prop({ required: true, select: false }) tokenHash!: string; @@ -20,4 +23,5 @@ export class SuperAdminRefreshToken { export const SuperAdminRefreshTokenSchema = SchemaFactory.createForClass(SuperAdminRefreshToken); SuperAdminRefreshTokenSchema.index({ adminEmail: 1, revoked: 1 }); +SuperAdminRefreshTokenSchema.index({ adminEmail: 1, jti: 1 }, { unique: true, sparse: true }); SuperAdminRefreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); diff --git a/src/modules/chat/chat.module.ts b/src/modules/chat/chat.module.ts index 00108cb..f11b4d4 100644 --- a/src/modules/chat/chat.module.ts +++ b/src/modules/chat/chat.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; import { MongooseModule } from '@nestjs/mongoose'; +import { NotificationsModule } from '../notifications/notifications.module'; import { UsersModule } from '../users/users.module'; import { ChatController } from './chat.controller'; import { ChatGateway } from './chat.gateway'; @@ -15,6 +16,7 @@ import { Message, MessageSchema } from './schemas/message.schema'; imports: [ ConfigModule, JwtModule.register({}), + NotificationsModule, UsersModule, MongooseModule.forFeature([ { name: Conversation.name, schema: ConversationSchema }, diff --git a/src/modules/chat/chat.repository.ts b/src/modules/chat/chat.repository.ts index 4eb322b..acad6e3 100644 --- a/src/modules/chat/chat.repository.ts +++ b/src/modules/chat/chat.repository.ts @@ -51,11 +51,16 @@ export class ChatRepository { }); } - async findConversationsForUser(userId: string, skip: number, limit: number): Promise { + async findConversationsForUser( + userId: string, + skip: number, + limit: number, + sort: Record = { lastMessageAt: -1, updatedAt: -1 }, + ): Promise { return this.conversationModel .find({ participantIds: new Types.ObjectId(userId) }) .populate({ path: 'participantIds', select: 'name username stageName avatar isVerified isDisabled' }) - .sort({ lastMessageAt: -1, updatedAt: -1 }) + .sort(sort) .skip(skip) .limit(limit) .exec(); @@ -83,11 +88,16 @@ export class ChatRepository { }); } - async findMessages(conversationId: string, skip: number, limit: number): Promise { + async findMessages( + conversationId: string, + skip: number, + limit: number, + sort: Record = { createdAt: -1 }, + ): Promise { return this.messageModel .find({ conversationId: new Types.ObjectId(conversationId) }) .populate({ path: 'senderId', select: 'name username stageName avatar isVerified' }) - .sort({ createdAt: -1 }) + .sort(sort) .skip(skip) .limit(limit) .exec(); diff --git a/src/modules/chat/chat.service.ts b/src/modules/chat/chat.service.ts index c2b2d93..78c21eb 100644 --- a/src/modules/chat/chat.service.ts +++ b/src/modules/chat/chat.service.ts @@ -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 { 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 { CreateConversationDto } from './dto/create-conversation.dto'; import { MessageQueryDto } from './dto/message-query.dto'; @@ -9,9 +12,12 @@ import { ChatRepository } from './chat.repository'; @Injectable() export class ChatService { + private readonly logger = new Logger(ChatService.name); + constructor( private readonly chatRepository: ChatRepository, private readonly usersRepository: UsersRepository, + private readonly notificationsService: NotificationsService, ) {} async createConversation(currentUserId: string, dto: CreateConversationDto) { @@ -61,9 +67,13 @@ export class ChatService { const limit = query.limit ?? 20; const cursorOffset = decodeOffsetCursor(query.cursor); const skip = cursorOffset ?? (page - 1) * limit; + const direction = resolveMongoSortDirection(query.sortOrder); 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), ]); @@ -78,14 +88,15 @@ export class ChatService { const nextOffset = skip + mappedItems.length; const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null; - return { - items: mappedItems, + return buildPaginatedResponse(mappedItems, { page, limit, total, - totalPages: Math.ceil(total / limit) || 1, + offset: skip, + currentCursor: query.cursor ?? null, nextCursor, - }; + mode: 'cursor', + }); } async getMessages(currentUserId: string, conversationId: string, query: MessageQueryDto) { @@ -94,9 +105,10 @@ export class ChatService { const limit = query.limit ?? 20; const cursorOffset = decodeOffsetCursor(query.cursor); const skip = cursorOffset ?? (page - 1) * limit; + const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record; 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), ]); @@ -104,14 +116,15 @@ export class ChatService { const nextOffset = skip + items.length; const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null; - return { - items, + return buildPaginatedResponse(items, { page, limit, total, - totalPages: Math.ceil(total / limit) || 1, + offset: skip, + currentCursor: query.cursor ?? null, nextCursor, - }; + mode: 'cursor', + }); } async sendMessage(currentUserId: string, dto: SendMessageDto) { @@ -144,6 +157,12 @@ export class ChatService { currentUserId, preview, ); + await this.dispatchMessageNotifications( + currentUserId, + conversation.participantIds.map((id) => id.toString()), + conversation.id, + preview, + ); return message; } @@ -247,4 +266,32 @@ export class ChatService { } } } + + private async dispatchMessageNotifications( + actorId: string, + participantIds: string[], + conversationId: string, + previewText: string, + ): Promise { + 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' + }`, + ); + } + } + } } diff --git a/src/modules/chat/dto/create-conversation.dto.ts b/src/modules/chat/dto/create-conversation.dto.ts index 46add12..b34d1d1 100644 --- a/src/modules/chat/dto/create-conversation.dto.ts +++ b/src/modules/chat/dto/create-conversation.dto.ts @@ -1,5 +1,7 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; import { IsArray, IsBoolean, IsOptional, IsString, Length } from 'class-validator'; +import { toBoolean } from '../../../common/utils/query-transform.util'; export class CreateConversationDto { @IsArray() @@ -8,6 +10,7 @@ export class CreateConversationDto { @ApiPropertyOptional({ default: false }) @IsOptional() + @Transform(toBoolean) @IsBoolean() isGroup?: boolean; diff --git a/src/modules/chat/schemas/message.schema.ts b/src/modules/chat/schemas/message.schema.ts index 9ff6b2f..87447a5 100644 --- a/src/modules/chat/schemas/message.schema.ts +++ b/src/modules/chat/schemas/message.schema.ts @@ -1,5 +1,6 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { HydratedDocument, Types } from 'mongoose'; +import { resolveManagedFileUrl } from '../../../common/utils/public-url.util'; import { User } from '../../users/schemas/user.schema'; export type MessageDocument = HydratedDocument; @@ -31,3 +32,11 @@ export class Message { export const MessageSchema = SchemaFactory.createForClass(Message); MessageSchema.index({ conversationId: 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 }); diff --git a/src/modules/comments/comments.controller.ts b/src/modules/comments/comments.controller.ts index 3495321..c2d11aa 100644 --- a/src/modules/comments/comments.controller.ts +++ b/src/modules/comments/comments.controller.ts @@ -1,12 +1,16 @@ import { Controller, Delete, Get, Param, Post, Query, Body, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 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 { 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 { AdminCommentQueryDto } from './dto/admin-comment-query.dto'; import { CommentQueryDto } from './dto/comment-query.dto'; import { CreateCommentDto } from './dto/create-comment.dto'; import { CommentsService } from './comments.service'; +import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions'; @ApiTags('Comments') @Controller('comments') @@ -34,6 +38,14 @@ export class CommentsController { 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() @UseGuards(JwtAuthGuard) @Delete(':commentId') @@ -42,7 +54,8 @@ export class CommentsController { } @ApiBearerAuth() - @UseGuards(SuperAdminJwtAuthGuard) + @UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard) + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE) @Delete('admin/:commentId') async adminRemove(@CurrentUser() user: JwtPayload, @Param('commentId') commentId: string) { return this.commentsService.removeBySuperAdmin(user.email ?? user.sub, commentId); diff --git a/src/modules/comments/comments.module.ts b/src/modules/comments/comments.module.ts index 966d07d..5287e22 100644 --- a/src/modules/comments/comments.module.ts +++ b/src/modules/comments/comments.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; import { AuditModule } from '../audit/audit.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { PostsModule } from '../posts/posts.module'; +import { UsersModule } from '../users/users.module'; import { Comment, CommentSchema } from './schemas/comment.schema'; import { CommentsController } from './comments.controller'; import { CommentsService } from './comments.service'; @@ -12,6 +14,8 @@ import { CommentsRepository } from './comments.repository'; AuditModule, MongooseModule.forFeature([{ name: Comment.name, schema: CommentSchema }]), PostsModule, + NotificationsModule, + UsersModule, ], controllers: [CommentsController], providers: [CommentsService, CommentsRepository], diff --git a/src/modules/comments/comments.repository.ts b/src/modules/comments/comments.repository.ts index a6431b0..48f621a 100644 --- a/src/modules/comments/comments.repository.ts +++ b/src/modules/comments/comments.repository.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { ClientSession, FilterQuery, Model, Types } from 'mongoose'; +import { ModerationStatus } from '../../common/enums/moderation-status.enum'; import { Comment, CommentDocument } from './schemas/comment.schema'; @Injectable() @@ -8,6 +9,14 @@ export class CommentsRepository { constructor(@InjectModel(Comment.name) private readonly commentModel: Model) {} private withActiveFilter>(filter: T): FilterQuery { + return { + ...filter, + isDeleted: { $ne: true }, + moderationStatus: { $ne: ModerationStatus.HIDDEN }, + }; + } + + private withAdminFilter>(filter: T): FilterQuery { return { ...filter, isDeleted: { $ne: true }, @@ -15,15 +24,24 @@ export class CommentsRepository { } async create( - payload: { postId: string; authorId: string; content: string; parentCommentId?: string }, + payload: { + postId: string; + authorId: string; + content: string; + mentionUsernames?: string[]; + parentCommentId?: string; + }, session?: ClientSession, ) { - return this.commentModel.create({ + const doc = new this.commentModel({ postId: new Types.ObjectId(payload.postId), authorId: new Types.ObjectId(payload.authorId), content: payload.content, + mentionUsernames: payload.mentionUsernames ?? [], ...(payload.parentCommentId ? { parentCommentId: new Types.ObjectId(payload.parentCommentId) } : {}), - }, { session }); + }); + + return session ? doc.save({ session }) : doc.save(); } async findById(commentId: string): Promise { @@ -55,11 +73,31 @@ export class CommentsRepository { return !!updated; } - async findMany(filter: FilterQuery, skip: number, limit: number) { + async findMany( + filter: FilterQuery, + skip: number, + limit: number, + sort: Record = { createdAt: -1 }, + ) { return this.commentModel .find(this.withActiveFilter(filter)) .populate({ path: 'authorId', select: 'name username avatar stageName isVerified' }) - .sort({ createdAt: -1 }) + .sort(sort) + .skip(skip) + .limit(limit) + .exec(); + } + + async findManyAdmin( + filter: FilterQuery, + skip: number, + limit: number, + sort: Record = { createdAt: -1 }, + ) { + return this.commentModel + .find(this.withAdminFilter(filter)) + .populate({ path: 'authorId', select: 'name username avatar stageName isVerified' }) + .sort(sort) .skip(skip) .limit(limit) .exec(); @@ -69,6 +107,31 @@ export class CommentsRepository { return this.commentModel.countDocuments(this.withActiveFilter(filter)).exec(); } + async countAdmin(filter: FilterQuery): Promise { + return this.commentModel.countDocuments(this.withAdminFilter(filter)).exec(); + } + + async updateModerationStatus( + commentId: string, + payload: Pick, + ): Promise { + 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 { return this.commentModel .countDocuments({ postId: new Types.ObjectId(postId), isDeleted: { $ne: true } }) diff --git a/src/modules/comments/comments.service.ts b/src/modules/comments/comments.service.ts index 4d39237..98137c7 100644 --- a/src/modules/comments/comments.service.ts +++ b/src/modules/comments/comments.service.ts @@ -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 { NotificationsService } from '../notifications/notifications.service'; 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 { CreateCommentDto } from './dto/create-comment.dto'; import { CommentsRepository } from './comments.repository'; @Injectable() export class CommentsService { + private readonly logger = new Logger(CommentsService.name); + constructor( private readonly commentsRepository: CommentsRepository, private readonly postsRepository: PostsRepository, private readonly auditService: AuditService, + private readonly feedVersionService: FeedVersionService, + private readonly notificationsService: NotificationsService, + private readonly usersRepository: UsersRepository, ) {} async create(userId: string, dto: CreateCommentDto) { @@ -19,20 +32,42 @@ export class CommentsService { throw new NotFoundException('Post not found'); } + let parentRecipientId = ''; if (dto.parentCommentId) { const parent = await this.commentsRepository.findById(dto.parentCommentId); if (!parent || parent.postId.toString() !== dto.postId) { 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({ postId: dto.postId, authorId: userId, - content: dto.content, + content, + mentionUsernames: mentionResolution.mentionUsernames, parentCommentId: dto.parentCommentId, }); 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; } @@ -48,6 +83,7 @@ export class CommentsService { await this.commentsRepository.deleteById(commentId, userId); await this.syncCommentsCount(comment.postId.toString()); + await this.feedVersionService.bumpGlobalVersion(); return { success: true }; } @@ -59,6 +95,7 @@ export class CommentsService { await this.commentsRepository.deleteById(commentId, superAdminIdentifier); await this.syncCommentsCount(comment.postId.toString()); + await this.feedVersionService.bumpGlobalVersion(); await this.auditService.logSuperAdminAction( superAdminIdentifier, 'comment_delete', @@ -70,45 +107,281 @@ export class CommentsService { } async findByPost(postId: string, query: CommentQueryDto) { + if (!Types.ObjectId.isValid(postId)) { + throw new BadRequestException('Invalid post id'); + } + const page = query.page ?? 1; const limit = query.limit ?? 20; const skip = (page - 1) * limit; + const postObjectId = new Types.ObjectId(postId); + const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record; const [items, total] = await Promise.all([ - this.commentsRepository.findMany({ postId, parentCommentId: { $exists: false } }, skip, limit), - this.commentsRepository.count({ postId, parentCommentId: { $exists: false } }), + this.commentsRepository.findMany( + { + postId: postObjectId, + $or: [{ parentCommentId: { $exists: false } }, { parentCommentId: null }], + }, + skip, + limit, + sort, + ), + this.commentsRepository.count({ + postId: postObjectId, + $or: [{ parentCommentId: { $exists: false } }, { parentCommentId: null }], + }), ]); - return { - items, + return buildPaginatedResponse(items, { page, limit, total, - totalPages: Math.ceil(total / limit) || 1, - }; + offset: skip, + }); } async findReplies(parentCommentId: string, query: CommentQueryDto) { + if (!Types.ObjectId.isValid(parentCommentId)) { + throw new BadRequestException('Invalid parent comment id'); + } + const page = query.page ?? 1; const limit = query.limit ?? 20; const skip = (page - 1) * limit; + const parentObjectId = new Types.ObjectId(parentCommentId); + const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record; const [items, total] = await Promise.all([ - this.commentsRepository.findMany({ parentCommentId }, skip, limit), - this.commentsRepository.count({ parentCommentId }), + this.commentsRepository.findMany({ parentCommentId: parentObjectId }, skip, limit, sort), + this.commentsRepository.count({ parentCommentId: parentObjectId }), ]); - return { - items, + return buildPaginatedResponse(items, { page, limit, 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 = {}; + + 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; + 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 { const totalComments = await this.commentsRepository.countByPost(postId); await this.postsRepository.setCommentsCount(postId, totalComments); } + + private async dispatchCommentNotifications( + actorId: string, + postAuthorId: string, + parentRecipientId: string, + postId: string, + previewText: string, + ): Promise> { + const recipients = new Set(); + 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, + ): Promise { + 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 ''; + } } diff --git a/src/modules/comments/dto/admin-comment-query.dto.ts b/src/modules/comments/dto/admin-comment-query.dto.ts new file mode 100644 index 0000000..2191ed8 --- /dev/null +++ b/src/modules/comments/dto/admin-comment-query.dto.ts @@ -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; +} diff --git a/src/modules/comments/dto/comment-query.dto.ts b/src/modules/comments/dto/comment-query.dto.ts index 62e7309..7104781 100644 --- a/src/modules/comments/dto/comment-query.dto.ts +++ b/src/modules/comments/dto/comment-query.dto.ts @@ -1,3 +1,10 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; 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']; +} diff --git a/src/modules/comments/dto/create-comment.dto.ts b/src/modules/comments/dto/create-comment.dto.ts index 8a73741..0353ded 100644 --- a/src/modules/comments/dto/create-comment.dto.ts +++ b/src/modules/comments/dto/create-comment.dto.ts @@ -1,5 +1,7 @@ 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 { @ApiProperty() @@ -15,4 +17,13 @@ export class CreateCommentDto { @IsOptional() @IsMongoId() 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[]; } diff --git a/src/modules/comments/schemas/comment.schema.ts b/src/modules/comments/schemas/comment.schema.ts index deb4726..ecd0577 100644 --- a/src/modules/comments/schemas/comment.schema.ts +++ b/src/modules/comments/schemas/comment.schema.ts @@ -1,5 +1,6 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { HydratedDocument, Types } from 'mongoose'; +import { ModerationStatus } from '../../../common/enums/moderation-status.enum'; import { Post } from '../../posts/schemas/post.schema'; import { User } from '../../users/schemas/user.schema'; @@ -19,6 +20,20 @@ export class Comment { @Prop({ required: true, maxlength: 1000 }) 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 }) isDeleted!: boolean; @@ -32,3 +47,4 @@ export class Comment { export const CommentSchema = SchemaFactory.createForClass(Comment); CommentSchema.index({ postId: 1, createdAt: -1 }); CommentSchema.index({ postId: 1, parentCommentId: 1, isDeleted: 1, createdAt: -1 }); +CommentSchema.index({ moderationStatus: 1, createdAt: -1 }); diff --git a/src/modules/feed/dto/feed-query.dto.ts b/src/modules/feed/dto/feed-query.dto.ts index 12e3c3f..7ebbf49 100644 --- a/src/modules/feed/dto/feed-query.dto.ts +++ b/src/modules/feed/dto/feed-query.dto.ts @@ -1,7 +1,8 @@ import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; 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 { toBoolean } from '../../../common/utils/query-transform.util'; export class FeedQueryDto extends PaginationQueryDto { @IsOptional() @@ -9,7 +10,7 @@ export class FeedQueryDto extends PaginationQueryDto { preferredPostType?: PostType; @IsOptional() - @Type(() => Boolean) + @Transform(toBoolean) @IsBoolean() followingOnly?: boolean; @@ -19,4 +20,16 @@ export class FeedQueryDto extends PaginationQueryDto { @Min(1) @Max(500) radiusKm?: number; + + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + includeSuggestions?: boolean; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(2) + @Max(10) + suggestionInterval?: number; } diff --git a/src/modules/feed/feed.controller.ts b/src/modules/feed/feed.controller.ts index 31b2933..8f1c904 100644 --- a/src/modules/feed/feed.controller.ts +++ b/src/modules/feed/feed.controller.ts @@ -21,7 +21,7 @@ export class FeedController { @ApiBearerAuth() @UseGuards(JwtAuthGuard) @Get('trending') - async trending(@Query() query: FeedQueryDto) { - return this.feedService.getTrending(query); + async trending(@CurrentUser() user: JwtPayload, @Query() query: FeedQueryDto) { + return this.feedService.getTrending(user.sub, query); } } diff --git a/src/modules/feed/feed.module.ts b/src/modules/feed/feed.module.ts index 7ee5e7a..5d9b6f7 100644 --- a/src/modules/feed/feed.module.ts +++ b/src/modules/feed/feed.module.ts @@ -1,7 +1,11 @@ import { Module } from '@nestjs/common'; 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 { Post, PostSchema } from '../posts/schemas/post.schema'; +import { SavesModule } from '../saves/saves.module'; import { UsersModule } from '../users/users.module'; import { FeedController } from './feed.controller'; import { FeedService } from './feed.service'; @@ -10,6 +14,10 @@ import { FeedRepository } from './feed.repository'; @Module({ imports: [ UsersModule, + LikesModule, + SavesModule, + FollowsModule, + MarketplaceModule, MongooseModule.forFeature([ { name: Post.name, schema: PostSchema }, { name: Follow.name, schema: FollowSchema }, diff --git a/src/modules/feed/feed.repository.ts b/src/modules/feed/feed.repository.ts index 0a662ac..aadc597 100644 --- a/src/modules/feed/feed.repository.ts +++ b/src/modules/feed/feed.repository.ts @@ -42,11 +42,23 @@ export class FeedRepository { .exec(); } - async findTrendingPublicPosts(skip: number, limit: number): Promise { + async findTrendingPublicPosts( + filter: FilterQuery, + skip: number, + limit: number, + ): Promise { return this.postModel - .find({ visibility: 'public', isDeleted: { $ne: true } }) + .find({ ...filter, isDeleted: { $ne: true } }) .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) .limit(limit) .exec(); diff --git a/src/modules/feed/feed.service.ts b/src/modules/feed/feed.service.ts index e204596..9818747 100644 --- a/src/modules/feed/feed.service.ts +++ b/src/modules/feed/feed.service.ts @@ -1,21 +1,97 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { Types } from 'mongoose'; -import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util'; import { PostType } from '../../common/enums/post-type.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 { UsersRepository } from '../users/users.repository'; import { FeedQueryDto } from './dto/feed-query.dto'; import { FeedRepository } from './feed.repository'; +type FeedPostItem = Record & { + 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>; + } + | { + id: string; + feedItemType: 'featured_marketplace'; + title: string; + subtitle: string; + listings: Array>; + musicalInstruments: Array>; + instruments: Array>; + repairShops: Array>; + }; + @Injectable() export class FeedService { constructor( private readonly feedRepository: FeedRepository, 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) { + const cacheEnabled = + this.configService.get('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>(cacheKey); + if (cached) { + return cached; + } + } + const currentUser = await this.usersRepository.findById(currentUserId); if (!currentUser) { throw new NotFoundException('Current user not found'); @@ -26,20 +102,10 @@ export class FeedService { const page = query.page ?? 1; const followingOnly = query.followingOnly ?? false; const radiusKm = query.radiusKm ?? 30; + const skip = cursorOffset ?? (page - 1) * limit; const followingIds = await this.feedRepository.findFollowingIds(currentUserId); - const visibleAuthorIds = followingOnly ? [currentUserId, ...followingIds] : null; - - const filter: Record = { - $or: [ - { visibility: PostVisibility.PUBLIC }, - { authorId: new Types.ObjectId(currentUserId) }, - ], - }; - - if (visibleAuthorIds) { - filter.authorId = { $in: visibleAuthorIds.map((id) => new Types.ObjectId(id)) }; - } + const filter = this.buildVisiblePostsFilter(currentUserId, followingIds, followingOnly); const candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300)); @@ -64,48 +130,268 @@ export class FeedService { .sort( (a, b) => 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 skip = cursorOffset ?? (page - 1) * limit; - const items = scored.slice(skip, skip + limit).map((entry) => ({ - ...entry.post.toObject(), + const pagedPosts = scored.slice(skip, skip + limit).map((entry) => ({ + ...(entry.post.toObject() as unknown as Record), 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; - return { - items, + const result = buildPaginatedResponse(items, { page, limit, total, - totalPages: Math.ceil(total / limit) || 1, + offset: skip, + currentCursor: query.cursor ?? null, nextCursor, - }; + mode: 'cursor', + }); + + if (cacheEnabled) { + await this.cacheService.set( + cacheKey, + result, + this.configService.get('feedCache.userFeedTtlSeconds', { infer: true }) ?? 15, + ); + } + + return result; } - async getTrending(query: FeedQueryDto) { + async getTrending(currentUserId: string, query: FeedQueryDto) { + const cacheEnabled = + this.configService.get('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>(cacheKey); + if (cached) { + return cached; + } + } + const limit = query.limit ?? 20; const cursorOffset = decodeOffsetCursor(query.cursor); const page = query.page ?? 1; const skip = cursorOffset ?? (page - 1) * limit; + const followingIds = await this.feedRepository.findFollowingIds(currentUserId); + const trendingFilter: Record = { visibility: PostVisibility.PUBLIC }; + if (query.preferredPostType) { + trendingFilter.postType = query.preferredPostType; + } - const [items, total] = await Promise.all([ - this.feedRepository.findTrendingPublicPosts(skip, limit), - this.feedRepository.count({ visibility: PostVisibility.PUBLIC }), + const [rows, total] = await Promise.all([ + this.feedRepository.findTrendingPublicPosts(trendingFilter, skip, limit), + this.feedRepository.count(trendingFilter), ]); - const nextOffset = skip + items.length; + const decoratedPosts = await this.decoratePostsForViewer( + currentUserId, + rows.map((item) => item.toObject() as unknown as Record), + followingIds, + ); + const nextOffset = skip + rows.length; const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null; - return { - items, + const result = buildPaginatedResponse(decoratedPosts, { page, limit, total, - totalPages: Math.ceil(total / limit) || 1, + offset: skip, + currentCursor: query.cursor ?? null, nextCursor, + mode: 'cursor', + }); + + if (cacheEnabled) { + await this.cacheService.set( + cacheKey, + result, + this.configService.get('feedCache.trendingTtlSeconds', { infer: true }) ?? 30, + ); + } + + return result; + } + + private async decoratePostsForViewer( + currentUserId: string, + items: Array>, + followingIds: string[], + ): Promise { + 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> { + const cards = await this.buildHomeCards(currentUserId); + if (!cards.length) { + return posts; + } + + const result: Array = []; + 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 { + 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>, + musicalInstruments: (instruments.items ?? []) as unknown as Array>, + instruments: (instruments.items ?? []) as unknown as Array>, + repairShops: (repairShops.items ?? []) as unknown as Array>, + }); + } + + return cards; + } + + private buildVisiblePostsFilter( + currentUserId: string, + followingIds: string[], + followingOnly: boolean, + ): Record { + 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; currentUserId: string; followingIds: string[]; - post: any; + post: Record; preferredPostType?: PostType; radiusKm: number; }): number { const { currentUser, currentUserId, followingIds, post, preferredPostType, radiusKm } = input; - const author: any = post.authorId; - const authorId = typeof author === 'string' ? author : author?._id?.toString?.() ?? ''; + const author = post.authorId; + const authorId = this.extractEntityId(author); const isOwnPost = authorId === currentUserId; const isFollowing = followingIds.includes(authorId); @@ -127,10 +413,16 @@ export class FeedService { const ageHours = ageMs / (1000 * 60 * 60); 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( this.buildPreferenceTokens(currentUser), - (post.hashtags ?? []).map((x: string) => x.toLowerCase()), + (post.hashtags ?? []).map((value: string) => value.toLowerCase()), ); const distanceKm = this.computeDistanceKm( @@ -209,4 +501,45 @@ export class FeedService { const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return earthKm * c; } + + private buildCacheKey(scope: string, input: Record): 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 ''; + } } diff --git a/src/modules/follows/follows.controller.ts b/src/modules/follows/follows.controller.ts index b9ba693..8160c64 100644 --- a/src/modules/follows/follows.controller.ts +++ b/src/modules/follows/follows.controller.ts @@ -25,14 +25,14 @@ export class FollowsController { @UseGuards(JwtAuthGuard) @Get('followers/:userId') 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() @UseGuards(JwtAuthGuard) @Get('following/:userId') 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() @@ -47,6 +47,6 @@ export class FollowsController { @Get('suggestions') @Throttle(60, 60_000) 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); } } diff --git a/src/modules/follows/follows.repository.ts b/src/modules/follows/follows.repository.ts index 69e71c4..0ba4aba 100644 --- a/src/modules/follows/follows.repository.ts +++ b/src/modules/follows/follows.repository.ts @@ -38,12 +38,17 @@ export class FollowsRepository { await this.followModel.findByIdAndDelete(id, { session }).exec(); } - async findMany(filter: FilterQuery, skip: number, limit: number): Promise { + async findMany( + filter: FilterQuery, + skip: number, + limit: number, + sort: Record = { createdAt: -1 }, + ): Promise { return this.followModel .find(filter) .populate({ path: 'followerId', select: 'name username stageName avatar isVerified isDisabled' }) .populate({ path: 'followingId', select: 'name username stageName avatar isVerified isDisabled' }) - .sort({ createdAt: -1 }) + .sort(sort) .skip(skip) .limit(limit) .exec(); diff --git a/src/modules/follows/follows.service.spec.ts b/src/modules/follows/follows.service.spec.ts index 016a4c7..d628e62 100644 --- a/src/modules/follows/follows.service.spec.ts +++ b/src/modules/follows/follows.service.spec.ts @@ -26,6 +26,7 @@ describe('FollowsService', () => { followsRepository as any, usersRepository as any, outboxService as any, + { bumpGlobalVersion: jest.fn().mockResolvedValue(1) } as any, ); await expect(service.toggleFollow(currentUserId, { targetUserId })).resolves.toEqual({ diff --git a/src/modules/follows/follows.service.ts b/src/modules/follows/follows.service.ts index 3649d49..a649de3 100644 --- a/src/modules/follows/follows.service.ts +++ b/src/modules/follows/follows.service.ts @@ -1,5 +1,9 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; 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 { UsersRepository } from '../users/users.repository'; import { UserDocument } from '../users/schemas/user.schema'; @@ -14,6 +18,7 @@ export class FollowsService { private readonly followsRepository: FollowsRepository, private readonly usersRepository: UsersRepository, private readonly outboxService: OutboxService, + private readonly feedVersionService: FeedVersionService, ) {} async toggleFollow(currentUserId: string, dto: ToggleFollowDto) { @@ -37,11 +42,13 @@ export class FollowsService { if (existing) { await this.followsRepository.deleteById(existing.id); await this.syncFollowCounts(currentUserId, targetUserId); + await this.feedVersionService.bumpGlobalVersion(); return { following: false }; } const follow = await this.followsRepository.create(currentUserId, targetUserId); await this.syncFollowCounts(currentUserId, targetUserId); + await this.feedVersionService.bumpGlobalVersion(); try { await this.outboxService.enqueueFollowNotification(currentUserId, targetUserId, follow.id); @@ -56,36 +63,40 @@ export class FollowsService { 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 sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record; 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 }), ]); - return { - items, + return buildPaginatedResponse(items, { page, limit, 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 sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record; 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 }), ]); - return { - items, + return buildPaginatedResponse(items, { page, limit, total, - totalPages: Math.ceil(total / limit) || 1, - }; + offset: skip, + }); } 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); if (!currentUser) { 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); + if (query.sortOrder === 'asc') { + ranked.reverse(); + } + const total = ranked.length; const skip = (page - 1) * limit; const items = ranked.slice(skip, skip + limit).map((entry) => ({ @@ -136,13 +153,12 @@ export class FollowsService { reasons: this.buildSuggestionReasons(currentUser, entry.user), })); - return { - items, + return buildPaginatedResponse(items, { page, limit, total, - totalPages: Math.ceil(total / limit) || 1, - }; + offset: skip, + }); } private calculateSuggestionScore(currentUser: UserDocument, candidate: UserDocument): number { diff --git a/src/modules/likes/likes.module.ts b/src/modules/likes/likes.module.ts index ceb95e0..598ab2f 100644 --- a/src/modules/likes/likes.module.ts +++ b/src/modules/likes/likes.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; import { CommentsModule } from '../comments/comments.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { PostsModule } from '../posts/posts.module'; import { Like, LikeSchema } from './schemas/like.schema'; import { LikesController } from './likes.controller'; @@ -12,9 +13,10 @@ import { LikesService } from './likes.service'; MongooseModule.forFeature([{ name: Like.name, schema: LikeSchema }]), PostsModule, CommentsModule, + NotificationsModule, ], controllers: [LikesController], providers: [LikesService, LikesRepository], - exports: [LikesService], + exports: [LikesService, LikesRepository], }) export class LikesModule {} diff --git a/src/modules/likes/likes.repository.ts b/src/modules/likes/likes.repository.ts index fb61b4d..08046c7 100644 --- a/src/modules/likes/likes.repository.ts +++ b/src/modules/likes/likes.repository.ts @@ -25,6 +25,24 @@ export class LikesRepository { }); } + async findLikedPostIds(userId: string, postIds: string[]): Promise { + 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 { await this.likeModel.findByIdAndDelete(id).exec(); } diff --git a/src/modules/likes/likes.service.spec.ts b/src/modules/likes/likes.service.spec.ts index 4e1a908..767429a 100644 --- a/src/modules/likes/likes.service.spec.ts +++ b/src/modules/likes/likes.service.spec.ts @@ -16,6 +16,8 @@ describe('LikesService', () => { likesRepository as any, postsRepository as any, commentsRepository as any, + { bumpGlobalVersion: jest.fn() } as any, + { createLikeNotification: jest.fn() } as any, ); await expect( diff --git a/src/modules/likes/likes.service.ts b/src/modules/likes/likes.service.ts index 3ca252f..561600a 100644 --- a/src/modules/likes/likes.service.ts +++ b/src/modules/likes/likes.service.ts @@ -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 { PostsRepository } from '../posts/posts.repository'; import { LikesRepository } from './likes.repository'; @@ -6,10 +9,14 @@ import { ToggleLikeDto } from './dto/toggle-like.dto'; @Injectable() export class LikesService { + private readonly logger = new Logger(LikesService.name); + constructor( private readonly likesRepository: LikesRepository, private readonly postsRepository: PostsRepository, 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 }> { @@ -19,6 +26,7 @@ export class LikesService { async like(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> { await this.assertTargetExists(dto); + const notificationContext = await this.resolveNotificationContext(dto); const existing = await this.likesRepository.findOne(userId, dto.targetId, dto.targetType); if (existing) { @@ -29,6 +37,26 @@ export class LikesService { if (dto.targetType === 'post') { 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 }; } @@ -45,6 +73,7 @@ export class LikesService { if (dto.targetType === 'post') { await this.postsRepository.incrementLikesCount(dto.targetId, -1); } + await this.feedVersionService.bumpGlobalVersion(); return { liked: false, targetId: dto.targetId, targetType: dto.targetType }; } @@ -75,4 +104,51 @@ export class LikesService { const comment = await this.commentsRepository.findById(dto.targetId); 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 ''; + } } diff --git a/src/modules/marketplace/dto/create-instrument.dto.ts b/src/modules/marketplace/dto/create-instrument.dto.ts index 4770a18..3a2aebe 100644 --- a/src/modules/marketplace/dto/create-instrument.dto.ts +++ b/src/modules/marketplace/dto/create-instrument.dto.ts @@ -1,8 +1,9 @@ -import { Type } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; import { ArrayMaxSize, IsArray, IsBoolean, + IsEnum, IsNotEmpty, IsNumber, IsOptional, @@ -10,6 +11,10 @@ import { MaxLength, Min, } 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 { @IsString() @@ -38,13 +43,27 @@ export class CreateInstrumentDto { quantity!: number; @IsOptional() + @Transform(toStringArray) @IsArray() @ArrayMaxSize(5) @IsString({ each: true }) imageUrls?: string[]; @IsOptional() - @Type(() => Boolean) + @Transform(toBoolean) @IsBoolean() isActive?: boolean; + + @IsOptional() + @IsEnum(MarketplaceListingCondition) + condition?: MarketplaceListingCondition; + + @IsOptional() + @IsString() + @MaxLength(80) + instrumentType?: string; + + @IsOptional() + @IsEnum(MarketplaceListingCategory) + listingCategory?: MarketplaceListingCategory; } diff --git a/src/modules/marketplace/dto/create-repair-shop.dto.ts b/src/modules/marketplace/dto/create-repair-shop.dto.ts new file mode 100644 index 0000000..cadafbb --- /dev/null +++ b/src/modules/marketplace/dto/create-repair-shop.dto.ts @@ -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; +} diff --git a/src/modules/marketplace/dto/instrument-query.dto.ts b/src/modules/marketplace/dto/instrument-query.dto.ts index fb05036..91ee4cc 100644 --- a/src/modules/marketplace/dto/instrument-query.dto.ts +++ b/src/modules/marketplace/dto/instrument-query.dto.ts @@ -1,29 +1,60 @@ -import { Type } from 'class-transformer'; -import { IsBoolean, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; +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'; +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 { + @ApiPropertyOptional({ description: 'Search by title or description' }) @IsOptional() @IsString() q?: string; + @ApiPropertyOptional({ minimum: 0 }) @IsOptional() @Type(() => Number) @IsNumber() @Min(0) minPrice?: number; + @ApiPropertyOptional({ minimum: 0 }) @IsOptional() @Type(() => Number) @IsNumber() @Min(0) maxPrice?: number; + @ApiPropertyOptional({ default: true }) @IsOptional() - @Type(() => Boolean) + @Transform(toBoolean) @IsBoolean() 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() @Type(() => Number) @IsNumber() diff --git a/src/modules/marketplace/dto/marketplace-home-query.dto.ts b/src/modules/marketplace/dto/marketplace-home-query.dto.ts new file mode 100644 index 0000000..54b49a8 --- /dev/null +++ b/src/modules/marketplace/dto/marketplace-home-query.dto.ts @@ -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; +} diff --git a/src/modules/marketplace/dto/repair-shop-query.dto.ts b/src/modules/marketplace/dto/repair-shop-query.dto.ts new file mode 100644 index 0000000..e1e565e --- /dev/null +++ b/src/modules/marketplace/dto/repair-shop-query.dto.ts @@ -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; +} diff --git a/src/modules/marketplace/dto/update-marketplace-status.dto.ts b/src/modules/marketplace/dto/update-marketplace-status.dto.ts new file mode 100644 index 0000000..6318039 --- /dev/null +++ b/src/modules/marketplace/dto/update-marketplace-status.dto.ts @@ -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; +} diff --git a/src/modules/marketplace/dto/update-repair-shop.dto.ts b/src/modules/marketplace/dto/update-repair-shop.dto.ts new file mode 100644 index 0000000..4b75d64 --- /dev/null +++ b/src/modules/marketplace/dto/update-repair-shop.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateRepairShopDto } from './create-repair-shop.dto'; + +export class UpdateRepairShopDto extends PartialType(CreateRepairShopDto) {} diff --git a/src/modules/marketplace/dto/update-shop-profile.dto.ts b/src/modules/marketplace/dto/update-shop-profile.dto.ts new file mode 100644 index 0000000..7b64d94 --- /dev/null +++ b/src/modules/marketplace/dto/update-shop-profile.dto.ts @@ -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; +} diff --git a/src/modules/marketplace/enums/marketplace-listing-category.enum.ts b/src/modules/marketplace/enums/marketplace-listing-category.enum.ts new file mode 100644 index 0000000..bb8389d --- /dev/null +++ b/src/modules/marketplace/enums/marketplace-listing-category.enum.ts @@ -0,0 +1,7 @@ +export enum MarketplaceListingCategory { + MUSICAL_INSTRUMENT = 'musical_instrument', + ACCESSORY = 'accessory', + AUDIO_GEAR = 'audio_gear', + SHEET_MUSIC = 'sheet_music', + OTHER = 'other', +} diff --git a/src/modules/marketplace/enums/marketplace-listing-condition.enum.ts b/src/modules/marketplace/enums/marketplace-listing-condition.enum.ts new file mode 100644 index 0000000..dd4adf0 --- /dev/null +++ b/src/modules/marketplace/enums/marketplace-listing-condition.enum.ts @@ -0,0 +1,6 @@ +export enum MarketplaceListingCondition { + NEW = 'new', + LIKE_NEW = 'like_new', + USED = 'used', + REFURBISHED = 'refurbished', +} diff --git a/src/modules/marketplace/marketplace.controller.ts b/src/modules/marketplace/marketplace.controller.ts index 83beb44..27894b6 100644 --- a/src/modules/marketplace/marketplace.controller.ts +++ b/src/modules/marketplace/marketplace.controller.ts @@ -1,52 +1,461 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { + 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 { Roles } from '../../common/decorators/roles.decorator'; +import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator'; import { Throttle } from '../../common/decorators/throttle.decorator'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.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 { JwtPayload } from '../../common/interfaces/jwt-payload.interface'; import { CreateInstrumentDto } from './dto/create-instrument.dto'; +import { CreateRepairShopDto } from './dto/create-repair-shop.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 { UpdateMarketplaceStatusDto } from './dto/update-marketplace-status.dto'; +import { UpdateRepairShopDto } from './dto/update-repair-shop.dto'; import { MarketplaceService } from './marketplace.service'; +import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions'; @ApiTags('Marketplace') @Controller('marketplace') export class MarketplaceController { 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') async listPublic(@Query() query: InstrumentQueryDto) { - return this.marketplaceService.getPublic(query); + return this.marketplaceService.getPublicInstruments(query); } @Get('instruments/:id') 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() @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: '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') @Throttle(40, 60_000) - async createByAdmin(@CurrentUser() user: JwtPayload, @Body() dto: CreateInstrumentDto) { - return this.marketplaceService.createByAdmin(user.sub, dto); + async createByAdmin( + @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() @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 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') @Throttle(60, 60_000) async updateByAdmin( @CurrentUser() user: JwtPayload, @Param('id') instrumentId: string, @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() @@ -55,7 +464,7 @@ export class MarketplaceController { @Delete('admin/instruments/:id') @Throttle(40, 60_000) 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 }; } @@ -64,6 +473,147 @@ export class MarketplaceController { @Roles(UserRole.ADMIN) @Get('admin/instruments/me') 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 ?? [], + ); } } diff --git a/src/modules/marketplace/marketplace.module.ts b/src/modules/marketplace/marketplace.module.ts index 7a888af..a579c17 100644 --- a/src/modules/marketplace/marketplace.module.ts +++ b/src/modules/marketplace/marketplace.module.ts @@ -1,15 +1,23 @@ import { Module } from '@nestjs/common'; 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 { MarketplaceController } from './marketplace.controller'; import { MarketplaceRepository } from './marketplace.repository'; import { MarketplaceService } from './marketplace.service'; import { Instrument, InstrumentSchema } from './schemas/instrument.schema'; +import { RepairShop, RepairShopSchema } from './schemas/repair-shop.schema'; @Module({ imports: [ + AuditModule, 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], providers: [MarketplaceService, MarketplaceRepository], diff --git a/src/modules/marketplace/marketplace.repository.ts b/src/modules/marketplace/marketplace.repository.ts index 6cea0df..85afc40 100644 --- a/src/modules/marketplace/marketplace.repository.ts +++ b/src/modules/marketplace/marketplace.repository.ts @@ -2,12 +2,15 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { FilterQuery, Model, Types, UpdateQuery } from 'mongoose'; import { Instrument, InstrumentDocument } from './schemas/instrument.schema'; +import { RepairShop, RepairShopDocument } from './schemas/repair-shop.schema'; @Injectable() export class MarketplaceRepository { constructor( @InjectModel(Instrument.name) private readonly instrumentModel: Model, + @InjectModel(RepairShop.name) + private readonly repairShopModel: Model, ) {} async create(ownerAdminId: string, payload: Partial): Promise { @@ -23,7 +26,7 @@ export class MarketplaceRepository { } return this.instrumentModel .findById(instrumentId) - .populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled' }) + .populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' }) .exec(); } @@ -37,7 +40,7 @@ export class MarketplaceRepository { return this.instrumentModel .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(); } @@ -52,11 +55,12 @@ export class MarketplaceRepository { filter: FilterQuery, skip: number, limit: number, + sort: Record = { createdAt: -1 }, ): Promise { return this.instrumentModel .find(filter) - .populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled' }) - .sort({ createdAt: -1 }) + .populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' }) + .sort(sort) .skip(skip) .limit(limit) .exec(); @@ -66,6 +70,7 @@ export class MarketplaceRepository { filter: FilterQuery, skip: number, limit: number, + sort: Record = { createdAt: -1 }, ): Promise[]> { return this.instrumentModel .aggregate([ @@ -80,7 +85,7 @@ export class MarketplaceRepository { }, { $unwind: '$ownerAdmin' }, { $match: { 'ownerAdmin.isDisabled': false } }, - { $sort: { createdAt: -1 } }, + { $sort: sort }, { $skip: skip }, { $limit: limit }, { @@ -93,6 +98,9 @@ export class MarketplaceRepository { quantity: 1, imageUrls: 1, isActive: 1, + condition: 1, + instrumentType: 1, + listingCategory: 1, createdAt: 1, updatedAt: 1, ownerAdminId: { @@ -101,6 +109,7 @@ export class MarketplaceRepository { username: '$ownerAdmin.username', email: '$ownerAdmin.email', avatar: '$ownerAdmin.avatar', + shopName: '$ownerAdmin.shopName', }, }, }, @@ -132,4 +141,147 @@ export class MarketplaceRepository { async count(filter: FilterQuery): Promise { return this.instrumentModel.countDocuments(filter).exec(); } + + async createRepairShop( + ownerAdminId: string, + payload: Partial, + ): Promise { + return this.repairShopModel.create({ + ...payload, + ownerAdminId: new Types.ObjectId(ownerAdminId), + }); + } + + async findRepairShopByOwnerAdminId(ownerAdminId: string): Promise { + 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 { + 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, + ): Promise { + 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 { + if (!Types.ObjectId.isValid(repairShopId)) { + return null; + } + return this.repairShopModel.findByIdAndDelete(repairShopId).exec(); + } + + async findManyRepairShops( + filter: FilterQuery, + skip: number, + limit: number, + sort: Record = { createdAt: -1 }, + ): Promise { + 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, + skip: number, + limit: number, + sort: Record = { createdAt: -1 }, + ): Promise[]> { + 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): Promise { + 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): Promise { + return this.repairShopModel.countDocuments(filter).exec(); + } } diff --git a/src/modules/marketplace/marketplace.service.ts b/src/modules/marketplace/marketplace.service.ts index cc65ad1..ea7f1c4 100644 --- a/src/modules/marketplace/marketplace.service.ts +++ b/src/modules/marketplace/marketplace.service.ts @@ -1,146 +1,869 @@ import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; -import { FilterQuery, Types } from 'mongoose'; +import { InjectModel } from '@nestjs/mongoose'; +import { extname } from 'path'; +import { FilterQuery, Model, Types } from 'mongoose'; +import { buildPaginatedResponse } from '../../common/utils/pagination.util'; +import { resolveManagedFileUrl, resolveManagedFileUrls } from '../../common/utils/public-url.util'; +import { resolveMongoSortDirection } from '../../common/utils/sort.util'; import { UserRole } from '../../common/enums/user-role.enum'; +import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service'; +import { AuditService } from '../audit/audit.service'; +import { + SuperAdminCase, + SuperAdminCaseDocument, + SuperAdminCasePriority, + SuperAdminCaseStatus, +} from '../superadmin/schemas/superadmin-case.schema'; import { UsersRepository } from '../users/users.repository'; import { CreateInstrumentDto } from './dto/create-instrument.dto'; import { InstrumentQueryDto } from './dto/instrument-query.dto'; +import { CreateRepairShopDto } from './dto/create-repair-shop.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 { UpdateMarketplaceStatusDto } from './dto/update-marketplace-status.dto'; +import { UpdateRepairShopDto } from './dto/update-repair-shop.dto'; +import { MarketplaceListingCategory } from './enums/marketplace-listing-category.enum'; +import { MarketplaceListingCondition } from './enums/marketplace-listing-condition.enum'; import { MarketplaceRepository } from './marketplace.repository'; import { InstrumentDocument } from './schemas/instrument.schema'; +import { RepairShopDocument } from './schemas/repair-shop.schema'; + +type UploadedImageFile = { + mimetype?: string; + size: number; + buffer: Buffer; + originalname?: string; +}; @Injectable() export class MarketplaceService { constructor( private readonly marketplaceRepository: MarketplaceRepository, private readonly usersRepository: UsersRepository, + private readonly storageService: ManagedStorageService, + private readonly auditService: AuditService, + @InjectModel(SuperAdminCase.name) + private readonly superAdminCaseModel: Model, ) {} - async createByAdmin(adminUserId: string, dto: CreateInstrumentDto): Promise { - await this.assertAdminRole(adminUserId); - this.assertImageUrlsCount(dto.imageUrls); + async getHome(query: MarketplaceHomeQueryDto) { + const listingsLimit = query.listingsLimit ?? 6; + const instrumentsLimit = query.instrumentsLimit ?? 6; + const repairShopsLimit = query.repairShopsLimit ?? 4; + const onlyActive = query.onlyActive ?? true; + + const listingQuery = { + page: 1, + limit: listingsLimit, + isActive: onlyActive, + } as InstrumentQueryDto; + const instrumentQuery = { + page: 1, + limit: instrumentsLimit, + isActive: onlyActive, + } as InstrumentQueryDto; + const repairShopQuery = { + page: 1, + limit: repairShopsLimit, + isActive: onlyActive, + } as RepairShopQueryDto; + + const activeListingsFilter = onlyActive ? ({ isActive: true } as FilterQuery) : {}; + const activeRepairShopsFilter = onlyActive + ? ({ isActive: true } as FilterQuery) + : {}; + const activeMusicalInstrumentsFilter = this.buildInstrumentFilter( + { isActive: onlyActive ? true : undefined } as InstrumentQueryDto, + { onlyMusicalInstruments: true }, + ); + + const [listings, instruments, repairShops, featuredShops, listingCategories, totalListings, totalInstruments, totalRepairShops] = + await Promise.all([ + this.getPublicListings(listingQuery), + this.getPublicInstruments(instrumentQuery), + this.getPublicRepairShops(repairShopQuery), + this.getFeaturedShops(8), + this.getListingCategorySummaries(onlyActive), + this.marketplaceRepository.countPublic(activeListingsFilter), + this.marketplaceRepository.countPublic(activeMusicalInstrumentsFilter), + this.marketplaceRepository.countRepairShopsPublic(activeRepairShopsFilter), + ]); + + return { + categories: [ + { + key: 'listings', + title: 'Marketplace', + endpoint: '/marketplace/listings', + }, + { + key: 'musical_instruments', + title: 'Musical instruments', + endpoint: '/marketplace/instruments', + }, + { + key: 'repair_shops', + title: 'Repair shops', + endpoint: '/marketplace/repair-shops', + }, + ], + summary: { + activeListings: totalListings, + activeMusicalInstruments: totalInstruments, + activeRepairShops: totalRepairShops, + }, + filters: { + listingCategories, + }, + featuredShops, + sections: { + listings: { + title: 'Marketplace', + endpoint: '/marketplace/listings', + ...listings, + }, + musicalInstruments: { + title: 'Musical instruments', + endpoint: '/marketplace/instruments', + ...instruments, + }, + repairShops: { + title: 'Repair shops', + endpoint: '/marketplace/repair-shops', + ...repairShops, + }, + }, + }; + } + + async createListingByAdmin( + adminUserId: string, + dto: CreateInstrumentDto, + imageFiles: UploadedImageFile[] = [], + ): Promise { + const admin = await this.assertAdminRole(adminUserId); + this.assertShopConfigured(admin); + this.assertImageUrlsCount(dto.imageUrls, imageFiles.length); + const uploadedImageUrls = await this.saveMarketplaceImageFiles(imageFiles, { + folderSegments: ['marketplace', 'listings'], + fieldName: 'imageFiles', + fileNamePrefix: 'listing', + }); return this.marketplaceRepository.create(adminUserId, { ...dto, currency: (dto.currency ?? 'SAR').toUpperCase(), description: dto.description ?? '', - imageUrls: dto.imageUrls ?? [], + imageUrls: [...(dto.imageUrls ?? []), ...uploadedImageUrls], isActive: dto.isActive ?? true, + condition: dto.condition ?? MarketplaceListingCondition.USED, + instrumentType: dto.instrumentType?.trim() ?? '', + listingCategory: dto.listingCategory ?? MarketplaceListingCategory.MUSICAL_INSTRUMENT, }); } + async createListingBySuperAdmin( + ownerAdminId: string, + dto: CreateInstrumentDto, + imageFiles: UploadedImageFile[] = [], + ): Promise { + await this.assertActiveAdminOwner(ownerAdminId); + return this.createListingByAdmin(ownerAdminId, dto, imageFiles); + } + + async createByAdmin(adminUserId: string, dto: CreateInstrumentDto): Promise { + return this.createInstrumentByAdmin(adminUserId, dto); + } + + async createInstrumentByAdmin( + adminUserId: string, + dto: CreateInstrumentDto, + imageFiles: UploadedImageFile[] = [], + ): Promise { + return this.createListingByAdmin(adminUserId, { + ...dto, + listingCategory: MarketplaceListingCategory.MUSICAL_INSTRUMENT, + }, imageFiles); + } + + async createInstrumentBySuperAdmin( + ownerAdminId: string, + dto: CreateInstrumentDto, + imageFiles: UploadedImageFile[] = [], + ): Promise { + await this.assertActiveAdminOwner(ownerAdminId); + return this.createInstrumentByAdmin(ownerAdminId, dto, imageFiles); + } + + async updateListingByAdmin( + adminUserId: string, + instrumentId: string, + dto: UpdateInstrumentDto, + imageFiles: UploadedImageFile[] = [], + ): Promise { + await this.assertAdminRole(adminUserId); + this.assertImageUrlsCount(dto.imageUrls, imageFiles.length); + const existing = await this.marketplaceRepository.findById(instrumentId); + if (!existing) { + throw new NotFoundException('Listing not found'); + } + if (existing.ownerAdminId.toString() !== adminUserId) { + throw new ForbiddenException('You can update only your listings'); + } + + const uploadedImageUrls = await this.saveMarketplaceImageFiles(imageFiles, { + folderSegments: ['marketplace', 'listings'], + fieldName: 'imageFiles', + fileNamePrefix: 'listing', + }); + + const imageUrls = + typeof dto.imageUrls === 'undefined' && uploadedImageUrls.length === 0 + ? undefined + : [...(dto.imageUrls ?? []), ...uploadedImageUrls]; + + const updated = await this.marketplaceRepository.updateById(instrumentId, { + ...dto, + ...(typeof imageUrls !== 'undefined' ? { imageUrls } : {}), + ...(dto.currency ? { currency: dto.currency.toUpperCase() } : {}), + ...(typeof dto.instrumentType === 'string' ? { instrumentType: dto.instrumentType.trim() } : {}), + }); + if (!updated) { + await Promise.all(uploadedImageUrls.map((fileUrl) => this.deleteManagedUpload(fileUrl))); + throw new NotFoundException('Listing not found'); + } + await this.cleanupRemovedManagedUrls(existing.imageUrls, imageUrls); + return updated; + } + async updateByAdmin( adminUserId: string, instrumentId: string, dto: UpdateInstrumentDto, ): Promise { - await this.assertAdminRole(adminUserId); - this.assertImageUrlsCount(dto.imageUrls); + return this.updateInstrumentByAdmin(adminUserId, instrumentId, dto); + } + + async updateInstrumentByAdmin( + adminUserId: string, + instrumentId: string, + dto: UpdateInstrumentDto, + imageFiles: UploadedImageFile[] = [], + ): Promise { const existing = await this.marketplaceRepository.findById(instrumentId); - if (!existing) { + if (!existing || !this.isMusicalInstrumentListing(existing.listingCategory)) { throw new NotFoundException('Instrument not found'); } if (existing.ownerAdminId.toString() !== adminUserId) { throw new ForbiddenException('You can update only your instruments'); } - const updated = await this.marketplaceRepository.updateById(instrumentId, { + return this.updateListingByAdmin(adminUserId, instrumentId, { ...dto, - ...(dto.currency ? { currency: dto.currency.toUpperCase() } : {}), - }); - if (!updated) { - throw new NotFoundException('Instrument not found'); - } - return updated; + listingCategory: MarketplaceListingCategory.MUSICAL_INSTRUMENT, + }, imageFiles); } - async removeByAdmin(adminUserId: string, instrumentId: string): Promise { + async removeListingByAdmin(adminUserId: string, instrumentId: string): Promise { await this.assertAdminRole(adminUserId); const existing = await this.marketplaceRepository.findById(instrumentId); if (!existing) { + throw new NotFoundException('Listing not found'); + } + if (existing.ownerAdminId.toString() !== adminUserId) { + throw new ForbiddenException('You can delete only your listings'); + } + await this.marketplaceRepository.deleteById(instrumentId); + await this.cleanupRemovedManagedUrls(existing.imageUrls); + } + + async removeByAdmin(adminUserId: string, instrumentId: string): Promise { + return this.removeInstrumentByAdmin(adminUserId, instrumentId); + } + + async removeInstrumentByAdmin(adminUserId: string, instrumentId: string): Promise { + const existing = await this.marketplaceRepository.findById(instrumentId); + if (!existing || !this.isMusicalInstrumentListing(existing.listingCategory)) { throw new NotFoundException('Instrument not found'); } if (existing.ownerAdminId.toString() !== adminUserId) { throw new ForbiddenException('You can delete only your instruments'); } await this.marketplaceRepository.deleteById(instrumentId); + await this.cleanupRemovedManagedUrls(existing.imageUrls); } - async getMine(adminUserId: string, query: InstrumentQueryDto) { + async getMyListings( + adminUserId: string, + query: InstrumentQueryDto, + options?: { onlyMusicalInstruments?: boolean }, + ) { await this.assertAdminRole(adminUserId); const page = query.page ?? 1; const limit = query.limit ?? 20; const skip = (page - 1) * limit; - const filter = this.buildFilter(query); + const matchingShopOwnerIds = await this.findMatchingShopOwnerIds(query.q); + const filter = this.buildInstrumentFilter(query, { + ...options, + matchingShopOwnerIds, + }); filter.ownerAdminId = new Types.ObjectId(adminUserId); + const sort = this.buildInstrumentSort(query.sortBy, query.sortOrder); const [items, total] = await Promise.all([ - this.marketplaceRepository.findManyPublic(filter, skip, limit), + this.marketplaceRepository.findManyPublic(filter, skip, limit, sort), this.marketplaceRepository.countPublic(filter), ]); - return { - items, + return buildPaginatedResponse(this.decorateInstrumentRows(items), { page, limit, total, - totalPages: Math.ceil(total / limit) || 1, - }; + offset: skip, + }); } - async getPublic(query: InstrumentQueryDto) { + async getMine(adminUserId: string, query: InstrumentQueryDto) { + return this.getMyInstruments(adminUserId, query); + } + + async getMyInstruments(adminUserId: string, query: InstrumentQueryDto) { + return this.getMyListings(adminUserId, query, { onlyMusicalInstruments: true }); + } + + async getPublicListings( + query: InstrumentQueryDto, + options?: { onlyMusicalInstruments?: boolean }, + ) { const page = query.page ?? 1; const limit = query.limit ?? 20; const skip = (page - 1) * limit; - const filter = this.buildFilter(query); + const matchingShopOwnerIds = await this.findMatchingShopOwnerIds(query.q); + const filter = this.buildInstrumentFilter(query, { + ...options, + matchingShopOwnerIds, + }); if (typeof query.isActive === 'undefined') { filter.isActive = true; } + const sort = this.buildInstrumentSort(query.sortBy, query.sortOrder); const [items, total] = await Promise.all([ - this.marketplaceRepository.findMany(filter, skip, limit), - this.marketplaceRepository.count(filter), + this.marketplaceRepository.findManyPublic(filter, skip, limit, sort), + this.marketplaceRepository.countPublic(filter), ]); - return { - items, + return buildPaginatedResponse(this.decorateInstrumentRows(items), { page, limit, total, - totalPages: Math.ceil(total / limit) || 1, - }; + offset: skip, + }); + } + + async getPublic(query: InstrumentQueryDto) { + return this.getPublicInstruments(query); + } + + async getListingsForSuperAdmin(query: InstrumentQueryDto) { + const page = query.page ?? 1; + const limit = query.limit ?? 20; + const skip = (page - 1) * limit; + + const matchingShopOwnerIds = await this.findMatchingShopOwnerIds(query.q); + const filter = this.buildInstrumentFilter(query, { + matchingShopOwnerIds, + }); + const sort = this.buildInstrumentSort(query.sortBy, query.sortOrder); + + const [items, total] = await Promise.all([ + this.marketplaceRepository.findManyPublic(filter, skip, limit, sort), + this.marketplaceRepository.countPublic(filter), + ]); + + return buildPaginatedResponse(this.decorateInstrumentRows(items), { + page, + limit, + total, + offset: skip, + }); + } + + async getPublicInstruments(query: InstrumentQueryDto) { + return this.getPublicListings(query, { onlyMusicalInstruments: true }); } async findById(instrumentId: string) { + return this.findListingById(instrumentId); + } + + async findListingById(instrumentId: string) { const item = await this.marketplaceRepository.findById(instrumentId); if (!item) { - throw new NotFoundException('Instrument not found'); + throw new NotFoundException('Listing not found'); } const owner = item.ownerAdminId as unknown as { isDisabled?: boolean } | undefined; if (owner?.isDisabled) { + throw new NotFoundException('Listing not found'); + } + return this.decorateInstrumentItem(item); + } + + async findInstrumentById(instrumentId: string) { + const item = await this.findListingById(instrumentId); + if (!this.isMusicalInstrumentListing(item.listingCategory)) { throw new NotFoundException('Instrument not found'); } return item; } - private buildFilter(query: InstrumentQueryDto): FilterQuery { - const filter: FilterQuery = {}; + async createRepairShop( + adminUserId: string, + dto: CreateRepairShopDto, + imageFiles: UploadedImageFile[] = [], + ): Promise { + await this.assertAdminRole(adminUserId); + await this.assertAdminHasNoRepairShop(adminUserId); + this.assertShopImagesCount(dto.imageUrls, imageFiles.length); + this.assertShopCoordinates(dto.latitude, dto.longitude); + const uploadedImageUrls = await this.saveMarketplaceImageFiles(imageFiles, { + folderSegments: ['marketplace', 'repair-shops'], + fieldName: 'imageFiles', + fileNamePrefix: 'repair-shop', + }); + return this.marketplaceRepository.createRepairShop(adminUserId, { + ...dto, + description: dto.description ?? '', + services: dto.services ?? [], + phone: dto.phone ?? '', + whatsapp: dto.whatsapp ?? '', + imageUrls: [...(dto.imageUrls ?? []), ...uploadedImageUrls], + location: dto.location ?? '', + isActive: dto.isActive ?? true, + }); + } + async createRepairShopBySuperAdmin( + ownerAdminId: string, + dto: CreateRepairShopDto, + imageFiles: UploadedImageFile[] = [], + ): Promise { + await this.assertActiveAdminOwner(ownerAdminId); + return this.createRepairShop(ownerAdminId, dto, imageFiles); + } + + async updateRepairShop( + adminUserId: string, + repairShopId: string, + dto: UpdateRepairShopDto, + imageFiles: UploadedImageFile[] = [], + ): Promise { + await this.assertAdminRole(adminUserId); + this.assertShopImagesCount(dto.imageUrls, imageFiles.length); + this.assertShopCoordinates(dto.latitude, dto.longitude); + const existing = await this.marketplaceRepository.findRepairShopById(repairShopId); + if (!existing) { + throw new NotFoundException('Repair shop not found'); + } + if (existing.ownerAdminId.toString() !== adminUserId) { + throw new ForbiddenException('You can update only your repair shops'); + } + + const uploadedImageUrls = await this.saveMarketplaceImageFiles(imageFiles, { + folderSegments: ['marketplace', 'repair-shops'], + fieldName: 'imageFiles', + fileNamePrefix: 'repair-shop', + }); + + const imageUrls = + typeof dto.imageUrls === 'undefined' && uploadedImageUrls.length === 0 + ? undefined + : [...(dto.imageUrls ?? []), ...uploadedImageUrls]; + + const updated = await this.marketplaceRepository.updateRepairShopById(repairShopId, { + ...dto, + ...(typeof imageUrls !== 'undefined' ? { imageUrls } : {}), + }); + if (!updated) { + await Promise.all(uploadedImageUrls.map((fileUrl) => this.deleteManagedUpload(fileUrl))); + throw new NotFoundException('Repair shop not found'); + } + await this.cleanupRemovedManagedUrls(existing.imageUrls, imageUrls); + return updated; + } + + async removeRepairShop(adminUserId: string, repairShopId: string): Promise { + await this.assertAdminRole(adminUserId); + const existing = await this.marketplaceRepository.findRepairShopById(repairShopId); + if (!existing) { + throw new NotFoundException('Repair shop not found'); + } + if (existing.ownerAdminId.toString() !== adminUserId) { + throw new ForbiddenException('You can delete only your repair shops'); + } + await this.marketplaceRepository.deleteRepairShopById(repairShopId); + await this.cleanupRemovedManagedUrls(existing.imageUrls); + } + + async getMyRepairShops(adminUserId: string, query: RepairShopQueryDto) { + await this.assertAdminRole(adminUserId); + const page = query.page ?? 1; + const limit = query.limit ?? 20; + const skip = (page - 1) * limit; + + const filter = this.buildRepairShopFilter(query); + filter.ownerAdminId = new Types.ObjectId(adminUserId); + const sort = this.buildRepairShopSort(query.sortBy, query.sortOrder); + + const [items, total] = await Promise.all([ + this.marketplaceRepository.findManyRepairShopsPublic(filter, skip, limit, sort), + this.marketplaceRepository.countRepairShopsPublic(filter), + ]); + + return buildPaginatedResponse(this.decorateRepairShopRows(items), { + page, + limit, + total, + offset: skip, + }); + } + + async getPublicRepairShops(query: RepairShopQueryDto) { + const page = query.page ?? 1; + const limit = query.limit ?? 20; + const skip = (page - 1) * limit; + + const filter = this.buildRepairShopFilter(query); + if (typeof query.isActive === 'undefined') { + filter.isActive = true; + } + const sort = this.buildRepairShopSort(query.sortBy, query.sortOrder); + + const [items, total] = await Promise.all([ + this.marketplaceRepository.findManyRepairShopsPublic(filter, skip, limit, sort), + this.marketplaceRepository.countRepairShopsPublic(filter), + ]); + + return buildPaginatedResponse(this.decorateRepairShopRows(items), { + page, + limit, + total, + offset: skip, + }); + } + + async getRepairShopsForSuperAdmin(query: RepairShopQueryDto) { + const page = query.page ?? 1; + const limit = query.limit ?? 20; + const skip = (page - 1) * limit; + + const filter = this.buildRepairShopFilter(query); + const sort = this.buildRepairShopSort(query.sortBy, query.sortOrder); + + const [items, total] = await Promise.all([ + this.marketplaceRepository.findManyRepairShopsPublic(filter, skip, limit, sort), + this.marketplaceRepository.countRepairShopsPublic(filter), + ]); + + return buildPaginatedResponse(this.decorateRepairShopRows(items), { + page, + limit, + total, + offset: skip, + }); + } + + async findRepairShopById(repairShopId: string) { + const item = await this.marketplaceRepository.findRepairShopById(repairShopId); + if (!item) { + throw new NotFoundException('Repair shop not found'); + } + const owner = item.ownerAdminId as unknown as { isDisabled?: boolean } | undefined; + if (owner?.isDisabled) { + throw new NotFoundException('Repair shop not found'); + } + return this.decorateRepairShopItem(item); + } + + async updateMyShopProfile( + adminUserId: string, + dto: UpdateShopProfileDto, + shopImageFiles: UploadedImageFile[] = [], + ) { + const admin = await this.assertAdminRole(adminUserId); + this.assertShopImagesCount(dto.shopImageUrls, shopImageFiles.length); + this.assertShopCoordinates(dto.shopLatitude, dto.shopLongitude); + + const uploadedImageUrls = await this.saveMarketplaceImageFiles(shopImageFiles, { + folderSegments: ['marketplace', 'shop-profiles'], + fieldName: 'shopImageFiles', + fileNamePrefix: 'shop', + }); + + const shopImageUrls = + typeof dto.shopImageUrls === 'undefined' && uploadedImageUrls.length === 0 + ? undefined + : [...(dto.shopImageUrls ?? []), ...uploadedImageUrls]; + + const payload: Record = { + ...dto, + shopName: typeof dto.shopName === 'string' ? dto.shopName.trim() : admin.shopName, + shopDescription: + typeof dto.shopDescription === 'string' ? dto.shopDescription.trim() : admin.shopDescription, + shopLocation: typeof dto.shopLocation === 'string' ? dto.shopLocation.trim() : admin.shopLocation, + ...(typeof shopImageUrls !== 'undefined' ? { shopImageUrls } : {}), + }; + + const updated = await this.usersRepository.updateById(adminUserId, payload); + if (!updated) { + await Promise.all(uploadedImageUrls.map((fileUrl) => this.deleteManagedUpload(fileUrl))); + throw new NotFoundException('Admin not found'); + } + + await this.cleanupRemovedManagedUrls(admin.shopImageUrls, shopImageUrls); + + return this.mapShopProfile(updated); + } + + async updateShopProfileBySuperAdmin( + ownerAdminId: string, + dto: UpdateShopProfileDto, + shopImageFiles: UploadedImageFile[] = [], + ) { + await this.assertActiveAdminOwner(ownerAdminId); + return this.updateMyShopProfile(ownerAdminId, dto, shopImageFiles); + } + + async getMyShopProfile(adminUserId: string) { + const admin = await this.assertAdminRole(adminUserId); + return this.mapShopProfile(admin); + } + + async getShopProfileByAdminId(adminId: string) { + const admin = await this.assertAdminRole(adminId); + if (admin.isDisabled) { + throw new NotFoundException('Shop not found'); + } + return this.mapShopProfile(admin); + } + + async updateListingStatusBySuperAdmin( + superAdminIdentifier: string, + listingId: string, + dto: UpdateMarketplaceStatusDto, + ) { + const existing = await this.marketplaceRepository.findById(listingId); + if (!existing) { + throw new NotFoundException('Listing not found'); + } + + const updated = await this.marketplaceRepository.updateById(listingId, { + isActive: dto.isActive, + }); + if (!updated) { + throw new NotFoundException('Listing not found'); + } + + const reason = dto.reason?.trim() ?? ''; + await this.auditService.logSuperAdminAction( + superAdminIdentifier, + dto.isActive ? 'marketplace_listing_activate' : 'marketplace_listing_deactivate', + 'listing', + listingId, + { + isActive: dto.isActive, + reason, + }, + ); + await this.recordMarketplaceCase({ + actor: superAdminIdentifier, + resourceType: 'listing', + resourceId: listingId, + title: this.buildMarketplaceCaseTitle('Listing', existing.title ?? listingId), + description: reason, + action: dto.isActive ? 'listing_activated' : 'listing_deactivated', + note: reason, + status: dto.isActive ? 'resolved' : 'in_review', + priority: dto.isActive ? 'normal' : 'high', + }); + + return this.decorateInstrumentItem(updated); + } + + async removeListingBySuperAdmin(superAdminIdentifier: string, listingId: string) { + const existing = await this.marketplaceRepository.findById(listingId); + if (!existing) { + throw new NotFoundException('Listing not found'); + } + await this.marketplaceRepository.deleteById(listingId); + await this.cleanupRemovedManagedUrls(existing.imageUrls); + await this.auditService.logSuperAdminAction( + superAdminIdentifier, + 'marketplace_listing_delete', + 'listing', + listingId, + { title: existing.title ?? '' }, + ); + await this.recordMarketplaceCase({ + actor: superAdminIdentifier, + resourceType: 'listing', + resourceId: listingId, + title: this.buildMarketplaceCaseTitle('Listing', existing.title ?? listingId), + description: 'Deleted by superadmin', + action: 'listing_deleted', + note: 'Deleted by superadmin', + status: 'resolved', + priority: 'high', + }); + } + + async updateRepairShopStatusBySuperAdmin( + superAdminIdentifier: string, + repairShopId: string, + dto: UpdateMarketplaceStatusDto, + ) { + const existing = await this.marketplaceRepository.findRepairShopById(repairShopId); + if (!existing) { + throw new NotFoundException('Repair shop not found'); + } + + const updated = await this.marketplaceRepository.updateRepairShopById(repairShopId, { + isActive: dto.isActive, + }); + if (!updated) { + throw new NotFoundException('Repair shop not found'); + } + + const reason = dto.reason?.trim() ?? ''; + await this.auditService.logSuperAdminAction( + superAdminIdentifier, + dto.isActive ? 'marketplace_repair_shop_activate' : 'marketplace_repair_shop_deactivate', + 'repair_shop', + repairShopId, + { + isActive: dto.isActive, + reason, + }, + ); + await this.recordMarketplaceCase({ + actor: superAdminIdentifier, + resourceType: 'repair_shop', + resourceId: repairShopId, + title: this.buildMarketplaceCaseTitle('Repair shop', existing.name ?? repairShopId), + description: reason, + action: dto.isActive ? 'repair_shop_activated' : 'repair_shop_deactivated', + note: reason, + status: dto.isActive ? 'resolved' : 'in_review', + priority: dto.isActive ? 'normal' : 'high', + }); + + return this.decorateRepairShopItem(updated); + } + + async removeRepairShopBySuperAdmin(superAdminIdentifier: string, repairShopId: string) { + const existing = await this.marketplaceRepository.findRepairShopById(repairShopId); + if (!existing) { + throw new NotFoundException('Repair shop not found'); + } + await this.marketplaceRepository.deleteRepairShopById(repairShopId); + await this.cleanupRemovedManagedUrls(existing.imageUrls); + await this.auditService.logSuperAdminAction( + superAdminIdentifier, + 'marketplace_repair_shop_delete', + 'repair_shop', + repairShopId, + { name: existing.name ?? '' }, + ); + await this.recordMarketplaceCase({ + actor: superAdminIdentifier, + resourceType: 'repair_shop', + resourceId: repairShopId, + title: this.buildMarketplaceCaseTitle('Repair shop', existing.name ?? repairShopId), + description: 'Deleted by superadmin', + action: 'repair_shop_deleted', + note: 'Deleted by superadmin', + status: 'resolved', + priority: 'high', + }); + } + + private buildInstrumentFilter( + query: InstrumentQueryDto, + options?: { + onlyMusicalInstruments?: boolean; + matchingShopOwnerIds?: Types.ObjectId[]; + }, + ): FilterQuery { + const clauses: FilterQuery[] = []; if (query.q) { - filter.$or = [ + const searchClauses: Record[] = [ { title: { $regex: query.q, $options: 'i' } }, { description: { $regex: query.q, $options: 'i' } }, + { instrumentType: { $regex: query.q, $options: 'i' } }, ]; + if (options?.matchingShopOwnerIds?.length) { + searchClauses.push({ ownerAdminId: { $in: options.matchingShopOwnerIds } }); + } + clauses.push({ + $or: searchClauses, + }); } if (typeof query.minPrice === 'number' || typeof query.maxPrice === 'number') { - filter.price = {}; + const priceFilter: Record = {}; if (typeof query.minPrice === 'number') { - (filter.price as Record).$gte = query.minPrice; + priceFilter.$gte = query.minPrice; } if (typeof query.maxPrice === 'number') { - (filter.price as Record).$lte = query.maxPrice; + priceFilter.$lte = query.maxPrice; } + clauses.push({ price: priceFilter }); + } + + if (typeof query.isActive === 'boolean') { + clauses.push({ isActive: query.isActive }); + } + + if (query.condition) { + clauses.push({ condition: query.condition }); + } + + if (query.instrumentType?.trim()) { + clauses.push({ instrumentType: { $regex: query.instrumentType.trim(), $options: 'i' } }); + } + + if (query.listingCategory) { + clauses.push({ listingCategory: query.listingCategory }); + } + + if (options?.onlyMusicalInstruments) { + clauses.push(this.buildMusicalInstrumentCategoryFilter()); + } + + if (!clauses.length) { + return {}; + } + + if (clauses.length === 1) { + return clauses[0]; + } + + return { $and: clauses }; + } + + private buildRepairShopFilter(query: RepairShopQueryDto): FilterQuery { + const filter: FilterQuery = {}; + + if (query.q) { + filter.$or = [ + { name: { $regex: query.q, $options: 'i' } }, + { description: { $regex: query.q, $options: 'i' } }, + { location: { $regex: query.q, $options: 'i' } }, + { services: { $elemMatch: { $regex: query.q, $options: 'i' } } }, + ]; } if (typeof query.isActive === 'boolean') { @@ -150,7 +873,173 @@ export class MarketplaceService { return filter; } - private async assertAdminRole(userId: string): Promise { + private buildInstrumentSort( + sortBy: InstrumentQueryDto['sortBy'], + sortOrder: InstrumentQueryDto['sortOrder'], + ): Record { + const direction = resolveMongoSortDirection(sortOrder); + const field = sortBy ?? 'createdAt'; + return { [field]: direction }; + } + + private buildRepairShopSort( + sortBy: RepairShopQueryDto['sortBy'], + sortOrder: RepairShopQueryDto['sortOrder'], + ): Record { + const direction = resolveMongoSortDirection(sortOrder); + const field = sortBy ?? 'createdAt'; + return { [field]: direction }; + } + + private buildMusicalInstrumentCategoryFilter(): FilterQuery { + return { + $or: [ + { listingCategory: MarketplaceListingCategory.MUSICAL_INSTRUMENT }, + { listingCategory: { $exists: false } }, + { listingCategory: null }, + ], + }; + } + + private isMusicalInstrumentListing(value: unknown): boolean { + return value === MarketplaceListingCategory.MUSICAL_INSTRUMENT || value == null; + } + + private buildMarketplaceCaseTitle(prefix: string, subject: string): string { + const normalized = subject.trim(); + return `${prefix} moderation: ${normalized.slice(0, 72) || 'untitled'}`; + } + + private async recordMarketplaceCase(params: { + actor: string; + resourceType: 'listing' | 'repair_shop'; + resourceId: string; + title: string; + description: string; + action: string; + note: string; + status: SuperAdminCaseStatus; + priority: SuperAdminCasePriority; + }): Promise { + const existing = await this.superAdminCaseModel + .findOne({ + resourceType: params.resourceType, + resourceId: params.resourceId, + status: { $in: ['open', 'in_review'] }, + }) + .sort({ updatedAt: -1 }) + .exec(); + + if (!existing) { + await this.superAdminCaseModel.create({ + title: params.title, + description: params.description, + caseType: 'marketplace_review', + resourceType: params.resourceType, + resourceId: params.resourceId, + status: params.status, + priority: params.priority, + createdBy: params.actor, + updatedBy: params.actor, + resolution: params.note, + events: [ + { + action: params.action, + actor: params.actor, + note: params.note, + metadata: {}, + createdAt: new Date(), + }, + ], + }); + return; + } + + existing.status = params.status; + existing.priority = params.priority; + existing.updatedBy = params.actor; + existing.title = params.title; + existing.description = params.description; + existing.resolution = params.note; + existing.events.push({ + action: params.action, + actor: params.actor, + note: params.note, + metadata: {}, + createdAt: new Date(), + }); + await existing.save(); + } + + private async findMatchingShopOwnerIds(query: string | undefined): Promise { + const search = query?.trim(); + if (!search) { + return []; + } + + const rows = await this.usersRepository.findMany( + { + role: UserRole.ADMIN, + isDisabled: false, + $or: [ + { shopName: { $regex: search, $options: 'i' } }, + { name: { $regex: search, $options: 'i' } }, + ], + }, + 0, + 25, + { isVerified: -1, followersCount: -1, createdAt: -1 }, + ); + + return rows.map((row) => new Types.ObjectId(row.id)); + } + + private async getFeaturedShops(limit: number) { + const admins = await this.usersRepository.findMany( + { + role: UserRole.ADMIN, + isDisabled: false, + shopName: { $ne: '' }, + 'shopImageUrls.0': { $exists: true }, + }, + 0, + limit, + { isVerified: -1, followersCount: -1, createdAt: -1 }, + ); + + return admins.map((admin) => this.mapShopProfile(admin)); + } + + private async getListingCategorySummaries(onlyActive: boolean) { + const categories = [ + { key: 'all', listingCategory: null as MarketplaceListingCategory | null }, + { key: MarketplaceListingCategory.MUSICAL_INSTRUMENT, listingCategory: MarketplaceListingCategory.MUSICAL_INSTRUMENT }, + { key: MarketplaceListingCategory.ACCESSORY, listingCategory: MarketplaceListingCategory.ACCESSORY }, + { key: MarketplaceListingCategory.AUDIO_GEAR, listingCategory: MarketplaceListingCategory.AUDIO_GEAR }, + { key: MarketplaceListingCategory.SHEET_MUSIC, listingCategory: MarketplaceListingCategory.SHEET_MUSIC }, + { key: MarketplaceListingCategory.OTHER, listingCategory: MarketplaceListingCategory.OTHER }, + ]; + + return Promise.all( + categories.map(async (category) => { + const filter = category.listingCategory + ? this.buildInstrumentFilter( + { + listingCategory: category.listingCategory, + isActive: onlyActive ? true : undefined, + } as InstrumentQueryDto, + ) + : (onlyActive ? ({ isActive: true } as FilterQuery) : {}); + + return { + key: category.key, + count: await this.marketplaceRepository.countPublic(filter), + }; + }), + ); + } + + private async assertAdminRole(userId: string) { const user = await this.usersRepository.findById(userId); if (!user) { throw new NotFoundException('User not found'); @@ -158,11 +1047,238 @@ export class MarketplaceService { if (user.role !== UserRole.ADMIN) { throw new ForbiddenException('Admin role required'); } + return user; } - private assertImageUrlsCount(imageUrls?: string[]): void { - if (imageUrls && imageUrls.length > 5) { + private async assertActiveAdminOwner(userId: string) { + const user = await this.assertAdminRole(userId); + if (user.isDisabled) { + throw new ForbiddenException('Disabled admins cannot own marketplace resources'); + } + return user; + } + + private async assertAdminHasNoRepairShop(adminUserId: string): Promise { + const existingShop = await this.marketplaceRepository.findRepairShopByOwnerAdminId(adminUserId); + if (existingShop) { + throw new BadRequestException( + 'Each admin can create one marketplace shop only. Update your existing shop instead.', + ); + } + } + + private assertImageUrlsCount(imageUrls?: string[], additionalImagesCount = 0): void { + if ((imageUrls?.length ?? 0) + additionalImagesCount > 5) { throw new BadRequestException('You can upload up to 5 images only'); } } + + private assertShopImagesCount(imageUrls?: string[], additionalImagesCount = 0): void { + if ((imageUrls?.length ?? 0) + additionalImagesCount > 8) { + throw new BadRequestException('Shop can contain up to 8 images only'); + } + } + + private assertShopCoordinates( + latitude: number | undefined, + longitude: number | undefined, + ): void { + const hasLat = typeof latitude === 'number'; + const hasLng = typeof longitude === 'number'; + if (hasLat !== hasLng) { + throw new BadRequestException('shopLatitude and shopLongitude must be provided together'); + } + } + + private assertShopConfigured(admin: { + shopName?: string; + shopImageUrls?: string[]; + shopDescription?: string; + shopLocation?: string; + }): void { + const hasName = !!admin.shopName?.trim(); + const hasImages = (admin.shopImageUrls?.length ?? 0) > 0; + const hasDescription = !!admin.shopDescription?.trim(); + const hasLocation = !!admin.shopLocation?.trim(); + + if (!hasName || !hasImages || !hasDescription || !hasLocation) { + throw new BadRequestException( + 'Complete shop profile first (shopName, shopImageUrls, shopDescription, shopLocation)', + ); + } + } + + private async saveMarketplaceImageFiles( + imageFiles: UploadedImageFile[], + options: { + folderSegments: string[]; + fieldName: 'imageFiles' | 'shopImageFiles'; + fileNamePrefix: string; + }, + ): Promise { + const uploads: string[] = []; + + for (const imageFile of imageFiles) { + uploads.push(await this.saveMarketplaceImageFile(imageFile, options)); + } + + return uploads; + } + + private async saveMarketplaceImageFile( + imageFile: UploadedImageFile, + options: { + folderSegments: string[]; + fieldName: 'imageFiles' | 'shopImageFiles'; + fileNamePrefix: string; + }, + ): Promise { + const extension = this.resolveImageExtension(imageFile); + const maxSize = 8 * 1024 * 1024; + + if (!extension) { + throw new BadRequestException(`${options.fieldName} must be png, jpg, jpeg, webp, or gif`); + } + + if (imageFile.size > maxSize) { + throw new BadRequestException(`${options.fieldName} size must be 8MB or less`); + } + + return this.storageService.saveFile({ + folderSegments: options.folderSegments, + extension, + buffer: imageFile.buffer, + contentType: imageFile.mimetype, + fileNamePrefix: options.fileNamePrefix, + }); + } + + private resolveImageExtension(imageFile: UploadedImageFile): string | null { + const originalExtension = extname(imageFile.originalname ?? '').toLowerCase(); + const allowedExtensions = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']); + + if (allowedExtensions.has(originalExtension)) { + return originalExtension; + } + + switch (imageFile.mimetype) { + case 'image/png': + return '.png'; + case 'image/jpeg': + case 'image/jpg': + return '.jpg'; + case 'image/webp': + return '.webp'; + case 'image/gif': + return '.gif'; + default: + return null; + } + } + + private async cleanupRemovedManagedUrls( + currentUrls: unknown, + nextUrls?: string[], + ): Promise { + const current = Array.isArray(currentUrls) + ? currentUrls.filter((value): value is string => typeof value === 'string' && !!value.trim()) + : []; + const next = new Set((nextUrls ?? []).filter((value) => typeof value === 'string' && !!value.trim())); + + const removedUrls = current.filter((fileUrl) => !next.has(fileUrl)); + await Promise.all(removedUrls.map((fileUrl) => this.deleteManagedUpload(fileUrl))); + } + + private async deleteManagedUpload(fileUrl: string): Promise { + await this.storageService.deleteFile(fileUrl); + } + + private mapShopProfile(admin: { + id?: string; + _id?: unknown; + name?: string; + username?: string; + email?: string; + shopName?: string; + shopDescription?: string; + shopImageUrls?: string[]; + shopLocation?: string; + shopLatitude?: number | null; + shopLongitude?: number | null; + isDisabled?: boolean; + createdAt?: Date; + updatedAt?: Date; + }) { + return { + adminId: (admin as any)._id?.toString?.() ?? admin.id, + adminName: admin.name ?? '', + adminUsername: admin.username ?? '', + adminEmail: admin.email ?? '', + shopName: admin.shopName ?? '', + shopDescription: admin.shopDescription ?? '', + shopImageUrls: resolveManagedFileUrls(admin.shopImageUrls ?? []) as string[], + shopLocation: admin.shopLocation ?? '', + shopLatitude: admin.shopLatitude ?? null, + shopLongitude: admin.shopLongitude ?? null, + isDisabled: admin.isDisabled ?? false, + createdAt: admin.createdAt, + updatedAt: admin.updatedAt, + }; + } + + private decorateInstrumentRows(items: Array | InstrumentDocument>): Array> { + return items.map((item) => this.decorateInstrumentItem(item)); + } + + private decorateRepairShopRows( + items: Array | RepairShopDocument>, + ): Array> { + return items.map((item) => this.decorateRepairShopItem(item)); + } + + private decorateInstrumentItem(item: Record | InstrumentDocument): Record { + const row = + typeof (item as InstrumentDocument).toObject === 'function' + ? ((item as InstrumentDocument).toObject() as unknown as Record) + : ({ ...item } as Record); + + row.imageUrls = resolveManagedFileUrls(row.imageUrls); + if (row.ownerAdminId && typeof row.ownerAdminId === 'object') { + const owner = { ...(row.ownerAdminId as Record) }; + owner.avatar = resolveManagedFileUrl(owner.avatar); + row.ownerAdminId = owner; + row.shop = { + adminId: owner._id ?? owner.id ?? '', + name: owner.shopName ?? owner.name ?? '', + username: owner.username ?? '', + avatar: owner.avatar ?? '', + }; + row.storeName = owner.shopName ?? owner.name ?? ''; + } + + return row; + } + + private decorateRepairShopItem(item: Record | RepairShopDocument): Record { + const row = + typeof (item as RepairShopDocument).toObject === 'function' + ? ((item as RepairShopDocument).toObject() as unknown as Record) + : ({ ...item } as Record); + + row.imageUrls = resolveManagedFileUrls(row.imageUrls); + if (row.ownerAdminId && typeof row.ownerAdminId === 'object') { + const owner = { ...(row.ownerAdminId as Record) }; + owner.avatar = resolveManagedFileUrl(owner.avatar); + row.ownerAdminId = owner; + row.shop = { + adminId: owner._id ?? owner.id ?? '', + name: owner.shopName ?? owner.name ?? '', + username: owner.username ?? '', + avatar: owner.avatar ?? '', + }; + row.storeName = owner.shopName ?? owner.name ?? ''; + } + + return row; + } } diff --git a/src/modules/marketplace/schemas/instrument.schema.ts b/src/modules/marketplace/schemas/instrument.schema.ts index 5fd906b..1e1d2d6 100644 --- a/src/modules/marketplace/schemas/instrument.schema.ts +++ b/src/modules/marketplace/schemas/instrument.schema.ts @@ -1,6 +1,9 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { HydratedDocument, Types } from 'mongoose'; +import { resolveManagedFileUrls } from '../../../common/utils/public-url.util'; import { User } from '../../users/schemas/user.schema'; +import { MarketplaceListingCategory } from '../enums/marketplace-listing-category.enum'; +import { MarketplaceListingCondition } from '../enums/marketplace-listing-condition.enum'; export type InstrumentDocument = HydratedDocument; @@ -29,9 +32,39 @@ export class Instrument { @Prop({ default: true, index: true }) isActive!: boolean; + + @Prop({ + type: String, + enum: MarketplaceListingCondition, + default: MarketplaceListingCondition.USED, + index: true, + }) + condition!: MarketplaceListingCondition; + + @Prop({ default: '', trim: true, maxlength: 80, index: true }) + instrumentType!: string; + + @Prop({ + type: String, + enum: MarketplaceListingCategory, + default: MarketplaceListingCategory.MUSICAL_INSTRUMENT, + index: true, + }) + listingCategory!: MarketplaceListingCategory; } export const InstrumentSchema = SchemaFactory.createForClass(Instrument); InstrumentSchema.index({ ownerAdminId: 1, createdAt: -1 }); InstrumentSchema.index({ isActive: 1, createdAt: -1 }); InstrumentSchema.index({ title: 1, isActive: 1, createdAt: -1 }); +InstrumentSchema.index({ listingCategory: 1, isActive: 1, createdAt: -1 }); +InstrumentSchema.index({ condition: 1, isActive: 1, createdAt: -1 }); +InstrumentSchema.index({ instrumentType: 1, isActive: 1, createdAt: -1 }); + +const transformManagedInstrumentFiles = (_doc: unknown, ret: any) => { + ret.imageUrls = resolveManagedFileUrls(ret.imageUrls); + return ret; +}; + +InstrumentSchema.set('toJSON', { transform: transformManagedInstrumentFiles }); +InstrumentSchema.set('toObject', { transform: transformManagedInstrumentFiles }); diff --git a/src/modules/marketplace/schemas/repair-shop.schema.ts b/src/modules/marketplace/schemas/repair-shop.schema.ts new file mode 100644 index 0000000..1ea0a28 --- /dev/null +++ b/src/modules/marketplace/schemas/repair-shop.schema.ts @@ -0,0 +1,55 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { HydratedDocument, Types } from 'mongoose'; +import { resolveManagedFileUrls } from '../../../common/utils/public-url.util'; +import { User } from '../../users/schemas/user.schema'; + +export type RepairShopDocument = HydratedDocument; + +@Schema({ timestamps: true, versionKey: false }) +export class RepairShop { + @Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true }) + ownerAdminId!: Types.ObjectId; + + @Prop({ required: true, trim: true, maxlength: 120, index: true }) + name!: string; + + @Prop({ default: '', trim: true, maxlength: 2000 }) + description!: string; + + @Prop({ type: [String], default: [] }) + services!: string[]; + + @Prop({ default: '', trim: true, maxlength: 40 }) + phone!: string; + + @Prop({ default: '', trim: true, maxlength: 40 }) + whatsapp!: string; + + @Prop({ type: [String], default: [] }) + imageUrls!: string[]; + + @Prop({ default: '', trim: true, maxlength: 160 }) + location!: string; + + @Prop({ type: Number, min: -90, max: 90, default: null }) + latitude!: number | null; + + @Prop({ type: Number, min: -180, max: 180, default: null }) + longitude!: number | null; + + @Prop({ default: true, index: true }) + isActive!: boolean; +} + +export const RepairShopSchema = SchemaFactory.createForClass(RepairShop); +RepairShopSchema.index({ ownerAdminId: 1, createdAt: -1 }); +RepairShopSchema.index({ isActive: 1, createdAt: -1 }); +RepairShopSchema.index({ name: 1, isActive: 1, createdAt: -1 }); + +const transformManagedRepairShopFiles = (_doc: unknown, ret: any) => { + ret.imageUrls = resolveManagedFileUrls(ret.imageUrls); + return ret; +}; + +RepairShopSchema.set('toJSON', { transform: transformManagedRepairShopFiles }); +RepairShopSchema.set('toObject', { transform: transformManagedRepairShopFiles }); diff --git a/src/modules/media/dto/text-to-music.dto.ts b/src/modules/media/dto/text-to-music.dto.ts new file mode 100644 index 0000000..5d65051 --- /dev/null +++ b/src/modules/media/dto/text-to-music.dto.ts @@ -0,0 +1,23 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsNotEmpty, IsOptional, IsString, MaxLength, Max, Min } from 'class-validator'; + +export class TextToMusicDto { + @IsString() + @IsNotEmpty() + @MaxLength(500) + prompt!: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(5) + @Max(30) + durationSeconds?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(2147483647) + seed?: number; +} diff --git a/src/modules/media/media.controller.ts b/src/modules/media/media.controller.ts index 066a490..28f3907 100644 --- a/src/modules/media/media.controller.ts +++ b/src/modules/media/media.controller.ts @@ -1,6 +1,22 @@ -import { Controller } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '../../common/decorators/throttle.decorator'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { JwtPayload } from '../../common/interfaces/jwt-payload.interface'; +import { TextToMusicDto } from './dto/text-to-music.dto'; +import { MediaService } from './media.service'; @ApiTags('Media') @Controller('media') -export class MediaController {} +export class MediaController { + constructor(private readonly mediaService: MediaService) {} + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Post('ai/text-to-music') + @Throttle(6, 60_000) + async generateMusicFromText(@CurrentUser() user: JwtPayload, @Body() dto: TextToMusicDto) { + return this.mediaService.generateMusicFromText(user.sub, dto); + } +} diff --git a/src/modules/media/media.service.ts b/src/modules/media/media.service.ts index cb815d3..c9902c2 100644 --- a/src/modules/media/media.service.ts +++ b/src/modules/media/media.service.ts @@ -1,4 +1,133 @@ -import { Injectable } from '@nestjs/common'; +import { + BadGatewayException, + Injectable, + ServiceUnavailableException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { GoogleAuth } from 'google-auth-library'; +import { generateWaveformPeaksFromBuffer } from '../../common/utils/waveform.util'; +import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service'; +import { TextToMusicDto } from './dto/text-to-music.dto'; @Injectable() -export class MediaService {} +export class MediaService { + constructor( + private readonly configService: ConfigService, + private readonly storageService: ManagedStorageService, + ) {} + + async generateMusicFromText(userId: string, dto: TextToMusicDto) { + const enabled = this.configService.get('aiMusic.enabled', { infer: true }); + if (!enabled) { + throw new ServiceUnavailableException('AI music generation is disabled'); + } + + const apiKey = this.configService.get('aiMusic.apiKey', { infer: true }) ?? ''; + const projectId = this.configService.get('aiMusic.projectId', { infer: true }) ?? ''; + const location = this.configService.get('aiMusic.location', { infer: true }) ?? ''; + const model = this.configService.get('aiMusic.model', { infer: true }) ?? 'lyria-002'; + if (!projectId || !location) { + throw new ServiceUnavailableException('AI music settings are not configured'); + } + + let url = `https://${location}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${location}/publishers/google/models/${model}:predict`; + let authorizationHeader: string | null = null; + + if (apiKey) { + url = `${url}?key=${encodeURIComponent(apiKey)}`; + } else { + const auth = new GoogleAuth({ + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }); + + const client = await auth.getClient(); + const accessTokenRaw = await client.getAccessToken(); + const accessToken = + typeof accessTokenRaw === 'string' ? accessTokenRaw : accessTokenRaw?.token ?? ''; + + if (!accessToken) { + throw new ServiceUnavailableException('Failed to authenticate with Google Cloud'); + } + authorizationHeader = `Bearer ${accessToken}`; + } + + const requestBody: Record = { + instances: [{ prompt: dto.prompt }], + parameters: { + sampleCount: 1, + durationSeconds: dto.durationSeconds ?? 12, + }, + }; + + if (typeof dto.seed === 'number') { + (requestBody.parameters as Record).seed = dto.seed; + } + + const response = await fetch(url, { + method: 'POST', + headers: { + ...(authorizationHeader ? { Authorization: authorizationHeader } : {}), + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + const result = (await response.json()) as { + error?: { message?: string }; + predictions?: Array<{ + bytesBase64Encoded?: string; + mimeType?: string; + audio?: { bytesBase64Encoded?: string; mimeType?: string }; + }>; + }; + + if (!response.ok) { + throw new BadGatewayException( + result?.error?.message ?? 'Google AI returned an unexpected error', + ); + } + + const first = result?.predictions?.[0]; + const audioBase64 = first?.bytesBase64Encoded ?? first?.audio?.bytesBase64Encoded ?? ''; + const mimeType = first?.mimeType ?? first?.audio?.mimeType ?? 'audio/wav'; + + if (!audioBase64) { + throw new BadGatewayException('Google AI did not return audio content'); + } + + const extension = this.resolveAudioExtension(mimeType); + const buffer = Buffer.from(audioBase64, 'base64'); + const audioUrl = await this.storageService.saveFile({ + folderSegments: ['ai-music'], + extension: `.${extension}`, + buffer, + contentType: mimeType, + fileNamePrefix: `ai-${userId}`, + }); + + return { + prompt: dto.prompt, + durationSeconds: dto.durationSeconds ?? 12, + mimeType, + sizeBytes: buffer.length, + audioUrl, + waveformPeaks: generateWaveformPeaksFromBuffer(buffer), + }; + } + + private resolveAudioExtension(mimeType: string): string { + if (mimeType.includes('mpeg') || mimeType.includes('mp3')) { + return 'mp3'; + } + if (mimeType.includes('ogg')) { + return 'ogg'; + } + if (mimeType.includes('aac')) { + return 'aac'; + } + if (mimeType.includes('wav')) { + return 'wav'; + } + return 'wav'; + } +} diff --git a/src/modules/notifications/dto/create-notification.dto.ts b/src/modules/notifications/dto/create-notification.dto.ts index e5b4072..1366348 100644 --- a/src/modules/notifications/dto/create-notification.dto.ts +++ b/src/modules/notifications/dto/create-notification.dto.ts @@ -1,4 +1,5 @@ -import { IsEnum, IsMongoId, IsOptional } from 'class-validator'; +import { IsEnum, IsMongoId, IsObject, IsOptional, IsString, MaxLength } from 'class-validator'; +import { NOTIFICATION_TYPES, NotificationType } from '../schemas/notification.schema'; export class CreateNotificationDto { @IsMongoId() @@ -7,10 +8,34 @@ export class CreateNotificationDto { @IsMongoId() actorId!: string; - @IsEnum(['like', 'comment', 'follow', 'message']) - type!: 'like' | 'comment' | 'follow' | 'message'; + @IsEnum(NOTIFICATION_TYPES) + type!: NotificationType; @IsOptional() @IsMongoId() referenceId?: string; + + @IsOptional() + @IsString() + @MaxLength(120) + title?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + previewText?: string; + + @IsOptional() + @IsString() + @MaxLength(80) + resourceType?: string; + + @IsOptional() + @IsString() + @MaxLength(240) + deepLink?: string; + + @IsOptional() + @IsObject() + metadata?: Record; } diff --git a/src/modules/notifications/dto/notification-query.dto.ts b/src/modules/notifications/dto/notification-query.dto.ts index c66070a..08eb75c 100644 --- a/src/modules/notifications/dto/notification-query.dto.ts +++ b/src/modules/notifications/dto/notification-query.dto.ts @@ -1,14 +1,24 @@ import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; +import { ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsBoolean, IsOptional } from 'class-validator'; +import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator'; +import { toBoolean } from '../../../common/utils/query-transform.util'; +import { NOTIFICATION_TYPES, NotificationType } from '../schemas/notification.schema'; export class NotificationQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ default: false }) @IsOptional() - @Transform(({ value }) => { - if (value === 'true' || value === true) return true; - if (value === 'false' || value === false) return false; - return value; - }) + @Transform(toBoolean) @IsBoolean() read?: boolean; + + @ApiPropertyOptional({ enum: NOTIFICATION_TYPES }) + @IsOptional() + @IsEnum(NOTIFICATION_TYPES) + type?: NotificationType; + + @ApiPropertyOptional({ example: 'comment' }) + @IsOptional() + @IsString() + resourceType?: string; } diff --git a/src/modules/notifications/notifications.controller.ts b/src/modules/notifications/notifications.controller.ts index 274bb3d..3b098eb 100644 --- a/src/modules/notifications/notifications.controller.ts +++ b/src/modules/notifications/notifications.controller.ts @@ -1,33 +1,48 @@ import { Controller, Get, Param, Patch, Query, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 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 { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard'; +import { SuperAdminPermissionsGuard } from '../../common/guards/superadmin-permissions.guard'; import { JwtPayload } from '../../common/interfaces/jwt-payload.interface'; +import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions'; import { NotificationQueryDto } from './dto/notification-query.dto'; import { NotificationsService } from './notifications.service'; @ApiTags('Notifications') @ApiBearerAuth() -@UseGuards(JwtAuthGuard) @Controller('notifications') export class NotificationsController { constructor(private readonly notificationsService: NotificationsService) {} + @ApiBearerAuth() + @UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard) + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.NOTIFICATIONS_READ) + @Get('superadmin') + async getForSuperAdmin(@Query() query: NotificationQueryDto) { + return this.notificationsService.getForSuperAdmin(query); + } + + @UseGuards(JwtAuthGuard) @Get() async getMine(@CurrentUser() user: JwtPayload, @Query() query: NotificationQueryDto) { return this.notificationsService.getMine(user.sub, query); } + @UseGuards(JwtAuthGuard) @Get('unread-count') async getUnreadCount(@CurrentUser() user: JwtPayload) { return this.notificationsService.getUnreadCount(user.sub); } + @UseGuards(JwtAuthGuard) @Patch('read-all') async markAllRead(@CurrentUser() user: JwtPayload) { return this.notificationsService.markAllRead(user.sub); } + @UseGuards(JwtAuthGuard) @Patch(':id/read') async markRead(@CurrentUser() user: JwtPayload, @Param('id') notificationId: string) { return this.notificationsService.markRead(user.sub, notificationId); diff --git a/src/modules/notifications/notifications.repository.ts b/src/modules/notifications/notifications.repository.ts index e72a104..d5ae3ff 100644 --- a/src/modules/notifications/notifications.repository.ts +++ b/src/modules/notifications/notifications.repository.ts @@ -31,6 +31,7 @@ export class NotificationsRepository { filter: FilterQuery, skip: number, limit: number, + sort: Record = { createdAt: -1 }, ): Promise { return this.notificationModel .find({ @@ -38,7 +39,22 @@ export class NotificationsRepository { ...filter, }) .populate({ path: 'actorId', select: 'name username stageName avatar isVerified isDisabled' }) - .sort({ createdAt: -1 }) + .sort(sort) + .skip(skip) + .limit(limit) + .exec(); + } + + async findMany( + filter: FilterQuery, + skip: number, + limit: number, + sort: Record = { createdAt: -1 }, + ): Promise { + return this.notificationModel + .find(filter) + .populate({ path: 'actorId', select: 'name username stageName avatar isVerified isDisabled' }) + .sort(sort) .skip(skip) .limit(limit) .exec(); @@ -62,6 +78,19 @@ export class NotificationsRepository { .exec(); } + async count(filter: FilterQuery): Promise { + return this.notificationModel.countDocuments(filter).exec(); + } + + async countUnreadAll(filter: FilterQuery = {}): Promise { + return this.notificationModel + .countDocuments({ + ...filter, + read: false, + }) + .exec(); + } + async markRead(recipientId: string, notificationId: string): Promise { const updated = await this.notificationModel .findOneAndUpdate( diff --git a/src/modules/notifications/notifications.service.spec.ts b/src/modules/notifications/notifications.service.spec.ts index 72951a3..8bf4268 100644 --- a/src/modules/notifications/notifications.service.spec.ts +++ b/src/modules/notifications/notifications.service.spec.ts @@ -2,6 +2,35 @@ import { NotFoundException } from '@nestjs/common'; import { NotificationsService } from './notifications.service'; describe('NotificationsService', () => { + it('creates mention notifications with mention type', async () => { + const notificationsRepository = { + create: jest.fn().mockResolvedValue({ toJSON: () => ({ _id: 'notification-1' }) }), + countUnread: jest.fn().mockResolvedValue(5), + }; + const notificationsGateway = { + emitCreated: jest.fn(), + }; + + const service = new NotificationsService( + notificationsRepository as any, + notificationsGateway as any, + ); + + await service.createMentionNotification( + '507f1f77bcf86cd799439011', + '507f191e810c19729de860ea', + '507f1f77bcf86cd799439012', + { previewText: 'Hello @rami' }, + ); + + expect(notificationsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'mention', + previewText: 'Hello @rami', + }), + ); + }); + it('recalculates unread count after markAllRead', async () => { const notificationsRepository = { markAllRead: jest.fn().mockResolvedValue(4), diff --git a/src/modules/notifications/notifications.service.ts b/src/modules/notifications/notifications.service.ts index ea482b9..461444d 100644 --- a/src/modules/notifications/notifications.service.ts +++ b/src/modules/notifications/notifications.service.ts @@ -1,9 +1,12 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { Types } from 'mongoose'; +import { buildPaginatedResponse } from '../../common/utils/pagination.util'; +import { resolveMongoSortDirection } from '../../common/utils/sort.util'; import { CreateNotificationDto } from './dto/create-notification.dto'; import { NotificationQueryDto } from './dto/notification-query.dto'; import { NotificationsGateway } from './notifications.gateway'; import { NotificationsRepository } from './notifications.repository'; +import { NotificationType } from './schemas/notification.schema'; @Injectable() export class NotificationsService { @@ -17,11 +20,20 @@ export class NotificationsService { return null; } + const resourceType = (dto.resourceType ?? this.resolveResourceType(dto.type)).trim(); + const deepLink = (dto.deepLink ?? this.buildDeepLink(dto.type, dto.referenceId, resourceType)).trim(); + const title = (dto.title ?? this.buildTitle(dto.type)).trim(); + const previewText = (dto.previewText ?? '').trim(); const notification = await this.notificationsRepository.create({ recipientId: new Types.ObjectId(dto.recipientId), actorId: new Types.ObjectId(dto.actorId), type: dto.type, referenceId: dto.referenceId ? new Types.ObjectId(dto.referenceId) : undefined, + title, + previewText, + resourceType, + deepLink, + metadata: dto.metadata ?? {}, read: false, readAt: null, }); @@ -37,7 +49,107 @@ export class NotificationsService { actorId, recipientId, type: 'follow', + referenceId: referenceId || actorId, + resourceType: 'user', + deepLink: `/users/${actorId}`, + }); + } + + async createLikeNotification( + actorId: string, + recipientId: string, + referenceId: string, + options?: { resourceType?: string; previewText?: string }, + ) { + return this.create({ + actorId, + recipientId, + type: 'like', referenceId, + resourceType: options?.resourceType ?? 'post', + previewText: options?.previewText ?? '', + }); + } + + async createCommentNotification( + actorId: string, + recipientId: string, + referenceId: string, + options?: { resourceType?: string; previewText?: string }, + ) { + return this.create({ + actorId, + recipientId, + type: 'comment', + referenceId, + resourceType: options?.resourceType ?? 'post', + previewText: options?.previewText ?? '', + }); + } + + async createSaveNotification( + actorId: string, + recipientId: string, + referenceId: string, + options?: { resourceType?: string; previewText?: string }, + ) { + return this.create({ + actorId, + recipientId, + type: 'save', + referenceId, + resourceType: options?.resourceType ?? 'post', + previewText: options?.previewText ?? '', + }); + } + + async createShareNotification( + actorId: string, + recipientId: string, + referenceId: string, + options?: { resourceType?: string; previewText?: string }, + ) { + return this.create({ + actorId, + recipientId, + type: 'share', + referenceId, + resourceType: options?.resourceType ?? 'post', + previewText: options?.previewText ?? '', + }); + } + + async createMentionNotification( + actorId: string, + recipientId: string, + referenceId: string, + options?: { resourceType?: string; previewText?: string; deepLink?: string }, + ) { + return this.create({ + actorId, + recipientId, + type: 'mention', + referenceId, + resourceType: options?.resourceType ?? 'post', + previewText: options?.previewText ?? '', + deepLink: options?.deepLink, + }); + } + + async createMessageNotification( + actorId: string, + recipientId: string, + conversationId: string, + previewText = '', + ) { + return this.create({ + actorId, + recipientId, + type: 'message', + referenceId: conversationId, + resourceType: 'conversation', + deepLink: `/chat/conversations/${conversationId}`, + previewText, }); } @@ -50,20 +162,64 @@ export class NotificationsService { if (typeof query.read === 'boolean') { filter.read = query.read; } + if (query.type) { + filter.type = query.type; + } + if (query.resourceType) { + filter.resourceType = query.resourceType.trim(); + } + + const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record; const [items, total, unreadCount] = await Promise.all([ - this.notificationsRepository.findMine(recipientId, filter, skip, limit), + this.notificationsRepository.findMine(recipientId, filter, skip, limit, sort), this.notificationsRepository.countMine(recipientId, filter), this.notificationsRepository.countUnread(recipientId), ]); return { - items, + ...buildPaginatedResponse(items, { + page, + limit, + total, + offset: skip, + }), + unreadCount, + }; + } + + async getForSuperAdmin(query: NotificationQueryDto) { + const page = query.page ?? 1; + const limit = query.limit ?? 20; + const skip = (page - 1) * limit; + const filter: Record = {}; + + if (typeof query.read === 'boolean') { + filter.read = query.read; + } + if (query.type) { + filter.type = query.type; + } + if (query.resourceType) { + filter.resourceType = query.resourceType.trim(); + } + + const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record; + + const [items, total, unreadCount] = await Promise.all([ + this.notificationsRepository.findMany(filter, skip, limit, sort), + this.notificationsRepository.count(filter), + this.notificationsRepository.countUnreadAll(filter), + ]); + + return { + ...buildPaginatedResponse(items, { + page, + limit, + total, + offset: skip, + }), unreadCount, - page, - limit, - total, - totalPages: Math.ceil(total / limit) || 1, }; } @@ -104,4 +260,60 @@ export class NotificationsService { unreadCount, }; } + + private buildTitle(type: NotificationType): string { + switch (type) { + case 'like': + return 'New like'; + case 'comment': + return 'New comment'; + case 'follow': + return 'New follower'; + case 'message': + return 'New message'; + case 'save': + return 'Post saved'; + case 'share': + return 'Post shared'; + case 'mention': + return 'New mention'; + default: + return 'Notification'; + } + } + + private resolveResourceType(type: NotificationType): string { + switch (type) { + case 'follow': + return 'user'; + case 'message': + return 'conversation'; + default: + return 'post'; + } + } + + private buildDeepLink( + type: NotificationType, + referenceId?: string, + resourceType?: string, + ): string { + if (type === 'message' && referenceId) { + return `/chat/conversations/${referenceId}`; + } + + if (resourceType === 'user' && referenceId) { + return `/users/${referenceId}`; + } + + if (resourceType === 'comment' && referenceId) { + return `/comments/${referenceId}`; + } + + if (referenceId) { + return `/posts/${referenceId}`; + } + + return ''; + } } diff --git a/src/modules/notifications/schemas/notification.schema.ts b/src/modules/notifications/schemas/notification.schema.ts index b0d49e0..fa6b985 100644 --- a/src/modules/notifications/schemas/notification.schema.ts +++ b/src/modules/notifications/schemas/notification.schema.ts @@ -4,6 +4,9 @@ import { User } from '../../users/schemas/user.schema'; export type NotificationDocument = HydratedDocument; +export const NOTIFICATION_TYPES = ['like', 'comment', 'follow', 'message', 'save', 'share', 'mention'] as const; +export type NotificationType = (typeof NOTIFICATION_TYPES)[number]; + @Schema({ timestamps: true, versionKey: false }) export class Notification { @Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true }) @@ -12,12 +15,27 @@ export class Notification { @Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true }) actorId!: Types.ObjectId; - @Prop({ required: true, enum: ['like', 'comment', 'follow', 'message'] }) - type!: 'like' | 'comment' | 'follow' | 'message'; + @Prop({ required: true, enum: NOTIFICATION_TYPES }) + type!: NotificationType; @Prop({ type: Types.ObjectId }) referenceId?: Types.ObjectId; + @Prop({ default: '', trim: true, maxlength: 120 }) + title!: string; + + @Prop({ default: '', trim: true, maxlength: 500 }) + previewText!: string; + + @Prop({ default: '', trim: true, maxlength: 80, index: true }) + resourceType!: string; + + @Prop({ default: '', trim: true, maxlength: 240 }) + deepLink!: string; + + @Prop({ type: Object, default: {} }) + metadata!: Record; + @Prop({ default: false }) read!: boolean; @@ -29,3 +47,4 @@ export const NotificationSchema = SchemaFactory.createForClass(Notification); NotificationSchema.index({ recipientId: 1, createdAt: -1 }); NotificationSchema.index({ recipientId: 1, read: 1, createdAt: -1 }); NotificationSchema.index({ recipientId: 1, read: 1, type: 1, createdAt: -1 }); +NotificationSchema.index({ recipientId: 1, resourceType: 1, createdAt: -1 }); diff --git a/src/modules/outbox/outbox.service.ts b/src/modules/outbox/outbox.service.ts index 17537ba..0957b73 100644 --- a/src/modules/outbox/outbox.service.ts +++ b/src/modules/outbox/outbox.service.ts @@ -1,18 +1,28 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, OnModuleInit } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; +import { AppLoggerService } from '../../infrastructure/logging/app-logger.service'; +import { AppQueueService } from '../../infrastructure/queue/app-queue.service'; import { NotificationsService } from '../notifications/notifications.service'; import { OutboxEvent, OutboxEventDocument } from './schemas/outbox-event.schema'; @Injectable() -export class OutboxService { - private readonly logger = new Logger(OutboxService.name); +export class OutboxService implements OnModuleInit { + private static readonly PROCESS_EVENT_JOB = 'process_outbox_event'; constructor( @InjectModel(OutboxEvent.name) private readonly outboxEventModel: Model, private readonly notificationsService: NotificationsService, + private readonly queueService: AppQueueService, + private readonly logger: AppLoggerService, ) {} + onModuleInit(): void { + this.queueService.registerProcessor(OutboxService.PROCESS_EVENT_JOB, async (payload) => { + await this.processEvent(String(payload.eventId ?? '')); + }); + } + async enqueueFollowNotification(actorId: string, recipientId: string, referenceId?: string): Promise { const event = await this.outboxEventModel.create({ eventType: 'follow_notification', @@ -24,7 +34,7 @@ export class OutboxService { status: 'pending', }); - await this.processEvent(event.id); + await this.queueService.enqueue(OutboxService.PROCESS_EVENT_JOB, { eventId: event.id }); } async processEvent(eventId: string): Promise { @@ -48,7 +58,14 @@ export class OutboxService { } catch (error) { event.status = 'failed'; event.lastError = error instanceof Error ? error.message : 'unknown outbox error'; - this.logger.warn(`Outbox event ${event.id} failed: ${event.lastError}`); + this.logger.warn( + { + eventId: event.id, + eventType: event.eventType, + error: event.lastError, + }, + OutboxService.name, + ); } finally { event.attempts += 1; await event.save(); diff --git a/src/modules/posts/dto/admin-post-query.dto.ts b/src/modules/posts/dto/admin-post-query.dto.ts new file mode 100644 index 0000000..878fa26 --- /dev/null +++ b/src/modules/posts/dto/admin-post-query.dto.ts @@ -0,0 +1,16 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsMongoId, IsOptional } from 'class-validator'; +import { ModerationStatus } from '../../../common/enums/moderation-status.enum'; +import { PostQueryDto } from './post-query.dto'; + +export class AdminPostQueryDto extends PostQueryDto { + @ApiPropertyOptional({ description: 'Optional author filter' }) + @IsOptional() + @IsMongoId() + authorId?: string; + + @ApiPropertyOptional({ enum: ModerationStatus, description: 'Optional moderation status filter' }) + @IsOptional() + @IsEnum(ModerationStatus) + moderationStatus?: ModerationStatus; +} diff --git a/src/modules/posts/dto/create-post.dto.ts b/src/modules/posts/dto/create-post.dto.ts index 2a63780..b71f762 100644 --- a/src/modules/posts/dto/create-post.dto.ts +++ b/src/modules/posts/dto/create-post.dto.ts @@ -1,12 +1,27 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsOptional, IsString, IsUrl, Length } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { + ArrayMaxSize, + IsArray, + IsEnum, + IsMongoId, + IsNumber, + IsOptional, + IsString, + IsUrl, + Length, + Max, + Min, +} from 'class-validator'; import { PostVisibility } from '../../../common/enums/post-visibility.enum'; +import { toNumberArray, toStringArray } from '../../../common/utils/array-transform.util'; export class CreatePostDto { - @ApiProperty({ maxLength: 2200, description: 'Post description/content' }) + @ApiPropertyOptional({ maxLength: 2200, description: 'Post caption/content (optional with media)' }) + @IsOptional() @IsString() - @Length(1, 2200) - content!: string; + @Length(0, 2200) + content?: string; @ApiPropertyOptional({ description: 'Single video URL (optional)' }) @IsOptional() @@ -18,6 +33,92 @@ export class CreatePostDto { @IsUrl({ require_tld: false }) audioUrl?: string; + @ApiPropertyOptional({ description: 'Media duration in seconds for audio/video posts' }) + @IsOptional() + @IsNumber() + @Min(1) + @Max(7200) + durationSeconds?: number; + + @ApiPropertyOptional({ description: 'Cover/thumbnail URL for audio/video posts' }) + @IsOptional() + @IsUrl({ require_tld: false }) + thumbnailUrl?: string; + + @ApiPropertyOptional({ description: 'Music style or genre for audio/video posts', maxLength: 80 }) + @IsOptional() + @IsString() + @Length(0, 80) + style?: string; + + @ApiPropertyOptional({ description: 'Maqam name for audio/video posts', maxLength: 80 }) + @IsOptional() + @IsString() + @Length(0, 80) + maqam?: string; + + @ApiPropertyOptional({ description: 'Rhythm signature like 6/8 for audio/video posts', maxLength: 40 }) + @IsOptional() + @IsString() + @Length(0, 40) + rhythmSignature?: string; + + @ApiPropertyOptional({ + type: [Number], + description: 'Optional waveform samples for audio posts only', + }) + @IsOptional() + @Transform(toNumberArray) + @IsArray() + @ArrayMaxSize(512) + @IsNumber({}, { each: true }) + waveformPeaks?: number[]; + + @ApiPropertyOptional({ type: [String], description: 'Multiple image URLs for image carousel (max 10)' }) + @IsOptional() + @Transform(toStringArray) + @IsArray() + @ArrayMaxSize(10) + @IsUrl({ require_tld: false }, { each: true }) + imageUrls?: string[]; + + @ApiPropertyOptional({ type: [String], description: 'Tagged user ids (max 20)' }) + @IsOptional() + @Transform(toStringArray) + @IsArray() + @ArrayMaxSize(20) + @IsMongoId({ each: true }) + taggedUserIds?: 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[]; + + @ApiPropertyOptional({ description: 'Post location text' }) + @IsOptional() + @IsString() + @Length(0, 120) + location?: string; + + @ApiPropertyOptional({ description: 'Post latitude' }) + @IsOptional() + @IsNumber() + @Min(-90) + @Max(90) + latitude?: number; + + @ApiPropertyOptional({ description: 'Post longitude' }) + @IsOptional() + @IsNumber() + @Min(-180) + @Max(180) + longitude?: number; + @ApiPropertyOptional({ enum: PostVisibility, default: PostVisibility.PUBLIC }) @IsOptional() @IsEnum(PostVisibility) diff --git a/src/modules/posts/dto/create-reel.dto.ts b/src/modules/posts/dto/create-reel.dto.ts new file mode 100644 index 0000000..f570bd6 --- /dev/null +++ b/src/modules/posts/dto/create-reel.dto.ts @@ -0,0 +1,73 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + ArrayMaxSize, + IsArray, + IsEnum, + IsNumber, + IsOptional, + IsString, + IsUrl, + Length, + Max, + Min, +} from 'class-validator'; +import { PostVisibility } from '../../../common/enums/post-visibility.enum'; +import { toStringArray } from '../../../common/utils/array-transform.util'; + +export class CreateReelDto { + @ApiPropertyOptional({ maxLength: 2200, description: 'Reel caption' }) + @IsOptional() + @IsString() + @Length(0, 2200) + content?: string; + + @ApiPropertyOptional({ description: 'Reel video URL (if not uploading videoFile)' }) + @IsOptional() + @IsUrl({ require_tld: false }) + videoUrl?: string; + + @ApiPropertyOptional({ description: 'Video duration in seconds' }) + @IsOptional() + @IsNumber() + @Min(1) + @Max(7200) + durationSeconds?: number; + + @ApiPropertyOptional({ description: 'Video thumbnail URL' }) + @IsOptional() + @IsUrl({ require_tld: false }) + thumbnailUrl?: string; + + @ApiPropertyOptional({ description: 'Music style or genre for the reel', maxLength: 80 }) + @IsOptional() + @IsString() + @Length(0, 80) + style?: string; + + @ApiPropertyOptional({ description: 'Maqam name for the reel', maxLength: 80 }) + @IsOptional() + @IsString() + @Length(0, 80) + maqam?: string; + + @ApiPropertyOptional({ description: 'Rhythm signature like 6/8 for the reel', maxLength: 40 }) + @IsOptional() + @IsString() + @Length(0, 40) + rhythmSignature?: 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[]; + + @ApiPropertyOptional({ enum: PostVisibility, default: PostVisibility.PUBLIC }) + @IsOptional() + @IsEnum(PostVisibility) + visibility?: PostVisibility; +} diff --git a/src/modules/posts/dto/post-query.dto.ts b/src/modules/posts/dto/post-query.dto.ts index 9d4f3b6..0dfbec5 100644 --- a/src/modules/posts/dto/post-query.dto.ts +++ b/src/modules/posts/dto/post-query.dto.ts @@ -1,11 +1,45 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsOptional } from 'class-validator'; +import { IsEnum, IsOptional, IsString } from 'class-validator'; import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; +import { PostType } from '../../../common/enums/post-type.enum'; import { PostVisibility } from '../../../common/enums/post-visibility.enum'; +export const POST_SORT_FIELDS = [ + 'createdAt', + 'updatedAt', + 'likesCount', + 'commentsCount', + 'savesCount', + 'shareCount', + 'viewCount', + 'playCount', +] as const; + +export type PostSortField = (typeof POST_SORT_FIELDS)[number]; + export class PostQueryDto extends PaginationQueryDto { @ApiPropertyOptional({ enum: PostVisibility }) @IsOptional() @IsEnum(PostVisibility) visibility?: PostVisibility; + + @ApiPropertyOptional({ enum: PostType }) + @IsOptional() + @IsEnum(PostType) + postType?: PostType; + + @ApiPropertyOptional({ description: 'Search inside post content' }) + @IsOptional() + @IsString() + q?: string; + + @ApiPropertyOptional({ description: 'Filter by hashtag without the # prefix', example: 'music' }) + @IsOptional() + @IsString() + hashtag?: string; + + @ApiPropertyOptional({ enum: POST_SORT_FIELDS, default: 'createdAt' }) + @IsOptional() + @IsEnum(POST_SORT_FIELDS) + sortBy?: PostSortField; } diff --git a/src/modules/posts/dto/reel-query.dto.ts b/src/modules/posts/dto/reel-query.dto.ts new file mode 100644 index 0000000..b352b58 --- /dev/null +++ b/src/modules/posts/dto/reel-query.dto.ts @@ -0,0 +1,27 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsMongoId, IsOptional, IsString } from 'class-validator'; +import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; +import { PostVisibility } from '../../../common/enums/post-visibility.enum'; +import { POST_SORT_FIELDS, PostSortField } from './post-query.dto'; + +export class ReelQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ enum: PostVisibility }) + @IsOptional() + @IsEnum(PostVisibility) + visibility?: PostVisibility; + + @ApiPropertyOptional({ description: 'Optional author filter' }) + @IsOptional() + @IsMongoId() + authorId?: string; + + @ApiPropertyOptional({ description: 'Search inside reel caption/content' }) + @IsOptional() + @IsString() + q?: string; + + @ApiPropertyOptional({ enum: POST_SORT_FIELDS, default: 'createdAt' }) + @IsOptional() + @IsEnum(POST_SORT_FIELDS) + sortBy?: PostSortField; +} diff --git a/src/modules/posts/dto/update-post.dto.ts b/src/modules/posts/dto/update-post.dto.ts index a1c0958..efe958f 100644 --- a/src/modules/posts/dto/update-post.dto.ts +++ b/src/modules/posts/dto/update-post.dto.ts @@ -1,6 +1,20 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsOptional, IsString, IsUrl, Length } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { + ArrayMaxSize, + IsArray, + IsEnum, + IsMongoId, + IsNumber, + IsOptional, + IsString, + IsUrl, + Length, + Max, + Min, +} from 'class-validator'; import { PostVisibility } from '../../../common/enums/post-visibility.enum'; +import { toNumberArray, toStringArray } from '../../../common/utils/array-transform.util'; export class UpdatePostDto { @ApiPropertyOptional({ maxLength: 2200 }) @@ -19,6 +33,92 @@ export class UpdatePostDto { @IsUrl({ require_tld: false }) audioUrl?: string; + @ApiPropertyOptional({ description: 'Media duration in seconds for audio/video posts' }) + @IsOptional() + @IsNumber() + @Min(1) + @Max(7200) + durationSeconds?: number; + + @ApiPropertyOptional({ description: 'Cover/thumbnail URL for audio/video posts' }) + @IsOptional() + @IsUrl({ require_tld: false }) + thumbnailUrl?: string; + + @ApiPropertyOptional({ description: 'Music style or genre for audio/video posts', maxLength: 80 }) + @IsOptional() + @IsString() + @Length(0, 80) + style?: string; + + @ApiPropertyOptional({ description: 'Maqam name for audio/video posts', maxLength: 80 }) + @IsOptional() + @IsString() + @Length(0, 80) + maqam?: string; + + @ApiPropertyOptional({ description: 'Rhythm signature like 6/8 for audio/video posts', maxLength: 40 }) + @IsOptional() + @IsString() + @Length(0, 40) + rhythmSignature?: string; + + @ApiPropertyOptional({ + type: [Number], + description: 'Optional waveform samples for audio posts only', + }) + @IsOptional() + @Transform(toNumberArray) + @IsArray() + @ArrayMaxSize(512) + @IsNumber({}, { each: true }) + waveformPeaks?: number[]; + + @ApiPropertyOptional({ type: [String], description: 'Set image carousel URLs (max 10)' }) + @IsOptional() + @Transform(toStringArray) + @IsArray() + @ArrayMaxSize(10) + @IsUrl({ require_tld: false }, { each: true }) + imageUrls?: string[]; + + @ApiPropertyOptional({ type: [String], description: 'Set tagged user ids (max 20)' }) + @IsOptional() + @Transform(toStringArray) + @IsArray() + @ArrayMaxSize(20) + @IsMongoId({ each: true }) + taggedUserIds?: string[]; + + @ApiPropertyOptional({ type: [String], description: 'Set mention usernames like rami_sabry (max 30)' }) + @IsOptional() + @Transform(toStringArray) + @IsArray() + @ArrayMaxSize(30) + @IsString({ each: true }) + @Length(1, 30, { each: true }) + mentionUsernames?: string[]; + + @ApiPropertyOptional({ description: 'Post location text' }) + @IsOptional() + @IsString() + @Length(0, 120) + location?: string; + + @ApiPropertyOptional({ description: 'Post latitude' }) + @IsOptional() + @IsNumber() + @Min(-90) + @Max(90) + latitude?: number; + + @ApiPropertyOptional({ description: 'Post longitude' }) + @IsOptional() + @IsNumber() + @Min(-180) + @Max(180) + longitude?: number; + @ApiPropertyOptional({ enum: PostVisibility }) @IsOptional() @IsEnum(PostVisibility) diff --git a/src/modules/posts/posts.controller.ts b/src/modules/posts/posts.controller.ts index cf93164..a515e5c 100644 --- a/src/modules/posts/posts.controller.ts +++ b/src/modules/posts/posts.controller.ts @@ -1,12 +1,34 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + 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 { 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 { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator'; +import { AdminPostQueryDto } from './dto/admin-post-query.dto'; +import { CreateReelDto } from './dto/create-reel.dto'; import { CreatePostDto } from './dto/create-post.dto'; import { PostQueryDto } from './dto/post-query.dto'; +import { ReelQueryDto } from './dto/reel-query.dto'; import { UpdatePostDto } from './dto/update-post.dto'; import { PostsService } from './posts.service'; +import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions'; @ApiTags('Posts') @Controller('posts') @@ -15,9 +37,58 @@ export class PostsController { @ApiBearerAuth() @UseGuards(JwtAuthGuard) + @UseInterceptors( + FileFieldsInterceptor([ + { name: 'imageFiles', maxCount: 10 }, + { name: 'videoFile', maxCount: 1 }, + { name: 'audioFile', maxCount: 1 }, + ]), + ) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + content: { type: 'string', example: 'First post #music' }, + visibility: { type: 'string', enum: ['public', 'followers', 'private'] }, + imageUrls: { type: 'array', items: { type: 'string' } }, + taggedUserIds: { type: 'array', items: { type: 'string' } }, + mentionUsernames: { type: 'array', items: { type: 'string' } }, + location: { type: 'string', example: 'Riyadh, Saudi Arabia' }, + latitude: { type: 'number', example: 24.7136 }, + longitude: { type: 'number', example: 46.6753 }, + videoUrl: { type: 'string', example: 'https://cdn.example.com/video.mp4' }, + audioUrl: { type: 'string', example: 'https://cdn.example.com/audio.mp3' }, + durationSeconds: { type: 'number', example: 54 }, + thumbnailUrl: { type: 'string', example: 'https://cdn.example.com/cover.jpg' }, + style: { type: 'string', example: 'Sharqi' }, + maqam: { type: 'string', example: 'Hijaz' }, + rhythmSignature: { type: 'string', example: '6/8' }, + waveformPeaks: { type: 'array', items: { type: 'number' } }, + imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } }, + videoFile: { type: 'string', format: 'binary' }, + audioFile: { type: 'string', format: 'binary' }, + }, + }, + }) @Post() - async create(@CurrentUser() user: JwtPayload, @Body() dto: CreatePostDto) { - return this.postsService.create(user.sub, dto); + async create( + @CurrentUser() user: JwtPayload, + @Body() dto: CreatePostDto, + @UploadedFiles() + files?: { + imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>; + videoFile?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>; + audioFile?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>; + }, + ) { + return this.postsService.create( + user.sub, + dto, + files?.imageFiles ?? [], + files?.videoFile?.[0], + files?.audioFile?.[0], + ); } @ApiBearerAuth() @@ -27,6 +98,58 @@ export class PostsController { return this.postsService.findUserPosts(userId, query); } + @ApiBearerAuth() + @UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard) + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE) + @Get('admin/moderation') + async findPlatformPosts(@Query() query: AdminPostQueryDto) { + return this.postsService.findPlatformPosts(query); + } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @UseInterceptors( + FileFieldsInterceptor([ + { name: 'videoFile', maxCount: 1 }, + ]), + ) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + content: { type: 'string', example: 'New reel from oud session #reel' }, + visibility: { type: 'string', enum: ['public', 'followers', 'private'] }, + videoUrl: { type: 'string', example: 'https://cdn.example.com/reel.mp4' }, + durationSeconds: { type: 'number', example: 42 }, + thumbnailUrl: { type: 'string', example: 'https://cdn.example.com/reel-cover.jpg' }, + style: { type: 'string', example: 'Sharqi' }, + maqam: { type: 'string', example: 'Hijaz' }, + rhythmSignature: { type: 'string', example: '6/8' }, + mentionUsernames: { type: 'array', items: { type: 'string' } }, + videoFile: { type: 'string', format: 'binary' }, + }, + }, + }) + @Post('reels') + async createReel( + @CurrentUser() user: JwtPayload, + @Body() dto: CreateReelDto, + @UploadedFiles() + files?: { + videoFile?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>; + }, + ) { + return this.postsService.createReel(user.sub, dto, files?.videoFile?.[0]); + } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Get('reels') + async findReels(@Query() query: ReelQueryDto) { + return this.postsService.findReels(query); + } + @ApiBearerAuth() @UseGuards(JwtAuthGuard) @Get(':postId') @@ -36,13 +159,60 @@ export class PostsController { @ApiBearerAuth() @UseGuards(JwtAuthGuard) + @UseInterceptors( + FileFieldsInterceptor([ + { name: 'imageFiles', maxCount: 10 }, + { name: 'videoFile', maxCount: 1 }, + { name: 'audioFile', maxCount: 1 }, + ]), + ) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + content: { type: 'string', example: 'Updated content' }, + visibility: { type: 'string', enum: ['public', 'followers', 'private'] }, + imageUrls: { type: 'array', items: { type: 'string' } }, + taggedUserIds: { type: 'array', items: { type: 'string' } }, + mentionUsernames: { type: 'array', items: { type: 'string' } }, + location: { type: 'string', example: 'Jeddah, Saudi Arabia' }, + latitude: { type: 'number', example: 21.5433 }, + longitude: { type: 'number', example: 39.1728 }, + videoUrl: { type: 'string', example: 'https://cdn.example.com/video.mp4' }, + audioUrl: { type: 'string', example: 'https://cdn.example.com/audio.mp3' }, + durationSeconds: { type: 'number', example: 54 }, + thumbnailUrl: { type: 'string', example: 'https://cdn.example.com/cover.jpg' }, + style: { type: 'string', example: 'Sharqi' }, + maqam: { type: 'string', example: 'Hijaz' }, + rhythmSignature: { type: 'string', example: '6/8' }, + waveformPeaks: { type: 'array', items: { type: 'number' } }, + imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } }, + videoFile: { type: 'string', format: 'binary' }, + audioFile: { type: 'string', format: 'binary' }, + }, + }, + }) @Patch(':postId') async update( @CurrentUser() user: JwtPayload, @Param('postId') postId: string, @Body() dto: UpdatePostDto, + @UploadedFiles() + files?: { + imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>; + videoFile?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>; + audioFile?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>; + }, ) { - return this.postsService.update(user.sub, postId, dto); + return this.postsService.update( + user.sub, + postId, + dto, + files?.imageFiles ?? [], + files?.videoFile?.[0], + files?.audioFile?.[0], + ); } @ApiBearerAuth() @@ -52,4 +222,37 @@ export class PostsController { await this.postsService.remove(user.sub, postId); return { success: true }; } + + @ApiBearerAuth() + @UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard) + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE) + @Delete('admin/:postId') + async removeBySuperAdmin(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) { + await this.postsService.removeBySuperAdmin(user.email ?? user.sub, postId); + return { success: true }; + } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post(':postId/view') + async registerView(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) { + return this.postsService.registerView(user.sub, postId); + } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post(':postId/play') + async registerPlay(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) { + return this.postsService.registerPlay(user.sub, postId); + } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post(':postId/share') + async registerShare(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) { + return this.postsService.registerShare(user.sub, postId); + } } diff --git a/src/modules/posts/posts.module.ts b/src/modules/posts/posts.module.ts index 2a537fb..a4415a0 100644 --- a/src/modules/posts/posts.module.ts +++ b/src/modules/posts/posts.module.ts @@ -1,5 +1,7 @@ import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; +import { AuditModule } from '../audit/audit.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { UsersModule } from '../users/users.module'; import { Post, PostSchema } from './schemas/post.schema'; import { PostsController } from './posts.controller'; @@ -14,6 +16,8 @@ import { PostsService } from './posts.service'; schema: PostSchema, }, ]), + AuditModule, + NotificationsModule, UsersModule, ], controllers: [PostsController], diff --git a/src/modules/posts/posts.repository.ts b/src/modules/posts/posts.repository.ts index ec5c57d..0dad40b 100644 --- a/src/modules/posts/posts.repository.ts +++ b/src/modules/posts/posts.repository.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { ClientSession, FilterQuery, Model, Types, UpdateQuery } from 'mongoose'; +import { ModerationStatus } from '../../common/enums/moderation-status.enum'; import { Post, PostDocument } from './schemas/post.schema'; @Injectable() @@ -8,6 +9,14 @@ export class PostsRepository { constructor(@InjectModel(Post.name) private readonly postModel: Model) {} private withActiveFilter>(filter: T): FilterQuery { + return { + ...filter, + isDeleted: { $ne: true }, + moderationStatus: { $ne: ModerationStatus.HIDDEN }, + }; + } + + private withAdminFilter>(filter: T): FilterQuery { return { ...filter, isDeleted: { $ne: true }, @@ -28,6 +37,8 @@ export class PostsRepository { return this.postModel .findOne({ _id: new Types.ObjectId(postId), isDeleted: { $ne: true } }) + .populate({ path: 'authorId', select: 'name username avatar isVerified stageName' }) + .populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' }) .exec(); } @@ -36,7 +47,11 @@ export class PostsRepository { return null; } - return this.postModel.findByIdAndUpdate(postId, payload, { new: true }).exec(); + return this.postModel + .findByIdAndUpdate(postId, payload, { new: true }) + .populate({ path: 'authorId', select: 'name username avatar isVerified stageName' }) + .populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' }) + .exec(); } async deleteById(postId: string, deletedBy?: string): Promise { @@ -56,11 +71,33 @@ export class PostsRepository { .exec(); } - async findMany(filter: FilterQuery, skip: number, limit: number): Promise { + async findMany( + filter: FilterQuery, + skip: number, + limit: number, + sort: Record = { createdAt: -1 }, + ): Promise { return this.postModel .find(this.withActiveFilter(filter)) .populate({ path: 'authorId', select: 'name username avatar isVerified stageName' }) - .sort({ createdAt: -1 }) + .populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' }) + .sort(sort) + .skip(skip) + .limit(limit) + .exec(); + } + + async findManyAdmin( + filter: FilterQuery, + skip: number, + limit: number, + sort: Record = { createdAt: -1 }, + ): Promise { + return this.postModel + .find(this.withAdminFilter(filter)) + .populate({ path: 'authorId', select: 'name username avatar isVerified stageName' }) + .populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' }) + .sort(sort) .skip(skip) .limit(limit) .exec(); @@ -75,6 +112,7 @@ export class PostsRepository { const rows = await this.postModel .find({ _id: { $in: ids }, isDeleted: { $ne: true } }) .populate({ path: 'authorId', select: 'name username avatar isVerified stageName' }) + .populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' }) .exec(); const order = new Map(postIds.map((id, idx) => [id, idx])); @@ -103,6 +141,24 @@ export class PostsRepository { .exec(); } + async incrementShareCount(postId: string, delta = 1, session?: ClientSession): Promise { + await this.postModel + .findByIdAndUpdate(postId, { $inc: { shareCount: delta } }, { new: false, session }) + .exec(); + } + + async incrementViewCount(postId: string, delta = 1, session?: ClientSession): Promise { + await this.postModel + .findByIdAndUpdate(postId, { $inc: { viewCount: delta } }, { new: false, session }) + .exec(); + } + + async incrementPlayCount(postId: string, delta = 1, session?: ClientSession): Promise { + await this.postModel + .findByIdAndUpdate(postId, { $inc: { playCount: delta } }, { new: false, session }) + .exec(); + } + async setCommentsCount(postId: string, nextValue: number, session?: ClientSession): Promise { await this.postModel .findByIdAndUpdate( @@ -118,4 +174,30 @@ export class PostsRepository { async count(filter: FilterQuery): Promise { return this.postModel.countDocuments(this.withActiveFilter(filter)).exec(); } + + async countAdmin(filter: FilterQuery): Promise { + return this.postModel.countDocuments(this.withAdminFilter(filter)).exec(); + } + + async updateModerationStatus( + postId: string, + payload: Pick, + ): Promise { + if (!Types.ObjectId.isValid(postId)) { + return null; + } + + return this.postModel + .findByIdAndUpdate( + postId, + { + moderationStatus: payload.moderationStatus, + moderationReason: payload.moderationReason, + }, + { new: true }, + ) + .populate({ path: 'authorId', select: 'name username avatar isVerified stageName' }) + .populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' }) + .exec(); + } } diff --git a/src/modules/posts/posts.service.ts b/src/modules/posts/posts.service.ts index db33090..682f397 100644 --- a/src/modules/posts/posts.service.ts +++ b/src/modules/posts/posts.service.ts @@ -1,63 +1,268 @@ -import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { extname } from 'path'; import { Types } from 'mongoose'; +import { ModerationStatus } from '../../common/enums/moderation-status.enum'; import { PostType } from '../../common/enums/post-type.enum'; import { PostVisibility } from '../../common/enums/post-visibility.enum'; +import { buildPaginatedResponse } from '../../common/utils/pagination.util'; +import { resolveMongoSortDirection } from '../../common/utils/sort.util'; +import { + generateWaveformPeaksFromBuffer, + generateWaveformPeaksFromSeed, + normalizeWaveformPeaks, +} from '../../common/utils/waveform.util'; +import { FeedVersionService } from '../../infrastructure/cache/feed-version.service'; +import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { AuditService } from '../audit/audit.service'; import { UsersRepository } from '../users/users.repository'; +import { AdminPostQueryDto } from './dto/admin-post-query.dto'; +import { CreateReelDto } from './dto/create-reel.dto'; import { CreatePostDto } from './dto/create-post.dto'; import { PostQueryDto } from './dto/post-query.dto'; +import { ReelQueryDto } from './dto/reel-query.dto'; import { UpdatePostDto } from './dto/update-post.dto'; import { PostDocument } from './schemas/post.schema'; import { PostsRepository } from './posts.repository'; +type PostMediaMetadataInput = Pick< + CreatePostDto, + | 'durationSeconds' + | 'thumbnailUrl' + | 'style' + | 'maqam' + | 'rhythmSignature' + | 'waveformPeaks' +>; + +type NormalizedPostMediaMetadata = { + durationSeconds: number | null; + thumbnailUrl: string; + style: string; + maqam: string; + rhythmSignature: string; + waveformPeaks: number[]; +}; + @Injectable() export class PostsService { + private readonly logger = new Logger(PostsService.name); + constructor( private readonly postsRepository: PostsRepository, private readonly usersRepository: UsersRepository, + private readonly storageService: ManagedStorageService, + private readonly feedVersionService: FeedVersionService, + private readonly notificationsService: NotificationsService, + private readonly auditService: AuditService, ) {} - async create(userId: string, dto: CreatePostDto): Promise { - const postType = this.resolvePostType(dto.videoUrl, dto.audioUrl); - const hashtags = this.extractHashtags(dto.content); + async create( + userId: string, + dto: CreatePostDto, + imageFiles: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }> = [], + videoFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, + audioFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, + ): Promise { + const inputImageUrls = dto.imageUrls ?? []; + if (inputImageUrls.length > 10 || imageFiles.length > 10) { + throw new BadRequestException('Post can contain up to 10 images'); + } + if (imageFiles.length && inputImageUrls.length) { + throw new BadRequestException('Provide either imageFiles or imageUrls, not both'); + } + if (videoFile && audioFile) { + throw new BadRequestException('Post can contain either images, video, or audio'); + } + if (videoFile && dto.videoUrl) { + throw new BadRequestException('Provide either videoFile or videoUrl, not both'); + } + if (audioFile && dto.audioUrl) { + throw new BadRequestException('Provide either audioFile or audioUrl, not both'); + } + if ((videoFile || dto.videoUrl) && (imageFiles.length || inputImageUrls.length)) { + throw new BadRequestException('Post can contain either images or video, not both'); + } + if ((audioFile || dto.audioUrl) && (imageFiles.length || inputImageUrls.length)) { + throw new BadRequestException('Post can contain either images or audio, not both'); + } - const post = await this.postsRepository.create(userId, { - content: dto.content, - videoUrl: dto.videoUrl ?? '', - audioUrl: dto.audioUrl ?? '', - postType, - visibility: dto.visibility ?? PostVisibility.PUBLIC, - hashtags, + const uploadedImageUrls = await this.saveImageFiles(imageFiles); + const uploadedVideoUrl = videoFile ? await this.saveMediaFile('video', videoFile) : ''; + const uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : ''; + const finalImageUrls = uploadedImageUrls.length ? uploadedImageUrls : inputImageUrls; + const finalVideoUrl = uploadedVideoUrl || dto.videoUrl || ''; + const finalAudioUrl = uploadedAudioUrl || dto.audioUrl || ''; + const finalContent = dto.content?.trim() ?? ''; + const taggedUserIds = await this.normalizeTaggedUserIds(dto.taggedUserIds, userId); + const mentionResolution = await this.resolveMentionTargets(dto.mentionUsernames, finalContent, userId); + const { location, latitude, longitude } = this.normalizeLocation(dto); + if (!finalContent && !finalImageUrls.length && !finalVideoUrl && !finalAudioUrl) { + throw new BadRequestException('Post must contain caption or media'); + } + + const postType = this.resolvePostType(finalImageUrls, finalVideoUrl, finalAudioUrl); + const hashtags = this.extractHashtags(finalContent); + const mediaMetadata = this.normalizeMediaMetadata(dto, postType, undefined, { + audioSourceBuffer: audioFile?.buffer, + waveformSeed: finalAudioUrl || finalContent || `${userId}:${Date.now()}`, }); + let post: PostDocument; + try { + post = await this.postsRepository.create(userId, { + content: finalContent, + imageUrls: finalImageUrls, + videoUrl: finalVideoUrl, + audioUrl: finalAudioUrl, + taggedUserIds, + mentionUsernames: mentionResolution.mentionUsernames, + location, + latitude, + longitude, + postType, + visibility: dto.visibility ?? PostVisibility.PUBLIC, + hashtags, + ...mediaMetadata, + }); + } catch (error) { + await Promise.all([ + ...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)), + uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(), + uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(), + ]); + throw error; + } + await this.usersRepository.incrementPostsCount(userId, 1); + await this.feedVersionService.bumpGlobalVersion(); + await this.notifyMentionedUsers(userId, post.id, mentionResolution.mentionedUsers, finalContent); return post; } - async update(userId: string, postId: string, dto: UpdatePostDto): Promise { + async update( + userId: string, + postId: string, + dto: UpdatePostDto, + imageFiles: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }> = [], + videoFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, + audioFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, + ): Promise { const post = await this.postsRepository.findById(postId); if (!post) { throw new NotFoundException('Post not found'); } - if (post.authorId.toString() !== userId) { + if (this.extractEntityId(post.authorId) !== userId) { throw new ForbiddenException('You can only update your own posts'); } - const hasVideoUpdate = typeof dto.videoUrl !== 'undefined'; - const hasAudioUpdate = typeof dto.audioUrl !== 'undefined'; + const inputImageUrls = dto.imageUrls ?? []; + if (inputImageUrls.length > 10 || imageFiles.length > 10) { + throw new BadRequestException('Post can contain up to 10 images'); + } + if (imageFiles.length && inputImageUrls.length) { + throw new BadRequestException('Provide either imageFiles or imageUrls, not both'); + } - const nextVideoUrl = hasVideoUpdate ? dto.videoUrl ?? '' : post.videoUrl ?? ''; - const nextAudioUrl = hasAudioUpdate ? dto.audioUrl ?? '' : post.audioUrl ?? ''; - const nextPostType = this.resolvePostType(nextVideoUrl, nextAudioUrl); + if (videoFile && audioFile) { + throw new BadRequestException('Post can contain either images, video, or audio'); + } + if (videoFile && dto.videoUrl) { + throw new BadRequestException('Provide either videoFile or videoUrl, not both'); + } + if (audioFile && dto.audioUrl) { + throw new BadRequestException('Provide either audioFile or audioUrl, not both'); + } + if ((videoFile || dto.videoUrl) && (imageFiles.length || inputImageUrls.length)) { + throw new BadRequestException('Post can contain either images or video, not both'); + } + if ((audioFile || dto.audioUrl) && (imageFiles.length || inputImageUrls.length)) { + throw new BadRequestException('Post can contain either images or audio, not both'); + } - const payload: UpdatePostDto & { postType: PostType } = { + const uploadedImageUrls = await this.saveImageFiles(imageFiles); + const uploadedVideoUrl = videoFile ? await this.saveMediaFile('video', videoFile) : ''; + const uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : ''; + const hasImageUpdate = typeof dto.imageUrls !== 'undefined' || imageFiles.length > 0; + + const hasVideoUpdate = typeof dto.videoUrl !== 'undefined' || !!videoFile; + const hasAudioUpdate = typeof dto.audioUrl !== 'undefined' || !!audioFile; + const nextImageUrls = hasImageUpdate + ? imageFiles.length + ? uploadedImageUrls + : inputImageUrls + : post.imageUrls ?? []; + + const nextVideoUrl = hasVideoUpdate + ? videoFile + ? uploadedVideoUrl + : dto.videoUrl ?? '' + : post.videoUrl ?? ''; + const nextAudioUrl = hasAudioUpdate + ? audioFile + ? uploadedAudioUrl + : dto.audioUrl ?? '' + : post.audioUrl ?? ''; + const nextPostType = this.resolvePostType(nextImageUrls, nextVideoUrl, nextAudioUrl); + const nextContent = typeof dto.content === 'string' ? dto.content.trim() : post.content ?? ''; + const nextTaggedUserIds = + typeof dto.taggedUserIds !== 'undefined' + ? await this.normalizeTaggedUserIds(dto.taggedUserIds, userId) + : (post.taggedUserIds ?? []).map((id: Types.ObjectId | string) => new Types.ObjectId(id.toString())); + const previousMentionUsernames = this.normalizeMentionUsernames(post.mentionUsernames ?? []); + const shouldRecomputeMentions = + typeof dto.content === 'string' || typeof dto.mentionUsernames !== 'undefined'; + const mentionResolution = shouldRecomputeMentions + ? await this.resolveMentionTargets(dto.mentionUsernames, nextContent, userId) + : { + mentionUsernames: previousMentionUsernames, + mentionedUsers: [] as Array<{ id: string; username: string }>, + }; + const { location: nextLocation, latitude: nextLatitude, longitude: nextLongitude } = + this.normalizeLocation(dto, { + location: post.location ?? '', + latitude: post.latitude ?? null, + longitude: post.longitude ?? null, + }); + if (!nextContent && !nextImageUrls.length && !nextVideoUrl && !nextAudioUrl) { + throw new BadRequestException('Post must contain caption or media'); + } + const mediaMetadata = this.normalizeMediaMetadata( + dto, + nextPostType, + { + durationSeconds: post.durationSeconds ?? null, + thumbnailUrl: post.thumbnailUrl ?? '', + style: post.style ?? '', + maqam: post.maqam ?? '', + rhythmSignature: post.rhythmSignature ?? '', + waveformPeaks: post.waveformPeaks ?? [], + }, + { + audioSourceBuffer: audioFile?.buffer, + waveformSeed: nextAudioUrl || nextContent || post.id, + }, + ); + + const payload: Record = { ...dto, + content: nextContent, + imageUrls: nextImageUrls, + taggedUserIds: nextTaggedUserIds, + mentionUsernames: mentionResolution.mentionUsernames, + location: nextLocation, + latitude: nextLatitude, + longitude: nextLongitude, postType: nextPostType, + ...mediaMetadata, }; if (typeof dto.content === 'string') { - (payload as UpdatePostDto & { postType: PostType; hashtags: string[] }).hashtags = - this.extractHashtags(dto.content); + payload.hashtags = this.extractHashtags(nextContent); + } + if (hasImageUpdate) { + payload.hashtags = this.extractHashtags(nextContent); } if (hasVideoUpdate && !hasAudioUpdate) { @@ -66,12 +271,65 @@ export class PostsService { if (hasAudioUpdate && !hasVideoUpdate) { payload.videoUrl = ''; } + if (hasImageUpdate) { + payload.videoUrl = ''; + payload.audioUrl = ''; + } - const updated = await this.postsRepository.updateById(postId, payload); + if (videoFile) { + payload.videoUrl = uploadedVideoUrl; + payload.imageUrls = []; + payload.audioUrl = ''; + } + if (audioFile) { + payload.audioUrl = uploadedAudioUrl; + payload.imageUrls = []; + payload.videoUrl = ''; + } + + let updated: PostDocument | null; + try { + updated = await this.postsRepository.updateById(postId, payload as any); + } catch (error) { + await Promise.all([ + ...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)), + uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(), + uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(), + ]); + throw error; + } if (!updated) { + await Promise.all([ + ...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)), + uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(), + uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(), + ]); throw new NotFoundException('Post not found'); } + if (hasVideoUpdate && (post.videoUrl ?? '') !== (updated.videoUrl ?? '')) { + await this.deleteManagedPostMedia(post.videoUrl ?? ''); + } + if (hasAudioUpdate && (post.audioUrl ?? '') !== (updated.audioUrl ?? '')) { + await this.deleteManagedPostMedia(post.audioUrl ?? ''); + } + if (hasImageUpdate) { + const nextImageSet = new Set(updated.imageUrls ?? []); + await Promise.all( + (post.imageUrls ?? []) + .filter((url) => !nextImageSet.has(url)) + .map((url) => this.deleteManagedPostMedia(url)), + ); + } + + await this.feedVersionService.bumpGlobalVersion(); + if (shouldRecomputeMentions) { + const previousMentionSet = new Set(previousMentionUsernames); + const nextMentionedUsers = mentionResolution.mentionedUsers.filter( + (mentionedUser) => !previousMentionSet.has(mentionedUser.username), + ); + await this.notifyMentionedUsers(userId, postId, nextMentionedUsers, nextContent); + } return updated; } @@ -81,12 +339,18 @@ export class PostsService { throw new NotFoundException('Post not found'); } - if (post.authorId.toString() !== userId) { + if (this.extractEntityId(post.authorId) !== userId) { throw new ForbiddenException('You can only delete your own posts'); } await this.postsRepository.deleteById(postId, userId); + await Promise.all([ + ...(post.imageUrls ?? []).map((url) => this.deleteManagedPostMedia(url)), + this.deleteManagedPostMedia(post.videoUrl ?? ''), + this.deleteManagedPostMedia(post.audioUrl ?? ''), + ]); await this.usersRepository.incrementPostsCount(userId, -1); + await this.feedVersionService.bumpGlobalVersion(); } async findById(postId: string): Promise { @@ -110,27 +374,281 @@ export class PostsService { if (query.visibility) { filter.visibility = query.visibility; } + if (query.postType) { + filter.postType = query.postType; + } + if (query.q) { + filter.content = { $regex: query.q.trim(), $options: 'i' }; + } + if (query.hashtag) { + filter.hashtags = query.hashtag.trim().replace(/^#+/, '').toLowerCase(); + } + const direction = resolveMongoSortDirection(query.sortOrder); + const sortField = query.sortBy ?? 'createdAt'; + const sort = { [sortField]: direction } as Record; const [items, total] = await Promise.all([ - this.postsRepository.findMany(filter, skip, limit), + this.postsRepository.findMany(filter, skip, limit, sort), this.postsRepository.count(filter), ]); - return { - items, + return buildPaginatedResponse(items, { page, limit, total, - totalPages: Math.ceil(total / limit) || 1, + offset: skip, + }); + } + + async findPlatformPosts(query: AdminPostQueryDto) { + const page = query.page ?? 1; + const limit = query.limit ?? 20; + const skip = (page - 1) * limit; + const filter: Record = {}; + + if (query.visibility) { + filter.visibility = query.visibility; + } + if (query.postType) { + filter.postType = query.postType; + } + if (query.authorId) { + filter.authorId = new Types.ObjectId(query.authorId); + } + if (query.q?.trim()) { + filter.content = { $regex: query.q.trim(), $options: 'i' }; + } + if (query.hashtag?.trim()) { + filter.hashtags = query.hashtag.trim().replace(/^#+/, '').toLowerCase(); + } + if (query.moderationStatus) { + filter.moderationStatus = query.moderationStatus; + } + + const direction = resolveMongoSortDirection(query.sortOrder); + const sortField = query.sortBy ?? 'createdAt'; + const sort = { [sortField]: direction } as Record; + + const [items, total] = await Promise.all([ + this.postsRepository.findManyAdmin(filter, skip, limit, sort), + this.postsRepository.countAdmin(filter), + ]); + + return buildPaginatedResponse(items, { + page, + limit, + total, + offset: skip, + }); + } + + async createReel( + userId: string, + dto: CreateReelDto, + videoFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, + ): Promise { + if (!videoFile && !dto.videoUrl) { + throw new BadRequestException('Reel requires videoFile or videoUrl'); + } + + if (videoFile && dto.videoUrl) { + throw new BadRequestException('Provide either videoFile or videoUrl for reel, not both'); + } + + return this.create( + userId, + { + content: dto.content ?? '', + videoUrl: dto.videoUrl, + durationSeconds: dto.durationSeconds, + thumbnailUrl: dto.thumbnailUrl, + style: dto.style, + maqam: dto.maqam, + rhythmSignature: dto.rhythmSignature, + mentionUsernames: dto.mentionUsernames, + visibility: dto.visibility ?? PostVisibility.PUBLIC, + }, + [], + videoFile, + undefined, + ); + } + + async findReels(query: ReelQueryDto) { + const page = query.page ?? 1; + const limit = query.limit ?? 20; + const skip = (page - 1) * limit; + + const filter: Record = { postType: PostType.VIDEO }; + if (query.visibility) { + filter.visibility = query.visibility; + } + if (query.authorId) { + filter.authorId = new Types.ObjectId(query.authorId); + } + if (query.q) { + filter.content = { $regex: query.q.trim(), $options: 'i' }; + } + const direction = resolveMongoSortDirection(query.sortOrder); + const sortField = query.sortBy ?? 'createdAt'; + const sort = { [sortField]: direction } as Record; + + const [items, total] = await Promise.all([ + this.postsRepository.findMany(filter, skip, limit, sort), + this.postsRepository.count(filter), + ]); + + return buildPaginatedResponse(items, { + page, + limit, + total, + offset: skip, + }); + } + + async registerView(userId: string, postId: string): Promise<{ success: true; postId: string; viewCount: number }> { + const post = await this.postsRepository.findById(postId); + if (!post) { + throw new NotFoundException('Post not found'); + } + + await this.postsRepository.incrementViewCount(postId, 1); + + return { + success: true, + postId, + viewCount: (post.viewCount ?? 0) + 1, }; } - private resolvePostType(videoUrl?: string, audioUrl?: string): PostType { + async registerPlay( + userId: string, + postId: string, + ): Promise<{ success: true; postId: string; playCount: number }> { + const post = await this.postsRepository.findById(postId); + if (!post) { + throw new NotFoundException('Post not found'); + } + + if (post.postType !== PostType.AUDIO && post.postType !== PostType.VIDEO) { + throw new BadRequestException('play counter is available only for audio or video posts'); + } + + await this.postsRepository.incrementPlayCount(postId, 1); + + return { + success: true, + postId, + playCount: (post.playCount ?? 0) + 1, + }; + } + + async registerShare( + userId: string, + postId: string, + ): Promise<{ success: true; postId: string; shareCount: number }> { + const post = await this.postsRepository.findById(postId); + if (!post) { + throw new NotFoundException('Post not found'); + } + + await this.postsRepository.incrementShareCount(postId, 1); + await this.feedVersionService.bumpGlobalVersion(); + + const authorId = this.extractEntityId(post.authorId); + if (authorId && authorId !== userId) { + try { + await this.notificationsService.createShareNotification(userId, authorId, postId, { + resourceType: 'post', + previewText: (post.content ?? '').slice(0, 140), + }); + } catch (error) { + this.logger.warn( + `Share notification failed for actor=${userId} recipient=${authorId}: ${ + error instanceof Error ? error.message : 'unknown error' + }`, + ); + } + } + + return { + success: true, + postId, + shareCount: (post.shareCount ?? 0) + 1, + }; + } + + async removeBySuperAdmin(superAdminIdentifier: string, postId: string): Promise { + const post = await this.postsRepository.findById(postId); + if (!post) { + throw new NotFoundException('Post not found'); + } + + await this.postsRepository.deleteById(postId, superAdminIdentifier); + await Promise.all([ + ...(post.imageUrls ?? []).map((url) => this.deleteManagedPostMedia(url)), + this.deleteManagedPostMedia(post.videoUrl ?? ''), + this.deleteManagedPostMedia(post.audioUrl ?? ''), + ]); + const authorId = this.extractEntityId(post.authorId); + if (authorId) { + await this.usersRepository.incrementPostsCount(authorId, -1); + } + await this.feedVersionService.bumpGlobalVersion(); + await this.auditService.logSuperAdminAction( + superAdminIdentifier, + 'post_delete', + 'post', + postId, + { authorId }, + ); + } + + async updateModerationStatusBySuperAdmin( + superAdminIdentifier: string, + postId: string, + dto: { status: ModerationStatus; reason?: string }, + ): Promise { + const post = await this.postsRepository.findById(postId); + if (!post) { + throw new NotFoundException('Post not found'); + } + + const updated = await this.postsRepository.updateModerationStatus(postId, { + moderationStatus: dto.status, + moderationReason: dto.reason?.trim() ?? '', + }); + if (!updated) { + throw new NotFoundException('Post not found'); + } + + await this.feedVersionService.bumpGlobalVersion(); + await this.auditService.logSuperAdminAction( + superAdminIdentifier, + 'post_moderation_status_update', + 'post', + postId, + { + previousStatus: post.moderationStatus ?? ModerationStatus.ACTIVE, + nextStatus: dto.status, + reason: dto.reason?.trim() ?? '', + }, + ); + + return updated; + } + + private resolvePostType(imageUrls: string[] = [], videoUrl?: string, audioUrl?: string): PostType { + const hasImages = imageUrls.length > 0; const hasVideo = !!videoUrl?.trim(); const hasAudio = !!audioUrl?.trim(); - if (hasVideo && hasAudio) { - throw new BadRequestException('Post can contain either video or audio, not both'); + if ((hasImages && hasVideo) || (hasImages && hasAudio) || (hasVideo && hasAudio)) { + throw new BadRequestException('Post can contain either images, video, or audio'); + } + + if (hasImages) { + return PostType.IMAGE; } if (hasVideo) { @@ -144,6 +662,133 @@ export class PostsService { return PostType.TEXT; } + private normalizeMediaMetadata( + dto: PostMediaMetadataInput, + postType: PostType, + fallback: NormalizedPostMediaMetadata = { + durationSeconds: null, + thumbnailUrl: '', + style: '', + maqam: '', + rhythmSignature: '', + waveformPeaks: [], + }, + options: { + audioSourceBuffer?: Buffer; + waveformSeed?: string; + } = {}, + ): NormalizedPostMediaMetadata { + const supportsMediaMetadata = postType === PostType.AUDIO || postType === PostType.VIDEO; + const supportsWaveform = postType === PostType.AUDIO; + + if (!supportsMediaMetadata) { + if (this.hasMediaMetadataInput(dto)) { + throw new BadRequestException('Audio/video metadata is allowed only for audio or video posts'); + } + return { + durationSeconds: null, + thumbnailUrl: '', + style: '', + maqam: '', + rhythmSignature: '', + waveformPeaks: [], + }; + } + + if (!supportsWaveform && typeof dto.waveformPeaks !== 'undefined') { + throw new BadRequestException('waveformPeaks is allowed only for audio posts'); + } + + return { + durationSeconds: + typeof dto.durationSeconds === 'number' ? dto.durationSeconds : fallback.durationSeconds, + thumbnailUrl: + typeof dto.thumbnailUrl === 'string' ? dto.thumbnailUrl.trim() : fallback.thumbnailUrl, + style: typeof dto.style === 'string' ? dto.style.trim() : fallback.style, + maqam: typeof dto.maqam === 'string' ? dto.maqam.trim() : fallback.maqam, + rhythmSignature: + typeof dto.rhythmSignature === 'string' + ? dto.rhythmSignature.trim() + : fallback.rhythmSignature, + waveformPeaks: supportsWaveform + ? this.resolveAudioWaveformPeaks( + Array.isArray(dto.waveformPeaks) ? dto.waveformPeaks : undefined, + options.audioSourceBuffer, + options.waveformSeed, + fallback.waveformPeaks, + ) + : [], + }; + } + + private hasMediaMetadataInput(dto: PostMediaMetadataInput): boolean { + return ( + typeof dto.durationSeconds === 'number' || + typeof dto.thumbnailUrl === 'string' || + typeof dto.style === 'string' || + typeof dto.maqam === 'string' || + typeof dto.rhythmSignature === 'string' || + typeof dto.waveformPeaks !== 'undefined' + ); + } + + private extractMentions(content: string): string[] { + const matches = content.match(/@[\p{L}\p{N}_.]+/gu) ?? []; + const normalized = matches + .map((item) => item.replace('@', '').trim().toLowerCase()) + .filter(Boolean); + return Array.from(new Set(normalized)).slice(0, 30); + } + + private normalizeMentionUsernames(input: string[] = []): string[] { + return Array.from( + new Set( + input + .map((username) => username?.trim().replace(/^@+/, '').toLowerCase()) + .filter((username): username is string => !!username), + ), + ); + } + + 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 extractHashtags(content: string): string[] { const matches = content.match(/#[\p{L}\p{N}_]+/gu) ?? []; const normalized = matches @@ -152,4 +797,278 @@ export class PostsService { return Array.from(new Set(normalized)).slice(0, 20); } + + private normalizeLocation( + dto: Pick, + fallback: { location: string; latitude: number | null; longitude: number | null } = { + location: '', + latitude: null, + longitude: null, + }, + ): { location: string; latitude: number | null; longitude: number | null } { + const location = typeof dto.location === 'string' ? dto.location.trim() : fallback.location; + const latitude = typeof dto.latitude === 'number' ? dto.latitude : fallback.latitude; + const longitude = typeof dto.longitude === 'number' ? dto.longitude : fallback.longitude; + + const hasLatitude = typeof latitude === 'number'; + const hasLongitude = typeof longitude === 'number'; + if (hasLatitude !== hasLongitude) { + throw new BadRequestException('latitude and longitude must be provided together'); + } + + return { location, latitude, longitude }; + } + + private async normalizeTaggedUserIds( + input: string[] | undefined, + authorId: string, + ): Promise { + if (!input?.length) { + return []; + } + + const unique = Array.from( + new Set( + input + .map((item) => item?.trim()) + .filter((item): item is string => !!item) + .filter((item) => item !== authorId), + ), + ); + + if (unique.length > 20) { + throw new BadRequestException('You can tag up to 20 users only'); + } + + if (unique.some((id) => !Types.ObjectId.isValid(id))) { + throw new BadRequestException('Invalid tagged user id'); + } + + const rows = await this.usersRepository.findMany( + { + _id: { $in: unique.map((id) => new Types.ObjectId(id)) }, + }, + 0, + unique.length, + ); + + if (rows.length !== unique.length) { + throw new BadRequestException('One or more tagged users do not exist'); + } + + return unique.map((id) => new Types.ObjectId(id)); + } + + private async notifyMentionedUsers( + actorId: string, + postId: string, + mentionedUsers: Array<{ id: string; username: string }>, + content: string, + ): Promise { + if (!mentionedUsers.length) { + return; + } + + await Promise.all( + mentionedUsers.map(async (mentionedUser) => { + try { + await this.notificationsService.createMentionNotification(actorId, mentionedUser.id, postId, { + resourceType: 'post', + previewText: content.slice(0, 140), + }); + } catch (error) { + this.logger.warn( + `Mention notification failed for actor=${actorId} recipient=${mentionedUser.id}: ${ + error instanceof Error ? error.message : 'unknown error' + }`, + ); + } + }), + ); + } + + private async saveMediaFile( + mediaType: 'image' | 'video' | 'audio', + file: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, + ): Promise { + const extension = this.resolveMediaExtension(mediaType, file); + if (!extension) { + throw new BadRequestException( + mediaType === 'image' + ? 'imageFiles must be jpg, jpeg, png, webp, or gif' + : mediaType === 'video' + ? 'videoFile must be mp4, mov, webm, mkv, or avi' + : 'audioFile must be mp3, wav, m4a, aac, ogg, or webm', + ); + } + + const maxSize = + mediaType === 'image' + ? 10 * 1024 * 1024 + : mediaType === 'video' + ? 100 * 1024 * 1024 + : 20 * 1024 * 1024; + if (file.size > maxSize) { + throw new BadRequestException( + mediaType === 'image' + ? 'Each image must be 10MB or less' + : mediaType === 'video' + ? 'videoFile size must be 100MB or less' + : 'audioFile size must be 20MB or less', + ); + } + + const folder = + mediaType === 'image' ? 'images' : mediaType === 'video' ? 'videos' : 'audios'; + return this.storageService.saveFile({ + folderSegments: ['posts', folder], + extension, + buffer: file.buffer, + contentType: file.mimetype, + fileNamePrefix: mediaType, + }); + } + + private resolveMediaExtension( + mediaType: 'image' | 'video' | 'audio', + file: { mimetype?: string; originalname?: string }, + ): string | null { + const originalExtension = extname(file.originalname ?? '').toLowerCase(); + const imageAllowed = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']); + const videoAllowed = new Set(['.mp4', '.mov', '.webm', '.mkv', '.avi']); + const audioAllowed = new Set(['.mp3', '.wav', '.m4a', '.aac', '.ogg', '.webm']); + const allowed = + mediaType === 'image' ? imageAllowed : mediaType === 'video' ? videoAllowed : audioAllowed; + + if (allowed.has(originalExtension)) { + return originalExtension; + } + + if (mediaType === 'image') { + switch (file.mimetype) { + case 'image/jpeg': + return '.jpg'; + case 'image/png': + return '.png'; + case 'image/webp': + return '.webp'; + case 'image/gif': + return '.gif'; + default: + return null; + } + } + + if (mediaType === 'video') { + switch (file.mimetype) { + case 'video/mp4': + return '.mp4'; + case 'video/quicktime': + return '.mov'; + case 'video/webm': + return '.webm'; + case 'video/x-matroska': + return '.mkv'; + case 'video/x-msvideo': + return '.avi'; + default: + return null; + } + } + + switch (file.mimetype) { + case 'audio/mpeg': + return '.mp3'; + case 'audio/wav': + case 'audio/x-wav': + return '.wav'; + case 'audio/mp4': + case 'audio/x-m4a': + return '.m4a'; + case 'audio/aac': + return '.aac'; + case 'audio/ogg': + return '.ogg'; + case 'audio/webm': + return '.webm'; + default: + return null; + } + } + + private async saveImageFiles( + files: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>, + ): Promise { + if (!files.length) { + return []; + } + if (files.length > 10) { + throw new BadRequestException('Post can contain up to 10 images'); + } + + const urls: string[] = []; + try { + for (const file of files) { + urls.push(await this.saveMediaFile('image', file)); + } + return urls; + } catch (error) { + await Promise.all(urls.map((url) => this.deleteManagedPostMedia(url))); + throw error; + } + } + + private async deleteManagedPostMedia(fileUrl: string): Promise { + await this.storageService.deleteFile(fileUrl); + } + + private resolveAudioWaveformPeaks( + providedPeaks: number[] | undefined, + sourceBuffer: Buffer | undefined, + waveformSeed: string | undefined, + fallbackPeaks: number[] = [], + ): number[] { + if (Array.isArray(providedPeaks) && providedPeaks.length) { + return normalizeWaveformPeaks(providedPeaks); + } + + if (sourceBuffer?.length) { + return generateWaveformPeaksFromBuffer(sourceBuffer); + } + + if (fallbackPeaks.length) { + return normalizeWaveformPeaks(fallbackPeaks); + } + + return generateWaveformPeaksFromSeed(waveformSeed ?? 'audio-post'); + } + + 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 ''; + } } diff --git a/src/modules/posts/schemas/post.schema.ts b/src/modules/posts/schemas/post.schema.ts index 3994a30..0f9f515 100644 --- a/src/modules/posts/schemas/post.schema.ts +++ b/src/modules/posts/schemas/post.schema.ts @@ -1,7 +1,12 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { HydratedDocument, Types } from 'mongoose'; +import { ModerationStatus } from '../../../common/enums/moderation-status.enum'; import { PostType } from '../../../common/enums/post-type.enum'; import { PostVisibility } from '../../../common/enums/post-visibility.enum'; +import { + resolveManagedFileUrl, + resolveManagedFileUrls, +} from '../../../common/utils/public-url.util'; import { User } from '../../users/schemas/user.schema'; export type PostDocument = HydratedDocument; @@ -20,6 +25,42 @@ export class Post { @Prop({ default: '' }) audioUrl!: string; + @Prop({ type: Number, min: 1, max: 7200, default: null }) + durationSeconds!: number | null; + + @Prop({ default: '' }) + thumbnailUrl!: string; + + @Prop({ default: '', trim: true, maxlength: 80 }) + style!: string; + + @Prop({ default: '', trim: true, maxlength: 80 }) + maqam!: string; + + @Prop({ default: '', trim: true, maxlength: 40 }) + rhythmSignature!: string; + + @Prop({ type: [Number], default: [] }) + waveformPeaks!: number[]; + + @Prop({ type: [String], default: [] }) + imageUrls!: string[]; + + @Prop({ type: [Types.ObjectId], ref: User.name, default: [], index: true }) + taggedUserIds!: Types.ObjectId[]; + + @Prop({ type: [String], default: [] }) + mentionUsernames!: string[]; + + @Prop({ default: '' }) + location!: string; + + @Prop({ type: Number, min: -90, max: 90, default: null }) + latitude!: number | null; + + @Prop({ type: Number, min: -180, max: 180, default: null }) + longitude!: number | null; + @Prop({ enum: PostType, default: PostType.TEXT, index: true }) postType!: PostType; @@ -35,9 +76,29 @@ export class Post { @Prop({ default: 0, min: 0 }) savesCount!: number; + @Prop({ default: 0, min: 0 }) + shareCount!: number; + + @Prop({ default: 0, min: 0 }) + viewCount!: number; + + @Prop({ default: 0, min: 0 }) + playCount!: number; + @Prop({ type: [String], default: [], index: true }) hashtags!: 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 }) isDeleted!: boolean; @@ -54,6 +115,29 @@ PostSchema.index({ authorId: 1, createdAt: -1 }); PostSchema.index({ visibility: 1, createdAt: -1 }); PostSchema.index({ postType: 1, createdAt: -1 }); PostSchema.index({ hashtags: 1, createdAt: -1 }); +PostSchema.index({ taggedUserIds: 1, createdAt: -1 }); +PostSchema.index({ moderationStatus: 1, createdAt: -1 }); PostSchema.index({ authorId: 1, isDeleted: 1, createdAt: -1 }); PostSchema.index({ visibility: 1, isDeleted: 1, createdAt: -1 }); -PostSchema.index({ visibility: 1, isDeleted: 1, likesCount: -1, commentsCount: -1, savesCount: -1, createdAt: -1 }); +PostSchema.index({ + visibility: 1, + isDeleted: 1, + likesCount: -1, + commentsCount: -1, + savesCount: -1, + shareCount: -1, + viewCount: -1, + playCount: -1, + createdAt: -1, +}); + +const transformManagedPostFiles = (_doc: unknown, ret: any) => { + ret.imageUrls = resolveManagedFileUrls(ret.imageUrls); + ret.videoUrl = resolveManagedFileUrl(ret.videoUrl); + ret.audioUrl = resolveManagedFileUrl(ret.audioUrl); + ret.thumbnailUrl = resolveManagedFileUrl(ret.thumbnailUrl); + return ret; +}; + +PostSchema.set('toJSON', { transform: transformManagedPostFiles }); +PostSchema.set('toObject', { transform: transformManagedPostFiles }); diff --git a/src/modules/saves/saves.module.ts b/src/modules/saves/saves.module.ts index d6d781e..f07196c 100644 --- a/src/modules/saves/saves.module.ts +++ b/src/modules/saves/saves.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; +import { NotificationsModule } from '../notifications/notifications.module'; import { PostsModule } from '../posts/posts.module'; import { Save, SaveSchema } from './schemas/save.schema'; import { SavesController } from './saves.controller'; @@ -10,9 +11,10 @@ import { SavesService } from './saves.service'; imports: [ MongooseModule.forFeature([{ name: Save.name, schema: SaveSchema }]), PostsModule, + NotificationsModule, ], controllers: [SavesController], providers: [SavesService, SavesRepository], - exports: [SavesService], + exports: [SavesService, SavesRepository], }) export class SavesModule {} diff --git a/src/modules/saves/saves.repository.ts b/src/modules/saves/saves.repository.ts index 96372fe..31e7d53 100644 --- a/src/modules/saves/saves.repository.ts +++ b/src/modules/saves/saves.repository.ts @@ -20,14 +20,36 @@ export class SavesRepository { }); } + async findSavedPostIds(userId: string, postIds: string[]): Promise { + if (!postIds.length) { + return []; + } + + const rows = await this.saveModel + .find({ + userId: new Types.ObjectId(userId), + postId: { $in: postIds.map((id) => new Types.ObjectId(id)) }, + }) + .select({ postId: 1 }) + .lean() + .exec(); + + return rows.map((row) => row.postId.toString()); + } + async deleteById(id: string): Promise { await this.saveModel.findByIdAndDelete(id).exec(); } - async findUserSavedPostIds(userId: string, skip: number, limit: number): Promise { + async findUserSavedPostIds( + userId: string, + skip: number, + limit: number, + sort: Record = { createdAt: -1 }, + ): Promise { const rows = await this.saveModel .find({ userId: new Types.ObjectId(userId) }) - .sort({ createdAt: -1 }) + .sort(sort) .skip(skip) .limit(limit) .select('postId') diff --git a/src/modules/saves/saves.service.spec.ts b/src/modules/saves/saves.service.spec.ts index b02e79f..dd6eccd 100644 --- a/src/modules/saves/saves.service.spec.ts +++ b/src/modules/saves/saves.service.spec.ts @@ -12,6 +12,8 @@ describe('SavesService', () => { const service = new SavesService( savesRepository as any, postsRepository as any, + { bumpGlobalVersion: jest.fn() } as any, + { createSaveNotification: jest.fn() } as any, ); await expect( diff --git a/src/modules/saves/saves.service.ts b/src/modules/saves/saves.service.ts index f694513..23d30e1 100644 --- a/src/modules/saves/saves.service.ts +++ b/src/modules/saves/saves.service.ts @@ -1,14 +1,23 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +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 { NotificationsService } from '../notifications/notifications.service'; import { PostsRepository } from '../posts/posts.repository'; import { ToggleSaveDto } from './dto/toggle-save.dto'; import { SavesRepository } from './saves.repository'; @Injectable() export class SavesService { + private readonly logger = new Logger(SavesService.name); + constructor( private readonly savesRepository: SavesRepository, private readonly postsRepository: PostsRepository, + private readonly feedVersionService: FeedVersionService, + private readonly notificationsService: NotificationsService, ) {} async toggle(userId: string, dto: ToggleSaveDto): Promise<{ saved: boolean; postId: string }> { @@ -17,7 +26,7 @@ export class SavesService { } async save(userId: string, dto: ToggleSaveDto): Promise<{ saved: boolean; postId: string }> { - await this.assertPostExists(dto.postId); + const post = await this.getPostOrThrow(dto.postId); const existing = await this.savesRepository.findOne(userId, dto.postId); if (existing) { @@ -26,6 +35,22 @@ export class SavesService { await this.savesRepository.create(userId, dto.postId); await this.postsRepository.incrementSavesCount(dto.postId, 1); + await this.feedVersionService.bumpGlobalVersion(); + const recipientId = this.extractEntityId(post.authorId); + if (recipientId && recipientId !== userId) { + try { + await this.notificationsService.createSaveNotification(userId, recipientId, dto.postId, { + resourceType: 'post', + previewText: (post.content ?? '').slice(0, 140), + }); + } catch (error) { + this.logger.warn( + `Save notification failed for actor=${userId} recipient=${recipientId}: ${ + error instanceof Error ? error.message : 'unknown error' + }`, + ); + } + } return { saved: true, postId: dto.postId }; } @@ -39,6 +64,7 @@ export class SavesService { await this.savesRepository.deleteById(existing.id); await this.postsRepository.incrementSavesCount(dto.postId, -1); + await this.feedVersionService.bumpGlobalVersion(); return { saved: false, postId: dto.postId }; } @@ -56,32 +82,66 @@ export class SavesService { const page = query.page ?? 1; const limit = query.limit ?? 20; const skip = (page - 1) * limit; + const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record; const [postIds, total] = await Promise.all([ - this.savesRepository.findUserSavedPostIds(userId, skip, limit), + this.savesRepository.findUserSavedPostIds(userId, skip, limit, sort), this.savesRepository.countByUser(userId), ]); const items = await this.postsRepository.findManyByIds(postIds); - return { - items, + return buildPaginatedResponse(items, { page, limit, total, - totalPages: Math.ceil(total / limit) || 1, - }; + offset: skip, + }); } private async assertPostExists(postId: string): Promise { - const exists = await this.postExists(postId); - if (!exists) { - throw new NotFoundException('Post not found'); - } + await this.getPostOrThrow(postId); } private async postExists(postId: string): Promise { const post = await this.postsRepository.findById(postId); return !!post; } + + private async getPostOrThrow(postId: string) { + const post = await this.postsRepository.findById(postId); + if (!post) { + throw new NotFoundException('Post not found'); + } + return post; + } + + 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 ''; + } } diff --git a/src/modules/superadmin/dto/bulk-superadmin-action.dto.ts b/src/modules/superadmin/dto/bulk-superadmin-action.dto.ts new file mode 100644 index 0000000..beb8ee2 --- /dev/null +++ b/src/modules/superadmin/dto/bulk-superadmin-action.dto.ts @@ -0,0 +1,41 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { ArrayMinSize, IsArray, IsBoolean, IsIn, IsOptional, IsString, MaxLength } from 'class-validator'; +import { toBoolean } from '../../../common/utils/query-transform.util'; +import { SUPERADMIN_CASE_PRIORITIES } from '../schemas/superadmin-case.schema'; + +const BULK_RESOURCE_TYPES = ['post', 'comment', 'user', 'listing', 'repair_shop'] as const; + +export class BulkSuperAdminActionDto { + @ApiProperty({ enum: BULK_RESOURCE_TYPES }) + @IsIn(BULK_RESOURCE_TYPES) + resourceType!: (typeof BULK_RESOURCE_TYPES)[number]; + + @ApiProperty({ type: [String] }) + @IsArray() + @ArrayMinSize(1) + @IsString({ each: true }) + targetIds!: string[]; + + @ApiProperty() + @IsString() + @MaxLength(60) + action!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(1200) + reason?: string; + + @ApiPropertyOptional({ enum: SUPERADMIN_CASE_PRIORITIES }) + @IsOptional() + @IsIn(SUPERADMIN_CASE_PRIORITIES) + priority?: (typeof SUPERADMIN_CASE_PRIORITIES)[number]; + + @ApiPropertyOptional() + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + assignToMe?: boolean; +} diff --git a/src/modules/superadmin/dto/create-superadmin-case.dto.ts b/src/modules/superadmin/dto/create-superadmin-case.dto.ts new file mode 100644 index 0000000..6250b63 --- /dev/null +++ b/src/modules/superadmin/dto/create-superadmin-case.dto.ts @@ -0,0 +1,83 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { ArrayMaxSize, IsArray, IsIn, IsOptional, IsString, MaxLength } from 'class-validator'; +import { + SUPERADMIN_CASE_PRIORITIES, + SUPERADMIN_CASE_STATUSES, +} from '../schemas/superadmin-case.schema'; + +const toStringArray = ({ value }: { value: unknown }): string[] => { + if (Array.isArray(value)) { + return value + .map((entry) => (typeof entry === 'string' ? entry.trim() : '')) + .filter(Boolean); + } + + if (typeof value === 'string') { + return value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + } + + return []; +}; + +export class CreateSuperAdminCaseDto { + @ApiProperty() + @IsString() + @MaxLength(120) + title!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(2000) + description?: string; + + @ApiProperty() + @IsString() + @MaxLength(80) + caseType!: string; + + @ApiProperty() + @IsString() + @MaxLength(80) + resourceType!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(120) + resourceId?: string; + + @ApiPropertyOptional({ enum: SUPERADMIN_CASE_STATUSES }) + @IsOptional() + @IsIn(SUPERADMIN_CASE_STATUSES) + status?: (typeof SUPERADMIN_CASE_STATUSES)[number]; + + @ApiPropertyOptional({ enum: SUPERADMIN_CASE_PRIORITIES }) + @IsOptional() + @IsIn(SUPERADMIN_CASE_PRIORITIES) + priority?: (typeof SUPERADMIN_CASE_PRIORITIES)[number]; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(160) + assignedTo?: string; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @Transform(toStringArray) + @IsArray() + @ArrayMaxSize(12) + @IsString({ each: true }) + tags?: string[]; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(1200) + note?: string; +} diff --git a/src/modules/superadmin/dto/superadmin-case-query.dto.ts b/src/modules/superadmin/dto/superadmin-case-query.dto.ts new file mode 100644 index 0000000..f8a6034 --- /dev/null +++ b/src/modules/superadmin/dto/superadmin-case-query.dto.ts @@ -0,0 +1,37 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsIn, IsOptional, IsString } from 'class-validator'; +import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; +import { + SUPERADMIN_CASE_PRIORITIES, + SUPERADMIN_CASE_STATUSES, +} from '../schemas/superadmin-case.schema'; + +export class SuperAdminCaseQueryDto extends PaginationQueryDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + q?: string; + + @ApiPropertyOptional({ enum: SUPERADMIN_CASE_STATUSES }) + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value)) + @IsIn(SUPERADMIN_CASE_STATUSES) + status?: (typeof SUPERADMIN_CASE_STATUSES)[number]; + + @ApiPropertyOptional({ enum: SUPERADMIN_CASE_PRIORITIES }) + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value)) + @IsIn(SUPERADMIN_CASE_PRIORITIES) + priority?: (typeof SUPERADMIN_CASE_PRIORITIES)[number]; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + resourceType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + assignedTo?: string; +} diff --git a/src/modules/superadmin/dto/superadmin-charts-query.dto.ts b/src/modules/superadmin/dto/superadmin-charts-query.dto.ts new file mode 100644 index 0000000..e6ed6e1 --- /dev/null +++ b/src/modules/superadmin/dto/superadmin-charts-query.dto.ts @@ -0,0 +1,11 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional } from 'class-validator'; + +const CHART_RANGES = ['7d', '30d', '90d'] as const; + +export class SuperAdminChartsQueryDto { + @ApiPropertyOptional({ enum: CHART_RANGES, default: '30d' }) + @IsOptional() + @IsIn(CHART_RANGES) + range?: (typeof CHART_RANGES)[number]; +} diff --git a/src/modules/superadmin/dto/superadmin-recent-activity-query.dto.ts b/src/modules/superadmin/dto/superadmin-recent-activity-query.dto.ts new file mode 100644 index 0000000..c4df353 --- /dev/null +++ b/src/modules/superadmin/dto/superadmin-recent-activity-query.dto.ts @@ -0,0 +1,13 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, Max, Min } from 'class-validator'; + +export class SuperAdminRecentActivityQueryDto { + @ApiPropertyOptional({ default: 12, minimum: 1, maximum: 50 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(50) + limit?: number; +} diff --git a/src/modules/superadmin/dto/superadmin-reports-query.dto.ts b/src/modules/superadmin/dto/superadmin-reports-query.dto.ts new file mode 100644 index 0000000..f74dfb9 --- /dev/null +++ b/src/modules/superadmin/dto/superadmin-reports-query.dto.ts @@ -0,0 +1,13 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, Max, Min } from 'class-validator'; + +export class SuperAdminReportsQueryDto { + @ApiPropertyOptional({ default: 8, minimum: 1, maximum: 25 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(25) + limit?: number; +} diff --git a/src/modules/superadmin/dto/update-content-moderation-status.dto.ts b/src/modules/superadmin/dto/update-content-moderation-status.dto.ts new file mode 100644 index 0000000..38e6dd7 --- /dev/null +++ b/src/modules/superadmin/dto/update-content-moderation-status.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator'; +import { ModerationStatus } from '../../../common/enums/moderation-status.enum'; + +export class UpdateContentModerationStatusDto { + @ApiProperty({ enum: ModerationStatus }) + @IsEnum(ModerationStatus) + status!: ModerationStatus; + + @ApiPropertyOptional({ maxLength: 300 }) + @IsOptional() + @IsString() + @MaxLength(300) + reason?: string; +} diff --git a/src/modules/superadmin/dto/update-superadmin-case.dto.ts b/src/modules/superadmin/dto/update-superadmin-case.dto.ts new file mode 100644 index 0000000..22ee012 --- /dev/null +++ b/src/modules/superadmin/dto/update-superadmin-case.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateSuperAdminCaseDto } from './create-superadmin-case.dto'; + +export class UpdateSuperAdminCaseDto extends PartialType(CreateSuperAdminCaseDto) {} diff --git a/src/modules/superadmin/dto/update-superadmin-settings.dto.ts b/src/modules/superadmin/dto/update-superadmin-settings.dto.ts new file mode 100644 index 0000000..ae2f123 --- /dev/null +++ b/src/modules/superadmin/dto/update-superadmin-settings.dto.ts @@ -0,0 +1,61 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { ArrayMaxSize, IsArray, IsBoolean, IsOptional, IsString, IsUrl, MaxLength } from 'class-validator'; +import { toStringArray } from '../../../common/utils/array-transform.util'; +import { toBoolean } from '../../../common/utils/query-transform.util'; + +export class UpdateSuperAdminSettingsDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(120) + siteName?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsUrl({ require_tld: false }) + publicBaseUrl?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsUrl({ require_tld: false }) + dashboardApiBaseUrl?: string; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @Transform(toStringArray) + @IsArray() + @ArrayMaxSize(20) + @IsString({ each: true }) + corsOrigins?: string[]; + + @ApiPropertyOptional() + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + maintenanceMode?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + emailEnabled?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + marketplaceAutoApprove?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + contentAutoHideFlagged?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(1000) + notes?: string; +} diff --git a/src/modules/superadmin/dto/update-superadmin-user-status.dto.ts b/src/modules/superadmin/dto/update-superadmin-user-status.dto.ts new file mode 100644 index 0000000..97af28d --- /dev/null +++ b/src/modules/superadmin/dto/update-superadmin-user-status.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator'; +import { toBoolean } from '../../../common/utils/query-transform.util'; + +export class UpdateSuperAdminUserStatusDto { + @ApiProperty() + @Transform(toBoolean) + @IsBoolean() + isDisabled!: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(300) + reason?: string; +} diff --git a/src/modules/superadmin/schemas/superadmin-case.schema.ts b/src/modules/superadmin/schemas/superadmin-case.schema.ts new file mode 100644 index 0000000..4e1027d --- /dev/null +++ b/src/modules/superadmin/schemas/superadmin-case.schema.ts @@ -0,0 +1,83 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { HydratedDocument } from 'mongoose'; + +export type SuperAdminCaseDocument = HydratedDocument; + +export const SUPERADMIN_CASE_STATUSES = ['open', 'in_review', 'resolved'] as const; +export const SUPERADMIN_CASE_PRIORITIES = ['low', 'normal', 'high', 'critical'] as const; +export type SuperAdminCaseStatus = (typeof SUPERADMIN_CASE_STATUSES)[number]; +export type SuperAdminCasePriority = (typeof SUPERADMIN_CASE_PRIORITIES)[number]; + +@Schema({ timestamps: true, versionKey: false }) +export class SuperAdminCase { + @Prop({ required: true, trim: true, maxlength: 120, index: true }) + title!: string; + + @Prop({ default: '', trim: true, maxlength: 2000 }) + description!: string; + + @Prop({ required: true, trim: true, index: true }) + caseType!: string; + + @Prop({ required: true, trim: true, index: true }) + resourceType!: string; + + @Prop({ default: '', trim: true, index: true }) + resourceId!: string; + + @Prop({ + required: true, + enum: SUPERADMIN_CASE_STATUSES, + default: 'open', + index: true, + }) + status!: SuperAdminCaseStatus; + + @Prop({ + required: true, + enum: SUPERADMIN_CASE_PRIORITIES, + default: 'normal', + index: true, + }) + priority!: SuperAdminCasePriority; + + @Prop({ default: '', trim: true, index: true }) + assignedTo!: string; + + @Prop({ default: '', trim: true }) + createdBy!: string; + + @Prop({ default: '', trim: true }) + updatedBy!: string; + + @Prop({ type: [String], default: [] }) + tags!: string[]; + + @Prop({ default: '', trim: true, maxlength: 1200 }) + resolution!: string; + + @Prop({ + type: [ + { + action: { type: String, required: true, trim: true }, + actor: { type: String, required: true, trim: true }, + note: { type: String, default: '', trim: true, maxlength: 1200 }, + metadata: { type: Object, default: {} }, + createdAt: { type: Date, default: Date.now }, + }, + ], + default: [], + }) + events!: Array<{ + action: string; + actor: string; + note: string; + metadata?: Record; + createdAt: Date; + }>; +} + +export const SuperAdminCaseSchema = SchemaFactory.createForClass(SuperAdminCase); + +SuperAdminCaseSchema.index({ status: 1, priority: 1, updatedAt: -1 }); +SuperAdminCaseSchema.index({ resourceType: 1, resourceId: 1, updatedAt: -1 }); diff --git a/src/modules/superadmin/schemas/superadmin-settings-history.schema.ts b/src/modules/superadmin/schemas/superadmin-settings-history.schema.ts new file mode 100644 index 0000000..932c5ae --- /dev/null +++ b/src/modules/superadmin/schemas/superadmin-settings-history.schema.ts @@ -0,0 +1,30 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { HydratedDocument } from 'mongoose'; + +export type SuperAdminSettingsHistoryDocument = HydratedDocument; + +@Schema({ timestamps: true, versionKey: false }) +export class SuperAdminSettingsHistory { + @Prop({ default: 'default', index: true }) + scope!: string; + + @Prop({ required: true, trim: true, maxlength: 160 }) + updatedBy!: string; + + @Prop({ type: [String], default: [] }) + changedFields!: string[]; + + @Prop({ type: Object, required: true }) + previousSettings!: Record; + + @Prop({ type: Object, required: true }) + nextSettings!: Record; + + @Prop({ default: '', trim: true, maxlength: 1000 }) + note!: string; +} + +export const SuperAdminSettingsHistorySchema = + SchemaFactory.createForClass(SuperAdminSettingsHistory); + +SuperAdminSettingsHistorySchema.index({ scope: 1, createdAt: -1 }); diff --git a/src/modules/superadmin/schemas/superadmin-settings.schema.ts b/src/modules/superadmin/schemas/superadmin-settings.schema.ts new file mode 100644 index 0000000..351ccf5 --- /dev/null +++ b/src/modules/superadmin/schemas/superadmin-settings.schema.ts @@ -0,0 +1,42 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { HydratedDocument } from 'mongoose'; + +export type SuperAdminSettingsDocument = HydratedDocument; + +@Schema({ timestamps: true, versionKey: false }) +export class SuperAdminSettings { + @Prop({ default: 'default', unique: true, index: true }) + scope!: string; + + @Prop({ default: 'Oudelaa SuperAdmin', trim: true, maxlength: 120 }) + siteName!: string; + + @Prop({ default: '', trim: true, maxlength: 200 }) + publicBaseUrl!: string; + + @Prop({ default: '', trim: true, maxlength: 200 }) + dashboardApiBaseUrl!: string; + + @Prop({ type: [String], default: [] }) + corsOrigins!: string[]; + + @Prop({ default: false }) + maintenanceMode!: boolean; + + @Prop({ default: false }) + emailEnabled!: boolean; + + @Prop({ default: false }) + marketplaceAutoApprove!: boolean; + + @Prop({ default: false }) + contentAutoHideFlagged!: boolean; + + @Prop({ default: '', trim: true, maxlength: 1000 }) + notes!: string; + + @Prop({ default: '', trim: true, maxlength: 160 }) + updatedBy!: string; +} + +export const SuperAdminSettingsSchema = SchemaFactory.createForClass(SuperAdminSettings); diff --git a/src/modules/superadmin/superadmin-permissions.ts b/src/modules/superadmin/superadmin-permissions.ts new file mode 100644 index 0000000..4e4a77d --- /dev/null +++ b/src/modules/superadmin/superadmin-permissions.ts @@ -0,0 +1,22 @@ +export const SUPERADMIN_PERMISSIONS = { + OVERVIEW_READ: 'overview.read', + ANALYTICS_READ: 'analytics.read', + USERS_READ: 'users.read', + USERS_MANAGE: 'users.manage', + CONTENT_MODERATE: 'content.moderate', + MARKETPLACE_MANAGE: 'marketplace.manage', + NOTIFICATIONS_READ: 'notifications.read', + AUDIT_READ: 'audit.read', + SETTINGS_READ: 'settings.read', + SETTINGS_WRITE: 'settings.write', + SESSIONS_MANAGE: 'sessions.manage', + OPS_READ: 'ops.read', + CASES_MANAGE: 'cases.manage', +} as const; + +export type SuperAdminPermission = + (typeof SUPERADMIN_PERMISSIONS)[keyof typeof SUPERADMIN_PERMISSIONS]; + +export const DEFAULT_SUPERADMIN_PERMISSIONS: SuperAdminPermission[] = Object.values( + SUPERADMIN_PERMISSIONS, +); diff --git a/src/modules/superadmin/superadmin.controller.ts b/src/modules/superadmin/superadmin.controller.ts new file mode 100644 index 0000000..ef7c84d --- /dev/null +++ b/src/modules/superadmin/superadmin.controller.ts @@ -0,0 +1,184 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { PaginationQueryDto } from '../../common/dto/pagination-query.dto'; +import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +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 { BulkSuperAdminActionDto } from './dto/bulk-superadmin-action.dto'; +import { CreateSuperAdminCaseDto } from './dto/create-superadmin-case.dto'; +import { SuperAdminCaseQueryDto } from './dto/superadmin-case-query.dto'; +import { SuperAdminChartsQueryDto } from './dto/superadmin-charts-query.dto'; +import { SuperAdminRecentActivityQueryDto } from './dto/superadmin-recent-activity-query.dto'; +import { SuperAdminReportsQueryDto } from './dto/superadmin-reports-query.dto'; +import { UpdateContentModerationStatusDto } from './dto/update-content-moderation-status.dto'; +import { UpdateSuperAdminCaseDto } from './dto/update-superadmin-case.dto'; +import { UpdateSuperAdminSettingsDto } from './dto/update-superadmin-settings.dto'; +import { UpdateSuperAdminUserStatusDto } from './dto/update-superadmin-user-status.dto'; +import { SUPERADMIN_PERMISSIONS } from './superadmin-permissions'; +import { SuperAdminService } from './superadmin.service'; + +@ApiTags('SuperAdmin') +@ApiBearerAuth() +@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard) +@Controller('superadmin') +export class SuperAdminController { + constructor(private readonly superAdminService: SuperAdminService) {} + + @Get('session') + getSession(@CurrentUser() user: JwtPayload) { + return this.superAdminService.getSession(user); + } + + @Get('overview') + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.OVERVIEW_READ) + async getOverview() { + return this.superAdminService.getOverview(); + } + + @Get('charts') + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.ANALYTICS_READ) + async getCharts(@Query() query: SuperAdminChartsQueryDto) { + return this.superAdminService.getCharts(query); + } + + @Get('recent-activity') + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.ANALYTICS_READ) + async getRecentActivity(@Query() query: SuperAdminRecentActivityQueryDto) { + return this.superAdminService.getRecentActivity(query); + } + + @Get('reports') + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.ANALYTICS_READ) + async getReports(@Query() query: SuperAdminReportsQueryDto) { + return this.superAdminService.getReports(query); + } + + @Get('ops') + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.OPS_READ) + async getOps() { + return this.superAdminService.getOps(); + } + + @Get('cases') + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CASES_MANAGE) + async getCases(@Query() query: SuperAdminCaseQueryDto) { + return this.superAdminService.getCases(query); + } + + @Post('cases') + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CASES_MANAGE) + async createCase(@CurrentUser() user: JwtPayload, @Body() dto: CreateSuperAdminCaseDto) { + return this.superAdminService.createCase(user.email ?? user.sub, dto); + } + + @Patch('cases/:caseId') + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CASES_MANAGE) + async updateCase( + @CurrentUser() user: JwtPayload, + @Param('caseId') caseId: string, + @Body() dto: UpdateSuperAdminCaseDto, + ) { + return this.superAdminService.updateCase(user.email ?? user.sub, caseId, dto); + } + + @Post('bulk-actions') + @SuperAdminPermissions( + SUPERADMIN_PERMISSIONS.CASES_MANAGE, + SUPERADMIN_PERMISSIONS.CONTENT_MODERATE, + ) + async performBulkAction( + @CurrentUser() user: JwtPayload, + @Body() dto: BulkSuperAdminActionDto, + ) { + return this.superAdminService.performBulkAction(user.email ?? user.sub, dto); + } + + @Get('settings') + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.SETTINGS_READ) + async getSettings(): Promise> { + return this.superAdminService.getSettings(); + } + + @Get('settings/history') + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.SETTINGS_READ) + async getSettingsHistory(@Query() query: PaginationQueryDto) { + return this.superAdminService.getSettingsHistory(query); + } + + @Patch('settings') + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.SETTINGS_WRITE) + async updateSettings( + @CurrentUser() user: JwtPayload, + @Body() dto: UpdateSuperAdminSettingsDto, + ): Promise> { + return this.superAdminService.updateSettings(user.email ?? user.sub, dto); + } + + @Post('settings/history/:historyId/restore') + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.SETTINGS_WRITE) + async restoreSettingsVersion( + @CurrentUser() user: JwtPayload, + @Param('historyId') historyId: string, + ) { + return this.superAdminService.restoreSettingsVersion(user.email ?? user.sub, historyId); + } + + @Patch('posts/:postId/status') + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE) + async updatePostStatus( + @CurrentUser() user: JwtPayload, + @Param('postId') postId: string, + @Body() dto: UpdateContentModerationStatusDto, + ) { + return this.superAdminService.updatePostStatus(user.email ?? user.sub, postId, dto); + } + + @Delete('posts/:postId') + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE) + async deletePost(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) { + return this.superAdminService.deletePost(user.email ?? user.sub, postId); + } + + @Post('posts/:postId/restore') + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE) + async restorePost(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) { + return this.superAdminService.restorePost(user.email ?? user.sub, postId); + } + + @Patch('comments/:commentId/status') + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE) + async updateCommentStatus( + @CurrentUser() user: JwtPayload, + @Param('commentId') commentId: string, + @Body() dto: UpdateContentModerationStatusDto, + ) { + return this.superAdminService.updateCommentStatus(user.email ?? user.sub, commentId, dto); + } + + @Delete('comments/:commentId') + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE) + async deleteComment(@CurrentUser() user: JwtPayload, @Param('commentId') commentId: string) { + return this.superAdminService.deleteComment(user.email ?? user.sub, commentId); + } + + @Post('comments/:commentId/restore') + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE) + async restoreComment( + @CurrentUser() user: JwtPayload, + @Param('commentId') commentId: string, + ) { + return this.superAdminService.restoreComment(user.email ?? user.sub, commentId); + } + + @Patch('users/:userId/status') + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_MANAGE) + async updateUserStatus( + @CurrentUser() user: JwtPayload, + @Param('userId') userId: string, + @Body() dto: UpdateSuperAdminUserStatusDto, + ) { + return this.superAdminService.updateUserStatus(user.email ?? user.sub, userId, dto); + } +} diff --git a/src/modules/superadmin/superadmin.module.ts b/src/modules/superadmin/superadmin.module.ts new file mode 100644 index 0000000..3b7e928 --- /dev/null +++ b/src/modules/superadmin/superadmin.module.ts @@ -0,0 +1,56 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { AuditModule } from '../audit/audit.module'; +import { CommentsModule } from '../comments/comments.module'; +import { Comment, CommentSchema } from '../comments/schemas/comment.schema'; +import { Instrument, InstrumentSchema } from '../marketplace/schemas/instrument.schema'; +import { RepairShop, RepairShopSchema } from '../marketplace/schemas/repair-shop.schema'; +import { Notification, NotificationSchema } from '../notifications/schemas/notification.schema'; +import { PostsModule } from '../posts/posts.module'; +import { Post, PostSchema } from '../posts/schemas/post.schema'; +import { UsersModule } from '../users/users.module'; +import { User, UserSchema } from '../users/schemas/user.schema'; +import { AuditLog, AuditLogSchema } from '../audit/schemas/audit-log.schema'; +import { SuperAdminController } from './superadmin.controller'; +import { SuperAdminService } from './superadmin.service'; +import { + SuperAdminSettings, + SuperAdminSettingsSchema, +} from './schemas/superadmin-settings.schema'; +import { + SuperAdminSettingsHistory, + SuperAdminSettingsHistorySchema, +} from './schemas/superadmin-settings-history.schema'; +import { SuperAdminCase, SuperAdminCaseSchema } from './schemas/superadmin-case.schema'; +import { OutboxEvent, OutboxEventSchema } from '../outbox/schemas/outbox-event.schema'; +import { + SuperAdminRefreshToken, + SuperAdminRefreshTokenSchema, +} from '../auth/schemas/super-admin-refresh-token.schema'; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: User.name, schema: UserSchema }, + { name: Post.name, schema: PostSchema }, + { name: Comment.name, schema: CommentSchema }, + { name: Instrument.name, schema: InstrumentSchema }, + { name: RepairShop.name, schema: RepairShopSchema }, + { name: Notification.name, schema: NotificationSchema }, + { name: AuditLog.name, schema: AuditLogSchema }, + { name: SuperAdminSettings.name, schema: SuperAdminSettingsSchema }, + { name: SuperAdminSettingsHistory.name, schema: SuperAdminSettingsHistorySchema }, + { name: SuperAdminCase.name, schema: SuperAdminCaseSchema }, + { name: OutboxEvent.name, schema: OutboxEventSchema }, + { name: SuperAdminRefreshToken.name, schema: SuperAdminRefreshTokenSchema }, + ]), + AuditModule, + PostsModule, + CommentsModule, + UsersModule, + ], + controllers: [SuperAdminController], + providers: [SuperAdminService], + exports: [SuperAdminService], +}) +export class SuperAdminModule {} diff --git a/src/modules/superadmin/superadmin.service.ts b/src/modules/superadmin/superadmin.service.ts new file mode 100644 index 0000000..d849b02 --- /dev/null +++ b/src/modules/superadmin/superadmin.service.ts @@ -0,0 +1,1657 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectConnection, InjectModel } from '@nestjs/mongoose'; +import { Connection, Model, Types } from 'mongoose'; +import { buildPaginatedResponse } from '../../common/utils/pagination.util'; +import { resolveMongoSortDirection } from '../../common/utils/sort.util'; +import { ModerationStatus } from '../../common/enums/moderation-status.enum'; +import { PaginationQueryDto } from '../../common/dto/pagination-query.dto'; +import { FeedVersionService } from '../../infrastructure/cache/feed-version.service'; +import { RedisService } from '../../infrastructure/redis/redis.service'; +import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service'; +import { AuditService } from '../audit/audit.service'; +import { AuditLog, AuditLogDocument } from '../audit/schemas/audit-log.schema'; +import { CommentsService } from '../comments/comments.service'; +import { Comment, CommentDocument } from '../comments/schemas/comment.schema'; +import { SuperAdminRefreshToken, SuperAdminRefreshTokenDocument } from '../auth/schemas/super-admin-refresh-token.schema'; +import { Instrument, InstrumentDocument } from '../marketplace/schemas/instrument.schema'; +import { RepairShop, RepairShopDocument } from '../marketplace/schemas/repair-shop.schema'; +import { Notification, NotificationDocument } from '../notifications/schemas/notification.schema'; +import { OutboxEvent, OutboxEventDocument } from '../outbox/schemas/outbox-event.schema'; +import { PostsService } from '../posts/posts.service'; +import { Post, PostDocument } from '../posts/schemas/post.schema'; +import { UsersService } from '../users/users.service'; +import { User, UserDocument } from '../users/schemas/user.schema'; +import { BulkSuperAdminActionDto } from './dto/bulk-superadmin-action.dto'; +import { CreateSuperAdminCaseDto } from './dto/create-superadmin-case.dto'; +import { SuperAdminCaseQueryDto } from './dto/superadmin-case-query.dto'; +import { SuperAdminChartsQueryDto } from './dto/superadmin-charts-query.dto'; +import { SuperAdminRecentActivityQueryDto } from './dto/superadmin-recent-activity-query.dto'; +import { SuperAdminReportsQueryDto } from './dto/superadmin-reports-query.dto'; +import { UpdateContentModerationStatusDto } from './dto/update-content-moderation-status.dto'; +import { UpdateSuperAdminCaseDto } from './dto/update-superadmin-case.dto'; +import { UpdateSuperAdminSettingsDto } from './dto/update-superadmin-settings.dto'; +import { UpdateSuperAdminUserStatusDto } from './dto/update-superadmin-user-status.dto'; +import { DEFAULT_SUPERADMIN_PERMISSIONS } from './superadmin-permissions'; +import { SuperAdminCase, SuperAdminCaseDocument } from './schemas/superadmin-case.schema'; +import { + SuperAdminSettings, + SuperAdminSettingsDocument, +} from './schemas/superadmin-settings.schema'; +import { + SuperAdminSettingsHistory, + SuperAdminSettingsHistoryDocument, +} from './schemas/superadmin-settings-history.schema'; + +type CaseStatus = 'open' | 'in_review' | 'resolved'; +type CasePriority = 'low' | 'normal' | 'high' | 'critical'; + +@Injectable() +export class SuperAdminService { + private readonly defaultSettingsScope = 'default'; + + constructor( + @InjectConnection() private readonly connection: Connection, + @InjectModel(User.name) private readonly userModel: Model, + @InjectModel(Post.name) private readonly postModel: Model, + @InjectModel(Comment.name) private readonly commentModel: Model, + @InjectModel(Instrument.name) private readonly instrumentModel: Model, + @InjectModel(RepairShop.name) private readonly repairShopModel: Model, + @InjectModel(Notification.name) + private readonly notificationModel: Model, + @InjectModel(AuditLog.name) private readonly auditLogModel: Model, + @InjectModel(SuperAdminSettings.name) + private readonly settingsModel: Model, + @InjectModel(SuperAdminSettingsHistory.name) + private readonly settingsHistoryModel: Model, + @InjectModel(SuperAdminCase.name) + private readonly superAdminCaseModel: Model, + @InjectModel(OutboxEvent.name) + private readonly outboxEventModel: Model, + @InjectModel(SuperAdminRefreshToken.name) + private readonly superAdminRefreshTokenModel: Model, + private readonly configService: ConfigService, + private readonly auditService: AuditService, + private readonly postsService: PostsService, + private readonly commentsService: CommentsService, + private readonly usersService: UsersService, + private readonly redisService: RedisService, + private readonly storageService: ManagedStorageService, + private readonly feedVersionService: FeedVersionService, + ) {} + + async getOverview(): Promise> { + const musicalInstrumentFilter = this.buildMusicalInstrumentFilter(); + + const [ + usersCount, + adminsCount, + disabledUsersCount, + postsCount, + hiddenPostsCount, + flaggedPostsCount, + commentsCount, + hiddenCommentsCount, + flaggedCommentsCount, + allListingsCount, + musicalInstrumentsCount, + inactiveListingsCount, + repairShopsCount, + inactiveRepairShopsCount, + unreadNotificationsCount, + openCasesCount, + inReviewCasesCount, + failedOutboxEventsCount, + pendingOutboxEventsCount, + activeSuperAdminSessionsCount, + ] = await Promise.all([ + this.userModel.countDocuments({ role: 'user' }).exec(), + this.userModel.countDocuments({ role: 'admin' }).exec(), + this.userModel.countDocuments({ isDisabled: true }).exec(), + this.postModel.countDocuments({ isDeleted: { $ne: true } }).exec(), + this.postModel + .countDocuments({ + isDeleted: { $ne: true }, + moderationStatus: ModerationStatus.HIDDEN, + }) + .exec(), + this.postModel + .countDocuments({ + isDeleted: { $ne: true }, + moderationStatus: ModerationStatus.FLAGGED, + }) + .exec(), + this.commentModel.countDocuments({ isDeleted: { $ne: true } }).exec(), + this.commentModel + .countDocuments({ + isDeleted: { $ne: true }, + moderationStatus: ModerationStatus.HIDDEN, + }) + .exec(), + this.commentModel + .countDocuments({ + isDeleted: { $ne: true }, + moderationStatus: ModerationStatus.FLAGGED, + }) + .exec(), + this.instrumentModel.countDocuments({}).exec(), + this.instrumentModel.countDocuments(musicalInstrumentFilter).exec(), + this.instrumentModel.countDocuments({ isActive: false }).exec(), + this.repairShopModel.countDocuments({}).exec(), + this.repairShopModel.countDocuments({ isActive: false }).exec(), + this.notificationModel.countDocuments({ read: false }).exec(), + this.superAdminCaseModel.countDocuments({ status: 'open' }).exec(), + this.superAdminCaseModel.countDocuments({ status: 'in_review' }).exec(), + this.outboxEventModel.countDocuments({ status: 'failed' }).exec(), + this.outboxEventModel.countDocuments({ status: 'pending' }).exec(), + this.superAdminRefreshTokenModel + .countDocuments({ revoked: false, expiresAt: { $gt: new Date() } }) + .exec(), + ]); + + return { + metrics: { + usersCount, + adminsCount, + disabledUsersCount, + postsCount, + hiddenPostsCount, + flaggedPostsCount, + commentsCount, + hiddenCommentsCount, + flaggedCommentsCount, + marketplaceListingsCount: allListingsCount, + musicalInstrumentsCount, + generalMarketplaceListingsCount: Math.max(0, allListingsCount - musicalInstrumentsCount), + inactiveListingsCount, + repairShopsCount, + inactiveRepairShopsCount, + unreadNotificationsCount, + openCasesCount, + inReviewCasesCount, + failedOutboxEventsCount, + pendingOutboxEventsCount, + activeSuperAdminSessionsCount, + }, + }; + } + + async getCharts(query: SuperAdminChartsQueryDto): Promise> { + const range = query.range ?? '30d'; + const days = this.resolveRangeDays(range); + const [users, posts, comments, listings, repairShops, notifications, activeSessions, openCases, failedOutbox, pendingOutbox] = + await Promise.all([ + this.buildDailySeries(this.userModel, {}, days), + this.buildDailySeries(this.postModel, { isDeleted: { $ne: true } }, days), + this.buildDailySeries(this.commentModel, { isDeleted: { $ne: true } }, days), + this.buildDailySeries(this.instrumentModel, {}, days), + this.buildDailySeries(this.repairShopModel, {}, days), + this.buildDailySeries(this.notificationModel, {}, days), + this.superAdminRefreshTokenModel + .countDocuments({ revoked: false, expiresAt: { $gt: new Date() } }) + .exec(), + this.superAdminCaseModel.countDocuments({ status: { $in: ['open', 'in_review'] } }).exec(), + this.outboxEventModel.countDocuments({ status: 'failed' }).exec(), + this.outboxEventModel.countDocuments({ status: 'pending' }).exec(), + ]); + + return { + range, + days, + series: { + users, + posts, + comments, + listings, + repairShops, + notifications, + }, + breakdowns: { + userRoles: await this.buildUserRoleBreakdown(), + postTypes: await this.buildPostTypeBreakdown(), + listingCategories: await this.buildListingCategoryBreakdown(), + moderation: [ + { + label: 'flagged_posts', + value: await this.postModel + .countDocuments({ isDeleted: { $ne: true }, moderationStatus: ModerationStatus.FLAGGED }) + .exec(), + }, + { + label: 'hidden_posts', + value: await this.postModel + .countDocuments({ isDeleted: { $ne: true }, moderationStatus: ModerationStatus.HIDDEN }) + .exec(), + }, + { + label: 'flagged_comments', + value: await this.commentModel + .countDocuments({ isDeleted: { $ne: true }, moderationStatus: ModerationStatus.FLAGGED }) + .exec(), + }, + { + label: 'hidden_comments', + value: await this.commentModel + .countDocuments({ isDeleted: { $ne: true }, moderationStatus: ModerationStatus.HIDDEN }) + .exec(), + }, + ], + }, + kpis: { + activeSuperAdminSessionsCount: activeSessions, + moderationQueueCount: openCases, + failedOutboxEventsCount: failedOutbox, + pendingOutboxEventsCount: pendingOutbox, + }, + }; + } + + async getRecentActivity( + query: SuperAdminRecentActivityQueryDto, + ): Promise<{ items: Array> }> { + const limit = query.limit ?? 12; + const batchLimit = Math.max(limit, 8); + + const [users, posts, comments, listings, repairShops, auditLogs, cases] = await Promise.all([ + this.userModel + .find({}) + .sort({ createdAt: -1 }) + .limit(batchLimit) + .select('name username email role isDisabled createdAt') + .lean() + .exec(), + this.postModel + .find({ isDeleted: { $ne: true } }) + .sort({ createdAt: -1 }) + .limit(batchLimit) + .populate({ path: 'authorId', select: 'name username' }) + .select('content postType moderationStatus createdAt authorId') + .lean() + .exec(), + this.commentModel + .find({ isDeleted: { $ne: true } }) + .sort({ createdAt: -1 }) + .limit(batchLimit) + .populate({ path: 'authorId', select: 'name username' }) + .select('content moderationStatus createdAt authorId') + .lean() + .exec(), + this.instrumentModel + .find({}) + .sort({ createdAt: -1 }) + .limit(batchLimit) + .populate({ path: 'ownerAdminId', select: 'name username shopName' }) + .select('title listingCategory isActive createdAt ownerAdminId') + .lean() + .exec(), + this.repairShopModel + .find({}) + .sort({ createdAt: -1 }) + .limit(batchLimit) + .populate({ path: 'ownerAdminId', select: 'name username shopName' }) + .select('name isActive location createdAt ownerAdminId') + .lean() + .exec(), + this.auditLogModel + .find({}) + .sort({ createdAt: -1 }) + .limit(batchLimit) + .select('actorType actorIdentifier action targetType targetId createdAt') + .lean() + .exec(), + this.superAdminCaseModel + .find({}) + .sort({ updatedAt: -1, createdAt: -1 }) + .limit(batchLimit) + .lean() + .exec(), + ]); + + const items = [ + ...users.map((user) => ({ + type: 'user', + action: 'user_created', + title: user.name || user.username || user.email, + subtitle: `${user.email} - ${user.role}`, + status: user.isDisabled ? 'disabled' : 'active', + deepLink: '/users', + createdAt: (user as any).createdAt, + })), + ...posts.map((post: any) => ({ + type: 'post', + action: 'post_created', + title: (post.content || '').slice(0, 80) || `${post.postType ?? 'post'} post`, + subtitle: post.authorId?.name || post.authorId?.username || 'unknown author', + status: post.moderationStatus ?? ModerationStatus.ACTIVE, + deepLink: '/content', + createdAt: post.createdAt, + })), + ...comments.map((comment: any) => ({ + type: 'comment', + action: 'comment_created', + title: (comment.content || '').slice(0, 80) || 'comment', + subtitle: comment.authorId?.name || comment.authorId?.username || 'unknown author', + status: comment.moderationStatus ?? ModerationStatus.ACTIVE, + deepLink: '/content', + createdAt: comment.createdAt, + })), + ...listings.map((listing: any) => ({ + type: 'listing', + action: 'listing_created', + title: listing.title, + subtitle: `${listing.ownerAdminId?.shopName || listing.ownerAdminId?.name || 'shop'} - ${ + listing.listingCategory || 'listing' + }`, + status: listing.isActive ? 'active' : 'inactive', + deepLink: '/marketplace', + createdAt: listing.createdAt, + })), + ...repairShops.map((shop: any) => ({ + type: 'repair_shop', + action: 'repair_shop_created', + title: shop.name, + subtitle: shop.location || shop.ownerAdminId?.shopName || shop.ownerAdminId?.name || 'repair shop', + status: shop.isActive ? 'active' : 'inactive', + deepLink: '/marketplace', + createdAt: shop.createdAt, + })), + ...cases.map((item) => ({ + type: 'case', + action: 'case_updated', + title: item.title, + subtitle: `${item.resourceType} - ${item.priority}`, + status: item.status, + deepLink: '/orders', + createdAt: (item as any).updatedAt ?? (item as any).createdAt, + })), + ...auditLogs.map((audit) => ({ + type: 'audit', + action: audit.action, + title: audit.action, + subtitle: `${audit.actorIdentifier || audit.actorType} - ${audit.targetType}`, + status: audit.actorType, + deepLink: '/security', + createdAt: (audit as any).createdAt, + })), + ] + .filter((item) => item.createdAt) + .sort( + (a, b) => + new Date(b.createdAt as string | Date).getTime() - + new Date(a.createdAt as string | Date).getTime(), + ) + .slice(0, limit); + + return { items }; + } + + async getReports(query: SuperAdminReportsQueryDto): Promise> { + const limit = query.limit ?? 8; + + const [ + flaggedPosts, + flaggedComments, + disabledUsers, + inactiveListings, + inactiveRepairShops, + flaggedPostsCount, + flaggedCommentsCount, + disabledUsersCount, + inactiveListingsCount, + inactiveRepairShopsCount, + openCasesCount, + failedOutboxEventsCount, + pendingOutboxEventsCount, + ] = await Promise.all([ + this.postModel + .find({ + isDeleted: { $ne: true }, + moderationStatus: ModerationStatus.FLAGGED, + }) + .sort({ updatedAt: -1, createdAt: -1 }) + .limit(limit) + .populate({ path: 'authorId', select: 'name username avatar isVerified stageName' }) + .lean() + .exec(), + this.commentModel + .find({ + isDeleted: { $ne: true }, + moderationStatus: ModerationStatus.FLAGGED, + }) + .sort({ updatedAt: -1, createdAt: -1 }) + .limit(limit) + .populate({ path: 'authorId', select: 'name username avatar stageName isVerified' }) + .lean() + .exec(), + this.userModel + .find({ isDisabled: true }) + .sort({ disabledAt: -1, updatedAt: -1, createdAt: -1 }) + .limit(limit) + .lean() + .exec(), + this.instrumentModel + .find({ isActive: false }) + .sort({ updatedAt: -1, createdAt: -1 }) + .limit(limit) + .populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' }) + .lean() + .exec(), + this.repairShopModel + .find({ isActive: false }) + .sort({ updatedAt: -1, createdAt: -1 }) + .limit(limit) + .populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' }) + .lean() + .exec(), + this.postModel + .countDocuments({ + isDeleted: { $ne: true }, + moderationStatus: ModerationStatus.FLAGGED, + }) + .exec(), + this.commentModel + .countDocuments({ + isDeleted: { $ne: true }, + moderationStatus: ModerationStatus.FLAGGED, + }) + .exec(), + this.userModel.countDocuments({ isDisabled: true }).exec(), + this.instrumentModel.countDocuments({ isActive: false }).exec(), + this.repairShopModel.countDocuments({ isActive: false }).exec(), + this.superAdminCaseModel.countDocuments({ status: { $in: ['open', 'in_review'] } }).exec(), + this.outboxEventModel.countDocuments({ status: 'failed' }).exec(), + this.outboxEventModel.countDocuments({ status: 'pending' }).exec(), + ]); + + return { + summary: { + flaggedPostsCount, + flaggedCommentsCount, + disabledUsersCount, + inactiveListingsCount, + inactiveRepairShopsCount, + openCasesCount, + failedOutboxEventsCount, + pendingOutboxEventsCount, + }, + flaggedPosts, + flaggedComments, + disabledUsers, + inactiveListings, + inactiveRepairShops, + }; + } + + getSession(currentUser: { email?: string; permissions?: string[] }): Record { + return { + superAdmin: { + email: currentUser.email ?? this.configService.get('superAdmin.email', { infer: true }), + }, + permissions: currentUser.permissions ?? DEFAULT_SUPERADMIN_PERMISSIONS, + sessionStrategy: 'httpOnly_cookies', + }; + } + + async getCases(query: SuperAdminCaseQueryDto): Promise> { + const page = query.page ?? 1; + const limit = query.limit ?? 20; + const skip = (page - 1) * limit; + const filter: Record = {}; + + if (query.q?.trim()) { + filter.$or = [ + { title: { $regex: query.q.trim(), $options: 'i' } }, + { description: { $regex: query.q.trim(), $options: 'i' } }, + { resourceId: { $regex: query.q.trim(), $options: 'i' } }, + { assignedTo: { $regex: query.q.trim(), $options: 'i' } }, + { resourceType: { $regex: query.q.trim(), $options: 'i' } }, + ]; + } + + if (query.status) { + filter.status = query.status; + } + if (query.priority) { + filter.priority = query.priority; + } + if (query.resourceType?.trim()) { + filter.resourceType = query.resourceType.trim(); + } + if (query.assignedTo?.trim()) { + filter.assignedTo = query.assignedTo.trim(); + } + + const sort = { updatedAt: resolveMongoSortDirection(query.sortOrder) } as Record; + const [items, total] = await Promise.all([ + this.superAdminCaseModel.find(filter).sort(sort).skip(skip).limit(limit).lean().exec(), + this.superAdminCaseModel.countDocuments(filter).exec(), + ]); + + return buildPaginatedResponse(items, { + page, + limit, + total, + offset: skip, + }); + } + + async createCase( + superAdminIdentifier: string, + dto: CreateSuperAdminCaseDto, + ): Promise { + const item = await this.superAdminCaseModel.create({ + title: dto.title.trim(), + description: dto.description?.trim() ?? '', + caseType: dto.caseType.trim(), + resourceType: dto.resourceType.trim(), + resourceId: dto.resourceId?.trim() ?? '', + status: dto.status ?? 'open', + priority: dto.priority ?? 'normal', + assignedTo: dto.assignedTo?.trim() ?? '', + createdBy: superAdminIdentifier, + updatedBy: superAdminIdentifier, + tags: dto.tags ?? [], + events: [ + { + action: 'case_created', + actor: superAdminIdentifier, + note: dto.note?.trim() ?? '', + metadata: {}, + createdAt: new Date(), + }, + ], + }); + + await this.auditService.logSuperAdminAction( + superAdminIdentifier, + 'superadmin_case_create', + 'superadmin_case', + item.id, + { + resourceType: item.resourceType, + resourceId: item.resourceId, + }, + ); + + return item; + } + + async updateCase( + superAdminIdentifier: string, + caseId: string, + dto: UpdateSuperAdminCaseDto, + ): Promise { + const current = await this.superAdminCaseModel.findById(caseId).exec(); + if (!current) { + throw new NotFoundException('Case not found'); + } + + const payload: Record = { + updatedBy: superAdminIdentifier, + }; + + if (typeof dto.title === 'string') payload.title = dto.title.trim(); + if (typeof dto.description === 'string') payload.description = dto.description.trim(); + if (typeof dto.caseType === 'string') payload.caseType = dto.caseType.trim(); + if (typeof dto.resourceType === 'string') payload.resourceType = dto.resourceType.trim(); + if (typeof dto.resourceId === 'string') payload.resourceId = dto.resourceId.trim(); + if (typeof dto.status === 'string') payload.status = dto.status; + if (typeof dto.priority === 'string') payload.priority = dto.priority; + if (typeof dto.assignedTo === 'string') payload.assignedTo = dto.assignedTo.trim(); + if (Array.isArray(dto.tags)) payload.tags = dto.tags; + if (typeof dto.note === 'string') payload.resolution = dto.note.trim(); + + const updated = await this.superAdminCaseModel + .findByIdAndUpdate( + caseId, + { + ...payload, + $push: { + events: { + action: 'case_updated', + actor: superAdminIdentifier, + note: dto.note?.trim() ?? '', + metadata: { + fields: Object.keys(dto), + }, + createdAt: new Date(), + }, + }, + }, + { new: true }, + ) + .exec(); + + if (!updated) { + throw new NotFoundException('Case not found'); + } + + await this.auditService.logSuperAdminAction( + superAdminIdentifier, + 'superadmin_case_update', + 'superadmin_case', + caseId, + { + fields: Object.keys(dto), + previousStatus: current.status, + nextStatus: updated.status, + }, + ); + + return updated; + } + + async performBulkAction( + superAdminIdentifier: string, + dto: BulkSuperAdminActionDto, + ): Promise> { + const items: Array> = []; + + for (const targetId of dto.targetIds) { + try { + await this.performSingleBulkAction(superAdminIdentifier, dto.resourceType, targetId, dto); + await this.applyBulkCaseMetadata(superAdminIdentifier, dto.resourceType, targetId, dto); + items.push({ id: targetId, success: true }); + } catch (error) { + items.push({ + id: targetId, + success: false, + error: error instanceof Error ? error.message : 'unknown error', + }); + } + } + + const succeeded = items.filter((item) => item.success).length; + const failed = items.length - succeeded; + + await this.auditService.logSuperAdminAction( + superAdminIdentifier, + 'superadmin_bulk_action', + dto.resourceType, + undefined, + { + action: dto.action, + targetIds: dto.targetIds, + succeeded, + failed, + }, + ); + + return { + resourceType: dto.resourceType, + action: dto.action, + total: dto.targetIds.length, + succeeded, + failed, + items, + }; + } + + async getSettings(): Promise> { + const stored = await this.settingsModel.findOne({ scope: this.defaultSettingsScope }).lean().exec(); + const latestHistory = await this.settingsHistoryModel + .findOne({ scope: this.defaultSettingsScope }) + .sort({ createdAt: -1 }) + .lean() + .exec(); + + return { + settings: { + ...this.buildDefaultSettings(), + ...(stored ?? {}), + }, + historySummary: latestHistory + ? { + lastUpdatedBy: latestHistory.updatedBy, + lastChangedFields: latestHistory.changedFields, + lastUpdatedAt: (latestHistory as any).createdAt, + } + : null, + runtime: { + nodeEnv: this.configService.get('nodeEnv', { infer: true }), + host: this.configService.get('host', { infer: true }), + port: this.configService.get('port', { infer: true }), + globalPrefix: this.configService.get('globalPrefix', { infer: true }), + publicBaseUrl: this.configService.get('publicBaseUrl', { infer: true }), + responseEnvelopeEnabled: this.configService.get('responseEnvelopeEnabled', { + infer: true, + }), + emailEnabled: this.configService.get('email.enabled', { infer: true }), + corsOrigins: this.configService.get('cors.origins', { infer: true }) ?? [], + swaggerPath: this.configService.get('swagger.path', { infer: true }), + storageProvider: this.configService.get('storage.provider', { infer: true }), + storageBasePath: this.configService.get('storage.basePath', { infer: true }), + queueEnabled: this.configService.get('queue.enabled', { infer: true }) ?? false, + redisEnabled: this.configService.get('redis.enabled', { infer: true }) ?? false, + sessionStrategy: 'httpOnly_cookies', + }, + }; + } + + async getSettingsHistory(query: PaginationQueryDto) { + const page = query.page ?? 1; + const limit = query.limit ?? 20; + const skip = (page - 1) * limit; + const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record; + const [items, total] = await Promise.all([ + this.settingsHistoryModel + .find({ scope: this.defaultSettingsScope }) + .sort(sort) + .skip(skip) + .limit(limit) + .lean() + .exec(), + this.settingsHistoryModel.countDocuments({ scope: this.defaultSettingsScope }).exec(), + ]); + + return buildPaginatedResponse(items, { + page, + limit, + total, + offset: skip, + }); + } + + async restoreSettingsVersion( + superAdminIdentifier: string, + historyId: string, + ): Promise> { + const historyEntry = await this.settingsHistoryModel.findById(historyId).lean().exec(); + if (!historyEntry) { + throw new NotFoundException('Settings history entry not found'); + } + + const previousSettings = + (await this.settingsModel.findOne({ scope: this.defaultSettingsScope }).lean().exec()) ?? + this.buildDefaultSettings(); + const nextSettings = { + ...(historyEntry.nextSettings ?? {}), + scope: this.defaultSettingsScope, + updatedBy: superAdminIdentifier, + }; + + await this.settingsModel + .findOneAndUpdate( + { scope: this.defaultSettingsScope }, + nextSettings, + { + new: true, + upsert: true, + setDefaultsOnInsert: true, + }, + ) + .exec(); + + await this.settingsHistoryModel.create({ + scope: this.defaultSettingsScope, + updatedBy: superAdminIdentifier, + changedFields: Object.keys(historyEntry.nextSettings ?? {}), + previousSettings, + nextSettings, + note: `Restored from history ${historyId}`, + }); + + await this.auditService.logSuperAdminAction( + superAdminIdentifier, + 'superadmin_settings_restore', + 'superadmin_settings_history', + historyId, + ); + + return this.getSettings(); + } + + async getOps(): Promise> { + const mongoStatuses = ['disconnected', 'connected', 'connecting', 'disconnecting']; + let redisStatus = 'disabled'; + + if (this.redisService.isEnabled()) { + try { + const client = this.redisService.getClient(); + redisStatus = (await client?.ping()) === 'PONG' ? 'connected' : 'degraded'; + } catch { + redisStatus = 'unreachable'; + } + } + + const [openCasesCount, failedOutboxEventsCount, pendingOutboxEventsCount, activeSuperAdminSessionsCount] = + await Promise.all([ + this.superAdminCaseModel.countDocuments({ status: { $in: ['open', 'in_review'] } }).exec(), + this.outboxEventModel.countDocuments({ status: 'failed' }).exec(), + this.outboxEventModel.countDocuments({ status: 'pending' }).exec(), + this.superAdminRefreshTokenModel + .countDocuments({ revoked: false, expiresAt: { $gt: new Date() } }) + .exec(), + ]); + + return { + services: { + mongodb: { + status: mongoStatuses[this.connection.readyState] ?? 'unknown', + database: this.connection.name, + }, + redis: { + enabled: this.redisService.isEnabled(), + status: redisStatus, + }, + queue: { + enabled: this.configService.get('queue.enabled', { infer: true }) ?? false, + name: this.configService.get('queue.name', { infer: true }) ?? 'app-jobs', + }, + storage: { + provider: this.configService.get('storage.provider', { infer: true }) ?? 'local', + basePath: this.configService.get('storage.basePath', { infer: true }) ?? 'uploads', + }, + email: { + enabled: this.configService.get('email.enabled', { infer: true }) ?? false, + }, + websocket: { + redisAdapterEnabled: + this.configService.get('redis.socketAdapterEnabled', { infer: true }) ?? false, + }, + }, + queues: { + outbox: { + pending: pendingOutboxEventsCount, + failed: failedOutboxEventsCount, + }, + }, + workload: { + openCasesCount, + activeSuperAdminSessionsCount, + }, + }; + } + + async updateSettings( + superAdminIdentifier: string, + dto: UpdateSuperAdminSettingsDto, + ): Promise> { + const previousSettings = + (await this.settingsModel.findOne({ scope: this.defaultSettingsScope }).lean().exec()) ?? + this.buildDefaultSettings(); + const changedFields = Object.keys(dto); + + await this.settingsModel + .findOneAndUpdate( + { scope: this.defaultSettingsScope }, + { + ...dto, + scope: this.defaultSettingsScope, + updatedBy: superAdminIdentifier, + }, + { + new: true, + upsert: true, + setDefaultsOnInsert: true, + }, + ) + .exec(); + + const nextSettings = + (await this.settingsModel.findOne({ scope: this.defaultSettingsScope }).lean().exec()) ?? + this.buildDefaultSettings(); + + await this.settingsHistoryModel.create({ + scope: this.defaultSettingsScope, + updatedBy: superAdminIdentifier, + changedFields, + previousSettings, + nextSettings, + note: `Updated fields: ${changedFields.join(', ')}`, + }); + + await this.auditService.logSuperAdminAction( + superAdminIdentifier, + 'superadmin_settings_update', + 'superadmin_settings', + this.defaultSettingsScope, + { fields: changedFields }, + ); + + return this.getSettings(); + } + + async updatePostStatus( + superAdminIdentifier: string, + postId: string, + dto: UpdateContentModerationStatusDto, + ) { + const post = await this.postsService.updateModerationStatusBySuperAdmin( + superAdminIdentifier, + postId, + dto, + ); + await this.recordResourceCase({ + actor: superAdminIdentifier, + caseType: 'content_moderation', + resourceType: 'post', + resourceId: postId, + title: this.buildContentCaseTitle('Post', (post as any)?.content ?? postId), + description: dto.reason?.trim() ?? '', + action: `post_status_${dto.status}`, + note: dto.reason?.trim() ?? '', + status: dto.status === ModerationStatus.ACTIVE ? 'resolved' : 'in_review', + priority: dto.status === ModerationStatus.FLAGGED ? 'high' : 'normal', + }); + return post; + } + + async deletePost(superAdminIdentifier: string, postId: string) { + const existing = await this.postModel.findById(postId).select('content isDeleted').lean().exec(); + if (!existing || (existing as any).isDeleted) { + throw new NotFoundException('Post not found'); + } + + await this.postsService.removeBySuperAdmin(superAdminIdentifier, postId); + await this.recordResourceCase({ + actor: superAdminIdentifier, + caseType: 'content_moderation', + resourceType: 'post', + resourceId: postId, + title: this.buildContentCaseTitle('Post', String((existing as any).content ?? postId)), + description: 'Deleted by superadmin', + action: 'post_deleted', + note: 'Deleted by superadmin', + status: 'resolved', + priority: 'high', + }); + + return { success: true, postId }; + } + + async restorePost(superAdminIdentifier: string, postId: string) { + const post = await this.postModel + .findOne({ _id: new Types.ObjectId(postId), isDeleted: true }) + .select('+deletedAt +deletedBy') + .exec(); + if (!post) { + throw new NotFoundException('Deleted post not found'); + } + + post.isDeleted = false; + post.deletedAt = null; + post.deletedBy = null; + await post.save(); + await this.userModel.findByIdAndUpdate(post.authorId, { $inc: { postsCount: 1 } }).exec(); + await this.feedVersionService.bumpGlobalVersion(); + + await this.auditService.logSuperAdminAction( + superAdminIdentifier, + 'post_restore', + 'post', + postId, + ); + + await this.recordResourceCase({ + actor: superAdminIdentifier, + caseType: 'content_moderation', + resourceType: 'post', + resourceId: postId, + title: this.buildContentCaseTitle('Post', post.content ?? postId), + description: 'Restored by superadmin', + action: 'post_restored', + note: 'Restored by superadmin', + status: 'resolved', + priority: 'normal', + }); + + const restored = await this.postModel + .findById(postId) + .populate({ path: 'authorId', select: 'name username avatar isVerified stageName' }) + .populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' }) + .exec(); + return restored; + } + + async updateCommentStatus( + superAdminIdentifier: string, + commentId: string, + dto: UpdateContentModerationStatusDto, + ) { + const comment = await this.commentsService.updateModerationStatusBySuperAdmin( + superAdminIdentifier, + commentId, + dto, + ); + await this.recordResourceCase({ + actor: superAdminIdentifier, + caseType: 'content_moderation', + resourceType: 'comment', + resourceId: commentId, + title: this.buildContentCaseTitle('Comment', (comment as any)?.content ?? commentId), + description: dto.reason?.trim() ?? '', + action: `comment_status_${dto.status}`, + note: dto.reason?.trim() ?? '', + status: dto.status === ModerationStatus.ACTIVE ? 'resolved' : 'in_review', + priority: dto.status === ModerationStatus.FLAGGED ? 'high' : 'normal', + }); + return comment; + } + + async deleteComment(superAdminIdentifier: string, commentId: string) { + const existing = await this.commentModel.findById(commentId).select('content isDeleted').lean().exec(); + if (!existing || (existing as any).isDeleted) { + throw new NotFoundException('Comment not found'); + } + + await this.commentsService.removeBySuperAdmin(superAdminIdentifier, commentId); + await this.recordResourceCase({ + actor: superAdminIdentifier, + caseType: 'content_moderation', + resourceType: 'comment', + resourceId: commentId, + title: this.buildContentCaseTitle('Comment', String((existing as any).content ?? commentId)), + description: 'Deleted by superadmin', + action: 'comment_deleted', + note: 'Deleted by superadmin', + status: 'resolved', + priority: 'high', + }); + + return { success: true, commentId }; + } + + async restoreComment(superAdminIdentifier: string, commentId: string) { + const comment = await this.commentModel + .findOne({ _id: new Types.ObjectId(commentId), isDeleted: true }) + .select('+deletedAt +deletedBy') + .exec(); + if (!comment) { + throw new NotFoundException('Deleted comment not found'); + } + + comment.isDeleted = false; + comment.deletedAt = null; + comment.deletedBy = null; + await comment.save(); + + const totalComments = await this.commentModel + .countDocuments({ postId: comment.postId, isDeleted: { $ne: true } }) + .exec(); + await this.postModel + .findByIdAndUpdate(comment.postId, { commentsCount: totalComments }) + .exec(); + await this.feedVersionService.bumpGlobalVersion(); + + await this.auditService.logSuperAdminAction( + superAdminIdentifier, + 'comment_restore', + 'comment', + commentId, + ); + + await this.recordResourceCase({ + actor: superAdminIdentifier, + caseType: 'content_moderation', + resourceType: 'comment', + resourceId: commentId, + title: this.buildContentCaseTitle('Comment', comment.content ?? commentId), + description: 'Restored by superadmin', + action: 'comment_restored', + note: 'Restored by superadmin', + status: 'resolved', + priority: 'normal', + }); + + const restored = await this.commentModel + .findById(commentId) + .populate({ path: 'authorId', select: 'name username avatar stageName isVerified' }) + .exec(); + return restored; + } + + async updateUserStatus( + superAdminIdentifier: string, + userId: string, + dto: UpdateSuperAdminUserStatusDto, + ) { + const user = dto.isDisabled + ? await this.usersService.disableUserBySuperAdmin(superAdminIdentifier, userId, { + reason: dto.reason ?? 'Disabled via superadmin status endpoint', + }) + : await this.usersService.enableUserBySuperAdmin(superAdminIdentifier, userId); + + await this.recordResourceCase({ + actor: superAdminIdentifier, + caseType: 'user_review', + resourceType: 'user', + resourceId: userId, + title: `User review: ${user.email ?? user.username ?? userId}`, + description: dto.reason?.trim() ?? '', + action: dto.isDisabled ? 'user_disabled' : 'user_enabled', + note: dto.reason?.trim() ?? '', + status: dto.isDisabled ? 'in_review' : 'resolved', + priority: dto.isDisabled ? 'high' : 'normal', + }); + + return user; + } + + private async performSingleBulkAction( + superAdminIdentifier: string, + resourceType: string, + targetId: string, + dto: BulkSuperAdminActionDto, + ): Promise { + switch (resourceType) { + case 'post': + await this.performPostBulkAction(superAdminIdentifier, targetId, dto); + return; + case 'comment': + await this.performCommentBulkAction(superAdminIdentifier, targetId, dto); + return; + case 'user': + await this.performUserBulkAction(superAdminIdentifier, targetId, dto); + return; + case 'listing': + await this.performListingBulkAction(superAdminIdentifier, targetId, dto); + return; + case 'repair_shop': + await this.performRepairShopBulkAction(superAdminIdentifier, targetId, dto); + return; + default: + throw new BadRequestException(`Unsupported bulk resource type "${resourceType}"`); + } + } + + private async performPostBulkAction( + superAdminIdentifier: string, + targetId: string, + dto: BulkSuperAdminActionDto, + ) { + switch (dto.action) { + case 'activate': + await this.updatePostStatus(superAdminIdentifier, targetId, { + status: ModerationStatus.ACTIVE, + reason: dto.reason, + }); + return; + case 'flag': + await this.updatePostStatus(superAdminIdentifier, targetId, { + status: ModerationStatus.FLAGGED, + reason: dto.reason, + }); + return; + case 'hide': + await this.updatePostStatus(superAdminIdentifier, targetId, { + status: ModerationStatus.HIDDEN, + reason: dto.reason, + }); + return; + case 'delete': + await this.deletePost(superAdminIdentifier, targetId); + return; + case 'restore': + await this.restorePost(superAdminIdentifier, targetId); + return; + default: + throw new BadRequestException(`Unsupported post action "${dto.action}"`); + } + } + + private async performCommentBulkAction( + superAdminIdentifier: string, + targetId: string, + dto: BulkSuperAdminActionDto, + ) { + switch (dto.action) { + case 'activate': + await this.updateCommentStatus(superAdminIdentifier, targetId, { + status: ModerationStatus.ACTIVE, + reason: dto.reason, + }); + return; + case 'flag': + await this.updateCommentStatus(superAdminIdentifier, targetId, { + status: ModerationStatus.FLAGGED, + reason: dto.reason, + }); + return; + case 'hide': + await this.updateCommentStatus(superAdminIdentifier, targetId, { + status: ModerationStatus.HIDDEN, + reason: dto.reason, + }); + return; + case 'delete': + await this.deleteComment(superAdminIdentifier, targetId); + return; + case 'restore': + await this.restoreComment(superAdminIdentifier, targetId); + return; + default: + throw new BadRequestException(`Unsupported comment action "${dto.action}"`); + } + } + + private async performUserBulkAction( + superAdminIdentifier: string, + targetId: string, + dto: BulkSuperAdminActionDto, + ) { + switch (dto.action) { + case 'disable': + await this.updateUserStatus(superAdminIdentifier, targetId, { + isDisabled: true, + reason: dto.reason, + }); + return; + case 'enable': + await this.updateUserStatus(superAdminIdentifier, targetId, { + isDisabled: false, + reason: dto.reason, + }); + return; + default: + throw new BadRequestException(`Unsupported user action "${dto.action}"`); + } + } + + private async performListingBulkAction( + superAdminIdentifier: string, + targetId: string, + dto: BulkSuperAdminActionDto, + ) { + const listing = await this.instrumentModel.findById(targetId).select('title imageUrls').lean().exec(); + if (!listing) { + throw new NotFoundException('Listing not found'); + } + + switch (dto.action) { + case 'activate': + await this.instrumentModel.findByIdAndUpdate(targetId, { isActive: true }).exec(); + await this.recordResourceCase({ + actor: superAdminIdentifier, + caseType: 'marketplace_review', + resourceType: 'listing', + resourceId: targetId, + title: `Listing review: ${(listing as any).title ?? targetId}`, + description: dto.reason?.trim() ?? '', + action: 'listing_activated', + note: dto.reason?.trim() ?? '', + status: 'resolved', + priority: dto.priority ?? 'normal', + }); + return; + case 'deactivate': + await this.instrumentModel.findByIdAndUpdate(targetId, { isActive: false }).exec(); + await this.recordResourceCase({ + actor: superAdminIdentifier, + caseType: 'marketplace_review', + resourceType: 'listing', + resourceId: targetId, + title: `Listing review: ${(listing as any).title ?? targetId}`, + description: dto.reason?.trim() ?? '', + action: 'listing_deactivated', + note: dto.reason?.trim() ?? '', + status: 'in_review', + priority: dto.priority ?? 'high', + }); + return; + case 'delete': + await this.instrumentModel.findByIdAndDelete(targetId).exec(); + await Promise.all(((listing as any).imageUrls ?? []).map((url: string) => this.safeDeleteManagedUrl(url))); + await this.recordResourceCase({ + actor: superAdminIdentifier, + caseType: 'marketplace_review', + resourceType: 'listing', + resourceId: targetId, + title: `Listing review: ${(listing as any).title ?? targetId}`, + description: dto.reason?.trim() ?? '', + action: 'listing_deleted', + note: dto.reason?.trim() ?? '', + status: 'resolved', + priority: 'high', + }); + return; + default: + throw new BadRequestException(`Unsupported listing action "${dto.action}"`); + } + } + + private async performRepairShopBulkAction( + superAdminIdentifier: string, + targetId: string, + dto: BulkSuperAdminActionDto, + ) { + const shop = await this.repairShopModel.findById(targetId).select('name imageUrls').lean().exec(); + if (!shop) { + throw new NotFoundException('Repair shop not found'); + } + + switch (dto.action) { + case 'activate': + await this.repairShopModel.findByIdAndUpdate(targetId, { isActive: true }).exec(); + await this.recordResourceCase({ + actor: superAdminIdentifier, + caseType: 'marketplace_review', + resourceType: 'repair_shop', + resourceId: targetId, + title: `Repair shop review: ${(shop as any).name ?? targetId}`, + description: dto.reason?.trim() ?? '', + action: 'repair_shop_activated', + note: dto.reason?.trim() ?? '', + status: 'resolved', + priority: dto.priority ?? 'normal', + }); + return; + case 'deactivate': + await this.repairShopModel.findByIdAndUpdate(targetId, { isActive: false }).exec(); + await this.recordResourceCase({ + actor: superAdminIdentifier, + caseType: 'marketplace_review', + resourceType: 'repair_shop', + resourceId: targetId, + title: `Repair shop review: ${(shop as any).name ?? targetId}`, + description: dto.reason?.trim() ?? '', + action: 'repair_shop_deactivated', + note: dto.reason?.trim() ?? '', + status: 'in_review', + priority: dto.priority ?? 'high', + }); + return; + case 'delete': + await this.repairShopModel.findByIdAndDelete(targetId).exec(); + await Promise.all(((shop as any).imageUrls ?? []).map((url: string) => this.safeDeleteManagedUrl(url))); + await this.recordResourceCase({ + actor: superAdminIdentifier, + caseType: 'marketplace_review', + resourceType: 'repair_shop', + resourceId: targetId, + title: `Repair shop review: ${(shop as any).name ?? targetId}`, + description: dto.reason?.trim() ?? '', + action: 'repair_shop_deleted', + note: dto.reason?.trim() ?? '', + status: 'resolved', + priority: 'high', + }); + return; + default: + throw new BadRequestException(`Unsupported repair shop action "${dto.action}"`); + } + } + + private async recordResourceCase(params: { + actor: string; + caseType: string; + resourceType: string; + resourceId: string; + title: string; + description: string; + action: string; + note: string; + status: CaseStatus; + priority: CasePriority; + assignedTo?: string; + }): Promise { + const existing = await this.superAdminCaseModel + .findOne({ + resourceType: params.resourceType, + resourceId: params.resourceId, + status: { $in: ['open', 'in_review'] }, + }) + .sort({ updatedAt: -1 }) + .exec(); + + if (!existing) { + await this.superAdminCaseModel.create({ + title: params.title, + description: params.description, + caseType: params.caseType, + resourceType: params.resourceType, + resourceId: params.resourceId, + status: params.status, + priority: params.priority, + assignedTo: params.assignedTo ?? '', + createdBy: params.actor, + updatedBy: params.actor, + resolution: params.note, + events: [ + { + action: params.action, + actor: params.actor, + note: params.note, + metadata: {}, + createdAt: new Date(), + }, + ], + }); + return; + } + + existing.status = params.status; + existing.priority = params.priority; + existing.updatedBy = params.actor; + existing.title = params.title; + existing.description = params.description; + existing.resolution = params.note; + if (params.assignedTo) { + existing.assignedTo = params.assignedTo; + } + existing.events.push({ + action: params.action, + actor: params.actor, + note: params.note, + metadata: {}, + createdAt: new Date(), + }); + await existing.save(); + } + + private buildContentCaseTitle(prefix: string, content: string): string { + const normalized = content.trim(); + return `${prefix} moderation: ${normalized.slice(0, 72) || 'untitled'}`; + } + + private async applyBulkCaseMetadata( + superAdminIdentifier: string, + resourceType: string, + resourceId: string, + dto: BulkSuperAdminActionDto, + ): Promise { + if (!dto.assignToMe && !dto.priority) { + return; + } + + const current = await this.superAdminCaseModel + .findOne({ + resourceType, + resourceId, + status: { $in: ['open', 'in_review'] }, + }) + .sort({ updatedAt: -1 }) + .exec(); + + if (!current) { + return; + } + + if (dto.assignToMe) { + current.assignedTo = superAdminIdentifier; + if (current.status === 'open') { + current.status = 'in_review'; + } + } + if (dto.priority) { + current.priority = dto.priority as CasePriority; + } + current.updatedBy = superAdminIdentifier; + current.events.push({ + action: 'bulk_case_metadata_update', + actor: superAdminIdentifier, + note: dto.reason?.trim() ?? '', + metadata: { + assignedTo: dto.assignToMe ? superAdminIdentifier : undefined, + priority: dto.priority, + }, + createdAt: new Date(), + }); + await current.save(); + } + + private async safeDeleteManagedUrl(fileUrl?: string): Promise { + try { + await this.storageService.deleteFile(fileUrl); + } catch { + // Best-effort cleanup only. + } + } + + private resolveRangeDays(range: string): number { + switch (range) { + case '7d': + return 7; + case '90d': + return 90; + case '30d': + default: + return 30; + } + } + + private async buildDailySeries( + model: Model, + filter: Record, + days: number, + ) { + const buckets = this.createDateBuckets(days); + const start = buckets[0]; + const rows = await model + .aggregate([ + { + $match: { + ...filter, + createdAt: { $gte: start }, + }, + }, + { + $project: { + bucket: { + $dateToString: { + format: '%Y-%m-%d', + date: '$createdAt', + }, + }, + }, + }, + { + $group: { + _id: '$bucket', + count: { $sum: 1 }, + }, + }, + { $sort: { _id: 1 } }, + ]) + .exec(); + + const counts = new Map( + rows.map((row: { _id: string; count: number }) => [row._id, row.count]), + ); + + return buckets.map((date) => { + const isoDate = this.formatDateKey(date); + return { + date: isoDate, + label: new Intl.DateTimeFormat('en-GB', { + month: 'short', + day: 'numeric', + }).format(date), + count: counts.get(isoDate) ?? 0, + }; + }); + } + + private async buildUserRoleBreakdown() { + const rows = await this.userModel + .aggregate([{ $group: { _id: '$role', value: { $sum: 1 } } }]) + .exec(); + return rows.map((row: { _id: string | null; value: number }) => ({ + label: row._id ?? 'unknown', + value: row.value, + })); + } + + private async buildPostTypeBreakdown() { + const rows = await this.postModel + .aggregate([ + { $match: { isDeleted: { $ne: true } } }, + { $group: { _id: '$postType', value: { $sum: 1 } } }, + ]) + .exec(); + + return rows.map((row: { _id: string | null; value: number }) => ({ + label: row._id ?? 'unknown', + value: row.value, + })); + } + + private async buildListingCategoryBreakdown() { + const rows = await this.instrumentModel + .aggregate([ + { + $project: { + category: { + $ifNull: ['$listingCategory', 'musical_instrument'], + }, + }, + }, + { $group: { _id: '$category', value: { $sum: 1 } } }, + ]) + .exec(); + + return rows.map((row: { _id: string | null; value: number }) => ({ + label: row._id ?? 'unknown', + value: row.value, + })); + } + + private createDateBuckets(days: number): Date[] { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const buckets: Date[] = []; + + for (let index = days - 1; index >= 0; index -= 1) { + const date = new Date(today); + date.setDate(today.getDate() - index); + buckets.push(date); + } + + return buckets; + } + + private formatDateKey(date: Date): string { + return date.toISOString().slice(0, 10); + } + + private buildDefaultSettings(): Omit & { scope: string } { + const publicBaseUrl = this.configService.get('publicBaseUrl', { infer: true }) ?? ''; + const globalPrefix = this.configService.get('globalPrefix', { infer: true }) ?? 'api/v1'; + + return { + scope: this.defaultSettingsScope, + siteName: 'Oudelaa SuperAdmin', + publicBaseUrl, + dashboardApiBaseUrl: publicBaseUrl ? `${publicBaseUrl}/${globalPrefix}` : '', + corsOrigins: + (this.configService.get('cors.origins', { infer: true }) as string[] | undefined) ?? + [], + maintenanceMode: false, + emailEnabled: this.configService.get('email.enabled', { infer: true }) ?? false, + marketplaceAutoApprove: false, + contentAutoHideFlagged: false, + notes: '', + updatedBy: '', + }; + } + + private buildMusicalInstrumentFilter() { + return { + $or: [ + { listingCategory: 'musical_instrument' }, + { listingCategory: { $exists: false } }, + { listingCategory: null }, + ], + }; + } +} diff --git a/src/modules/users/dto/create-user.dto.ts b/src/modules/users/dto/create-user.dto.ts index b84bd5e..c0b2952 100644 --- a/src/modules/users/dto/create-user.dto.ts +++ b/src/modules/users/dto/create-user.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; import { IsArray, IsBoolean, @@ -15,6 +16,7 @@ import { } from 'class-validator'; import { ExperienceLevel } from '../../../common/enums/experience-level.enum'; import { MusicRole } from '../../../common/enums/music-role.enum'; +import { toBoolean } from '../../../common/utils/query-transform.util'; export class CreateUserDto { @ApiProperty({ example: 'John Doe' }) @@ -54,6 +56,11 @@ export class CreateUserDto { @IsUrl({ require_tld: false }) avatar?: string; + @ApiProperty({ required: false, example: 'https://cdn.example.com/profile-cover.jpg' }) + @IsOptional() + @IsUrl({ require_tld: false }) + coverImage?: string; + @ApiProperty({ required: false, example: 'Riyadh, Saudi Arabia' }) @IsOptional() @IsString() @@ -76,11 +83,13 @@ export class CreateUserDto { @ApiProperty({ required: false, default: false }) @IsOptional() + @Transform(toBoolean) @IsBoolean() isPrivate?: boolean; @ApiProperty({ required: false, default: false }) @IsOptional() + @Transform(toBoolean) @IsBoolean() isVerified?: boolean; diff --git a/src/modules/users/dto/profile-setup.dto.ts b/src/modules/users/dto/profile-setup.dto.ts index 24d60af..e3daff6 100644 --- a/src/modules/users/dto/profile-setup.dto.ts +++ b/src/modules/users/dto/profile-setup.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; import { IsNumber, IsOptional, IsString, IsUrl, Length, Max, Min } from 'class-validator'; @@ -14,7 +14,12 @@ export class ProfileSetupDto { @IsUrl({ require_tld: false }) avatar?: string; - @ApiPropertyOptional({ example: 'äÈÐÉ ÞÕíÑÉ Úäí', maxLength: 150 }) + @ApiPropertyOptional({ example: 'https://cdn.example.com/profile-cover.jpg' }) + @IsOptional() + @IsUrl({ require_tld: false }) + coverImage?: string; + + @ApiPropertyOptional({ example: 'Short bio about me', maxLength: 150 }) @IsOptional() @IsString() @Length(0, 150) diff --git a/src/modules/users/dto/talent-discover-query.dto.ts b/src/modules/users/dto/talent-discover-query.dto.ts new file mode 100644 index 0000000..1572308 --- /dev/null +++ b/src/modules/users/dto/talent-discover-query.dto.ts @@ -0,0 +1,27 @@ +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'; +import { UserQueryDto } from './user-query.dto'; + +export class TalentDiscoverQueryDto extends UserQueryDto { + @ApiPropertyOptional({ default: true }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + hasAvatarOnly?: boolean; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + includeRoleBuckets?: boolean; + + @ApiPropertyOptional({ minimum: 1, maximum: 24, default: 8 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + @Max(24) + limit?: number; +} diff --git a/src/modules/users/dto/update-user.dto.ts b/src/modules/users/dto/update-user.dto.ts index d623311..98aaece 100644 --- a/src/modules/users/dto/update-user.dto.ts +++ b/src/modules/users/dto/update-user.dto.ts @@ -1,7 +1,22 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsArray, IsBoolean, IsEnum, IsOptional, IsString, IsUrl, Length } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsEmail, + IsEnum, + IsNumber, + IsOptional, + IsString, + IsUrl, + Length, + Matches, + Max, + Min, +} from 'class-validator'; import { ExperienceLevel } from '../../../common/enums/experience-level.enum'; import { MusicRole } from '../../../common/enums/music-role.enum'; +import { toBoolean } from '../../../common/utils/query-transform.util'; export class UpdateUserDto { @ApiPropertyOptional({ example: 'John Doe' }) @@ -10,6 +25,18 @@ export class UpdateUserDto { @Length(2, 80) name?: string; + @ApiPropertyOptional({ example: 'artist_one' }) + @IsOptional() + @IsString() + @Length(3, 30) + @Matches(/^[a-zA-Z0-9_.]+$/, { message: 'username can contain letters, numbers, _ and .' }) + username?: string; + + @ApiPropertyOptional({ example: 'artist@example.com' }) + @IsOptional() + @IsEmail() + email?: string; + @ApiPropertyOptional({ example: 'Artist One' }) @IsOptional() @IsString() @@ -27,17 +54,43 @@ export class UpdateUserDto { @IsUrl({ require_tld: false }) avatar?: string; + @ApiPropertyOptional({ example: 'https://cdn.example.com/profile-cover.jpg' }) + @IsOptional() + @IsUrl({ require_tld: false }) + coverImage?: string; + @ApiPropertyOptional({ example: 'Riyadh, Saudi Arabia' }) @IsOptional() @IsString() @Length(0, 120) location?: string; + @ApiPropertyOptional({ required: false, example: 24.7136, minimum: -90, maximum: 90 }) + @IsOptional() + @IsNumber() + @Min(-90) + @Max(90) + latitude?: number; + + @ApiPropertyOptional({ required: false, example: 46.6753, minimum: -180, maximum: 180 }) + @IsOptional() + @IsNumber() + @Min(-180) + @Max(180) + longitude?: number; + @ApiPropertyOptional({ default: false }) @IsOptional() + @Transform(toBoolean) @IsBoolean() isPrivate?: boolean; + @ApiPropertyOptional({ default: false }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + isVerified?: boolean; + @ApiPropertyOptional({ enum: MusicRole, isArray: true }) @IsOptional() @IsArray() diff --git a/src/modules/users/dto/user-query.dto.ts b/src/modules/users/dto/user-query.dto.ts index 25c1ac1..660ad88 100644 --- a/src/modules/users/dto/user-query.dto.ts +++ b/src/modules/users/dto/user-query.dto.ts @@ -1,6 +1,20 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsOptional, IsString } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator'; import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; +import { ExperienceLevel } from '../../../common/enums/experience-level.enum'; +import { MusicRole } from '../../../common/enums/music-role.enum'; +import { toBoolean } from '../../../common/utils/query-transform.util'; + +export const USER_SORT_FIELDS = [ + 'createdAt', + 'name', + 'username', + 'followersCount', + 'postsCount', +] as const; + +export type UserSortField = (typeof USER_SORT_FIELDS)[number]; export class UserQueryDto extends PaginationQueryDto { @ApiPropertyOptional({ description: 'Search by name or username' }) @@ -10,6 +24,34 @@ export class UserQueryDto extends PaginationQueryDto { @ApiPropertyOptional({ default: false }) @IsOptional() + @Transform(toBoolean) @IsBoolean() isVerified?: boolean; + + @ApiPropertyOptional({ enum: MusicRole }) + @IsOptional() + @IsEnum(MusicRole) + musicRole?: MusicRole; + + @ApiPropertyOptional({ enum: ExperienceLevel }) + @IsOptional() + @IsEnum(ExperienceLevel) + experienceLevel?: ExperienceLevel; + + @ApiPropertyOptional() + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + isPrivate?: boolean; + + @ApiPropertyOptional({ description: 'Require or exclude users with avatar images' }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + hasAvatar?: boolean; + + @ApiPropertyOptional({ enum: USER_SORT_FIELDS, default: 'createdAt' }) + @IsOptional() + @IsEnum(USER_SORT_FIELDS) + sortBy?: UserSortField; } diff --git a/src/modules/users/schemas/user.schema.ts b/src/modules/users/schemas/user.schema.ts index 8f58c25..d683edf 100644 --- a/src/modules/users/schemas/user.schema.ts +++ b/src/modules/users/schemas/user.schema.ts @@ -3,6 +3,10 @@ import { HydratedDocument } from 'mongoose'; import { ExperienceLevel } from '../../../common/enums/experience-level.enum'; import { MusicRole } from '../../../common/enums/music-role.enum'; import { UserRole } from '../../../common/enums/user-role.enum'; +import { + resolveManagedFileUrl, + resolveManagedFileUrls, +} from '../../../common/utils/public-url.util'; export type UserDocument = HydratedDocument; @@ -50,6 +54,9 @@ export class User { @Prop({ default: '' }) avatar!: string; + @Prop({ default: '' }) + coverImage!: string; + @Prop({ default: '' }) location!: string; @@ -88,35 +95,38 @@ export class User { @Prop({ default: false, index: true }) isVerified!: boolean; + + @Prop({ default: '', trim: true, maxlength: 120 }) + shopName!: string; + + @Prop({ default: '', trim: true, maxlength: 2000 }) + shopDescription!: string; + + @Prop({ type: [String], default: [] }) + shopImageUrls!: string[]; + + @Prop({ default: '', trim: true, maxlength: 160 }) + shopLocation!: string; + + @Prop({ type: Number, min: -90, max: 90, default: null }) + shopLatitude!: number | null; + + @Prop({ type: Number, min: -180, max: 180, default: null }) + shopLongitude!: number | null; } export const UserSchema = SchemaFactory.createForClass(User); UserSchema.index({ createdAt: -1 }); -const resolveAvatarUrl = (avatar: unknown): unknown => { - if (typeof avatar !== 'string' || !avatar.trim()) { - return avatar; - } - - if (!avatar.startsWith('/uploads/')) { - return avatar; - } - - const baseUrl = (process.env.PUBLIC_BASE_URL ?? '').replace(/\/$/, ''); - if (!baseUrl) { - return avatar; - } - - return `${baseUrl}${avatar}`; -}; - const stripLegacyRoleFlags = (_doc: unknown, ret: any) => { delete ret.isInstrumentalist; delete ret.isSinger; delete ret.isComposer; delete ret.isLyricist; - ret.avatar = resolveAvatarUrl(ret.avatar); + ret.avatar = resolveManagedFileUrl(ret.avatar); + ret.coverImage = resolveManagedFileUrl(ret.coverImage); + ret.shopImageUrls = resolveManagedFileUrls(ret.shopImageUrls); return ret; }; diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index 2f94a05..de220a3 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -7,24 +7,28 @@ import { Patch, Post, Query, - UploadedFile, + UploadedFiles, UseGuards, UseInterceptors, } from '@nestjs/common'; -import { FileInterceptor } from '@nestjs/platform-express'; +import { FileFieldsInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +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 { JwtPayload } from '../../common/interfaces/jwt-payload.interface'; import { AdminDisableUserDto } from './dto/admin-disable-user.dto'; import { CreateAdminDto } from './dto/create-admin.dto'; import { MusicSetupDto } from './dto/music-setup.dto'; import { ProfileSetupDto } from './dto/profile-setup.dto'; +import { TalentDiscoverQueryDto } from './dto/talent-discover-query.dto'; import { UpdateUserRoleDto } from './dto/update-user-role.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { UserQueryDto } from './dto/user-query.dto'; import { UsersService } from './users.service'; +import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions'; @ApiTags('Users') @Controller('users') @@ -34,8 +38,13 @@ export class UsersController { @ApiBearerAuth() @UseGuards(JwtAuthGuard) @Patch('me/profile-setup') - @UseInterceptors(FileInterceptor('avatarFile')) - @ApiConsumes('application/json', 'multipart/form-data') + @UseInterceptors( + FileFieldsInterceptor([ + { name: 'avatarFile', maxCount: 1 }, + { name: 'coverImageFile', maxCount: 1 }, + ]), + ) + @ApiConsumes('multipart/form-data') @ApiBody({ schema: { type: 'object', @@ -46,7 +55,9 @@ export class UsersController { latitude: { type: 'number', example: 24.7136 }, longitude: { type: 'number', example: 46.6753 }, avatar: { type: 'string', example: 'https://cdn.example.com/avatar.jpg' }, + coverImage: { type: 'string', example: 'https://cdn.example.com/profile-cover.jpg' }, avatarFile: { type: 'string', format: 'binary' }, + coverImageFile: { type: 'string', format: 'binary' }, }, required: ['latitude', 'longitude'], }, @@ -54,10 +65,28 @@ export class UsersController { async updateProfileSetup( @CurrentUser() user: JwtPayload, @Body() dto: ProfileSetupDto, - @UploadedFile() - avatarFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, + @UploadedFiles() + files?: { + avatarFile?: Array<{ + mimetype?: string; + size: number; + buffer: Buffer; + originalname?: string; + }>; + coverImageFile?: Array<{ + mimetype?: string; + size: number; + buffer: Buffer; + originalname?: string; + }>; + }, ) { - return this.usersService.updateProfileSetup(user.sub, dto, avatarFile); + return this.usersService.updateProfileSetup( + user.sub, + dto, + files?.avatarFile?.[0], + files?.coverImageFile?.[0], + ); } @ApiBearerAuth() @@ -70,12 +99,59 @@ export class UsersController { @ApiBearerAuth() @UseGuards(JwtAuthGuard) @Patch('me') - async updateMe(@CurrentUser() user: JwtPayload, @Body() dto: UpdateUserDto) { - return this.usersService.updateProfile(user.sub, dto); + @UseInterceptors( + FileFieldsInterceptor([ + { name: 'avatarFile', maxCount: 1 }, + { name: 'coverImageFile', maxCount: 1 }, + ]), + ) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + name: { type: 'string', example: 'Raffi Wadeea' }, + stageName: { type: 'string', example: 'Artist One' }, + bio: { type: 'string', example: 'Short bio' }, + avatar: { type: 'string', example: 'https://cdn.example.com/avatar.jpg' }, + coverImage: { type: 'string', example: 'https://cdn.example.com/profile-cover.jpg' }, + location: { type: 'string', example: 'Riyadh, Saudi Arabia' }, + isPrivate: { type: 'boolean', example: false }, + avatarFile: { type: 'string', format: 'binary' }, + coverImageFile: { type: 'string', format: 'binary' }, + }, + }, + }) + async updateMe( + @CurrentUser() user: JwtPayload, + @Body() dto: UpdateUserDto, + @UploadedFiles() + files?: { + avatarFile?: Array<{ + mimetype?: string; + size: number; + buffer: Buffer; + originalname?: string; + }>; + coverImageFile?: Array<{ + mimetype?: string; + size: number; + buffer: Buffer; + originalname?: string; + }>; + }, + ) { + return this.usersService.updateProfile( + user.sub, + dto, + files?.avatarFile?.[0], + files?.coverImageFile?.[0], + ); } @ApiBearerAuth() - @UseGuards(SuperAdminJwtAuthGuard) + @UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard) + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_MANAGE) @Post('admin/create-admin') async createAdmin( @CurrentUser() user: JwtPayload, @@ -85,28 +161,32 @@ export class UsersController { } @ApiBearerAuth() - @UseGuards(SuperAdminJwtAuthGuard) + @UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard) + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_READ) @Get('admin') async adminFindMany(@Query() query: UserQueryDto) { return this.usersService.searchUsersForSuperAdmin(query); } @ApiBearerAuth() - @UseGuards(SuperAdminJwtAuthGuard) + @UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard) + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_READ) @Get('admin/admins') async adminListAdmins(@Query() query: UserQueryDto) { return this.usersService.listAdminsBySuperAdmin(query); } @ApiBearerAuth() - @UseGuards(SuperAdminJwtAuthGuard) + @UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard) + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_READ) @Get('admin/:id') async adminFindOne(@Param('id') targetUserId: string) { return this.usersService.findUserByIdForSuperAdmin(targetUserId); } @ApiBearerAuth() - @UseGuards(SuperAdminJwtAuthGuard) + @UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard) + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_MANAGE) @Patch('admin/:id') async adminUpdateUser( @CurrentUser() user: JwtPayload, @@ -117,7 +197,8 @@ export class UsersController { } @ApiBearerAuth() - @UseGuards(SuperAdminJwtAuthGuard) + @UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard) + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_MANAGE) @Patch('admin/:id/role') async adminUpdateUserRole( @CurrentUser() user: JwtPayload, @@ -128,7 +209,8 @@ export class UsersController { } @ApiBearerAuth() - @UseGuards(SuperAdminJwtAuthGuard) + @UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard) + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_MANAGE) @Patch('admin/:id/disable') async disableUser( @CurrentUser() user: JwtPayload, @@ -139,14 +221,16 @@ export class UsersController { } @ApiBearerAuth() - @UseGuards(SuperAdminJwtAuthGuard) + @UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard) + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_MANAGE) @Patch('admin/:id/enable') async enableUser(@CurrentUser() user: JwtPayload, @Param('id') targetUserId: string) { return this.usersService.enableUserBySuperAdmin(user.email ?? user.sub, targetUserId); } @ApiBearerAuth() - @UseGuards(SuperAdminJwtAuthGuard) + @UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard) + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_MANAGE) @Delete('admin/:id') async deleteUser(@CurrentUser() user: JwtPayload, @Param('id') targetUserId: string) { await this.usersService.deleteUserBySuperAdmin(user.email ?? user.sub, targetUserId); @@ -154,7 +238,8 @@ export class UsersController { } @ApiBearerAuth() - @UseGuards(SuperAdminJwtAuthGuard) + @UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard) + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_MANAGE) @Patch('admin/admins/:id') async adminUpdateAdmin( @CurrentUser() user: JwtPayload, @@ -165,18 +250,49 @@ export class UsersController { } @ApiBearerAuth() - @UseGuards(SuperAdminJwtAuthGuard) + @UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard) + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_MANAGE) @Delete('admin/admins/:id') async adminDeleteAdmin(@CurrentUser() user: JwtPayload, @Param('id') targetUserId: string) { await this.usersService.deleteAdminBySuperAdmin(user.email ?? user.sub, targetUserId); return { message: 'Admin deleted successfully' }; } + @ApiBearerAuth() + @UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard) + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_READ) + @Get('admin/discover') + async adminDiscoverTalents(@Query() query: TalentDiscoverQueryDto) { + return this.usersService.discoverTalentsForSuperAdmin(query); + } + + @ApiBearerAuth() + @UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard) + @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_READ) + @Get('admin/:id/profile-overview') + async adminGetProfileOverview(@Param('id') id: string) { + return this.usersService.getProfileOverviewForSuperAdmin(id); + } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Get('discover') + async discoverTalents(@Query() query: TalentDiscoverQueryDto) { + return this.usersService.discoverTalents(query); + } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Get(':id/profile-overview') + async getProfileOverview(@CurrentUser() user: JwtPayload, @Param('id') id: string) { + return this.usersService.getProfileOverview(id, user.sub); + } + @ApiBearerAuth() @UseGuards(JwtAuthGuard) @Get(':id') async findOne(@Param('id') id: string) { - return this.usersService.findByIdOrFail(id); + return this.usersService.findPublicByIdOrFail(id); } @ApiBearerAuth() diff --git a/src/modules/users/users.repository.ts b/src/modules/users/users.repository.ts index af7cc89..ce7ab32 100644 --- a/src/modules/users/users.repository.ts +++ b/src/modules/users/users.repository.ts @@ -69,8 +69,22 @@ export class UsersRepository { await this.userModel.findByIdAndUpdate(userId, { followingCount }, { new: false }).exec(); } - async findMany(filter: FilterQuery, skip: number, limit: number): Promise { - return this.userModel.find(filter).sort({ createdAt: -1 }).skip(skip).limit(limit).exec(); + async findMany( + filter: FilterQuery, + skip: number, + limit: number, + sort: Record = { createdAt: -1 }, + ): Promise { + return this.userModel.find(filter).sort(sort).skip(skip).limit(limit).exec(); + } + + async findByUsernames(usernames: string[]): Promise { + if (!usernames.length) { + return []; + } + + const normalized = Array.from(new Set(usernames.map((username) => username.toLowerCase()))); + return this.userModel.find({ username: { $in: normalized } }).exec(); } async count(filter: FilterQuery): Promise { diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index 5df49e6..9f7508f 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -1,16 +1,20 @@ import { BadRequestException, + ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { InjectConnection } from '@nestjs/mongoose'; import { ConfigService } from '@nestjs/config'; -import { randomUUID } from 'crypto'; -import { mkdir, unlink, writeFile } from 'fs/promises'; -import { extname, join } from 'path'; +import { extname } from 'path'; import { Connection, Types } from 'mongoose'; +import { ModerationStatus } from '../../common/enums/moderation-status.enum'; import { ExperienceLevel } from '../../common/enums/experience-level.enum'; +import { MusicRole } from '../../common/enums/music-role.enum'; import { hashValue } from '../../common/utils/hash.util'; +import { buildPaginatedResponse } from '../../common/utils/pagination.util'; +import { resolveMongoSortDirection } from '../../common/utils/sort.util'; +import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service'; import { AuditService } from '../audit/audit.service'; import { UserRole } from '../../common/enums/user-role.enum'; import { CreateUserDto } from './dto/create-user.dto'; @@ -18,12 +22,20 @@ import { AdminDisableUserDto } from './dto/admin-disable-user.dto'; import { CreateAdminDto } from './dto/create-admin.dto'; import { MusicSetupDto } from './dto/music-setup.dto'; import { ProfileSetupDto } from './dto/profile-setup.dto'; +import { TalentDiscoverQueryDto } from './dto/talent-discover-query.dto'; import { UpdateUserRoleDto } from './dto/update-user-role.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { UserQueryDto } from './dto/user-query.dto'; import { UsersRepository } from './users.repository'; import { UserDocument } from './schemas/user.schema'; +type UploadedImageFile = { + mimetype?: string; + size: number; + buffer: Buffer; + originalname?: string; +}; + @Injectable() export class UsersService { constructor( @@ -31,6 +43,7 @@ export class UsersService { private readonly usersRepository: UsersRepository, private readonly auditService: AuditService, private readonly configService: ConfigService, + private readonly storageService: ManagedStorageService, ) {} async create(dto: CreateUserDto & { password: string; role?: UserRole }): Promise { @@ -51,6 +64,7 @@ export class UsersService { stageName: dto.stageName ?? '', bio: dto.bio ?? '', avatar: dto.avatar ?? '', + coverImage: dto.coverImage ?? '', location: dto.location ?? '', latitude: dto.latitude, longitude: dto.longitude, @@ -126,18 +140,21 @@ export class UsersService { filter.isVerified = query.isVerified; } + const direction = resolveMongoSortDirection(query.sortOrder); + const sortField = query.sortBy ?? 'createdAt'; + const sort = { [sortField]: direction } as Record; + const [items, total] = await Promise.all([ - this.usersRepository.findMany(filter, skip, limit), + this.usersRepository.findMany(filter, skip, limit, sort), this.usersRepository.count(filter), ]); - return { - items, + return buildPaginatedResponse(items, { page, limit, total, - totalPages: Math.ceil(total / limit) || 1, - }; + offset: skip, + }); } async updateAdminBySuperAdmin( @@ -146,18 +163,15 @@ export class UsersService { dto: UpdateUserDto, ): Promise { await this.assertTargetIsAdmin(adminUserId); - - const updated = await this.usersRepository.updateById(adminUserId, dto); - if (!updated) { - throw new NotFoundException('Admin not found'); - } + const payload = await this.prepareManagedUserUpdatePayload(adminUserId, dto); + const updated = await this.updateUserAndCleanupReplacedImages(adminUserId, payload); await this.auditService.logSuperAdminAction( superAdminIdentifier, 'admin_update', 'user', adminUserId, - { fields: Object.keys(dto) }, + { fields: Object.keys(payload) }, ); return updated; @@ -165,7 +179,10 @@ export class UsersService { async deleteAdminBySuperAdmin(superAdminIdentifier: string, adminUserId: string): Promise { const admin = await this.assertTargetIsAdmin(adminUserId); - await this.deleteUserRelatedData(adminUserId, admin.avatar ?? ''); + await this.deleteUserRelatedData(adminUserId, { + avatarUrl: admin.avatar ?? '', + coverImageUrl: admin.coverImage ?? '', + }); await this.usersRepository.deleteById(adminUserId); @@ -186,6 +203,7 @@ export class UsersService { throw new BadRequestException('Cannot assign superadmin role via API'); } + await this.assertTargetIsManagedUser(targetUserId); const updated = await this.usersRepository.updateById(targetUserId, { role: dto.role }); if (!updated) { throw new NotFoundException('User not found'); @@ -210,6 +228,14 @@ export class UsersService { return user; } + async findPublicByIdOrFail(userId: string): Promise { + const user = await this.findByIdOrFail(userId); + if (user.isDisabled) { + throw new NotFoundException('User not found'); + } + return user; + } + async findByEmailWithPassword(email: string): Promise { return this.usersRepository.findOneWithPassword({ email: email.toLowerCase() }); } @@ -227,25 +253,38 @@ export class UsersService { } async linkGoogleAccount(userId: string, googleId: string, avatar?: string): Promise { - const user = await this.usersRepository.updateById(userId, { - googleId, - authProvider: 'google', - ...(avatar ? { avatar } : {}), - }); - - if (!user) { - throw new NotFoundException('User not found'); - } + const currentUser = await this.findByIdOrFail(userId); + const user = await this.updateUserAndCleanupReplacedImages( + userId, + { + googleId, + authProvider: 'google', + ...(avatar ? { avatar } : {}), + }, + { currentUser }, + ); return user; } - async updateProfile(userId: string, dto: UpdateUserDto): Promise { - const user = await this.usersRepository.updateById(userId, dto); - if (!user) { - throw new NotFoundException('User not found'); - } - return user; + async updateProfile( + userId: string, + dto: UpdateUserDto, + avatarFile?: UploadedImageFile, + coverImageFile?: UploadedImageFile, + ): Promise { + const currentUser = await this.findByIdOrFail(userId); + const payload: Record = { ...dto }; + const uploadedImageUrls = await this.attachUploadedProfileImages( + payload, + avatarFile, + coverImageFile, + ); + + return this.updateUserAndCleanupReplacedImages(userId, payload, { + currentUser, + uploadedImageUrls, + }); } async updatePassword(userId: string, passwordHash: string): Promise { @@ -263,7 +302,7 @@ export class UsersService { } async findUserByIdForSuperAdmin(targetUserId: string): Promise { - return this.findByIdOrFail(targetUserId); + return this.assertTargetIsManagedUser(targetUserId); } async updateUserBySuperAdmin( @@ -271,24 +310,67 @@ export class UsersService { targetUserId: string, dto: UpdateUserDto, ): Promise { - const user = await this.usersRepository.updateById(targetUserId, dto); - if (!user) { - throw new NotFoundException('User not found'); - } + await this.assertTargetIsManagedUser(targetUserId); + const payload = await this.prepareManagedUserUpdatePayload(targetUserId, dto); + const user = await this.updateUserAndCleanupReplacedImages(targetUserId, payload); await this.auditService.logSuperAdminAction( superAdminIdentifier, 'user_update', 'user', targetUserId, - { fields: Object.keys(dto) }, + { fields: Object.keys(payload) }, ); return user; } + private async prepareManagedUserUpdatePayload( + targetUserId: string, + dto: UpdateUserDto, + ): Promise> { + const payload: Record = { ...dto }; + + if (typeof dto.email === 'string') { + const normalizedEmail = dto.email.trim().toLowerCase(); + if (!normalizedEmail) { + throw new BadRequestException('Email is required'); + } + + const existingUser = await this.usersRepository.findOne({ + email: normalizedEmail, + _id: { $ne: targetUserId }, + }); + if (existingUser) { + throw new BadRequestException('Email already exists'); + } + + payload.email = normalizedEmail; + } + + if (typeof dto.username === 'string') { + const normalizedUsername = dto.username.trim().toLowerCase(); + if (!normalizedUsername) { + throw new BadRequestException('Username is required'); + } + + const existingUser = await this.usersRepository.findOne({ + username: normalizedUsername, + _id: { $ne: targetUserId }, + }); + if (existingUser) { + throw new BadRequestException('Username already exists'); + } + + payload.username = normalizedUsername; + } + + return payload; + } + async updateProfileSetup( userId: string, dto: ProfileSetupDto, - avatarFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, + avatarFile?: UploadedImageFile, + coverImageFile?: UploadedImageFile, ): Promise { if (!Number.isFinite(dto.latitude) || !Number.isFinite(dto.longitude)) { throw new BadRequestException('latitude and longitude are required'); @@ -296,66 +378,71 @@ export class UsersService { const currentUser = await this.findByIdOrFail(userId); const payload: Record = { ...dto }; - let uploadedAvatarUrl: string | null = null; + const uploadedImageUrls = await this.attachUploadedProfileImages( + payload, + avatarFile, + coverImageFile, + ); - if (avatarFile) { - uploadedAvatarUrl = await this.saveAvatarFile(avatarFile); - payload.avatar = uploadedAvatarUrl; - } - - const user = await this.usersRepository.updateById(userId, payload); - if (!user) { - if (uploadedAvatarUrl) { - await this.deleteManagedAvatar(uploadedAvatarUrl); - } - throw new NotFoundException('User not found'); - } - - const nextAvatar = - typeof payload.avatar === 'string' ? payload.avatar : currentUser.avatar; - - if (nextAvatar !== currentUser.avatar) { - await this.deleteManagedAvatar(currentUser.avatar); - } - - return user; + return this.updateUserAndCleanupReplacedImages(userId, payload, { + currentUser, + uploadedImageUrls, + }); } - private async saveAvatarFile( - avatarFile: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, + private async saveAvatarFile(avatarFile: UploadedImageFile): Promise { + return this.saveProfileImageFile(avatarFile, { + folderSegment: 'avatars', + fileFieldName: 'avatarFile', + fileNamePrefix: 'avatar', + }); + } + + private async saveCoverImageFile(coverImageFile: UploadedImageFile): Promise { + return this.saveProfileImageFile(coverImageFile, { + folderSegment: 'profile-covers', + fileFieldName: 'coverImageFile', + fileNamePrefix: 'cover', + }); + } + + private async saveProfileImageFile( + imageFile: UploadedImageFile, + options: { + folderSegment: string; + fileFieldName: 'avatarFile' | 'coverImageFile'; + fileNamePrefix: string; + }, ): Promise { - const extension = this.resolveAvatarExtension(avatarFile); + const extension = this.resolveImageExtension(imageFile); const maxSize = 5 * 1024 * 1024; if (!extension) { - throw new BadRequestException('avatarFile must be png, jpg, jpeg, webp, or gif'); + throw new BadRequestException(`${options.fileFieldName} must be png, jpg, jpeg, webp, or gif`); } - if (avatarFile.size > maxSize) { - throw new BadRequestException('avatarFile size must be 5MB or less'); + if (imageFile.size > maxSize) { + throw new BadRequestException(`${options.fileFieldName} size must be 5MB or less`); } - const uploadDir = join(process.cwd(), 'uploads', 'avatars'); - const fileName = `${randomUUID()}${extension}`; - - await mkdir(uploadDir, { recursive: true }); - await writeFile(join(uploadDir, fileName), avatarFile.buffer); - - return `/uploads/avatars/${encodeURIComponent(fileName)}`; + return this.storageService.saveFile({ + folderSegments: [options.folderSegment], + extension, + buffer: imageFile.buffer, + contentType: imageFile.mimetype, + fileNamePrefix: options.fileNamePrefix, + }); } - private resolveAvatarExtension(avatarFile: { - mimetype?: string; - originalname?: string; - }): string | null { - const originalExtension = extname(avatarFile.originalname ?? '').toLowerCase(); + private resolveImageExtension(imageFile: UploadedImageFile): string | null { + const originalExtension = extname(imageFile.originalname ?? '').toLowerCase(); const allowedExtensions = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']); if (allowedExtensions.has(originalExtension)) { return originalExtension; } - switch (avatarFile.mimetype) { + switch (imageFile.mimetype) { case 'image/png': return '.png'; case 'image/jpeg': @@ -370,30 +457,69 @@ export class UsersService { } } - private async deleteManagedAvatar(avatarUrl?: string): Promise { - const marker = '/uploads/avatars/'; - const markerIndex = avatarUrl?.indexOf(marker) ?? -1; + private async attachUploadedProfileImages( + payload: Record, + avatarFile?: UploadedImageFile, + coverImageFile?: UploadedImageFile, + ): Promise { + const uploadedImageUrls: string[] = []; - if (markerIndex === -1) { - return; + if (avatarFile) { + const uploadedAvatarUrl = await this.saveAvatarFile(avatarFile); + payload.avatar = uploadedAvatarUrl; + uploadedImageUrls.push(uploadedAvatarUrl); } - const encodedFileName = avatarUrl! - .slice(markerIndex + marker.length) - .split('?')[0] - .split('#')[0]; - - if (!encodedFileName) { - return; + if (coverImageFile) { + const uploadedCoverImageUrl = await this.saveCoverImageFile(coverImageFile); + payload.coverImage = uploadedCoverImageUrl; + uploadedImageUrls.push(uploadedCoverImageUrl); } - const fileName = decodeURIComponent(encodedFileName); + return uploadedImageUrls; + } - try { - await unlink(join(process.cwd(), 'uploads', 'avatars', fileName)); - } catch { - // Ignore cleanup failures for already-missing files. + private async updateUserAndCleanupReplacedImages( + userId: string, + payload: Record, + options?: { + currentUser?: UserDocument; + uploadedImageUrls?: string[]; + }, + ): Promise { + const currentUser = options?.currentUser ?? (await this.findByIdOrFail(userId)); + const user = await this.usersRepository.updateById(userId, payload); + + if (!user) { + await Promise.all((options?.uploadedImageUrls ?? []).map((fileUrl) => this.deleteManagedUpload(fileUrl))); + throw new NotFoundException('User not found'); } + + await this.cleanupReplacedUserImages(currentUser, payload); + return user; + } + + private async cleanupReplacedUserImages( + currentUser: UserDocument, + payload: Record, + ): Promise { + const imageFields: Array<'avatar' | 'coverImage'> = ['avatar', 'coverImage']; + + await Promise.all( + imageFields.map(async (field) => { + const nextValue = payload[field]; + if (typeof nextValue !== 'string') { + return; + } + + const currentValue = (currentUser.get(field) as string | undefined) ?? ''; + if (nextValue === currentValue) { + return; + } + + await this.deleteManagedUpload(currentValue); + }), + ); } async updateMusicSetup(userId: string, dto: MusicSetupDto): Promise { @@ -411,7 +537,7 @@ export class UsersService { targetUserId: string, dto: AdminDisableUserDto, ): Promise { - await this.findByIdOrFail(targetUserId); + await this.assertTargetIsManagedUser(targetUserId); const updated = await this.usersRepository.updateById(targetUserId, { isDisabled: true, @@ -433,6 +559,7 @@ export class UsersService { } async enableUserBySuperAdmin(superAdminIdentifier: string, targetUserId: string): Promise { + await this.assertTargetIsManagedUser(targetUserId); const updated = await this.usersRepository.updateById(targetUserId, { isDisabled: false, disabledAt: null, @@ -452,8 +579,11 @@ export class UsersService { } async deleteUserBySuperAdmin(superAdminIdentifier: string, targetUserId: string): Promise { - const user = await this.findByIdOrFail(targetUserId); - await this.deleteUserRelatedData(targetUserId, user.avatar ?? ''); + const user = await this.assertTargetIsManagedUser(targetUserId); + await this.deleteUserRelatedData(targetUserId, { + avatarUrl: user.avatar ?? '', + coverImageUrl: user.coverImage ?? '', + }); await this.usersRepository.deleteById(targetUserId); await this.auditService.logSuperAdminAction( superAdminIdentifier, @@ -474,30 +604,152 @@ export class UsersService { const limit = query.limit ?? 20; const skip = (page - 1) * limit; - const filter: Record = {}; + const filter = this.buildPublicUserFilter(query); - if (query.q) { - filter.$or = [ - { name: { $regex: query.q, $options: 'i' } }, - { username: { $regex: query.q, $options: 'i' } }, - ]; - } - - if (typeof query.isVerified === 'boolean') { - filter.isVerified = query.isVerified; - } + const direction = resolveMongoSortDirection(query.sortOrder); + const sortField = query.sortBy ?? 'createdAt'; + const sort = { [sortField]: direction } as Record; const [items, total] = await Promise.all([ - this.usersRepository.findMany(filter, skip, limit), + this.usersRepository.findMany(filter, skip, limit, sort), this.usersRepository.count(filter), ]); - return { - items, + return buildPaginatedResponse(items, { page, limit, total, - totalPages: Math.ceil(total / limit) || 1, + offset: skip, + }); + } + + async discoverTalents(query: TalentDiscoverQueryDto) { + const page = query.page ?? 1; + const limit = query.limit ?? 8; + const skip = (page - 1) * limit; + + const baseQuery: UserQueryDto = { + ...query, + hasAvatar: query.hasAvatarOnly ?? true, + }; + const filter = this.buildPublicUserFilter(baseQuery, { + forcePublicTalentsOnly: true, + }); + const direction = resolveMongoSortDirection(query.sortOrder); + const sortField = query.sortBy ?? 'followersCount'; + const sort = { [sortField]: direction } as Record; + + const [items, total, roleBuckets] = await Promise.all([ + this.usersRepository.findMany(filter, skip, limit, sort), + this.usersRepository.count(filter), + query.includeRoleBuckets === false + ? Promise.resolve([]) + : this.buildTalentRoleBuckets(baseQuery), + ]); + + const result = buildPaginatedResponse(items, { + page, + limit, + total, + offset: skip, + }); + + return { + ...result, + roleBuckets, + activeRole: query.musicRole ?? null, + }; + } + + async discoverTalentsForSuperAdmin(query: TalentDiscoverQueryDto) { + return this.discoverTalents(query); + } + + async getProfileOverview(userId: string, viewerUserId: string) { + const user = await this.findPublicByIdOrFail(userId); + const objectUserId = new Types.ObjectId(userId); + const postsCollection = this.connection.collection('posts'); + const followsCollection = this.connection.collection('follows'); + + const [reelsCount, audioCount, imageCount, textCount, collaborationsCount, followingState] = + await Promise.all([ + postsCollection.countDocuments({ + authorId: objectUserId, + postType: 'video', + isDeleted: false, + moderationStatus: { $ne: ModerationStatus.HIDDEN }, + }), + postsCollection.countDocuments({ + authorId: objectUserId, + postType: 'audio', + isDeleted: false, + moderationStatus: { $ne: ModerationStatus.HIDDEN }, + }), + postsCollection.countDocuments({ + authorId: objectUserId, + postType: 'image', + isDeleted: false, + moderationStatus: { $ne: ModerationStatus.HIDDEN }, + }), + postsCollection.countDocuments({ + authorId: objectUserId, + postType: 'text', + isDeleted: false, + moderationStatus: { $ne: ModerationStatus.HIDDEN }, + }), + postsCollection.countDocuments({ + isDeleted: false, + moderationStatus: { $ne: ModerationStatus.HIDDEN }, + $or: [ + { authorId: objectUserId, 'taggedUserIds.0': { $exists: true } }, + { authorId: { $ne: objectUserId }, taggedUserIds: objectUserId }, + ], + }), + viewerUserId === userId + ? Promise.resolve(false) + : followsCollection.countDocuments({ + followerId: new Types.ObjectId(viewerUserId), + followingId: objectUserId, + }).then((count) => count > 0), + ]); + + return { + user, + stats: { + followersCount: user.followersCount ?? 0, + followingCount: user.followingCount ?? 0, + postsCount: user.postsCount ?? 0, + collaborationsCount, + }, + contentCounts: { + reels: reelsCount, + audio: audioCount, + image: imageCount, + text: textCount, + other: imageCount + textCount, + }, + tabs: [ + { key: 'reels', postType: 'video', count: reelsCount }, + { key: 'audio', postType: 'audio', count: audioCount }, + { key: 'other', count: imageCount + textCount }, + ], + viewerState: { + isOwnProfile: viewerUserId === userId, + following: followingState, + canMessage: viewerUserId !== userId, + }, + }; + } + + async getProfileOverviewForSuperAdmin(userId: string) { + const overview = await this.getProfileOverview(userId, userId); + return { + ...overview, + viewerState: { + isOwnProfile: false, + following: false, + canMessage: false, + }, }; } @@ -508,7 +760,26 @@ export class UsersService { total: number; totalPages: number; }> { - return this.searchUsers(query); + const page = query.page ?? 1; + const limit = query.limit ?? 20; + const skip = (page - 1) * limit; + + const filter = this.buildAdminUserSearchFilter(query); + const direction = resolveMongoSortDirection(query.sortOrder); + const sortField = query.sortBy ?? 'createdAt'; + const sort = { [sortField]: direction } as Record; + + const [items, total] = await Promise.all([ + this.usersRepository.findMany(filter, skip, limit, sort), + this.usersRepository.count(filter), + ]); + + return buildPaginatedResponse(items, { + page, + limit, + total, + offset: skip, + }); } private async assertTargetIsAdmin(userId: string): Promise { @@ -519,7 +790,21 @@ export class UsersService { return user; } - private async deleteUserRelatedData(userId: string, avatarUrl?: string): Promise { + private async assertTargetIsManagedUser(userId: string): Promise { + const user = await this.findByIdOrFail(userId); + if (user.role === UserRole.SUPERADMIN) { + throw new ForbiddenException('Superadmin accounts cannot be managed through this endpoint'); + } + return user; + } + + private async deleteUserRelatedData( + userId: string, + options?: { + avatarUrl?: string; + coverImageUrl?: string; + }, + ): Promise { if (!Types.ObjectId.isValid(userId)) { return; } @@ -566,8 +851,11 @@ export class UsersService { : []; const localFilesToDelete = new Set(); - if (avatarUrl) { - localFilesToDelete.add(avatarUrl); + if (options?.avatarUrl) { + localFilesToDelete.add(options.avatarUrl); + } + if (options?.coverImageUrl) { + localFilesToDelete.add(options.coverImageUrl); } for (const post of adminPosts) { @@ -629,21 +917,130 @@ export class UsersService { } private async deleteManagedUpload(fileUrl: string): Promise { - if (!fileUrl?.startsWith('/uploads/')) { - return; + await this.storageService.deleteFile(fileUrl); + } + + private buildPublicUserFilter( + query: Pick, + options?: { forcePublicTalentsOnly?: boolean }, + ): Record { + const clauses: Record[] = [{ isDisabled: false }]; + + if (query.q?.trim()) { + clauses.push({ + $or: [ + { name: { $regex: query.q.trim(), $options: 'i' } }, + { username: { $regex: query.q.trim(), $options: 'i' } }, + { stageName: { $regex: query.q.trim(), $options: 'i' } }, + { bio: { $regex: query.q.trim(), $options: 'i' } }, + ], + }); } - const relativePath = fileUrl.split('?')[0].split('#')[0].replace(/^\/+/, ''); - const normalizedPath = relativePath.replace(/\//g, '\\'); - - if (normalizedPath.includes('..')) { - return; + if (typeof query.isVerified === 'boolean') { + clauses.push({ isVerified: query.isVerified }); } - try { - await unlink(join(process.cwd(), normalizedPath)); - } catch { - // Ignore cleanup failures for already-missing files. + if (query.musicRole) { + clauses.push({ musicRoles: query.musicRole }); } + + if (query.experienceLevel) { + clauses.push({ experienceLevel: query.experienceLevel }); + } + + if (typeof query.isPrivate === 'boolean') { + clauses.push({ isPrivate: query.isPrivate }); + } + + if (typeof query.hasAvatar === 'boolean') { + clauses.push( + query.hasAvatar + ? { avatar: { $ne: '' } } + : { + $or: [{ avatar: '' }, { avatar: { $exists: false } }], + }, + ); + } + + if (options?.forcePublicTalentsOnly) { + clauses.push({ role: UserRole.USER }); + clauses.push({ isPrivate: false }); + clauses.push({ musicRoles: { $exists: true, $ne: [] } }); + } + + if (clauses.length === 1) { + return clauses[0]; + } + + return { $and: clauses }; + } + + private async buildTalentRoleBuckets( + query: Pick, + ) { + const roles = Object.values(MusicRole); + return Promise.all( + roles.map(async (role) => ({ + role, + count: await this.usersRepository.count( + this.buildPublicUserFilter( + { + ...query, + musicRole: role, + }, + { forcePublicTalentsOnly: true }, + ), + ), + })), + ); + } + + private buildAdminUserSearchFilter(query: UserQueryDto): Record { + const clauses: Record[] = [{ role: { $ne: UserRole.SUPERADMIN } }]; + + if (query.q?.trim()) { + clauses.push({ + $or: [ + { name: { $regex: query.q.trim(), $options: 'i' } }, + { username: { $regex: query.q.trim(), $options: 'i' } }, + { email: { $regex: query.q.trim(), $options: 'i' } }, + { stageName: { $regex: query.q.trim(), $options: 'i' } }, + { bio: { $regex: query.q.trim(), $options: 'i' } }, + ], + }); + } + + if (typeof query.isVerified === 'boolean') { + clauses.push({ isVerified: query.isVerified }); + } + + if (query.musicRole) { + clauses.push({ musicRoles: query.musicRole }); + } + + if (query.experienceLevel) { + clauses.push({ experienceLevel: query.experienceLevel }); + } + + if (typeof query.isPrivate === 'boolean') { + clauses.push({ isPrivate: query.isPrivate }); + } + + if (typeof query.hasAvatar === 'boolean') { + clauses.push( + query.hasAvatar + ? { avatar: { $ne: '' } } + : { + $or: [{ avatar: '' }, { avatar: { $exists: false } }], + }, + ); + } + + if (clauses.length === 1) { + return clauses[0]; + } + + return { $and: clauses }; } } diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index bbffb13..814da6c 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -1,21 +1,357 @@ +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; import * as request from 'supertest'; import { AppModule } from '../src/app.module'; -describe('AppController (e2e)', () => { +Object.assign(process.env, { + NODE_ENV: 'test', + EMAIL_ENABLED: 'false', + REDIS_ENABLED: 'false', + REDIS_SOCKET_ADAPTER_ENABLED: 'false', + QUEUE_ENABLED: 'false', + REQUEST_LOGGING_ENABLED: 'false', + FEED_CACHE_ENABLED: 'false', + BCRYPT_SALT_ROUNDS: '8', + PUBLIC_BASE_URL: process.env.PUBLIC_BASE_URL ?? 'http://127.0.0.1:4000', + STORAGE_PUBLIC_BASE_URL: + process.env.STORAGE_PUBLIC_BASE_URL ?? process.env.PUBLIC_BASE_URL ?? 'http://127.0.0.1:4000', + MONGODB_URI: process.env.MONGODB_URI ?? 'mongodb://127.0.0.1:27017/oudelaa-e2e', + JWT_ACCESS_SECRET: process.env.JWT_ACCESS_SECRET ?? 'test-access-secret-123456', + JWT_REFRESH_SECRET: process.env.JWT_REFRESH_SECRET ?? 'test-refresh-secret-123456', + SUPERADMIN_EMAIL: process.env.SUPERADMIN_EMAIL ?? 'superadmin@example.com', + SUPERADMIN_PASSWORD: process.env.SUPERADMIN_PASSWORD ?? 'StrongPass123!', + SUPERADMIN_ACCESS_SECRET: + process.env.SUPERADMIN_ACCESS_SECRET ?? 'test-superadmin-access-123456', + SUPERADMIN_REFRESH_SECRET: + process.env.SUPERADMIN_REFRESH_SECRET ?? 'test-superadmin-refresh-123456', +}); + +jest.setTimeout(120000); + +const tinyPngBuffer = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9WlH0i8AAAAASUVORK5CYII=', + 'base64', +); + +describe('Oudelaa smoke (e2e)', () => { let app: INestApplication; - beforeEach(async () => { + const ts = Date.now(); + const primary = { + email: `smoke_primary_${ts}@example.com`, + password: 'StrongPass123!', + accessToken: '', + userId: '', + username: '', + }; + const secondary = { + email: `smoke_secondary_${ts}@example.com`, + password: 'StrongPass123!', + accessToken: '', + userId: '', + username: '', + }; + const tertiary = { + email: `smoke_tertiary_${ts}@example.com`, + password: 'StrongPass123!', + accessToken: '', + userId: '', + username: '', + }; + const superAdmin = { + accessToken: '', + refreshToken: '', + secondAccessToken: '', + secondRefreshToken: '', + }; + let postId = ''; + + beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); app = moduleFixture.createNestApplication(); + const configService = app.get(ConfigService); + app.setGlobalPrefix(configService.get('globalPrefix', 'api/v1')); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: true }, + }), + ); await app.init(); }); - it('/health (GET)', () => { - return request(app.getHttpServer()).get('/health').expect(200); + afterAll(async () => { + if (app) { + await app.close(); + } + }); + + const registerAndVerify = async (user: { + email: string; + password: string; + accessToken: string; + userId: string; + username: string; + }) => { + const registerResponse = await request(app.getHttpServer()) + .post('/api/v1/auth/register-basic') + .send({ + email: user.email, + password: user.password, + confirmPassword: user.password, + }) + .expect(201); + + const verifyResponse = await request(app.getHttpServer()) + .post('/api/v1/auth/verify-email') + .send({ + email: user.email, + code: registerResponse.body.debugCode, + }) + .expect(200); + + user.accessToken = verifyResponse.body.accessToken; + user.userId = verifyResponse.body.user._id || verifyResponse.body.user.id; + user.username = verifyResponse.body.user.username; + }; + + it('/api/v1/health (GET)', () => { + return request(app.getHttpServer()).get('/api/v1/health').expect(200); + }); + + it('registers and verifies smoke users', async () => { + await registerAndVerify(primary); + await registerAndVerify(secondary); + await registerAndVerify(tertiary); + + expect(primary.accessToken).toBeTruthy(); + expect(secondary.username).toBeTruthy(); + expect(tertiary.userId).toBeTruthy(); + }); + + it('updates profile setup with avatar upload', async () => { + const response = await request(app.getHttpServer()) + .patch('/api/v1/users/me/profile-setup') + .set('Authorization', `Bearer ${primary.accessToken}`) + .field('stageName', 'Smoke Artist') + .field('bio', 'Smoke profile bio') + .field('location', 'Riyadh, Saudi Arabia') + .field('latitude', '24.7136') + .field('longitude', '46.6753') + .attach('avatarFile', tinyPngBuffer, { filename: 'avatar.png', contentType: 'image/png' }) + .expect(200); + + expect(response.body.stageName).toBe('Smoke Artist'); + expect(response.body.avatar).toMatch(/^https?:\/\//); + }); + + it('updates music setup and discovers talents', async () => { + await request(app.getHttpServer()) + .patch('/api/v1/users/me/music-setup') + .set('Authorization', `Bearer ${primary.accessToken}`) + .send({ + musicRoles: ['instrumentalist', 'composer'], + musicGenres: ['Tarab'], + experienceLevel: 'intermediate', + favoriteInstruments: ['Oud'], + favoriteMaqamat: ['Rast'], + }) + .expect(200); + + const discoverResponse = await request(app.getHttpServer()) + .get('/api/v1/users/discover?musicRole=instrumentalist&hasAvatarOnly=true&limit=8') + .set('Authorization', `Bearer ${secondary.accessToken}`) + .expect(200); + + expect(discoverResponse.body.items).toBeInstanceOf(Array); + expect(discoverResponse.body.roleBuckets).toBeInstanceOf(Array); + expect(discoverResponse.body.pagination).toBeTruthy(); + + const overviewResponse = await request(app.getHttpServer()) + .get(`/api/v1/users/${primary.userId}/profile-overview`) + .set('Authorization', `Bearer ${secondary.accessToken}`) + .expect(200); + + expect(overviewResponse.body.stats).toBeTruthy(); + expect(overviewResponse.body.contentCounts).toBeTruthy(); + expect(overviewResponse.body.viewerState).toBeTruthy(); + }); + + it('creates an image post with tag and mention', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v1/posts') + .set('Authorization', `Bearer ${primary.accessToken}`) + .field('content', `Smoke image post with @${secondary.username} #smoke`) + .field('visibility', 'public') + .field('taggedUserIds', secondary.userId) + .field('mentionUsernames', secondary.username) + .field('location', 'Riyadh, Saudi Arabia') + .field('latitude', '24.7136') + .field('longitude', '46.6753') + .attach('imageFiles', tinyPngBuffer, { filename: 'post.png', contentType: 'image/png' }) + .expect(201); + + postId = response.body._id || response.body.id; + + expect(postId).toBeTruthy(); + expect(response.body.postType).toBe('image'); + expect(response.body.imageUrls).toBeInstanceOf(Array); + expect(response.body.imageUrls[0]).toMatch(/^https?:\/\//); + expect(response.body.mentionUsernames).toContain(secondary.username.toLowerCase()); + }); + + it('returns unified pagination in feed', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v1/feed/me?includeSuggestions=false&limit=10') + .set('Authorization', `Bearer ${primary.accessToken}`) + .expect(200); + + expect(response.body.items).toBeInstanceOf(Array); + expect(response.body.pagination).toEqual( + expect.objectContaining({ + mode: 'cursor', + limit: 10, + }), + ); + }); + + it('creates mention notification for mentioned post user', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v1/notifications') + .set('Authorization', `Bearer ${secondary.accessToken}`) + .expect(200); + + const mention = (response.body.items ?? []).find( + (item: any) => item.type === 'mention' && (item.referenceId === postId || item.deepLink === `/posts/${postId}`), + ); + + expect(response.body.pagination).toBeTruthy(); + expect(mention).toBeTruthy(); + }); + + it('creates comment mention notification for a third user', async () => { + await request(app.getHttpServer()) + .post('/api/v1/comments') + .set('Authorization', `Bearer ${secondary.accessToken}`) + .send({ + postId, + content: `Great take @${tertiary.username}`, + mentionUsernames: [tertiary.username], + }) + .expect(201); + + const commentsResponse = await request(app.getHttpServer()) + .get(`/api/v1/comments/post/${postId}?page=1&limit=10`) + .set('Authorization', `Bearer ${primary.accessToken}`) + .expect(200); + + expect(commentsResponse.body.pagination).toEqual( + expect.objectContaining({ + mode: 'offset', + limit: 10, + }), + ); + + const notificationsResponse = await request(app.getHttpServer()) + .get('/api/v1/notifications') + .set('Authorization', `Bearer ${tertiary.accessToken}`) + .expect(200); + + const mention = (notificationsResponse.body.items ?? []).find( + (item: any) => item.type === 'mention' && item.resourceType === 'comment', + ); + + expect(mention).toBeTruthy(); + }); + + it('supports superadmin sessions refresh rotation and notifications access', async () => { + const loginOne = await request(app.getHttpServer()) + .post('/api/v1/auth/superadmin/login') + .send({ + email: process.env.SUPERADMIN_EMAIL, + password: process.env.SUPERADMIN_PASSWORD, + }) + .expect(200); + + superAdmin.accessToken = loginOne.body.accessToken; + superAdmin.refreshToken = loginOne.body.refreshToken; + + const sessionsAfterFirstLogin = await request(app.getHttpServer()) + .get('/api/v1/auth/superadmin/sessions') + .set('Authorization', `Bearer ${superAdmin.accessToken}`) + .expect(200); + + const countAfterFirstLogin = (sessionsAfterFirstLogin.body.items ?? []).length; + + const loginTwo = await request(app.getHttpServer()) + .post('/api/v1/auth/superadmin/login') + .send({ + email: process.env.SUPERADMIN_EMAIL, + password: process.env.SUPERADMIN_PASSWORD, + }) + .expect(200); + + superAdmin.secondAccessToken = loginTwo.body.accessToken; + superAdmin.secondRefreshToken = loginTwo.body.refreshToken; + + const initialSessions = await request(app.getHttpServer()) + .get('/api/v1/auth/superadmin/sessions') + .set('Authorization', `Bearer ${superAdmin.accessToken}`) + .expect(200); + + const initialItems = initialSessions.body.items ?? []; + expect(initialItems).toHaveLength(countAfterFirstLogin + 1); + + const recentSessionIds = initialItems + .slice(0, 2) + .map((item: { id?: string }) => item.id) + .filter((id: string | undefined): id is string => Boolean(id)); + + const refreshResponse = await request(app.getHttpServer()) + .post('/api/v1/auth/superadmin/refresh') + .send({ refreshToken: superAdmin.refreshToken }) + .expect(200); + + superAdmin.accessToken = refreshResponse.body.accessToken; + superAdmin.refreshToken = refreshResponse.body.refreshToken; + + const refreshedSessions = await request(app.getHttpServer()) + .get('/api/v1/auth/superadmin/sessions') + .set('Authorization', `Bearer ${superAdmin.accessToken}`) + .expect(200); + + const refreshedItems = refreshedSessions.body.items ?? []; + expect(refreshedItems).toHaveLength(initialItems.length); + + const notificationsResponse = await request(app.getHttpServer()) + .get('/api/v1/notifications/superadmin?limit=10') + .set('Authorization', `Bearer ${superAdmin.accessToken}`) + .expect(200); + + expect(notificationsResponse.body.items).toBeInstanceOf(Array); + + const secondarySession = refreshedItems.find( + (item: { id?: string }) => item.id && recentSessionIds.includes(item.id), + ); + + expect(secondarySession?.id).toBeTruthy(); + + await request(app.getHttpServer()) + .post(`/api/v1/auth/superadmin/sessions/${secondarySession.id}/revoke`) + .set('Authorization', `Bearer ${superAdmin.accessToken}`) + .expect(201); + + const sessionsAfterRevoke = await request(app.getHttpServer()) + .get('/api/v1/auth/superadmin/sessions') + .set('Authorization', `Bearer ${superAdmin.accessToken}`) + .expect(200); + + expect(sessionsAfterRevoke.body.items ?? []).toHaveLength(initialItems.length - 1); }); }); diff --git a/test/jest-e2e.json b/test/jest-e2e.json index e9d912f..3536f4d 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -2,6 +2,7 @@ "moduleFileExtensions": ["js", "json", "ts"], "rootDir": ".", "testEnvironment": "node", + "testTimeout": 120000, "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" diff --git a/tsconfig.build.json b/tsconfig.build.json index 64f86c6..ba8eb2c 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] + "exclude": ["node_modules", "test", "dist", "oudelaa_dashboard", "**/*spec.ts"] }