From 75cb4b16e74562e4e0e52f7be886d5a2552d1cd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Gierwia=C5=82o?= Date: Wed, 12 Nov 2025 22:42:15 +0100 Subject: [PATCH] feat: implement real-time chat with Socket.IO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented WebSocket-based real-time messaging for both event rooms and private match chats using Socket.IO with comprehensive test coverage. Backend changes: - Installed socket.io@4.8.1 for WebSocket server - Created Socket.IO server with JWT authentication middleware - Implemented event room management (join/leave/messages) - Added active users tracking with real-time updates - Implemented private match room messaging - Integrated Socket.IO with Express HTTP server - Messages are persisted to PostgreSQL via Prisma - Added 12 comprehensive unit tests (89.13% coverage) Frontend changes: - Installed socket.io-client for WebSocket connections - Created socket service layer for connection management - Updated EventChatPage with real-time messaging - Updated MatchChatPage with real-time private chat - Added connection status indicators (● Connected/Disconnected) - Disabled message input when not connected Infrastructure: - Updated nginx config to proxy WebSocket connections at /socket.io - Added Upgrade and Connection headers for WebSocket support - Set long timeouts (7d) for persistent WebSocket connections Key features: - JWT-authenticated socket connections - Room-based architecture for events and matches - Real-time message broadcasting - Active users list with automatic updates - Automatic cleanup on disconnect - Message persistence in database Test coverage: - 12 tests passing (authentication, event rooms, match rooms, disconnect, errors) - Socket.IO module: 89.13% statements, 81.81% branches, 91.66% functions - Overall coverage: 81.19% Phase 1, Step 4 completed. Ready for Phase 2 (Core Features). --- backend/package-lock.json | 311 +++++++++++++++- backend/package.json | 4 +- backend/src/__tests__/socket.test.js | 514 +++++++++++++++++++++++++++ backend/src/server.js | 10 +- backend/src/socket/index.js | 270 ++++++++++++++ frontend/package-lock.json | 140 +++++++- frontend/package.json | 3 +- frontend/src/pages/EventChatPage.jsx | 121 +++++-- frontend/src/pages/MatchChatPage.jsx | 79 +++- frontend/src/services/socket.js | 56 +++ nginx/conf.d/default.conf | 27 +- 11 files changed, 1472 insertions(+), 63 deletions(-) create mode 100644 backend/src/__tests__/socket.test.js create mode 100644 backend/src/socket/index.js create mode 100644 frontend/src/services/socket.js diff --git a/backend/package-lock.json b/backend/package-lock.json index 8b41dd1..e1d9413 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,12 +15,14 @@ "dotenv": "^16.3.1", "express": "^4.18.2", "express-validator": "^7.3.0", - "jsonwebtoken": "^9.0.2" + "jsonwebtoken": "^9.0.2", + "socket.io": "^4.8.1" }, "devDependencies": { "jest": "^29.7.0", "nodemon": "^3.0.2", "prisma": "^5.8.0", + "socket.io-client": "^4.8.1", "supertest": "^6.3.3" } }, @@ -1077,6 +1079,12 @@ "@sinonjs/commons": "^3.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/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1122,6 +1130,15 @@ "@babel/types": "^7.28.2" } }, + "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/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -1163,7 +1180,6 @@ "version": "24.10.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -1417,6 +1433,15 @@ "dev": true, "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.27", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.27.tgz", @@ -2040,6 +2065,106 @@ "node": ">= 0.8" } }, + "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-client": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", + "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io-client/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "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/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "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/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -4520,6 +4645,157 @@ "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-adapter/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/socket.io-client": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", + "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-client/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "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-parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/socket.io/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/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -4834,7 +5110,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -4978,6 +5253,36 @@ "node": "^12.13.0 || ^14.15.0 || >=16.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/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/backend/package.json b/backend/package.json index def3764..fbeef34 100644 --- a/backend/package.json +++ b/backend/package.json @@ -29,12 +29,14 @@ "dotenv": "^16.3.1", "express": "^4.18.2", "express-validator": "^7.3.0", - "jsonwebtoken": "^9.0.2" + "jsonwebtoken": "^9.0.2", + "socket.io": "^4.8.1" }, "devDependencies": { "jest": "^29.7.0", "nodemon": "^3.0.2", "prisma": "^5.8.0", + "socket.io-client": "^4.8.1", "supertest": "^6.3.3" }, "jest": { diff --git a/backend/src/__tests__/socket.test.js b/backend/src/__tests__/socket.test.js new file mode 100644 index 0000000..e825352 --- /dev/null +++ b/backend/src/__tests__/socket.test.js @@ -0,0 +1,514 @@ +const http = require('http'); +const { Server } = require('socket.io'); +const Client = require('socket.io-client'); +const { initializeSocket } = require('../socket'); +const { generateToken } = require('../utils/auth'); +const { prisma } = require('../utils/db'); + +describe('Socket.IO Server', () => { + let httpServer; + let io; + let serverSocket; + let clientSocket; + let testUser; + let testToken; + const port = 3001; + + beforeAll(async () => { + // Create test user + testUser = await prisma.user.create({ + data: { + username: 'sockettest', + email: 'sockettest@example.com', + passwordHash: 'hashedpassword', + avatar: 'https://example.com/avatar.jpg', + }, + }); + + testToken = generateToken({ userId: testUser.id }); + + // Create HTTP server and initialize Socket.IO + httpServer = http.createServer(); + io = initializeSocket(httpServer); + + httpServer.listen(port); + + // Wait for server to be ready + await new Promise((resolve) => { + httpServer.once('listening', resolve); + }); + }); + + afterAll(async () => { + // Cleanup test user + await prisma.user.delete({ + where: { id: testUser.id }, + }); + + // Close server and client + if (clientSocket) clientSocket.close(); + io.close(); + httpServer.close(); + }); + + afterEach(() => { + if (clientSocket && clientSocket.connected) { + clientSocket.close(); + } + }); + + describe('Authentication', () => { + test('should reject connection without token', (done) => { + clientSocket = Client(`http://localhost:${port}`); + + clientSocket.on('connect_error', (error) => { + expect(error.message).toBe('Authentication required'); + done(); + }); + + clientSocket.on('connect', () => { + done(new Error('Should not connect without token')); + }); + }); + + test('should reject connection with invalid token', (done) => { + clientSocket = Client(`http://localhost:${port}`, { + auth: { token: 'invalid-token' }, + }); + + clientSocket.on('connect_error', (error) => { + expect(error.message).toBe('Invalid token'); + done(); + }); + }); + + test('should accept connection with valid token', (done) => { + clientSocket = Client(`http://localhost:${port}`, { + auth: { token: testToken }, + }); + + clientSocket.on('connect', () => { + expect(clientSocket.connected).toBe(true); + done(); + }); + + clientSocket.on('connect_error', (error) => { + done(error); + }); + }); + }); + + describe('Event Rooms', () => { + let testEvent; + let testChatRoom; + + beforeAll(async () => { + // Create test event and chat room + testEvent = await prisma.event.create({ + data: { + name: 'Test Event', + location: 'Test Location', + startDate: new Date('2025-12-01'), + endDate: new Date('2025-12-03'), + }, + }); + + testChatRoom = await prisma.chatRoom.create({ + data: { + eventId: testEvent.id, + type: 'event', + }, + }); + }); + + afterAll(async () => { + // Cleanup test data + await prisma.chatRoom.delete({ + where: { id: testChatRoom.id }, + }); + await prisma.event.delete({ + where: { id: testEvent.id }, + }); + }); + + test('should join event room successfully', (done) => { + clientSocket = Client(`http://localhost:${port}`, { + auth: { token: testToken }, + }); + + clientSocket.on('connect', () => { + clientSocket.emit('join_event_room', { eventId: testEvent.id }); + }); + + clientSocket.on('active_users', (users) => { + expect(Array.isArray(users)).toBe(true); + expect(users.length).toBeGreaterThan(0); + const currentUser = users.find(u => u.userId === testUser.id); + expect(currentUser).toBeDefined(); + expect(currentUser.username).toBe(testUser.username); + done(); + }); + + clientSocket.on('error', (error) => { + done(error); + }); + }); + + test('should receive user_joined notification', (done) => { + const client1 = Client(`http://localhost:${port}`, { + auth: { token: testToken }, + }); + + client1.on('connect', () => { + client1.emit('join_event_room', { eventId: testEvent.id }); + + // Create second user and join the same room + const client2Token = generateToken({ userId: testUser.id }); + const client2 = Client(`http://localhost:${port}`, { + auth: { token: client2Token }, + }); + + client1.on('user_joined', (userData) => { + expect(userData.userId).toBe(testUser.id); + expect(userData.username).toBe(testUser.username); + client1.close(); + client2.close(); + done(); + }); + + client2.on('connect', () => { + client2.emit('join_event_room', { eventId: testEvent.id }); + }); + }); + }); + + test('should send and receive event messages', (done) => { + clientSocket = Client(`http://localhost:${port}`, { + auth: { token: testToken }, + }); + + const messageContent = 'Test message content'; + + clientSocket.on('connect', () => { + clientSocket.emit('join_event_room', { eventId: testEvent.id }); + + clientSocket.on('active_users', () => { + // Wait for active_users, then send message + clientSocket.emit('send_event_message', { + eventId: testEvent.id, + content: messageContent, + }); + }); + }); + + clientSocket.on('event_message', async (message) => { + expect(message.content).toBe(messageContent); + expect(message.userId).toBe(testUser.id); + expect(message.username).toBe(testUser.username); + expect(message.type).toBe('text'); + expect(message.createdAt).toBeDefined(); + + // Verify message was saved to database + const dbMessage = await prisma.message.findUnique({ + where: { id: message.id }, + }); + expect(dbMessage).toBeDefined(); + expect(dbMessage.content).toBe(messageContent); + + // Cleanup + await prisma.message.delete({ where: { id: message.id } }); + + done(); + }); + + clientSocket.on('error', (error) => { + done(error); + }); + }); + + test('should leave event room and update active users', (done) => { + clientSocket = Client(`http://localhost:${port}`, { + auth: { token: testToken }, + }); + + clientSocket.on('connect', () => { + clientSocket.emit('join_event_room', { eventId: testEvent.id }); + + clientSocket.on('active_users', (users) => { + if (users.length > 0) { + // User joined, now leave + clientSocket.emit('leave_event_room', { eventId: testEvent.id }); + setTimeout(() => { + done(); + }, 100); + } + }); + }); + }); + }); + + describe('Match Rooms', () => { + let testUser2; + let testMatch; + let testMatchRoom; + let testEvent; + + beforeAll(async () => { + // Create test event for match + testEvent = await prisma.event.create({ + data: { + name: 'Match Test Event', + location: 'Test Location', + startDate: new Date('2025-12-01'), + endDate: new Date('2025-12-03'), + }, + }); + + // Create second user with timestamp to avoid conflicts + const timestamp = Date.now(); + testUser2 = await prisma.user.create({ + data: { + username: `sockettest2_${timestamp}`, + email: `sockettest2_${timestamp}@example.com`, + passwordHash: 'hashedpassword', + avatar: 'https://example.com/avatar2.jpg', + }, + }); + + // Create match + testMatch = await prisma.match.create({ + data: { + status: 'active', + user1: { connect: { id: testUser.id } }, + user2: { connect: { id: testUser2.id } }, + event: { connect: { id: testEvent.id } }, + }, + }); + + // Create match chat room + testMatchRoom = await prisma.chatRoom.create({ + data: { + type: 'private', + }, + }); + + // Link room to match + await prisma.match.update({ + where: { id: testMatch.id }, + data: { roomId: testMatchRoom.id }, + }); + }); + + afterAll(async () => { + // Cleanup + await prisma.match.delete({ + where: { id: testMatch.id }, + }); + await prisma.chatRoom.delete({ + where: { id: testMatchRoom.id }, + }); + await prisma.user.delete({ + where: { id: testUser2.id }, + }); + await prisma.event.delete({ + where: { id: testEvent.id }, + }); + }); + + test('should join match room successfully', (done) => { + clientSocket = Client(`http://localhost:${port}`, { + auth: { token: testToken }, + }); + + clientSocket.on('connect', () => { + clientSocket.emit('join_match_room', { matchId: testMatch.id }); + // Just verify no error occurs + setTimeout(() => { + expect(clientSocket.connected).toBe(true); + done(); + }, 100); + }); + + clientSocket.on('error', (error) => { + done(error); + }); + }); + + test('should send and receive match messages', (done) => { + clientSocket = Client(`http://localhost:${port}`, { + auth: { token: testToken }, + }); + + const messageContent = 'Private match message'; + + clientSocket.on('connect', () => { + clientSocket.emit('join_match_room', { matchId: testMatch.id }); + + setTimeout(() => { + clientSocket.emit('send_match_message', { + matchId: testMatch.id, + content: messageContent, + }); + }, 100); + }); + + clientSocket.on('match_message', async (message) => { + expect(message.content).toBe(messageContent); + expect(message.userId).toBe(testUser.id); + expect(message.username).toBe(testUser.username); + expect(message.type).toBe('text'); + + // Verify message was saved + const dbMessage = await prisma.message.findUnique({ + where: { id: message.id }, + }); + expect(dbMessage).toBeDefined(); + expect(dbMessage.roomId).toBe(testMatchRoom.id); + + // Cleanup + await prisma.message.delete({ where: { id: message.id } }); + + done(); + }); + + clientSocket.on('error', (error) => { + done(error); + }); + }); + + test('should handle match room not found error', (done) => { + clientSocket = Client(`http://localhost:${port}`, { + auth: { token: testToken }, + }); + + clientSocket.on('connect', () => { + clientSocket.emit('join_match_room', { matchId: 99999 }); + + setTimeout(() => { + clientSocket.emit('send_match_message', { + matchId: 99999, + content: 'Test', + }); + }, 100); + }); + + clientSocket.on('error', (error) => { + expect(error.message).toBe('Match room not found'); + done(); + }); + }); + }); + + describe('Disconnect Handling', () => { + let testEvent; + let testChatRoom; + + beforeAll(async () => { + testEvent = await prisma.event.create({ + data: { + name: 'Disconnect Test Event', + location: 'Test Location', + startDate: new Date('2025-12-01'), + endDate: new Date('2025-12-03'), + }, + }); + + testChatRoom = await prisma.chatRoom.create({ + data: { + eventId: testEvent.id, + type: 'event', + }, + }); + }); + + afterAll(async () => { + await prisma.chatRoom.delete({ + where: { id: testChatRoom.id }, + }); + await prisma.event.delete({ + where: { id: testEvent.id }, + }); + }); + + test('should handle disconnect and update active users', (done) => { + const client1 = Client(`http://localhost:${port}`, { + auth: { token: testToken }, + }); + + const client2Token = generateToken({ userId: testUser.id }); + const client2 = Client(`http://localhost:${port}`, { + auth: { token: client2Token }, + }); + + let client1Connected = false; + let client2Connected = false; + + client1.on('connect', () => { + client1Connected = true; + client1.emit('join_event_room', { eventId: testEvent.id }); + }); + + client2.on('connect', () => { + client2Connected = true; + client2.emit('join_event_room', { eventId: testEvent.id }); + }); + + client2.on('user_left', (userData) => { + expect(userData.userId).toBe(testUser.id); + expect(userData.username).toBe(testUser.username); + client2.close(); + done(); + }); + + // Wait for both to join, then disconnect client1 + setTimeout(() => { + if (client1Connected && client2Connected) { + client1.close(); + } + }, 500); + }); + }); + + describe('Error Handling', () => { + let testEvent; + + beforeAll(async () => { + testEvent = await prisma.event.create({ + data: { + name: 'Error Test Event', + location: 'Test Location', + startDate: new Date('2025-12-01'), + endDate: new Date('2025-12-03'), + }, + }); + }); + + afterAll(async () => { + await prisma.event.delete({ + where: { id: testEvent.id }, + }); + }); + + test('should handle chat room not found error', (done) => { + clientSocket = Client(`http://localhost:${port}`, { + auth: { token: testToken }, + }); + + clientSocket.on('connect', () => { + clientSocket.emit('join_event_room', { eventId: testEvent.id }); + + setTimeout(() => { + clientSocket.emit('send_event_message', { + eventId: testEvent.id, + content: 'Test message', + }); + }, 100); + }); + + clientSocket.on('error', (error) => { + expect(error.message).toBe('Chat room not found'); + done(); + }); + }); + }); +}); diff --git a/backend/src/server.js b/backend/src/server.js index 075260f..34f6923 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -1,6 +1,8 @@ require('dotenv').config(); +const http = require('http'); const app = require('./app'); const { testConnection, disconnect } = require('./utils/db'); +const { initializeSocket } = require('./socket'); const PORT = process.env.PORT || 3000; @@ -8,7 +10,13 @@ async function startServer() { // Test database connection await testConnection(); - const server = app.listen(PORT, '0.0.0.0', () => { + // Create HTTP server + const server = http.createServer(app); + + // Initialize Socket.IO + initializeSocket(server); + + server.listen(PORT, '0.0.0.0', () => { console.log('================================='); console.log('πŸš€ spotlight.cam Backend Started'); console.log('================================='); diff --git a/backend/src/socket/index.js b/backend/src/socket/index.js new file mode 100644 index 0000000..e553bfb --- /dev/null +++ b/backend/src/socket/index.js @@ -0,0 +1,270 @@ +const { Server } = require('socket.io'); +const { verifyToken } = require('../utils/auth'); +const { prisma } = require('../utils/db'); + +// Track active users in each event room +const activeUsers = new Map(); // eventId -> Set of { socketId, userId, username, avatar } + +function initializeSocket(httpServer) { + const io = new Server(httpServer, { + cors: { + origin: process.env.CORS_ORIGIN || 'http://localhost:8080', + credentials: true, + }, + }); + + // Authentication middleware for Socket.IO + io.use(async (socket, next) => { + try { + const token = socket.handshake.auth.token; + + if (!token) { + return next(new Error('Authentication required')); + } + + const decoded = verifyToken(token); + if (!decoded) { + return next(new Error('Invalid token')); + } + + // Get user from database + const user = await prisma.user.findUnique({ + where: { id: decoded.userId }, + select: { + id: true, + username: true, + email: true, + avatar: true, + }, + }); + + if (!user) { + return next(new Error('User not found')); + } + + socket.user = user; + next(); + } catch (error) { + console.error('Socket auth error:', error); + next(new Error('Authentication failed')); + } + }); + + io.on('connection', (socket) => { + console.log(`βœ… User connected: ${socket.user.username} (${socket.id})`); + + // Join event room + socket.on('join_event_room', async ({ eventId }) => { + try { + const roomName = `event_${eventId}`; + socket.join(roomName); + socket.currentEventRoom = roomName; + socket.currentEventId = eventId; + + // Add user to active users + if (!activeUsers.has(eventId)) { + activeUsers.set(eventId, new Set()); + } + + const userInfo = { + socketId: socket.id, + userId: socket.user.id, + username: socket.user.username, + avatar: socket.user.avatar, + }; + + activeUsers.get(eventId).add(JSON.stringify(userInfo)); + + console.log(`πŸ‘€ ${socket.user.username} joined event room ${eventId}`); + + // Broadcast updated active users list + const users = Array.from(activeUsers.get(eventId)).map(u => JSON.parse(u)); + io.to(roomName).emit('active_users', users); + + // Notify room about new user + socket.to(roomName).emit('user_joined', { + userId: socket.user.id, + username: socket.user.username, + avatar: socket.user.avatar, + }); + } catch (error) { + console.error('Join event room error:', error); + socket.emit('error', { message: 'Failed to join room' }); + } + }); + + // Leave event room + socket.on('leave_event_room', ({ eventId }) => { + const roomName = `event_${eventId}`; + socket.leave(roomName); + + // Remove from active users + if (activeUsers.has(eventId)) { + const users = activeUsers.get(eventId); + const userInfo = JSON.stringify({ + socketId: socket.id, + userId: socket.user.id, + username: socket.user.username, + avatar: socket.user.avatar, + }); + users.delete(userInfo); + + // Broadcast updated list + const updatedUsers = Array.from(users).map(u => JSON.parse(u)); + io.to(roomName).emit('active_users', updatedUsers); + } + + console.log(`πŸ‘€ ${socket.user.username} left event room ${eventId}`); + }); + + // Send message to event room + socket.on('send_event_message', async ({ eventId, content }) => { + try { + const roomName = `event_${eventId}`; + + // Save message to database + const chatRoom = await prisma.chatRoom.findFirst({ + where: { + eventId: parseInt(eventId), + type: 'event', + }, + }); + + if (!chatRoom) { + return socket.emit('error', { message: 'Chat room not found' }); + } + + const message = await prisma.message.create({ + data: { + roomId: chatRoom.id, + userId: socket.user.id, + content, + type: 'text', + }, + include: { + user: { + select: { + id: true, + username: true, + avatar: true, + }, + }, + }, + }); + + // Broadcast message to room + io.to(roomName).emit('event_message', { + id: message.id, + roomId: message.roomId, + userId: message.user.id, + username: message.user.username, + avatar: message.user.avatar, + content: message.content, + type: message.type, + createdAt: message.createdAt, + }); + + console.log(`πŸ’¬ Message in event ${eventId} from ${socket.user.username}`); + } catch (error) { + console.error('Send message error:', error); + socket.emit('error', { message: 'Failed to send message' }); + } + }); + + // Join private match room + socket.on('join_match_room', ({ matchId }) => { + const roomName = `match_${matchId}`; + socket.join(roomName); + socket.currentMatchRoom = roomName; + console.log(`πŸ‘₯ ${socket.user.username} joined match room ${matchId}`); + }); + + // Send message to match room + socket.on('send_match_message', async ({ matchId, content }) => { + try { + const roomName = `match_${matchId}`; + + // Get match and its room + const match = await prisma.match.findUnique({ + where: { id: parseInt(matchId) }, + include: { room: true }, + }); + + if (!match || !match.room) { + return socket.emit('error', { message: 'Match room not found' }); + } + + // Save message + const message = await prisma.message.create({ + data: { + roomId: match.room.id, + userId: socket.user.id, + content, + type: 'text', + }, + include: { + user: { + select: { + id: true, + username: true, + avatar: true, + }, + }, + }, + }); + + // Broadcast to match room + io.to(roomName).emit('match_message', { + id: message.id, + roomId: message.roomId, + userId: message.user.id, + username: message.user.username, + avatar: message.user.avatar, + content: message.content, + type: message.type, + createdAt: message.createdAt, + }); + + console.log(`πŸ’¬ Private message in match ${matchId} from ${socket.user.username}`); + } catch (error) { + console.error('Send match message error:', error); + socket.emit('error', { message: 'Failed to send message' }); + } + }); + + // Handle disconnection + socket.on('disconnect', () => { + console.log(`❌ User disconnected: ${socket.user.username} (${socket.id})`); + + // Remove from active users in all event rooms + if (socket.currentEventId) { + const eventId = socket.currentEventId; + if (activeUsers.has(eventId)) { + const users = activeUsers.get(eventId); + const userInfo = JSON.stringify({ + socketId: socket.id, + userId: socket.user.id, + username: socket.user.username, + avatar: socket.user.avatar, + }); + users.delete(userInfo); + + // Broadcast updated list + const updatedUsers = Array.from(users).map(u => JSON.parse(u)); + io.to(socket.currentEventRoom).emit('active_users', updatedUsers); + + // Notify about user leaving + socket.to(socket.currentEventRoom).emit('user_left', { + userId: socket.user.id, + username: socket.user.username, + }); + } + } + }); + }); + + console.log('πŸ”Œ Socket.IO initialized'); + return io; +} + +module.exports = { initializeSocket }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8ecf48c..435f7c3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,7 +11,8 @@ "lucide-react": "^0.553.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-router-dom": "^7.9.5" + "react-router-dom": "^7.9.5", + "socket.io-client": "^4.8.1" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -1406,6 +1407,12 @@ "win32" ] }, + "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/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1993,6 +2000,45 @@ "dev": true, "license": "MIT" }, + "node_modules/engine.io-client": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", + "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/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-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/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -2923,7 +2969,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -3647,6 +3692,68 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/socket.io-client": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", + "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-client/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/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4178,6 +4285,35 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "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/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index b7d4918..c6bf5e8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,8 @@ "lucide-react": "^0.553.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-router-dom": "^7.9.5" + "react-router-dom": "^7.9.5", + "socket.io-client": "^4.8.1" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/frontend/src/pages/EventChatPage.jsx b/frontend/src/pages/EventChatPage.jsx index 7bce183..0c6f6e6 100644 --- a/frontend/src/pages/EventChatPage.jsx +++ b/frontend/src/pages/EventChatPage.jsx @@ -3,17 +3,17 @@ import { useParams, useNavigate } from 'react-router-dom'; import Layout from '../components/layout/Layout'; import { useAuth } from '../contexts/AuthContext'; import { mockEvents } from '../mocks/events'; -import { mockEventMessages } from '../mocks/messages'; -import { mockUsers } from '../mocks/users'; import { Send, UserPlus } from 'lucide-react'; +import { connectSocket, disconnectSocket, getSocket } from '../services/socket'; const EventChatPage = () => { const { eventId } = useParams(); const { user } = useAuth(); const navigate = useNavigate(); - const [messages, setMessages] = useState(mockEventMessages); + const [messages, setMessages] = useState([]); const [newMessage, setNewMessage] = useState(''); - const [activeUsers, setActiveUsers] = useState(mockUsers.slice(1, 5)); + const [activeUsers, setActiveUsers] = useState([]); + const [isConnected, setIsConnected] = useState(false); const messagesEndRef = useRef(null); const event = mockEvents.find(e => e.id === parseInt(eventId)); @@ -26,29 +26,86 @@ const EventChatPage = () => { scrollToBottom(); }, [messages]); + useEffect(() => { + // Connect to Socket.IO + const socket = connectSocket(); + + if (!socket) { + console.error('Failed to connect to socket'); + return; + } + + // Socket event listeners + socket.on('connect', () => { + setIsConnected(true); + // Join event room + socket.emit('join_event_room', { eventId: parseInt(eventId) }); + }); + + socket.on('disconnect', () => { + setIsConnected(false); + }); + + // Receive messages + socket.on('event_message', (message) => { + setMessages((prev) => [...prev, message]); + }); + + // Receive active users list + socket.on('active_users', (users) => { + // Filter out duplicates and current user + const uniqueUsers = users + .filter((u, index, self) => + index === self.findIndex((t) => t.userId === u.userId) + ) + .filter((u) => u.userId !== user.id); + setActiveUsers(uniqueUsers); + }); + + // User joined notification + socket.on('user_joined', (userData) => { + console.log(`${userData.username} joined the room`); + }); + + // User left notification + socket.on('user_left', (userData) => { + console.log(`${userData.username} left the room`); + }); + + // Cleanup + return () => { + socket.emit('leave_event_room', { eventId: parseInt(eventId) }); + socket.off('connect'); + socket.off('disconnect'); + socket.off('event_message'); + socket.off('active_users'); + socket.off('user_joined'); + socket.off('user_left'); + }; + }, [eventId, user.id]); + const handleSendMessage = (e) => { e.preventDefault(); if (!newMessage.trim()) return; - const message = { - id: messages.length + 1, - room_id: parseInt(eventId), - user_id: user.id, - username: user.username, - avatar: user.avatar, - content: newMessage, - type: 'text', - created_at: new Date().toISOString(), - }; + const socket = getSocket(); + if (!socket || !socket.connected) { + alert('Not connected to chat server'); + return; + } + + // Send message via Socket.IO + socket.emit('send_event_message', { + eventId: parseInt(eventId), + content: newMessage, + }); - setMessages([...messages, message]); setNewMessage(''); }; const handleMatchWith = (userId) => { - // Mockup - in the future will be WebSocket request + // TODO: Implement match request alert(`Match request sent to user!`); - // Simulate acceptance after 1 second setTimeout(() => { navigate(`/matches/1/chat`); }, 1000); @@ -70,6 +127,11 @@ const EventChatPage = () => {

{event.name}

{event.location}

+
+ + {isConnected ? '● Connected' : '● Disconnected'} + +
@@ -78,10 +140,13 @@ const EventChatPage = () => {

Active users ({activeUsers.length})

+ {activeUsers.length === 0 && ( +

No other users online

+ )}
{activeUsers.map((activeUser) => (
@@ -94,13 +159,10 @@ const EventChatPage = () => {

{activeUser.username}

-

- ⭐ {activeUser.rating} -

diff --git a/frontend/src/pages/MatchChatPage.jsx b/frontend/src/pages/MatchChatPage.jsx index a8aef0a..4eb238d 100644 --- a/frontend/src/pages/MatchChatPage.jsx +++ b/frontend/src/pages/MatchChatPage.jsx @@ -2,15 +2,15 @@ import { useState, useRef, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import Layout from '../components/layout/Layout'; import { useAuth } from '../contexts/AuthContext'; -import { mockPrivateMessages } from '../mocks/messages'; import { mockUsers } from '../mocks/users'; import { Send, Video, Upload, X, Check, Link as LinkIcon } from 'lucide-react'; +import { connectSocket, getSocket } from '../services/socket'; const MatchChatPage = () => { const { matchId } = useParams(); const { user } = useAuth(); const navigate = useNavigate(); - const [messages, setMessages] = useState(mockPrivateMessages); + const [messages, setMessages] = useState([]); const [newMessage, setNewMessage] = useState(''); const [selectedFile, setSelectedFile] = useState(null); const [isTransferring, setIsTransferring] = useState(false); @@ -18,10 +18,11 @@ const MatchChatPage = () => { const [webrtcStatus, setWebrtcStatus] = useState('disconnected'); // disconnected, connecting, connected, failed const [showLinkInput, setShowLinkInput] = useState(false); const [videoLink, setVideoLink] = useState(''); + const [isConnected, setIsConnected] = useState(false); const messagesEndRef = useRef(null); const fileInputRef = useRef(null); - // Partner user (mockup) + // Partner user (mockup - TODO: fetch from backend in Phase 2) const partner = mockUsers[1]; // sarah_swing const scrollToBottom = () => { @@ -32,21 +33,56 @@ const MatchChatPage = () => { scrollToBottom(); }, [messages]); + useEffect(() => { + // Connect to Socket.IO + const socket = connectSocket(); + + if (!socket) { + console.error('Failed to connect to socket'); + return; + } + + // Socket event listeners + socket.on('connect', () => { + setIsConnected(true); + // Join match room + socket.emit('join_match_room', { matchId: parseInt(matchId) }); + console.log(`Joined match room ${matchId}`); + }); + + socket.on('disconnect', () => { + setIsConnected(false); + }); + + // Receive messages + socket.on('match_message', (message) => { + setMessages((prev) => [...prev, message]); + }); + + // Cleanup + return () => { + socket.off('connect'); + socket.off('disconnect'); + socket.off('match_message'); + }; + }, [matchId, user.id]); + const handleSendMessage = (e) => { e.preventDefault(); if (!newMessage.trim()) return; - const message = { - id: messages.length + 1, - room_id: 10, - user_id: user.id, - username: user.username, - content: newMessage, - type: 'text', - created_at: new Date().toISOString(), - }; + const socket = getSocket(); + if (!socket || !socket.connected) { + alert('Not connected to chat server'); + return; + } + + // Send message via Socket.IO + socket.emit('send_match_message', { + matchId: parseInt(matchId), + content: newMessage, + }); - setMessages([...messages, message]); setNewMessage(''); }; @@ -204,8 +240,13 @@ const MatchChatPage = () => {
{/* Messages */}
+ {messages.length === 0 && ( +
+ No messages yet. Start the conversation! +
+ )} {messages.map((message) => { - const isOwnMessage = message.user_id === user.id; + const isOwnMessage = message.userId === user.id; return (
{ >
{message.username} @@ -223,7 +264,7 @@ const MatchChatPage = () => { {message.username} - {new Date(message.created_at).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })} + {new Date(message.createdAt).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
{ value={newMessage} onChange={(e) => setNewMessage(e.target.value)} placeholder="Write a message..." - className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-primary-500 focus:border-primary-500" + disabled={!isConnected} + className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50" /> diff --git a/frontend/src/services/socket.js b/frontend/src/services/socket.js new file mode 100644 index 0000000..c8c3adc --- /dev/null +++ b/frontend/src/services/socket.js @@ -0,0 +1,56 @@ +import { io } from 'socket.io-client'; + +const SOCKET_URL = 'http://localhost:8080'; + +let socket = null; + +export function connectSocket() { + const token = localStorage.getItem('token'); + + if (!token) { + console.error('No token found for socket connection'); + return null; + } + + if (socket && socket.connected) { + return socket; + } + + socket = io(SOCKET_URL, { + auth: { + token, + }, + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 1000, + }); + + socket.on('connect', () => { + console.log('βœ… Socket connected:', socket.id); + }); + + socket.on('disconnect', (reason) => { + console.log('❌ Socket disconnected:', reason); + }); + + socket.on('connect_error', (error) => { + console.error('Socket connection error:', error.message); + }); + + socket.on('error', (error) => { + console.error('Socket error:', error); + }); + + return socket; +} + +export function disconnectSocket() { + if (socket) { + socket.disconnect(); + socket = null; + } +} + +export function getSocket() { + return socket; +} diff --git a/nginx/conf.d/default.conf b/nginx/conf.d/default.conf index c92ee44..465ecdf 100644 --- a/nginx/conf.d/default.conf +++ b/nginx/conf.d/default.conf @@ -49,15 +49,20 @@ server { proxy_read_timeout 60s; } - # WebSocket dla Socket.IO (do dodania pΓ³ΕΊniej) - # location /socket.io { - # proxy_pass http://backend; - # proxy_http_version 1.1; - # - # proxy_set_header Upgrade $http_upgrade; - # proxy_set_header Connection "upgrade"; - # proxy_set_header Host $host; - # proxy_set_header X-Real-IP $remote_addr; - # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - # } + # WebSocket for Socket.IO + location /socket.io { + proxy_pass http://backend; + proxy_http_version 1.1; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # Timeouts for WebSocket + proxy_connect_timeout 7d; + proxy_send_timeout 7d; + proxy_read_timeout 7d; + } }