Merge branch 'master' into production

This commit is contained in:
2025-12-27 21:57:30 +03:30
26 changed files with 540 additions and 223 deletions
-148
View File
@@ -1,148 +0,0 @@
# Pager Socket.IO Testing Guide
## Quick Start
1. **Start your NestJS server:**
```bash
npm run start:dev
```
2. **Open the test page:**
- Open `test-pager-socket.html` in your web browser
- Or serve it via a simple HTTP server:
```bash
# Using Python
python3 -m http.server 8080
# Then open http://localhost:8080/test-pager-socket.html
# Using Node.js (if you have http-server installed)
npx http-server -p 8080
```
## Testing Scenarios
### Scenario 1: Test as Restaurant Admin/Staff
1. **Get your admin JWT token:**
- Login as admin via your API to get the JWT token
- Example:
```bash
curl -X POST http://localhost:4000/admin/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "admin@example.com",
"password": "password"
}'
```
- Copy the `accessToken` from the response
2. **Connect to the server:**
- Enter server URL: `http://localhost:4000`
- Enter your admin JWT token in the "Admin Token" field
- Click "Connect"
3. **Join restaurant room:**
- Select "Restaurant (Admin/Staff)" from User Type
- Click "Join Room"
- The restaurant ID will be automatically extracted from your token
3. **Create a pager request (via API):**
```bash
curl -X POST http://localhost:4000/public/pager \
-H "Content-Type: application/json" \
-H "X-Slug: your-restaurant-slug" \
-d '{
"tableNumber": "5",
"message": "Need assistance"
}'
```
4. **Observe the event:**
- You should see `pager:created` event in the Events Log
### Scenario 2: Test as Public User
1. **Create a pager request first (to get cookie ID):**
```bash
curl -X POST http://localhost:4000/public/pager \
-H "Content-Type: application/json" \
-H "X-Slug: your-restaurant-slug" \
-d '{
"tableNumber": "3",
"message": "Need water"
}' \
-c cookies.txt
```
2. **Get the cookie ID:**
- Check the response or cookies.txt file for `pagerId` cookie value
3. **Connect and join session:**
- Connect to the server
- Select "Session (Public User)" from User Type
- Enter the cookie ID from step 2
- Click "Join Room"
4. **Update pager status (via API as admin):**
```bash
curl -X PATCH http://localhost:4000/admin/pager/{pagerId}/status \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
-d '{
"status": "acknowledged"
}'
```
5. **Observe the event:**
- You should see `pager:status:updated` event in the Events Log
## Socket Events Reference
### Client → Server Events
| Event | Payload | Description |
|-------|---------|-------------|
| `join:restaurant` | `{}` (empty, restId extracted from token) | Join restaurant room for admin/staff. **Requires authentication via JWT token in connection auth** |
| `join:session` | `{ cookieId: string }` | Join session room for public user |
| `leave:room` | `{ room: string }` | Leave a specific room |
**Note:** For `join:restaurant`, the restaurant ID is automatically extracted from the authenticated admin's JWT token. You must provide the token when connecting:
```javascript
const socket = io('http://localhost:4000/pager', {
auth: {
token: 'your-jwt-token-here' // or 'Bearer your-jwt-token-here'
}
});
```
### Server → Client Events
| Event | Payload | Description |
|-------|---------|-------------|
| `pager:created` | `{ pager: {...} }` | New pager request created |
| `pager:status:updated` | `{ pager: {...} }` | Pager status updated |
| `joined` | `{ room: string, message: string }` | Successfully joined a room |
| `left` | `{ room: string, message: string }` | Successfully left a room |
| `error` | `{ message: string }` | Error occurred |
## Room Names
- **Restaurant Room:** `restaurant:{restId}`
- **Session Room:** `cookie:{cookieId}`
## Testing Tips
1. **Open multiple browser tabs/windows** to simulate multiple clients
2. **Use browser DevTools** to inspect WebSocket connections
3. **Check server logs** to see gateway activity
4. **Test reconnection** by disconnecting and reconnecting
5. **Test room isolation** by joining different rooms in different tabs
## Troubleshooting
- **Connection fails:** Check if server is running on the correct port
- **No events received:** Verify you've joined the correct room
- **CORS errors:** Ensure CORS is enabled in your NestJS app (already configured in gateway)
- **Events not showing:** Check browser console for errors
+182 -3
View File
@@ -48,6 +48,7 @@
"firebase-admin": "^13.6.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sanitize-html": "^2.17.0",
"socket.io": "^4.8.1",
"ulid": "^3.0.1",
"uuid": "^13.0.0"
@@ -63,6 +64,7 @@
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/sanitize-html": "^2.16.0",
"@types/supertest": "^6.0.2",
"@types/uuid": "^10.0.0",
"eslint": "^9.18.0",
@@ -6476,6 +6478,16 @@
"node": ">= 0.6"
}
},
"node_modules/@types/sanitize-html": {
"version": "2.16.0",
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.0.tgz",
"integrity": "sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==",
"dev": true,
"license": "MIT",
"dependencies": {
"htmlparser2": "^8.0.0"
}
},
"node_modules/@types/send": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz",
@@ -8712,7 +8724,6 @@
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -8837,6 +8848,61 @@
"node": ">=8"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
@@ -9081,6 +9147,18 @@
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/error-ex": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
@@ -9162,7 +9240,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -10786,6 +10863,25 @@
"dev": true,
"license": "MIT"
},
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@@ -11113,6 +11209,15 @@
"node": ">=0.12.0"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -13011,6 +13116,24 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-postinstall": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
@@ -13377,6 +13500,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -13594,7 +13723,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
@@ -13745,6 +13873,34 @@
"node": ">= 0.4"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postgres-array": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz",
@@ -14371,6 +14527,20 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sanitize-html": {
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz",
"integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^8.0.0",
"is-plain-object": "^5.0.0",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
}
},
"node_modules/schema-utils": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
@@ -14782,6 +14952,15 @@
"node": ">= 8"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-support": {
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+2
View File
@@ -64,6 +64,7 @@
"firebase-admin": "^13.6.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sanitize-html": "^2.17.0",
"socket.io": "^4.8.1",
"ulid": "^3.0.1",
"uuid": "^13.0.0"
@@ -79,6 +80,7 @@
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/sanitize-html": "^2.16.0",
"@types/supertest": "^6.0.2",
"@types/uuid": "^10.0.0",
"eslint": "^9.18.0",
@@ -40,7 +40,7 @@ export class AdminRepository extends EntityRepository<Admin> {
// Ensure all roles are populated
await adminRole.admin.roles.loadItems();
for (const role of adminRole.admin.roles.getItems()) {
if (role.role && !role.role.permissions.isInitialized()) {
if (role.role && role.role.permissions && !role.role.permissions.isInitialized()) {
await role.role.permissions.loadItems();
}
}
@@ -46,7 +46,7 @@ export class CartValidationService {
validateStock(food: Food, quantity: number): void {
const availableStock = food.inventory!.availableStock;
if (availableStock < quantity) {
throw new BadRequestException(CartMessage.INSUFFICIENT_STOCK);
throw new BadRequestException(CartMessage.INSUFFICIENT_STOCK + food.title);
}
}
@@ -155,9 +155,9 @@ export class NotificationsController {
return this.preferenceService.updatePreference(preferenceId, restaurantId, dto);
}
@UseGuards(SuperAdminAuthGuard)
// @UseGuards(SuperAdminAuthGuard)
@ApiBearerAuth()
@Get('super-admin/sms-count')
@Get('super-admin/notifications/sms-count')
@ApiOperation({ summary: 'Get SMS count for each restaurant with pagination' })
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)', minimum: 1 })
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 10)', minimum: 1 })
@@ -5,15 +5,19 @@ import { PagerCreatedEvent } from '../events/pager.events';
import { Permission } from 'src/common/enums/permission.enum';
import { NotifTitleEnum } from 'src/modules/notifications/interfaces/notification.interface';
import { NotificationService } from 'src/modules/notifications/services/notification.service';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class PagerListeners {
private readonly logger = new Logger(PagerListeners.name);
private readonly smsPatternPagerCreated: string
constructor(
private readonly adminService: AdminRepository,
private readonly notificationService: NotificationService,
) {}
private readonly configService: ConfigService,
) {
this.smsPatternPagerCreated = this.configService.get<string>('SMS_PATTERN_PAGER_CREATED') ?? '123';
}
@OnEvent(PagerCreatedEvent.name)
async handlePagerCreated(event: PagerCreatedEvent) {
@@ -30,7 +34,7 @@ export class PagerListeners {
title: NotifTitleEnum.PAGER_CREATED,
content: `میز شماره ${event.tableNumber} پیج جدیدی انجام داد`,
sms: {
templateId: '1234567890',
templateId: this.smsPatternPagerCreated,
parameters: {
tableNumber: event.tableNumber.toString(),
},
@@ -45,7 +45,7 @@ export class PaymentsController {
@ApiOperation({ summary: 'Get all the restaurant payments for user' })
@ApiHeader(API_HEADER_SLUG)
findAllByRestaurant(@UserId() userId: string, @RestId() restId: string) {
return this.paymentsService.findAllByRestaurantId(restId, userId);
return this.paymentsService.findAllPaymentsByRestaurantId(restId, userId);
}
@UseGuards(AuthGuard)
@@ -219,7 +219,7 @@ export class PaymentsService {
return payment;
}
findAllByRestaurantId(restId: string, userId: string) {
findAllPaymentsByRestaurantId(restId: string, userId: string) {
return this.em.find(
Payment,
{ order: { restaurant: { id: restId }, user: { id: userId } } },
@@ -77,7 +77,16 @@ export class RestaurantsController {
@ApiOperation({ summary: 'Create a new restaurant' })
@Post('super-admin/restaurants')
createRestaurant(@Body() createRestaurantDto: CreateRestaurantDto) {
return this.restaurantsService.create(createRestaurantDto);
return this.restaurantsService.setupRestuarant(createRestaurantDto);
}
@UseGuards(SuperAdminAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get restaurant by subscription ID' })
@ApiParam({ name: 'subscriptionId', required: true, description: 'Subscription ID' })
@Get('super-admin/restaurants/subscription/:subscriptionId')
findOneBySubscriptionId(@Param('subscriptionId') subscriptionId: string) {
return this.restaurantsService.findOneBySubscriptionId(subscriptionId);
}
@@ -0,0 +1,50 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { EntityManager } from '@mikro-orm/postgresql';
import { Restaurant } from '../entities/restaurant.entity';
@Injectable()
export class RestaurantCrone {
private readonly logger = new Logger(RestaurantCrone.name);
constructor(private readonly em: EntityManager) { }
// run every day at 03:00 (3:00 AM)
@Cron('0 3 * * *', {
name: 'deactivateExpiredSubscriptions',
timeZone: 'UTC',
})
async handleCron() {
try {
this.logger.debug('Starting daily subscription expiration check');
// Get current date
const now = new Date();
// Find restaurants where subscription has expired and they're still active
const expiredRestaurants = await this.em.find(Restaurant, {
subscriptionEndDate: { $lt: now },
isActive: true,
});
if (!expiredRestaurants || expiredRestaurants.length === 0) {
this.logger.debug('No restaurants with expired subscriptions found');
return;
}
this.logger.log(`Found ${expiredRestaurants.length} restaurants with expired subscriptions`);
// Deactivate expired restaurants
for (const restaurant of expiredRestaurants) {
restaurant.isActive = false;
}
// Persist all changes
await this.em.persistAndFlush(expiredRestaurants);
this.logger.log(`Successfully deactivated ${expiredRestaurants.length} restaurants with expired subscriptions`);
} catch (err) {
this.logger.error(`RestaurantCrone failed: ${err?.message}`, err);
}
}
}
@@ -1,5 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, IsNumber } from 'class-validator';
import { IsString, IsOptional, IsNumber, IsDate, IsEnum } from 'class-validator';
import { Type } from 'class-transformer';
import { PlanEnum } from '../interface/plan.interface';
export class CreateRestaurantDto {
@ApiProperty({ example: 'کافه ژیوان', description: 'نام رستوران' })
@@ -13,15 +15,29 @@ export class CreateRestaurantDto {
@ApiPropertyOptional({ example: 2020, description: 'سال تأسیس' })
@IsOptional()
@IsNumber()
establishedYear?: number;
establishedYear: number;
@ApiPropertyOptional({ example: '09123456789', description: 'شماره تلفن' })
@IsOptional()
@IsString()
phone?: string;
phone: string;
@ApiProperty({ example: 'base', enum: PlanEnum, description: 'پلن رستوران' })
@IsEnum(PlanEnum)
plan!: PlanEnum;
@ApiProperty({ example: 'sub_1234567890', description: 'شناسه اشتراک' })
@IsString()
subscriptionId!: string;
@ApiProperty({ example: '2025-12-26', description: 'تاریخ انقضای اشتراک' })
@Type(() => Date)
@IsDate()
subscriptionEndDate!: Date;
@ApiProperty({ example: '2025-12-26', description: 'تاریخ شروع اشتراک' })
@Type(() => Date)
@IsDate()
subscriptionStartDate!: Date;
}
@@ -102,4 +102,11 @@ export class Restaurant extends BaseEntity {
@Property({unique: true})
subscriptionId!: string;
@Property()
subscriptionEndDate!: Date;
@Property()
subscriptionStartDate!: Date;
}
@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { CreateRestaurantDto } from '../dto/create-restaurant.dto';
import { UpdateRestaurantDto } from '../dto/update-restaurant.dto';
import { Restaurant } from '../entities/restaurant.entity';
@@ -8,6 +8,10 @@ import { RestMessage } from 'src/common/enums/message.enum';
import { FindRestaurantsDto } from '../dto/find-restaurants.dto';
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
import { PlanEnum } from '../interface/plan.interface';
import { Admin } from '../../admin/entities/admin.entity';
import { AdminRole } from '../../admin/entities/adminRole.entity';
import { Role } from '../../roles/entities/role.entity';
import { normalizePhone } from 'src/modules/utils/phone.util';
@Injectable()
export class RestaurantsService {
@@ -16,20 +20,62 @@ export class RestaurantsService {
private readonly restRepository: RestRepository,
) { }
async create(dto: CreateRestaurantDto): Promise<Restaurant> {
const restaurant = this.em.create(Restaurant, {
name: dto.name,
slug: dto.slug,
establishedYear: dto.establishedYear,
phone: dto.phone,
isActive: true,
plan: PlanEnum.Base,
domain: `https://dmenu-plus-front.dev.danakcorp.com/${dto.slug}`,
subscriptionId: dto.subscriptionId,
});
async setupRestuarant(dto: CreateRestaurantDto): Promise<Restaurant> {
return await this.em.transactional(async (em) => {
// Validate unique constraints
const validateSlug = await em.findOne(Restaurant, { slug: dto.slug })
if (validateSlug) {
throw new BadRequestException("Duplicate slug: " + dto.slug)
}
const validateSubscriptionId = await em.findOne(Restaurant, { subscriptionId: dto.subscriptionId })
if (validateSubscriptionId) {
throw new BadRequestException("Duplicate subscriptionId: " + dto.subscriptionId)
}
await this.em.persistAndFlush(restaurant);
return restaurant;
// Create restaurant
const restaurant = em.create(Restaurant, {
name: dto.name,
slug: dto.slug,
establishedYear: dto.establishedYear,
phone: dto.phone,
isActive: true,
plan: dto.plan,
domain: `https://dmenu-plus-front.dev.danakcorp.com/${dto.slug}`,
subscriptionId: dto.subscriptionId,
subscriptionEndDate: dto.subscriptionEndDate,
subscriptionStartDate: dto.subscriptionStartDate,
});
// Find the appropriate role based on plan
const roleName = dto.plan === PlanEnum.Base ? 'مدیر (پلن پایه)' : 'مدیر ( پلن ویژه)';
const role = await em.findOne(Role, { name: roleName });
if (!role) {
throw new BadRequestException(`Role not found for plan: ${dto.plan}`);
}
const normalizedPhone = normalizePhone(dto.phone);
let admin = await em.findOne(Admin, { phone: normalizedPhone });
if (!admin) {
admin = em.create(Admin, {
phone: normalizedPhone,
firstName: 'نام مدیر',
lastName: 'نام خانوادگی مدیر',
});
}
// Create admin role relationship
const adminRole = em.create(AdminRole, {
admin,
role,
restaurant,
});
// Persist all entities
em.persist([restaurant, admin, adminRole]);
await em.flush();
return restaurant;
});
}
async findAll(dto: FindRestaurantsDto): Promise<PaginatedResult<Restaurant>> {
@@ -64,6 +110,14 @@ export class RestaurantsService {
return restaurant;
}
async findOneBySubscriptionId(subscriptionId: string): Promise<Restaurant> {
const restaurant = await this.restRepository.findOne({ subscriptionId });
if (!restaurant) {
throw new NotFoundException(RestMessage.NOT_FOUND);
}
return restaurant;
}
async getRestaurantSpecification(slug: string) {
const restaurant = await this.findBySlug(slug);
return restaurant;
@@ -8,12 +8,13 @@ import { ScheduleRepository } from './repositories/schedule.repository';
import { Schedule } from './entities/schedule.entity';
import { ScheduleService } from './providers/schedule.service';
import { ScheduleController } from './controllers/schedule.controller';
import { RestaurantCrone } from './crone/restaurant.crone';
import { JwtModule } from '@nestjs/jwt';
import { AuthModule } from '../auth/auth.module';
@Module({
controllers: [RestaurantsController, ScheduleController],
providers: [RestaurantsService, RestRepository, ScheduleRepository, ScheduleService],
providers: [RestaurantsService, RestRepository, ScheduleRepository, ScheduleService, RestaurantCrone],
imports: [MikroOrmModule.forFeature([Restaurant, Schedule]), JwtModule, forwardRef(() => AuthModule)],
exports: [RestRepository, ScheduleRepository, ScheduleService],
})
@@ -0,0 +1,13 @@
export class ReviewCreatedEvent {
constructor(
public readonly reviewId: string,
public readonly restaurantId: string,
public readonly userId: string,
public readonly foodId: string,
public readonly foodName: string,
public readonly orderId: string,
public readonly orderNumber: string | number,
public readonly rating: number,
) {}
}
@@ -0,0 +1,77 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { ReviewCreatedEvent } from '../events/review.events';
import { AdminRepository } from 'src/modules/admin/repositories/admin.repository';
import { Permission } from 'src/common/enums/permission.enum';
import { NotifTitleEnum } from 'src/modules/notifications/interfaces/notification.interface';
import { NotificationService } from 'src/modules/notifications/services/notification.service';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class ReviewListeners {
private readonly logger = new Logger(ReviewListeners.name);
private reviewCreatedSmsTemplateId: string;
constructor(
private readonly adminService: AdminRepository,
private readonly notificationService: NotificationService,
private readonly configService: ConfigService,
) {
this.reviewCreatedSmsTemplateId = this.configService.get<string>('SMS_PATTERN_REVIEW_CREATED') ?? '123';
}
@OnEvent(ReviewCreatedEvent.name)
async handleReviewCreated(event: ReviewCreatedEvent) {
try {
this.logger.log(
`Review created event received: ${event.reviewId} for restaurant: ${event.restaurantId}, food: ${event.foodName}, rating: ${event.rating}`,
);
// Get admins of restaurant that have review management permissions
const admins = await this.adminService.findAdminsWithPermission(event.restaurantId, Permission.MANAGE_REVIEWS);
const recipients = admins.map(admin => ({
adminId: admin.id,
}));
if (recipients.length === 0) {
this.logger.warn(`No admins found with MANAGE_REVIEWS permission for restaurant ${event.restaurantId}`);
return;
}
await this.notificationService.sendNotification({
restaurantId: event.restaurantId,
message: {
title: NotifTitleEnum.REVIEW_CREATED,
content: `نظر جدید برای غذا "${event.foodName}" با امتیاز ${event.rating} از 5 برای سفارش شماره ${event.orderNumber} ثبت شد`,
sms: {
templateId: this.reviewCreatedSmsTemplateId,
parameters: {
foodName: event.foodName,
rating: event.rating.toString(),
orderNumber: String(event.orderNumber),
},
},
pushNotif: {
title: `نظر جدید`,
content: `نظر جدید برای غذا "${event.foodName}" با امتیاز ${event.rating} ثبت شد`,
icon: `/`,
action: {
type: NotifTitleEnum.REVIEW_CREATED,
url: `/`,
},
},
},
recipients,
metadata: {
priority: 1,
},
});
} catch (error) {
this.logger.error(
`Failed to send notification for review created event: ${event.reviewId}`,
error instanceof Error ? error.stack : String(error),
);
}
}
}
+30 -4
View File
@@ -13,6 +13,9 @@ import { Order } from '../../orders/entities/order.entity';
import { OrderItem } from '../../orders/entities/order-item.entity';
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
import { ReviewStatus } from '../enums/review-status.enum';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { ReviewCreatedEvent } from '../events/review.events';
import { sanitizeText } from '../../utils/sanitize.util';
@Injectable()
export class ReviewService {
@@ -21,6 +24,7 @@ export class ReviewService {
private readonly foodRepository: FoodRepository,
private readonly userRepository: UserRepository,
private readonly em: EntityManager,
private readonly eventEmitter: EventEmitter2,
) { }
async create(userId: string, createReviewDto: CreateReviewDto): Promise<Review> {
@@ -36,8 +40,8 @@ export class ReviewService {
throw new NotFoundException(UserMessage.USER_NOT_FOUND);
}
// Find order and verify it belongs to the user
const order = await this.em.findOne(Order, { id: orderId, user: { id: userId } });
// Find order and verify it belongs to the user, populate restaurant to get restaurantId
const order = await this.em.findOne(Order, { id: orderId, user: { id: userId } }, { populate: ['restaurant'] });
if (!order) {
throw new NotFoundException('Order not found or does not belong to the current user');
}
@@ -67,7 +71,7 @@ export class ReviewService {
food,
user,
rating,
comment: comment || undefined,
comment: sanitizeText(comment) || undefined,
positivePoints: positivePoints || undefined,
negativePoints: negativePoints || undefined,
status: ReviewStatus.PENDING,
@@ -83,6 +87,21 @@ export class ReviewService {
// Update food rating average
await this.updateFoodRating(foodId);
// Emit ReviewCreatedEvent
this.eventEmitter.emit(
ReviewCreatedEvent.name,
new ReviewCreatedEvent(
review.id,
order.restaurant.id,
userId,
foodId,
food.title || 'Unknown Food',
orderId,
order.orderNumber || '',
rating,
),
);
return review;
}
@@ -127,7 +146,14 @@ export class ReviewService {
}
const oldRating = review.rating;
this.em.assign(review, dto);
// Sanitize comment if provided
const sanitizedDto = {
...dto,
comment: dto.comment !== undefined ? sanitizeText(dto.comment) || undefined : undefined,
};
this.em.assign(review, sanitizedDto);
await this.em.persistAndFlush(review);
// Update food rating average if rating changed
+5 -2
View File
@@ -9,11 +9,14 @@ import { FoodModule } from '../foods/food.module';
import { UserModule } from '../users/user.module';
import { AuthModule } from '../auth/auth.module';
import { JwtModule } from '@nestjs/jwt';
import { ReviewListeners } from './listeners/review.listeners';
import { AdminModule } from '../admin/admin.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [MikroOrmModule.forFeature([Review]), FoodModule, UserModule, AuthModule, JwtModule],
imports: [MikroOrmModule.forFeature([Review]), FoodModule, UserModule, AuthModule, JwtModule, AdminModule, NotificationsModule],
controllers: [ReviewController],
providers: [ReviewService, ReviewRepository, FoodRatingCronService],
providers: [ReviewService, ReviewRepository, FoodRatingCronService, ReviewListeners],
exports: [ReviewRepository],
})
export class ReviewModule {}
@@ -1,9 +1,10 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityRepository } from '@mikro-orm/core';
import { EntityManager, EntityRepository } from '@mikro-orm/core';
import { Permission } from '../entities/permission.entity';
import { CacheService } from 'src/modules/utils/cache.service';
import { AdminRepository } from 'src/modules/admin/repositories/admin.repository';
import { AdminRole } from 'src/modules/admin/entities/adminRole.entity';
@Injectable()
export class PermissionsService {
@@ -14,7 +15,8 @@ export class PermissionsService {
private readonly permissionRepository: EntityRepository<Permission>,
private readonly cacheService: CacheService,
private readonly adminRepository: AdminRepository,
) {}
private readonly em: EntityManager,
) { }
async findAll() {
const permissions = await this.permissionRepository.findAll();
@@ -76,33 +78,19 @@ export class PermissionsService {
}
async getAdminFullPermissions(adminId: string, restId: string): Promise<Permission[]> {
const admin = await this.adminRepository.findOne(
{ id: adminId, roles: { restaurant: { id: restId } } },
{ populate: ['roles', 'roles.role', 'roles.role.permissions'] },
);
if (!admin || !admin.roles) {
return [];
const listOfPermissions = []
const adminRoles = await this.em.findOne(AdminRole, {
admin: {
id: adminId,
},
restaurant: {
id: restId,
},
}, { populate: ['role', 'role.permissions'] });
if (adminRoles) {
listOfPermissions.push(...adminRoles.role.permissions.getItems());
}
// Ensure roles collection is loaded
await admin.roles.loadItems();
// Extract permission names as array of strings
const permissions = await Promise.all(
admin.roles
.getItems()
.filter(r => r.role) // Filter out any null/undefined roles
.map(async r => {
// Ensure permissions collection is initialized
if (!r.role.permissions.isInitialized()) {
await r.role.permissions.loadItems();
}
return r.role.permissions.getItems();
}),
);
return permissions.flat();
return listOfPermissions.map(permission => permission);
}
/**
+20
View File
@@ -0,0 +1,20 @@
import sanitizeHtml from 'sanitize-html';
/**
* Sanitizes user input to prevent XSS attacks and remove malicious content
* Allows only safe text content, removing all HTML tags and scripts
* @param input - The string to sanitize
* @returns Sanitized string with all HTML tags and scripts removed
*/
export function sanitizeText(input: string | undefined | null): string | undefined | null {
if (!input) {
return input;
}
return sanitizeHtml(input, {
allowedTags: [], // No HTML tags allowed
allowedAttributes: {}, // No attributes allowed
allowedSchemes: [], // No URL schemes allowed
}).trim();
}
+3 -3
View File
@@ -11,14 +11,14 @@ export const adminsData: AdminData[] = [
phone: '09362532122',
firstName: 'مرتضی',
lastName: 'مرتضایی',
roleName: 'restaurant',
roleName: 'مدیر ( پلن ویژه)',
restaurantSlug: 'zhivan',
},
{
phone: '09185290775',
firstName: 'حمید',
lastName: 'ضرقامی',
roleName: 'restaurant',
roleName: 'مدیر (پلن پایه)',
restaurantSlug: 'boote',
},
{
@@ -32,7 +32,7 @@ export const adminsData: AdminData[] = [
phone: '09184317567',
firstName: 'ادمین',
lastName: 'هنر',
roleName: 'restaurant',
roleName: 'مدیر (پلن پایه)',
restaurantSlug: 'honar',
},
];
+3 -3
View File
@@ -20,7 +20,7 @@ export interface RestaurantData {
marriageDateScore: string;
referrerScore: string;
};
plan: PlanEnum.Premium;
plan: PlanEnum;
subscriptionId: string;
}
@@ -84,7 +84,7 @@ export const restaurantsData: RestaurantData[] = [
marriageDateScore: '10000',
referrerScore: '10000',
},
plan: PlanEnum.Premium,
plan: PlanEnum.Base,
subscriptionId: 'sub_seed_boote_001',
},
{
@@ -115,7 +115,7 @@ export const restaurantsData: RestaurantData[] = [
marriageDateScore: '10000',
referrerScore: '10000',
},
plan: PlanEnum.Premium,
plan: PlanEnum.Base,
subscriptionId: 'sub_seed_honar_001',
},
];
+14 -2
View File
@@ -8,12 +8,24 @@ export interface RoleConfig {
export const rolesData: RoleConfig[] = [
{
name: 'restaurant',
name: 'مدیر (پلن پایه)',
isSystem: false,
permissionFilter: (name: Permission) =>
name === Permission.MANAGE_FOODS ||
name === Permission.MANAGE_CATEGORIES ||
name === Permission.MANAGE_SCHEDULES ||
name === Permission.MANAGE_PAGER ||
name === Permission.MANAGE_CONTACTS ||
name === Permission.MANAGE_ROLES ||
name === Permission.MANAGE_SETTINGS
},
{
name: 'مدیر ( پلن ویژه)',
isSystem: false,
permissionFilter: () => true, // All permissions for restaurant role
},
{
name: 'accountant',
name: 'حسابدار ',
isSystem: false,
permissionFilter: (name: Permission) =>
name === Permission.VIEW_REPORTS || name === Permission.MANAGE_PAYMENTS || name === Permission.MANAGE_ORDERS,
+5 -1
View File
@@ -9,7 +9,11 @@ export class RestaurantsSeeder {
for (const restaurantData of restaurantsData) {
let restaurant = await em.findOne(Restaurant, { slug: restaurantData.slug });
if (!restaurant) {
restaurant = em.create(Restaurant, restaurantData);
restaurant = em.create(Restaurant, {
...restaurantData,
subscriptionEndDate: new Date('2025-12-26'),
subscriptionStartDate: new Date('2026-12-26'),
});
em.persist(restaurant);
}
restaurantsMap.set(restaurantData.slug, restaurant);
+1 -1
View File
@@ -13,7 +13,7 @@ export class RolesSeeder {
if (!role) {
role = em.create(Role, {
name: roleConfig.name,
isSystem: roleConfig.isSystem,
isSystem: true,
});
// Add permissions based on role configuration