feat: expand backend admin marketplace and scaling
فشلت بعض الفحوصات
/ deploy (push) Failing after 1m22s

هذا الالتزام موجود في:
2026-05-14 16:17:12 +03:00
الأصل 0e76a4a9fc
التزام 5bd5e19a89
158 ملفات معدلة مع 19563 إضافات و3315 حذوفات

عرض الملف

@@ -1,9 +1,12 @@
# For mobile/LAN testing set PUBLIC_BASE_URL to your machine IP, not localhost.
# Example: PUBLIC_BASE_URL=http://192.168.1.12:4000
NODE_ENV=development NODE_ENV=development
PORT=4000 PORT=4000
HOST=0.0.0.0 HOST=0.0.0.0
PUBLIC_BASE_URL=http://localhost:4000 PUBLIC_BASE_URL=http://localhost:4000
RESPONSE_ENVELOPE_ENABLED=false RESPONSE_ENVELOPE_ENABLED=false
GLOBAL_PREFIX=api/v1 GLOBAL_PREFIX=api/v1
# Add every frontend origin used by web/mobile debug tools.
CORS_ORIGINS=http://localhost:3000,http://192.168.1.14:3000,http://192.168.1.14:5173 CORS_ORIGINS=http://localhost:3000,http://192.168.1.14:3000,http://192.168.1.14:5173
MONGODB_URI=mongodb://127.0.0.1:27017/oudelaa MONGODB_URI=mongodb://127.0.0.1:27017/oudelaa
JWT_ACCESS_SECRET=change_me_access_secret JWT_ACCESS_SECRET=change_me_access_secret
@@ -11,6 +14,7 @@ JWT_ACCESS_EXPIRES_IN=15m
JWT_REFRESH_SECRET=change_me_refresh_secret JWT_REFRESH_SECRET=change_me_refresh_secret
JWT_REFRESH_EXPIRES_IN=30d JWT_REFRESH_EXPIRES_IN=30d
BCRYPT_SALT_ROUNDS=12 BCRYPT_SALT_ROUNDS=12
REFRESH_TOKEN_HASH_SECRET=
PASSWORD_RESET_CODE_EXPIRES_MINUTES=10 PASSWORD_RESET_CODE_EXPIRES_MINUTES=10
PASSWORD_RESET_MAX_ATTEMPTS=5 PASSWORD_RESET_MAX_ATTEMPTS=5
PASSWORD_RESET_TOKEN_SECRET= PASSWORD_RESET_TOKEN_SECRET=
@@ -21,9 +25,43 @@ SWAGGER_TITLE=Oudelaa API
SWAGGER_DESCRIPTION=Social media backend API documentation SWAGGER_DESCRIPTION=Social media backend API documentation
SWAGGER_VERSION=1.0.0 SWAGGER_VERSION=1.0.0
SWAGGER_PATH=docs SWAGGER_PATH=docs
LOG_LEVEL=log
REQUEST_LOGGING_ENABLED=true
FEED_CACHE_ENABLED=true
FEED_CACHE_USER_TTL_SECONDS=15
FEED_CACHE_TRENDING_TTL_SECONDS=30
REDIS_ENABLED=false
REDIS_URL=
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_USERNAME=
REDIS_PASSWORD=
REDIS_DB=0
REDIS_KEY_PREFIX=oudelaa
REDIS_SOCKET_ADAPTER_ENABLED=false
QUEUE_ENABLED=false
QUEUE_NAME=app-jobs
QUEUE_DEFAULT_ATTEMPTS=3
QUEUE_DEFAULT_BACKOFF_MS=1000
QUEUE_REMOVE_ON_COMPLETE=true
QUEUE_WORKER_CONCURRENCY=5
STORAGE_PROVIDER=local
STORAGE_BASE_PATH=uploads
# Leave empty for local storage unless you want a dedicated CDN/base URL.
STORAGE_PUBLIC_BASE_URL=
S3_BUCKET=
S3_REGION=auto
S3_ENDPOINT=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_FORCE_PATH_STYLE=false
GOOGLE_CLIENT_ID=your_google_client_id GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret GOOGLE_CLIENT_SECRET=your_google_client_secret
# Match this to the same reachable host/IP used in PUBLIC_BASE_URL.
GOOGLE_CALLBACK_URL=http://192.168.1.14:4000/api/v1/auth/google/callback GOOGLE_CALLBACK_URL=http://192.168.1.14:4000/api/v1/auth/google/callback
EMAIL_ENABLED=false EMAIL_ENABLED=false
EMAIL_SMTP_HOST=smtp.gmail.com EMAIL_SMTP_HOST=smtp.gmail.com
@@ -33,6 +71,11 @@ EMAIL_SMTP_USER=
EMAIL_SMTP_PASS= EMAIL_SMTP_PASS=
EMAIL_FROM_NAME=Oudelaa EMAIL_FROM_NAME=Oudelaa
EMAIL_FROM_EMAIL= EMAIL_FROM_EMAIL=
AI_MUSIC_ENABLED=false
AI_MUSIC_API_KEY=
AI_MUSIC_PROJECT_ID=
AI_MUSIC_LOCATION=us-central1
AI_MUSIC_MODEL=lyria-002
SUPERADMIN_EMAIL=admin@oudelaa.com SUPERADMIN_EMAIL=admin@oudelaa.com
@@ -41,4 +84,3 @@ SUPERADMIN_ACCESS_SECRET=change_me_superadmin_access_secret
SUPERADMIN_ACCESS_EXPIRES_IN=15m SUPERADMIN_ACCESS_EXPIRES_IN=15m
SUPERADMIN_REFRESH_SECRET=change_me_superadmin_refresh_secret SUPERADMIN_REFRESH_SECRET=change_me_superadmin_refresh_secret
SUPERADMIN_REFRESH_EXPIRES_IN=30d SUPERADMIN_REFRESH_EXPIRES_IN=30d

6
.gitignore مباع
عرض الملف

@@ -10,3 +10,9 @@ uploads
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
*.log
*.err.log
# Local workspace
.vscode/
oudelaa_dashboard/

118
PERFORMANCE_TESTING.md Normal file
عرض الملف

@@ -0,0 +1,118 @@
# Performance Testing
This project now includes built-in scripts to check correctness, startup time, endpoint latency, and basic load.
## 1. Correctness first
Run these before any performance test:
```powershell
npm run build
npm test -- --runInBand
npm run test:e2e -- --runInBand
```
## 2. Startup time
Build the app, then measure cold start:
```powershell
npm run build
npm run perf:startup
```
Optional parameters:
```powershell
node scripts/startup-benchmark.js --port 4200 --timeout 45000
```
## 3. Health endpoint load
If the API is already running on port `4000`:
```powershell
npm run perf:health
```
This runs a simple GET benchmark against `http://127.0.0.1:4000/api/v1/health`.
## 4. Custom endpoint load
Examples:
```powershell
node scripts/load-test.js --url http://127.0.0.1:4000/api/v1/health --duration 20 --concurrency 50
node scripts/load-test.js --url http://127.0.0.1:4000/api/v1/feed/trending --header "Authorization: Bearer YOUR_TOKEN" --duration 30 --concurrency 25
node scripts/load-test.js --url http://127.0.0.1:4000/api/v1/auth/login --method POST --body "{\"email\":\"user@example.com\",\"password\":\"secret\"}" --duration 20 --concurrency 10
```
Supported options:
- `--url`
- `--method`
- `--duration`
- `--concurrency`
- `--timeout`
- `--warmup`
- `--header "Key: Value"`
- `--body`
- `--body-file`
## 5. What to watch
- `requestsPerSecond`: throughput
- `successRate`: percentage of successful requests
- `non2xxCount`: server or validation failures
- `timeoutCount`: slow requests
- `latencyMs.p95` and `latencyMs.p99`: tail latency under load
## 6. Practical test order
1. `build`
2. unit tests
3. e2e tests
4. startup benchmark
5. health benchmark
6. authenticated benchmarks for hot endpoints:
- `auth/login`
- `feed/me`
- `feed/trending`
- `posts`
- `chat/messages`
- `notifications`
## 7. Real API benchmark examples
Login:
```powershell
node scripts/load-test.js --url http://127.0.0.1:4000/api/v1/auth/login --method POST --header "Content-Type: application/json" --body "{\"email\":\"user@example.com\",\"password\":\"secret123\"}" --duration 20 --concurrency 10
```
Trending feed with bearer token:
```powershell
node scripts/load-test.js --url http://127.0.0.1:4000/api/v1/feed/trending --header "Authorization: Bearer YOUR_ACCESS_TOKEN" --duration 30 --concurrency 25
```
Authenticated user feed:
```powershell
node scripts/load-test.js --url http://127.0.0.1:4000/api/v1/feed/me?limit=20 --header "Authorization: Bearer YOUR_ACCESS_TOKEN" --duration 30 --concurrency 15
```
Notifications:
```powershell
node scripts/load-test.js --url http://127.0.0.1:4000/api/v1/notifications --header "Authorization: Bearer YOUR_ACCESS_TOKEN" --duration 20 --concurrency 15
```
## 8. Limits
These scripts are useful local benchmarks, not full production profiling. They do not replace:
- database profiling
- CPU and memory profiling
- distributed load tools like k6 or Gatling
- multi-instance tests behind a reverse proxy

عرض الملف

@@ -1,3 +1,5 @@
# Oudelaa Backend # Oudelaa Backend
Production-oriented NestJS backend for a social media platform. Production-oriented NestJS backend for a social media platform.
Frontend handoff notes and WebSocket event docs live in [docs/FRONTEND_INTEGRATION.md](docs/FRONTEND_INTEGRATION.md).

85
SCALING_SETUP.md Normal file
عرض الملف

@@ -0,0 +1,85 @@
# Scaling Setup
This codebase now supports optional Redis, BullMQ, S3-compatible storage, structured JSON logging, and feed caching.
## What was added
- Redis-backed cache and rate limiting fallback to in-memory
- Optional Socket.IO Redis adapter
- Optional BullMQ queue for outbox processing
- Pluggable storage layer with:
- `local`
- `s3` compatible providers such as AWS S3 or Cloudflare R2
- Feed response caching with versioned invalidation
- Fast refresh-token fingerprinting to reduce bcrypt load
- JSON request logging
## Feature flags
### Redis
```env
REDIS_ENABLED=true
REDIS_URL=redis://127.0.0.1:6379
REDIS_KEY_PREFIX=oudelaa
REDIS_SOCKET_ADAPTER_ENABLED=true
```
### Queue
```env
QUEUE_ENABLED=true
QUEUE_NAME=app-jobs
QUEUE_DEFAULT_ATTEMPTS=3
QUEUE_DEFAULT_BACKOFF_MS=1000
QUEUE_WORKER_CONCURRENCY=5
```
Queue processing falls back to in-process execution when Redis/queue is disabled.
### S3 / R2
```env
STORAGE_PROVIDER=s3
STORAGE_BASE_PATH=uploads
STORAGE_PUBLIC_BASE_URL=https://cdn.example.com
S3_BUCKET=oudelaa
S3_REGION=auto
S3_ENDPOINT=https://<account-or-endpoint>
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_FORCE_PATH_STYLE=false
```
For Cloudflare R2, `S3_REGION=auto` is acceptable and `STORAGE_PUBLIC_BASE_URL` should usually point to the CDN/custom domain.
### Logging
```env
LOG_LEVEL=log
REQUEST_LOGGING_ENABLED=true
```
### Feed cache
```env
FEED_CACHE_ENABLED=true
FEED_CACHE_USER_TTL_SECONDS=15
FEED_CACHE_TRENDING_TTL_SECONDS=30
```
## Practical rollout order
1. Enable JSON logging in staging
2. Enable Redis cache and Redis rate limiting
3. Enable BullMQ queue for outbox jobs
4. Move uploads to S3/R2
5. Enable Socket.IO Redis adapter when running multiple instances
6. Run authenticated load tests against `auth`, `feed`, `posts`, `chat`, and `notifications`
## Current limitations
- The app is still a modular monolith, not separate microservices yet
- Feed caching is versioned invalidation plus TTL, not full fan-out precomputation
- Marketplace images are still URL-based data, not binary upload pipelines
- Local tests do not replace full production benchmarking

عرض الملف

@@ -0,0 +1,248 @@
# Frontend Integration
## Network setup
When testing from a phone or another device on the same LAN:
1. Set `HOST=0.0.0.0`
2. Set `PUBLIC_BASE_URL` to the machine IP, not `localhost`
3. Add frontend origins to `CORS_ORIGINS`
4. Keep `GOOGLE_CALLBACK_URL` on the same reachable host if Google auth is used
Example:
```env
HOST=0.0.0.0
PUBLIC_BASE_URL=http://192.168.1.12:4000
CORS_ORIGINS=http://192.168.1.12:3000,http://192.168.1.12:5173
GOOGLE_CALLBACK_URL=http://192.168.1.12:4000/api/v1/auth/google/callback
```
With `PUBLIC_BASE_URL` configured, file fields such as `avatar`, `coverImage`, `imageUrls`, `videoUrl`, `audioUrl`, `thumbnailUrl`, `mediaUrl`, and marketplace images are returned as absolute URLs.
## Pagination contract
List endpoints now keep the legacy fields and also return a unified `pagination` object.
Example:
```json
{
"items": [],
"count": 0,
"page": 1,
"limit": 20,
"total": 0,
"totalPages": 1,
"nextCursor": null,
"pagination": {
"mode": "offset",
"page": 1,
"limit": 20,
"count": 0,
"total": 0,
"totalPages": 1,
"hasNextPage": false,
"hasPreviousPage": false,
"nextPage": null,
"previousPage": null,
"currentCursor": null,
"nextCursor": null
}
}
```
Notes:
- Offset endpoints use `page` and `limit`
- Cursor-aware endpoints also return `nextCursor`
- Existing clients can keep reading the old top-level fields
- `sortOrder=asc|desc` is available on paginated endpoints
## Filter and sorting contract
Boolean query filters are parsed consistently now. Send:
- `?isActive=true`
- `?isActive=false`
- `?read=true`
- `?read=false`
- `?followingOnly=true`
Common conventions:
- `page`, `limit`, `sortOrder`
- `sortOrder` defaults to `desc`
- `sortBy` is supported on selected endpoints with endpoint-specific allowed values
Supported filters:
- `GET /marketplace/home`
- `listingsLimit`, `instrumentsLimit`, `repairShopsLimit`, `onlyActive`
- `GET /users`
- `q`, `isVerified`, `musicRole`, `experienceLevel`, `isPrivate`, `hasAvatar`, `sortBy`
- `sortBy`: `createdAt`, `name`, `username`, `followersCount`, `postsCount`
- `GET /users/discover`
- `q`, `musicRole`, `experienceLevel`, `hasAvatarOnly`, `includeRoleBuckets`, `sortBy`, `sortOrder`
- `GET /users/:id/profile-overview`
- returns `stats`, `contentCounts`, `tabs`, and `viewerState`
- `GET /posts/user/:userId`
- `visibility`, `postType`, `q`, `hashtag`, `sortBy`
- `sortBy`: `createdAt`, `updatedAt`, `likesCount`, `commentsCount`, `savesCount`, `shareCount`, `viewCount`, `playCount`
- `GET /posts/reels`
- `visibility`, `authorId`, `q`, `sortBy`
- `GET /marketplace/listings`
- `q`, `minPrice`, `maxPrice`, `isActive`, `listingCategory`, `condition`, `instrumentType`, `sortBy`
- `listingCategory`: `musical_instrument`, `accessory`, `audio_gear`, `sheet_music`, `other`
- `GET /marketplace/instruments`
- `q`, `minPrice`, `maxPrice`, `isActive`, `condition`, `instrumentType`, `sortBy`
- this route is now a musical-instruments-only view of marketplace listings
- `sortBy`: `createdAt`, `updatedAt`, `price`, `title`
- `GET /marketplace/repair-shops`
- `q`, `isActive`, `sortBy`
- `sortBy`: `createdAt`, `updatedAt`, `name`
- `GET /notifications`
- `read`, `type`, `resourceType`, `sortOrder`
- `GET /comments/post/:postId`
- `page`, `limit`, `sortOrder`
## WebSocket auth
Both namespaces accept the JWT access token in one of these places:
- `auth.token`
- `Authorization` header as `Bearer <token>`
## Chat namespace
Namespace: `/chat`
Client emits:
- `join_conversation`
- payload: `{ "conversationId": "..." }`
- `send_message`
- payload matches `SendMessageDto`
- `typing`
- payload: `{ "conversationId": "...", "isTyping": true }`
- `mark_seen`
- payload: `{ "messageId": "...", "conversationId": "..." }`
Server emits:
- `presence`
- payload: `{ "userId": "...", "online": true }`
- `joined_conversation`
- payload: `{ "conversationId": "..." }`
- `new_message`
- payload: message object
- `typing`
- payload: `{ "conversationId": "...", "userId": "...", "isTyping": true }`
- `message_seen`
- payload: `{ "messageId": "...", "userId": "..." }`
## Notifications namespace
Namespace: `/notifications`
Server emits:
- `notification_created`
- payload: notification object
- `notifications_unread_count`
- payload: `{ "unreadCount": 3 }`
Notification types currently used:
- `like`
- `comment`
- `follow`
- `message`
- `save`
- `share`
- `mention`
## Mentions
Posts and comments support:
- `taggedUserIds`: official tagged users by Mongo id
- `mentionUsernames`: usernames such as `["rami_sabry"]`
The backend also extracts `@username` from `content` automatically and emits mention notifications for matched users.
## Marketplace split
Marketplace is now separated from musical instruments at the API contract level:
- `GET /marketplace/listings`
- general sale listings across all listing categories
- `GET /marketplace/instruments`
- only musical instruments
- `GET /marketplace/repair-shops`
- service providers and maintenance shops
- `GET /marketplace/home`
- frontend-friendly grouped payload with `categories`, `summary`, `filters.listingCategories`, `featuredShops`, and `sections`
Admin endpoints follow the same split:
- `POST /marketplace/admin/listings`
- `GET /marketplace/admin/listings/me`
- `PATCH /marketplace/admin/listings/:id`
- `DELETE /marketplace/admin/listings/:id`
The older `/marketplace/admin/instruments*` routes still exist and always force `listingCategory=musical_instrument` for backward compatibility.
Marketplace shop ownership rule:
- each admin can own one repair-shop / marketplace shop record only
- if the admin already created one, the frontend should call update endpoints instead of create
Listing responses now also include:
- `shop`
- `{ adminId, name, username, avatar }`
- `storeName`
- direct shortcut for list cards
- `condition`
- `new`, `like_new`, `used`, `refurbished`
- `instrumentType`
- free-text type such as `oud`, `piano`, or `violin`
Search on marketplace listings now matches:
- listing title
- listing description
- instrument type
- shop name
## Talent discovery
The talents screen can now be built from:
- `GET /users/discover`
- paginated talent cards
- `roleBuckets` counts for tabs such as instrumentalists, singers, producers, and lyricists
- `GET /users/:id/profile-overview`
- summary for the profile header and tab counts
`profile-overview` returns:
- `stats.followersCount`
- `stats.followingCount`
- `stats.postsCount`
- `stats.collaborationsCount`
- `contentCounts.reels`
- `contentCounts.audio`
- `contentCounts.other`
- `viewerState.following`
## Smoke / e2e
Run:
```bash
npm run test:e2e
```
The smoke suite covers health, auth verification, profile setup, image upload, post creation, feed retrieval, notifications, and comment mentions.

2032
package-lock.json مولّد

تم حذف اختلاف الملف لأن الملف كبير جداً تحميل الاختلاف

عرض الملف

@@ -12,9 +12,14 @@
"lint": "eslint \"src/**/*.ts\" --fix", "lint": "eslint \"src/**/*.ts\" --fix",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:e2e": "jest --config ./test/jest-e2e.json" "test:e2e": "jest --runInBand --config ./test/jest-e2e.json",
"perf:load": "node scripts/load-test.js",
"perf:health": "node scripts/load-test.js --url http://127.0.0.1:4000/api/v1/health --duration 15 --concurrency 20",
"perf:startup": "node scripts/startup-benchmark.js"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1041.0",
"@aws-sdk/lib-storage": "^3.1041.0",
"@nestjs/common": "^10.4.0", "@nestjs/common": "^10.4.0",
"@nestjs/config": "^3.2.3", "@nestjs/config": "^3.2.3",
"@nestjs/core": "^10.4.0", "@nestjs/core": "^10.4.0",
@@ -25,11 +30,14 @@
"@nestjs/platform-socket.io": "^10.4.0", "@nestjs/platform-socket.io": "^10.4.0",
"@nestjs/swagger": "^8.1.0", "@nestjs/swagger": "^8.1.0",
"@nestjs/websockets": "^10.4.0", "@nestjs/websockets": "^10.4.0",
"@socket.io/redis-adapter": "^8.3.0",
"@types/passport-google-oauth20": "^2.0.17", "@types/passport-google-oauth20": "^2.0.17",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"bullmq": "^5.76.5",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"google-auth-library": "^10.6.2", "google-auth-library": "^10.6.2",
"ioredis": "^5.10.1",
"joi": "^17.13.3", "joi": "^17.13.3",
"mongoose": "^8.6.0", "mongoose": "^8.6.0",
"nodemailer": "^8.0.5", "nodemailer": "^8.0.5",

تم حذف اختلاف الملف لأن الملف كبير جداً تحميل الاختلاف

260
scripts/load-test.js Normal file
عرض الملف

@@ -0,0 +1,260 @@
const { readFileSync } = require('fs');
const { performance } = require('perf_hooks');
function parseArgs(argv) {
const options = {
url: 'http://127.0.0.1:4000/api/v1/health',
method: 'GET',
duration: 15,
concurrency: 20,
timeout: 5000,
warmup: 5,
headers: {},
body: undefined,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const next = argv[index + 1];
if (arg === '--url' && next) {
options.url = next;
index += 1;
continue;
}
if (arg === '--method' && next) {
options.method = next.toUpperCase();
index += 1;
continue;
}
if (arg === '--duration' && next) {
options.duration = Number(next);
index += 1;
continue;
}
if (arg === '--concurrency' && next) {
options.concurrency = Number(next);
index += 1;
continue;
}
if (arg === '--timeout' && next) {
options.timeout = Number(next);
index += 1;
continue;
}
if (arg === '--warmup' && next) {
options.warmup = Number(next);
index += 1;
continue;
}
if (arg === '--header' && next) {
const separatorIndex = next.indexOf(':');
if (separatorIndex === -1) {
throw new Error(`Invalid header format: ${next}`);
}
const key = next.slice(0, separatorIndex).trim();
const value = next.slice(separatorIndex + 1).trim();
options.headers[key] = value;
index += 1;
continue;
}
if (arg === '--body' && next) {
options.body = next;
index += 1;
continue;
}
if (arg === '--body-file' && next) {
options.body = readFileSync(next, 'utf8');
index += 1;
continue;
}
}
if (!Number.isFinite(options.duration) || options.duration <= 0) {
throw new Error('duration must be a positive number of seconds');
}
if (!Number.isInteger(options.concurrency) || options.concurrency <= 0) {
throw new Error('concurrency must be a positive integer');
}
if (!Number.isFinite(options.timeout) || options.timeout <= 0) {
throw new Error('timeout must be a positive number of milliseconds');
}
if (!Number.isInteger(options.warmup) || options.warmup < 0) {
throw new Error('warmup must be zero or a positive integer');
}
if (options.body && !options.headers['Content-Type']) {
options.headers['Content-Type'] = 'application/json';
}
return options;
}
function percentile(sortedValues, p) {
if (!sortedValues.length) {
return 0;
}
const rank = Math.ceil((p / 100) * sortedValues.length) - 1;
const index = Math.min(sortedValues.length - 1, Math.max(0, rank));
return sortedValues[index];
}
function average(values) {
if (!values.length) {
return 0;
}
const total = values.reduce((sum, value) => sum + value, 0);
return total / values.length;
}
function printUsage() {
console.log('Usage: node scripts/load-test.js --url <url> [--duration 15] [--concurrency 20]');
console.log('Optional: --method POST --header "Authorization: Bearer <token>" --body "{\"key\":\"value\"}"');
}
async function warmup(options) {
if (options.warmup === 0) {
return;
}
for (let index = 0; index < options.warmup; index += 1) {
const response = await fetch(options.url, {
method: options.method,
headers: options.headers,
body: options.body,
});
await response.arrayBuffer();
}
}
async function runWorker(options, results, endAt) {
while (Date.now() < endAt) {
const startedAt = performance.now();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), options.timeout);
try {
const response = await fetch(options.url, {
method: options.method,
headers: options.headers,
body: options.body,
signal: controller.signal,
});
const payload = await response.arrayBuffer();
const durationMs = performance.now() - startedAt;
results.latencies.push(durationMs);
results.totalRequests += 1;
results.totalBytes += payload.byteLength;
const statusKey = String(response.status);
results.statusCounts[statusKey] = (results.statusCounts[statusKey] ?? 0) + 1;
if (response.ok) {
results.successCount += 1;
} else {
results.non2xxCount += 1;
}
} catch (error) {
const durationMs = performance.now() - startedAt;
results.latencies.push(durationMs);
results.totalRequests += 1;
if (error && typeof error === 'object' && error.name === 'AbortError') {
results.timeoutCount += 1;
} else {
results.networkErrorCount += 1;
}
} finally {
clearTimeout(timeoutId);
}
}
}
function buildSummary(options, results, totalDurationMs) {
const latencies = [...results.latencies].sort((left, right) => left - right);
const requestsPerSecond = results.totalRequests / (totalDurationMs / 1000);
const successRate = results.totalRequests === 0 ? 0 : (results.successCount / results.totalRequests) * 100;
return {
target: options.url,
method: options.method,
durationSeconds: Number((totalDurationMs / 1000).toFixed(2)),
concurrency: options.concurrency,
totalRequests: results.totalRequests,
successCount: results.successCount,
non2xxCount: results.non2xxCount,
timeoutCount: results.timeoutCount,
networkErrorCount: results.networkErrorCount,
requestsPerSecond: Number(requestsPerSecond.toFixed(2)),
successRate: Number(successRate.toFixed(2)),
transferredBytes: results.totalBytes,
latencyMs: {
min: Number((latencies[0] ?? 0).toFixed(2)),
avg: Number(average(latencies).toFixed(2)),
p50: Number(percentile(latencies, 50).toFixed(2)),
p90: Number(percentile(latencies, 90).toFixed(2)),
p95: Number(percentile(latencies, 95).toFixed(2)),
p99: Number(percentile(latencies, 99).toFixed(2)),
max: Number((latencies[latencies.length - 1] ?? 0).toFixed(2)),
},
statusCounts: results.statusCounts,
};
}
async function main() {
if (process.argv.includes('--help')) {
printUsage();
return;
}
const options = parseArgs(process.argv.slice(2));
console.log(`Warmup: ${options.warmup} request(s) to ${options.url}`);
await warmup(options);
const results = {
latencies: [],
totalRequests: 0,
successCount: 0,
non2xxCount: 0,
timeoutCount: 0,
networkErrorCount: 0,
totalBytes: 0,
statusCounts: {},
};
const startedAt = performance.now();
const endAt = Date.now() + options.duration * 1000;
await Promise.all(
Array.from({ length: options.concurrency }, () => runWorker(options, results, endAt)),
);
const totalDurationMs = performance.now() - startedAt;
const summary = buildSummary(options, results, totalDurationMs);
console.log('');
console.log(JSON.stringify(summary, null, 2));
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});

عرض الملف

@@ -0,0 +1,150 @@
const { existsSync } = require('fs');
const { spawn } = require('child_process');
const { performance } = require('perf_hooks');
function parseArgs(argv) {
const options = {
entry: 'dist/main.js',
port: 4100,
timeout: 30000,
path: '/api/v1/health',
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const next = argv[index + 1];
if (arg === '--entry' && next) {
options.entry = next;
index += 1;
continue;
}
if (arg === '--port' && next) {
options.port = Number(next);
index += 1;
continue;
}
if (arg === '--timeout' && next) {
options.timeout = Number(next);
index += 1;
continue;
}
if (arg === '--path' && next) {
options.path = next.startsWith('/') ? next : `/${next}`;
index += 1;
}
}
if (!existsSync(options.entry)) {
throw new Error(`Entry file not found: ${options.entry}. Run "npm run build" first.`);
}
if (!Number.isInteger(options.port) || options.port <= 0) {
throw new Error('port must be a positive integer');
}
if (!Number.isFinite(options.timeout) || options.timeout <= 0) {
throw new Error('timeout must be a positive number of milliseconds');
}
return options;
}
async function waitForHealth(url, timeoutMs) {
const deadline = Date.now() + timeoutMs;
let lastError = null;
while (Date.now() < deadline) {
try {
const response = await fetch(url, { method: 'GET' });
if (response.ok) {
const body = await response.text();
return { status: response.status, body };
}
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
if (lastError instanceof Error) {
throw new Error(`Startup timeout. Last error: ${lastError.message}`);
}
throw new Error('Startup timeout. Health endpoint did not become ready.');
}
async function terminate(child) {
if (child.exitCode !== null) {
return;
}
child.kill();
await new Promise((resolve) => {
child.once('exit', resolve);
setTimeout(resolve, 5000);
});
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const url = `http://127.0.0.1:${options.port}${options.path}`;
const stdoutLines = [];
const stderrLines = [];
const startedAt = performance.now();
const child = spawn(process.execPath, [options.entry], {
cwd: process.cwd(),
env: {
...process.env,
PORT: String(options.port),
PUBLIC_BASE_URL: `http://127.0.0.1:${options.port}`,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
child.stdout.on('data', (chunk) => {
stdoutLines.push(...String(chunk).split(/\r?\n/).filter(Boolean));
if (stdoutLines.length > 20) {
stdoutLines.splice(0, stdoutLines.length - 20);
}
});
child.stderr.on('data', (chunk) => {
stderrLines.push(...String(chunk).split(/\r?\n/).filter(Boolean));
if (stderrLines.length > 20) {
stderrLines.splice(0, stderrLines.length - 20);
}
});
try {
const healthResult = await waitForHealth(url, options.timeout);
const readyMs = performance.now() - startedAt;
console.log(
JSON.stringify(
{
entry: options.entry,
url,
startupMs: Number(readyMs.toFixed(2)),
healthStatus: healthResult.status,
recentStdout: stdoutLines,
recentStderr: stderrLines,
},
null,
2,
),
);
} finally {
await terminate(child);
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});

عرض الملف

@@ -6,6 +6,11 @@ import { AppService } from './app.service';
import configuration from './config/configuration'; import configuration from './config/configuration';
import { validationSchema } from './config/validation.schema'; import { validationSchema } from './config/validation.schema';
import { DatabaseModule } from './database/database.module'; import { DatabaseModule } from './database/database.module';
import { CacheModule } from './infrastructure/cache/cache.module';
import { LoggingModule } from './infrastructure/logging/logging.module';
import { QueueModule } from './infrastructure/queue/queue.module';
import { RedisModule } from './infrastructure/redis/redis.module';
import { StorageModule } from './infrastructure/storage/storage.module';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { AuditModule } from './modules/audit/audit.module'; import { AuditModule } from './modules/audit/audit.module';
import { ChatModule } from './modules/chat/chat.module'; import { ChatModule } from './modules/chat/chat.module';
@@ -19,6 +24,7 @@ import { NotificationsModule } from './modules/notifications/notifications.modul
import { OutboxModule } from './modules/outbox/outbox.module'; import { OutboxModule } from './modules/outbox/outbox.module';
import { PostsModule } from './modules/posts/posts.module'; import { PostsModule } from './modules/posts/posts.module';
import { SavesModule } from './modules/saves/saves.module'; import { SavesModule } from './modules/saves/saves.module';
import { SuperAdminModule } from './modules/superadmin/superadmin.module';
import { UsersModule } from './modules/users/users.module'; import { UsersModule } from './modules/users/users.module';
import { ThrottleGuard } from './common/guards/throttle.guard'; import { ThrottleGuard } from './common/guards/throttle.guard';
@@ -30,6 +36,11 @@ import { ThrottleGuard } from './common/guards/throttle.guard';
load: [configuration], load: [configuration],
validationSchema, validationSchema,
}), }),
LoggingModule,
RedisModule,
CacheModule,
StorageModule,
QueueModule,
DatabaseModule, DatabaseModule,
AuditModule, AuditModule,
UsersModule, UsersModule,
@@ -45,6 +56,7 @@ import { ThrottleGuard } from './common/guards/throttle.guard';
MediaModule, MediaModule,
MarketplaceModule, MarketplaceModule,
SavesModule, SavesModule,
SuperAdminModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [

عرض الملف

@@ -0,0 +1,7 @@
import { SetMetadata } from '@nestjs/common';
import { SuperAdminPermission } from '../../modules/superadmin/superadmin-permissions';
export const SUPERADMIN_PERMISSIONS_KEY = 'superadmin_permissions';
export const SuperAdminPermissions = (...permissions: SuperAdminPermission[]) =>
SetMetadata(SUPERADMIN_PERMISSIONS_KEY, permissions);

عرض الملف

@@ -1,14 +1,18 @@
import { Type } from 'class-transformer'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; import { Transform, Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { SortOrder } from '../enums/sort-order.enum';
import { APP_CONSTANTS } from '../../config/constants'; import { APP_CONSTANTS } from '../../config/constants';
export class PaginationQueryDto { export class PaginationQueryDto {
@ApiPropertyOptional({ default: APP_CONSTANTS.DEFAULT_PAGE })
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(1)
page?: number = APP_CONSTANTS.DEFAULT_PAGE; page?: number = APP_CONSTANTS.DEFAULT_PAGE;
@ApiPropertyOptional({ default: APP_CONSTANTS.DEFAULT_LIMIT, maximum: APP_CONSTANTS.MAX_LIMIT })
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@@ -16,7 +20,20 @@ export class PaginationQueryDto {
@Max(APP_CONSTANTS.MAX_LIMIT) @Max(APP_CONSTANTS.MAX_LIMIT)
limit?: number = APP_CONSTANTS.DEFAULT_LIMIT; limit?: number = APP_CONSTANTS.DEFAULT_LIMIT;
@ApiPropertyOptional({ description: 'Cursor token for cursor-based endpoints' })
@IsOptional() @IsOptional()
@IsString() @IsString()
cursor?: string; cursor?: string;
@ApiPropertyOptional({
enum: SortOrder,
default: SortOrder.DESC,
description: 'Used by offset-based list endpoints. Cursor feeds may ignore it.',
})
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
)
@IsEnum(SortOrder)
sortOrder?: SortOrder = SortOrder.DESC;
} }

عرض الملف

@@ -0,0 +1,5 @@
export enum ModerationStatus {
ACTIVE = 'active',
HIDDEN = 'hidden',
FLAGGED = 'flagged',
}

عرض الملف

@@ -3,4 +3,7 @@ export enum NotificationType {
COMMENT = 'comment', COMMENT = 'comment',
FOLLOW = 'follow', FOLLOW = 'follow',
MESSAGE = 'message', MESSAGE = 'message',
SAVE = 'save',
SHARE = 'share',
MENTION = 'mention',
} }

عرض الملف

@@ -1,5 +1,6 @@
export enum PostType { export enum PostType {
TEXT = 'text', TEXT = 'text',
IMAGE = 'image',
VIDEO = 'video', VIDEO = 'video',
AUDIO = 'audio', AUDIO = 'audio',
} }

عرض الملف

@@ -0,0 +1,5 @@
export enum RepairRequestStatus {
PENDING = 'pending',
ACCEPTED = 'accepted',
COMPLETED = 'completed',
}

عرض الملف

@@ -0,0 +1,4 @@
export enum SortOrder {
ASC = 'asc',
DESC = 'desc',
}

عرض الملف

@@ -0,0 +1,33 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtPayload } from '../interfaces/jwt-payload.interface';
import { SUPERADMIN_PERMISSIONS_KEY } from '../decorators/superadmin-permissions.decorator';
@Injectable()
export class SuperAdminPermissionsGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
SUPERADMIN_PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredPermissions?.length) {
return true;
}
const request = context.switchToHttp().getRequest<{ user?: JwtPayload }>();
const payload = request.user;
const grantedPermissions = new Set(payload?.permissions ?? []);
const hasAllPermissions = requiredPermissions.every((permission) =>
grantedPermissions.has(permission),
);
if (!hasAllPermissions) {
throw new ForbiddenException('Missing superadmin permission');
}
return true;
}
}

عرض الملف

@@ -6,20 +6,17 @@ import {
Injectable, Injectable,
} from '@nestjs/common'; } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { AppCacheService } from '../../infrastructure/cache/app-cache.service';
import { THROTTLE_META_KEY, ThrottleMeta } from '../decorators/throttle.decorator'; import { THROTTLE_META_KEY, ThrottleMeta } from '../decorators/throttle.decorator';
type Bucket = {
count: number;
resetAt: number;
};
@Injectable() @Injectable()
export class ThrottleGuard implements CanActivate { export class ThrottleGuard implements CanActivate {
private readonly buckets = new Map<string, Bucket>(); constructor(
private readonly reflector: Reflector,
private readonly cacheService: AppCacheService,
) {}
constructor(private readonly reflector: Reflector) {} async canActivate(context: ExecutionContext): Promise<boolean> {
canActivate(context: ExecutionContext): boolean {
const meta = this.reflector.getAllAndOverride<ThrottleMeta>(THROTTLE_META_KEY, [ const meta = this.reflector.getAllAndOverride<ThrottleMeta>(THROTTLE_META_KEY, [
context.getHandler(), context.getHandler(),
context.getClass(), context.getClass(),
@@ -28,23 +25,25 @@ export class ThrottleGuard implements CanActivate {
return true; return true;
} }
const req = context.switchToHttp().getRequest<Request & { ip?: string; originalUrl?: string }>(); const req = context.switchToHttp().getRequest<
const ip = req.ip ?? 'unknown'; Request & {
const route = req.originalUrl ?? 'unknown-route'; ip?: string;
const key = `${ip}:${route}`; originalUrl?: string;
const now = Date.now(); baseUrl?: string;
const existing = this.buckets.get(key); route?: { path?: string };
user?: { sub?: string };
}
>();
const actorKey = req.user?.sub ?? req.ip ?? 'unknown';
const routePath = `${req.baseUrl ?? ''}${req.route?.path ?? req.originalUrl ?? 'unknown-route'}`;
const windowSeconds = Math.max(1, Math.ceil(meta.windowMs / 1000));
const bucketKey = `rate-limit:${routePath}:${actorKey}`;
const currentCount = await this.cacheService.incr(bucketKey, windowSeconds);
if (!existing || now > existing.resetAt) { if (currentCount > meta.limit) {
this.buckets.set(key, { count: 1, resetAt: now + meta.windowMs });
return true;
}
if (existing.count >= meta.limit) {
throw new HttpException('Too many requests, please try again later', HttpStatus.TOO_MANY_REQUESTS); throw new HttpException('Too many requests, please try again later', HttpStatus.TOO_MANY_REQUESTS);
} }
existing.count += 1;
return true; return true;
} }
} }

عرض الملف

@@ -4,4 +4,5 @@ export interface JwtPayload {
role?: string; role?: string;
tokenType: 'access' | 'refresh' | 'superadmin_access' | 'superadmin_refresh'; tokenType: 'access' | 'refresh' | 'superadmin_access' | 'superadmin_refresh';
email?: string; email?: string;
permissions?: string[];
} }

عرض الملف

@@ -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]);
});
});

عرض الملف

@@ -0,0 +1,69 @@
import { TransformFnParams } from 'class-transformer';
const parseArrayInput = (value: unknown): unknown[] | unknown | undefined => {
if (value === undefined || value === null) {
return undefined;
}
if (Array.isArray(value)) {
return value.flatMap((item) => {
const parsed = parseArrayInput(item);
if (typeof parsed === 'undefined') {
return [];
}
return Array.isArray(parsed) ? parsed : [parsed];
});
}
if (typeof value !== 'string') {
return value;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
try {
return parseArrayInput(JSON.parse(trimmed));
} catch {
return [trimmed];
}
}
if (trimmed.includes(',')) {
return trimmed
.split(',')
.map((part) => part.trim())
.filter(Boolean);
}
return [trimmed];
};
export const toStringArray = ({ value }: TransformFnParams): string[] | unknown | undefined => {
const parsed = parseArrayInput(value);
if (typeof parsed === 'undefined') {
return undefined;
}
if (!Array.isArray(parsed)) {
return parsed;
}
return parsed.map((item) => String(item).trim()).filter(Boolean);
};
export const toNumberArray = ({ value }: TransformFnParams): number[] | unknown | undefined => {
const parsed = parseArrayInput(value);
if (typeof parsed === 'undefined') {
return undefined;
}
if (!Array.isArray(parsed)) {
return parsed;
}
return parsed.map((item) => Number(item));
};

عرض الملف

@@ -1,7 +1,24 @@
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { createHmac, timingSafeEqual } from 'crypto';
export const hashValue = async (value: string, saltRounds: number): Promise<string> => export const hashValue = async (value: string, saltRounds: number): Promise<string> =>
bcrypt.hash(value, saltRounds); bcrypt.hash(value, saltRounds);
export const compareHash = async (value: string, hashedValue: string): Promise<boolean> => export const compareHash = async (value: string, hashedValue: string): Promise<boolean> =>
bcrypt.compare(value, hashedValue); bcrypt.compare(value, hashedValue);
export const hashHighEntropyValue = (value: string, secret: string): string =>
`sha256:${createHmac('sha256', secret).update(value).digest('hex')}`;
export const compareStoredHighEntropyValue = async (
value: string,
storedValue: string,
secret: string,
): Promise<boolean> => {
if (storedValue.startsWith('sha256:')) {
const nextValue = hashHighEntropyValue(value, secret);
return timingSafeEqual(Buffer.from(nextValue), Buffer.from(storedValue));
}
return compareHash(value, storedValue);
};

عرض الملف

@@ -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');
});
});

عرض الملف

@@ -0,0 +1,70 @@
export type PaginatedResponseOptions = {
page: number;
limit: number;
total: number;
offset: number;
currentCursor?: string | null;
nextCursor?: string | null;
mode?: 'offset' | 'cursor';
};
export type PaginatedResponse<T> = {
items: T[];
count: number;
page: number;
limit: number;
total: number;
totalPages: number;
nextCursor: string | null;
pagination: {
mode: 'offset' | 'cursor';
page: number;
limit: number;
count: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
nextPage: number | null;
previousPage: number | null;
currentCursor: string | null;
nextCursor: string | null;
};
};
export const buildPaginatedResponse = <T>(
items: T[],
options: PaginatedResponseOptions,
): PaginatedResponse<T> => {
const count = items.length;
const totalPages = Math.ceil(options.total / options.limit) || 1;
const hasNextPage = options.offset + count < options.total;
const hasPreviousPage = options.offset > 0;
const mode =
options.mode ??
(options.currentCursor !== undefined || options.nextCursor !== undefined ? 'cursor' : 'offset');
return {
items,
count,
page: options.page,
limit: options.limit,
total: options.total,
totalPages,
nextCursor: options.nextCursor ?? null,
pagination: {
mode,
page: options.page,
limit: options.limit,
count,
total: options.total,
totalPages,
hasNextPage,
hasPreviousPage,
nextPage: hasNextPage ? options.page + 1 : null,
previousPage: hasPreviousPage ? Math.max(1, options.page - 1) : null,
currentCursor: options.currentCursor ?? null,
nextCursor: options.nextCursor ?? null,
},
};
};

عرض الملف

@@ -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',
]);
});
});

عرض الملف

@@ -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));
};

عرض الملف

@@ -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);
});
});

عرض الملف

@@ -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;
};

عرض الملف

@@ -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);
});
});

عرض الملف

@@ -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);

عرض الملف

@@ -0,0 +1,101 @@
const DEFAULT_SAMPLES = 48;
const scaleToRange = (values: number[]): number[] => {
if (!values.length) {
return [];
}
const max = Math.max(...values);
if (max <= 0) {
return values.map(() => 0);
}
return values.map((value) => Math.max(0, Math.min(100, Math.round((value / max) * 100))));
};
export const normalizeWaveformPeaks = (
input: number[] | undefined,
maxSamples = DEFAULT_SAMPLES,
): number[] => {
if (!input?.length) {
return [];
}
const cleaned = input
.map((value) => (Number.isFinite(value) ? Math.abs(value) : 0))
.filter((value) => value > 0);
if (!cleaned.length) {
return [];
}
if (cleaned.length <= maxSamples) {
return scaleToRange(cleaned);
}
const windowSize = cleaned.length / maxSamples;
const compressed: number[] = [];
for (let i = 0; i < maxSamples; i += 1) {
const start = Math.floor(i * windowSize);
const end = Math.min(cleaned.length, Math.floor((i + 1) * windowSize));
const slice = cleaned.slice(start, Math.max(start + 1, end));
const peak = Math.max(...slice);
compressed.push(peak);
}
return scaleToRange(compressed);
};
export const generateWaveformPeaksFromBuffer = (
buffer: Buffer,
samples = DEFAULT_SAMPLES,
): number[] => {
if (!buffer.length) {
return [];
}
const chunkSize = Math.max(1, Math.ceil(buffer.length / samples));
const peaks: number[] = [];
for (let offset = 0; offset < buffer.length; offset += chunkSize) {
const chunk = buffer.subarray(offset, Math.min(buffer.length, offset + chunkSize));
let total = 0;
let localPeak = 0;
for (const byte of chunk) {
const centered = Math.abs(byte - 128);
total += centered;
localPeak = Math.max(localPeak, centered);
}
const average = chunk.length ? total / chunk.length : 0;
peaks.push(Math.max(average, localPeak * 0.65));
}
return normalizeWaveformPeaks(peaks, samples);
};
export const generateWaveformPeaksFromSeed = (
seed: string,
samples = DEFAULT_SAMPLES,
): number[] => {
const source = seed.trim() || 'audio';
let hash = 2166136261;
for (let i = 0; i < source.length; i += 1) {
hash ^= source.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
const peaks: number[] = [];
let state = hash >>> 0;
for (let i = 0; i < samples; i += 1) {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
const base = 18 + (state % 65);
const accent = i % 6 === 0 ? 18 : i % 3 === 0 ? 10 : 0;
peaks.push(base + accent);
}
return normalizeWaveformPeaks(peaks, samples);
};

عرض الملف

@@ -47,8 +47,61 @@ export default () => ({
fromName: process.env.EMAIL_FROM_NAME ?? 'Oudelaa', fromName: process.env.EMAIL_FROM_NAME ?? 'Oudelaa',
fromEmail: process.env.EMAIL_FROM_EMAIL ?? process.env.EMAIL_SMTP_USER ?? '', fromEmail: process.env.EMAIL_FROM_EMAIL ?? process.env.EMAIL_SMTP_USER ?? '',
}, },
aiMusic: {
enabled: (process.env.AI_MUSIC_ENABLED ?? 'false').toLowerCase() === 'true',
apiKey: process.env.AI_MUSIC_API_KEY ?? '',
projectId: process.env.AI_MUSIC_PROJECT_ID ?? '',
location: process.env.AI_MUSIC_LOCATION ?? 'us-central1',
model: process.env.AI_MUSIC_MODEL ?? 'lyria-002',
},
security: { security: {
bcryptSaltRounds: Number(process.env.BCRYPT_SALT_ROUNDS ?? 12), bcryptSaltRounds: Number(process.env.BCRYPT_SALT_ROUNDS ?? 12),
refreshTokenHashSecret:
process.env.REFRESH_TOKEN_HASH_SECRET ?? process.env.JWT_REFRESH_SECRET ?? '',
},
redis: {
enabled: (process.env.REDIS_ENABLED ?? 'false').toLowerCase() === 'true',
url: process.env.REDIS_URL ?? '',
host: process.env.REDIS_HOST ?? '127.0.0.1',
port: Number(process.env.REDIS_PORT ?? 6379),
username: process.env.REDIS_USERNAME ?? '',
password: process.env.REDIS_PASSWORD ?? '',
db: Number(process.env.REDIS_DB ?? 0),
keyPrefix: process.env.REDIS_KEY_PREFIX ?? 'oudelaa',
socketAdapterEnabled:
(process.env.REDIS_SOCKET_ADAPTER_ENABLED ?? 'false').toLowerCase() === 'true',
},
queue: {
enabled: (process.env.QUEUE_ENABLED ?? 'false').toLowerCase() === 'true',
name: process.env.QUEUE_NAME ?? 'app-jobs',
defaultJobAttempts: Number(process.env.QUEUE_DEFAULT_ATTEMPTS ?? 3),
defaultJobBackoffMs: Number(process.env.QUEUE_DEFAULT_BACKOFF_MS ?? 1000),
removeOnComplete:
(process.env.QUEUE_REMOVE_ON_COMPLETE ?? 'true').toLowerCase() === 'true',
workerConcurrency: Number(process.env.QUEUE_WORKER_CONCURRENCY ?? 5),
},
storage: {
provider: process.env.STORAGE_PROVIDER ?? 'local',
basePath: process.env.STORAGE_BASE_PATH ?? 'uploads',
publicBaseUrl: process.env.STORAGE_PUBLIC_BASE_URL ?? '',
s3: {
bucket: process.env.S3_BUCKET ?? '',
region: process.env.S3_REGION ?? 'auto',
endpoint: process.env.S3_ENDPOINT ?? '',
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? '',
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? '',
forcePathStyle:
(process.env.S3_FORCE_PATH_STYLE ?? 'false').toLowerCase() === 'true',
},
},
logging: {
level: process.env.LOG_LEVEL ?? 'log',
requestEnabled: (process.env.REQUEST_LOGGING_ENABLED ?? 'true').toLowerCase() === 'true',
},
feedCache: {
enabled: (process.env.FEED_CACHE_ENABLED ?? 'true').toLowerCase() === 'true',
userFeedTtlSeconds: Number(process.env.FEED_CACHE_USER_TTL_SECONDS ?? 15),
trendingTtlSeconds: Number(process.env.FEED_CACHE_TRENDING_TTL_SECONDS ?? 30),
}, },
passwordReset: { passwordReset: {
codeExpiresMinutes: Number(process.env.PASSWORD_RESET_CODE_EXPIRES_MINUTES ?? 10), codeExpiresMinutes: Number(process.env.PASSWORD_RESET_CODE_EXPIRES_MINUTES ?? 10),

عرض الملف

@@ -30,7 +30,42 @@ export const validationSchema = Joi.object({
EMAIL_SMTP_PASS: Joi.string().allow('').optional(), EMAIL_SMTP_PASS: Joi.string().allow('').optional(),
EMAIL_FROM_NAME: Joi.string().default('Oudelaa'), EMAIL_FROM_NAME: Joi.string().default('Oudelaa'),
EMAIL_FROM_EMAIL: Joi.string().allow('').optional(), EMAIL_FROM_EMAIL: Joi.string().allow('').optional(),
AI_MUSIC_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
AI_MUSIC_API_KEY: Joi.string().allow('').optional(),
AI_MUSIC_PROJECT_ID: Joi.string().allow('').optional(),
AI_MUSIC_LOCATION: Joi.string().default('us-central1'),
AI_MUSIC_MODEL: Joi.string().default('lyria-002'),
BCRYPT_SALT_ROUNDS: Joi.number().min(8).max(15).default(12), BCRYPT_SALT_ROUNDS: Joi.number().min(8).max(15).default(12),
REFRESH_TOKEN_HASH_SECRET: Joi.string().allow('').optional(),
REDIS_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
REDIS_URL: Joi.string().allow('').optional(),
REDIS_HOST: Joi.string().default('127.0.0.1'),
REDIS_PORT: Joi.number().default(6379),
REDIS_USERNAME: Joi.string().allow('').optional(),
REDIS_PASSWORD: Joi.string().allow('').optional(),
REDIS_DB: Joi.number().min(0).default(0),
REDIS_KEY_PREFIX: Joi.string().default('oudelaa'),
REDIS_SOCKET_ADAPTER_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
QUEUE_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
QUEUE_NAME: Joi.string().default('app-jobs'),
QUEUE_DEFAULT_ATTEMPTS: Joi.number().min(1).max(20).default(3),
QUEUE_DEFAULT_BACKOFF_MS: Joi.number().min(100).max(600000).default(1000),
QUEUE_REMOVE_ON_COMPLETE: Joi.boolean().truthy('true').falsy('false').default(true),
QUEUE_WORKER_CONCURRENCY: Joi.number().min(1).max(100).default(5),
STORAGE_PROVIDER: Joi.string().valid('local', 's3').default('local'),
STORAGE_BASE_PATH: Joi.string().default('uploads'),
STORAGE_PUBLIC_BASE_URL: Joi.string().allow('').optional(),
S3_BUCKET: Joi.string().allow('').optional(),
S3_REGION: Joi.string().allow('').default('auto'),
S3_ENDPOINT: Joi.string().allow('').optional(),
S3_ACCESS_KEY_ID: Joi.string().allow('').optional(),
S3_SECRET_ACCESS_KEY: Joi.string().allow('').optional(),
S3_FORCE_PATH_STYLE: Joi.boolean().truthy('true').falsy('false').default(false),
LOG_LEVEL: Joi.string().valid('error', 'warn', 'log', 'debug', 'verbose').default('log'),
REQUEST_LOGGING_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true),
FEED_CACHE_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true),
FEED_CACHE_USER_TTL_SECONDS: Joi.number().min(1).max(3600).default(15),
FEED_CACHE_TRENDING_TTL_SECONDS: Joi.number().min(1).max(3600).default(30),
PASSWORD_RESET_CODE_EXPIRES_MINUTES: Joi.number().min(1).max(60).default(10), PASSWORD_RESET_CODE_EXPIRES_MINUTES: Joi.number().min(1).max(60).default(10),
PASSWORD_RESET_MAX_ATTEMPTS: Joi.number().min(1).max(10).default(5), PASSWORD_RESET_MAX_ATTEMPTS: Joi.number().min(1).max(10).default(5),
PASSWORD_RESET_TOKEN_SECRET: Joi.string().allow('').optional(), PASSWORD_RESET_TOKEN_SECRET: Joi.string().allow('').optional(),

عرض الملف

@@ -0,0 +1,102 @@
import { Injectable } from '@nestjs/common';
import { RedisService } from '../redis/redis.service';
type MemoryEntry = {
value: string;
expiresAt: number | null;
};
@Injectable()
export class AppCacheService {
private readonly memory = new Map<string, MemoryEntry>();
constructor(private readonly redisService: RedisService) {}
async get<T>(key: string): Promise<T | null> {
const redis = this.redisService.getClient();
const fullKey = this.buildKey(key);
if (redis) {
const value = await redis.get(fullKey);
if (!value) {
return null;
}
return JSON.parse(value) as T;
}
const memoryEntry = this.memory.get(fullKey);
if (!memoryEntry) {
return null;
}
if (memoryEntry.expiresAt && memoryEntry.expiresAt <= Date.now()) {
this.memory.delete(fullKey);
return null;
}
return JSON.parse(memoryEntry.value) as T;
}
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
const redis = this.redisService.getClient();
const fullKey = this.buildKey(key);
const serialized = JSON.stringify(value);
if (redis) {
if (ttlSeconds && ttlSeconds > 0) {
await redis.set(fullKey, serialized, 'EX', ttlSeconds);
return;
}
await redis.set(fullKey, serialized);
return;
}
const expiresAt = ttlSeconds && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null;
this.memory.set(fullKey, { value: serialized, expiresAt });
}
async del(key: string): Promise<void> {
const redis = this.redisService.getClient();
const fullKey = this.buildKey(key);
if (redis) {
await redis.del(fullKey);
return;
}
this.memory.delete(fullKey);
}
async remember<T>(key: string, ttlSeconds: number, factory: () => Promise<T>): Promise<T> {
const cached = await this.get<T>(key);
if (cached !== null) {
return cached;
}
const value = await factory();
await this.set(key, value, ttlSeconds);
return value;
}
async incr(key: string, ttlSeconds?: number): Promise<number> {
const redis = this.redisService.getClient();
const fullKey = this.buildKey(key);
if (redis) {
const nextValue = await redis.incr(fullKey);
if (ttlSeconds && ttlSeconds > 0 && nextValue === 1) {
await redis.expire(fullKey, ttlSeconds);
}
return nextValue;
}
const existing = await this.get<number>(key);
const nextValue = (existing ?? 0) + 1;
await this.set(key, nextValue, ttlSeconds);
return nextValue;
}
private buildKey(key: string): string {
return `${this.redisService.getKeyPrefix()}:${key}`;
}
}

عرض الملف

@@ -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 {}

عرض الملف

@@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common';
import { AppCacheService } from './app-cache.service';
@Injectable()
export class FeedVersionService {
private static readonly GLOBAL_VERSION_KEY = 'feed:global:version';
constructor(private readonly cacheService: AppCacheService) {}
async getGlobalVersion(): Promise<number> {
const current = await this.cacheService.get<number>(FeedVersionService.GLOBAL_VERSION_KEY);
if (typeof current === 'number' && current > 0) {
return current;
}
await this.cacheService.set(FeedVersionService.GLOBAL_VERSION_KEY, 1);
return 1;
}
async bumpGlobalVersion(): Promise<number> {
return this.cacheService.incr(FeedVersionService.GLOBAL_VERSION_KEY);
}
}

عرض الملف

@@ -0,0 +1,91 @@
import { Injectable, LoggerService } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
type AppLogLevel = 'error' | 'warn' | 'log' | 'debug' | 'verbose';
@Injectable()
export class AppLoggerService implements LoggerService {
private readonly levelPriority: Record<AppLogLevel, number> = {
error: 0,
warn: 1,
log: 2,
debug: 3,
verbose: 4,
};
constructor(private readonly configService: ConfigService) {}
log(message: any, context?: string): void {
this.write('log', message, undefined, context);
}
error(message: any, trace?: string, context?: string): void {
this.write('error', message, trace, context);
}
warn(message: any, context?: string): void {
this.write('warn', message, undefined, context);
}
debug(message: any, context?: string): void {
this.write('debug', message, undefined, context);
}
verbose(message: any, context?: string): void {
this.write('verbose', message, undefined, context);
}
logHttp(payload: Record<string, unknown>): void {
this.write('log', 'http_request', undefined, 'HttpLogger', payload);
}
private write(
level: AppLogLevel,
message: any,
trace?: string,
context?: string,
extra: Record<string, unknown> = {},
): void {
if (!this.shouldLog(level)) {
return;
}
const entry: Record<string, unknown> = {
level,
timestamp: new Date().toISOString(),
context: context ?? 'Application',
...extra,
};
if (typeof message === 'string') {
entry.message = message;
} else if (message instanceof Error) {
entry.message = message.message;
entry.errorName = message.name;
entry.stack = message.stack;
} else {
entry.message = 'structured_log';
entry.payload = message;
}
if (trace) {
entry.trace = trace;
}
const serialized = `${JSON.stringify(entry)}\n`;
if (level === 'error') {
process.stderr.write(serialized);
return;
}
process.stdout.write(serialized);
}
private shouldLog(level: AppLogLevel): boolean {
const configuredLevel =
(this.configService.get<string>('logging.level', { infer: true }) as AppLogLevel | undefined) ??
'log';
return this.levelPriority[level] <= this.levelPriority[configuredLevel];
}
}

عرض الملف

@@ -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 {}

عرض الملف

@@ -0,0 +1,145 @@
import {
Injectable,
OnApplicationBootstrap,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JobsOptions, Queue, Worker } from 'bullmq';
import { AppLoggerService } from '../logging/app-logger.service';
import { RedisService } from '../redis/redis.service';
type JobProcessor = (payload: Record<string, unknown>) => Promise<void>;
@Injectable()
export class AppQueueService
implements OnModuleInit, OnApplicationBootstrap, OnModuleDestroy
{
private readonly processors = new Map<string, JobProcessor>();
private queue: Queue | null = null;
private worker: Worker | null = null;
constructor(
private readonly configService: ConfigService,
private readonly redisService: RedisService,
private readonly logger: AppLoggerService,
) {}
onModuleInit(): void {
// Intentionally empty. Processors are usually registered by other providers before bootstrap.
}
onApplicationBootstrap(): void {
if (!this.isQueueEnabled() || !this.redisService.isEnabled()) {
return;
}
const queueName = this.getQueueName();
const queueConnection = this.redisService.createQueueClient();
const workerConnection = this.redisService.createQueueClient();
if (!queueConnection || !workerConnection) {
return;
}
this.queue = new Queue(queueName, {
connection: queueConnection,
defaultJobOptions: this.getDefaultJobOptions(),
});
this.worker = new Worker(
queueName,
async (job) => {
const processor = this.processors.get(job.name);
if (!processor) {
throw new Error(`No processor registered for job "${job.name}"`);
}
await processor(job.data as Record<string, unknown>);
},
{
connection: workerConnection,
concurrency:
this.configService.get<number>('queue.workerConcurrency', { infer: true }) ?? 5,
},
);
this.worker.on('failed', (job, error) => {
this.logger.error(
{
queue: queueName,
jobName: job?.name,
jobId: job?.id,
error: error.message,
},
undefined,
AppQueueService.name,
);
});
}
registerProcessor(jobName: string, processor: JobProcessor): void {
this.processors.set(jobName, processor);
}
async enqueue(
jobName: string,
payload: Record<string, unknown>,
options: JobsOptions = {},
): Promise<void> {
if (this.queue) {
await this.queue.add(jobName, payload, {
...this.getDefaultJobOptions(),
...options,
});
return;
}
const processor = this.processors.get(jobName);
if (!processor) {
return;
}
queueMicrotask(() => {
void processor(payload).catch((error: Error) => {
this.logger.error(
{
jobName,
payload,
error: error.message,
},
error.stack,
AppQueueService.name,
);
});
});
}
async onModuleDestroy(): Promise<void> {
await this.worker?.close();
await this.queue?.close();
this.worker = null;
this.queue = null;
}
private isQueueEnabled(): boolean {
return this.configService.get<boolean>('queue.enabled', { infer: true }) ?? false;
}
private getQueueName(): string {
return this.configService.get<string>('queue.name', { infer: true }) ?? 'app-jobs';
}
private getDefaultJobOptions(): JobsOptions {
return {
attempts:
this.configService.get<number>('queue.defaultJobAttempts', { infer: true }) ?? 3,
backoff: {
type: 'exponential',
delay:
this.configService.get<number>('queue.defaultJobBackoffMs', { infer: true }) ?? 1000,
},
removeOnComplete:
this.configService.get<boolean>('queue.removeOnComplete', { infer: true }) ?? true,
};
}
}

عرض الملف

@@ -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 {}

عرض الملف

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { RedisService } from './redis.service';
@Global()
@Module({
providers: [RedisService],
exports: [RedisService],
})
export class RedisModule {}

عرض الملف

@@ -0,0 +1,79 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis, { RedisOptions } from 'ioredis';
@Injectable()
export class RedisService implements OnModuleDestroy {
private client: Redis | null = null;
constructor(private readonly configService: ConfigService) {}
isEnabled(): boolean {
return this.configService.get<boolean>('redis.enabled', { infer: true }) ?? false;
}
getKeyPrefix(): string {
return this.configService.get<string>('redis.keyPrefix', { infer: true }) ?? 'oudelaa';
}
getClient(): Redis | null {
if (!this.isEnabled()) {
return null;
}
if (!this.client) {
this.client = this.createClient({ maxRetriesPerRequest: null });
}
return this.client;
}
createPubSubClients(): { pubClient: Redis; subClient: Redis } | null {
if (!this.isEnabled()) {
return null;
}
const pubClient = this.createClient({ maxRetriesPerRequest: null });
const subClient = pubClient.duplicate();
return { pubClient, subClient };
}
createQueueClient(): Redis | null {
if (!this.isEnabled()) {
return null;
}
return this.createClient({ maxRetriesPerRequest: null });
}
onModuleDestroy(): void {
if (this.client) {
void this.client.quit().catch(() => this.client?.disconnect());
this.client = null;
}
}
private createClient(overrides: Partial<RedisOptions> = {}): Redis {
const url = this.configService.get<string>('redis.url', { infer: true }) ?? '';
const baseOptions: RedisOptions = {
host: this.configService.get<string>('redis.host', { infer: true }) ?? '127.0.0.1',
port: this.configService.get<number>('redis.port', { infer: true }) ?? 6379,
username: this.configService.get<string>('redis.username', { infer: true }) || undefined,
password: this.configService.get<string>('redis.password', { infer: true }) || undefined,
db: this.configService.get<number>('redis.db', { infer: true }) ?? 0,
lazyConnect: false,
enableReadyCheck: true,
...overrides,
};
if (url) {
return new Redis(url, {
...overrides,
lazyConnect: false,
enableReadyCheck: true,
});
}
return new Redis(baseOptions);
}
}

عرض الملف

@@ -0,0 +1,45 @@
import { INestApplicationContext } from '@nestjs/common';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { ServerOptions } from 'socket.io';
import Redis from 'ioredis';
import { RedisService } from '../redis/redis.service';
export class RedisIoAdapter extends IoAdapter {
private adapterConstructor: ReturnType<typeof createAdapter> | null = null;
private pubClient: Redis | null = null;
private subClient: Redis | null = null;
constructor(
app: INestApplicationContext,
private readonly redisService: RedisService,
) {
super(app);
}
async connectToRedis(): Promise<void> {
const clients = this.redisService.createPubSubClients();
if (!clients) {
return;
}
this.pubClient = clients.pubClient;
this.subClient = clients.subClient;
this.adapterConstructor = createAdapter(this.pubClient as any, this.subClient as any);
}
createIOServer(port: number, options?: ServerOptions) {
const server = super.createIOServer(port, options);
if (this.adapterConstructor) {
server.adapter(this.adapterConstructor);
}
return server;
}
async close(): Promise<void> {
await this.pubClient?.quit().catch(() => this.pubClient?.disconnect());
await this.subClient?.quit().catch(() => this.subClient?.disconnect());
this.pubClient = null;
this.subClient = null;
}
}

عرض الملف

@@ -0,0 +1,210 @@
import { BadRequestException, Injectable, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
import { randomUUID } from 'crypto';
import { mkdir, unlink, writeFile } from 'fs/promises';
import { join, posix } from 'path';
@Injectable()
export class ManagedStorageService implements OnModuleDestroy {
private s3Client: S3Client | null = null;
constructor(private readonly configService: ConfigService) {}
async saveFile(params: {
folderSegments: string[];
extension: string;
buffer: Buffer;
contentType?: string;
fileNamePrefix?: string;
}): Promise<string> {
const fileName = `${params.fileNamePrefix ?? 'file'}-${randomUUID()}${params.extension}`;
const provider = this.getProvider();
const basePath = this.getBasePath();
const normalizedSegments = params.folderSegments.map((segment) =>
segment.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''),
);
const objectKey = posix.join(basePath, ...normalizedSegments, fileName);
if (provider === 's3') {
const client = this.getS3Client();
const upload = new Upload({
client,
params: {
Bucket: this.getS3Bucket(),
Key: objectKey,
Body: params.buffer,
ContentType: params.contentType || undefined,
},
});
await upload.done();
return this.resolvePublicUrl(objectKey);
}
const uploadDir = join(process.cwd(), ...objectKey.split('/').slice(0, -1));
await mkdir(uploadDir, { recursive: true });
await writeFile(join(process.cwd(), ...objectKey.split('/')), params.buffer);
return `/${objectKey}`;
}
async deleteFile(fileUrl?: string): Promise<void> {
if (!fileUrl) {
return;
}
if (this.getProvider() === 's3') {
const objectKey = this.resolveS3ObjectKey(fileUrl);
if (!objectKey) {
return;
}
const client = this.getS3Client();
await client.send(
new DeleteObjectCommand({
Bucket: this.getS3Bucket(),
Key: objectKey,
}),
);
return;
}
const relativePath = this.resolveLocalRelativePath(fileUrl);
if (!relativePath || relativePath.includes('..')) {
return;
}
try {
await unlink(join(process.cwd(), relativePath.replace(/\//g, '\\')));
} catch {
// Ignore cleanup failures for already-missing files.
}
}
onModuleDestroy(): void {
this.s3Client = null;
}
private getProvider(): 'local' | 's3' {
return (this.configService.get<string>('storage.provider', { infer: true }) as
| 'local'
| 's3'
| undefined) ?? 'local';
}
private getBasePath(): string {
return (this.configService.get<string>('storage.basePath', { infer: true }) ?? 'uploads')
.replace(/\\/g, '/')
.replace(/^\/+|\/+$/g, '');
}
private getS3Bucket(): string {
const bucket = this.configService.get<string>('storage.s3.bucket', { infer: true }) ?? '';
if (!bucket) {
throw new BadRequestException('S3 bucket is not configured');
}
return bucket;
}
private getS3Client(): S3Client {
if (this.s3Client) {
return this.s3Client;
}
const region = this.configService.get<string>('storage.s3.region', { infer: true }) ?? 'auto';
const endpoint = this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? '';
const accessKeyId =
this.configService.get<string>('storage.s3.accessKeyId', { infer: true }) ?? '';
const secretAccessKey =
this.configService.get<string>('storage.s3.secretAccessKey', { infer: true }) ?? '';
const forcePathStyle =
this.configService.get<boolean>('storage.s3.forcePathStyle', { infer: true }) ?? false;
if (!endpoint || !accessKeyId || !secretAccessKey) {
throw new BadRequestException('S3 storage settings are not fully configured');
}
this.s3Client = new S3Client({
region,
endpoint,
forcePathStyle,
credentials: {
accessKeyId,
secretAccessKey,
},
});
return this.s3Client;
}
private resolvePublicUrl(objectKey: string): string {
const publicBaseUrl =
(this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? '').replace(
/\/$/,
'',
);
if (publicBaseUrl) {
return `${publicBaseUrl}/${objectKey}`;
}
const endpoint = (this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? '').replace(
/\/$/,
'',
);
const bucket = this.getS3Bucket();
const forcePathStyle =
this.configService.get<boolean>('storage.s3.forcePathStyle', { infer: true }) ?? false;
if (!endpoint) {
throw new BadRequestException('storage.publicBaseUrl or storage.s3.endpoint is required');
}
return forcePathStyle ? `${endpoint}/${bucket}/${objectKey}` : `${endpoint}/${objectKey}`;
}
private resolveLocalRelativePath(fileUrl: string): string | null {
const normalizedUrl = fileUrl.split('?')[0].split('#')[0];
if (!normalizedUrl.startsWith('/')) {
return null;
}
const expectedPrefix = `/${this.getBasePath()}/`;
if (!normalizedUrl.startsWith(expectedPrefix) && normalizedUrl !== `/${this.getBasePath()}`) {
return null;
}
return normalizedUrl.replace(/^\/+/, '');
}
private resolveS3ObjectKey(fileUrl: string): string | null {
const normalizedUrl = fileUrl.split('?')[0].split('#')[0];
const publicBaseUrl =
(this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? '').replace(
/\/$/,
'',
);
if (publicBaseUrl && normalizedUrl.startsWith(`${publicBaseUrl}/`)) {
return normalizedUrl.slice(publicBaseUrl.length + 1);
}
const endpoint = (this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? '').replace(
/\/$/,
'',
);
const bucket = this.getS3Bucket();
const forcePathStyle =
this.configService.get<boolean>('storage.s3.forcePathStyle', { infer: true }) ?? false;
if (endpoint && normalizedUrl.startsWith(`${endpoint}/`)) {
const pathPart = normalizedUrl.slice(endpoint.length + 1);
if (forcePathStyle) {
const expectedPrefix = `${bucket}/`;
return pathPart.startsWith(expectedPrefix) ? pathPart.slice(expectedPrefix.length) : null;
}
return pathPart;
}
return null;
}
}

عرض الملف

@@ -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 {}

عرض الملف

@@ -9,14 +9,27 @@ import { existsSync, mkdirSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { ResponseEnvelopeInterceptor } from './common/interceptors/response-envelope.interceptor'; import { ResponseEnvelopeInterceptor } from './common/interceptors/response-envelope.interceptor';
import { AppLoggerService } from './infrastructure/logging/app-logger.service';
import { RedisService } from './infrastructure/redis/redis.service';
import { RedisIoAdapter } from './infrastructure/socket/redis-io.adapter';
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule, { bufferLogs: true });
const configService = app.get(ConfigService); const configService = app.get(ConfigService);
const appLogger = app.get(AppLoggerService);
app.useLogger(appLogger);
const corsOrigins = configService.get<string[]>('cors.origins', []); const corsOrigins = configService.get<string[]>('cors.origins', []);
const uploadsDir = join(process.cwd(), 'uploads'); const storageProvider = configService.get<string>('storage.provider', { infer: true }) ?? 'local';
const storageBasePath =
(configService.get<string>('storage.basePath', { infer: true }) ?? 'uploads').replace(
/^\/+|\/+$/g,
'',
);
const publicBaseUrl =
(configService.get<string>('publicBaseUrl', { infer: true }) ?? '').replace(/\/$/, '');
const uploadsDir = join(process.cwd(), storageBasePath);
if (!existsSync(uploadsDir)) { if (storageProvider === 'local' && !existsSync(uploadsDir)) {
mkdirSync(uploadsDir, { recursive: true }); mkdirSync(uploadsDir, { recursive: true });
} }
@@ -43,15 +56,13 @@ async function bootstrap(): Promise<void> {
res.setHeader('x-request-id', requestId); res.setHeader('x-request-id', requestId);
res.on('finish', () => { res.on('finish', () => {
const log = { appLogger.logHttp({
level: 'info',
requestId, requestId,
method: req.method, method: req.method,
path: req.originalUrl, path: req.originalUrl,
statusCode: res.statusCode, statusCode: res.statusCode,
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
}; });
console.log(JSON.stringify(log));
}); });
next(); next();
@@ -62,7 +73,18 @@ async function bootstrap(): Promise<void> {
app.useGlobalInterceptors(new ResponseEnvelopeInterceptor()); app.useGlobalInterceptors(new ResponseEnvelopeInterceptor());
} }
app.use('/uploads', express.static(uploadsDir)); if (storageProvider === 'local') {
app.use(`/${storageBasePath}`, express.static(uploadsDir));
}
const redisEnabled = configService.get<boolean>('redis.enabled', { infer: true }) ?? false;
const socketAdapterEnabled =
configService.get<boolean>('redis.socketAdapterEnabled', { infer: true }) ?? false;
if (redisEnabled && socketAdapterEnabled) {
const redisIoAdapter = new RedisIoAdapter(app, app.get(RedisService));
await redisIoAdapter.connectToRedis();
app.useWebSocketAdapter(redisIoAdapter);
}
const swaggerConfig = new DocumentBuilder() const swaggerConfig = new DocumentBuilder()
.setTitle(configService.get<string>('swagger.title', 'Oudelaa API')) .setTitle(configService.get<string>('swagger.title', 'Oudelaa API'))
@@ -78,7 +100,16 @@ async function bootstrap(): Promise<void> {
const port = configService.get<number>('port', 4000); const port = configService.get<number>('port', 4000);
const host = configService.get<string>('host', '0.0.0.0'); const host = configService.get<string>('host', '0.0.0.0');
if (host === '0.0.0.0' && publicBaseUrl.includes('localhost')) {
appLogger.warn(
`PUBLIC_BASE_URL is set to "${publicBaseUrl}". Mobile devices on the LAN will not be able to open uploaded files until this is changed to your machine IP, for example http://192.168.x.x:${port}`,
'Bootstrap',
);
}
await app.listen(port, host); await app.listen(port, host);
appLogger.log(`Server listening on http://${host}:${port}`, 'Bootstrap');
appLogger.log(`Resolved PUBLIC_BASE_URL=${publicBaseUrl || `http://localhost:${port}`}`, 'Bootstrap');
} }
void bootstrap(); void bootstrap();

عرض الملف

@@ -0,0 +1,22 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator';
import { SuperAdminPermissionsGuard } from '../../common/guards/superadmin-permissions.guard';
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
import { AuditService } from './audit.service';
import { AuditQueryDto } from './dto/audit-query.dto';
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
@ApiTags('Audit')
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@Controller('audit/superadmin')
export class AuditController {
constructor(private readonly auditService: AuditService) {}
@Get('logs')
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.AUDIT_READ)
async listLogs(@Query() query: AuditQueryDto) {
return this.auditService.listSuperAdminLogs(query);
}
}

عرض الملف

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose'; import { MongooseModule } from '@nestjs/mongoose';
import { AuditController } from './audit.controller';
import { AuditRepository } from './audit.repository'; import { AuditRepository } from './audit.repository';
import { AuditService } from './audit.service'; import { AuditService } from './audit.service';
import { AuditLog, AuditLogSchema } from './schemas/audit-log.schema'; import { AuditLog, AuditLogSchema } from './schemas/audit-log.schema';
@@ -13,6 +14,7 @@ import { AuditLog, AuditLogSchema } from './schemas/audit-log.schema';
}, },
]), ]),
], ],
controllers: [AuditController],
providers: [AuditRepository, AuditService], providers: [AuditRepository, AuditService],
exports: [AuditService], exports: [AuditService],
}) })

عرض الملف

@@ -26,4 +26,17 @@ export class AuditRepository {
metadata: payload.metadata ?? {}, metadata: payload.metadata ?? {},
}); });
} }
async findMany(
filter: Record<string, unknown>,
skip: number,
limit: number,
sort: Record<string, 1 | -1> = { createdAt: -1 },
): Promise<AuditLogDocument[]> {
return this.auditModel.find(filter).sort(sort).skip(skip).limit(limit).exec();
}
async count(filter: Record<string, unknown>): Promise<number> {
return this.auditModel.countDocuments(filter).exec();
}
} }

عرض الملف

@@ -1,5 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
import { AuditRepository } from './audit.repository'; import { AuditRepository } from './audit.repository';
import { AuditQueryDto } from './dto/audit-query.dto';
@Injectable() @Injectable()
export class AuditService { export class AuditService {
@@ -21,4 +24,41 @@ export class AuditService {
metadata, metadata,
}); });
} }
async listSuperAdminLogs(query: AuditQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
const filter: Record<string, unknown> = {};
if (query.q?.trim()) {
filter.$or = [
{ action: { $regex: query.q.trim(), $options: 'i' } },
{ targetType: { $regex: query.q.trim(), $options: 'i' } },
{ targetId: { $regex: query.q.trim(), $options: 'i' } },
{ actorIdentifier: { $regex: query.q.trim(), $options: 'i' } },
];
}
if (query.actorType) {
filter.actorType = query.actorType;
}
if (query.targetType?.trim()) {
filter.targetType = query.targetType.trim();
}
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const [items, total] = await Promise.all([
this.auditRepository.findMany(filter, skip, limit, sort),
this.auditRepository.count(filter),
]);
return buildPaginatedResponse(items, {
page,
limit,
total,
offset: skip,
});
}
} }

عرض الملف

@@ -0,0 +1,22 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
const ACTOR_TYPES = ['user', 'superadmin', 'system'] as const;
export class AuditQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ description: 'Search in action, targetType, targetId, or actorIdentifier' })
@IsOptional()
@IsString()
q?: string;
@ApiPropertyOptional({ enum: ACTOR_TYPES })
@IsOptional()
@IsEnum(ACTOR_TYPES)
actorType?: (typeof ACTOR_TYPES)[number];
@ApiPropertyOptional()
@IsOptional()
@IsString()
targetType?: string;
}

عرض الملف

@@ -3,7 +3,10 @@ import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Request } from 'express'; import { Request } from 'express';
import { Throttle } from '../../common/decorators/throttle.decorator'; import { Throttle } from '../../common/decorators/throttle.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { SuperAdminPermissionsGuard } from '../../common/guards/superadmin-permissions.guard';
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface'; import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { ForgotPasswordDto } from './dto/forgot-password.dto'; import { ForgotPasswordDto } from './dto/forgot-password.dto';
@@ -18,6 +21,7 @@ import { SendEmailVerificationDto } from './dto/send-email-verification.dto';
import { SuperAdminLoginDto } from './dto/super-admin-login.dto'; import { SuperAdminLoginDto } from './dto/super-admin-login.dto';
import { VerifyEmailDto } from './dto/verify-email.dto'; import { VerifyEmailDto } from './dto/verify-email.dto';
import { VerifyResetCodeDto } from './dto/verify-reset-code.dto'; import { VerifyResetCodeDto } from './dto/verify-reset-code.dto';
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
@ApiTags('Auth') @ApiTags('Auth')
@Controller('auth') @Controller('auth')
@@ -151,4 +155,24 @@ export class AuthController {
await this.authService.revokeUserSession(user.sub, jti); await this.authService.revokeUserSession(user.sub, jti);
return { success: true }; return { success: true };
} }
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.SESSIONS_MANAGE)
@Get('superadmin/sessions')
async listSuperAdminSessions(@CurrentUser() user: JwtPayload) {
return this.authService.listSuperAdminSessions(user.email ?? '');
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.SESSIONS_MANAGE)
@Post('superadmin/sessions/:sessionId/revoke')
async revokeSuperAdminSession(
@CurrentUser() user: JwtPayload,
@Param('sessionId') sessionId: string,
) {
await this.authService.revokeSuperAdminSession(user.email ?? '', sessionId);
return { success: true };
}
} }

عرض الملف

@@ -91,11 +91,13 @@ export class AuthRepository {
async createSuperAdminRefreshToken( async createSuperAdminRefreshToken(
adminEmail: string, adminEmail: string,
jti: string,
tokenHash: string, tokenHash: string,
expiresAt: Date, expiresAt: Date,
): Promise<void> { ): Promise<void> {
await this.superAdminRefreshTokenModel.create({ await this.superAdminRefreshTokenModel.create({
adminEmail: adminEmail.toLowerCase(), adminEmail: adminEmail.toLowerCase(),
jti,
tokenHash, tokenHash,
expiresAt, expiresAt,
}); });
@@ -108,12 +110,28 @@ export class AuthRepository {
.exec(); .exec();
} }
async findActiveSuperAdminTokenByJti(
adminEmail: string,
jti: string,
): Promise<SuperAdminRefreshTokenDocument | null> {
return this.superAdminRefreshTokenModel
.findOne({ adminEmail: adminEmail.toLowerCase(), jti, revoked: false })
.select('+tokenHash')
.exec();
}
async revokeAllSuperAdminTokens(adminEmail: string): Promise<void> { async revokeAllSuperAdminTokens(adminEmail: string): Promise<void> {
await this.superAdminRefreshTokenModel await this.superAdminRefreshTokenModel
.updateMany({ adminEmail: adminEmail.toLowerCase(), revoked: false }, { revoked: true }) .updateMany({ adminEmail: adminEmail.toLowerCase(), revoked: false }, { revoked: true })
.exec(); .exec();
} }
async revokeSuperAdminTokenByJti(adminEmail: string, jti: string): Promise<void> {
await this.superAdminRefreshTokenModel
.updateOne({ adminEmail: adminEmail.toLowerCase(), jti, revoked: false }, { revoked: true })
.exec();
}
async removeExpiredAndRevokedSuperAdmin(adminEmail: string): Promise<void> { async removeExpiredAndRevokedSuperAdmin(adminEmail: string): Promise<void> {
await this.superAdminRefreshTokenModel await this.superAdminRefreshTokenModel
.deleteMany({ .deleteMany({
@@ -123,6 +141,34 @@ export class AuthRepository {
.exec(); .exec();
} }
async listSuperAdminSessions(adminEmail: string): Promise<SuperAdminRefreshTokenDocument[]> {
return this.superAdminRefreshTokenModel
.find({
adminEmail: adminEmail.toLowerCase(),
revoked: false,
expiresAt: { $gt: new Date() },
})
.select('adminEmail jti expiresAt createdAt')
.sort({ createdAt: -1 })
.exec();
}
async revokeSuperAdminSessionById(adminEmail: string, sessionId: string): Promise<boolean> {
const updated = await this.superAdminRefreshTokenModel
.findOneAndUpdate(
{
_id: new Types.ObjectId(sessionId),
adminEmail: adminEmail.toLowerCase(),
revoked: false,
},
{ revoked: true },
{ new: false },
)
.exec();
return !!updated;
}
async invalidateActivePasswordResetCodes(userId: string): Promise<void> { async invalidateActivePasswordResetCodes(userId: string): Promise<void> {
await this.passwordResetCodeModel await this.passwordResetCodeModel
.updateMany( .updateMany(

عرض الملف

@@ -9,7 +9,12 @@ import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { randomBytes, randomInt, randomUUID } from 'crypto'; import { randomBytes, randomInt, randomUUID } from 'crypto';
import { OAuth2Client } from 'google-auth-library'; import { OAuth2Client } from 'google-auth-library';
import { compareHash, hashValue } from '../../common/utils/hash.util'; import {
compareHash,
compareStoredHighEntropyValue,
hashHighEntropyValue,
hashValue,
} from '../../common/utils/hash.util';
import { EmailService } from '../email/email.service'; import { EmailService } from '../email/email.service';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { ForgotPasswordDto } from './dto/forgot-password.dto'; import { ForgotPasswordDto } from './dto/forgot-password.dto';
@@ -25,6 +30,7 @@ import { VerifyEmailDto } from './dto/verify-email.dto';
import { VerifyResetCodeDto } from './dto/verify-reset-code.dto'; import { VerifyResetCodeDto } from './dto/verify-reset-code.dto';
import { AuthRepository } from './auth.repository'; import { AuthRepository } from './auth.repository';
import { AuthResult, TokenPair } from './types/token-pair.type'; import { AuthResult, TokenPair } from './types/token-pair.type';
import { DEFAULT_SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -56,16 +62,10 @@ export class AuthService {
username: generatedUsername, username: generatedUsername,
password: passwordHash, password: passwordHash,
}); });
const code = await this.issueEmailVerificationCode(user.id, user.email); return {
const response: { message: string; email: string; debugCode?: string } = { message: 'Registration successful. Account is pending SuperAdmin verification.',
message: 'Registration successful. Verify your email with the code sent.',
email: user.email, email: user.email,
}; };
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
if (nodeEnv !== 'production') {
response.debugCode = code;
}
return response;
} }
async registerBasic(dto: RegisterBasicDto): Promise<{ message: string; email: string; debugCode?: string }> { async registerBasic(dto: RegisterBasicDto): Promise<{ message: string; email: string; debugCode?: string }> {
@@ -84,16 +84,10 @@ export class AuthService {
password: passwordHash, password: passwordHash,
}); });
const code = await this.issueEmailVerificationCode(user.id, user.email); return {
const response: { message: string; email: string; debugCode?: string } = { message: 'Registration successful. Account is pending SuperAdmin verification.',
message: 'Registration successful. Verify your email with the code sent.',
email: user.email, email: user.email,
}; };
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
if (nodeEnv !== 'production') {
response.debugCode = code;
}
return response;
} }
async login(dto: LoginDto): Promise<AuthResult> { async login(dto: LoginDto): Promise<AuthResult> {
@@ -105,7 +99,7 @@ export class AuthService {
throw new ForbiddenException('Account is disabled'); throw new ForbiddenException('Account is disabled');
} }
if (!user.isVerified) { if (!user.isVerified) {
throw new ForbiddenException('Email not verified'); throw new ForbiddenException('Account is pending SuperAdmin verification');
} }
const isMatch = await compareHash(dto.password, user.password); const isMatch = await compareHash(dto.password, user.password);
@@ -123,71 +117,20 @@ export class AuthService {
): Promise<{ message: string; debugCode?: string }> { ): Promise<{ message: string; debugCode?: string }> {
const normalizedEmail = dto.email.toLowerCase(); const normalizedEmail = dto.email.toLowerCase();
const user = await this.usersService.findByEmail(normalizedEmail); const user = await this.usersService.findByEmail(normalizedEmail);
const message = 'If this email exists, a verification code was sent'; const message = 'Account verification is managed by SuperAdmin';
if (!user || user.isDisabled) { if (!user || user.isDisabled) {
return { message }; return { message };
} }
if (user.isVerified) { if (user.isVerified) {
return { message: 'Email is already verified' }; return { message: 'Account is already verified' };
} }
const code = await this.issueEmailVerificationCode(user.id, user.email); return { message: 'Account is pending SuperAdmin verification' };
const response: { message: string; debugCode?: string } = { message };
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
if (nodeEnv !== 'production') {
response.debugCode = code;
}
return response;
} }
async verifyEmail(dto: VerifyEmailDto): Promise<AuthResult & { message: string }> { async verifyEmail(_dto: VerifyEmailDto): Promise<{ message: string }> {
const normalizedEmail = dto.email.toLowerCase();
const user = await this.usersService.findByEmail(normalizedEmail);
if (!user || user.isDisabled) {
throw new UnauthorizedException('Invalid or expired verification code');
}
if (user.isVerified) {
const tokens = await this.generateAndStoreTokenPair(user.id, user.username, user.role ?? 'user');
const safeUser = await this.usersService.findByIdOrFail(user.id);
return {
message: 'Email already verified',
...tokens,
user: safeUser.toObject() as unknown as Record<string, unknown>,
};
}
const codeRecord = await this.authRepository.findLatestActiveEmailVerificationCode(user.id);
if (!codeRecord) {
throw new UnauthorizedException('Invalid or expired verification code');
}
const maxAttempts = this.configService.get<number>('emailVerification.maxAttempts', { infer: true });
if (codeRecord.attempts >= maxAttempts) {
await this.authRepository.markEmailVerificationCodeUsed(codeRecord.id);
throw new UnauthorizedException('Verification code attempts exceeded');
}
const isMatch = await compareHash(dto.code, codeRecord.codeHash);
if (!isMatch) {
await this.authRepository.incrementEmailVerificationAttempts(codeRecord.id);
if (codeRecord.attempts + 1 >= maxAttempts) {
await this.authRepository.markEmailVerificationCodeUsed(codeRecord.id);
}
throw new UnauthorizedException('Invalid or expired verification code');
}
await this.usersService.markEmailVerified(user.id);
await this.authRepository.markEmailVerificationCodeUsed(codeRecord.id);
await this.authRepository.markAllEmailVerificationCodesUsedByUser(user.id);
const safeUser = await this.usersService.findByIdOrFail(user.id);
const tokens = await this.generateAndStoreTokenPair(safeUser.id, safeUser.username, safeUser.role ?? 'user');
return { return {
message: 'Email verified successfully', message: 'Account verification is managed by SuperAdmin',
...tokens,
user: safeUser.toObject() as unknown as Record<string, unknown>,
}; };
} }
@@ -209,7 +152,11 @@ export class AuthService {
throw new UnauthorizedException('Refresh token reuse detected'); throw new UnauthorizedException('Refresh token reuse detected');
} }
const isMatch = await compareHash(dto.refreshToken, tokenRecord.tokenHash); const isMatch = await compareStoredHighEntropyValue(
dto.refreshToken,
tokenRecord.tokenHash,
this.getRefreshTokenHashSecret(),
);
if (!isMatch) { if (!isMatch) {
await this.authRepository.markCompromisedAndRevokeAll(decoded.sub); await this.authRepository.markCompromisedAndRevokeAll(decoded.sub);
throw new UnauthorizedException('Refresh token reuse detected'); throw new UnauthorizedException('Refresh token reuse detected');
@@ -265,7 +212,7 @@ export class AuthService {
email: googleUser.email, email: googleUser.email,
password: passwordHash, password: passwordHash,
avatar: googleUser.avatar ?? '', avatar: googleUser.avatar ?? '',
isVerified: true, isVerified: false,
}); });
} }
@@ -277,6 +224,10 @@ export class AuthService {
throw new ForbiddenException('Account is disabled'); throw new ForbiddenException('Account is disabled');
} }
if (!user.isVerified) {
throw new ForbiddenException('Account is pending SuperAdmin verification');
}
const tokens = await this.generateAndStoreTokenPair(user.id, user.username, user.role ?? 'user'); const tokens = await this.generateAndStoreTokenPair(user.id, user.username, user.role ?? 'user');
const safeUser = await this.usersService.findByIdOrFail(user.id); const safeUser = await this.usersService.findByIdOrFail(user.id);
return { ...tokens, user: safeUser.toObject() as unknown as Record<string, unknown> }; return { ...tokens, user: safeUser.toObject() as unknown as Record<string, unknown> };
@@ -345,43 +296,51 @@ export class AuthService {
refreshToken: string; refreshToken: string;
superAdmin: { email: string }; superAdmin: { email: string };
}> { }> {
const decoded = this.jwtService.verify<{ email: string; tokenType: string }>(dto.refreshToken, { const decoded = this.jwtService.verify<{ email: string; tokenType: string; jti?: string }>(
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }), dto.refreshToken,
}); {
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
},
);
if (decoded.tokenType !== 'superadmin_refresh' || !decoded.email) { if (decoded.tokenType !== 'superadmin_refresh' || !decoded.email || !decoded.jti) {
throw new UnauthorizedException('Invalid superadmin refresh token'); throw new UnauthorizedException('Invalid superadmin refresh token');
} }
const activeTokens = await this.authRepository.findActiveSuperAdminTokens(decoded.email); const tokenRecord = await this.authRepository.findActiveSuperAdminTokenByJti(
if (!activeTokens.length) { decoded.email,
throw new UnauthorizedException('Invalid superadmin refresh token'); decoded.jti,
);
if (!tokenRecord) {
await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
throw new UnauthorizedException('Superadmin refresh token reuse detected');
} }
let validTokenFound = false; const isMatch = await compareStoredHighEntropyValue(
for (const token of activeTokens) { dto.refreshToken,
const isMatch = await compareHash(dto.refreshToken, token.tokenHash); tokenRecord.tokenHash,
if (isMatch) { this.getRefreshTokenHashSecret(),
validTokenFound = true; );
break; if (!isMatch) {
} await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
throw new UnauthorizedException('Superadmin refresh token reuse detected');
} }
if (!validTokenFound) { await this.authRepository.revokeSuperAdminTokenByJti(decoded.email, decoded.jti);
throw new UnauthorizedException('Invalid superadmin refresh token');
}
await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
const tokens = await this.generateAndStoreSuperAdminTokenPair(decoded.email); const tokens = await this.generateAndStoreSuperAdminTokenPair(decoded.email);
return { ...tokens, superAdmin: { email: decoded.email } }; return { ...tokens, superAdmin: { email: decoded.email } };
} }
async superAdminLogout(dto: RefreshTokenDto): Promise<void> { async superAdminLogout(dto: RefreshTokenDto): Promise<void> {
try { try {
const decoded = this.jwtService.verify<{ email: string }>(dto.refreshToken, { const decoded = this.jwtService.verify<{ email: string; jti?: string }>(dto.refreshToken, {
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }), secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
}); });
await this.authRepository.revokeAllSuperAdminTokens(decoded.email); if (decoded.jti) {
await this.authRepository.revokeSuperAdminTokenByJti(decoded.email, decoded.jti);
} else {
await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
}
await this.authRepository.removeExpiredAndRevokedSuperAdmin(decoded.email); await this.authRepository.removeExpiredAndRevokedSuperAdmin(decoded.email);
} catch { } catch {
throw new BadRequestException('Invalid superadmin refresh token'); throw new BadRequestException('Invalid superadmin refresh token');
@@ -403,6 +362,27 @@ export class AuthService {
await this.authRepository.revokeUserTokenByJti(userId, jti); await this.authRepository.revokeUserTokenByJti(userId, jti);
} }
async listSuperAdminSessions(
adminEmail: string,
): Promise<{ items: Array<{ id: string; jti: string; createdAt: Date; expiresAt: Date }> }> {
const sessions = await this.authRepository.listSuperAdminSessions(adminEmail);
return {
items: sessions.map((session) => ({
id: session.id,
jti: session.jti,
createdAt: (session as unknown as { createdAt: Date }).createdAt,
expiresAt: session.expiresAt,
})),
};
}
async revokeSuperAdminSession(adminEmail: string, sessionId: string): Promise<void> {
const revoked = await this.authRepository.revokeSuperAdminSessionById(adminEmail, sessionId);
if (!revoked) {
throw new BadRequestException('Superadmin session not found');
}
}
async forgotPassword(dto: ForgotPasswordDto): Promise<{ message: string; debugCode?: string }> { async forgotPassword(dto: ForgotPasswordDto): Promise<{ message: string; debugCode?: string }> {
const normalizedEmail = dto.email.toLowerCase(); const normalizedEmail = dto.email.toLowerCase();
const user = await this.usersService.findByEmail(normalizedEmail); const user = await this.usersService.findByEmail(normalizedEmail);
@@ -549,8 +529,7 @@ export class AuthService {
), ),
]); ]);
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true }); const tokenHash = hashHighEntropyValue(refreshToken, this.getRefreshTokenHashSecret());
const tokenHash = await hashValue(refreshToken, saltRounds);
const refreshExpiresIn = this.configService.get<string>('jwt.refreshExpiresIn', { const refreshExpiresIn = this.configService.get<string>('jwt.refreshExpiresIn', {
infer: true, infer: true,
@@ -563,6 +542,8 @@ export class AuthService {
} }
private async generateAndStoreSuperAdminTokenPair(adminEmail: string): Promise<TokenPair> { private async generateAndStoreSuperAdminTokenPair(adminEmail: string): Promise<TokenPair> {
const permissions = this.getSuperAdminPermissions();
const refreshJti = randomUUID();
const [accessToken, refreshToken] = await Promise.all([ const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync( this.jwtService.signAsync(
{ {
@@ -571,6 +552,7 @@ export class AuthService {
email: adminEmail.toLowerCase(), email: adminEmail.toLowerCase(),
role: 'superadmin', role: 'superadmin',
tokenType: 'superadmin_access', tokenType: 'superadmin_access',
permissions,
}, },
{ {
secret: this.configService.get<string>('superAdmin.accessSecret', { infer: true }), secret: this.configService.get<string>('superAdmin.accessSecret', { infer: true }),
@@ -584,6 +566,7 @@ export class AuthService {
email: adminEmail.toLowerCase(), email: adminEmail.toLowerCase(),
role: 'superadmin', role: 'superadmin',
tokenType: 'superadmin_refresh', tokenType: 'superadmin_refresh',
jti: refreshJti,
}, },
{ {
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }), secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
@@ -592,8 +575,7 @@ export class AuthService {
), ),
]); ]);
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true }); const tokenHash = hashHighEntropyValue(refreshToken, this.getRefreshTokenHashSecret());
const tokenHash = await hashValue(refreshToken, saltRounds);
const refreshExpiresIn = this.configService.get<string>('superAdmin.refreshExpiresIn', { const refreshExpiresIn = this.configService.get<string>('superAdmin.refreshExpiresIn', {
infer: true, infer: true,
}); });
@@ -601,6 +583,7 @@ export class AuthService {
await this.authRepository.createSuperAdminRefreshToken( await this.authRepository.createSuperAdminRefreshToken(
adminEmail, adminEmail,
refreshJti,
tokenHash, tokenHash,
new Date(Date.now() + refreshExpiresInMs), new Date(Date.now() + refreshExpiresInMs),
); );
@@ -647,6 +630,18 @@ export class AuthService {
return String(randomInt(100000, 1000000)); return String(randomInt(100000, 1000000));
} }
private getRefreshTokenHashSecret(): string {
return (
this.configService.get<string>('security.refreshTokenHashSecret', { infer: true }) ??
this.configService.get<string>('jwt.refreshSecret', { infer: true }) ??
''
);
}
private getSuperAdminPermissions(): string[] {
return [...DEFAULT_SUPERADMIN_PERMISSIONS];
}
private async issueEmailVerificationCode(userId: string, email: string): Promise<string> { private async issueEmailVerificationCode(userId: string, email: string): Promise<string> {
const code = this.generateResetCode(); const code = this.generateResetCode();
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true }); const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });

عرض الملف

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
import { import {
IsArray, IsArray,
IsBoolean, IsBoolean,
@@ -15,6 +15,7 @@ import {
} from 'class-validator'; } from 'class-validator';
import { ExperienceLevel } from '../../../common/enums/experience-level.enum'; import { ExperienceLevel } from '../../../common/enums/experience-level.enum';
import { MusicRole } from '../../../common/enums/music-role.enum'; import { MusicRole } from '../../../common/enums/music-role.enum';
import { toBoolean } from '../../../common/utils/query-transform.util';
export class RegisterDto { export class RegisterDto {
@ApiProperty({ example: 'john@example.com' }) @ApiProperty({ example: 'john@example.com' })
@@ -78,6 +79,7 @@ export class RegisterDto {
@ApiProperty({ required: false, default: false }) @ApiProperty({ required: false, default: false })
@IsOptional() @IsOptional()
@Transform(toBoolean)
@IsBoolean() @IsBoolean()
isPrivate?: boolean; isPrivate?: boolean;

عرض الملف

@@ -12,7 +12,7 @@ export class EmailVerificationCode {
@Prop({ required: true, select: false }) @Prop({ required: true, select: false })
codeHash!: string; codeHash!: string;
@Prop({ required: true, index: true }) @Prop({ required: true })
expiresAt!: Date; expiresAt!: Date;
@Prop({ default: 0, min: 0 }) @Prop({ default: 0, min: 0 })

عرض الملف

@@ -12,7 +12,7 @@ export class PasswordResetCode {
@Prop({ required: true, select: false }) @Prop({ required: true, select: false })
codeHash!: string; codeHash!: string;
@Prop({ required: true, index: true }) @Prop({ required: true })
expiresAt!: Date; expiresAt!: Date;
@Prop({ default: 0, min: 0 }) @Prop({ default: 0, min: 0 })

عرض الملف

@@ -8,6 +8,9 @@ export class SuperAdminRefreshToken {
@Prop({ required: true, trim: true, lowercase: true, index: true }) @Prop({ required: true, trim: true, lowercase: true, index: true })
adminEmail!: string; adminEmail!: string;
@Prop({ required: true, trim: true, index: true })
jti!: string;
@Prop({ required: true, select: false }) @Prop({ required: true, select: false })
tokenHash!: string; tokenHash!: string;
@@ -20,4 +23,5 @@ export class SuperAdminRefreshToken {
export const SuperAdminRefreshTokenSchema = SchemaFactory.createForClass(SuperAdminRefreshToken); export const SuperAdminRefreshTokenSchema = SchemaFactory.createForClass(SuperAdminRefreshToken);
SuperAdminRefreshTokenSchema.index({ adminEmail: 1, revoked: 1 }); SuperAdminRefreshTokenSchema.index({ adminEmail: 1, revoked: 1 });
SuperAdminRefreshTokenSchema.index({ adminEmail: 1, jti: 1 }, { unique: true, sparse: true });
SuperAdminRefreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); SuperAdminRefreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });

عرض الملف

@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { MongooseModule } from '@nestjs/mongoose'; import { MongooseModule } from '@nestjs/mongoose';
import { NotificationsModule } from '../notifications/notifications.module';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { ChatController } from './chat.controller'; import { ChatController } from './chat.controller';
import { ChatGateway } from './chat.gateway'; import { ChatGateway } from './chat.gateway';
@@ -15,6 +16,7 @@ import { Message, MessageSchema } from './schemas/message.schema';
imports: [ imports: [
ConfigModule, ConfigModule,
JwtModule.register({}), JwtModule.register({}),
NotificationsModule,
UsersModule, UsersModule,
MongooseModule.forFeature([ MongooseModule.forFeature([
{ name: Conversation.name, schema: ConversationSchema }, { name: Conversation.name, schema: ConversationSchema },

عرض الملف

@@ -51,11 +51,16 @@ export class ChatRepository {
}); });
} }
async findConversationsForUser(userId: string, skip: number, limit: number): Promise<ConversationDocument[]> { async findConversationsForUser(
userId: string,
skip: number,
limit: number,
sort: Record<string, 1 | -1> = { lastMessageAt: -1, updatedAt: -1 },
): Promise<ConversationDocument[]> {
return this.conversationModel return this.conversationModel
.find({ participantIds: new Types.ObjectId(userId) }) .find({ participantIds: new Types.ObjectId(userId) })
.populate({ path: 'participantIds', select: 'name username stageName avatar isVerified isDisabled' }) .populate({ path: 'participantIds', select: 'name username stageName avatar isVerified isDisabled' })
.sort({ lastMessageAt: -1, updatedAt: -1 }) .sort(sort)
.skip(skip) .skip(skip)
.limit(limit) .limit(limit)
.exec(); .exec();
@@ -83,11 +88,16 @@ export class ChatRepository {
}); });
} }
async findMessages(conversationId: string, skip: number, limit: number): Promise<MessageDocument[]> { async findMessages(
conversationId: string,
skip: number,
limit: number,
sort: Record<string, 1 | -1> = { createdAt: -1 },
): Promise<MessageDocument[]> {
return this.messageModel return this.messageModel
.find({ conversationId: new Types.ObjectId(conversationId) }) .find({ conversationId: new Types.ObjectId(conversationId) })
.populate({ path: 'senderId', select: 'name username stageName avatar isVerified' }) .populate({ path: 'senderId', select: 'name username stageName avatar isVerified' })
.sort({ createdAt: -1 }) .sort(sort)
.skip(skip) .skip(skip)
.limit(limit) .limit(limit)
.exec(); .exec();

عرض الملف

@@ -1,6 +1,9 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Types } from 'mongoose'; import { Types } from 'mongoose';
import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util'; import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util';
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
import { NotificationsService } from '../notifications/notifications.service';
import { UsersRepository } from '../users/users.repository'; import { UsersRepository } from '../users/users.repository';
import { CreateConversationDto } from './dto/create-conversation.dto'; import { CreateConversationDto } from './dto/create-conversation.dto';
import { MessageQueryDto } from './dto/message-query.dto'; import { MessageQueryDto } from './dto/message-query.dto';
@@ -9,9 +12,12 @@ import { ChatRepository } from './chat.repository';
@Injectable() @Injectable()
export class ChatService { export class ChatService {
private readonly logger = new Logger(ChatService.name);
constructor( constructor(
private readonly chatRepository: ChatRepository, private readonly chatRepository: ChatRepository,
private readonly usersRepository: UsersRepository, private readonly usersRepository: UsersRepository,
private readonly notificationsService: NotificationsService,
) {} ) {}
async createConversation(currentUserId: string, dto: CreateConversationDto) { async createConversation(currentUserId: string, dto: CreateConversationDto) {
@@ -61,9 +67,13 @@ export class ChatService {
const limit = query.limit ?? 20; const limit = query.limit ?? 20;
const cursorOffset = decodeOffsetCursor(query.cursor); const cursorOffset = decodeOffsetCursor(query.cursor);
const skip = cursorOffset ?? (page - 1) * limit; const skip = cursorOffset ?? (page - 1) * limit;
const direction = resolveMongoSortDirection(query.sortOrder);
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.chatRepository.findConversationsForUser(currentUserId, skip, limit), this.chatRepository.findConversationsForUser(currentUserId, skip, limit, {
lastMessageAt: direction,
updatedAt: direction,
}),
this.chatRepository.countConversationsForUser(currentUserId), this.chatRepository.countConversationsForUser(currentUserId),
]); ]);
@@ -78,14 +88,15 @@ export class ChatService {
const nextOffset = skip + mappedItems.length; const nextOffset = skip + mappedItems.length;
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null; const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
return { return buildPaginatedResponse(mappedItems, {
items: mappedItems,
page, page,
limit, limit,
total, total,
totalPages: Math.ceil(total / limit) || 1, offset: skip,
currentCursor: query.cursor ?? null,
nextCursor, nextCursor,
}; mode: 'cursor',
});
} }
async getMessages(currentUserId: string, conversationId: string, query: MessageQueryDto) { async getMessages(currentUserId: string, conversationId: string, query: MessageQueryDto) {
@@ -94,9 +105,10 @@ export class ChatService {
const limit = query.limit ?? 20; const limit = query.limit ?? 20;
const cursorOffset = decodeOffsetCursor(query.cursor); const cursorOffset = decodeOffsetCursor(query.cursor);
const skip = cursorOffset ?? (page - 1) * limit; const skip = cursorOffset ?? (page - 1) * limit;
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.chatRepository.findMessages(conversation.id, skip, limit), this.chatRepository.findMessages(conversation.id, skip, limit, sort),
this.chatRepository.countMessages(conversation.id), this.chatRepository.countMessages(conversation.id),
]); ]);
@@ -104,14 +116,15 @@ export class ChatService {
const nextOffset = skip + items.length; const nextOffset = skip + items.length;
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null; const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
return { return buildPaginatedResponse(items, {
items,
page, page,
limit, limit,
total, total,
totalPages: Math.ceil(total / limit) || 1, offset: skip,
currentCursor: query.cursor ?? null,
nextCursor, nextCursor,
}; mode: 'cursor',
});
} }
async sendMessage(currentUserId: string, dto: SendMessageDto) { async sendMessage(currentUserId: string, dto: SendMessageDto) {
@@ -144,6 +157,12 @@ export class ChatService {
currentUserId, currentUserId,
preview, preview,
); );
await this.dispatchMessageNotifications(
currentUserId,
conversation.participantIds.map((id) => id.toString()),
conversation.id,
preview,
);
return message; return message;
} }
@@ -247,4 +266,32 @@ export class ChatService {
} }
} }
} }
private async dispatchMessageNotifications(
actorId: string,
participantIds: string[],
conversationId: string,
previewText: string,
): Promise<void> {
for (const recipientId of participantIds) {
if (recipientId === actorId) {
continue;
}
try {
await this.notificationsService.createMessageNotification(
actorId,
recipientId,
conversationId,
previewText.slice(0, 160),
);
} catch (error) {
this.logger.warn(
`Message notification failed for actor=${actorId} recipient=${recipientId}: ${
error instanceof Error ? error.message : 'unknown error'
}`,
);
}
}
}
} }

عرض الملف

@@ -1,5 +1,7 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsArray, IsBoolean, IsOptional, IsString, Length } from 'class-validator'; import { IsArray, IsBoolean, IsOptional, IsString, Length } from 'class-validator';
import { toBoolean } from '../../../common/utils/query-transform.util';
export class CreateConversationDto { export class CreateConversationDto {
@IsArray() @IsArray()
@@ -8,6 +10,7 @@ export class CreateConversationDto {
@ApiPropertyOptional({ default: false }) @ApiPropertyOptional({ default: false })
@IsOptional() @IsOptional()
@Transform(toBoolean)
@IsBoolean() @IsBoolean()
isGroup?: boolean; isGroup?: boolean;

عرض الملف

@@ -1,5 +1,6 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose'; import { HydratedDocument, Types } from 'mongoose';
import { resolveManagedFileUrl } from '../../../common/utils/public-url.util';
import { User } from '../../users/schemas/user.schema'; import { User } from '../../users/schemas/user.schema';
export type MessageDocument = HydratedDocument<Message>; export type MessageDocument = HydratedDocument<Message>;
@@ -31,3 +32,11 @@ export class Message {
export const MessageSchema = SchemaFactory.createForClass(Message); export const MessageSchema = SchemaFactory.createForClass(Message);
MessageSchema.index({ conversationId: 1, createdAt: -1 }); MessageSchema.index({ conversationId: 1, createdAt: -1 });
MessageSchema.index({ conversationId: 1, isUnsent: 1, createdAt: -1 }); MessageSchema.index({ conversationId: 1, isUnsent: 1, createdAt: -1 });
const transformManagedMessageFiles = (_doc: unknown, ret: any) => {
ret.mediaUrl = resolveManagedFileUrl(ret.mediaUrl);
return ret;
};
MessageSchema.set('toJSON', { transform: transformManagedMessageFiles });
MessageSchema.set('toObject', { transform: transformManagedMessageFiles });

عرض الملف

@@ -1,12 +1,16 @@
import { Controller, Delete, Get, Param, Post, Query, Body, UseGuards } from '@nestjs/common'; import { Controller, Delete, Get, Param, Post, Query, Body, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { SuperAdminPermissionsGuard } from '../../common/guards/superadmin-permissions.guard';
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard'; import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface'; import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
import { AdminCommentQueryDto } from './dto/admin-comment-query.dto';
import { CommentQueryDto } from './dto/comment-query.dto'; import { CommentQueryDto } from './dto/comment-query.dto';
import { CreateCommentDto } from './dto/create-comment.dto'; import { CreateCommentDto } from './dto/create-comment.dto';
import { CommentsService } from './comments.service'; import { CommentsService } from './comments.service';
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
@ApiTags('Comments') @ApiTags('Comments')
@Controller('comments') @Controller('comments')
@@ -34,6 +38,14 @@ export class CommentsController {
return this.commentsService.findReplies(commentId, query); return this.commentsService.findReplies(commentId, query);
} }
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE)
@Get('admin')
async adminList(@Query() query: AdminCommentQueryDto) {
return this.commentsService.findPlatformComments(query);
}
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Delete(':commentId') @Delete(':commentId')
@@ -42,7 +54,8 @@ export class CommentsController {
} }
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard) @UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE)
@Delete('admin/:commentId') @Delete('admin/:commentId')
async adminRemove(@CurrentUser() user: JwtPayload, @Param('commentId') commentId: string) { async adminRemove(@CurrentUser() user: JwtPayload, @Param('commentId') commentId: string) {
return this.commentsService.removeBySuperAdmin(user.email ?? user.sub, commentId); return this.commentsService.removeBySuperAdmin(user.email ?? user.sub, commentId);

عرض الملف

@@ -1,7 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose'; import { MongooseModule } from '@nestjs/mongoose';
import { AuditModule } from '../audit/audit.module'; import { AuditModule } from '../audit/audit.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PostsModule } from '../posts/posts.module'; import { PostsModule } from '../posts/posts.module';
import { UsersModule } from '../users/users.module';
import { Comment, CommentSchema } from './schemas/comment.schema'; import { Comment, CommentSchema } from './schemas/comment.schema';
import { CommentsController } from './comments.controller'; import { CommentsController } from './comments.controller';
import { CommentsService } from './comments.service'; import { CommentsService } from './comments.service';
@@ -12,6 +14,8 @@ import { CommentsRepository } from './comments.repository';
AuditModule, AuditModule,
MongooseModule.forFeature([{ name: Comment.name, schema: CommentSchema }]), MongooseModule.forFeature([{ name: Comment.name, schema: CommentSchema }]),
PostsModule, PostsModule,
NotificationsModule,
UsersModule,
], ],
controllers: [CommentsController], controllers: [CommentsController],
providers: [CommentsService, CommentsRepository], providers: [CommentsService, CommentsRepository],

عرض الملف

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose'; import { InjectModel } from '@nestjs/mongoose';
import { ClientSession, FilterQuery, Model, Types } from 'mongoose'; import { ClientSession, FilterQuery, Model, Types } from 'mongoose';
import { ModerationStatus } from '../../common/enums/moderation-status.enum';
import { Comment, CommentDocument } from './schemas/comment.schema'; import { Comment, CommentDocument } from './schemas/comment.schema';
@Injectable() @Injectable()
@@ -8,6 +9,14 @@ export class CommentsRepository {
constructor(@InjectModel(Comment.name) private readonly commentModel: Model<CommentDocument>) {} constructor(@InjectModel(Comment.name) private readonly commentModel: Model<CommentDocument>) {}
private withActiveFilter<T extends FilterQuery<CommentDocument>>(filter: T): FilterQuery<CommentDocument> { private withActiveFilter<T extends FilterQuery<CommentDocument>>(filter: T): FilterQuery<CommentDocument> {
return {
...filter,
isDeleted: { $ne: true },
moderationStatus: { $ne: ModerationStatus.HIDDEN },
};
}
private withAdminFilter<T extends FilterQuery<CommentDocument>>(filter: T): FilterQuery<CommentDocument> {
return { return {
...filter, ...filter,
isDeleted: { $ne: true }, isDeleted: { $ne: true },
@@ -15,15 +24,24 @@ export class CommentsRepository {
} }
async create( async create(
payload: { postId: string; authorId: string; content: string; parentCommentId?: string }, payload: {
postId: string;
authorId: string;
content: string;
mentionUsernames?: string[];
parentCommentId?: string;
},
session?: ClientSession, session?: ClientSession,
) { ) {
return this.commentModel.create({ const doc = new this.commentModel({
postId: new Types.ObjectId(payload.postId), postId: new Types.ObjectId(payload.postId),
authorId: new Types.ObjectId(payload.authorId), authorId: new Types.ObjectId(payload.authorId),
content: payload.content, content: payload.content,
mentionUsernames: payload.mentionUsernames ?? [],
...(payload.parentCommentId ? { parentCommentId: new Types.ObjectId(payload.parentCommentId) } : {}), ...(payload.parentCommentId ? { parentCommentId: new Types.ObjectId(payload.parentCommentId) } : {}),
}, { session }); });
return session ? doc.save({ session }) : doc.save();
} }
async findById(commentId: string): Promise<CommentDocument | null> { async findById(commentId: string): Promise<CommentDocument | null> {
@@ -55,11 +73,31 @@ export class CommentsRepository {
return !!updated; return !!updated;
} }
async findMany(filter: FilterQuery<CommentDocument>, skip: number, limit: number) { async findMany(
filter: FilterQuery<CommentDocument>,
skip: number,
limit: number,
sort: Record<string, 1 | -1> = { createdAt: -1 },
) {
return this.commentModel return this.commentModel
.find(this.withActiveFilter(filter)) .find(this.withActiveFilter(filter))
.populate({ path: 'authorId', select: 'name username avatar stageName isVerified' }) .populate({ path: 'authorId', select: 'name username avatar stageName isVerified' })
.sort({ createdAt: -1 }) .sort(sort)
.skip(skip)
.limit(limit)
.exec();
}
async findManyAdmin(
filter: FilterQuery<CommentDocument>,
skip: number,
limit: number,
sort: Record<string, 1 | -1> = { createdAt: -1 },
) {
return this.commentModel
.find(this.withAdminFilter(filter))
.populate({ path: 'authorId', select: 'name username avatar stageName isVerified' })
.sort(sort)
.skip(skip) .skip(skip)
.limit(limit) .limit(limit)
.exec(); .exec();
@@ -69,6 +107,31 @@ export class CommentsRepository {
return this.commentModel.countDocuments(this.withActiveFilter(filter)).exec(); return this.commentModel.countDocuments(this.withActiveFilter(filter)).exec();
} }
async countAdmin(filter: FilterQuery<CommentDocument>): Promise<number> {
return this.commentModel.countDocuments(this.withAdminFilter(filter)).exec();
}
async updateModerationStatus(
commentId: string,
payload: Pick<Comment, 'moderationStatus' | 'moderationReason'>,
): Promise<CommentDocument | null> {
if (!Types.ObjectId.isValid(commentId)) {
return null;
}
return this.commentModel
.findByIdAndUpdate(
commentId,
{
moderationStatus: payload.moderationStatus,
moderationReason: payload.moderationReason,
},
{ new: true },
)
.populate({ path: 'authorId', select: 'name username avatar stageName isVerified' })
.exec();
}
async countByPost(postId: string): Promise<number> { async countByPost(postId: string): Promise<number> {
return this.commentModel return this.commentModel
.countDocuments({ postId: new Types.ObjectId(postId), isDeleted: { $ne: true } }) .countDocuments({ postId: new Types.ObjectId(postId), isDeleted: { $ne: true } })

عرض الملف

@@ -1,16 +1,29 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Types } from 'mongoose';
import { ModerationStatus } from '../../common/enums/moderation-status.enum';
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import { NotificationsService } from '../notifications/notifications.service';
import { PostsRepository } from '../posts/posts.repository'; import { PostsRepository } from '../posts/posts.repository';
import { UsersRepository } from '../users/users.repository';
import { AdminCommentQueryDto } from './dto/admin-comment-query.dto';
import { CommentQueryDto } from './dto/comment-query.dto'; import { CommentQueryDto } from './dto/comment-query.dto';
import { CreateCommentDto } from './dto/create-comment.dto'; import { CreateCommentDto } from './dto/create-comment.dto';
import { CommentsRepository } from './comments.repository'; import { CommentsRepository } from './comments.repository';
@Injectable() @Injectable()
export class CommentsService { export class CommentsService {
private readonly logger = new Logger(CommentsService.name);
constructor( constructor(
private readonly commentsRepository: CommentsRepository, private readonly commentsRepository: CommentsRepository,
private readonly postsRepository: PostsRepository, private readonly postsRepository: PostsRepository,
private readonly auditService: AuditService, private readonly auditService: AuditService,
private readonly feedVersionService: FeedVersionService,
private readonly notificationsService: NotificationsService,
private readonly usersRepository: UsersRepository,
) {} ) {}
async create(userId: string, dto: CreateCommentDto) { async create(userId: string, dto: CreateCommentDto) {
@@ -19,20 +32,42 @@ export class CommentsService {
throw new NotFoundException('Post not found'); throw new NotFoundException('Post not found');
} }
let parentRecipientId = '';
if (dto.parentCommentId) { if (dto.parentCommentId) {
const parent = await this.commentsRepository.findById(dto.parentCommentId); const parent = await this.commentsRepository.findById(dto.parentCommentId);
if (!parent || parent.postId.toString() !== dto.postId) { if (!parent || parent.postId.toString() !== dto.postId) {
throw new NotFoundException('Parent comment not found'); throw new NotFoundException('Parent comment not found');
} }
parentRecipientId = parent.authorId.toString();
} }
const content = dto.content.trim();
const mentionResolution = await this.resolveMentionTargets(dto.mentionUsernames, content, userId);
const comment = await this.commentsRepository.create({ const comment = await this.commentsRepository.create({
postId: dto.postId, postId: dto.postId,
authorId: userId, authorId: userId,
content: dto.content, content,
mentionUsernames: mentionResolution.mentionUsernames,
parentCommentId: dto.parentCommentId, parentCommentId: dto.parentCommentId,
}); });
await this.syncCommentsCount(dto.postId); await this.syncCommentsCount(dto.postId);
await this.feedVersionService.bumpGlobalVersion();
const postAuthorId = this.extractEntityId(post.authorId);
const previewText = content.slice(0, 160);
const commentNotificationRecipients = await this.dispatchCommentNotifications(
userId,
postAuthorId,
parentRecipientId,
dto.postId,
previewText,
);
await this.notifyMentionedUsers(
userId,
dto.postId,
mentionResolution.mentionedUsers,
previewText,
commentNotificationRecipients,
);
return comment; return comment;
} }
@@ -48,6 +83,7 @@ export class CommentsService {
await this.commentsRepository.deleteById(commentId, userId); await this.commentsRepository.deleteById(commentId, userId);
await this.syncCommentsCount(comment.postId.toString()); await this.syncCommentsCount(comment.postId.toString());
await this.feedVersionService.bumpGlobalVersion();
return { success: true }; return { success: true };
} }
@@ -59,6 +95,7 @@ export class CommentsService {
await this.commentsRepository.deleteById(commentId, superAdminIdentifier); await this.commentsRepository.deleteById(commentId, superAdminIdentifier);
await this.syncCommentsCount(comment.postId.toString()); await this.syncCommentsCount(comment.postId.toString());
await this.feedVersionService.bumpGlobalVersion();
await this.auditService.logSuperAdminAction( await this.auditService.logSuperAdminAction(
superAdminIdentifier, superAdminIdentifier,
'comment_delete', 'comment_delete',
@@ -70,45 +107,281 @@ export class CommentsService {
} }
async findByPost(postId: string, query: CommentQueryDto) { async findByPost(postId: string, query: CommentQueryDto) {
if (!Types.ObjectId.isValid(postId)) {
throw new BadRequestException('Invalid post id');
}
const page = query.page ?? 1; const page = query.page ?? 1;
const limit = query.limit ?? 20; const limit = query.limit ?? 20;
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const postObjectId = new Types.ObjectId(postId);
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.commentsRepository.findMany({ postId, parentCommentId: { $exists: false } }, skip, limit), this.commentsRepository.findMany(
this.commentsRepository.count({ postId, parentCommentId: { $exists: false } }), {
postId: postObjectId,
$or: [{ parentCommentId: { $exists: false } }, { parentCommentId: null }],
},
skip,
limit,
sort,
),
this.commentsRepository.count({
postId: postObjectId,
$or: [{ parentCommentId: { $exists: false } }, { parentCommentId: null }],
}),
]); ]);
return { return buildPaginatedResponse(items, {
items,
page, page,
limit, limit,
total, total,
totalPages: Math.ceil(total / limit) || 1, offset: skip,
}; });
} }
async findReplies(parentCommentId: string, query: CommentQueryDto) { async findReplies(parentCommentId: string, query: CommentQueryDto) {
if (!Types.ObjectId.isValid(parentCommentId)) {
throw new BadRequestException('Invalid parent comment id');
}
const page = query.page ?? 1; const page = query.page ?? 1;
const limit = query.limit ?? 20; const limit = query.limit ?? 20;
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const parentObjectId = new Types.ObjectId(parentCommentId);
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.commentsRepository.findMany({ parentCommentId }, skip, limit), this.commentsRepository.findMany({ parentCommentId: parentObjectId }, skip, limit, sort),
this.commentsRepository.count({ parentCommentId }), this.commentsRepository.count({ parentCommentId: parentObjectId }),
]); ]);
return { return buildPaginatedResponse(items, {
items,
page, page,
limit, limit,
total, total,
totalPages: Math.ceil(total / limit) || 1, offset: skip,
}; });
}
async findPlatformComments(query: AdminCommentQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
const filter: Record<string, unknown> = {};
if (query.postId) {
filter.postId = new Types.ObjectId(query.postId);
}
if (query.authorId) {
filter.authorId = new Types.ObjectId(query.authorId);
}
if (query.q?.trim()) {
filter.content = { $regex: query.q.trim(), $options: 'i' };
}
if (query.moderationStatus) {
filter.moderationStatus = query.moderationStatus;
}
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const [items, total] = await Promise.all([
this.commentsRepository.findManyAdmin(filter, skip, limit, sort),
this.commentsRepository.countAdmin(filter),
]);
return buildPaginatedResponse(items, {
page,
limit,
total,
offset: skip,
});
}
async updateModerationStatusBySuperAdmin(
superAdminIdentifier: string,
commentId: string,
dto: { status: ModerationStatus; reason?: string },
) {
const comment = await this.commentsRepository.findById(commentId);
if (!comment) {
throw new NotFoundException('Comment not found');
}
const updated = await this.commentsRepository.updateModerationStatus(commentId, {
moderationStatus: dto.status,
moderationReason: dto.reason?.trim() ?? '',
});
if (!updated) {
throw new NotFoundException('Comment not found');
}
await this.feedVersionService.bumpGlobalVersion();
await this.auditService.logSuperAdminAction(
superAdminIdentifier,
'comment_moderation_status_update',
'comment',
commentId,
{
previousStatus: comment.moderationStatus ?? ModerationStatus.ACTIVE,
nextStatus: dto.status,
reason: dto.reason?.trim() ?? '',
},
);
return updated;
} }
private async syncCommentsCount(postId: string): Promise<void> { private async syncCommentsCount(postId: string): Promise<void> {
const totalComments = await this.commentsRepository.countByPost(postId); const totalComments = await this.commentsRepository.countByPost(postId);
await this.postsRepository.setCommentsCount(postId, totalComments); await this.postsRepository.setCommentsCount(postId, totalComments);
} }
private async dispatchCommentNotifications(
actorId: string,
postAuthorId: string,
parentRecipientId: string,
postId: string,
previewText: string,
): Promise<Set<string>> {
const recipients = new Set<string>();
if (postAuthorId && postAuthorId !== actorId) {
recipients.add(postAuthorId);
}
if (parentRecipientId && parentRecipientId !== actorId) {
recipients.add(parentRecipientId);
}
for (const recipientId of recipients) {
try {
await this.notificationsService.createCommentNotification(actorId, recipientId, postId, {
resourceType: 'post',
previewText,
});
} catch (error) {
this.logger.warn(
`Comment notification failed for actor=${actorId} recipient=${recipientId}: ${
error instanceof Error ? error.message : 'unknown error'
}`,
);
}
}
return recipients;
}
private normalizeMentionUsernames(input: string[] = []): string[] {
return Array.from(
new Set(
input
.map((username) => username?.trim().replace(/^@+/, '').toLowerCase())
.filter((username): username is string => !!username),
),
);
}
private extractMentions(content: string): string[] {
const matches = content.match(/@[\p{L}\p{N}_.]+/gu) ?? [];
return this.normalizeMentionUsernames(matches.map((item) => item.replace('@', '')));
}
private async resolveMentionTargets(
explicitMentionUsernames: string[] | undefined,
content: string,
authorId: string,
): Promise<{
mentionUsernames: string[];
mentionedUsers: Array<{ id: string; username: string }>;
}> {
const mergedMentionUsernames = Array.from(
new Set([
...this.extractMentions(content),
...this.normalizeMentionUsernames(explicitMentionUsernames ?? []),
]),
);
if (mergedMentionUsernames.length > 30) {
throw new BadRequestException('You can mention up to 30 users only');
}
if (!mergedMentionUsernames.length) {
return { mentionUsernames: [], mentionedUsers: [] };
}
const users = await this.usersRepository.findByUsernames(mergedMentionUsernames);
const userByUsername = new Map(
users.map((user) => [user.username.toLowerCase(), { id: user.id, username: user.username.toLowerCase() }]),
);
const mentionedUsers = mergedMentionUsernames
.map((username) => userByUsername.get(username))
.filter((user): user is { id: string; username: string } => !!user)
.filter((user) => user.id !== authorId);
return {
mentionUsernames: mentionedUsers.map((user) => user.username),
mentionedUsers,
};
}
private async notifyMentionedUsers(
actorId: string,
postId: string,
mentionedUsers: Array<{ id: string; username: string }>,
previewText: string,
excludedRecipientIds: Set<string>,
): Promise<void> {
if (!mentionedUsers.length) {
return;
}
for (const mentionedUser of mentionedUsers) {
if (excludedRecipientIds.has(mentionedUser.id)) {
continue;
}
try {
await this.notificationsService.createMentionNotification(actorId, mentionedUser.id, postId, {
resourceType: 'comment',
previewText,
deepLink: `/posts/${postId}`,
});
} catch (error) {
this.logger.warn(
`Comment mention notification failed for actor=${actorId} recipient=${mentionedUser.id}: ${
error instanceof Error ? error.message : 'unknown error'
}`,
);
}
}
}
private extractEntityId(value: unknown): string {
if (!value) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (value instanceof Types.ObjectId) {
return value.toString();
}
if (typeof value === 'object') {
const candidate = value as { _id?: unknown; id?: unknown };
if (candidate._id instanceof Types.ObjectId) {
return candidate._id.toString();
}
if (typeof candidate._id === 'string') {
return candidate._id;
}
if (typeof candidate.id === 'string') {
return candidate.id;
}
}
return '';
}
} }

عرض الملف

@@ -0,0 +1,26 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsMongoId, IsOptional, IsString } from 'class-validator';
import { ModerationStatus } from '../../../common/enums/moderation-status.enum';
import { CommentQueryDto } from './comment-query.dto';
export class AdminCommentQueryDto extends CommentQueryDto {
@ApiPropertyOptional({ description: 'Search comment content' })
@IsOptional()
@IsString()
q?: string;
@ApiPropertyOptional({ description: 'Optional post filter' })
@IsOptional()
@IsMongoId()
postId?: string;
@ApiPropertyOptional({ description: 'Optional author filter' })
@IsOptional()
@IsMongoId()
authorId?: string;
@ApiPropertyOptional({ enum: ModerationStatus, description: 'Optional moderation status filter' })
@IsOptional()
@IsEnum(ModerationStatus)
moderationStatus?: ModerationStatus;
}

عرض الملف

@@ -1,3 +1,10 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
export class CommentQueryDto extends PaginationQueryDto {} export class CommentQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({
description: 'Use asc to display oldest comments first, or desc for newest first',
default: 'desc',
})
declare sortOrder: PaginationQueryDto['sortOrder'];
}

عرض الملف

@@ -1,5 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsMongoId, IsOptional, IsString, Length } from 'class-validator'; import { Transform } from 'class-transformer';
import { ArrayMaxSize, IsArray, IsMongoId, IsOptional, IsString, Length } from 'class-validator';
import { toStringArray } from '../../../common/utils/array-transform.util';
export class CreateCommentDto { export class CreateCommentDto {
@ApiProperty() @ApiProperty()
@@ -15,4 +17,13 @@ export class CreateCommentDto {
@IsOptional() @IsOptional()
@IsMongoId() @IsMongoId()
parentCommentId?: string; parentCommentId?: string;
@ApiPropertyOptional({ type: [String], description: 'Mention usernames like rami_sabry (max 30)' })
@IsOptional()
@Transform(toStringArray)
@IsArray()
@ArrayMaxSize(30)
@IsString({ each: true })
@Length(1, 30, { each: true })
mentionUsernames?: string[];
} }

عرض الملف

@@ -1,5 +1,6 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose'; import { HydratedDocument, Types } from 'mongoose';
import { ModerationStatus } from '../../../common/enums/moderation-status.enum';
import { Post } from '../../posts/schemas/post.schema'; import { Post } from '../../posts/schemas/post.schema';
import { User } from '../../users/schemas/user.schema'; import { User } from '../../users/schemas/user.schema';
@@ -19,6 +20,20 @@ export class Comment {
@Prop({ required: true, maxlength: 1000 }) @Prop({ required: true, maxlength: 1000 })
content!: string; content!: string;
@Prop({ type: [String], default: [] })
mentionUsernames!: string[];
@Prop({
type: String,
enum: Object.values(ModerationStatus),
default: ModerationStatus.ACTIVE,
index: true,
})
moderationStatus!: ModerationStatus;
@Prop({ default: '', maxlength: 300 })
moderationReason!: string;
@Prop({ default: false, index: true }) @Prop({ default: false, index: true })
isDeleted!: boolean; isDeleted!: boolean;
@@ -32,3 +47,4 @@ export class Comment {
export const CommentSchema = SchemaFactory.createForClass(Comment); export const CommentSchema = SchemaFactory.createForClass(Comment);
CommentSchema.index({ postId: 1, createdAt: -1 }); CommentSchema.index({ postId: 1, createdAt: -1 });
CommentSchema.index({ postId: 1, parentCommentId: 1, isDeleted: 1, createdAt: -1 }); CommentSchema.index({ postId: 1, parentCommentId: 1, isDeleted: 1, createdAt: -1 });
CommentSchema.index({ moderationStatus: 1, createdAt: -1 });

عرض الملف

@@ -1,7 +1,8 @@
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
import { IsBoolean, IsEnum, IsNumber, IsOptional, Max, Min } from 'class-validator'; import { IsBoolean, IsEnum, IsNumber, IsOptional, Max, Min } from 'class-validator';
import { Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
import { PostType } from '../../../common/enums/post-type.enum'; import { PostType } from '../../../common/enums/post-type.enum';
import { toBoolean } from '../../../common/utils/query-transform.util';
export class FeedQueryDto extends PaginationQueryDto { export class FeedQueryDto extends PaginationQueryDto {
@IsOptional() @IsOptional()
@@ -9,7 +10,7 @@ export class FeedQueryDto extends PaginationQueryDto {
preferredPostType?: PostType; preferredPostType?: PostType;
@IsOptional() @IsOptional()
@Type(() => Boolean) @Transform(toBoolean)
@IsBoolean() @IsBoolean()
followingOnly?: boolean; followingOnly?: boolean;
@@ -19,4 +20,16 @@ export class FeedQueryDto extends PaginationQueryDto {
@Min(1) @Min(1)
@Max(500) @Max(500)
radiusKm?: number; radiusKm?: number;
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
includeSuggestions?: boolean;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(2)
@Max(10)
suggestionInterval?: number;
} }

عرض الملف

@@ -21,7 +21,7 @@ export class FeedController {
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get('trending') @Get('trending')
async trending(@Query() query: FeedQueryDto) { async trending(@CurrentUser() user: JwtPayload, @Query() query: FeedQueryDto) {
return this.feedService.getTrending(query); return this.feedService.getTrending(user.sub, query);
} }
} }

عرض الملف

@@ -1,7 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose'; import { MongooseModule } from '@nestjs/mongoose';
import { FollowsModule } from '../follows/follows.module';
import { LikesModule } from '../likes/likes.module';
import { MarketplaceModule } from '../marketplace/marketplace.module';
import { Follow, FollowSchema } from '../follows/schemas/follow.schema'; import { Follow, FollowSchema } from '../follows/schemas/follow.schema';
import { Post, PostSchema } from '../posts/schemas/post.schema'; import { Post, PostSchema } from '../posts/schemas/post.schema';
import { SavesModule } from '../saves/saves.module';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { FeedController } from './feed.controller'; import { FeedController } from './feed.controller';
import { FeedService } from './feed.service'; import { FeedService } from './feed.service';
@@ -10,6 +14,10 @@ import { FeedRepository } from './feed.repository';
@Module({ @Module({
imports: [ imports: [
UsersModule, UsersModule,
LikesModule,
SavesModule,
FollowsModule,
MarketplaceModule,
MongooseModule.forFeature([ MongooseModule.forFeature([
{ name: Post.name, schema: PostSchema }, { name: Post.name, schema: PostSchema },
{ name: Follow.name, schema: FollowSchema }, { name: Follow.name, schema: FollowSchema },

عرض الملف

@@ -42,11 +42,23 @@ export class FeedRepository {
.exec(); .exec();
} }
async findTrendingPublicPosts(skip: number, limit: number): Promise<PostDocument[]> { async findTrendingPublicPosts(
filter: FilterQuery<PostDocument>,
skip: number,
limit: number,
): Promise<PostDocument[]> {
return this.postModel return this.postModel
.find({ visibility: 'public', isDeleted: { $ne: true } }) .find({ ...filter, isDeleted: { $ne: true } })
.populate({ path: 'authorId', select: 'name username stageName avatar isVerified isDisabled' }) .populate({ path: 'authorId', select: 'name username stageName avatar isVerified isDisabled' })
.sort({ likesCount: -1, commentsCount: -1, savesCount: -1, createdAt: -1 }) .sort({
shareCount: -1,
likesCount: -1,
commentsCount: -1,
savesCount: -1,
viewCount: -1,
playCount: -1,
createdAt: -1,
})
.skip(skip) .skip(skip)
.limit(limit) .limit(limit)
.exec(); .exec();

عرض الملف

@@ -1,21 +1,97 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Types } from 'mongoose'; import { Types } from 'mongoose';
import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util';
import { PostType } from '../../common/enums/post-type.enum'; import { PostType } from '../../common/enums/post-type.enum';
import { PostVisibility } from '../../common/enums/post-visibility.enum'; import { PostVisibility } from '../../common/enums/post-visibility.enum';
import { UsersRepository } from '../users/users.repository'; import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util';
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { AppCacheService } from '../../infrastructure/cache/app-cache.service';
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
import { FollowsService } from '../follows/follows.service';
import { LikesRepository } from '../likes/likes.repository';
import { MarketplaceService } from '../marketplace/marketplace.service';
import { SavesRepository } from '../saves/saves.repository';
import { UserDocument } from '../users/schemas/user.schema'; import { UserDocument } from '../users/schemas/user.schema';
import { UsersRepository } from '../users/users.repository';
import { FeedQueryDto } from './dto/feed-query.dto'; import { FeedQueryDto } from './dto/feed-query.dto';
import { FeedRepository } from './feed.repository'; import { FeedRepository } from './feed.repository';
type FeedPostItem = Record<string, unknown> & {
feedItemType: 'post';
feedScore?: number;
likedByMe: boolean;
savedByMe: boolean;
followingAuthor: boolean;
isOwnPost: boolean;
canComment: boolean;
canMessage: boolean;
engagement: {
likesCount: number;
commentsCount: number;
savesCount: number;
shareCount: number;
viewCount: number;
playCount: number;
};
};
type FeedCardItem =
| {
id: string;
feedItemType: 'suggested_users';
title: string;
subtitle: string;
items: Array<Record<string, unknown>>;
}
| {
id: string;
feedItemType: 'featured_marketplace';
title: string;
subtitle: string;
listings: Array<Record<string, unknown>>;
musicalInstruments: Array<Record<string, unknown>>;
instruments: Array<Record<string, unknown>>;
repairShops: Array<Record<string, unknown>>;
};
@Injectable() @Injectable()
export class FeedService { export class FeedService {
constructor( constructor(
private readonly feedRepository: FeedRepository, private readonly feedRepository: FeedRepository,
private readonly usersRepository: UsersRepository, private readonly usersRepository: UsersRepository,
private readonly cacheService: AppCacheService,
private readonly feedVersionService: FeedVersionService,
private readonly configService: ConfigService,
private readonly likesRepository: LikesRepository,
private readonly savesRepository: SavesRepository,
private readonly followsService: FollowsService,
private readonly marketplaceService: MarketplaceService,
) {} ) {}
async getMyFeed(currentUserId: string, query: FeedQueryDto) { async getMyFeed(currentUserId: string, query: FeedQueryDto) {
const cacheEnabled =
this.configService.get<boolean>('feedCache.enabled', { infer: true }) ?? true;
const globalVersion = cacheEnabled ? await this.feedVersionService.getGlobalVersion() : 0;
const includeSuggestions = this.shouldIncludeSuggestions(query);
const cacheKey = this.buildCacheKey('me', {
currentUserId,
globalVersion,
page: query.page ?? 1,
limit: query.limit ?? 20,
cursor: query.cursor ?? '',
followingOnly: query.followingOnly ?? false,
radiusKm: query.radiusKm ?? 30,
preferredPostType: query.preferredPostType ?? '',
includeSuggestions,
suggestionInterval: query.suggestionInterval ?? 4,
});
if (cacheEnabled) {
const cached = await this.cacheService.get<Record<string, unknown>>(cacheKey);
if (cached) {
return cached;
}
}
const currentUser = await this.usersRepository.findById(currentUserId); const currentUser = await this.usersRepository.findById(currentUserId);
if (!currentUser) { if (!currentUser) {
throw new NotFoundException('Current user not found'); throw new NotFoundException('Current user not found');
@@ -26,20 +102,10 @@ export class FeedService {
const page = query.page ?? 1; const page = query.page ?? 1;
const followingOnly = query.followingOnly ?? false; const followingOnly = query.followingOnly ?? false;
const radiusKm = query.radiusKm ?? 30; const radiusKm = query.radiusKm ?? 30;
const skip = cursorOffset ?? (page - 1) * limit;
const followingIds = await this.feedRepository.findFollowingIds(currentUserId); const followingIds = await this.feedRepository.findFollowingIds(currentUserId);
const visibleAuthorIds = followingOnly ? [currentUserId, ...followingIds] : null; const filter = this.buildVisiblePostsFilter(currentUserId, followingIds, followingOnly);
const filter: Record<string, unknown> = {
$or: [
{ visibility: PostVisibility.PUBLIC },
{ authorId: new Types.ObjectId(currentUserId) },
],
};
if (visibleAuthorIds) {
filter.authorId = { $in: visibleAuthorIds.map((id) => new Types.ObjectId(id)) };
}
const candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300)); const candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300));
@@ -64,48 +130,268 @@ export class FeedService {
.sort( .sort(
(a, b) => (a, b) =>
b.score - a.score || b.score - a.score ||
new Date((b.post as any).createdAt ?? 0).getTime() - new Date((a.post as any).createdAt ?? 0).getTime(), new Date((b.post as any).createdAt ?? 0).getTime() -
new Date((a.post as any).createdAt ?? 0).getTime(),
); );
const total = scored.length; const total = scored.length;
const skip = cursorOffset ?? (page - 1) * limit; const pagedPosts = scored.slice(skip, skip + limit).map((entry) => ({
const items = scored.slice(skip, skip + limit).map((entry) => ({ ...(entry.post.toObject() as unknown as Record<string, unknown>),
...entry.post.toObject(),
feedScore: Number(entry.score.toFixed(3)), feedScore: Number(entry.score.toFixed(3)),
})); }));
const nextOffset = skip + items.length; const decoratedPosts = await this.decoratePostsForViewer(currentUserId, pagedPosts, followingIds);
const items = includeSuggestions
? await this.mixHomeFeedItems(currentUserId, decoratedPosts, query.suggestionInterval ?? 4)
: decoratedPosts;
const nextOffset = skip + pagedPosts.length;
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null; const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
return { const result = buildPaginatedResponse(items, {
items,
page, page,
limit, limit,
total, total,
totalPages: Math.ceil(total / limit) || 1, offset: skip,
currentCursor: query.cursor ?? null,
nextCursor, nextCursor,
}; mode: 'cursor',
});
if (cacheEnabled) {
await this.cacheService.set(
cacheKey,
result,
this.configService.get<number>('feedCache.userFeedTtlSeconds', { infer: true }) ?? 15,
);
}
return result;
} }
async getTrending(query: FeedQueryDto) { async getTrending(currentUserId: string, query: FeedQueryDto) {
const cacheEnabled =
this.configService.get<boolean>('feedCache.enabled', { infer: true }) ?? true;
const globalVersion = cacheEnabled ? await this.feedVersionService.getGlobalVersion() : 0;
const cacheKey = this.buildCacheKey('trending', {
currentUserId,
globalVersion,
page: query.page ?? 1,
limit: query.limit ?? 20,
cursor: query.cursor ?? '',
preferredPostType: query.preferredPostType ?? '',
});
if (cacheEnabled) {
const cached = await this.cacheService.get<Record<string, unknown>>(cacheKey);
if (cached) {
return cached;
}
}
const limit = query.limit ?? 20; const limit = query.limit ?? 20;
const cursorOffset = decodeOffsetCursor(query.cursor); const cursorOffset = decodeOffsetCursor(query.cursor);
const page = query.page ?? 1; const page = query.page ?? 1;
const skip = cursorOffset ?? (page - 1) * limit; const skip = cursorOffset ?? (page - 1) * limit;
const followingIds = await this.feedRepository.findFollowingIds(currentUserId);
const trendingFilter: Record<string, unknown> = { visibility: PostVisibility.PUBLIC };
if (query.preferredPostType) {
trendingFilter.postType = query.preferredPostType;
}
const [items, total] = await Promise.all([ const [rows, total] = await Promise.all([
this.feedRepository.findTrendingPublicPosts(skip, limit), this.feedRepository.findTrendingPublicPosts(trendingFilter, skip, limit),
this.feedRepository.count({ visibility: PostVisibility.PUBLIC }), this.feedRepository.count(trendingFilter),
]); ]);
const nextOffset = skip + items.length; const decoratedPosts = await this.decoratePostsForViewer(
currentUserId,
rows.map((item) => item.toObject() as unknown as Record<string, unknown>),
followingIds,
);
const nextOffset = skip + rows.length;
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null; const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
return { const result = buildPaginatedResponse(decoratedPosts, {
items,
page, page,
limit, limit,
total, total,
totalPages: Math.ceil(total / limit) || 1, offset: skip,
currentCursor: query.cursor ?? null,
nextCursor, nextCursor,
mode: 'cursor',
});
if (cacheEnabled) {
await this.cacheService.set(
cacheKey,
result,
this.configService.get<number>('feedCache.trendingTtlSeconds', { infer: true }) ?? 30,
);
}
return result;
}
private async decoratePostsForViewer(
currentUserId: string,
items: Array<Record<string, unknown>>,
followingIds: string[],
): Promise<FeedPostItem[]> {
const postIds = items
.map((item) => this.extractEntityId(item._id ?? item.id))
.filter(Boolean);
const followingSet = new Set(followingIds);
const [likedPostIds, savedPostIds] = await Promise.all([
this.likesRepository.findLikedPostIds(currentUserId, postIds),
this.savesRepository.findSavedPostIds(currentUserId, postIds),
]);
const likedSet = new Set(likedPostIds);
const savedSet = new Set(savedPostIds);
return items.map((item) => {
const postId = this.extractEntityId(item._id ?? item.id);
const authorId = this.extractEntityId(item.authorId);
const likesCount = Number(item.likesCount ?? 0);
const commentsCount = Number(item.commentsCount ?? 0);
const savesCount = Number(item.savesCount ?? 0);
const shareCount = Number(item.shareCount ?? 0);
const viewCount = Number(item.viewCount ?? 0);
const playCount = Number(item.playCount ?? 0);
return {
...item,
id: postId,
feedItemType: 'post',
likedByMe: likedSet.has(postId),
savedByMe: savedSet.has(postId),
followingAuthor: !!authorId && followingSet.has(authorId),
isOwnPost: authorId === currentUserId,
canComment: true,
canMessage: !!authorId && authorId !== currentUserId,
engagement: {
likesCount,
commentsCount,
savesCount,
shareCount,
viewCount,
playCount,
},
};
});
}
private async mixHomeFeedItems(
currentUserId: string,
posts: FeedPostItem[],
suggestionInterval: number,
): Promise<Array<FeedPostItem | FeedCardItem>> {
const cards = await this.buildHomeCards(currentUserId);
if (!cards.length) {
return posts;
}
const result: Array<FeedPostItem | FeedCardItem> = [];
let cardIndex = 0;
for (let index = 0; index < posts.length; index += 1) {
result.push(posts[index]);
if ((index + 1) % suggestionInterval === 0 && cardIndex < cards.length) {
result.push(cards[cardIndex]);
cardIndex += 1;
}
}
while (cardIndex < cards.length) {
result.push(cards[cardIndex]);
cardIndex += 1;
}
return result;
}
private async buildHomeCards(currentUserId: string): Promise<FeedCardItem[]> {
const [suggestions, listings, instruments, repairShops] = await Promise.all([
this.followsService.getSuggestions(currentUserId, {
page: 1,
limit: 5,
}),
this.marketplaceService.getPublicListings({
page: 1,
limit: 3,
isActive: true,
} as any),
this.marketplaceService.getPublicInstruments({
page: 1,
limit: 3,
isActive: true,
} as any),
this.marketplaceService.getPublicRepairShops({
page: 1,
limit: 2,
isActive: true,
} as any),
]);
const cards: FeedCardItem[] = [];
if (Array.isArray(suggestions.items) && suggestions.items.length > 0) {
cards.push({
id: `suggested-users:${currentUserId}`,
feedItemType: 'suggested_users',
title: 'Suggested creators',
subtitle: 'People you may want to follow',
items: suggestions.items.map((entry) => ({
...entry,
following: false,
})),
});
}
if (
(listings.items?.length ?? 0) > 0 ||
(instruments.items?.length ?? 0) > 0 ||
(repairShops.items?.length ?? 0) > 0
) {
cards.push({
id: `featured-marketplace:${currentUserId}`,
feedItemType: 'featured_marketplace',
title: 'Explore marketplace',
subtitle: 'Featured listings, musical instruments, and repair shops',
listings: (listings.items ?? []) as unknown as Array<Record<string, unknown>>,
musicalInstruments: (instruments.items ?? []) as unknown as Array<Record<string, unknown>>,
instruments: (instruments.items ?? []) as unknown as Array<Record<string, unknown>>,
repairShops: (repairShops.items ?? []) as unknown as Array<Record<string, unknown>>,
});
}
return cards;
}
private buildVisiblePostsFilter(
currentUserId: string,
followingIds: string[],
followingOnly: boolean,
): Record<string, unknown> {
const currentUserObjectId = new Types.ObjectId(currentUserId);
const followingObjectIds = followingIds.map((id) => new Types.ObjectId(id));
if (followingOnly) {
return {
$or: [
{ authorId: currentUserObjectId },
{
authorId: { $in: followingObjectIds },
visibility: { $in: [PostVisibility.PUBLIC, PostVisibility.FOLLOWERS] },
},
],
};
}
return {
$or: [
{ visibility: PostVisibility.PUBLIC },
{ authorId: currentUserObjectId },
{
authorId: { $in: followingObjectIds },
visibility: PostVisibility.FOLLOWERS,
},
],
}; };
} }
@@ -113,13 +399,13 @@ export class FeedService {
currentUser: UserDocument; currentUser: UserDocument;
currentUserId: string; currentUserId: string;
followingIds: string[]; followingIds: string[];
post: any; post: Record<string, any>;
preferredPostType?: PostType; preferredPostType?: PostType;
radiusKm: number; radiusKm: number;
}): number { }): number {
const { currentUser, currentUserId, followingIds, post, preferredPostType, radiusKm } = input; const { currentUser, currentUserId, followingIds, post, preferredPostType, radiusKm } = input;
const author: any = post.authorId; const author = post.authorId;
const authorId = typeof author === 'string' ? author : author?._id?.toString?.() ?? ''; const authorId = this.extractEntityId(author);
const isOwnPost = authorId === currentUserId; const isOwnPost = authorId === currentUserId;
const isFollowing = followingIds.includes(authorId); const isFollowing = followingIds.includes(authorId);
@@ -127,10 +413,16 @@ export class FeedService {
const ageHours = ageMs / (1000 * 60 * 60); const ageHours = ageMs / (1000 * 60 * 60);
const freshness = Math.max(0, 36 - ageHours); const freshness = Math.max(0, 36 - ageHours);
const engagement = post.likesCount * 3 + post.commentsCount * 4 + post.savesCount * 5; const engagement =
Number(post.likesCount ?? 0) * 3 +
Number(post.commentsCount ?? 0) * 4 +
Number(post.savesCount ?? 0) * 5 +
Number(post.shareCount ?? 0) * 6 +
Number(post.viewCount ?? 0) * 0.15 +
Number(post.playCount ?? 0) * 0.25;
const hashtagMatches = this.intersectionCount( const hashtagMatches = this.intersectionCount(
this.buildPreferenceTokens(currentUser), this.buildPreferenceTokens(currentUser),
(post.hashtags ?? []).map((x: string) => x.toLowerCase()), (post.hashtags ?? []).map((value: string) => value.toLowerCase()),
); );
const distanceKm = this.computeDistanceKm( const distanceKm = this.computeDistanceKm(
@@ -209,4 +501,45 @@ export class FeedService {
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return earthKm * c; return earthKm * c;
} }
private buildCacheKey(scope: string, input: Record<string, unknown>): string {
return `feed:${scope}:${JSON.stringify(input)}`;
}
private shouldIncludeSuggestions(query: FeedQueryDto): boolean {
return (
query.includeSuggestions === true &&
!(query.cursor ?? '').trim() &&
(query.page ?? 1) === 1
);
}
private extractEntityId(value: unknown): string {
if (!value) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (value instanceof Types.ObjectId) {
return value.toString();
}
if (typeof value === 'object') {
const candidate = value as { _id?: unknown; id?: unknown };
if (candidate._id instanceof Types.ObjectId) {
return candidate._id.toString();
}
if (typeof candidate._id === 'string') {
return candidate._id;
}
if (typeof candidate.id === 'string') {
return candidate.id;
}
}
return '';
}
} }

عرض الملف

@@ -25,14 +25,14 @@ export class FollowsController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get('followers/:userId') @Get('followers/:userId')
async followers(@Param('userId') userId: string, @Query() query: PaginationQueryDto) { async followers(@Param('userId') userId: string, @Query() query: PaginationQueryDto) {
return this.followsService.getFollowers(userId, query.page, query.limit); return this.followsService.getFollowers(userId, query);
} }
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get('following/:userId') @Get('following/:userId')
async following(@Param('userId') userId: string, @Query() query: PaginationQueryDto) { async following(@Param('userId') userId: string, @Query() query: PaginationQueryDto) {
return this.followsService.getFollowing(userId, query.page, query.limit); return this.followsService.getFollowing(userId, query);
} }
@ApiBearerAuth() @ApiBearerAuth()
@@ -47,6 +47,6 @@ export class FollowsController {
@Get('suggestions') @Get('suggestions')
@Throttle(60, 60_000) @Throttle(60, 60_000)
async suggestions(@CurrentUser() user: JwtPayload, @Query() query: PaginationQueryDto) { async suggestions(@CurrentUser() user: JwtPayload, @Query() query: PaginationQueryDto) {
return this.followsService.getSuggestions(user.sub, query.page, query.limit); return this.followsService.getSuggestions(user.sub, query);
} }
} }

عرض الملف

@@ -38,12 +38,17 @@ export class FollowsRepository {
await this.followModel.findByIdAndDelete(id, { session }).exec(); await this.followModel.findByIdAndDelete(id, { session }).exec();
} }
async findMany(filter: FilterQuery<FollowDocument>, skip: number, limit: number): Promise<FollowDocument[]> { async findMany(
filter: FilterQuery<FollowDocument>,
skip: number,
limit: number,
sort: Record<string, 1 | -1> = { createdAt: -1 },
): Promise<FollowDocument[]> {
return this.followModel return this.followModel
.find(filter) .find(filter)
.populate({ path: 'followerId', select: 'name username stageName avatar isVerified isDisabled' }) .populate({ path: 'followerId', select: 'name username stageName avatar isVerified isDisabled' })
.populate({ path: 'followingId', select: 'name username stageName avatar isVerified isDisabled' }) .populate({ path: 'followingId', select: 'name username stageName avatar isVerified isDisabled' })
.sort({ createdAt: -1 }) .sort(sort)
.skip(skip) .skip(skip)
.limit(limit) .limit(limit)
.exec(); .exec();

عرض الملف

@@ -26,6 +26,7 @@ describe('FollowsService', () => {
followsRepository as any, followsRepository as any,
usersRepository as any, usersRepository as any,
outboxService as any, outboxService as any,
{ bumpGlobalVersion: jest.fn().mockResolvedValue(1) } as any,
); );
await expect(service.toggleFollow(currentUserId, { targetUserId })).resolves.toEqual({ await expect(service.toggleFollow(currentUserId, { targetUserId })).resolves.toEqual({

عرض الملف

@@ -1,5 +1,9 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Types } from 'mongoose'; import { Types } from 'mongoose';
import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
import { OutboxService } from '../outbox/outbox.service'; import { OutboxService } from '../outbox/outbox.service';
import { UsersRepository } from '../users/users.repository'; import { UsersRepository } from '../users/users.repository';
import { UserDocument } from '../users/schemas/user.schema'; import { UserDocument } from '../users/schemas/user.schema';
@@ -14,6 +18,7 @@ export class FollowsService {
private readonly followsRepository: FollowsRepository, private readonly followsRepository: FollowsRepository,
private readonly usersRepository: UsersRepository, private readonly usersRepository: UsersRepository,
private readonly outboxService: OutboxService, private readonly outboxService: OutboxService,
private readonly feedVersionService: FeedVersionService,
) {} ) {}
async toggleFollow(currentUserId: string, dto: ToggleFollowDto) { async toggleFollow(currentUserId: string, dto: ToggleFollowDto) {
@@ -37,11 +42,13 @@ export class FollowsService {
if (existing) { if (existing) {
await this.followsRepository.deleteById(existing.id); await this.followsRepository.deleteById(existing.id);
await this.syncFollowCounts(currentUserId, targetUserId); await this.syncFollowCounts(currentUserId, targetUserId);
await this.feedVersionService.bumpGlobalVersion();
return { following: false }; return { following: false };
} }
const follow = await this.followsRepository.create(currentUserId, targetUserId); const follow = await this.followsRepository.create(currentUserId, targetUserId);
await this.syncFollowCounts(currentUserId, targetUserId); await this.syncFollowCounts(currentUserId, targetUserId);
await this.feedVersionService.bumpGlobalVersion();
try { try {
await this.outboxService.enqueueFollowNotification(currentUserId, targetUserId, follow.id); await this.outboxService.enqueueFollowNotification(currentUserId, targetUserId, follow.id);
@@ -56,36 +63,40 @@ export class FollowsService {
return { following: true }; return { following: true };
} }
async getFollowers(userId: string, page = 1, limit = 20) { async getFollowers(userId: string, query: PaginationQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.followsRepository.findMany({ followingId: userId }, skip, limit), this.followsRepository.findMany({ followingId: userId }, skip, limit, sort),
this.followsRepository.count({ followingId: userId }), this.followsRepository.count({ followingId: userId }),
]); ]);
return { return buildPaginatedResponse(items, {
items,
page, page,
limit, limit,
total, total,
totalPages: Math.ceil(total / limit) || 1, offset: skip,
}; });
} }
async getFollowing(userId: string, page = 1, limit = 20) { async getFollowing(userId: string, query: PaginationQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.followsRepository.findMany({ followerId: userId }, skip, limit), this.followsRepository.findMany({ followerId: userId }, skip, limit, sort),
this.followsRepository.count({ followerId: userId }), this.followsRepository.count({ followerId: userId }),
]); ]);
return { return buildPaginatedResponse(items, {
items,
page, page,
limit, limit,
total, total,
totalPages: Math.ceil(total / limit) || 1, offset: skip,
}; });
} }
async getFollowStatus(currentUserId: string, targetUserId: string) { async getFollowStatus(currentUserId: string, targetUserId: string) {
@@ -104,7 +115,9 @@ export class FollowsService {
}; };
} }
async getSuggestions(currentUserId: string, page = 1, limit = 20) { async getSuggestions(currentUserId: string, query: PaginationQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const currentUser = await this.usersRepository.findById(currentUserId); const currentUser = await this.usersRepository.findById(currentUserId);
if (!currentUser) { if (!currentUser) {
throw new NotFoundException('Current user not found'); throw new NotFoundException('Current user not found');
@@ -128,6 +141,10 @@ export class FollowsService {
})) }))
.sort((a, b) => b.score - a.score || b.user.followersCount - a.user.followersCount); .sort((a, b) => b.score - a.score || b.user.followersCount - a.user.followersCount);
if (query.sortOrder === 'asc') {
ranked.reverse();
}
const total = ranked.length; const total = ranked.length;
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const items = ranked.slice(skip, skip + limit).map((entry) => ({ const items = ranked.slice(skip, skip + limit).map((entry) => ({
@@ -136,13 +153,12 @@ export class FollowsService {
reasons: this.buildSuggestionReasons(currentUser, entry.user), reasons: this.buildSuggestionReasons(currentUser, entry.user),
})); }));
return { return buildPaginatedResponse(items, {
items,
page, page,
limit, limit,
total, total,
totalPages: Math.ceil(total / limit) || 1, offset: skip,
}; });
} }
private calculateSuggestionScore(currentUser: UserDocument, candidate: UserDocument): number { private calculateSuggestionScore(currentUser: UserDocument, candidate: UserDocument): number {

عرض الملف

@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose'; import { MongooseModule } from '@nestjs/mongoose';
import { CommentsModule } from '../comments/comments.module'; import { CommentsModule } from '../comments/comments.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PostsModule } from '../posts/posts.module'; import { PostsModule } from '../posts/posts.module';
import { Like, LikeSchema } from './schemas/like.schema'; import { Like, LikeSchema } from './schemas/like.schema';
import { LikesController } from './likes.controller'; import { LikesController } from './likes.controller';
@@ -12,9 +13,10 @@ import { LikesService } from './likes.service';
MongooseModule.forFeature([{ name: Like.name, schema: LikeSchema }]), MongooseModule.forFeature([{ name: Like.name, schema: LikeSchema }]),
PostsModule, PostsModule,
CommentsModule, CommentsModule,
NotificationsModule,
], ],
controllers: [LikesController], controllers: [LikesController],
providers: [LikesService, LikesRepository], providers: [LikesService, LikesRepository],
exports: [LikesService], exports: [LikesService, LikesRepository],
}) })
export class LikesModule {} export class LikesModule {}

عرض الملف

@@ -25,6 +25,24 @@ export class LikesRepository {
}); });
} }
async findLikedPostIds(userId: string, postIds: string[]): Promise<string[]> {
if (!postIds.length) {
return [];
}
const rows = await this.likeModel
.find({
userId: new Types.ObjectId(userId),
targetType: 'post',
targetId: { $in: postIds.map((id) => new Types.ObjectId(id)) },
})
.select({ targetId: 1 })
.lean()
.exec();
return rows.map((row) => row.targetId.toString());
}
async deleteById(id: string): Promise<void> { async deleteById(id: string): Promise<void> {
await this.likeModel.findByIdAndDelete(id).exec(); await this.likeModel.findByIdAndDelete(id).exec();
} }

عرض الملف

@@ -16,6 +16,8 @@ describe('LikesService', () => {
likesRepository as any, likesRepository as any,
postsRepository as any, postsRepository as any,
commentsRepository as any, commentsRepository as any,
{ bumpGlobalVersion: jest.fn() } as any,
{ createLikeNotification: jest.fn() } as any,
); );
await expect( await expect(

عرض الملف

@@ -1,4 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Types } from 'mongoose';
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
import { NotificationsService } from '../notifications/notifications.service';
import { CommentsRepository } from '../comments/comments.repository'; import { CommentsRepository } from '../comments/comments.repository';
import { PostsRepository } from '../posts/posts.repository'; import { PostsRepository } from '../posts/posts.repository';
import { LikesRepository } from './likes.repository'; import { LikesRepository } from './likes.repository';
@@ -6,10 +9,14 @@ import { ToggleLikeDto } from './dto/toggle-like.dto';
@Injectable() @Injectable()
export class LikesService { export class LikesService {
private readonly logger = new Logger(LikesService.name);
constructor( constructor(
private readonly likesRepository: LikesRepository, private readonly likesRepository: LikesRepository,
private readonly postsRepository: PostsRepository, private readonly postsRepository: PostsRepository,
private readonly commentsRepository: CommentsRepository, private readonly commentsRepository: CommentsRepository,
private readonly feedVersionService: FeedVersionService,
private readonly notificationsService: NotificationsService,
) {} ) {}
async toggle(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> { async toggle(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> {
@@ -19,6 +26,7 @@ export class LikesService {
async like(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> { async like(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> {
await this.assertTargetExists(dto); await this.assertTargetExists(dto);
const notificationContext = await this.resolveNotificationContext(dto);
const existing = await this.likesRepository.findOne(userId, dto.targetId, dto.targetType); const existing = await this.likesRepository.findOne(userId, dto.targetId, dto.targetType);
if (existing) { if (existing) {
@@ -29,6 +37,26 @@ export class LikesService {
if (dto.targetType === 'post') { if (dto.targetType === 'post') {
await this.postsRepository.incrementLikesCount(dto.targetId, 1); await this.postsRepository.incrementLikesCount(dto.targetId, 1);
} }
await this.feedVersionService.bumpGlobalVersion();
if (notificationContext.recipientId && notificationContext.recipientId !== userId) {
try {
await this.notificationsService.createLikeNotification(
userId,
notificationContext.recipientId,
dto.targetId,
{
resourceType: dto.targetType,
previewText: notificationContext.previewText,
},
);
} catch (error) {
this.logger.warn(
`Like notification failed for actor=${userId} recipient=${notificationContext.recipientId}: ${
error instanceof Error ? error.message : 'unknown error'
}`,
);
}
}
return { liked: true, targetId: dto.targetId, targetType: dto.targetType }; return { liked: true, targetId: dto.targetId, targetType: dto.targetType };
} }
@@ -45,6 +73,7 @@ export class LikesService {
if (dto.targetType === 'post') { if (dto.targetType === 'post') {
await this.postsRepository.incrementLikesCount(dto.targetId, -1); await this.postsRepository.incrementLikesCount(dto.targetId, -1);
} }
await this.feedVersionService.bumpGlobalVersion();
return { liked: false, targetId: dto.targetId, targetType: dto.targetType }; return { liked: false, targetId: dto.targetId, targetType: dto.targetType };
} }
@@ -75,4 +104,51 @@ export class LikesService {
const comment = await this.commentsRepository.findById(dto.targetId); const comment = await this.commentsRepository.findById(dto.targetId);
return !!comment; return !!comment;
} }
private async resolveNotificationContext(
dto: ToggleLikeDto,
): Promise<{ recipientId: string; previewText: string }> {
if (dto.targetType === 'post') {
const post = await this.postsRepository.findById(dto.targetId);
return {
recipientId: this.extractEntityId(post?.authorId),
previewText: (post?.content ?? '').slice(0, 140),
};
}
const comment = await this.commentsRepository.findById(dto.targetId);
return {
recipientId: comment?.authorId?.toString?.() ?? '',
previewText: (comment?.content ?? '').slice(0, 140),
};
}
private extractEntityId(value: unknown): string {
if (!value) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (value instanceof Types.ObjectId) {
return value.toString();
}
if (typeof value === 'object') {
const candidate = value as { _id?: unknown; id?: unknown };
if (candidate._id instanceof Types.ObjectId) {
return candidate._id.toString();
}
if (typeof candidate._id === 'string') {
return candidate._id;
}
if (typeof candidate.id === 'string') {
return candidate.id;
}
}
return '';
}
} }

عرض الملف

@@ -1,8 +1,9 @@
import { Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
import { import {
ArrayMaxSize, ArrayMaxSize,
IsArray, IsArray,
IsBoolean, IsBoolean,
IsEnum,
IsNotEmpty, IsNotEmpty,
IsNumber, IsNumber,
IsOptional, IsOptional,
@@ -10,6 +11,10 @@ import {
MaxLength, MaxLength,
Min, Min,
} from 'class-validator'; } from 'class-validator';
import { MarketplaceListingCondition } from '../enums/marketplace-listing-condition.enum';
import { MarketplaceListingCategory } from '../enums/marketplace-listing-category.enum';
import { toStringArray } from '../../../common/utils/array-transform.util';
import { toBoolean } from '../../../common/utils/query-transform.util';
export class CreateInstrumentDto { export class CreateInstrumentDto {
@IsString() @IsString()
@@ -38,13 +43,27 @@ export class CreateInstrumentDto {
quantity!: number; quantity!: number;
@IsOptional() @IsOptional()
@Transform(toStringArray)
@IsArray() @IsArray()
@ArrayMaxSize(5) @ArrayMaxSize(5)
@IsString({ each: true }) @IsString({ each: true })
imageUrls?: string[]; imageUrls?: string[];
@IsOptional() @IsOptional()
@Type(() => Boolean) @Transform(toBoolean)
@IsBoolean() @IsBoolean()
isActive?: boolean; isActive?: boolean;
@IsOptional()
@IsEnum(MarketplaceListingCondition)
condition?: MarketplaceListingCondition;
@IsOptional()
@IsString()
@MaxLength(80)
instrumentType?: string;
@IsOptional()
@IsEnum(MarketplaceListingCategory)
listingCategory?: MarketplaceListingCategory;
} }

عرض الملف

@@ -0,0 +1,74 @@
import { Transform, Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsNumber,
IsOptional,
IsString,
IsUrl,
Length,
Max,
Min,
} from 'class-validator';
import { toStringArray } from '../../../common/utils/array-transform.util';
import { toBoolean } from '../../../common/utils/query-transform.util';
export class CreateRepairShopDto {
@IsString()
@Length(2, 120)
name!: string;
@IsOptional()
@IsString()
@Length(0, 2000)
description?: string;
@IsOptional()
@Transform(toStringArray)
@IsArray()
@ArrayMaxSize(20)
@IsString({ each: true })
services?: string[];
@IsOptional()
@IsString()
@Length(0, 40)
phone?: string;
@IsOptional()
@IsString()
@Length(0, 40)
whatsapp?: string;
@IsOptional()
@Transform(toStringArray)
@IsArray()
@ArrayMaxSize(8)
@IsUrl({ require_tld: false }, { each: true })
imageUrls?: string[];
@IsOptional()
@IsString()
@Length(0, 160)
location?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(-90)
@Max(90)
latitude?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(-180)
@Max(180)
longitude?: number;
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
isActive?: boolean;
}

عرض الملف

@@ -1,29 +1,60 @@
import { Type } from 'class-transformer'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
import { toBoolean } from '../../../common/utils/query-transform.util';
import { MarketplaceListingCondition } from '../enums/marketplace-listing-condition.enum';
import { MarketplaceListingCategory } from '../enums/marketplace-listing-category.enum';
export const INSTRUMENT_SORT_FIELDS = ['createdAt', 'updatedAt', 'price', 'title'] as const;
export type InstrumentSortField = (typeof INSTRUMENT_SORT_FIELDS)[number];
export class InstrumentQueryDto extends PaginationQueryDto { export class InstrumentQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ description: 'Search by title or description' })
@IsOptional() @IsOptional()
@IsString() @IsString()
q?: string; q?: string;
@ApiPropertyOptional({ minimum: 0 })
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsNumber() @IsNumber()
@Min(0) @Min(0)
minPrice?: number; minPrice?: number;
@ApiPropertyOptional({ minimum: 0 })
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsNumber() @IsNumber()
@Min(0) @Min(0)
maxPrice?: number; maxPrice?: number;
@ApiPropertyOptional({ default: true })
@IsOptional() @IsOptional()
@Type(() => Boolean) @Transform(toBoolean)
@IsBoolean() @IsBoolean()
isActive?: boolean; isActive?: boolean;
@ApiPropertyOptional({ enum: MarketplaceListingCondition })
@IsOptional()
@IsEnum(MarketplaceListingCondition)
condition?: MarketplaceListingCondition;
@ApiPropertyOptional({ description: 'Filter by instrument type such as oud, piano, violin' })
@IsOptional()
@IsString()
instrumentType?: string;
@ApiPropertyOptional({ enum: INSTRUMENT_SORT_FIELDS, default: 'createdAt' })
@IsOptional()
@IsEnum(INSTRUMENT_SORT_FIELDS)
sortBy?: InstrumentSortField;
@ApiPropertyOptional({ enum: MarketplaceListingCategory })
@IsOptional()
@IsEnum(MarketplaceListingCategory)
listingCategory?: MarketplaceListingCategory;
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsNumber() @IsNumber()

عرض الملف

@@ -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;
}

عرض الملف

@@ -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;
}

عرض الملف

@@ -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;
}

عرض الملف

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateRepairShopDto } from './create-repair-shop.dto';
export class UpdateRepairShopDto extends PartialType(CreateRepairShopDto) {}

عرض الملف

@@ -0,0 +1,51 @@
import { Transform, Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsNumber,
IsOptional,
IsString,
IsUrl,
Length,
Max,
Min,
} from 'class-validator';
import { toStringArray } from '../../../common/utils/array-transform.util';
export class UpdateShopProfileDto {
@IsOptional()
@IsString()
@Length(2, 120)
shopName?: string;
@IsOptional()
@IsString()
@Length(0, 2000)
shopDescription?: string;
@IsOptional()
@Transform(toStringArray)
@IsArray()
@ArrayMaxSize(8)
@IsUrl({ require_tld: false }, { each: true })
shopImageUrls?: string[];
@IsOptional()
@IsString()
@Length(0, 160)
shopLocation?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(-90)
@Max(90)
shopLatitude?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(-180)
@Max(180)
shopLongitude?: number;
}

عرض الملف

@@ -0,0 +1,7 @@
export enum MarketplaceListingCategory {
MUSICAL_INSTRUMENT = 'musical_instrument',
ACCESSORY = 'accessory',
AUDIO_GEAR = 'audio_gear',
SHEET_MUSIC = 'sheet_music',
OTHER = 'other',
}

عرض الملف

@@ -0,0 +1,6 @@
export enum MarketplaceListingCondition {
NEW = 'new',
LIKE_NEW = 'like_new',
USED = 'used',
REFURBISHED = 'refurbished',
}

عرض الملف

@@ -1,52 +1,461 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import {
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator'; import { Roles } from '../../common/decorators/roles.decorator';
import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator';
import { Throttle } from '../../common/decorators/throttle.decorator'; import { Throttle } from '../../common/decorators/throttle.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RolesGuard } from '../../common/guards/roles.guard'; import { RolesGuard } from '../../common/guards/roles.guard';
import { SuperAdminPermissionsGuard } from '../../common/guards/superadmin-permissions.guard';
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
import { UserRole } from '../../common/enums/user-role.enum'; import { UserRole } from '../../common/enums/user-role.enum';
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface'; import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
import { CreateInstrumentDto } from './dto/create-instrument.dto'; import { CreateInstrumentDto } from './dto/create-instrument.dto';
import { CreateRepairShopDto } from './dto/create-repair-shop.dto';
import { InstrumentQueryDto } from './dto/instrument-query.dto'; import { InstrumentQueryDto } from './dto/instrument-query.dto';
import { MarketplaceHomeQueryDto } from './dto/marketplace-home-query.dto';
import { RepairShopQueryDto } from './dto/repair-shop-query.dto';
import { UpdateShopProfileDto } from './dto/update-shop-profile.dto';
import { UpdateInstrumentDto } from './dto/update-instrument.dto'; import { UpdateInstrumentDto } from './dto/update-instrument.dto';
import { UpdateMarketplaceStatusDto } from './dto/update-marketplace-status.dto';
import { UpdateRepairShopDto } from './dto/update-repair-shop.dto';
import { MarketplaceService } from './marketplace.service'; import { MarketplaceService } from './marketplace.service';
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
@ApiTags('Marketplace') @ApiTags('Marketplace')
@Controller('marketplace') @Controller('marketplace')
export class MarketplaceController { export class MarketplaceController {
constructor(private readonly marketplaceService: MarketplaceService) {} constructor(private readonly marketplaceService: MarketplaceService) {}
@Get('home')
async getMarketplaceHome(@Query() query: MarketplaceHomeQueryDto) {
return this.marketplaceService.getHome(query);
}
@Get('listings')
async listPublicListings(@Query() query: InstrumentQueryDto) {
return this.marketplaceService.getPublicListings(query);
}
@Get('listings/:id')
async findListing(@Param('id') listingId: string) {
return this.marketplaceService.findListingById(listingId);
}
@Get('instruments') @Get('instruments')
async listPublic(@Query() query: InstrumentQueryDto) { async listPublic(@Query() query: InstrumentQueryDto) {
return this.marketplaceService.getPublic(query); return this.marketplaceService.getPublicInstruments(query);
} }
@Get('instruments/:id') @Get('instruments/:id')
async findOne(@Param('id') instrumentId: string) { async findOne(@Param('id') instrumentId: string) {
return this.marketplaceService.findById(instrumentId); return this.marketplaceService.findInstrumentById(instrumentId);
}
@Get('repair-shops')
async listPublicRepairShops(@Query() query: RepairShopQueryDto) {
return this.marketplaceService.getPublicRepairShops(query);
}
@Get('repair-shops/:id')
async findRepairShop(@Param('id') repairShopId: string) {
return this.marketplaceService.findRepairShopById(repairShopId);
}
@Get('shops/:adminId')
async getShopByAdminId(@Param('adminId') adminId: string) {
return this.marketplaceService.getShopProfileByAdminId(adminId);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
@Get('superadmin/listings')
async listAllListingsForSuperAdmin(@Query() query: InstrumentQueryDto) {
return this.marketplaceService.getListingsForSuperAdmin(query);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
@Patch('superadmin/listings/:id/status')
async updateListingStatusBySuperAdmin(
@CurrentUser() user: JwtPayload,
@Param('id') listingId: string,
@Body() dto: UpdateMarketplaceStatusDto,
) {
return this.marketplaceService.updateListingStatusBySuperAdmin(
user.email ?? user.sub,
listingId,
dto,
);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
@Delete('superadmin/listings/:id')
async deleteListingBySuperAdmin(@CurrentUser() user: JwtPayload, @Param('id') listingId: string) {
await this.marketplaceService.removeListingBySuperAdmin(user.email ?? user.sub, listingId);
return { success: true };
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
@Get('superadmin/repair-shops')
async listAllRepairShopsForSuperAdmin(@Query() query: RepairShopQueryDto) {
return this.marketplaceService.getRepairShopsForSuperAdmin(query);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
@Patch('superadmin/repair-shops/:id/status')
async updateRepairShopStatusBySuperAdmin(
@CurrentUser() user: JwtPayload,
@Param('id') repairShopId: string,
@Body() dto: UpdateMarketplaceStatusDto,
) {
return this.marketplaceService.updateRepairShopStatusBySuperAdmin(
user.email ?? user.sub,
repairShopId,
dto,
);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
@Delete('superadmin/repair-shops/:id')
async deleteRepairShopBySuperAdmin(@CurrentUser() user: JwtPayload, @Param('id') repairShopId: string) {
await this.marketplaceService.removeRepairShopBySuperAdmin(user.email ?? user.sub, repairShopId);
return { success: true };
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }]))
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
title: { type: 'string', example: 'Professional Oud' },
description: { type: 'string', example: 'Well-maintained oud for studio work' },
price: { type: 'number', example: 3500 },
currency: { type: 'string', example: 'SAR' },
quantity: { type: 'number', example: 1 },
imageUrls: { type: 'array', items: { type: 'string' } },
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
isActive: { type: 'boolean', example: true },
condition: { type: 'string', example: 'used' },
instrumentType: { type: 'string', example: 'Oud' },
listingCategory: { type: 'string', example: 'musical_instrument' },
},
required: ['title', 'price', 'quantity'],
},
})
@Post('superadmin/admins/:adminId/listings')
@Throttle(40, 60_000)
async createListingBySuperAdmin(
@Param('adminId') adminId: string,
@Body() dto: CreateInstrumentDto,
@UploadedFiles()
files?: {
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
},
) {
return this.marketplaceService.createListingBySuperAdmin(adminId, dto, files?.imageFiles ?? []);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }]))
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
title: { type: 'string', example: 'Concert Guitar' },
description: { type: 'string', example: 'Acoustic guitar in excellent condition' },
price: { type: 'number', example: 2100 },
currency: { type: 'string', example: 'SAR' },
quantity: { type: 'number', example: 1 },
imageUrls: { type: 'array', items: { type: 'string' } },
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
isActive: { type: 'boolean', example: true },
condition: { type: 'string', example: 'used' },
instrumentType: { type: 'string', example: 'Guitar' },
},
required: ['title', 'price', 'quantity'],
},
})
@Post('superadmin/admins/:adminId/instruments')
@Throttle(40, 60_000)
async createInstrumentBySuperAdmin(
@Param('adminId') adminId: string,
@Body() dto: CreateInstrumentDto,
@UploadedFiles()
files?: {
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
},
) {
return this.marketplaceService.createInstrumentBySuperAdmin(adminId, dto, files?.imageFiles ?? []);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 8 }]))
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
name: { type: 'string', example: 'Fix Strings Workshop' },
description: { type: 'string', example: 'Repair shop for oud and violin' },
services: { type: 'array', items: { type: 'string' } },
phone: { type: 'string', example: '+966500000000' },
whatsapp: { type: 'string', example: '+966500000000' },
imageUrls: { type: 'array', items: { type: 'string' } },
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
location: { type: 'string', example: 'Riyadh' },
latitude: { type: 'number', example: 24.7136 },
longitude: { type: 'number', example: 46.6753 },
isActive: { type: 'boolean', example: true },
},
required: ['name'],
},
})
@Post('superadmin/admins/:adminId/repair-shops')
@Throttle(30, 60_000)
async createRepairShopBySuperAdmin(
@Param('adminId') adminId: string,
@Body() dto: CreateRepairShopDto,
@UploadedFiles()
files?: {
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
},
) {
return this.marketplaceService.createRepairShopBySuperAdmin(adminId, dto, files?.imageFiles ?? []);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
@UseInterceptors(FileFieldsInterceptor([{ name: 'shopImageFiles', maxCount: 8 }]))
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
shopName: { type: 'string', example: 'Awtarna Store' },
shopDescription: { type: 'string', example: 'Trusted marketplace shop profile' },
shopImageUrls: { type: 'array', items: { type: 'string' } },
shopImageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
shopLocation: { type: 'string', example: 'Riyadh' },
shopLatitude: { type: 'number', example: 24.7136 },
shopLongitude: { type: 'number', example: 46.6753 },
},
},
})
@Patch('superadmin/admins/:adminId/shop-profile')
@Throttle(30, 60_000)
async updateShopProfileBySuperAdmin(
@Param('adminId') adminId: string,
@Body() dto: UpdateShopProfileDto,
@UploadedFiles()
files?: {
shopImageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
},
) {
return this.marketplaceService.updateShopProfileBySuperAdmin(
adminId,
dto,
files?.shopImageFiles ?? [],
);
} }
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN)
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }]))
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
title: { type: 'string', example: 'Professional Oud' },
description: { type: 'string', example: 'Well-maintained oud for studio work' },
price: { type: 'number', example: 3500 },
currency: { type: 'string', example: 'SAR' },
quantity: { type: 'number', example: 1 },
imageUrls: { type: 'array', items: { type: 'string' } },
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
isActive: { type: 'boolean', example: true },
condition: { type: 'string', example: 'used' },
instrumentType: { type: 'string', example: 'Oud' },
listingCategory: { type: 'string', example: 'musical_instrument' },
},
required: ['title', 'price', 'quantity'],
},
})
@Post('admin/listings')
@Throttle(40, 60_000)
async createListingByAdmin(
@CurrentUser() user: JwtPayload,
@Body() dto: CreateInstrumentDto,
@UploadedFiles()
files?: {
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
},
) {
return this.marketplaceService.createListingByAdmin(user.sub, dto, files?.imageFiles ?? []);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }]))
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
title: { type: 'string', example: 'Updated listing title' },
description: { type: 'string', example: 'Updated description' },
price: { type: 'number', example: 3200 },
currency: { type: 'string', example: 'SAR' },
quantity: { type: 'number', example: 1 },
imageUrls: { type: 'array', items: { type: 'string' } },
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
isActive: { type: 'boolean', example: true },
instrumentType: { type: 'string', example: 'Oud' },
listingCategory: { type: 'string', example: 'musical_instrument' },
},
},
})
@Patch('admin/listings/:id')
@Throttle(60, 60_000)
async updateListingByAdmin(
@CurrentUser() user: JwtPayload,
@Param('id') listingId: string,
@Body() dto: UpdateInstrumentDto,
@UploadedFiles()
files?: {
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
},
) {
return this.marketplaceService.updateListingByAdmin(user.sub, listingId, dto, files?.imageFiles ?? []);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Delete('admin/listings/:id')
@Throttle(40, 60_000)
async removeListingByAdmin(@CurrentUser() user: JwtPayload, @Param('id') listingId: string) {
await this.marketplaceService.removeListingByAdmin(user.sub, listingId);
return { success: true };
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Get('admin/listings/me')
async myListings(@CurrentUser() user: JwtPayload, @Query() query: InstrumentQueryDto) {
return this.marketplaceService.getMyListings(user.sub, query);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }]))
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
title: { type: 'string', example: 'Concert Guitar' },
description: { type: 'string', example: 'Acoustic guitar in excellent condition' },
price: { type: 'number', example: 2100 },
currency: { type: 'string', example: 'SAR' },
quantity: { type: 'number', example: 1 },
imageUrls: { type: 'array', items: { type: 'string' } },
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
isActive: { type: 'boolean', example: true },
condition: { type: 'string', example: 'used' },
instrumentType: { type: 'string', example: 'Guitar' },
},
required: ['title', 'price', 'quantity'],
},
})
@Post('admin/instruments') @Post('admin/instruments')
@Throttle(40, 60_000) @Throttle(40, 60_000)
async createByAdmin(@CurrentUser() user: JwtPayload, @Body() dto: CreateInstrumentDto) { async createByAdmin(
return this.marketplaceService.createByAdmin(user.sub, dto); @CurrentUser() user: JwtPayload,
@Body() dto: CreateInstrumentDto,
@UploadedFiles()
files?: {
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
},
) {
return this.marketplaceService.createInstrumentByAdmin(user.sub, dto, files?.imageFiles ?? []);
} }
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN)
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }]))
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
title: { type: 'string', example: 'Updated instrument title' },
description: { type: 'string', example: 'Updated instrument description' },
price: { type: 'number', example: 2400 },
quantity: { type: 'number', example: 1 },
imageUrls: { type: 'array', items: { type: 'string' } },
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
condition: { type: 'string', example: 'used' },
instrumentType: { type: 'string', example: 'Violin' },
},
},
})
@Patch('admin/instruments/:id') @Patch('admin/instruments/:id')
@Throttle(60, 60_000) @Throttle(60, 60_000)
async updateByAdmin( async updateByAdmin(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@Param('id') instrumentId: string, @Param('id') instrumentId: string,
@Body() dto: UpdateInstrumentDto, @Body() dto: UpdateInstrumentDto,
@UploadedFiles()
files?: {
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
},
) { ) {
return this.marketplaceService.updateByAdmin(user.sub, instrumentId, dto); return this.marketplaceService.updateInstrumentByAdmin(
user.sub,
instrumentId,
dto,
files?.imageFiles ?? [],
);
} }
@ApiBearerAuth() @ApiBearerAuth()
@@ -55,7 +464,7 @@ export class MarketplaceController {
@Delete('admin/instruments/:id') @Delete('admin/instruments/:id')
@Throttle(40, 60_000) @Throttle(40, 60_000)
async removeByAdmin(@CurrentUser() user: JwtPayload, @Param('id') instrumentId: string) { async removeByAdmin(@CurrentUser() user: JwtPayload, @Param('id') instrumentId: string) {
await this.marketplaceService.removeByAdmin(user.sub, instrumentId); await this.marketplaceService.removeInstrumentByAdmin(user.sub, instrumentId);
return { success: true }; return { success: true };
} }
@@ -64,6 +473,147 @@ export class MarketplaceController {
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN)
@Get('admin/instruments/me') @Get('admin/instruments/me')
async myInstruments(@CurrentUser() user: JwtPayload, @Query() query: InstrumentQueryDto) { async myInstruments(@CurrentUser() user: JwtPayload, @Query() query: InstrumentQueryDto) {
return this.marketplaceService.getMine(user.sub, query); return this.marketplaceService.getMyInstruments(user.sub, query);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 8 }]))
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
name: { type: 'string', example: 'Fix Strings Workshop' },
description: { type: 'string', example: 'Repair shop for oud and violin' },
services: { type: 'array', items: { type: 'string' } },
phone: { type: 'string', example: '+966500000000' },
whatsapp: { type: 'string', example: '+966500000000' },
imageUrls: { type: 'array', items: { type: 'string' } },
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
location: { type: 'string', example: 'Riyadh' },
latitude: { type: 'number', example: 24.7136 },
longitude: { type: 'number', example: 46.6753 },
isActive: { type: 'boolean', example: true },
},
required: ['name'],
},
})
@Post('admin/repair-shops')
@Throttle(30, 60_000)
async createRepairShop(
@CurrentUser() user: JwtPayload,
@Body() dto: CreateRepairShopDto,
@UploadedFiles()
files?: {
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
},
) {
return this.marketplaceService.createRepairShop(user.sub, dto, files?.imageFiles ?? []);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 8 }]))
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
name: { type: 'string', example: 'Updated shop name' },
description: { type: 'string', example: 'Updated repair shop description' },
services: { type: 'array', items: { type: 'string' } },
phone: { type: 'string', example: '+966500000000' },
whatsapp: { type: 'string', example: '+966500000000' },
imageUrls: { type: 'array', items: { type: 'string' } },
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
location: { type: 'string', example: 'Jeddah' },
latitude: { type: 'number', example: 21.5433 },
longitude: { type: 'number', example: 39.1728 },
isActive: { type: 'boolean', example: true },
},
},
})
@Patch('admin/repair-shops/:id')
@Throttle(40, 60_000)
async updateRepairShop(
@CurrentUser() user: JwtPayload,
@Param('id') repairShopId: string,
@Body() dto: UpdateRepairShopDto,
@UploadedFiles()
files?: {
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
},
) {
return this.marketplaceService.updateRepairShop(
user.sub,
repairShopId,
dto,
files?.imageFiles ?? [],
);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Delete('admin/repair-shops/:id')
@Throttle(30, 60_000)
async deleteRepairShop(@CurrentUser() user: JwtPayload, @Param('id') repairShopId: string) {
await this.marketplaceService.removeRepairShop(user.sub, repairShopId);
return { success: true };
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Get('admin/repair-shops/me')
async myRepairShops(@CurrentUser() user: JwtPayload, @Query() query: RepairShopQueryDto) {
return this.marketplaceService.getMyRepairShops(user.sub, query);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Get('admin/shop-profile/me')
async myShopProfile(@CurrentUser() user: JwtPayload) {
return this.marketplaceService.getMyShopProfile(user.sub);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@UseInterceptors(FileFieldsInterceptor([{ name: 'shopImageFiles', maxCount: 8 }]))
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
shopName: { type: 'string', example: 'Awtarna Store' },
shopDescription: { type: 'string', example: 'Trusted marketplace shop profile' },
shopImageUrls: { type: 'array', items: { type: 'string' } },
shopImageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
shopLocation: { type: 'string', example: 'Riyadh' },
shopLatitude: { type: 'number', example: 24.7136 },
shopLongitude: { type: 'number', example: 46.6753 },
},
},
})
@Patch('admin/shop-profile')
@Throttle(30, 60_000)
async updateShopProfile(
@CurrentUser() user: JwtPayload,
@Body() dto: UpdateShopProfileDto,
@UploadedFiles()
files?: {
shopImageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
},
) {
return this.marketplaceService.updateMyShopProfile(
user.sub,
dto,
files?.shopImageFiles ?? [],
);
} }
} }

عرض الملف

@@ -1,15 +1,23 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose'; import { MongooseModule } from '@nestjs/mongoose';
import { AuditModule } from '../audit/audit.module';
import { SuperAdminCase, SuperAdminCaseSchema } from '../superadmin/schemas/superadmin-case.schema';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { MarketplaceController } from './marketplace.controller'; import { MarketplaceController } from './marketplace.controller';
import { MarketplaceRepository } from './marketplace.repository'; import { MarketplaceRepository } from './marketplace.repository';
import { MarketplaceService } from './marketplace.service'; import { MarketplaceService } from './marketplace.service';
import { Instrument, InstrumentSchema } from './schemas/instrument.schema'; import { Instrument, InstrumentSchema } from './schemas/instrument.schema';
import { RepairShop, RepairShopSchema } from './schemas/repair-shop.schema';
@Module({ @Module({
imports: [ imports: [
AuditModule,
UsersModule, UsersModule,
MongooseModule.forFeature([{ name: Instrument.name, schema: InstrumentSchema }]), MongooseModule.forFeature([
{ name: Instrument.name, schema: InstrumentSchema },
{ name: RepairShop.name, schema: RepairShopSchema },
{ name: SuperAdminCase.name, schema: SuperAdminCaseSchema },
]),
], ],
controllers: [MarketplaceController], controllers: [MarketplaceController],
providers: [MarketplaceService, MarketplaceRepository], providers: [MarketplaceService, MarketplaceRepository],

عرض الملف

@@ -2,12 +2,15 @@ import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose'; import { InjectModel } from '@nestjs/mongoose';
import { FilterQuery, Model, Types, UpdateQuery } from 'mongoose'; import { FilterQuery, Model, Types, UpdateQuery } from 'mongoose';
import { Instrument, InstrumentDocument } from './schemas/instrument.schema'; import { Instrument, InstrumentDocument } from './schemas/instrument.schema';
import { RepairShop, RepairShopDocument } from './schemas/repair-shop.schema';
@Injectable() @Injectable()
export class MarketplaceRepository { export class MarketplaceRepository {
constructor( constructor(
@InjectModel(Instrument.name) @InjectModel(Instrument.name)
private readonly instrumentModel: Model<InstrumentDocument>, private readonly instrumentModel: Model<InstrumentDocument>,
@InjectModel(RepairShop.name)
private readonly repairShopModel: Model<RepairShopDocument>,
) {} ) {}
async create(ownerAdminId: string, payload: Partial<Instrument>): Promise<InstrumentDocument> { async create(ownerAdminId: string, payload: Partial<Instrument>): Promise<InstrumentDocument> {
@@ -23,7 +26,7 @@ export class MarketplaceRepository {
} }
return this.instrumentModel return this.instrumentModel
.findById(instrumentId) .findById(instrumentId)
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled' }) .populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
.exec(); .exec();
} }
@@ -37,7 +40,7 @@ export class MarketplaceRepository {
return this.instrumentModel return this.instrumentModel
.findByIdAndUpdate(instrumentId, payload, { new: true }) .findByIdAndUpdate(instrumentId, payload, { new: true })
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled' }) .populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
.exec(); .exec();
} }
@@ -52,11 +55,12 @@ export class MarketplaceRepository {
filter: FilterQuery<InstrumentDocument>, filter: FilterQuery<InstrumentDocument>,
skip: number, skip: number,
limit: number, limit: number,
sort: Record<string, 1 | -1> = { createdAt: -1 },
): Promise<InstrumentDocument[]> { ): Promise<InstrumentDocument[]> {
return this.instrumentModel return this.instrumentModel
.find(filter) .find(filter)
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled' }) .populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
.sort({ createdAt: -1 }) .sort(sort)
.skip(skip) .skip(skip)
.limit(limit) .limit(limit)
.exec(); .exec();
@@ -66,6 +70,7 @@ export class MarketplaceRepository {
filter: FilterQuery<InstrumentDocument>, filter: FilterQuery<InstrumentDocument>,
skip: number, skip: number,
limit: number, limit: number,
sort: Record<string, 1 | -1> = { createdAt: -1 },
): Promise<Record<string, unknown>[]> { ): Promise<Record<string, unknown>[]> {
return this.instrumentModel return this.instrumentModel
.aggregate([ .aggregate([
@@ -80,7 +85,7 @@ export class MarketplaceRepository {
}, },
{ $unwind: '$ownerAdmin' }, { $unwind: '$ownerAdmin' },
{ $match: { 'ownerAdmin.isDisabled': false } }, { $match: { 'ownerAdmin.isDisabled': false } },
{ $sort: { createdAt: -1 } }, { $sort: sort },
{ $skip: skip }, { $skip: skip },
{ $limit: limit }, { $limit: limit },
{ {
@@ -93,6 +98,9 @@ export class MarketplaceRepository {
quantity: 1, quantity: 1,
imageUrls: 1, imageUrls: 1,
isActive: 1, isActive: 1,
condition: 1,
instrumentType: 1,
listingCategory: 1,
createdAt: 1, createdAt: 1,
updatedAt: 1, updatedAt: 1,
ownerAdminId: { ownerAdminId: {
@@ -101,6 +109,7 @@ export class MarketplaceRepository {
username: '$ownerAdmin.username', username: '$ownerAdmin.username',
email: '$ownerAdmin.email', email: '$ownerAdmin.email',
avatar: '$ownerAdmin.avatar', avatar: '$ownerAdmin.avatar',
shopName: '$ownerAdmin.shopName',
}, },
}, },
}, },
@@ -132,4 +141,147 @@ export class MarketplaceRepository {
async count(filter: FilterQuery<InstrumentDocument>): Promise<number> { async count(filter: FilterQuery<InstrumentDocument>): Promise<number> {
return this.instrumentModel.countDocuments(filter).exec(); return this.instrumentModel.countDocuments(filter).exec();
} }
async createRepairShop(
ownerAdminId: string,
payload: Partial<RepairShop>,
): Promise<RepairShopDocument> {
return this.repairShopModel.create({
...payload,
ownerAdminId: new Types.ObjectId(ownerAdminId),
});
}
async findRepairShopByOwnerAdminId(ownerAdminId: string): Promise<RepairShopDocument | null> {
if (!Types.ObjectId.isValid(ownerAdminId)) {
return null;
}
return this.repairShopModel
.findOne({ ownerAdminId: new Types.ObjectId(ownerAdminId) })
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
.exec();
}
async findRepairShopById(repairShopId: string): Promise<RepairShopDocument | null> {
if (!Types.ObjectId.isValid(repairShopId)) {
return null;
}
return this.repairShopModel
.findById(repairShopId)
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
.exec();
}
async updateRepairShopById(
repairShopId: string,
payload: UpdateQuery<RepairShopDocument>,
): Promise<RepairShopDocument | null> {
if (!Types.ObjectId.isValid(repairShopId)) {
return null;
}
return this.repairShopModel
.findByIdAndUpdate(repairShopId, payload, { new: true })
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
.exec();
}
async deleteRepairShopById(repairShopId: string): Promise<RepairShopDocument | null> {
if (!Types.ObjectId.isValid(repairShopId)) {
return null;
}
return this.repairShopModel.findByIdAndDelete(repairShopId).exec();
}
async findManyRepairShops(
filter: FilterQuery<RepairShopDocument>,
skip: number,
limit: number,
sort: Record<string, 1 | -1> = { createdAt: -1 },
): Promise<RepairShopDocument[]> {
return this.repairShopModel
.find(filter)
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
.sort(sort)
.skip(skip)
.limit(limit)
.exec();
}
async findManyRepairShopsPublic(
filter: FilterQuery<RepairShopDocument>,
skip: number,
limit: number,
sort: Record<string, 1 | -1> = { createdAt: -1 },
): Promise<Record<string, unknown>[]> {
return this.repairShopModel
.aggregate([
{ $match: filter },
{
$lookup: {
from: 'users',
localField: 'ownerAdminId',
foreignField: '_id',
as: 'ownerAdmin',
},
},
{ $unwind: '$ownerAdmin' },
{ $match: { 'ownerAdmin.isDisabled': false } },
{ $sort: sort },
{ $skip: skip },
{ $limit: limit },
{
$project: {
_id: 1,
name: 1,
description: 1,
services: 1,
phone: 1,
whatsapp: 1,
imageUrls: 1,
location: 1,
latitude: 1,
longitude: 1,
isActive: 1,
createdAt: 1,
updatedAt: 1,
ownerAdminId: {
_id: '$ownerAdmin._id',
name: '$ownerAdmin.name',
username: '$ownerAdmin.username',
email: '$ownerAdmin.email',
avatar: '$ownerAdmin.avatar',
shopName: '$ownerAdmin.shopName',
},
},
},
])
.exec();
}
async countRepairShopsPublic(filter: FilterQuery<RepairShopDocument>): Promise<number> {
const rows = await this.repairShopModel
.aggregate([
{ $match: filter },
{
$lookup: {
from: 'users',
localField: 'ownerAdminId',
foreignField: '_id',
as: 'ownerAdmin',
},
},
{ $unwind: '$ownerAdmin' },
{ $match: { 'ownerAdmin.isDisabled': false } },
{ $count: 'count' },
])
.exec();
return rows[0]?.count ?? 0;
}
async countRepairShops(filter: FilterQuery<RepairShopDocument>): Promise<number> {
return this.repairShopModel.countDocuments(filter).exec();
}
} }

لم تُعرض بعض الملفات لأن الكثير من الملفات تغيرت في هذا الاختلاف إظهار المزيد