diff --git a/PAGER_SOCKET_TEST.md b/PAGER_SOCKET_TEST.md new file mode 100644 index 0000000..eaa1b0d --- /dev/null +++ b/PAGER_SOCKET_TEST.md @@ -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 + diff --git a/package-lock.json b/package-lock.json index cb27486..1995b66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 241e33f..1355e8e 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/modules/pager/decorators/ws-admin-id.decorator.ts b/src/modules/pager/decorators/ws-admin-id.decorator.ts new file mode 100644 index 0000000..f744959 --- /dev/null +++ b/src/modules/pager/decorators/ws-admin-id.decorator.ts @@ -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(); + const adminId = client.adminId; + + if (!adminId) { + throw new Error('Admin ID not found. Ensure WsAdminAuthGuard is applied.'); + } + + return adminId; +}); + diff --git a/src/modules/pager/decorators/ws-rest-id.decorator.ts b/src/modules/pager/decorators/ws-rest-id.decorator.ts new file mode 100644 index 0000000..a9e3e21 --- /dev/null +++ b/src/modules/pager/decorators/ws-rest-id.decorator.ts @@ -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(); + const restId = client.restId; + + if (!restId) { + throw new Error('Restaurant ID not found. Ensure WsAdminAuthGuard is applied.'); + } + + return restId; +}); + diff --git a/src/modules/pager/guards/ws-admin-auth.guard.ts b/src/modules/pager/guards/ws-admin-auth.guard.ts new file mode 100644 index 0000000..2e15939 --- /dev/null +++ b/src/modules/pager/guards/ws-admin-auth.guard.ts @@ -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 { + const client: Socket = context.switchToWs().getClient(); + + 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('JWT_SECRET'); + const payload = await this.jwtService.verifyAsync(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 ", 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; + } +} diff --git a/src/modules/pager/pager.gateway.ts b/src/modules/pager/pager.gateway.ts new file mode 100644 index 0000000..6d5351f --- /dev/null +++ b/src/modules/pager/pager.gateway.ts @@ -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}`); + } +} diff --git a/src/modules/pager/pager.module.ts b/src/modules/pager/pager.module.ts index d8666cc..1cf9f75 100644 --- a/src/modules/pager/pager.module.ts +++ b/src/modules/pager/pager.module.ts @@ -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 {} diff --git a/src/modules/pager/pager.service.ts b/src/modules/pager/pager.service.ts index d070e07..c285c79 100644 --- a/src/modules/pager/pager.service.ts +++ b/src/modules/pager/pager.service.ts @@ -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; } } diff --git a/test-pager-socket.html b/test-pager-socket.html new file mode 100644 index 0000000..4602af1 --- /dev/null +++ b/test-pager-socket.html @@ -0,0 +1,494 @@ + + + + + + Pager Socket.IO Test + + + + +
+
+

🔔 Pager Socket.IO Test Client

+

Test real-time pager notifications

+
+ +
+ +
+

Connection

+
+ + +
+
+ + + Enter JWT token for admin authentication. Required when joining restaurant rooms. +
+
+ + +
+
+ Status: Disconnected +
+
+ + +
+

Room Management

+
+ + +
+
+ + + Restaurant ID is automatically extracted from your admin token. No need to enter manually. +
+ +
+ + +
+
+ Current Room: None +
+
+ + +
+

Events Log

+
+
+
Ready to connect...
+
INFO
+
Enter server URL and click Connect to start
+
+
+
+
+
+ + + + +