Compare commits
3 Commits
de32b68ec4
...
d8a927ac4e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8a927ac4e | ||
|
|
3442e880d8 | ||
|
|
7a5bff8f2b |
389
backend/package-lock.json
generated
389
backend/package-lock.json
generated
@@ -32,6 +32,7 @@
|
||||
"pg": "^8.16.3",
|
||||
"sequelize": "^6.37.7",
|
||||
"sequelize-cli": "^6.6.3",
|
||||
"socket.io": "^4.8.1",
|
||||
"stripe": "^18.4.0",
|
||||
"uuid": "^11.1.0",
|
||||
"winston": "^3.17.0",
|
||||
@@ -43,6 +44,7 @@
|
||||
"nodemon": "^3.1.10",
|
||||
"sequelize-mock": "^0.10.2",
|
||||
"sinon": "^21.0.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"supertest": "^7.1.4"
|
||||
}
|
||||
},
|
||||
@@ -3530,6 +3532,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/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -3575,6 +3583,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/debug": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||
@@ -3969,6 +3986,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.6",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.6.tgz",
|
||||
@@ -4934,6 +4960,170 @@
|
||||
"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/ws": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
|
||||
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
|
||||
"dev": true,
|
||||
"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/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/engine.io/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/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
@@ -8790,6 +8980,196 @@
|
||||
"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/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/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-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/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
@@ -9841,6 +10221,15 @@
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"pg": "^8.16.3",
|
||||
"sequelize": "^6.37.7",
|
||||
"sequelize-cli": "^6.6.3",
|
||||
"socket.io": "^4.8.1",
|
||||
"stripe": "^18.4.0",
|
||||
"uuid": "^11.1.0",
|
||||
"winston": "^3.17.0",
|
||||
@@ -62,6 +63,7 @@
|
||||
"nodemon": "^3.1.10",
|
||||
"sequelize-mock": "^0.10.2",
|
||||
"sinon": "^21.0.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"supertest": "^7.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ const express = require('express');
|
||||
const { Message, User } = require('../models');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const logger = require('../utils/logger');
|
||||
const { emitNewMessage, emitMessageRead } = require('../sockets/messageSocket');
|
||||
const { Op } = require('sequelize');
|
||||
const emailService = require('../services/emailService');
|
||||
const router = express.Router();
|
||||
|
||||
// Get all messages for the current user (inbox)
|
||||
@@ -37,6 +40,100 @@ router.get('/', authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get conversations grouped by user pairs
|
||||
router.get('/conversations', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
// Fetch all messages where user is sender or receiver
|
||||
const allMessages = await Message.findAll({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ senderId: userId },
|
||||
{ receiverId: userId }
|
||||
]
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'sender',
|
||||
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: 'receiver',
|
||||
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
||||
}
|
||||
],
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
|
||||
// Group messages by conversation partner
|
||||
const conversationsMap = new Map();
|
||||
|
||||
allMessages.forEach(message => {
|
||||
// Determine the conversation partner
|
||||
const partnerId = message.senderId === userId ? message.receiverId : message.senderId;
|
||||
const partner = message.senderId === userId ? message.receiver : message.sender;
|
||||
|
||||
if (!conversationsMap.has(partnerId)) {
|
||||
conversationsMap.set(partnerId, {
|
||||
partnerId,
|
||||
partner: partner ? {
|
||||
id: partner.id,
|
||||
firstName: partner.firstName,
|
||||
lastName: partner.lastName,
|
||||
profileImage: partner.profileImage
|
||||
} : null,
|
||||
lastMessage: null,
|
||||
lastMessageAt: null,
|
||||
unreadCount: 0
|
||||
});
|
||||
}
|
||||
|
||||
const conversation = conversationsMap.get(partnerId);
|
||||
|
||||
// Count unread messages (only those received by current user)
|
||||
if (message.receiverId === userId && !message.isRead) {
|
||||
conversation.unreadCount++;
|
||||
}
|
||||
|
||||
// Keep the most recent message (messages are already sorted DESC)
|
||||
if (!conversation.lastMessage) {
|
||||
conversation.lastMessage = {
|
||||
id: message.id,
|
||||
content: message.content,
|
||||
senderId: message.senderId,
|
||||
createdAt: message.createdAt,
|
||||
isRead: message.isRead
|
||||
};
|
||||
conversation.lastMessageAt = message.createdAt;
|
||||
}
|
||||
});
|
||||
|
||||
// Convert to array and sort by most recent message first
|
||||
const conversations = Array.from(conversationsMap.values())
|
||||
.filter(conv => conv.partner !== null) // Filter out conversations with deleted users
|
||||
.sort((a, b) => new Date(b.lastMessageAt) - new Date(a.lastMessageAt));
|
||||
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Conversations fetched", {
|
||||
userId: req.user.id,
|
||||
conversationCount: conversations.length
|
||||
});
|
||||
|
||||
res.json(conversations);
|
||||
} catch (error) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Conversations fetch failed", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
userId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get sent messages
|
||||
router.get('/sent', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
@@ -109,15 +206,26 @@ router.get('/:id', authenticateToken, async (req, res) => {
|
||||
}
|
||||
|
||||
// Mark as read if user is the receiver
|
||||
if (message.receiverId === req.user.id && !message.isRead) {
|
||||
const wasUnread = message.receiverId === req.user.id && !message.isRead;
|
||||
if (wasUnread) {
|
||||
await message.update({ isRead: true });
|
||||
|
||||
// Emit socket event to sender for real-time read receipt
|
||||
const io = req.app.get('io');
|
||||
if (io) {
|
||||
emitMessageRead(io, message.senderId, {
|
||||
messageId: message.id,
|
||||
readAt: new Date().toISOString(),
|
||||
readBy: req.user.id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Message fetched", {
|
||||
userId: req.user.id,
|
||||
messageId: req.params.id,
|
||||
markedAsRead: message.receiverId === req.user.id && !message.isRead
|
||||
markedAsRead: wasUnread
|
||||
});
|
||||
|
||||
res.json(message);
|
||||
@@ -165,6 +273,29 @@ router.post('/', authenticateToken, async (req, res) => {
|
||||
}]
|
||||
});
|
||||
|
||||
// Emit socket event to receiver for real-time notification
|
||||
const io = req.app.get('io');
|
||||
if (io) {
|
||||
emitNewMessage(io, receiverId, messageWithSender.toJSON());
|
||||
}
|
||||
|
||||
// Send email notification to receiver
|
||||
try {
|
||||
const sender = await User.findByPk(req.user.id, {
|
||||
attributes: ['id', 'firstName', 'lastName', 'email']
|
||||
});
|
||||
|
||||
await emailService.sendNewMessageNotification(receiver, sender, message);
|
||||
} catch (emailError) {
|
||||
// Log email error but don't block the message send
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Failed to send message notification email", {
|
||||
error: emailError.message,
|
||||
messageId: message.id,
|
||||
receiverId: receiverId
|
||||
});
|
||||
}
|
||||
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Message sent", {
|
||||
senderId: req.user.id,
|
||||
@@ -202,6 +333,16 @@ router.put('/:id/read', authenticateToken, async (req, res) => {
|
||||
|
||||
await message.update({ isRead: true });
|
||||
|
||||
// Emit socket event to sender for real-time read receipt
|
||||
const io = req.app.get('io');
|
||||
if (io) {
|
||||
emitMessageRead(io, message.senderId, {
|
||||
messageId: message.id,
|
||||
readAt: new Date().toISOString(),
|
||||
readBy: req.user.id
|
||||
});
|
||||
}
|
||||
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Message marked as read", {
|
||||
userId: req.user.id,
|
||||
|
||||
@@ -6,6 +6,8 @@ require("dotenv").config({
|
||||
path: envFile,
|
||||
});
|
||||
const express = require("express");
|
||||
const http = require("http");
|
||||
const { Server } = require("socket.io");
|
||||
const cors = require("cors");
|
||||
const bodyParser = require("body-parser");
|
||||
const path = require("path");
|
||||
@@ -31,7 +33,30 @@ const PayoutProcessor = require("./jobs/payoutProcessor");
|
||||
const RentalStatusJob = require("./jobs/rentalStatusJob");
|
||||
const ConditionCheckReminderJob = require("./jobs/conditionCheckReminder");
|
||||
|
||||
// Socket.io setup
|
||||
const { authenticateSocket } = require("./sockets/socketAuth");
|
||||
const { initializeMessageSocket } = require("./sockets/messageSocket");
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
|
||||
// Initialize Socket.io with CORS
|
||||
const io = new Server(server, {
|
||||
cors: {
|
||||
origin: process.env.FRONTEND_URL || "http://localhost:3000",
|
||||
credentials: true,
|
||||
methods: ["GET", "POST"],
|
||||
},
|
||||
});
|
||||
|
||||
// Apply socket authentication middleware
|
||||
io.use(authenticateSocket);
|
||||
|
||||
// Initialize message socket handlers
|
||||
initializeMessageSocket(io);
|
||||
|
||||
// Store io instance in app for use in routes
|
||||
app.set("io", io);
|
||||
|
||||
// Import security middleware
|
||||
const {
|
||||
@@ -152,11 +177,12 @@ sequelize
|
||||
const conditionCheckJobs = ConditionCheckReminderJob.startScheduledReminders();
|
||||
logger.info("Condition check reminder job started");
|
||||
|
||||
app.listen(PORT, () => {
|
||||
server.listen(PORT, () => {
|
||||
logger.info(`Server is running on port ${PORT}`, {
|
||||
port: PORT,
|
||||
environment: env,
|
||||
});
|
||||
logger.info("Socket.io server initialized");
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
@@ -54,6 +54,7 @@ class EmailService {
|
||||
"alphaInvitationToUser.html",
|
||||
"feedbackConfirmationToUser.html",
|
||||
"feedbackNotificationToAdmin.html",
|
||||
"newMessageToUser.html",
|
||||
];
|
||||
|
||||
for (const templateFile of templateFiles) {
|
||||
@@ -1849,6 +1850,44 @@ class EmailService {
|
||||
htmlContent
|
||||
);
|
||||
}
|
||||
|
||||
async sendNewMessageNotification(receiver, sender, message) {
|
||||
try {
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const conversationUrl = `${frontendUrl}/messages/conversations/${sender.id}`;
|
||||
|
||||
const timestamp = new Date(message.createdAt).toLocaleString("en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
const variables = {
|
||||
recipientName: receiver.firstName || "there",
|
||||
senderName: `${sender.firstName} ${sender.lastName}`.trim() || "A user",
|
||||
subject: message.subject,
|
||||
messageContent: message.content,
|
||||
conversationUrl: conversationUrl,
|
||||
timestamp: timestamp,
|
||||
};
|
||||
|
||||
const htmlContent = this.renderTemplate("newMessageToUser", variables);
|
||||
|
||||
const subject = `New message from ${sender.firstName} ${sender.lastName}`;
|
||||
|
||||
const result = await this.sendEmail(receiver.email, subject, htmlContent);
|
||||
|
||||
if (result.success) {
|
||||
console.log(
|
||||
`Message notification email sent to ${receiver.email} from ${sender.email}`
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Failed to send message notification email:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new EmailService();
|
||||
|
||||
343
backend/sockets/messageSocket.js
Normal file
343
backend/sockets/messageSocket.js
Normal file
@@ -0,0 +1,343 @@
|
||||
const logger = require("../utils/logger");
|
||||
|
||||
/**
|
||||
* Map to track typing status: { userId_receiverId: timestamp }
|
||||
* Used to prevent duplicate typing events and auto-clear stale states
|
||||
*/
|
||||
const typingStatus = new Map();
|
||||
|
||||
/**
|
||||
* Cleanup interval for stale typing indicators (every 5 seconds)
|
||||
*/
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
const TYPING_TIMEOUT = 5000; // 5 seconds
|
||||
|
||||
for (const [key, timestamp] of typingStatus.entries()) {
|
||||
if (now - timestamp > TYPING_TIMEOUT) {
|
||||
typingStatus.delete(key);
|
||||
}
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
/**
|
||||
* Generate conversation room ID from two user IDs
|
||||
* Always sorts IDs to ensure consistent room naming regardless of who initiates
|
||||
*/
|
||||
const getConversationRoom = (userId1, userId2) => {
|
||||
const sorted = [userId1, userId2].sort();
|
||||
return `conv_${sorted[0]}_${sorted[1]}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get personal user room ID
|
||||
*/
|
||||
const getUserRoom = (userId) => {
|
||||
return `user_${userId}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize message socket handlers
|
||||
* @param {SocketIO.Server} io - Socket.io server instance
|
||||
*/
|
||||
const initializeMessageSocket = (io) => {
|
||||
io.on("connection", (socket) => {
|
||||
const userId = socket.userId;
|
||||
const userRoom = getUserRoom(userId);
|
||||
|
||||
logger.info("User connected to messaging", {
|
||||
socketId: socket.id,
|
||||
userId,
|
||||
userEmail: socket.user.email,
|
||||
});
|
||||
|
||||
// Join user's personal room for receiving direct messages
|
||||
socket.join(userRoom);
|
||||
logger.debug("User joined personal room", {
|
||||
socketId: socket.id,
|
||||
userId,
|
||||
room: userRoom,
|
||||
});
|
||||
|
||||
/**
|
||||
* Join a specific conversation room
|
||||
* Used when user opens a chat with another user
|
||||
*/
|
||||
socket.on("join_conversation", (data) => {
|
||||
try {
|
||||
const { otherUserId } = data;
|
||||
|
||||
if (!otherUserId) {
|
||||
logger.warn("join_conversation - missing otherUserId", {
|
||||
socketId: socket.id,
|
||||
userId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const conversationRoom = getConversationRoom(userId, otherUserId);
|
||||
socket.join(conversationRoom);
|
||||
|
||||
logger.debug("User joined conversation room", {
|
||||
socketId: socket.id,
|
||||
userId,
|
||||
otherUserId,
|
||||
room: conversationRoom,
|
||||
});
|
||||
|
||||
socket.emit("conversation_joined", {
|
||||
conversationRoom,
|
||||
otherUserId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error joining conversation", {
|
||||
socketId: socket.id,
|
||||
userId,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Leave a specific conversation room
|
||||
* Used when user closes a chat
|
||||
*/
|
||||
socket.on("leave_conversation", (data) => {
|
||||
try {
|
||||
const { otherUserId } = data;
|
||||
|
||||
if (!otherUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const conversationRoom = getConversationRoom(userId, otherUserId);
|
||||
socket.leave(conversationRoom);
|
||||
|
||||
logger.debug("User left conversation room", {
|
||||
socketId: socket.id,
|
||||
userId,
|
||||
otherUserId,
|
||||
room: conversationRoom,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error leaving conversation", {
|
||||
socketId: socket.id,
|
||||
userId,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Typing start indicator
|
||||
* Notifies the recipient that this user is typing
|
||||
*/
|
||||
socket.on("typing_start", (data) => {
|
||||
try {
|
||||
const { receiverId } = data;
|
||||
|
||||
if (!receiverId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Throttle typing events (prevent spam)
|
||||
const typingKey = `${userId}_${receiverId}`;
|
||||
const lastTyping = typingStatus.get(typingKey);
|
||||
const now = Date.now();
|
||||
|
||||
if (lastTyping && now - lastTyping < 1000) {
|
||||
// Ignore if typed within last 1 second
|
||||
return;
|
||||
}
|
||||
|
||||
typingStatus.set(typingKey, now);
|
||||
|
||||
// Emit to recipient's personal room
|
||||
const receiverRoom = getUserRoom(receiverId);
|
||||
io.to(receiverRoom).emit("user_typing", {
|
||||
userId,
|
||||
firstName: socket.user.firstName,
|
||||
isTyping: true,
|
||||
});
|
||||
|
||||
logger.debug("Typing indicator sent", {
|
||||
socketId: socket.id,
|
||||
senderId: userId,
|
||||
receiverId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error handling typing_start", {
|
||||
socketId: socket.id,
|
||||
userId,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Typing stop indicator
|
||||
* Notifies the recipient that this user stopped typing
|
||||
*/
|
||||
socket.on("typing_stop", (data) => {
|
||||
try {
|
||||
const { receiverId } = data;
|
||||
|
||||
if (!receiverId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear typing status
|
||||
const typingKey = `${userId}_${receiverId}`;
|
||||
typingStatus.delete(typingKey);
|
||||
|
||||
// Emit to recipient's personal room
|
||||
const receiverRoom = getUserRoom(receiverId);
|
||||
io.to(receiverRoom).emit("user_typing", {
|
||||
userId,
|
||||
firstName: socket.user.firstName,
|
||||
isTyping: false,
|
||||
});
|
||||
|
||||
logger.debug("Typing stop sent", {
|
||||
socketId: socket.id,
|
||||
senderId: userId,
|
||||
receiverId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error handling typing_stop", {
|
||||
socketId: socket.id,
|
||||
userId,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Mark message as read (from client)
|
||||
* This is handled by the REST API route, but we listen here for consistency
|
||||
*/
|
||||
socket.on("mark_message_read", (data) => {
|
||||
try {
|
||||
const { messageId, senderId } = data;
|
||||
|
||||
if (!messageId || !senderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Emit to sender's room to update their UI
|
||||
const senderRoom = getUserRoom(senderId);
|
||||
io.to(senderRoom).emit("message_read", {
|
||||
messageId,
|
||||
readAt: new Date().toISOString(),
|
||||
readBy: userId,
|
||||
});
|
||||
|
||||
logger.debug("Message read notification sent", {
|
||||
socketId: socket.id,
|
||||
messageId,
|
||||
readBy: userId,
|
||||
notifiedUserId: senderId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error handling mark_message_read", {
|
||||
socketId: socket.id,
|
||||
userId,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Disconnect handler
|
||||
* Clean up rooms and typing status
|
||||
*/
|
||||
socket.on("disconnect", (reason) => {
|
||||
// Clean up all typing statuses for this user
|
||||
for (const [key] of typingStatus.entries()) {
|
||||
if (key.startsWith(`${userId}_`)) {
|
||||
typingStatus.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("User disconnected from messaging", {
|
||||
socketId: socket.id,
|
||||
userId,
|
||||
reason,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Error handler
|
||||
*/
|
||||
socket.on("error", (error) => {
|
||||
logger.error("Socket error", {
|
||||
socketId: socket.id,
|
||||
userId,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
logger.info("Message socket handlers initialized");
|
||||
};
|
||||
|
||||
/**
|
||||
* Emit new message event to a specific user
|
||||
* Called from message routes when a message is created
|
||||
* @param {SocketIO.Server} io - Socket.io server instance
|
||||
* @param {string} receiverId - User ID to send the message to
|
||||
* @param {Object} messageData - Message object with sender info
|
||||
*/
|
||||
const emitNewMessage = (io, receiverId, messageData) => {
|
||||
try {
|
||||
const receiverRoom = getUserRoom(receiverId);
|
||||
io.to(receiverRoom).emit("new_message", messageData);
|
||||
|
||||
logger.info("New message emitted", {
|
||||
receiverId,
|
||||
receiverRoom,
|
||||
messageId: messageData.id,
|
||||
senderId: messageData.senderId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error emitting new message", {
|
||||
receiverId,
|
||||
messageId: messageData.id,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Emit message read event to sender
|
||||
* Called from message routes when a message is marked as read
|
||||
* @param {SocketIO.Server} io - Socket.io server instance
|
||||
* @param {string} senderId - User ID who sent the message
|
||||
* @param {Object} readData - Read status data
|
||||
*/
|
||||
const emitMessageRead = (io, senderId, readData) => {
|
||||
try {
|
||||
const senderRoom = getUserRoom(senderId);
|
||||
io.to(senderRoom).emit("message_read", readData);
|
||||
|
||||
logger.debug("Message read status emitted", {
|
||||
senderId,
|
||||
messageId: readData.messageId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error emitting message read status", {
|
||||
senderId,
|
||||
messageId: readData.messageId,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
initializeMessageSocket,
|
||||
emitNewMessage,
|
||||
emitMessageRead,
|
||||
getConversationRoom,
|
||||
getUserRoom,
|
||||
};
|
||||
111
backend/sockets/socketAuth.js
Normal file
111
backend/sockets/socketAuth.js
Normal file
@@ -0,0 +1,111 @@
|
||||
const jwt = require("jsonwebtoken");
|
||||
const { User } = require("../models");
|
||||
const logger = require("../utils/logger");
|
||||
const cookie = require("cookie");
|
||||
|
||||
/**
|
||||
* Socket.io authentication middleware
|
||||
* Verifies JWT token and attaches user to socket
|
||||
* Tokens can be provided via:
|
||||
* 1. Cookie (accessToken) - preferred for browser clients
|
||||
* 2. Query parameter (token) - fallback for mobile/other clients
|
||||
*/
|
||||
const authenticateSocket = async (socket, next) => {
|
||||
try {
|
||||
let token = null;
|
||||
|
||||
// Try to get token from cookies first (browser clients)
|
||||
if (socket.handshake.headers.cookie) {
|
||||
const cookies = cookie.parse(socket.handshake.headers.cookie);
|
||||
token = cookies.accessToken;
|
||||
}
|
||||
|
||||
// Fallback to query parameter (mobile/other clients)
|
||||
if (!token && socket.handshake.auth?.token) {
|
||||
token = socket.handshake.auth.token;
|
||||
}
|
||||
|
||||
// Fallback to legacy query parameter
|
||||
if (!token && socket.handshake.query?.token) {
|
||||
token = socket.handshake.query.token;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
logger.warn("Socket connection rejected - no token provided", {
|
||||
socketId: socket.id,
|
||||
address: socket.handshake.address,
|
||||
});
|
||||
return next(new Error("Authentication required"));
|
||||
}
|
||||
|
||||
// Verify JWT
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const userId = decoded.id;
|
||||
|
||||
if (!userId) {
|
||||
logger.warn("Socket connection rejected - invalid token format", {
|
||||
socketId: socket.id,
|
||||
});
|
||||
return next(new Error("Invalid token format"));
|
||||
}
|
||||
|
||||
// Look up user
|
||||
const user = await User.findByPk(userId);
|
||||
|
||||
if (!user) {
|
||||
logger.warn("Socket connection rejected - user not found", {
|
||||
socketId: socket.id,
|
||||
userId,
|
||||
});
|
||||
return next(new Error("User not found"));
|
||||
}
|
||||
|
||||
// Validate JWT version (invalidate old tokens after password change)
|
||||
if (decoded.jwtVersion !== user.jwtVersion) {
|
||||
logger.warn("Socket connection rejected - JWT version mismatch", {
|
||||
socketId: socket.id,
|
||||
userId,
|
||||
tokenVersion: decoded.jwtVersion,
|
||||
userVersion: user.jwtVersion,
|
||||
});
|
||||
return next(
|
||||
new Error("Session expired due to password change. Please log in again.")
|
||||
);
|
||||
}
|
||||
|
||||
// Attach user to socket for use in event handlers
|
||||
socket.userId = user.id;
|
||||
socket.user = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
};
|
||||
|
||||
logger.info("Socket authenticated successfully", {
|
||||
socketId: socket.id,
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
// Check if token is expired
|
||||
if (error.name === "TokenExpiredError") {
|
||||
logger.warn("Socket connection rejected - token expired", {
|
||||
socketId: socket.id,
|
||||
});
|
||||
return next(new Error("Token expired"));
|
||||
}
|
||||
|
||||
logger.error("Socket authentication error", {
|
||||
socketId: socket.id,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
|
||||
return next(new Error("Authentication failed"));
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { authenticateSocket };
|
||||
251
backend/templates/emails/newMessageToUser.html
Normal file
251
backend/templates/emails/newMessageToUser.html
Normal file
@@ -0,0 +1,251 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>New Message from {{senderName}}</title>
|
||||
<style>
|
||||
/* Reset styles */
|
||||
body, table, td, p, a, li, blockquote {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
table, td {
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
img {
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100% !important;
|
||||
min-width: 100%;
|
||||
height: 100%;
|
||||
background-color: #f8f9fa;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||
padding: 40px 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
color: #d4edda;
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.content {
|
||||
padding: 40px 30px;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 20px 0;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.content h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 30px 0 15px 0;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin: 0 0 16px 0;
|
||||
color: #6c757d;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.content strong {
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
/* Button */
|
||||
.button {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
padding: 16px 32px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
margin: 20px 0;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4);
|
||||
}
|
||||
|
||||
/* Info box */
|
||||
.info-box {
|
||||
background-color: #e7f3ff;
|
||||
border-left: 4px solid #0066cc;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
color: #004085;
|
||||
}
|
||||
|
||||
/* Message box */
|
||||
.message-box {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.message-box .subject {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
margin: 0 0 15px 0;
|
||||
}
|
||||
|
||||
.message-box .content-text {
|
||||
color: #212529;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message-box .timestamp {
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
background-color: #f8f9fa;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media only screen and (max-width: 600px) {
|
||||
.email-container {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.header, .content, .footer {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.message-box {
|
||||
padding: 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="header">
|
||||
<div class="logo">RentAll</div>
|
||||
<div class="tagline">New Message</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>Hi {{recipientName}},</p>
|
||||
|
||||
<h1>You have a new message from {{senderName}}</h1>
|
||||
|
||||
<p>{{senderName}} sent you a message on RentAll.</p>
|
||||
|
||||
<div class="message-box">
|
||||
<div class="subject">Subject: {{subject}}</div>
|
||||
<div class="content-text">{{messageContent}}</div>
|
||||
<div class="timestamp">Sent {{timestamp}}</div>
|
||||
</div>
|
||||
|
||||
<a href="{{conversationUrl}}" class="button">View Conversation</a>
|
||||
|
||||
<p>Click the button above to read and reply to this message on RentAll.</p>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>Tip:</strong> Reply quickly to keep your conversations active and build trust within the RentAll community.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p><strong>RentAll</strong></p>
|
||||
<p>You received this email because you have an account on RentAll and someone sent you a message.</p>
|
||||
<p>If you have any questions, please contact our support team.</p>
|
||||
<p>© 2024 RentAll. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
156
backend/tests/unit/sockets/messageSocket.test.js
Normal file
156
backend/tests/unit/sockets/messageSocket.test.js
Normal file
@@ -0,0 +1,156 @@
|
||||
const { Server } = require('socket.io');
|
||||
const Client = require('socket.io-client');
|
||||
const http = require('http');
|
||||
const { initializeMessageSocket, emitNewMessage, emitMessageRead } = require('../../../sockets/messageSocket');
|
||||
|
||||
describe('Message Socket', () => {
|
||||
let io, serverSocket, clientSocket;
|
||||
let httpServer;
|
||||
|
||||
beforeAll((done) => {
|
||||
// Create HTTP server
|
||||
httpServer = http.createServer();
|
||||
|
||||
// Create Socket.io server
|
||||
io = new Server(httpServer);
|
||||
|
||||
httpServer.listen(() => {
|
||||
const port = httpServer.address().port;
|
||||
|
||||
// Initialize message socket handlers
|
||||
initializeMessageSocket(io);
|
||||
|
||||
// Create client socket
|
||||
clientSocket = new Client(`http://localhost:${port}`);
|
||||
|
||||
// Mock authentication by setting userId
|
||||
io.use((socket, next) => {
|
||||
socket.userId = 'test-user-123';
|
||||
socket.user = {
|
||||
id: 'test-user-123',
|
||||
email: 'test@example.com',
|
||||
firstName: 'Test',
|
||||
lastName: 'User'
|
||||
};
|
||||
next();
|
||||
});
|
||||
|
||||
// Wait for connection
|
||||
io.on('connection', (socket) => {
|
||||
serverSocket = socket;
|
||||
});
|
||||
|
||||
clientSocket.on('connect', done);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
io.close();
|
||||
clientSocket.close();
|
||||
httpServer.close();
|
||||
});
|
||||
|
||||
test('should connect successfully', () => {
|
||||
expect(clientSocket.connected).toBe(true);
|
||||
});
|
||||
|
||||
test('should join conversation room', (done) => {
|
||||
const otherUserId = 'other-user-456';
|
||||
|
||||
clientSocket.on('conversation_joined', (data) => {
|
||||
expect(data.otherUserId).toBe(otherUserId);
|
||||
expect(data.conversationRoom).toContain('conv_');
|
||||
done();
|
||||
});
|
||||
|
||||
clientSocket.emit('join_conversation', { otherUserId });
|
||||
});
|
||||
|
||||
test('should emit typing start event', (done) => {
|
||||
const receiverId = 'receiver-789';
|
||||
|
||||
serverSocket.on('typing_start', (data) => {
|
||||
expect(data.receiverId).toBe(receiverId);
|
||||
done();
|
||||
});
|
||||
|
||||
clientSocket.emit('typing_start', { receiverId });
|
||||
});
|
||||
|
||||
test('should emit typing stop event', (done) => {
|
||||
const receiverId = 'receiver-789';
|
||||
|
||||
serverSocket.on('typing_stop', (data) => {
|
||||
expect(data.receiverId).toBe(receiverId);
|
||||
done();
|
||||
});
|
||||
|
||||
clientSocket.emit('typing_stop', { receiverId });
|
||||
});
|
||||
|
||||
test('should emit new message to receiver', (done) => {
|
||||
const receiverId = 'receiver-123';
|
||||
const messageData = {
|
||||
id: 'message-456',
|
||||
senderId: 'sender-789',
|
||||
receiverId: receiverId,
|
||||
subject: 'Test Subject',
|
||||
content: 'Test message content',
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Create a second client to receive the message
|
||||
const port = httpServer.address().port;
|
||||
const receiverClient = new Client(`http://localhost:${port}`);
|
||||
|
||||
receiverClient.on('connect', () => {
|
||||
receiverClient.on('new_message', (message) => {
|
||||
expect(message.id).toBe(messageData.id);
|
||||
expect(message.content).toBe(messageData.content);
|
||||
receiverClient.close();
|
||||
done();
|
||||
});
|
||||
|
||||
// Emit the message
|
||||
emitNewMessage(io, receiverId, messageData);
|
||||
});
|
||||
});
|
||||
|
||||
test('should emit message read status to sender', (done) => {
|
||||
const senderId = 'sender-123';
|
||||
const readData = {
|
||||
messageId: 'message-789',
|
||||
readAt: new Date().toISOString(),
|
||||
readBy: 'reader-456'
|
||||
};
|
||||
|
||||
// Create a sender client to receive the read receipt
|
||||
const port = httpServer.address().port;
|
||||
const senderClient = new Client(`http://localhost:${port}`);
|
||||
|
||||
senderClient.on('connect', () => {
|
||||
senderClient.on('message_read', (data) => {
|
||||
expect(data.messageId).toBe(readData.messageId);
|
||||
expect(data.readBy).toBe(readData.readBy);
|
||||
senderClient.close();
|
||||
done();
|
||||
});
|
||||
|
||||
// Emit the read status
|
||||
emitMessageRead(io, senderId, readData);
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle disconnection gracefully', (done) => {
|
||||
const testClient = new Client(`http://localhost:${httpServer.address().port}`);
|
||||
|
||||
testClient.on('connect', () => {
|
||||
testClient.on('disconnect', (reason) => {
|
||||
expect(reason).toBeTruthy();
|
||||
done();
|
||||
});
|
||||
|
||||
testClient.disconnect();
|
||||
});
|
||||
});
|
||||
});
|
||||
137
frontend/package-lock.json
generated
137
frontend/package-lock.json
generated
@@ -26,6 +26,7 @@
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"stripe": "^18.4.0",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4"
|
||||
@@ -3318,6 +3319,12 @@
|
||||
"@sinonjs/commons": "^1.7.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/@stripe/react-stripe-js": {
|
||||
"version": "3.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.9.2.tgz",
|
||||
@@ -7183,6 +7190,66 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"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-client/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/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/enhanced-resolve": {
|
||||
"version": "5.18.3",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
||||
@@ -15583,6 +15650,68 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"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/sockjs": {
|
||||
"version": "0.3.24",
|
||||
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz",
|
||||
@@ -18103,6 +18232,14 @@
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"stripe": "^18.4.0",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { SocketProvider } from './contexts/SocketContext';
|
||||
import Navbar from './components/Navbar';
|
||||
import Footer from './components/Footer';
|
||||
import AuthModal from './components/AuthModal';
|
||||
@@ -202,10 +203,20 @@ const AppContent: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const AppWithSocket: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<SocketProvider isAuthenticated={!!user}>
|
||||
<AppContent />
|
||||
</SocketProvider>
|
||||
);
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
<AppWithSocket />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,172 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||
import { messageAPI } from '../services/api';
|
||||
import { User, Message } from '../types';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useSocket } from '../contexts/SocketContext';
|
||||
import TypingIndicator from './TypingIndicator';
|
||||
|
||||
interface ChatWindowProps {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
recipient: User;
|
||||
onMessagesRead?: (partnerId: string, count: number) => void;
|
||||
}
|
||||
|
||||
const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) => {
|
||||
const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient, onMessagesRead }) => {
|
||||
const { user: currentUser } = useAuth();
|
||||
const { isConnected, joinConversation, leaveConversation, onNewMessage, onUserTyping, emitTypingStart, emitTypingStop } = useSocket();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isRecipientTyping, setIsRecipientTyping] = useState(false);
|
||||
const [initialUnreadMessageIds, setInitialUnreadMessageIds] = useState<Set<string>>(new Set());
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const [hasScrolledToUnread, setHasScrolledToUnread] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const messageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
setHasScrolledToUnread(false); // Reset flag when opening chat
|
||||
fetchMessages();
|
||||
}
|
||||
}, [show, recipient.id]);
|
||||
|
||||
// Join conversation room when chat opens
|
||||
if (isConnected) {
|
||||
joinConversation(recipient.id);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Leave conversation room when chat closes
|
||||
if (isConnected) {
|
||||
leaveConversation(recipient.id);
|
||||
}
|
||||
};
|
||||
}, [show, recipient.id, isConnected]);
|
||||
|
||||
// Create a stable callback for handling new messages
|
||||
const handleNewMessage = useCallback(async (message: Message) => {
|
||||
console.log('[ChatWindow] Received new_message event:', message);
|
||||
|
||||
// Only add messages that are part of this conversation
|
||||
if (
|
||||
(message.senderId === recipient.id && message.receiverId === currentUser?.id) ||
|
||||
(message.senderId === currentUser?.id && message.receiverId === recipient.id)
|
||||
) {
|
||||
console.log('[ChatWindow] Message is for this conversation, adding to chat');
|
||||
setMessages((prevMessages) => {
|
||||
// Check if message already exists (avoid duplicates)
|
||||
if (prevMessages.some(m => m.id === message.id)) {
|
||||
console.log('[ChatWindow] Message already exists, skipping');
|
||||
return prevMessages;
|
||||
}
|
||||
console.log('[ChatWindow] Adding new message to chat');
|
||||
return [...prevMessages, message];
|
||||
});
|
||||
|
||||
// Mark incoming messages from recipient as read
|
||||
if (message.senderId === recipient.id && message.receiverId === currentUser?.id && !message.isRead) {
|
||||
console.log('[ChatWindow] Marking new incoming message as read');
|
||||
try {
|
||||
await messageAPI.markAsRead(message.id);
|
||||
|
||||
// Notify parent component that message was marked read
|
||||
if (onMessagesRead) {
|
||||
onMessagesRead(recipient.id, 1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to mark message ${message.id} as read:`, error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('[ChatWindow] Message not for this conversation, ignoring');
|
||||
}
|
||||
}, [recipient.id, currentUser?.id, onMessagesRead]);
|
||||
|
||||
// Listen for new messages in real-time
|
||||
useEffect(() => {
|
||||
console.log('[ChatWindow] Message listener useEffect running', { isConnected, show, recipientId: recipient.id });
|
||||
|
||||
if (!isConnected || !show) {
|
||||
console.log('[ChatWindow] Skipping listener setup:', { isConnected, show });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[ChatWindow] Setting up message listener for recipient:', recipient.id);
|
||||
|
||||
const cleanup = onNewMessage(handleNewMessage);
|
||||
|
||||
return () => {
|
||||
console.log('[ChatWindow] Cleaning up message listener for recipient:', recipient.id);
|
||||
cleanup();
|
||||
};
|
||||
}, [isConnected, show, onNewMessage, handleNewMessage]);
|
||||
|
||||
// Listen for typing indicators
|
||||
useEffect(() => {
|
||||
if (!isConnected || !show) return;
|
||||
|
||||
const cleanup = onUserTyping((data) => {
|
||||
// Only show typing indicator for the current recipient
|
||||
if (data.userId === recipient.id) {
|
||||
setIsRecipientTyping(data.isTyping);
|
||||
|
||||
// Auto-hide typing indicator after 3 seconds of no activity
|
||||
if (data.isTyping) {
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
setIsRecipientTyping(false);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cleanup();
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [isConnected, show, recipient.id, onUserTyping]);
|
||||
|
||||
// Initial scroll to unread messages (runs synchronously after DOM updates, refs are ready)
|
||||
useLayoutEffect(() => {
|
||||
if (!loading && !hasScrolledToUnread && messages.length > 0) {
|
||||
if (initialUnreadMessageIds.size > 0) {
|
||||
// Find the oldest unread message
|
||||
const oldestUnread = messages.find(m => initialUnreadMessageIds.has(m.id));
|
||||
|
||||
if (oldestUnread && messageRefs.current.has(oldestUnread.id)) {
|
||||
console.log(`[ChatWindow] Scrolling to oldest unread message: ${oldestUnread.id}`);
|
||||
messageRefs.current.get(oldestUnread.id)?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
} else {
|
||||
console.log('[ChatWindow] Unread message ref not found, scrolling to bottom');
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
}
|
||||
} else {
|
||||
// No unread messages, scroll to bottom
|
||||
console.log('[ChatWindow] No unread messages, scrolling to bottom');
|
||||
scrollToBottom();
|
||||
}
|
||||
setHasScrolledToUnread(true);
|
||||
}
|
||||
}, [loading, hasScrolledToUnread, messages, initialUnreadMessageIds]);
|
||||
|
||||
// Auto-scroll for new messages (only if user is at bottom)
|
||||
useEffect(() => {
|
||||
if (isAtBottom && hasScrolledToUnread) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [messages, isRecipientTyping, isAtBottom, hasScrolledToUnread]);
|
||||
|
||||
const fetchMessages = async () => {
|
||||
try {
|
||||
@@ -44,6 +185,29 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
|
||||
);
|
||||
|
||||
setMessages(allMessages);
|
||||
|
||||
// Mark all unread messages from recipient as read
|
||||
const unreadMessages = receivedFromRecipient.filter((msg: Message) => !msg.isRead);
|
||||
if (unreadMessages.length > 0) {
|
||||
console.log(`[ChatWindow] Marking ${unreadMessages.length} messages as read`);
|
||||
|
||||
// Save unread message IDs for scrolling purposes
|
||||
setInitialUnreadMessageIds(new Set(unreadMessages.map((m: Message) => m.id)));
|
||||
|
||||
// Mark each message as read
|
||||
const markReadPromises = unreadMessages.map((message: Message) =>
|
||||
messageAPI.markAsRead(message.id).catch((err: any) => {
|
||||
console.error(`Failed to mark message ${message.id} as read:`, err);
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(markReadPromises);
|
||||
|
||||
// Notify parent component that messages were marked read
|
||||
if (onMessagesRead) {
|
||||
onMessagesRead(recipient.id, unreadMessages.length);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch messages:', error);
|
||||
} finally {
|
||||
@@ -55,10 +219,54 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const setMessageRef = useCallback((id: string) => (el: HTMLDivElement | null) => {
|
||||
if (el) {
|
||||
messageRefs.current.set(id, el);
|
||||
} else {
|
||||
messageRefs.current.delete(id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!messagesContainerRef.current) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = messagesContainerRef.current;
|
||||
const isBottom = scrollHeight - scrollTop - clientHeight < 50; // 50px threshold
|
||||
setIsAtBottom(isBottom);
|
||||
};
|
||||
|
||||
// Handle typing indicators with debouncing
|
||||
const handleTyping = useCallback(() => {
|
||||
if (!isConnected) return;
|
||||
|
||||
// Emit typing start
|
||||
emitTypingStart(recipient.id);
|
||||
|
||||
// Clear existing timeout
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Set timeout to emit typing stop after 2 seconds of inactivity
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
emitTypingStop(recipient.id);
|
||||
}, 2000);
|
||||
}, [isConnected, recipient.id, emitTypingStart, emitTypingStop]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNewMessage(e.target.value);
|
||||
handleTyping();
|
||||
};
|
||||
|
||||
const handleSend = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newMessage.trim()) return;
|
||||
|
||||
// Stop typing indicator
|
||||
if (isConnected) {
|
||||
emitTypingStop(recipient.id);
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
const messageContent = newMessage;
|
||||
setNewMessage(''); // Clear input immediately for better UX
|
||||
@@ -70,13 +278,26 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
|
||||
content: messageContent
|
||||
});
|
||||
|
||||
// Add the new message to the list
|
||||
setMessages([...messages, response.data]);
|
||||
// Add message to sender's chat immediately for instant feedback
|
||||
// Socket will handle updating the receiver's chat
|
||||
setMessages((prevMessages) => {
|
||||
// Avoid duplicates
|
||||
if (prevMessages.some(m => m.id === response.data.id)) {
|
||||
return prevMessages;
|
||||
}
|
||||
return [...prevMessages, response.data];
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
setNewMessage(messageContent); // Restore message on error
|
||||
} finally {
|
||||
setSending(false);
|
||||
// Defer focus until after all DOM updates and scroll operations complete
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -145,7 +366,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
|
||||
)}
|
||||
<div>
|
||||
<h6 className="mb-0">{recipient.firstName} {recipient.lastName}</h6>
|
||||
<small className="opacity-75">@{recipient.username}</small>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -157,6 +377,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
|
||||
|
||||
{/* Messages Area */}
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="p-3 overflow-auto flex-grow-1"
|
||||
style={{
|
||||
backgroundColor: '#f8f9fa',
|
||||
@@ -182,7 +404,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
|
||||
formatDate(message.createdAt) !== formatDate(messages[index - 1].createdAt);
|
||||
|
||||
return (
|
||||
<div key={message.id}>
|
||||
<div key={message.id} ref={setMessageRef(message.id)}>
|
||||
{showDate && (
|
||||
<div className="text-center my-3">
|
||||
<small className="text-muted bg-white px-2 py-1 rounded">
|
||||
@@ -216,6 +438,13 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Typing indicator */}
|
||||
{isRecipientTyping && (
|
||||
<TypingIndicator
|
||||
firstName={recipient.firstName}
|
||||
isVisible={isRecipientTyping}
|
||||
/>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
@@ -225,11 +454,12 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
|
||||
<form onSubmit={handleSend} className="border-top p-3 flex-shrink-0">
|
||||
<div className="input-group">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Type a message..."
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
onChange={handleInputChange}
|
||||
disabled={sending}
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { rentalAPI } from "../services/api";
|
||||
import { useSocket } from "../contexts/SocketContext";
|
||||
import { rentalAPI, messageAPI } from "../services/api";
|
||||
|
||||
const Navbar: React.FC = () => {
|
||||
const { user, logout, openAuthModal } = useAuth();
|
||||
const { onNewMessage, onMessageRead } = useSocket();
|
||||
const navigate = useNavigate();
|
||||
const [searchFilters, setSearchFilters] = useState({
|
||||
search: "",
|
||||
location: "",
|
||||
});
|
||||
const [pendingRequestsCount, setPendingRequestsCount] = useState(0);
|
||||
const [unreadMessagesCount, setUnreadMessagesCount] = useState(0);
|
||||
|
||||
// Fetch pending rental requests count when user logs in
|
||||
useEffect(() => {
|
||||
@@ -41,6 +44,46 @@ const Navbar: React.FC = () => {
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
// Fetch unread messages count when user logs in
|
||||
useEffect(() => {
|
||||
const fetchUnreadCount = async () => {
|
||||
if (user) {
|
||||
try {
|
||||
const response = await messageAPI.getUnreadCount();
|
||||
setUnreadMessagesCount(response.data.count);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch unread message count:", error);
|
||||
}
|
||||
} else {
|
||||
setUnreadMessagesCount(0);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUnreadCount();
|
||||
}, [user]);
|
||||
|
||||
// Listen for real-time message updates via socket
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
// Listen for new messages
|
||||
const cleanupNewMessage = onNewMessage((message: any) => {
|
||||
if (message.receiverId === user.id) {
|
||||
setUnreadMessagesCount((prev) => prev + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for messages being read
|
||||
const cleanupMessageRead = onMessageRead(() => {
|
||||
setUnreadMessagesCount((prev) => Math.max(0, prev - 1));
|
||||
});
|
||||
|
||||
return () => {
|
||||
cleanupNewMessage();
|
||||
cleanupMessageRead();
|
||||
};
|
||||
}, [user, onNewMessage, onMessageRead]);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate("/");
|
||||
@@ -155,7 +198,7 @@ const Navbar: React.FC = () => {
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span style={{ display: "flex", alignItems: "center", position: "relative" }}>
|
||||
{pendingRequestsCount > 0 && (
|
||||
{(pendingRequestsCount > 0 || unreadMessagesCount > 0) && (
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
@@ -177,7 +220,7 @@ const Navbar: React.FC = () => {
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{pendingRequestsCount}
|
||||
{pendingRequestsCount + unreadMessagesCount}
|
||||
</span>
|
||||
)}
|
||||
<i className="bi bi-person-circle me-1"></i>
|
||||
@@ -224,6 +267,11 @@ const Navbar: React.FC = () => {
|
||||
<li>
|
||||
<Link className="dropdown-item" to="/messages">
|
||||
<i className="bi bi-envelope me-2"></i>Messages
|
||||
{unreadMessagesCount > 0 && (
|
||||
<span className="badge bg-danger rounded-pill ms-2">
|
||||
{unreadMessagesCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
|
||||
48
frontend/src/components/TypingIndicator.css
Normal file
48
frontend/src/components/TypingIndicator.css
Normal file
@@ -0,0 +1,48 @@
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.typing-text {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.typing-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.typing-dots .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: #6c757d;
|
||||
border-radius: 50%;
|
||||
animation: typing-bounce 1.4s infinite ease-in-out;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
.typing-dots .dot:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.typing-dots .dot:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
@keyframes typing-bounce {
|
||||
0%, 80%, 100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.7;
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-6px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
30
frontend/src/components/TypingIndicator.tsx
Normal file
30
frontend/src/components/TypingIndicator.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import './TypingIndicator.css';
|
||||
|
||||
interface TypingIndicatorProps {
|
||||
firstName: string;
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typing Indicator Component
|
||||
* Shows an animated "User is typing..." message
|
||||
*/
|
||||
const TypingIndicator: React.FC<TypingIndicatorProps> = ({ firstName, isVisible }) => {
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="typing-indicator">
|
||||
<span className="typing-text">{firstName} is typing</span>
|
||||
<div className="typing-dots">
|
||||
<span className="dot"></span>
|
||||
<span className="dot"></span>
|
||||
<span className="dot"></span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TypingIndicator;
|
||||
176
frontend/src/contexts/SocketContext.tsx
Normal file
176
frontend/src/contexts/SocketContext.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback, ReactNode } from 'react';
|
||||
import { Socket } from 'socket.io-client';
|
||||
import socketService from '../services/socket';
|
||||
|
||||
/**
|
||||
* Socket Context Type
|
||||
*/
|
||||
interface SocketContextType {
|
||||
socket: Socket | null;
|
||||
isConnected: boolean;
|
||||
joinConversation: (otherUserId: string) => void;
|
||||
leaveConversation: (otherUserId: string) => void;
|
||||
emitTypingStart: (receiverId: string) => void;
|
||||
emitTypingStop: (receiverId: string) => void;
|
||||
emitMarkMessageRead: (messageId: string, senderId: string) => void;
|
||||
onNewMessage: (callback: (message: any) => void) => () => void;
|
||||
onMessageRead: (callback: (data: { messageId: string; readAt: string; readBy: string }) => void) => () => void;
|
||||
onUserTyping: (callback: (data: { userId: string; firstName: string; isTyping: boolean }) => void) => () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Socket Context
|
||||
*/
|
||||
const SocketContext = createContext<SocketContextType | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Socket Provider Props
|
||||
*/
|
||||
interface SocketProviderProps {
|
||||
children: ReactNode;
|
||||
isAuthenticated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Socket Provider Component
|
||||
* Manages socket connection lifecycle and provides socket functionality to children
|
||||
*/
|
||||
export const SocketProvider: React.FC<SocketProviderProps> = ({
|
||||
children,
|
||||
isAuthenticated = false
|
||||
}) => {
|
||||
const [socket, setSocket] = useState<Socket | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
/**
|
||||
* Initialize socket connection when user is authenticated
|
||||
*/
|
||||
useEffect(() => {
|
||||
console.log('[SocketProvider] useEffect running', { isAuthenticated });
|
||||
|
||||
if (!isAuthenticated) {
|
||||
console.log('[SocketProvider] Not authenticated, skipping socket setup');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[SocketProvider] Initializing socket connection');
|
||||
const newSocket = socketService.connect();
|
||||
setSocket(newSocket);
|
||||
|
||||
// Listen for connection status changes
|
||||
console.log('[SocketProvider] Setting up connection listener');
|
||||
const removeListener = socketService.addConnectionListener((connected) => {
|
||||
console.log('[SocketProvider] Connection status changed:', connected);
|
||||
setIsConnected(connected);
|
||||
});
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
console.log('[SocketProvider] Cleaning up connection listener');
|
||||
removeListener();
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
|
||||
/**
|
||||
* Disconnect socket when user logs out
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated && socket) {
|
||||
console.log('[SocketProvider] User logged out, disconnecting socket');
|
||||
socketService.disconnect();
|
||||
setSocket(null);
|
||||
setIsConnected(false);
|
||||
}
|
||||
}, [isAuthenticated, socket]);
|
||||
|
||||
/**
|
||||
* Join a conversation room
|
||||
*/
|
||||
const joinConversation = useCallback((otherUserId: string) => {
|
||||
socketService.joinConversation(otherUserId);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Leave a conversation room
|
||||
*/
|
||||
const leaveConversation = useCallback((otherUserId: string) => {
|
||||
socketService.leaveConversation(otherUserId);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Emit typing start event
|
||||
*/
|
||||
const emitTypingStart = useCallback((receiverId: string) => {
|
||||
socketService.emitTypingStart(receiverId);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Emit typing stop event
|
||||
*/
|
||||
const emitTypingStop = useCallback((receiverId: string) => {
|
||||
socketService.emitTypingStop(receiverId);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Emit mark message as read event
|
||||
*/
|
||||
const emitMarkMessageRead = useCallback((messageId: string, senderId: string) => {
|
||||
socketService.emitMarkMessageRead(messageId, senderId);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Listen for new messages
|
||||
*/
|
||||
const onNewMessage = useCallback((callback: (message: any) => void) => {
|
||||
return socketService.onNewMessage(callback);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Listen for message read events
|
||||
*/
|
||||
const onMessageRead = useCallback((callback: (data: { messageId: string; readAt: string; readBy: string }) => void) => {
|
||||
return socketService.onMessageRead(callback);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Listen for typing indicators
|
||||
*/
|
||||
const onUserTyping = useCallback((callback: (data: { userId: string; firstName: string; isTyping: boolean }) => void) => {
|
||||
return socketService.onUserTyping(callback);
|
||||
}, []);
|
||||
|
||||
const value: SocketContextType = {
|
||||
socket,
|
||||
isConnected,
|
||||
joinConversation,
|
||||
leaveConversation,
|
||||
emitTypingStart,
|
||||
emitTypingStop,
|
||||
emitMarkMessageRead,
|
||||
onNewMessage,
|
||||
onMessageRead,
|
||||
onUserTyping,
|
||||
};
|
||||
|
||||
return (
|
||||
<SocketContext.Provider value={value}>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook to use Socket Context
|
||||
* @throws Error if used outside of SocketProvider
|
||||
*/
|
||||
export const useSocket = (): SocketContextType => {
|
||||
const context = useContext(SocketContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error('useSocket must be used within a SocketProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export default SocketContext;
|
||||
@@ -3,11 +3,13 @@ import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Message } from '../types';
|
||||
import { messageAPI } from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useSocket } from '../contexts/SocketContext';
|
||||
|
||||
const MessageDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { isConnected, onNewMessage } = useSocket();
|
||||
const [message, setMessage] = useState<Message | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -18,6 +20,34 @@ const MessageDetail: React.FC = () => {
|
||||
fetchMessage();
|
||||
}, [id]);
|
||||
|
||||
// Listen for new replies in real-time
|
||||
useEffect(() => {
|
||||
if (!isConnected || !message) return;
|
||||
|
||||
const cleanup = onNewMessage((newMessage: Message) => {
|
||||
// Check if this is a reply to the current thread
|
||||
if (newMessage.parentMessageId === message.id) {
|
||||
setMessage((prevMessage) => {
|
||||
if (!prevMessage) return prevMessage;
|
||||
|
||||
// Check if reply already exists (avoid duplicates)
|
||||
const replies = prevMessage.replies || [];
|
||||
if (replies.some(r => r.id === newMessage.id)) {
|
||||
return prevMessage;
|
||||
}
|
||||
|
||||
// Add new reply to the thread
|
||||
return {
|
||||
...prevMessage,
|
||||
replies: [...replies, newMessage]
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [isConnected, message?.id, onNewMessage]);
|
||||
|
||||
const fetchMessage = async () => {
|
||||
try {
|
||||
const response = await messageAPI.getMessage(id!);
|
||||
@@ -38,7 +68,7 @@ const MessageDetail: React.FC = () => {
|
||||
|
||||
try {
|
||||
const recipientId = message.senderId === user?.id ? message.receiverId : message.senderId;
|
||||
await messageAPI.sendMessage({
|
||||
const response = await messageAPI.sendMessage({
|
||||
receiverId: recipientId,
|
||||
subject: `Re: ${message.subject}`,
|
||||
content: replyContent,
|
||||
@@ -46,7 +76,20 @@ const MessageDetail: React.FC = () => {
|
||||
});
|
||||
|
||||
setReplyContent('');
|
||||
fetchMessage(); // Refresh to show the new reply
|
||||
|
||||
// Note: Socket will automatically add the reply to the thread
|
||||
// But we add it manually for immediate feedback if socket is disconnected
|
||||
if (!isConnected) {
|
||||
setMessage((prevMessage) => {
|
||||
if (!prevMessage) return prevMessage;
|
||||
const replies = prevMessage.replies || [];
|
||||
return {
|
||||
...prevMessage,
|
||||
replies: [...replies, response.data]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
alert('Reply sent successfully!');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to send reply');
|
||||
|
||||
@@ -1,29 +1,136 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Message, User } from '../types';
|
||||
import { Conversation, Message, User } from '../types';
|
||||
import { messageAPI } from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useSocket } from '../contexts/SocketContext';
|
||||
import ChatWindow from '../components/ChatWindow';
|
||||
|
||||
const Messages: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const { isConnected, onNewMessage, onMessageRead } = useSocket();
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedRecipient, setSelectedRecipient] = useState<User | null>(null);
|
||||
const [showChat, setShowChat] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMessages();
|
||||
fetchConversations();
|
||||
}, []);
|
||||
|
||||
const fetchMessages = async () => {
|
||||
// Listen for new messages and update conversations in real-time
|
||||
useEffect(() => {
|
||||
if (!isConnected) return;
|
||||
|
||||
const cleanup = onNewMessage((newMessage: Message) => {
|
||||
console.log('[Messages] Received new message:', newMessage);
|
||||
|
||||
setConversations((prevConversations) => {
|
||||
// Determine conversation partner
|
||||
const partnerId = newMessage.senderId === user?.id
|
||||
? newMessage.receiverId
|
||||
: newMessage.senderId;
|
||||
|
||||
// Find existing conversation
|
||||
const existingIndex = prevConversations.findIndex(
|
||||
c => c.partnerId === partnerId
|
||||
);
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
// Update existing conversation
|
||||
const updated = [...prevConversations];
|
||||
const conv = { ...updated[existingIndex] };
|
||||
|
||||
conv.lastMessage = {
|
||||
id: newMessage.id,
|
||||
content: newMessage.content,
|
||||
senderId: newMessage.senderId,
|
||||
createdAt: newMessage.createdAt,
|
||||
isRead: newMessage.isRead
|
||||
};
|
||||
conv.lastMessageAt = newMessage.createdAt;
|
||||
|
||||
// Increment unread count if user received the message
|
||||
if (newMessage.receiverId === user?.id && !newMessage.isRead) {
|
||||
conv.unreadCount++;
|
||||
}
|
||||
|
||||
updated[existingIndex] = conv;
|
||||
|
||||
// Re-sort by most recent
|
||||
updated.sort((a, b) =>
|
||||
new Date(b.lastMessageAt).getTime() - new Date(a.lastMessageAt).getTime()
|
||||
);
|
||||
|
||||
console.log('[Messages] Updated existing conversation');
|
||||
return updated;
|
||||
} else {
|
||||
// New conversation - add to top
|
||||
const partner = newMessage.senderId === user?.id
|
||||
? newMessage.receiver!
|
||||
: newMessage.sender!;
|
||||
|
||||
if (!partner) {
|
||||
console.warn('[Messages] Partner data missing from new message');
|
||||
return prevConversations;
|
||||
}
|
||||
|
||||
const newConv: Conversation = {
|
||||
partnerId,
|
||||
partner,
|
||||
lastMessage: {
|
||||
id: newMessage.id,
|
||||
content: newMessage.content,
|
||||
senderId: newMessage.senderId,
|
||||
createdAt: newMessage.createdAt,
|
||||
isRead: newMessage.isRead
|
||||
},
|
||||
unreadCount: newMessage.receiverId === user?.id && !newMessage.isRead ? 1 : 0,
|
||||
lastMessageAt: newMessage.createdAt
|
||||
};
|
||||
|
||||
console.log('[Messages] Created new conversation');
|
||||
return [newConv, ...prevConversations];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [isConnected, user?.id, onNewMessage]);
|
||||
|
||||
// Listen for read receipts and update unread counts
|
||||
useEffect(() => {
|
||||
if (!isConnected) return;
|
||||
|
||||
const cleanup = onMessageRead((data: any) => {
|
||||
console.log('[Messages] Message read:', data);
|
||||
|
||||
setConversations((prevConversations) => {
|
||||
return prevConversations.map(conv => {
|
||||
// If this is the conversation and the last message was marked as read
|
||||
if (conv.lastMessage.id === data.messageId && !conv.lastMessage.isRead) {
|
||||
return {
|
||||
...conv,
|
||||
lastMessage: { ...conv.lastMessage, isRead: true },
|
||||
unreadCount: Math.max(0, conv.unreadCount - 1)
|
||||
};
|
||||
}
|
||||
return conv;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [isConnected, onMessageRead]);
|
||||
|
||||
const fetchConversations = async () => {
|
||||
try {
|
||||
const response = await messageAPI.getMessages();
|
||||
setMessages(response.data);
|
||||
const response = await messageAPI.getConversations();
|
||||
setConversations(response.data);
|
||||
console.log('[Messages] Fetched conversations:', response.data.length);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to fetch messages');
|
||||
console.error('[Messages] Failed to fetch conversations:', err);
|
||||
setError(err.response?.data?.error || 'Failed to fetch conversations');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -43,25 +150,29 @@ const Messages: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMessageClick = async (message: Message) => {
|
||||
// Mark as read if unread
|
||||
if (!message.isRead) {
|
||||
try {
|
||||
await messageAPI.markAsRead(message.id);
|
||||
// Update local state
|
||||
setMessages(messages.map(m =>
|
||||
m.id === message.id ? { ...m, isRead: true } : m
|
||||
));
|
||||
} catch (err) {
|
||||
console.error('Failed to mark message as read:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Open chat with sender
|
||||
if (message.sender) {
|
||||
setSelectedRecipient(message.sender);
|
||||
const handleConversationClick = (conversation: Conversation) => {
|
||||
setSelectedRecipient(conversation.partner);
|
||||
setShowChat(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMessagesRead = (partnerId: string, count: number) => {
|
||||
console.log(`[Messages] ${count} messages marked as read for partner ${partnerId}`);
|
||||
|
||||
// Update the conversation's unread count
|
||||
setConversations(prevConversations =>
|
||||
prevConversations.map(conv =>
|
||||
conv.partnerId === partnerId
|
||||
? { ...conv, unreadCount: 0 }
|
||||
: conv
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleChatClose = () => {
|
||||
setShowChat(false);
|
||||
setSelectedRecipient(null);
|
||||
// Refresh conversations to get updated unread counts
|
||||
fetchConversations();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -76,7 +187,6 @@ const Messages: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="row justify-content-center">
|
||||
@@ -89,61 +199,80 @@ const Messages: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.length === 0 ? (
|
||||
{conversations.length === 0 ? (
|
||||
<div className="text-center py-5">
|
||||
<i className="bi bi-envelope" style={{ fontSize: '3rem', color: '#ccc' }}></i>
|
||||
<p className="text-muted mt-2">No messages in your inbox</p>
|
||||
<p className="text-muted mt-2">No conversations yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="list-group">
|
||||
{messages.map((message) => (
|
||||
{conversations.map((conversation) => {
|
||||
const isUnread = conversation.unreadCount > 0;
|
||||
const isLastMessageFromPartner = conversation.lastMessage.senderId === conversation.partnerId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`list-group-item list-group-item-action ${!message.isRead ? 'border-start border-primary border-4' : ''}`}
|
||||
onClick={() => handleMessageClick(message)}
|
||||
key={conversation.partnerId}
|
||||
className={`list-group-item list-group-item-action ${isUnread ? 'border-start border-primary border-4' : ''}`}
|
||||
onClick={() => handleConversationClick(conversation)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
backgroundColor: !message.isRead ? '#f0f7ff' : 'white'
|
||||
backgroundColor: isUnread ? '#f0f7ff' : 'white'
|
||||
}}
|
||||
>
|
||||
<div className="d-flex w-100 justify-content-between">
|
||||
<div className="d-flex align-items-center">
|
||||
{message.sender?.profileImage ? (
|
||||
<div className="d-flex w-100 justify-content-between align-items-start">
|
||||
<div className="d-flex align-items-center flex-grow-1">
|
||||
{/* Profile Picture */}
|
||||
{conversation.partner.profileImage ? (
|
||||
<img
|
||||
src={message.sender.profileImage}
|
||||
alt={`${message.sender.firstName} ${message.sender.lastName}`}
|
||||
src={conversation.partner.profileImage}
|
||||
alt={`${conversation.partner.firstName} ${conversation.partner.lastName}`}
|
||||
className="rounded-circle me-3"
|
||||
style={{ width: '40px', height: '40px', objectFit: 'cover' }}
|
||||
style={{ width: '50px', height: '50px', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center me-3"
|
||||
style={{ width: '40px', height: '40px' }}
|
||||
style={{ width: '50px', height: '50px' }}
|
||||
>
|
||||
<i className="bi bi-person-fill text-white"></i>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="d-flex align-items-center">
|
||||
<h6 className={`mb-1 ${!message.isRead ? 'fw-bold' : ''}`}>
|
||||
{message.sender?.firstName} {message.sender?.lastName}
|
||||
|
||||
<div className="flex-grow-1" style={{ minWidth: 0 }}>
|
||||
{/* User Name and Unread Badge */}
|
||||
<div className="d-flex align-items-center mb-1">
|
||||
<h6 className={`mb-0 ${isUnread ? 'fw-bold' : ''}`}>
|
||||
{conversation.partner.firstName} {conversation.partner.lastName}
|
||||
</h6>
|
||||
{!message.isRead && (
|
||||
<span className="badge bg-primary ms-2">New</span>
|
||||
{isUnread && (
|
||||
<span className="badge bg-primary ms-2">{conversation.unreadCount}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={`mb-1 text-truncate ${!message.isRead ? 'fw-semibold' : ''}`} style={{ maxWidth: '400px' }}>
|
||||
{message.subject}
|
||||
|
||||
{/* Last Message Preview */}
|
||||
<p
|
||||
className={`mb-0 text-truncate ${isUnread && isLastMessageFromPartner ? 'fw-semibold' : 'text-muted'}`}
|
||||
style={{ maxWidth: '100%' }}
|
||||
>
|
||||
{conversation.lastMessage.senderId === user?.id && (
|
||||
<span className="me-1">You: </span>
|
||||
)}
|
||||
{conversation.lastMessage.content}
|
||||
</p>
|
||||
<small className="text-muted text-truncate d-block" style={{ maxWidth: '400px' }}>
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="text-end ms-3" style={{ minWidth: 'fit-content' }}>
|
||||
<small className="text-muted d-block">
|
||||
{formatDate(conversation.lastMessageAt)}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<small className="text-muted">{formatDate(message.createdAt)}</small>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -152,11 +281,9 @@ const Messages: React.FC = () => {
|
||||
{selectedRecipient && (
|
||||
<ChatWindow
|
||||
show={showChat}
|
||||
onClose={() => {
|
||||
setShowChat(false);
|
||||
setSelectedRecipient(null);
|
||||
}}
|
||||
onClose={handleChatClose}
|
||||
recipient={selectedRecipient}
|
||||
onMessagesRead={handleMessagesRead}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { User, Item } from '../types';
|
||||
import { userAPI, itemAPI } from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ChatWindow from '../components/ChatWindow';
|
||||
|
||||
const PublicProfile: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -12,6 +13,7 @@ const PublicProfile: React.FC = () => {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showChat, setShowChat] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserProfile();
|
||||
@@ -85,11 +87,10 @@ const PublicProfile: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
<h3>{user.firstName} {user.lastName}</h3>
|
||||
<p className="text-muted">@{user.username}</p>
|
||||
{currentUser && currentUser.id !== user.id && (
|
||||
<button
|
||||
className="btn btn-primary mt-3"
|
||||
onClick={() => navigate('/messages')}
|
||||
onClick={() => setShowChat(true)}
|
||||
>
|
||||
<i className="bi bi-chat-dots-fill me-2"></i>Message
|
||||
</button>
|
||||
@@ -148,6 +149,15 @@ const PublicProfile: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ChatWindow popup */}
|
||||
{user && (
|
||||
<ChatWindow
|
||||
show={showChat}
|
||||
onClose={() => setShowChat(false)}
|
||||
recipient={user}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -239,6 +239,7 @@ export const rentalAPI = {
|
||||
export const messageAPI = {
|
||||
getMessages: () => api.get("/messages"),
|
||||
getSentMessages: () => api.get("/messages/sent"),
|
||||
getConversations: () => api.get("/messages/conversations"),
|
||||
getMessage: (id: string) => api.get(`/messages/${id}`),
|
||||
sendMessage: (data: any) => api.post("/messages", data),
|
||||
markAsRead: (id: string) => api.put(`/messages/${id}/read`),
|
||||
|
||||
314
frontend/src/services/socket.ts
Normal file
314
frontend/src/services/socket.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { io, Socket } from "socket.io-client";
|
||||
|
||||
/**
|
||||
* Socket event types for type safety
|
||||
*/
|
||||
export interface SocketEvents {
|
||||
// Incoming events (from server)
|
||||
new_message: (message: any) => void;
|
||||
message_read: (data: {
|
||||
messageId: string;
|
||||
readAt: string;
|
||||
readBy: string;
|
||||
}) => void;
|
||||
user_typing: (data: {
|
||||
userId: string;
|
||||
firstName: string;
|
||||
isTyping: boolean;
|
||||
}) => void;
|
||||
conversation_joined: (data: {
|
||||
conversationRoom: string;
|
||||
otherUserId: string;
|
||||
}) => void;
|
||||
|
||||
// Connection events
|
||||
connect: () => void;
|
||||
disconnect: (reason: string) => void;
|
||||
connect_error: (error: Error) => void;
|
||||
error: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Socket service for managing WebSocket connection
|
||||
* Implements singleton pattern to ensure only one socket instance
|
||||
*/
|
||||
class SocketService {
|
||||
private socket: Socket | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 5;
|
||||
private connectionListeners: Array<(connected: boolean) => void> = [];
|
||||
|
||||
/**
|
||||
* Get the socket instance URL based on environment
|
||||
*/
|
||||
private getSocketUrl(): string {
|
||||
// Use environment variable or default to localhost:5001 (matches backend)
|
||||
return process.env.REACT_APP_BASE_URL || "http://localhost:5001";
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize and connect to the socket server
|
||||
* Authentication happens via cookies (sent automatically)
|
||||
*/
|
||||
connect(): Socket {
|
||||
if (this.socket?.connected) {
|
||||
console.log("[Socket] Already connected");
|
||||
return this.socket;
|
||||
}
|
||||
|
||||
console.log("[Socket] Connecting to server...");
|
||||
|
||||
this.socket = io(this.getSocketUrl(), {
|
||||
withCredentials: true, // Send cookies for authentication
|
||||
transports: ["websocket", "polling"], // Try WebSocket first, fallback to polling
|
||||
path: "/socket.io", // Explicit Socket.io path
|
||||
reconnection: true,
|
||||
reconnectionAttempts: this.maxReconnectAttempts,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
// Connection event handlers
|
||||
this.socket.on("connect", () => {
|
||||
console.log("[Socket] Connected successfully", {
|
||||
socketId: this.socket?.id,
|
||||
});
|
||||
this.reconnectAttempts = 0;
|
||||
this.notifyConnectionListeners(true);
|
||||
});
|
||||
|
||||
this.socket.on("disconnect", (reason) => {
|
||||
console.log("[Socket] Disconnected", { reason });
|
||||
this.notifyConnectionListeners(false);
|
||||
});
|
||||
|
||||
this.socket.on("connect_error", (error) => {
|
||||
console.error("[Socket] Connection error", {
|
||||
error: error.message,
|
||||
attempt: this.reconnectAttempts + 1,
|
||||
});
|
||||
this.reconnectAttempts++;
|
||||
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error("[Socket] Max reconnection attempts reached");
|
||||
this.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("error", (error) => {
|
||||
console.error("[Socket] Socket error", error);
|
||||
});
|
||||
|
||||
return this.socket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the socket server
|
||||
*/
|
||||
disconnect(): void {
|
||||
if (this.socket) {
|
||||
console.log("[Socket] Disconnecting...");
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
this.notifyConnectionListeners(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current socket instance
|
||||
*/
|
||||
getSocket(): Socket | null {
|
||||
return this.socket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if socket is connected
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.socket?.connected ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a conversation room
|
||||
*/
|
||||
joinConversation(otherUserId: string): void {
|
||||
if (!this.socket?.connected) {
|
||||
console.warn("[Socket] Not connected, cannot join conversation");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[Socket] Joining conversation", { otherUserId });
|
||||
this.socket.emit("join_conversation", { otherUserId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave a conversation room
|
||||
*/
|
||||
leaveConversation(otherUserId: string): void {
|
||||
if (!this.socket?.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[Socket] Leaving conversation", { otherUserId });
|
||||
this.socket.emit("leave_conversation", { otherUserId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit typing start event
|
||||
*/
|
||||
emitTypingStart(receiverId: string): void {
|
||||
if (!this.socket?.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.emit("typing_start", { receiverId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit typing stop event
|
||||
*/
|
||||
emitTypingStop(receiverId: string): void {
|
||||
if (!this.socket?.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.emit("typing_stop", { receiverId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit mark message as read event
|
||||
*/
|
||||
emitMarkMessageRead(messageId: string, senderId: string): void {
|
||||
if (!this.socket?.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.emit("mark_message_read", { messageId, senderId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for new messages
|
||||
*/
|
||||
onNewMessage(callback: (message: any) => void): () => void {
|
||||
if (!this.socket) {
|
||||
console.warn("[Socket] Socket not initialized");
|
||||
return () => {};
|
||||
}
|
||||
|
||||
this.socket.on("new_message", callback);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
this.socket?.off("new_message", callback);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for message read events
|
||||
*/
|
||||
onMessageRead(
|
||||
callback: (data: {
|
||||
messageId: string;
|
||||
readAt: string;
|
||||
readBy: string;
|
||||
}) => void
|
||||
): () => void {
|
||||
if (!this.socket) {
|
||||
console.warn("[Socket] Socket not initialized");
|
||||
return () => {};
|
||||
}
|
||||
|
||||
this.socket.on("message_read", callback);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
this.socket?.off("message_read", callback);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for typing indicators
|
||||
*/
|
||||
onUserTyping(
|
||||
callback: (data: {
|
||||
userId: string;
|
||||
firstName: string;
|
||||
isTyping: boolean;
|
||||
}) => void
|
||||
): () => void {
|
||||
if (!this.socket) {
|
||||
console.warn("[Socket] Socket not initialized");
|
||||
return () => {};
|
||||
}
|
||||
|
||||
this.socket.on("user_typing", callback);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
this.socket?.off("user_typing", callback);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for conversation joined event
|
||||
*/
|
||||
onConversationJoined(
|
||||
callback: (data: { conversationRoom: string; otherUserId: string }) => void
|
||||
): () => void {
|
||||
if (!this.socket) {
|
||||
console.warn("[Socket] Socket not initialized");
|
||||
return () => {};
|
||||
}
|
||||
|
||||
this.socket.on("conversation_joined", callback);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
this.socket?.off("conversation_joined", callback);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add connection status listener
|
||||
*/
|
||||
addConnectionListener(callback: (connected: boolean) => void): () => void {
|
||||
this.connectionListeners.push(callback);
|
||||
|
||||
// Immediately notify of current status
|
||||
callback(this.isConnected());
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
this.connectionListeners = this.connectionListeners.filter(
|
||||
(cb) => cb !== callback
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all connection listeners of status change
|
||||
*/
|
||||
private notifyConnectionListeners(connected: boolean): void {
|
||||
this.connectionListeners.forEach((callback) => {
|
||||
try {
|
||||
callback(connected);
|
||||
} catch (error) {
|
||||
console.error("[Socket] Error in connection listener", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all event listeners
|
||||
*/
|
||||
removeAllListeners(): void {
|
||||
if (this.socket) {
|
||||
this.socket.removeAllListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const socketService = new SocketService();
|
||||
export default socketService;
|
||||
@@ -48,6 +48,20 @@ export interface Message {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
partnerId: string;
|
||||
partner: User;
|
||||
lastMessage: {
|
||||
id: string;
|
||||
content: string;
|
||||
senderId: string;
|
||||
createdAt: string;
|
||||
isRead: boolean;
|
||||
};
|
||||
unreadCount: number;
|
||||
lastMessageAt: string;
|
||||
}
|
||||
|
||||
export interface Item {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user