socket pager

This commit is contained in:
2025-12-10 09:49:10 +03:30
parent 33b250287c
commit 2e12cdb529
10 changed files with 1294 additions and 5 deletions
+148
View File
@@ -0,0 +1,148 @@
# 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
+325 -1
View File
@@ -32,9 +32,11 @@
"@nestjs/mapped-types": "*",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-fastify": "^11.1.8",
"@nestjs/platform-socket.io": "^11.1.9",
"@nestjs/schedule": "^6.0.1",
"@nestjs/swagger": "^11.2.1",
"@nestjs/throttler": "^6.4.0",
"@types/socket.io": "^3.0.1",
"axios": "^1.13.1",
"bullmq": "^5.65.1",
"cache-manager": "^7.2.4",
@@ -45,6 +47,7 @@
"firebase-admin": "^13.6.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socket.io": "^4.8.1",
"ulid": "^3.0.1",
"uuid": "^13.0.0"
},
@@ -4871,6 +4874,26 @@
}
}
},
"node_modules/@nestjs/platform-socket.io": {
"version": "11.1.9",
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.9.tgz",
"integrity": "sha512-OaAW+voXo5BXbFKd9Ot3SL05tEucRMhZRdw5wdWZf/RpIl9hB6G6OHr8DDxNbUGvuQWzNnZHCDHx3EQJzjcIyA==",
"license": "MIT",
"peer": true,
"dependencies": {
"socket.io": "4.8.1",
"tslib": "2.8.1"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/nest"
},
"peerDependencies": {
"@nestjs/common": "^11.0.0",
"@nestjs/websockets": "^11.0.0",
"rxjs": "^7.1.0"
}
},
"node_modules/@nestjs/schedule": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.0.1.tgz",
@@ -5054,6 +5077,30 @@
"reflect-metadata": "^0.1.13 || ^0.2.0"
}
},
"node_modules/@nestjs/websockets": {
"version": "11.1.9",
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.9.tgz",
"integrity": "sha512-kkkdeTVcc3X7ZzvVqUVpOAJoh49kTRUjWNUXo5jmG+27OvZoHfs/vuSiqxidrrbIgydSqN15HUsf1wZwQUrxCQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"iterare": "1.2.1",
"object-hash": "3.0.0",
"tslib": "2.8.1"
},
"peerDependencies": {
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-socket.io": "^11.0.0",
"reflect-metadata": "^0.1.12 || ^0.2.0",
"rxjs": "^7.1.0"
},
"peerDependenciesMeta": {
"@nestjs/platform-socket.io": {
"optional": true
}
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
@@ -6010,6 +6057,12 @@
"node": ">=18.0.0"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@tokenizer/inflate": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
@@ -6161,6 +6214,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -6414,6 +6476,15 @@
"@types/node": "*"
}
},
"node_modules/@types/socket.io": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/socket.io/-/socket.io-3.0.1.tgz",
"integrity": "sha512-XSma2FhVD78ymvoxYV4xGXrIH/0EKQ93rR+YR0Y+Kw1xbPzLDCip/UWSejZ08FpxYeYNci/PZPQS9anrvJRqMA==",
"license": "MIT",
"dependencies": {
"socket.io": "*"
}
},
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
@@ -7691,6 +7762,15 @@
],
"license": "MIT"
},
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
"license": "MIT",
"engines": {
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.19.tgz",
@@ -8855,6 +8935,95 @@
"once": "^1.4.0"
}
},
"node_modules/engine.io": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz",
"integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==",
"license": "MIT",
"dependencies": {
"@types/cors": "^2.8.12",
"@types/node": ">=10.0.0",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/engine.io/node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
@@ -12937,7 +13106,6 @@
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 6"
}
@@ -14393,6 +14561,141 @@
"node": ">=8"
}
},
"node_modules/socket.io": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
"integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.3.2",
"engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/socket.io-adapter": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz",
"integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==",
"license": "MIT",
"dependencies": {
"debug": "~4.3.4",
"ws": "~8.17.1"
}
},
"node_modules/socket.io-adapter/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io/node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/socket.io/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/socket.io/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/socket.io/node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/sonic-boom": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
@@ -16165,6 +16468,27 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+3
View File
@@ -47,9 +47,11 @@
"@nestjs/mapped-types": "*",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-fastify": "^11.1.8",
"@nestjs/platform-socket.io": "^11.1.9",
"@nestjs/schedule": "^6.0.1",
"@nestjs/swagger": "^11.2.1",
"@nestjs/throttler": "^6.4.0",
"@types/socket.io": "^3.0.1",
"axios": "^1.13.1",
"bullmq": "^5.65.1",
"cache-manager": "^7.2.4",
@@ -60,6 +62,7 @@
"firebase-admin": "^13.6.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socket.io": "^4.8.1",
"ulid": "^3.0.1",
"uuid": "^13.0.0"
},
@@ -0,0 +1,26 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { AuthenticatedSocket } from '../guards/ws-admin-auth.guard';
/**
* Extract admin ID from authenticated WebSocket client
* Use this decorator in WebSocket handlers to get the adminId from the authenticated admin
*
* @example
* ```typescript
* @SubscribeMessage('some:event')
* handleEvent(@WsAdminId() adminId: string) {
* // adminId is automatically extracted from authenticated admin
* }
* ```
*/
export const WsAdminId = createParamDecorator((data: unknown, ctx: ExecutionContext): string => {
const client = ctx.switchToWs().getClient<AuthenticatedSocket>();
const adminId = client.adminId;
if (!adminId) {
throw new Error('Admin ID not found. Ensure WsAdminAuthGuard is applied.');
}
return adminId;
});
@@ -0,0 +1,26 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { AuthenticatedSocket } from '../guards/ws-admin-auth.guard';
/**
* Extract restaurant ID from authenticated WebSocket client
* Use this decorator in WebSocket handlers to get the restId from the authenticated admin
*
* @example
* ```typescript
* @SubscribeMessage('join:restaurant')
* handleJoinRestaurant(@WsRestId() restId: string) {
* // restId is automatically extracted from authenticated admin
* }
* ```
*/
export const WsRestId = createParamDecorator((data: unknown, ctx: ExecutionContext): string => {
const client = ctx.switchToWs().getClient<AuthenticatedSocket>();
const restId = client.restId;
if (!restId) {
throw new Error('Restaurant ID not found. Ensure WsAdminAuthGuard is applied.');
}
return restId;
});
@@ -0,0 +1,104 @@
import { CanActivate, ExecutionContext, Injectable, Logger, Inject } from '@nestjs/common';
import { WsException } from '@nestjs/websockets';
import { Socket } from 'socket.io';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { IAdminTokenPayload } from '../../auth/interfaces/IToken-payload';
export interface AuthenticatedSocket extends Socket {
adminId?: string;
restId?: string;
}
@Injectable()
export class WsAdminAuthGuard implements CanActivate {
private readonly logger = new Logger(WsAdminAuthGuard.name);
constructor(
@Inject(JwtService)
private readonly jwtService: JwtService,
@Inject(ConfigService)
private readonly configService: ConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const client: Socket = context.switchToWs().getClient<Socket>();
try {
const token = this.extractToken(client);
if (!token) {
this.logger.warn('No token provided in WebSocket connection', {
socketId: client.id,
auth: client.handshake.auth,
query: client.handshake.query,
});
throw new WsException('Authentication required');
}
const secret = this.configService.getOrThrow<string>('JWT_SECRET');
const payload = await this.jwtService.verifyAsync<IAdminTokenPayload>(token, {
secret,
});
if (!payload.adminId || !payload.restId) {
this.logger.error('Invalid token payload structure', payload);
throw new WsException('Invalid token payload');
}
// Attach admin info to socket
(client as AuthenticatedSocket).adminId = payload.adminId;
(client as AuthenticatedSocket).restId = payload.restId;
this.logger.log(`Admin authenticated via WebSocket: ${payload.adminId}, restId: ${payload.restId}`);
return true;
} catch (error) {
if (error instanceof WsException) {
throw error;
}
this.logger.error('WebSocket authentication error', {
error: error instanceof Error ? error.message : 'Unknown error',
socketId: client.id,
});
throw new WsException('Authentication failed');
}
}
private extractToken(client: Socket): string | undefined {
// Try to get token from handshake auth (recommended for socket.io)
const auth = client.handshake.auth;
const authToken = (auth?.token as string | undefined) || (auth?.authorization as string | undefined);
if (authToken) {
// If it's "Bearer <token>", extract just the token
if (typeof authToken === 'string' && authToken.startsWith('Bearer ')) {
return authToken.substring(7);
}
return authToken;
}
// Try to get from query parameters (fallback)
const queryToken = client.handshake.query?.token || client.handshake.query?.authorization;
if (queryToken) {
if (typeof queryToken === 'string' && queryToken.startsWith('Bearer ')) {
return queryToken.substring(7);
}
return queryToken as string;
}
// Try to get from headers (if available)
const headers = client.handshake.headers;
const authHeader = headers.authorization || headers['authorization'];
if (authHeader && typeof authHeader === 'string') {
const [type, token] = authHeader.split(' ');
if (type?.toLowerCase() === 'bearer' && token) {
return token;
}
}
return undefined;
}
}
+139
View File
@@ -0,0 +1,139 @@
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
MessageBody,
ConnectedSocket,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Logger, UseGuards } from '@nestjs/common';
import { Pager } from './entities/pager.entity';
import { WsAdminAuthGuard, type AuthenticatedSocket } from './guards/ws-admin-auth.guard';
import { WsRestId } from './decorators/ws-rest-id.decorator';
@WebSocketGateway({
cors: {
origin: true,
credentials: true,
},
namespace: '/pager',
})
export class PagerGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server!: Server;
private readonly logger = new Logger(PagerGateway.name);
handleConnection(client: Socket) {
this.logger.log(`Client connected: ${client.id}`);
}
handleDisconnect(client: Socket) {
this.logger.log(`Client disconnected: ${client.id}`);
}
/**
* Join a room for a specific restaurant (admin/staff)
* Requires admin authentication via JWT token
* Restaurant ID is extracted from the authenticated admin's token
* Room format: `restaurant:${restId}`
*/
@UseGuards(WsAdminAuthGuard)
@SubscribeMessage('join:restaurant')
handleJoinRestaurant(@ConnectedSocket() client: AuthenticatedSocket, @WsRestId() restId: string) {
const room = `restaurant:${restId}`;
void client.join(room);
this.logger.log(`Admin ${client.adminId} (Client ${client.id}) joined room: ${room}`);
void client.emit('joined', { room, message: 'Successfully joined restaurant room' });
}
/**
* Join a room for a specific cookie/session (public user)
* Room format: `cookie:${cookieId}`
*/
@SubscribeMessage('join:session')
handleJoinSession(@ConnectedSocket() client: Socket, @MessageBody() data: { cookieId: string }) {
if (!data.cookieId) {
void client.emit('error', { message: 'Cookie ID is required' });
return;
}
const room = `cookie:${data.cookieId}`;
void client.join(room);
this.logger.log(`Client ${client.id} joined room: ${room}`);
void client.emit('joined', { room, message: 'Successfully joined session room' });
}
/**
* Leave a room
*/
@SubscribeMessage('leave:room')
handleLeaveRoom(@ConnectedSocket() client: Socket, @MessageBody() data: { room: string }) {
if (data.room) {
void client.leave(data.room);
this.logger.log(`Client ${client.id} left room: ${data.room}`);
void client.emit('left', { room: data.room, message: 'Successfully left room' });
}
}
/**
* Emit pager created event to restaurant room
*/
emitPagerCreated(pager: Pager) {
const room = `restaurant:${pager.restaurant.id}`;
this.server.to(room).emit('pager:created', {
pager: {
id: pager.id,
tableNumber: pager.tableNumber,
message: pager.message,
status: pager.status,
createdAt: pager.createdAt,
user: pager.user
? {
id: pager.user.id,
firstName: pager.user.firstName,
lastName: pager.user.lastName,
}
: null,
},
});
this.logger.log(`Emitted pager:created to room: ${room}`);
}
/**
* Emit pager status updated event to both restaurant and cookie rooms
*/
emitPagerStatusUpdated(pager: Pager) {
const restaurantRoom = `restaurant:${pager.restaurant.id}`;
const cookieRoom = `cookie:${pager.cookieId}`;
const pagerData = {
id: pager.id,
tableNumber: pager.tableNumber,
message: pager.message,
status: pager.status,
updatedAt: pager.updatedAt,
staff: pager.staff
? {
id: pager.staff.id,
firstName: pager.staff.firstName,
lastName: pager.staff.lastName,
}
: null,
};
// Emit to restaurant room (admin/staff)
this.server.to(restaurantRoom).emit('pager:status:updated', {
pager: pagerData,
});
this.logger.log(`Emitted pager:status:updated to room: ${restaurantRoom}`);
// Emit to cookie room (public user)
this.server.to(cookieRoom).emit('pager:status:updated', {
pager: pagerData,
});
this.logger.log(`Emitted pager:status:updated to room: ${cookieRoom}`);
}
}
+3 -1
View File
@@ -5,10 +5,12 @@ import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Pager } from './entities/pager.entity';
import { JwtModule } from '@nestjs/jwt';
import { RolesModule } from '../roles/roles.module';
import { PagerGateway } from './pager.gateway';
@Module({
imports: [MikroOrmModule.forFeature([Pager]), JwtModule, RolesModule],
controllers: [PagerController],
providers: [PagerService],
providers: [PagerService, PagerGateway],
exports: [PagerGateway],
})
export class PagerModule {}
+26 -3
View File
@@ -1,4 +1,4 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException, Inject, forwardRef } from '@nestjs/common';
import { CreatePagerDto } from './dto/create-pager.dto';
import { User } from '../users/entities/user.entity';
import { EntityManager } from '@mikro-orm/postgresql';
@@ -7,10 +7,15 @@ import { Pager } from './entities/pager.entity';
import { PagerStatus } from './interface/pager';
import { ulid } from 'ulid';
import { Admin } from '../admin/entities/admin.entity';
import { PagerGateway } from './pager.gateway';
@Injectable()
export class PagerService {
constructor(private readonly em: EntityManager) {}
constructor(
private readonly em: EntityManager,
@Inject(forwardRef(() => PagerGateway))
private readonly pagerGateway: PagerGateway,
) {}
async create(createPagerDto: CreatePagerDto, params: { restId?: string; userId?: string; slug: string }) {
const { restId, userId, slug } = params;
@@ -47,6 +52,13 @@ export class PagerService {
});
await this.em.persistAndFlush(pager);
// Populate relations before emitting
await this.em.populate(pager, ['restaurant', 'user']);
// Emit socket event for new pager
this.pagerGateway.emitPagerCreated(pager);
return pager;
}
@@ -61,7 +73,11 @@ export class PagerService {
}
async updateStatus(pagerId: string, restId: string, staffId: string, status: PagerStatus) {
const pager = await this.em.findOne(Pager, { id: pagerId, restaurant: { id: restId } });
const pager = await this.em.findOne(
Pager,
{ id: pagerId, restaurant: { id: restId } },
{ populate: ['restaurant'] },
);
if (!pager) {
throw new NotFoundException('Pager not found *');
}
@@ -74,6 +90,13 @@ export class PagerService {
pager.staff = staff;
}
await this.em.persistAndFlush(pager);
// Populate relations before emitting
await this.em.populate(pager, ['restaurant', 'staff', 'user']);
// Emit socket event for status update
this.pagerGateway.emitPagerStatusUpdated(pager);
return pager;
}
}
+494
View File
@@ -0,0 +1,494 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pager Socket.IO Test</title>
<script src="https://cdn.socket.io/4.8.1/socket.io.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
font-size: 28px;
margin-bottom: 10px;
}
.header p {
opacity: 0.9;
}
.content {
padding: 30px;
}
.section {
margin-bottom: 30px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
border-left: 4px solid #667eea;
}
.section h2 {
color: #333;
margin-bottom: 15px;
font-size: 20px;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
color: #555;
font-weight: 500;
}
input, select {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s;
}
input:focus, select:focus {
outline: none;
border-color: #667eea;
}
.button-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
button {
padding: 12px 24px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
flex: 1;
min-width: 120px;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-success {
background: #28a745;
color: white;
}
.btn-success:hover {
background: #218838;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4);
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4);
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
.status {
padding: 15px;
border-radius: 6px;
margin-bottom: 20px;
font-weight: 500;
}
.status.connected {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status.disconnected {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.status.connecting {
background: #fff3cd;
color: #856404;
border: 1px solid #ffeaa7;
}
.events {
max-height: 400px;
overflow-y: auto;
background: #1e1e1e;
color: #d4d4d4;
padding: 15px;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 13px;
}
.event-item {
margin-bottom: 10px;
padding: 10px;
background: #2d2d2d;
border-radius: 4px;
border-left: 3px solid #667eea;
}
.event-time {
color: #858585;
font-size: 11px;
margin-bottom: 5px;
}
.event-type {
color: #4ec9b0;
font-weight: bold;
margin-bottom: 5px;
}
.event-data {
color: #ce9178;
white-space: pre-wrap;
word-break: break-all;
}
.room-badge {
display: inline-block;
padding: 4px 12px;
background: #667eea;
color: white;
border-radius: 12px;
font-size: 12px;
margin-left: 10px;
}
.clear-btn {
margin-top: 10px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔔 Pager Socket.IO Test Client</h1>
<p>Test real-time pager notifications</p>
</div>
<div class="content">
<!-- Connection Section -->
<div class="section">
<h2>Connection</h2>
<div class="form-group">
<label for="serverUrl">Server URL:</label>
<input type="text" id="serverUrl" value="http://localhost:4000" placeholder="http://localhost:4000">
</div>
<div class="form-group">
<label for="authToken">Admin Token (for Restaurant rooms):</label>
<input type="text" id="authToken" placeholder="Bearer token (optional, required for restaurant rooms)">
<small style="color: #666; display: block; margin-top: 5px;">Enter JWT token for admin authentication. Required when joining restaurant rooms.</small>
</div>
<div class="button-group">
<button class="btn-primary" onclick="connect()">Connect</button>
<button class="btn-danger" onclick="disconnect()">Disconnect</button>
</div>
<div id="connectionStatus" class="status disconnected">
Status: Disconnected
</div>
</div>
<!-- Room Management Section -->
<div class="section">
<h2>Room Management</h2>
<div class="form-group">
<label for="userType">User Type:</label>
<select id="userType" onchange="updateRoomInputs()">
<option value="restaurant">Restaurant (Admin/Staff)</option>
<option value="session">Session (Public User)</option>
</select>
</div>
<div class="form-group" id="restaurantGroup">
<label for="restId">Restaurant ID:</label>
<input type="text" id="restId" placeholder="Auto-extracted from token" disabled>
<small style="color: #666; display: block; margin-top: 5px;">Restaurant ID is automatically extracted from your admin token. No need to enter manually.</small>
</div>
<div class="form-group" id="sessionGroup" style="display: none;">
<label for="cookieId">Cookie ID:</label>
<input type="text" id="cookieId" placeholder="Enter cookie ID (from pager cookie)">
</div>
<div class="button-group">
<button class="btn-success" onclick="joinRoom()">Join Room</button>
<button class="btn-secondary" onclick="leaveRoom()">Leave Room</button>
</div>
<div id="roomStatus" style="margin-top: 15px;">
<strong>Current Room:</strong> <span id="currentRoom">None</span>
</div>
</div>
<!-- Events Section -->
<div class="section">
<h2>Events Log <button class="btn-secondary clear-btn" onclick="clearEvents()">Clear</button></h2>
<div id="events" class="events">
<div class="event-item">
<div class="event-time">Ready to connect...</div>
<div class="event-type">INFO</div>
<div class="event-data">Enter server URL and click Connect to start</div>
</div>
</div>
</div>
</div>
</div>
<script>
let socket = null;
let currentRoom = null;
function updateRoomInputs() {
const userType = document.getElementById('userType').value;
const restaurantGroup = document.getElementById('restaurantGroup');
const sessionGroup = document.getElementById('sessionGroup');
if (userType === 'restaurant') {
restaurantGroup.style.display = 'block';
sessionGroup.style.display = 'none';
} else {
restaurantGroup.style.display = 'none';
sessionGroup.style.display = 'block';
}
}
function connect() {
const serverUrl = document.getElementById('serverUrl').value || 'http://localhost:4000';
const token = document.getElementById('authToken').value.trim();
if (socket && socket.connected) {
addEvent('WARNING', 'Already connected!');
return;
}
updateStatus('connecting', 'Connecting...');
addEvent('INFO', `Connecting to ${serverUrl}/pager...`);
const authOptions = {};
if (token) {
// Remove "Bearer " prefix if present
const cleanToken = token.startsWith('Bearer ') ? token.substring(7) : token;
authOptions.token = cleanToken;
addEvent('INFO', 'Using authentication token');
}
socket = io(`${serverUrl}/pager`, {
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 5,
auth: authOptions
});
socket.on('connect', () => {
updateStatus('connected', 'Connected');
addEvent('SUCCESS', `Connected! Socket ID: ${socket.id}`);
});
socket.on('disconnect', (reason) => {
updateStatus('disconnected', 'Disconnected');
addEvent('WARNING', `Disconnected: ${reason}`);
currentRoom = null;
document.getElementById('currentRoom').textContent = 'None';
});
socket.on('connect_error', (error) => {
updateStatus('disconnected', 'Connection Failed');
addEvent('ERROR', `Connection error: ${error.message}`);
});
socket.on('joined', (data) => {
addEvent('SUCCESS', `Joined room: ${data.room}`, data);
currentRoom = data.room;
document.getElementById('currentRoom').textContent = data.room;
// If it's a restaurant room, extract and display the restId
if (data.room && data.room.startsWith('restaurant:')) {
const restId = data.room.replace('restaurant:', '');
document.getElementById('restId').value = restId;
}
});
socket.on('left', (data) => {
addEvent('INFO', `Left room: ${data.room}`, data);
if (currentRoom === data.room) {
currentRoom = null;
document.getElementById('currentRoom').textContent = 'None';
}
});
socket.on('error', (data) => {
addEvent('ERROR', data.message || 'Error occurred', data);
});
// Pager events
socket.on('pager:created', (data) => {
addEvent('PAGER_CREATED', 'New pager request created!', data);
});
socket.on('pager:status:updated', (data) => {
addEvent('PAGER_STATUS_UPDATED', 'Pager status updated!', data);
});
// Handle authentication errors
socket.on('exception', (data) => {
addEvent('ERROR', `Server error: ${data.message || JSON.stringify(data)}`, data);
});
}
function disconnect() {
if (socket) {
socket.disconnect();
socket = null;
updateStatus('disconnected', 'Disconnected');
addEvent('INFO', 'Manually disconnected');
currentRoom = null;
document.getElementById('currentRoom').textContent = 'None';
}
}
function joinRoom() {
if (!socket || !socket.connected) {
addEvent('ERROR', 'Please connect first!');
return;
}
const userType = document.getElementById('userType').value;
if (userType === 'restaurant') {
const token = document.getElementById('authToken').value.trim();
if (!token) {
addEvent('ERROR', 'Admin token is required for restaurant rooms. Please enter your JWT token in the Connection section.');
return;
}
// Restaurant ID is extracted from token automatically, no need to send it
socket.emit('join:restaurant', {});
addEvent('INFO', 'Joining restaurant room (restId will be extracted from token)...');
} else {
const cookieId = document.getElementById('cookieId').value;
if (!cookieId) {
addEvent('ERROR', 'Please enter Cookie ID');
return;
}
socket.emit('join:session', { cookieId });
addEvent('INFO', `Joining session room: cookie:${cookieId}`);
}
}
function leaveRoom() {
if (!socket || !socket.connected) {
addEvent('ERROR', 'Please connect first!');
return;
}
if (!currentRoom) {
addEvent('WARNING', 'Not in any room');
return;
}
socket.emit('leave:room', { room: currentRoom });
}
function updateStatus(status, message) {
const statusEl = document.getElementById('connectionStatus');
statusEl.className = `status ${status}`;
statusEl.textContent = `Status: ${message}`;
}
function addEvent(type, message, data = null) {
const eventsDiv = document.getElementById('events');
const eventItem = document.createElement('div');
eventItem.className = 'event-item';
const time = new Date().toLocaleTimeString();
const typeColors = {
'INFO': '#4ec9b0',
'SUCCESS': '#4ec9b0',
'WARNING': '#dcdcaa',
'ERROR': '#f48771',
'PAGER_CREATED': '#4fc1ff',
'PAGER_STATUS_UPDATED': '#4fc1ff'
};
eventItem.innerHTML = `
<div class="event-time">${time}</div>
<div class="event-type" style="color: ${typeColors[type] || '#4ec9b0'}">[${type}]</div>
<div class="event-data">${message}</div>
${data ? `<div class="event-data" style="margin-top: 8px; color: #9cdcfe;">${JSON.stringify(data, null, 2)}</div>` : ''}
`;
eventsDiv.insertBefore(eventItem, eventsDiv.firstChild);
}
function clearEvents() {
document.getElementById('events').innerHTML = '';
addEvent('INFO', 'Events log cleared');
}
// Initialize
updateRoomInputs();
</script>
</body>
</html>